From edfe39cc3566103597adf4d7be73d81d10d25d42 Mon Sep 17 00:00:00 2001 From: chengma Date: Sat, 8 Aug 2026 11:40:07 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=9D=A6=E7=9A=AE=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E9=A1=B5=E5=88=86=E9=A1=B5=E4=B8=8E=E7=8A=B6=E6=80=81=E7=AD=9B?= =?UTF-8?q?=E9=80=89=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 导入真实样本后 /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 的行为难推理。 分页控件是 纯 GET,浏览器前进后退和书签都正常。首末页用 禁用,语义上不再是链接,不只靠颜色区分。 这是全项目第一个分页页面,通用逻辑单独放 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 --- admin/handler/web/shopee.go | 54 ++++++-- admin/repository/shopee.go | 84 +++++++++++-- admin/service/pagination.go | 106 ++++++++++++++++ admin/service/pagination_test.go | 111 +++++++++++++++++ admin/service/shopee_list.go | 91 ++++++++++++-- admin/service/shopee_list_test.go | 176 +++++++++++++++++++++++++-- admin/static/css/app.css | 30 +++++ admin/templates/partials/footer.html | 32 ++++- admin/templates/shopee/list.html | 9 ++ docs/admin/01-requirements.md | 9 +- docs/admin/05-ui-specification.md | 74 ++++++++++- 11 files changed, 734 insertions(+), 42 deletions(-) create mode 100644 admin/service/pagination.go create mode 100644 admin/service/pagination_test.go diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index b1d89bf..c18e2d4 100644 --- a/admin/handler/web/shopee.go +++ b/admin/handler/web/shopee.go @@ -4,6 +4,7 @@ import ( "fmt" "io" "net/http" + "net/url" "os" "path/filepath" "strings" @@ -11,6 +12,7 @@ import ( "github.com/gin-gonic/gin" "cmautobuy/admin/config" + "cmautobuy/admin/repository" "cmautobuy/admin/service" ) @@ -19,7 +21,7 @@ import ( // **商品级一行**(不是 SKU 级),数据来自 shopee_products 和 shopee_skus // 聚合联查。列定义见 docs/admin/05-ui-specification.md §4.2、工单 #41。 func (h *Handler) ShopeeList(c *gin.Context) { - h.renderShopeeList(c, c.Query("goods_id"), c.Query("msg"), nil) + h.renderShopeeList(c, c.Query("goods_id"), c.Query("status"), c.Query("page"), c.Query("msg"), nil) } // ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。 @@ -132,12 +134,25 @@ func (h *Handler) ShopeeImport(c *gin.Context) { if len(result.Failures) > 0 { msg += fmt.Sprintf(",%d 行解析失败(见下方列表)", len(result.Failures)) } - h.renderShopeeList(c, "", msg, result.Failures) + // 导入完成后回到第 1 页、清空筛选——刚导入的数据从第一页就能看到, + // 沿用筛选反而可能让人以为导入没生效(筛出来的还是老的几条)。 + h.renderShopeeList(c, "", "", "", msg, result.Failures) } // renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。 -func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures []service.ImportFailure) { - result, err := service.ListShopeeProducts(h.db, keyword) +// +// `[必须]` 分页控件用 纯 GET 导航,翻页要保留 keyword/statusRaw, +// 所以这里把它们原样透传回模板去拼下一页的链接,不在这里丢掉(工单 #43)。 +func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw, msg string, failures []service.ImportFailure) { + filter := repository.ShopeeFilter{ + Keyword: keyword, + Status: service.ParseShopeeStatus(statusRaw), + } + // 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数, + // 局部变量会把它遮住导致编译失败。 + pageNum := service.ParsePage(pageRaw) + + result, err := service.ListShopeeProducts(h.db, filter, pageNum) if err != nil { fail(c, http.StatusInternalServerError, "读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") @@ -146,22 +161,43 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures status := msg if status == "" { - status = fmt.Sprintf("共 %d 个商品", result.Total) - if result.IsFiltered { - status = fmt.Sprintf("筛选出 %d 条 / %s", len(result.Rows), status) - } + status = shopeeStatusLine(filter.Status, result) + } + + values := url.Values{} + if keyword != "" { + values.Set("goods_id", keyword) + } + if filter.Status != "" { + values.Set("status", filter.Status) } c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{ "Keyword": keyword, + "StatusFilter": filter.Status, + "StatusOptions": service.ShopeeStatusOptions(), "Rows": result.Rows, "Status": status, - "HasAnyProducts": result.Total > 0, + "HasAnyProducts": result.HasAnyProducts, "IsFiltered": result.IsFiltered, "Failures": failures, + "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), })) } +// shopeeStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。 +// +// `[必须]` 显示的是筛选后的**全量**总数(result.Total),不是本页行数, +// 筛选时前缀用状态文字("待补规格:6 个商品"),不筛选时用"共", +// 见工单 #43。 +func shopeeStatusLine(statusFilter string, result *service.ShopeeListResult) string { + prefix := fmt.Sprintf("共 %d 个商品", result.Total) + if text := service.ShopeeStatusLabel(statusFilter); text != "" { + prefix = fmt.Sprintf("%s:%d 个商品", text, result.Total) + } + return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages) +} + // ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。 // // 这是**从蝦皮商品出发**录入 PDD 链接的入口, diff --git a/admin/repository/shopee.go b/admin/repository/shopee.go index 221bfc2..50061c8 100644 --- a/admin/repository/shopee.go +++ b/admin/repository/shopee.go @@ -103,12 +103,64 @@ type ShopeeProductRow struct { CollectMsg sql.NullString } -// ListShopeeProducts 按关键字查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。 +// ShopeeFilter 是蝦皮商品列表页支持的筛选条件,两项都可以为空。 +// +// Status 取值见 §「状态筛选的四个取值」(工单 #43): +// +// "" 不筛选 +// "pending_spec" 有 parse_ok = 0 的 SKU(待补规格) +// "no_link" pdd_goods_url 为空(未填 PDD 链接) +// "has_link" pdd_goods_url 非空(已填链接) +// +// 认不出的取值一律当作 ""(不筛选),由 service 层的 ParseShopeeStatus 兜底, +// 这里不做校验——repository 只管拼 SQL。 +type ShopeeFilter struct { + Keyword string + Status string +} + +// shopeeFilterClause 把关键字和状态筛选拼成 WHERE 子句,供 ListShopeeProducts +// 和 CountShopeeProductsFiltered 共用——两处筛选逻辑必须完全一致, +// 否则底部统计会跟表格对不上(#19 踩过一次,见工单 #43)。 +// +// 「待补规格」用 EXISTS 子查询,不用 JOIN + DISTINCT:一个商品有多个失败 +// SKU 时 JOIN 会出重复行,DISTINCT 又会让外层 LIMIT/OFFSET 的行为难推理 +// (见工单 #43)。 +func shopeeFilterClause(filter ShopeeFilter) (string, []any) { + var clauses []string + var args []any + + if kw := strings.TrimSpace(filter.Keyword); kw != "" { + like := "%" + escapeLike(kw) + "%" + clauses = append(clauses, `(sp.goods_id LIKE ? ESCAPE '\' OR sp.title LIKE ? ESCAPE '\')`) + args = append(args, like, like) + } + + switch filter.Status { + case "pending_spec": + clauses = append(clauses, + `EXISTS (SELECT 1 FROM shopee_skus s WHERE s.goods_id = sp.goods_id AND s.parse_ok = 0)`) + case "no_link": + clauses = append(clauses, `(sp.pdd_goods_url IS NULL OR sp.pdd_goods_url = '')`) + case "has_link": + clauses = append(clauses, `(sp.pdd_goods_url IS NOT NULL AND sp.pdd_goods_url <> '')`) + } + + if len(clauses) == 0 { + return "", args + } + return " WHERE " + strings.Join(clauses, " AND "), args +} + +// ListShopeeProducts 按筛选条件分页查蝦皮商品列表(商品级一行),联查规格聚合数和采集状态。 // // keyword 匹配商品 ID 或商品名称,**不匹配颜色/尺码**—— // 那是 SKU 级信息,商品级列表里搜出来没法定位到具体是哪个 SKU(见 #41)。 -// keyword 为空表示不筛选。 -func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) { +// +// `[必须]` 分页用 LIMIT/OFFSET 在数据库里做,不把全量查出来在 Go 里切片 +// (工单 #43:这正是改之前 HTML 一次 3.4MB 的成因)。 +func ListShopeeProducts(q Execer, filter ShopeeFilter, limit, offset int) ([]ShopeeProductRow, error) { + where, args := shopeeFilterClause(filter) sqlText := ` SELECT sp.goods_id, sp.title, sp.shopee_status, sp.main_sku_code, sp.pdd_goods_url, sp.pdd_goods_id, sp.created_at, sp.updated_at, @@ -120,14 +172,9 @@ func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) { FROM shopee_products sp LEFT JOIN shopee_skus sk ON sk.goods_id = sp.goods_id LEFT JOIN pdd_products pp - ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` - var args []any - if keyword = strings.TrimSpace(keyword); keyword != "" { - like := "%" + escapeLike(keyword) + "%" - sqlText += ` WHERE sp.goods_id LIKE ? ESCAPE '\' OR sp.title LIKE ? ESCAPE '\'` - args = append(args, like, like) - } - sqlText += ` GROUP BY sp.goods_id ORDER BY sp.goods_id` + ON pp.goods_id = sp.pdd_goods_id AND pp.deleted_at IS NULL` + + where + ` GROUP BY sp.goods_id ORDER BY sp.goods_id LIMIT ? OFFSET ?` + args = append(args, limit, offset) rows, err := q.Query(sqlText, args...) if err != nil { @@ -157,6 +204,21 @@ func ListShopeeProducts(q Execer, keyword string) ([]ShopeeProductRow, error) { return list, rows.Err() } +// CountShopeeProductsFiltered 统计当前筛选条件下的商品总数。 +// +// `[必须]` 用和 ListShopeeProducts **完全相同**的筛选条件(shopeeFilterClause)—— +// 底部状态条和分页页数都靠它,写成两份 WHERE 迟早有一天会忘了同步改, +// 页码就会算错而且没人发现(#19 已经踩过一次,见工单 #43)。 +func CountShopeeProductsFiltered(q Execer, filter ShopeeFilter) (int, error) { + where, args := shopeeFilterClause(filter) + sqlText := `SELECT COUNT(*) FROM shopee_products sp` + where + var n int + if err := q.QueryRow(sqlText, args...).Scan(&n); err != nil { + return 0, fmt.Errorf("统计蝦皮商品数量失败: %w", err) + } + return n, nil +} + // GetShopeeProductByGoodsID 按 goods_id 查一条蝦皮商品,不带聚合。 // 弹窗组装商品信息时用。查不到返回 (nil, nil)。 func GetShopeeProductByGoodsID(q Execer, goodsID string) (*model.ShopeeProduct, error) { diff --git a/admin/service/pagination.go b/admin/service/pagination.go new file mode 100644 index 0000000..e8c3efa --- /dev/null +++ b/admin/service/pagination.go @@ -0,0 +1,106 @@ +// 分页的通用规则。 +// +// `[必须]` 这是全项目第一个做分页的页面(蝦皮数据页,工单 #43), +// 这里定的规则和常量供后面四个页面照抄,不要各写一份——分开写的话, +// 每页条数、越界兜底这些规则五个页面迟早会不一致。 +package service + +import ( + "strconv" + "strings" +) + +// PageSize 是全项目统一的每页条数。 +// +// `[必须]` 固定 20:见 docs/admin/05-ui-specification.md §3.2—— +// 20 行在 1366×768 上正好一屏不用滚动。 +const PageSize = 20 + +// ParsePage 解析 URL 上的 page 查询参数。 +// +// `[必须]` 认不出来的一律当作第 1 页,不报错——用户手改地址栏或者用旧书签 +// 不该把页面搞崩(工单 #43)。真正的越界(超过总页数)由 ClampPage 兜底, +// 这里只处理"不是正整数"的情况。 +func ParsePage(s string) int { + n, err := strconv.Atoi(strings.TrimSpace(s)) + if err != nil || n < 1 { + return 1 + } + return n +} + +// TotalPages 按 PageSize 把总数换算成总页数。 +// +// `[必须]` 总数为 0 时返回 1,不是 0——页面要显示"第 1/1 页", +// 不能出现"第 1/0 页"(工单 #43)。 +func TotalPages(total int) int { + if total <= 0 { + return 1 + } + return (total + PageSize - 1) / PageSize +} + +// ClampPage 把请求的页码限制在 [1, totalPages] 范围内。 +// +// `[必须]` page 超过总页数时兜到最后一页(显示有数据的那一页), +// 不是显示空表格——操作员看到空表格会以为数据没了(工单 #43)。 +func ClampPage(page, totalPages int) int { + if page < 1 { + return 1 + } + if page > totalPages { + return totalPages + } + return page +} + +// PaginationView 是分页控件要显示的全部内容,模板不做判断和算术。 +// +// `[必须]` FirstURL/PrevURL/NextURL/LastURL 是**整段拼好的**相对链接 +// (形如 "?status=no_link&page=3"),模板里必须整体作为单个 pipeline 输出 +// (``),不要在模板里把筛选参数和 page 分开拼接。 +// html/template 的上下文转义规则对"字面量 & 中间插一段动态内容"的情况, +// 会把动态内容当成单个参数值整体转义,问号和等号会被转成 %3F/%3D, +// 链接直接失效——这是本工单实测踩到的一个坑,见 pagination_test.go。 +type PaginationView struct { + Page int + TotalPages int + HasPrev bool + HasNext bool + FirstURL string + PrevURL string + NextURL string + LastURL string +} + +// NewPaginationView 根据当前页、总页数和筛选查询串组装分页控件视图。 +// +// baseQuery 是不含 page 的查询串(例如 url.Values{"status": {"no_link"}}.Encode() +// 的结果),由调用方(handler)负责把当前筛选和关键词编码进去—— +// 这样翻页时筛选和关键词才不会丢,见工单 #43。 +func NewPaginationView(page, totalPages int, baseQuery string) PaginationView { + v := PaginationView{ + Page: page, + TotalPages: totalPages, + HasPrev: page > 1, + HasNext: page < totalPages, + FirstURL: PaginationURL(baseQuery, 1), + LastURL: PaginationURL(baseQuery, totalPages), + } + if v.HasPrev { + v.PrevURL = PaginationURL(baseQuery, page-1) + } + if v.HasNext { + v.NextURL = PaginationURL(baseQuery, page+1) + } + return v +} + +// PaginationURL 把筛选查询串和目标页码拼成一个完整的相对链接("?...")。 +func PaginationURL(baseQuery string, page int) string { + q := "page=" + strconv.Itoa(page) + if baseQuery != "" { + q = baseQuery + "&" + q + } + return "?" + q +} diff --git a/admin/service/pagination_test.go b/admin/service/pagination_test.go new file mode 100644 index 0000000..f6feb1e --- /dev/null +++ b/admin/service/pagination_test.go @@ -0,0 +1,111 @@ +package service + +import "testing" + +func TestParsePage_非法值当作第1页(t *testing.T) { + cases := []string{"", "abc", "0", "-1", "-100", " "} + for _, c := range cases { + if got := ParsePage(c); got != 1 { + t.Errorf("ParsePage(%q) = %d,想要 1", c, got) + } + } +} + +func TestParsePage_合法值原样返回(t *testing.T) { + cases := map[string]int{"1": 1, "2": 2, "9999": 9999, " 3 ": 3} + for in, want := range cases { + if got := ParsePage(in); got != want { + t.Errorf("ParsePage(%q) = %d,想要 %d", in, got, want) + } + } +} + +func TestTotalPages_总数为0返回1不是0(t *testing.T) { + if got := TotalPages(0); got != 1 { + t.Errorf("TotalPages(0) = %d,想要 1(不能出现『第 1/0 页』)", got) + } +} + +func TestTotalPages_按PageSize向上取整(t *testing.T) { + cases := map[int]int{ + 1: 1, + PageSize: 1, + PageSize + 1: 2, + PageSize * 2: 2, + 5195: 260, // 工单 #43 实测样本量:5195 条 / 20 条一页 = 260 页 + } + for total, want := range cases { + if got := TotalPages(total); got != want { + t.Errorf("TotalPages(%d) = %d,想要 %d", total, got, want) + } + } +} + +func TestClampPage_越界兜到最后一页(t *testing.T) { + if got := ClampPage(9999, 260); got != 260 { + t.Errorf("ClampPage(9999, 260) = %d,想要 260(兜到最后一页,不是空表格)", got) + } +} + +func TestClampPage_小于1当作第1页(t *testing.T) { + for _, p := range []int{0, -1, -100} { + if got := ClampPage(p, 260); got != 1 { + t.Errorf("ClampPage(%d, 260) = %d,想要 1", p, got) + } + } +} + +func TestClampPage_合法范围原样返回(t *testing.T) { + if got := ClampPage(3, 260); got != 3 { + t.Errorf("ClampPage(3, 260) = %d,想要 3", got) + } +} + +func TestNewPaginationView_首页禁用上一页和首页(t *testing.T) { + v := NewPaginationView(1, 260, "") + if v.HasPrev { + t.Error("第 1 页时 HasPrev 应为 false(首页/上一页要禁用)") + } + if !v.HasNext { + t.Error("第 1 页且总页数 > 1 时 HasNext 应为 true") + } +} + +func TestNewPaginationView_末页禁用下一页和末页(t *testing.T) { + v := NewPaginationView(260, 260, "") + if v.HasPrev != true { + t.Error("最后一页时 HasPrev 应为 true(前面还有页)") + } + if v.HasNext { + t.Error("最后一页时 HasNext 应为 false(下一页/末页要禁用)") + } +} + +func TestNewPaginationView_总数为0时首尾都禁用(t *testing.T) { + v := NewPaginationView(1, 1, "") + if v.HasPrev || v.HasNext { + t.Errorf("只有 1 页时前后都应禁用,HasPrev=%v HasNext=%v", v.HasPrev, v.HasNext) + } +} + +func TestNewPaginationView_URL带上筛选条件(t *testing.T) { + v := NewPaginationView(2, 260, "status=no_link") + if v.PrevURL != "?status=no_link&page=1" { + t.Errorf("PrevURL = %q,想要 ?status=no_link&page=1", v.PrevURL) + } + if v.NextURL != "?status=no_link&page=3" { + t.Errorf("NextURL = %q,想要 ?status=no_link&page=3", v.NextURL) + } + if v.FirstURL != "?status=no_link&page=1" { + t.Errorf("FirstURL = %q,想要 ?status=no_link&page=1", v.FirstURL) + } + if v.LastURL != "?status=no_link&page=260" { + t.Errorf("LastURL = %q,想要 ?status=no_link&page=260", v.LastURL) + } +} + +func TestPaginationURL_无筛选条件时只有page(t *testing.T) { + if got := PaginationURL("", 3); got != "?page=3" { + t.Errorf("PaginationURL(\"\", 3) = %q,想要 ?page=3", got) + } +} diff --git a/admin/service/shopee_list.go b/admin/service/shopee_list.go index 111da54..c54eeae 100644 --- a/admin/service/shopee_list.go +++ b/admin/service/shopee_list.go @@ -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 是弹窗规格表的一行。 diff --git a/admin/service/shopee_list_test.go b/admin/service/shopee_list_test.go index 8fd1c9f..afaaa32 100644 --- a/admin/service/shopee_list_test.go +++ b/admin/service/shopee_list_test.go @@ -2,6 +2,7 @@ package service import ( "database/sql" + "fmt" "testing" "cmautobuy/admin/repository" @@ -47,7 +48,7 @@ func TestListShopeeProducts_商品级一行(t *testing.T) { seedShopeeSKU(t, db, "sku-2", "24420648774", "", "黑色", "L", "50-60公斤", true) seedShopeeSKU(t, db, "sku-3", "24420648774", "", "白色", "M", "40-50公斤", true) - result, err := ListShopeeProducts(db, "") + result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } @@ -82,7 +83,7 @@ func TestListShopeeProducts_颜色尺码只统计parse_ok(t *testing.T) { seedShopeeSKU(t, db, "s4", "26886533818", "紅色,3XL建議80-90公斤】", "", "", "", false) seedShopeeSKU(t, db, "s5", "26886533818", "粉色,3XL【寬鬆版 82.5-92.5kg", "", "", "", false) - result, err := ListShopeeProducts(db, "") + result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } @@ -109,7 +110,7 @@ func TestListShopeeProducts_待补大于0整行标黄(t *testing.T) { seedShopeeProduct(t, db, "1001", "商品A") seedShopeeSKU(t, db, "s1", "1001", "坏数据", "", "", "", false) - result, err := ListShopeeProducts(db, "") + result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } @@ -123,7 +124,7 @@ func TestListShopeeProducts_PDD链接未填写(t *testing.T) { db := newTestDB(t) seedShopeeProduct(t, db, "1001", "商品A") - result, err := ListShopeeProducts(db, "") + result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } @@ -148,7 +149,7 @@ func TestListShopeeProducts_采集状态联查(t *testing.T) { } setShopeePddLink(t, db, "1001", "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001") - result, err := ListShopeeProducts(db, "") + result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } @@ -168,7 +169,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺 seedShopeeSKU(t, db, "s1", "1001", "", "黑色", "M", "", true) // 按商品 ID 匹配 - result, err := ListShopeeProducts(db, "1001") + result, err := ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "1001"}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } @@ -177,7 +178,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺 } // 按商品名称匹配 - result, err = ListShopeeProducts(db, "背心") + result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "背心"}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } @@ -186,7 +187,7 @@ func TestListShopeeProducts_关键字匹配商品ID和名称_不匹配颜色尺 } // 颜色/尺码不参与匹配:搜"黑色"应该搜不到任何商品。 - result, err = ListShopeeProducts(db, "黑色") + result, err = ListShopeeProducts(db, repository.ShopeeFilter{Keyword: "黑色"}, 1) if err != nil { t.Fatalf("查询失败: %v", err) } @@ -250,3 +251,162 @@ func TestGetShopeeProductDetail_商品不存在返回nil(t *testing.T) { t.Fatalf("不存在的商品应该返回 nil,得到 %+v", detail) } } + +// ── 分页(工单 #43) ────────────────────────────────── + +func seedShopeeProducts(t *testing.T, db *sql.DB, n int) { + t.Helper() + for i := 0; i < n; i++ { + seedShopeeProduct(t, db, fmt.Sprintf("g%04d", i), fmt.Sprintf("商品%d", i)) + } +} + +func TestListShopeeProducts_每页固定20条(t *testing.T) { + db := newTestDB(t) + seedShopeeProducts(t, db, 25) + + result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if len(result.Rows) != 20 { + t.Errorf("第 1 页行数 = %d,想要 20", len(result.Rows)) + } + if result.Total != 25 { + t.Errorf("Total = %d,想要 25(全量,不是本页数)", result.Total) + } + if result.TotalPages != 2 { + t.Errorf("TotalPages = %d,想要 2", result.TotalPages) + } + + result2, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 2) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if len(result2.Rows) != 5 { + t.Errorf("第 2 页行数 = %d,想要 5(25 条剩下的)", len(result2.Rows)) + } +} + +func TestListShopeeProducts_page越界兜到最后一页(t *testing.T) { + db := newTestDB(t) + seedShopeeProducts(t, db, 25) + + result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 9999) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if result.Page != 2 { + t.Errorf("Page = %d,想要兜到最后一页 2", result.Page) + } + if len(result.Rows) != 5 { + t.Errorf("越界后应显示最后一页的数据,行数 = %d,想要 5,不是空表格", len(result.Rows)) + } +} + +func TestListShopeeProducts_page小于1或非数字当作第1页(t *testing.T) { + db := newTestDB(t) + seedShopeeProducts(t, db, 3) + + for _, p := range []int{0, -1, -100} { + result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, p) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if result.Page != 1 { + t.Errorf("page=%d 时 Page = %d,想要 1", p, result.Page) + } + } +} + +func TestListShopeeProducts_总数为0时第1页共1页(t *testing.T) { + db := newTestDB(t) + + result, err := ListShopeeProducts(db, repository.ShopeeFilter{}, 1) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if result.TotalPages != 1 { + t.Errorf("总数为 0 时 TotalPages = %d,想要 1(不能出现『第 1/0 页』)", result.TotalPages) + } + if result.Page != 1 { + t.Errorf("Page = %d,想要 1", result.Page) + } +} + +// ── 状态筛选(工单 #43) ────────────────────────────── + +func TestListShopeeProducts_待补规格筛选_一个商品多个失败SKU只出现一行(t *testing.T) { + db := newTestDB(t) + // 同一商品两条解析失败的 SKU:用 JOIN + DISTINCT 会因为多行匹配 EXISTS + // 子查询而出重复,这里验证过滤用 EXISTS 时该商品只出现一次。 + seedShopeeProduct(t, db, "1001", "待补商品") + seedShopeeSKU(t, db, "s1", "1001", "坏数据1", "", "", "", false) + seedShopeeSKU(t, db, "s2", "1001", "坏数据2", "", "", "", false) + seedShopeeProduct(t, db, "1002", "正常商品") + seedShopeeSKU(t, db, "s3", "1002", "", "黑色", "M", "", true) + + result, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "pending_spec"}, 1) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if result.Total != 1 { + t.Fatalf("Total = %d,想要 1(不因为两条失败 SKU 出重复行)", result.Total) + } + if len(result.Rows) != 1 || result.Rows[0].GoodsID != "1001" { + t.Fatalf("筛选结果不对: %+v", result.Rows) + } +} + +func TestListShopeeProducts_未填链接和已填链接筛选(t *testing.T) { + db := newTestDB(t) + seedShopeeProduct(t, db, "1001", "未填链接商品") + seedShopeeProduct(t, db, "1002", "已填链接商品") + setShopeePddLink(t, db, "1002", "9001", "https://mobile.yangkeduo.com/goods.html?goods_id=9001") + + noLink, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "no_link"}, 1) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if noLink.Total != 1 || noLink.Rows[0].GoodsID != "1001" { + t.Fatalf("未填链接筛选结果不对: %+v", noLink.Rows) + } + + hasLink, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "has_link"}, 1) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + if hasLink.Total != 1 || hasLink.Rows[0].GoodsID != "1002" { + t.Fatalf("已填链接筛选结果不对: %+v", hasLink.Rows) + } +} + +func TestListShopeeProducts_筛选参数认不出来当全部(t *testing.T) { + db := newTestDB(t) + seedShopeeProducts(t, db, 3) + + result, err := ListShopeeProducts(db, repository.ShopeeFilter{Status: "not_a_real_status"}, 1) + if err != nil { + t.Fatalf("查询失败: %v", err) + } + // repository 层不做校验,直接透传的话这个取值走不进 switch 的任何分支, + // 等价于不筛选——校验(认不出来当全部)在 service.ParseShopeeStatus, + // 这里验证 repository 端不会因为陌生取值报错或漏数据。 + if result.Total != 3 { + t.Errorf("Total = %d,想要 3(陌生取值不应影响结果)", result.Total) + } +} + +func TestParseShopeeStatus_认不出来当全部(t *testing.T) { + cases := []string{"", "not_a_real_status", " ", "PENDING_SPEC"} + for _, c := range cases { + if got := ParseShopeeStatus(c); got != "" { + t.Errorf("ParseShopeeStatus(%q) = %q,想要空串(当全部)", c, got) + } + } + for _, c := range []string{"pending_spec", "no_link", "has_link"} { + if got := ParseShopeeStatus(c); got != c { + t.Errorf("ParseShopeeStatus(%q) = %q,想要原样返回", c, got) + } + } +} diff --git a/admin/static/css/app.css b/admin/static/css/app.css index a88f54a..34a3f34 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -137,6 +137,8 @@ tr.empty small { color: #aaa; } .statusbar { position: fixed; left: 0; right: 0; bottom: 0; + display: flex; + align-items: center; background: #2b3440; color: #c8cdd4; padding: 10px 16px; @@ -262,6 +264,34 @@ select { background: #fff; } +/* ── 分页控件 ─────────────────────────── */ +/* 见 docs/admin/05-ui-specification.md §3.2。全项目第一个分页页面 + (蝦皮数据页,工单 #43)定的样式,其余四个页面照抄。 + 控件本身是 ,不是按钮/JS,样式借用 button 的观感即可。 */ +.pagination { + display: flex; + align-items: center; + gap: 6px; + margin-left: 12px; +} +.pagination a, +.pagination span.disabled { + padding: 4px 10px; + border: 1px solid #ccd1d6; + border-radius: 3px; + text-decoration: none; + font-size: 13px; +} +.pagination a { color: #1f6feb; background: #fff; } +.pagination a:hover { background: #f0f2f4; } +/* 首页/末页时对应按钮禁用:用 而不是不可点的 (语义上不再是链接), + 再配色区分,不能只靠颜色(见 05 §3.2)。 */ +.pagination span.disabled { + color: #aaa; + background: #f5f6f8; + cursor: not-allowed; +} + /* ── 错误页 ───────────────────────────── */ .error-box { background: #fff; diff --git a/admin/templates/partials/footer.html b/admin/templates/partials/footer.html index 6a7c16f..99ff294 100644 --- a/admin/templates/partials/footer.html +++ b/admin/templates/partials/footer.html @@ -1,8 +1,36 @@ {{define "footer"}} -{{/* 底部状态条:三段式布局的第三段,五个页面都有 */}} -
{{.Status}}
+{{/* 底部状态条:三段式布局的第三段,五个页面都有。 + Pagination 是可选的(只有做了分页的页面才传), + 没做分页的页面不受影响,见 docs/admin/05-ui-specification.md §3.2。 + + `[必须]` 每个链接的 href 必须整体来自 .Pagination.XxxURL 这一个 pipeline, + 不要在这里把筛选参数和 page 分开拼接(比如 "?{{.BaseQuery}}&page=...")—— + html/template 会把夹在字面量 & 中间的动态内容当成单个参数值整体转义, + ? 和 = 变成 %3F/%3D,链接直接失效。已经在 service.NewPaginationView + 里把完整链接拼好,这里只管原样输出。 */}} +
diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html index 224e46e..4bb0eb5 100644 --- a/admin/templates/shopee/list.html +++ b/admin/templates/shopee/list.html @@ -15,6 +15,15 @@
+ {{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花, + 见 docs/admin/05-ui-specification.md §4.1、工单 #43。 + 放在关键词前面:操作员大多是"看某一类"而不是"查某个 ID"。 */}} + + diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index 77da716..eb4d4fa 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -85,7 +85,14 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 ### 4.1 蝦皮数据模块 -**顶部工具条:** 导入按钮、商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。 +**顶部工具条:** 导入按钮、状态筛选(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、 +商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。 + +`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护 +(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话, +操作员得先知道 ID 才能搜——而"哪些商品需要处理"恰恰是不知道 ID 的时候才要问的。 +实测样本 5195 个商品里只有 6 个待补规格,只做分页要翻 260 页才能找全,等于找不到。 +界面细节和筛选条件的 SQL 见 [05 界面规范](05-ui-specification.md) §4.1。 **中间表格**(按 SKU 展开显示,数据来自商品表和 SKU 表联查): diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 063fc47..779b8d3 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -47,7 +47,7 @@ 见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。 - `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。 - `[建议]` 默认按 `更新时间 DESC` 排序。 -- `[建议]` 一页 50 条,超过分页。搜索走数据库,不要一次查出来在内存里过滤。 +- `[必须]` 每页 **20** 条,超过分页,见 §3.2。搜索走数据库,不要一次查出来在内存里过滤。 - `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。 - `[必须]` 空状态要分情况,文案不能都是"暂无数据": - 从没导入过 → "还没有数据,点左上角『导入』开始" @@ -72,12 +72,50 @@ `[建议]` placeholder 要短。收到 30% 之后长文案显示不全—— 真装不下就缩短文案,**不要把宽度调回去**。 +### 3.2 分页 + +`[必须]` 五个模块**统一每页 20 条**(原来这里写的是"建议一页 50 条", +已按工单 #43 改为 20 并升级为 `[必须]`)——20 行在 1366×768 上正好一屏, +不用滚动就能看完;50 条要滚动,操作员容易漏看最下面几行。 + +蝦皮数据页(§4)是全项目第一个做分页的页面,本节是它定下的通用规则, +**其余四个页面照抄**,不要各写一套: + +- `[必须]` 分页用 SQL 的 `LIMIT ? OFFSET ?`,**不得**把全量查出来在 Go 里 + 切片——这正是蝦皮数据页改之前一次吐 3.4MB HTML 的成因(实测 5195 行)。 +- `[必须]` 总数用**单独的 `COUNT(*)`** 查询,并且和列表查询**共用同一套 + 筛选条件拼装函数**。分开写两份 `WHERE` 迟早会漏改一处,页码跟着算错, + 而且不会报错、不容易发现(#19 已经踩过一次)。 +- `[必须]` 页码参数是 `?page=N`,从 **1** 开始,不是 0——给操作员看的东西 + 不要 0-based。 +- `[必须]` 越界要兜住: + - `page` 非数字、`0`、负数 → 当作第 1 页; + - `page` 超过总页数 → 显示**最后一页**(有数据的那页),不是空表格; + - 总数为 0 → 显示"第 1/1 页",不出现"第 1/0 页"。 +- `[必须]` 底部状态条显示**全量总数**(当前筛选条件下的总数),不是本页 + 行数——"共 20 个商品"会让操作员以为总共就 20 个。筛选后显示筛选结果的 + 总数,例如"待补规格:6 个商品 · 第 1/1 页",不是本页凑出来的数字。 +- `[必须]` 翻页时**保留当前筛选和关键词**——用查询参数原样带过去, + 丢了的话操作员翻到第二页筛选就没了,会以为数据变了。 +- `[必须]` 分页控件用 ``,**不要用 JS**。这是纯 GET 导航, + 浏览器的前进后退和书签都该正常工作。 +- `[必须]` 第一页时"首页 / 上一页"禁用,最后一页时"下一页 / 末页"禁用; + 禁用要有**视觉 + 语义**区分(不可点的元素不要还是 ``),不能只靠颜色。 +- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义, + 操作员应该靠筛选定位,不是靠翻页数页码。 + +公共实现放在 `admin/service/pagination.go`(`PageSize` 常量、 +`ParsePage` / `ClampPage` / `TotalPages` / `NewPaginationView`)和 +`admin/templates/partials/footer.html`(分页控件,`Pagination` 字段 +为空时不渲染,不影响还没做分页的页面),后面四个页面直接复用, +不要各写一份。 + ## 4. 蝦皮数据页 ### 4.1 工具条 ```text -[导入 Excel] [批量采集] 商品ID [________] [搜索] [删除] +[导入 Excel] [批量采集] 状态[全部▾] 商品ID [________] [搜索] [删除] ``` - **导入 Excel**:选文件 → POST 上传 → 显示结果统计 @@ -85,6 +123,21 @@ `[必须]` 失败行要列出行号和原因,不能静默跳过。 - **批量采集**:勾选若干行 → 服务端**按商品去重**后建采集任务。 `[必须]` 已是"采集中"的商品跳过,并在结果里说明跳过了几个。 +- **状态筛选**(`[必须]`,工单 #43):全部 / 待补规格 / 未填 PDD 链接 / 已填链接。 + 这个筛选是**刚需**,不是锦上添花——本页主要用途是维护 + (找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID + 搜索的话,操作员得先知道 ID 才能搜,而"哪些商品需要处理"恰恰是 + 不知道 ID 的时候才要问的。实测样本 5195 个商品里只有 6 个待补规格, + 只做分页(260 页)翻不出来。 + - 「待补规格」:`EXISTS (SELECT 1 FROM shopee_skus WHERE goods_id = 该商品 AND parse_ok = 0)`。 + `[必须]` 用 `EXISTS`,不用 `JOIN` + `DISTINCT`——一个商品有多个失败 + SKU 时 `JOIN` 会出重复行,`DISTINCT` 又会让分页的 `LIMIT/OFFSET` + 行为难推理。 + - 「未填 PDD 链接」:`pdd_goods_url IS NULL OR pdd_goods_url = ''`。 + - 「已填链接」:`pdd_goods_url IS NOT NULL AND pdd_goods_url <> ''`。 + - `[必须]` 筛选参数认不出来的一律当"全部",不报错——地址栏是用户可以 + 随便改的。 + - `[必须]` 筛选与关键词搜索可以叠加,翻页时都要保留,见 §3.2。 ### 4.2 表格列 @@ -145,6 +198,23 @@ 新增的行 `is_manual = 1`,`[必须]` 后续导入**不得删除**它们。 +### 4.5 底部状态条 + +```text +共 5195 个商品 · 第 1/260 页 [首页] [上一页] [下一页] [末页] +``` + +筛选后(例如状态选了"待补规格"): + +```text +待补规格:6 个商品 · 第 1/1 页 [首页] [上一页] [下一页] [末页] +``` + +`[必须]` 分页与状态条的通用规则见 §3.2,不在这里重复。这里只强调 +蝦皮页特有的一点:导入完成后状态条**显示导入结果**("导入完成:N 个商品 / +M 个 SKU,K 行解析失败"),覆盖掉正常的统计文案,并且回到第 1 页、清空筛选—— +刚导入的数据从第一页就能看到,沿用旧筛选反而可能让人以为导入没生效。 + ## 5. PDD 商品页 页面 `objectName` 为 `pddProductPage`,路由 `/pdd`。