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"}}
-{{/* 底部状态条:三段式布局的第三段,五个页面都有 */}}
-
+{{/* 底部状态条:三段式布局的第三段,五个页面都有。
+ Pagination 是可选的(只有做了分页的页面才传),
+ 没做分页的页面不受影响,见 docs/admin/05-ui-specification.md §3.2。
+
+ `[必须]` 每个链接的 href 必须整体来自 .Pagination.XxxURL 这一个 pipeline,
+ 不要在这里把筛选参数和 page 分开拼接(比如 "?{{.BaseQuery}}&page=...")——
+ html/template 会把夹在字面量 & 中间的动态内容当成单个参数值整体转义,
+ ? 和 = 变成 %3F/%3D,链接直接失效。已经在 service.NewPaginationView
+ 里把完整链接拼好,这里只管原样输出。 */}}
+