feat: 蝦皮数据页分页与状态筛选 (#43)

导入真实样本后 /shopee 一次吐 3.4MB / 5195 行。但只加分页会把问题从
「5195 行糊在一起」变成「260 页里藏着 6 个」——那 6 个待补规格的商品
仍然找不到。所以分页和状态筛选一起做。

HTML 3.4MB → 16.7KB。

状态条显示全量而不是本页:「共 5195 个商品 · 第 1/260 页」。
显示「共 20 个商品」会让操作员以为总共就 20 个。筛选后显示筛选结果
总数:「待补规格:6 个商品」。

列表查询和 COUNT 共用同一套筛选条件拼装。分开写两份 WHERE,迟早
有天忘了给 COUNT 也加条件,页码算错而且没人发现(#19 踩过一次)。

page 越界兜到最后一页而不是显示空表格——空表格会让操作员以为数据没了。
总数为 0 时显示「第 1/1 页」,不出现「第 1/0 页」。

「待补规格」用 EXISTS 不用 JOIN+DISTINCT:一个商品有多个失败 SKU 时
JOIN 会出重复行,DISTINCT 又让 LIMIT/OFFSET 的行为难推理。

分页控件是 <a href> 纯 GET,浏览器前进后退和书签都正常。首末页用
<span class="disabled"> 禁用,语义上不再是链接,不只靠颜色区分。

这是全项目第一个分页页面,通用逻辑单独放 service/pagination.go 供
后面四页复用,规则写进 05 §3.2 而不是蝦皮页那一节(#34 踩过这个错)。
05 §3 的每页条数从「建议 50」改为「统一 20」并写明理由。

实现踩到 html/template 的 URL 上下文转义:夹在字面量 & 中间的动态内容
会被整体当成一个参数值转义,?/= 变成 %3F/%3D 让链接失效。改为在 Go 里
把整段 URL 拼好,模板作为单个 pipeline 输出,并加了回归测试。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
chengma
2026-08-08 11:40:07 +08:00
co-authored by Claude Opus 5
parent 033bef0614
commit edfe39cc35
11 changed files with 734 additions and 42 deletions
+82 -9
View File
@@ -5,6 +5,7 @@ package service
import (
"database/sql"
"strings"
"cmautobuy/admin/model"
"cmautobuy/admin/repository"
@@ -42,32 +43,62 @@ type ShopeeProductView struct {
// ShopeeListResult 是列表页要的全部数据。
type ShopeeListResult struct {
Rows []ShopeeProductView
Total int // shopee_products 的总商品数(不受筛选影响),用于判断"是否已导入过"
Rows []ShopeeProductView
// Total 是**当前筛选条件下**的商品总数,不是本页行数。
// `[必须]` 底部状态条必须显示这个,不能显示本页行数(20),
// 否则操作员会以为总共就这么多,见工单 #43。
Total int
// HasAnyProducts 是 shopee_products 的全库总数是否大于 0,不受筛选影响,
// 只用于区分空状态文案:"从没导入过" vs "筛选没结果"。
HasAnyProducts bool
IsFiltered bool
Page int
PageSize int
TotalPages int
}
// ListShopeeProducts 查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行)并把每一行翻成界面文字。
//
// 采集状态来自 pdd_products(联查得到),不是蝦皮自己的字段,
// 见 docs/admin/01-requirements.md §6.1:
//
// shopee_products.pdd_goods_id 为空 -> "未填链接"
// pdd_products.collect_status -> 未采集 / 采集中 / 已采集 / 采集失败
func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
rows, err := repository.ListShopeeProducts(db, keyword)
//
// `[必须]` Total 用和 ListShopeeProducts 同一套筛选条件的 COUNT
// (repository.CountShopeeProductsFiltered),分页页数据此算出,
// page 越界时兜到最后一页,见 service/pagination.go(工单 #43)。
func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*ShopeeListResult, error) {
total, err := repository.CountShopeeProductsFiltered(db, filter)
if err != nil {
return nil, err
}
total, err := repository.CountShopeeProducts(db)
totalPages := TotalPages(total)
page = ClampPage(page, totalPages)
offset := (page - 1) * PageSize
rows, err := repository.ListShopeeProducts(db, filter, PageSize, offset)
if err != nil {
return nil, err
}
hasAny, err := repository.CountShopeeProducts(db)
if err != nil {
return nil, err
}
result := &ShopeeListResult{
Rows: make([]ShopeeProductView, 0, len(rows)),
Total: total,
IsFiltered: keyword != "",
Rows: make([]ShopeeProductView, 0, len(rows)),
Total: total,
HasAnyProducts: hasAny > 0,
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "",
Page: page,
PageSize: PageSize,
TotalPages: totalPages,
}
for _, r := range rows {
v := ShopeeProductView{
@@ -100,6 +131,48 @@ func ListShopeeProducts(db *sql.DB, keyword string) (*ShopeeListResult, error) {
return result, nil
}
// ---------- 状态筛选 ----------
// shopeeStatusTexts 是状态筛选下拉框每个取值对应的中文文字,
// 也用于底部状态条筛选后的前缀("待补规格:6 个商品"),见工单 #43。
var shopeeStatusTexts = map[string]string{
"pending_spec": "待补规格",
"no_link": "未填 PDD 链接",
"has_link": "已填链接",
}
// ShopeeStatusOption 是筛选下拉框的一项。
type ShopeeStatusOption struct {
Value string // 空串表示"全部"
Text string
}
// ShopeeStatusOptions 返回状态筛选下拉框的全部选项:全部 + 三个状态。
func ShopeeStatusOptions() []ShopeeStatusOption {
return []ShopeeStatusOption{
{"", "全部"},
{"pending_spec", shopeeStatusTexts["pending_spec"]},
{"no_link", shopeeStatusTexts["no_link"]},
{"has_link", shopeeStatusTexts["has_link"]},
}
}
// ParseShopeeStatus 校验筛选参数。认不出的一律当"全部",不报错——
// 地址栏参数是用户可以随便改的,不值得为它弹错误页(工单 #43)。
func ParseShopeeStatus(s string) string {
status := strings.TrimSpace(s)
if _, ok := shopeeStatusTexts[status]; ok {
return status
}
return ""
}
// ShopeeStatusLabel 返回状态筛选取值对应的中文文字,取值为空或认不出时
// 返回空串——调用方(handler 层组装状态条文案)据此判断要不要用作前缀。
func ShopeeStatusLabel(status string) string {
return shopeeStatusTexts[status]
}
// ---------- 弹窗 ----------
// ShopeeSpecView 是弹窗规格表的一行。