feat: 增加蝦皮店铺图片筛选 (#150)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 统一渲染蝦皮列表;数据写入改由商品目录接口负责。
|
||||
//
|
||||
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留 keyword/statusRaw,
|
||||
// 所以这里把它们原样透传回模板去拼下一页的链接,不在这里丢掉(工单 #43)。
|
||||
func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw, msg string) {
|
||||
// `[必须]` 分页控件用 <a href> 纯 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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
`<label for="shop">店铺</label>`, `name="shop"`, `.ShopOptions`, `.ShopFilter`,
|
||||
`<label for="image">图片</label>`, `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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 是弹窗规格表的一行。
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@
|
||||
<input type="hidden" name="shopee_goods_id" value="{{.GoodsID}}">
|
||||
<input type="hidden" name="list_goods_id" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="status" value="{{$.StatusFilter}}">
|
||||
<input type="hidden" name="shop" value="{{$.ShopFilter}}">
|
||||
<input type="hidden" name="image" value="{{$.ImageFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
<div class="field">
|
||||
<label for="shopee-pdd-url">PDD 商品链接</label>
|
||||
@@ -44,6 +46,8 @@
|
||||
<input type="hidden" name="shopee_goods_id" value="{{.GoodsID}}">
|
||||
<input type="hidden" name="list_goods_id" value="{{$.Keyword}}">
|
||||
<input type="hidden" name="status" value="{{$.StatusFilter}}">
|
||||
<input type="hidden" name="shop" value="{{$.ShopFilter}}">
|
||||
<input type="hidden" name="image" value="{{$.ImageFilter}}">
|
||||
<input type="hidden" name="page" value="{{$.CurrentPage}}">
|
||||
{{if .CanCollect}}
|
||||
<button type="submit">{{if eq .CollectStatus "failed"}}重新创建采集任务{{else}}创建采集任务{{end}}</button>
|
||||
|
||||
@@ -14,6 +14,18 @@
|
||||
<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<label for="shop">店铺</label>
|
||||
<select id="shop" name="shop">
|
||||
{{range .ShopOptions}}
|
||||
<option value="{{.Value}}" {{if eq .Value $.ShopFilter}}selected{{end}}>{{.Text}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<label for="image">图片</label>
|
||||
<select id="image" name="image">
|
||||
{{range .ImageOptions}}
|
||||
<option value="{{.Value}}" {{if eq .Value $.ImageFilter}}selected{{end}}>{{.Text}}</option>
|
||||
{{end}}
|
||||
</select>
|
||||
<label for="q">商品 ID</label>
|
||||
<input id="q" type="text" name="goods_id" value="{{.Keyword}}" placeholder="商品 ID">
|
||||
<button type="submit">搜索</button>
|
||||
@@ -22,6 +34,11 @@
|
||||
<form class="inline" method="post" action="/shopee/delete"
|
||||
data-confirm-delete>
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
<input type="hidden" name="list_goods_id" value="{{.Keyword}}">
|
||||
<input type="hidden" name="status" value="{{.StatusFilter}}">
|
||||
<input type="hidden" name="shop" value="{{.ShopFilter}}">
|
||||
<input type="hidden" name="image" value="{{.ImageFilter}}">
|
||||
<input type="hidden" name="page" value="{{.CurrentPage}}">
|
||||
<button type="submit" class="danger" data-need-checked>删除</button>
|
||||
</form>
|
||||
</div>
|
||||
@@ -78,7 +95,8 @@
|
||||
<tr class="empty">
|
||||
<td colspan="13">
|
||||
{{if .IsFiltered}}
|
||||
没有匹配的数据,换个商品 ID 或商品名称试试。
|
||||
当前筛选条件下没有商品。请调整状态、店铺、图片或商品 ID。<br>
|
||||
<small><a href="/shopee">清除筛选条件</a></small>
|
||||
{{else}}
|
||||
还没有数据。蝦皮与 PDD 对应数据由第三方脚本通过商品目录接口导入。<br>
|
||||
{{if $.CurrentUser.IsAdmin}}<small>可点击左上角「导入记录」查看脚本提交结果。</small>{{end}}
|
||||
|
||||
@@ -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 表格列
|
||||
|
||||
|
||||
Reference in New Issue
Block a user