From 889f5685541051f5bac7652f11f209bd45d8bde5 Mon Sep 17 00:00:00 2001 From: chengma Date: Wed, 12 Aug 2026 15:09:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=A2=9E=E5=8A=A0=E8=9D=A6=E7=9A=AE?= =?UTF-8?q?=E5=BA=97=E9=93=BA=E5=90=8D=E7=A7=B0=E6=90=9C=E7=B4=A2=20(#182)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- admin/handler/web/pagination_test.go | 15 ++++--- admin/handler/web/shopee.go | 55 ++++++++++++++---------- admin/main_test.go | 2 +- admin/repository/shopee.go | 13 ++++-- admin/service/shopee_list.go | 2 +- admin/service/shopee_list_test.go | 28 ++++++++++++ admin/static/css/app.css | 3 ++ admin/static/js/app.js | 2 +- admin/templates/shopee/detail_modal.html | 2 + admin/templates/shopee/list.html | 7 ++- docs/admin/05-ui-specification.md | 5 ++- 11 files changed, 94 insertions(+), 40 deletions(-) diff --git a/admin/handler/web/pagination_test.go b/admin/handler/web/pagination_test.go index 94189b8..c189ac1 100644 --- a/admin/handler/web/pagination_test.go +++ b/admin/handler/web/pagination_test.go @@ -43,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"}, "shop": {"has"}, "image": {"missing"}, "page": {"2"}, + "list_goods_id": {"1001"}, "list_shop_name": {"店铺 A"}, "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", "shop": "has", "image": "missing", "page": "2", "msg": "完成", + "goods_id": "1001", "shop_name": "店铺 A", "status": "no_link", "shop": "has", "image": "missing", "page": "2", "msg": "完成", }) }) t.Run("顺运宝", func(t *testing.T) { @@ -100,17 +100,18 @@ func Test列表写操作跳转保留筛选和页码(t *testing.T) { func Test蝦皮分页详情和状态条保留资料筛选(t *testing.T) { values := url.Values{ - "goods_id": {"1001"}, - "status": {"has_link"}, - "shop": {"has"}, - "image": {"missing"}, + "goods_id": {"1001"}, + "shop_name": {"店铺 A"}, + "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", + "goods_id": "1001", "shop_name": "店铺 A", "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) diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index b70c56a..38027d1 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("shop"), c.Query("image"), c.Query("page"), c.Query("msg")) + h.renderShopeeList(c, c.Query("goods_id"), c.Query("shop_name"), c.Query("status"), c.Query("shop"), c.Query("image"), c.Query("page"), c.Query("msg")) } // ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。 @@ -46,9 +46,10 @@ 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")), + "ShopNameKeyword": strings.TrimSpace(c.Query("shop_name")), + "ShopFilter": service.ParseShopeePresence(c.Query("shop")), + "ImageFilter": service.ParseShopeePresence(c.Query("image")), + "CurrentPage": service.ParsePage(c.Query("page")), }) } @@ -56,12 +57,13 @@ func (h *Handler) ShopeeDetail(c *gin.Context) { // // `[必须]` 分页控件用 纯 GET 导航,翻页要保留关键词和三个下拉筛选, // 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#150)。 -func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, shopRaw, imageRaw, pageRaw, msg string) { +func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, shopRaw, imageRaw, pageRaw, msg string) { filter := repository.ShopeeFilter{ - Keyword: keyword, - Status: service.ParseShopeeStatus(statusRaw), - Shop: service.ParseShopeePresence(shopRaw), - Image: service.ParseShopeePresence(imageRaw), + Keyword: keyword, + ShopName: strings.TrimSpace(shopName), + Status: service.ParseShopeeStatus(statusRaw), + Shop: service.ParseShopeePresence(shopRaw), + Image: service.ParseShopeePresence(imageRaw), } // 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数, // 局部变量会把它遮住导致编译失败。 @@ -83,6 +85,9 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, shopRaw, if keyword != "" { values.Set("goods_id", keyword) } + if filter.ShopName != "" { + values.Set("shop_name", filter.ShopName) + } if filter.Status != "" { values.Set("status", filter.Status) } @@ -94,20 +99,21 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, shopRaw, } 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), + "Keyword": keyword, + "ShopNameKeyword": filter.ShopName, + "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), })) } @@ -179,6 +185,9 @@ func (h *Handler) shopeeRedirect(c *gin.Context, msg string) { if value := strings.TrimSpace(c.PostForm("list_goods_id")); value != "" { params.Set("goods_id", value) } + if value := strings.TrimSpace(c.PostForm("list_shop_name")); value != "" { + params.Set("shop_name", value) + } if value := strings.TrimSpace(c.PostForm("status")); value != "" { params.Set("status", service.ParseShopeeStatus(value)) } diff --git a/admin/main_test.go b/admin/main_test.go index 32da8dd..9cf4eb1 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -64,7 +64,7 @@ func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *te jsSource := string(js) for _, want := range []string{ "var MODULE_QUERY_KEYS = {", - "\"/shopee\": [\"goods_id\", \"status\", \"shop\", \"image\", \"page\"]", + "\"/shopee\": [\"goods_id\", \"shop_name\", \"status\", \"shop\", \"image\", \"page\"]", "\"/syb\": [\"order_no\", \"shop\", \"stage\", \"page\", \"date_from\", \"date_to\"]", "var MODULE_STATE_PREFIX = \"cmautobuy:module-state:\"", "parsed.origin !== window.location.origin || parsed.pathname !== moduleRoot", diff --git a/admin/repository/shopee.go b/admin/repository/shopee.go index 6a7f16f..62fdd3b 100644 --- a/admin/repository/shopee.go +++ b/admin/repository/shopee.go @@ -131,10 +131,11 @@ type ShopeeProductRow struct { // 认不出的取值一律当作 ""(不筛选),由 service 层的 ParseShopeeStatus 兜底, // 这里不做校验——repository 只管拼 SQL。 type ShopeeFilter struct { - Keyword string - Status string - Shop string // has / missing / 空(全部) - Image string // has / missing / 空(全部) + Keyword string + ShopName string + Status string + Shop string // has / missing / 空(全部) + Image string // has / missing / 空(全部) } // shopeeFilterClause 把关键字、状态、店铺和图片筛选拼成 WHERE 子句,供 ListShopeeProducts @@ -153,6 +154,10 @@ func shopeeFilterClause(filter ShopeeFilter) (string, []any) { clauses = append(clauses, `(sp.goods_id LIKE ? ESCAPE '!' OR sp.title LIKE ? ESCAPE '!')`) args = append(args, like, like) } + if shopName := strings.TrimSpace(filter.ShopName); shopName != "" { + clauses = append(clauses, `sp.shopee_shop_name LIKE ? ESCAPE '!'`) + args = append(args, "%"+escapeLike(shopName)+"%") + } switch filter.Status { case "pending_spec": diff --git a/admin/service/shopee_list.go b/admin/service/shopee_list.go index 71063ab..aa1a610 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 != "" || filter.Shop != "" || filter.Image != "", + IsFiltered: strings.TrimSpace(filter.Keyword) != "" || strings.TrimSpace(filter.ShopName) != "" || filter.Status != "" || filter.Shop != "" || filter.Image != "", Page: page, PageSize: PageSize, TotalPages: totalPages, diff --git a/admin/service/shopee_list_test.go b/admin/service/shopee_list_test.go index 5408e48..569ff97 100644 --- a/admin/service/shopee_list_test.go +++ b/admin/service/shopee_list_test.go @@ -503,6 +503,8 @@ func TestListShopeeProducts_店铺图片三态及组合筛选(t *testing.T) { {"有店铺且有图片", 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"}}, + {"店铺名称", repository.ShopeeFilter{ShopName: "店铺 A"}, []string{"A"}}, + {"店铺名称组合", repository.ShopeeFilter{ShopName: "店铺", Shop: "has", Image: "missing"}, []string{"B"}}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -525,6 +527,32 @@ func TestListShopeeProducts_店铺图片三态及组合筛选(t *testing.T) { } } +func TestListShopeeProducts_店铺名称LIKE特殊字符按原文匹配(t *testing.T) { + db := newTestDB(t) + for _, product := range []struct{ id, shop string }{ + {"SPECIAL", "测试%店_铺!"}, + {"PLAIN", "测试普通店铺"}, + } { + seedShopeeProduct(t, db, product.id, product.id) + if _, err := db.Exec(`UPDATE shopee_products SET shopee_shop_name=? WHERE goods_id=?`, product.shop, product.id); err != nil { + t.Fatal(err) + } + } + + for _, keyword := range []string{"%", "_", "!", "%店_"} { + result, err := ListShopeeProducts(db, repository.ShopeeFilter{ShopName: keyword}, 1) + if err != nil { + t.Fatal(err) + } + if result.Total != 1 || len(result.Rows) != 1 || result.Rows[0].GoodsID != "SPECIAL" { + t.Fatalf("店铺搜索 %q 得到 %+v,总数 %d,期望只匹配 SPECIAL", keyword, result.Rows, result.Total) + } + if !result.IsFiltered { + t.Fatal("只使用店铺名称时 IsFiltered 应为 true") + } + } +} + func TestParseShopeePresence_三态参数(t *testing.T) { for _, value := range []string{"has", "missing"} { if got := ParseShopeePresence(value); got != value { diff --git a/admin/static/css/app.css b/admin/static/css/app.css index fef43fe..c6cc24d 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -112,6 +112,9 @@ body { 用 flex 而不是 width:工具条是 flex 布局,width 会被 flex-grow 盖掉。 min-width 由下面那条继承,窄屏时不会缩到没法输入。 */ .toolbar input[type="text"].search-narrow { flex: 0 0 30%; } +/* 蝦皮工具条同时放商品和店铺两个搜索框,各占剩余工具条的约 20%。 + min-width 继续由通用输入框规则提供,窄窗口时交给工具条自然换行。 */ +.toolbar input[type="text"].shopee-search-field { flex: 0 1 20%; } .toolbar input[type="text"] { flex: 1; diff --git a/admin/static/js/app.js b/admin/static/js/app.js index ae704ac..0ade5af 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -17,7 +17,7 @@ 都不进入 sessionStorage。链接原始 href 保持模块根地址,因此脚本失效时 仍可正常导航。 */ var MODULE_QUERY_KEYS = { - "/shopee": ["goods_id", "status", "shop", "image", "page"], + "/shopee": ["goods_id", "shop_name", "status", "shop", "image", "page"], "/pdd": ["q", "status", "page"], "/syb": ["order_no", "shop", "stage", "page", "date_from", "date_to"], "/tasks": ["type", "status", "creator", "q", "page"], diff --git a/admin/templates/shopee/detail_modal.html b/admin/templates/shopee/detail_modal.html index 4c0c993..abdfb35 100644 --- a/admin/templates/shopee/detail_modal.html +++ b/admin/templates/shopee/detail_modal.html @@ -22,6 +22,7 @@ + @@ -45,6 +46,7 @@ + diff --git a/admin/templates/shopee/list.html b/admin/templates/shopee/list.html index 4becdc9..d9ea833 100644 --- a/admin/templates/shopee/list.html +++ b/admin/templates/shopee/list.html @@ -26,8 +26,10 @@ {{end}} - - + + + + @@ -35,6 +37,7 @@ data-confirm-delete> + diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index fc51659..05b0849 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -136,7 +136,7 @@ ### 4.1 工具条 ```text -[导入记录] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品ID [________] [搜索] [删除] +[导入记录] 状态[全部▾] 店铺[全部▾] 图片[全部▾] 商品 ID / 名称 [____] 店铺名称 [____] [搜索] [删除] ``` - **导入记录**:管理员进入统一商品目录导入记录页;采购员无系统级审计入口。 @@ -158,6 +158,9 @@ - `[必须]` 筛选与关键词搜索可以叠加,翻页时都要保留,见 §3.2。 - **店铺筛选**(工单 #150):全部 / 有店铺 / 无店铺;按 `shopee_shop_name` 去除首尾空白后的非空/空判断。 +- **店铺名称搜索**(工单 #182):独立的 `shop_name` 包含匹配,和店铺三态筛选 + 同时保留;商品 ID / 名称与店铺名称两个输入框各使用 20% 弹性基础宽度,最小宽度 + 160px,空间不足时随工具条换行,不影响其他模块的搜索框宽度。 - **图片筛选**(工单 #150):全部 / 有图片 / 无图片;按 `image_url` 去除首尾空白后的非空/空判断。 - `[必须]` 店铺、图片使用独立三态下拉框,不使用含义不清的复选框;两个条件可互相