纯 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 表格列
|