diff --git a/admin/handler/web/pagination_test.go b/admin/handler/web/pagination_test.go index ff27679..c537c52 100644 --- a/admin/handler/web/pagination_test.go +++ b/admin/handler/web/pagination_test.go @@ -10,6 +10,7 @@ import ( "github.com/gin-gonic/gin" "cmautobuy/admin/model" + "cmautobuy/admin/service" ) func listPostContext(t *testing.T, path string, values url.Values) (*gin.Context, *httptest.ResponseRecorder) { @@ -42,11 +43,11 @@ func assertRedirectQuery(t *testing.T, recorder *httptest.ResponseRecorder, want func Test列表写操作跳转保留筛选和页码(t *testing.T) { t.Run("蝦皮", func(t *testing.T) { context, recorder := listPostContext(t, "/shopee/save", url.Values{ - "list_goods_id": {"1001"}, "status": {"no_link"}, "page": {"2"}, + "list_goods_id": {"1001"}, "status": {"no_link"}, "shop": {"has"}, "image": {"missing"}, "page": {"2"}, }) (&Handler{}).shopeeRedirect(context, "完成") assertRedirectQuery(t, recorder, "/shopee", map[string]string{ - "goods_id": "1001", "status": "no_link", "page": "2", "msg": "完成", + "goods_id": "1001", "status": "no_link", "shop": "has", "image": "missing", "page": "2", "msg": "完成", }) }) t.Run("顺运宝", func(t *testing.T) { @@ -96,3 +97,28 @@ func Test列表写操作跳转保留筛选和页码(t *testing.T) { }) }) } + +func Test蝦皮分页详情和状态条保留资料筛选(t *testing.T) { + values := url.Values{ + "goods_id": {"1001"}, + "status": {"has_link"}, + "shop": {"has"}, + "image": {"missing"}, + } + detail, err := url.Parse("/shopee/detail?" + detailValuesForShopee(values, 3)) + if err != nil { + t.Fatal(err) + } + for key, want := range map[string]string{ + "goods_id": "1001", "status": "has_link", "shop": "has", "image": "missing", "page": "3", + } { + if got := detail.Query().Get(key); got != want { + t.Errorf("详情参数 %s=%q,期望 %q", key, got, want) + } + } + + result := &service.ShopeeListResult{Total: 7, Page: 1, TotalPages: 1, IsFiltered: true} + if got := shopeeStatusLine("", result); got != "筛选结果:7 个商品 · 第 1/1 页" { + t.Errorf("资料筛选状态条=%q", got) + } +} diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index 8fa7418..b70c56a 100644 --- a/admin/handler/web/shopee.go +++ b/admin/handler/web/shopee.go @@ -18,7 +18,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("status"), c.Query("page"), c.Query("msg")) + h.renderShopeeList(c, c.Query("goods_id"), c.Query("status"), c.Query("shop"), c.Query("image"), c.Query("page"), c.Query("msg")) } // ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。 @@ -46,18 +46,22 @@ func (h *Handler) ShopeeDetail(c *gin.Context) { c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{ "D": detail, "CSRFToken": csrfToken(c), "Keyword": c.Query("goods_id"), "StatusFilter": c.Query("status"), + "ShopFilter": service.ParseShopeePresence(c.Query("shop")), + "ImageFilter": service.ParseShopeePresence(c.Query("image")), "CurrentPage": service.ParsePage(c.Query("page")), }) } // renderShopeeList 统一渲染蝦皮列表;数据写入改由商品目录接口负责。 // -// `[必须]` 分页控件用 纯 GET 导航,翻页要保留 keyword/statusRaw, -// 所以这里把它们原样透传回模板去拼下一页的链接,不在这里丢掉(工单 #43)。 -func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw, msg string) { +// `[必须]` 分页控件用 纯 GET 导航,翻页要保留关键词和三个下拉筛选, +// 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#150)。 +func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, shopRaw, imageRaw, pageRaw, msg string) { filter := repository.ShopeeFilter{ Keyword: keyword, Status: service.ParseShopeeStatus(statusRaw), + Shop: service.ParseShopeePresence(shopRaw), + Image: service.ParseShopeePresence(imageRaw), } // 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数, // 局部变量会把它遮住导致编译失败。 @@ -82,16 +86,27 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw, if filter.Status != "" { values.Set("status", filter.Status) } + if filter.Shop != "" { + values.Set("shop", filter.Shop) + } + if filter.Image != "" { + values.Set("image", filter.Image) + } c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{ "Keyword": keyword, "StatusFilter": filter.Status, "StatusOptions": service.ShopeeStatusOptions(), + "ShopFilter": filter.Shop, + "ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"), + "ImageFilter": filter.Image, + "ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"), "Rows": result.Rows, "Status": status, "HasAnyProducts": result.HasAnyProducts, "IsFiltered": result.IsFiltered, "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "CurrentPage": result.Page, "DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page), })) } @@ -114,6 +129,8 @@ func shopeeStatusLine(statusFilter string, result *service.ShopeeListResult) str prefix := fmt.Sprintf("共 %d 个商品", result.Total) if text := service.ShopeeStatusLabel(statusFilter); text != "" { prefix = fmt.Sprintf("%s:%d 个商品", text, result.Total) + } else if result.IsFiltered { + prefix = fmt.Sprintf("筛选结果:%d 个商品", result.Total) } return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages) } @@ -165,6 +182,12 @@ func (h *Handler) shopeeRedirect(c *gin.Context, msg string) { if value := strings.TrimSpace(c.PostForm("status")); value != "" { params.Set("status", service.ParseShopeeStatus(value)) } + if value := service.ParseShopeePresence(c.PostForm("shop")); value != "" { + params.Set("shop", value) + } + if value := service.ParseShopeePresence(c.PostForm("image")); value != "" { + params.Set("image", value) + } if value := strings.TrimSpace(c.PostForm("page")); value != "" { params.Set("page", value) } diff --git a/admin/main_test.go b/admin/main_test.go index bdfa217..c0acc5e 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -193,6 +193,27 @@ func TestShopeePage_展示主图店铺且保留标题宽度(t *testing.T) { } } +func TestShopeePage_店铺图片三态筛选和状态保留(t *testing.T) { + list, err := os.ReadFile("templates/shopee/list.html") + if err != nil { + t.Fatal(err) + } + detail, err := os.ReadFile("templates/shopee/detail_modal.html") + if err != nil { + t.Fatal(err) + } + content := string(list) + string(detail) + for _, want := range []string{ + ``, `name="shop"`, `.ShopOptions`, `.ShopFilter`, + ``, `name="image"`, `.ImageOptions`, `.ImageFilter`, + `清除筛选条件`, + } { + if !strings.Contains(content, want) { + t.Errorf("蝦皮三态筛选界面缺少 %q", want) + } + } +} + func TestShopeeImportRoute_已移除(t *testing.T) { router, err := newRouter(nil) if err != nil { diff --git a/admin/repository/shopee.go b/admin/repository/shopee.go index aea5646..6a7f16f 100644 --- a/admin/repository/shopee.go +++ b/admin/repository/shopee.go @@ -119,7 +119,7 @@ type ShopeeProductRow struct { CollectMsg sql.NullString } -// ShopeeFilter 是蝦皮商品列表页支持的筛选条件,两项都可以为空。 +// ShopeeFilter 是蝦皮商品列表页支持的筛选条件,四项都可以为空。 // // Status 取值见 §「状态筛选的四个取值」(工单 #43): // @@ -133,9 +133,11 @@ type ShopeeProductRow struct { type ShopeeFilter struct { Keyword string Status string + Shop string // has / missing / 空(全部) + Image string // has / missing / 空(全部) } -// shopeeFilterClause 把关键字和状态筛选拼成 WHERE 子句,供 ListShopeeProducts +// shopeeFilterClause 把关键字、状态、店铺和图片筛选拼成 WHERE 子句,供 ListShopeeProducts // 和 CountShopeeProductsFiltered 共用——两处筛选逻辑必须完全一致, // 否则底部统计会跟表格对不上(#19 踩过一次,见工单 #43)。 // @@ -162,6 +164,20 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) { clauses = append(clauses, `(sp.pdd_goods_url IS NOT NULL AND sp.pdd_goods_url <> '')`) } + switch filter.Shop { + case "has": + clauses = append(clauses, `(sp.shopee_shop_name IS NOT NULL AND TRIM(sp.shopee_shop_name) <> '')`) + case "missing": + clauses = append(clauses, `(sp.shopee_shop_name IS NULL OR TRIM(sp.shopee_shop_name) = '')`) + } + + switch filter.Image { + case "has": + clauses = append(clauses, `(sp.image_url IS NOT NULL AND TRIM(sp.image_url) <> '')`) + case "missing": + clauses = append(clauses, `(sp.image_url IS NULL OR TRIM(sp.image_url) = '')`) + } + if len(clauses) == 0 { return "", args } diff --git a/admin/service/shopee_list.go b/admin/service/shopee_list.go index 45db699..71063ab 100644 --- a/admin/service/shopee_list.go +++ b/admin/service/shopee_list.go @@ -100,7 +100,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (* Rows: make([]ShopeeProductView, 0, len(rows)), Total: total, HasAnyProducts: hasAny > 0, - IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "", + IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "", Page: page, PageSize: PageSize, TotalPages: totalPages, @@ -188,6 +188,25 @@ func ShopeeStatusLabel(status string) string { return shopeeStatusTexts[status] } +// ShopeePresenceOptions 返回资料字段的三态选项。使用下拉框而不是复选框, +// 让“全部”和“无资料”都有明确表达,不把未勾选误解成任意状态。 +func ShopeePresenceOptions(hasText, missingText string) []ShopeeStatusOption { + return []ShopeeStatusOption{ + {"", "全部"}, + {"has", hasText}, + {"missing", missingText}, + } +} + +// ParseShopeePresence 校验店铺/图片筛选参数;未知值按“全部”处理。 +func ParseShopeePresence(s string) string { + value := strings.TrimSpace(s) + if value == "has" || value == "missing" { + return value + } + return "" +} + // ---------- 弹窗 ---------- // ShopeeSpecView 是弹窗规格表的一行。 diff --git a/admin/service/shopee_list_test.go b/admin/service/shopee_list_test.go index 4b60c78..5408e48 100644 --- a/admin/service/shopee_list_test.go +++ b/admin/service/shopee_list_test.go @@ -474,3 +474,70 @@ func TestParseShopeeStatus_认不出来当全部(t *testing.T) { } } } + +func TestListShopeeProducts_店铺图片三态及组合筛选(t *testing.T) { + db := newTestDB(t) + for _, product := range []struct { + id, title, shop, image string + }{ + {"A", "店铺有图", "店铺 A", "https://example.invalid/a.jpg"}, + {"B", "店铺无图", "店铺 B", ""}, + {"C", "无店有图", "", "https://example.invalid/c.jpg"}, + {"D", "资料为空白", " ", " "}, + } { + seedShopeeProduct(t, db, product.id, product.title) + if _, err := db.Exec(`UPDATE shopee_products SET shopee_shop_name=?,image_url=? WHERE goods_id=?`, product.shop, product.image, product.id); err != nil { + t.Fatal(err) + } + } + + cases := []struct { + name string + filter repository.ShopeeFilter + want []string + }{ + {"有店铺", repository.ShopeeFilter{Shop: "has"}, []string{"A", "B"}}, + {"无店铺", repository.ShopeeFilter{Shop: "missing"}, []string{"C", "D"}}, + {"有图片", repository.ShopeeFilter{Image: "has"}, []string{"A", "C"}}, + {"无图片", repository.ShopeeFilter{Image: "missing"}, []string{"B", "D"}}, + {"有店铺且有图片", repository.ShopeeFilter{Shop: "has", Image: "has"}, []string{"A"}}, + {"无店铺且无图片", repository.ShopeeFilter{Shop: "missing", Image: "missing"}, []string{"D"}}, + {"组合关键词", repository.ShopeeFilter{Keyword: "店铺有图", Shop: "has", Image: "has"}, []string{"A"}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + result, err := ListShopeeProducts(db, tc.filter, 1) + if err != nil { + t.Fatal(err) + } + if result.Total != len(tc.want) || len(result.Rows) != len(tc.want) { + t.Fatalf("Total=%d rows=%d,期望 %v", result.Total, len(result.Rows), tc.want) + } + for index, want := range tc.want { + if result.Rows[index].GoodsID != want { + t.Fatalf("第 %d 行=%s,期望 %s", index, result.Rows[index].GoodsID, want) + } + } + if !result.IsFiltered { + t.Fatal("使用店铺/图片条件时 IsFiltered 应为 true") + } + }) + } +} + +func TestParseShopeePresence_三态参数(t *testing.T) { + for _, value := range []string{"has", "missing"} { + if got := ParseShopeePresence(value); got != value { + t.Errorf("ParseShopeePresence(%q)=%q", value, got) + } + } + for _, value := range []string{"", "all", "HAS", "unknown", " "} { + if got := ParseShopeePresence(value); got != "" { + t.Errorf("ParseShopeePresence(%q)=%q,期望全部", value, got) + } + } + options := ShopeePresenceOptions("有店铺", "无店铺") + if len(options) != 3 || options[0].Value != "" || options[1].Value != "has" || options[2].Value != "missing" { + t.Fatalf("三态选项不正确:%+v", options) + } +} diff --git a/admin/templates/shopee/detail_modal.html b/admin/templates/shopee/detail_modal.html index 39383f2..4c0c993 100644 --- a/admin/templates/shopee/detail_modal.html +++ b/admin/templates/shopee/detail_modal.html @@ -23,6 +23,8 @@ + +
@@ -44,6 +46,8 @@ + + {{if .CanCollect}} diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html index 0c5f606..4becdc9 100644 --- a/admin/templates/shopee/list.html +++ b/admin/templates/shopee/list.html @@ -14,6 +14,18 @@ {{end}} + + + + @@ -22,6 +34,11 @@
+ + + + +
@@ -78,7 +95,8 @@ {{if .IsFiltered}} - 没有匹配的数据,换个商品 ID 或商品名称试试。 + 当前筛选条件下没有商品。请调整状态、店铺、图片或商品 ID。
+
清除筛选条件 {{else}} 还没有数据。蝦皮与 PDD 对应数据由第三方脚本通过商品目录接口导入。
{{if $.CurrentUser.IsAdmin}}可点击左上角「导入记录」查看脚本提交结果。{{end}} diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index fef4a3f..a25b5ee 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -124,7 +124,7 @@ ### 4.1 工具条 ```text -[导入记录] 状态[全部▾] 商品ID [________] [搜索] [删除] +[导入记录] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品ID [________] [搜索] [删除] ``` - **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。 @@ -144,6 +144,13 @@ - `[必须]` 筛选参数认不出来的一律当"全部",不报错——地址栏是用户可以 随便改的。 - `[必须]` 筛选与关键词搜索可以叠加,翻页时都要保留,见 §3.2。 +- **店铺筛选**(工单 #150):全部 / 有店铺 / 无店铺;按 + `shopee_shop_name` 去除首尾空白后的非空/空判断。 +- **图片筛选**(工单 #150):全部 / 有图片 / 无图片;按 `image_url` + 去除首尾空白后的非空/空判断。 +- `[必须]` 店铺、图片使用独立三态下拉框,不使用含义不清的复选框;两个条件可互相 + 组合,也可与状态和商品 ID/标题搜索叠加。未知参数按“全部”处理,翻页、详情弹窗 + 和写操作返回时保留全部条件。工具条在窄窗口沿用公共 `flex-wrap` 自然换行。 ### 4.2 表格列