feat: 收敛店铺管理和蝦皮分类搜索 (#212 #213)

This commit is contained in:
chengma
2026-08-13 11:47:56 +08:00
parent 1abed648a5
commit 313e9f9151
22 changed files with 389 additions and 464 deletions
+5 -9
View File
@@ -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"}, "list_shop_name": {"店铺 A"}, "status": {"no_link"}, "shop": {"has"}, "image": {"missing"}, "page": {"2"},
"q": {"店铺 A"}, "search_field": {"shop_name"}, "status": {"no_link"}, "unlinked": {"1"}, "page": {"2"},
})
(&Handler{}).shopeeRedirect(context, "完成")
assertRedirectQuery(t, recorder, "/shopee", map[string]string{
"goods_id": "1001", "shop_name": "店铺 A", "status": "no_link", "shop": "has", "image": "missing", "page": "2", "msg": "完成",
"q": "店铺 A", "search_field": "shop_name", "status": "no_link", "unlinked": "1", "page": "2", "msg": "完成",
})
})
t.Run("顺运宝", func(t *testing.T) {
@@ -98,20 +98,16 @@ func Test列表写操作跳转保留筛选和页码(t *testing.T) {
})
}
func Test蝦皮分页详情和状态条保留资料筛选(t *testing.T) {
func Test蝦皮分页详情和状态条保留分类搜索(t *testing.T) {
values := url.Values{
"goods_id": {"1001"},
"shop_name": {"店铺 A"},
"status": {"has_link"},
"shop": {"has"},
"image": {"missing"},
"q": {"店铺 A"}, "search_field": {"shop_name"}, "status": {"has_link"}, "unlinked": {"1"},
}
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", "shop_name": "店铺 A", "status": "has_link", "shop": "has", "image": "missing", "page": "3",
"q": "店铺 A", "search_field": "shop_name", "status": "has_link", "unlinked": "1", "page": "3",
} {
if got := detail.Query().Get(key); got != want {
t.Errorf("详情参数 %s=%q,期望 %q", key, got, want)
+6 -25
View File
@@ -23,8 +23,7 @@ func (h *Handler) ShopList(c *gin.Context) {
}
func (h *Handler) ShopCreate(c *gin.Context) {
err := service.CreateShop(h.db, currentUser(c), c.PostForm("display_name"),
c.PostForm("syb_alias"), c.PostForm("shopee_alias"), time.Now())
err := service.CreateShop(h.db, currentUser(c), c.PostForm("shop_name"), time.Now())
if err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
@@ -33,12 +32,11 @@ func (h *Handler) ShopCreate(c *gin.Context) {
fail(c, http.StatusInternalServerError, "新增店铺失败,已有数据没有被改动。")
return
}
redirectShops(c, "店铺已新增;历史数据已按渠道原始名称精确关联", "")
redirectShops(c, "店铺已新增;历史数据已按店铺名称精确关联", "")
}
func (h *Handler) ShopUpdate(c *gin.Context) {
err := service.UpdateShop(h.db, currentUser(c), c.PostForm("shop_id"), c.PostForm("display_name"),
c.PostForm("syb_alias"), c.PostForm("shopee_alias"), time.Now())
err := service.UpdateShop(h.db, currentUser(c), c.PostForm("shop_id"), c.PostForm("shop_name"), time.Now())
if err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
@@ -47,7 +45,7 @@ func (h *Handler) ShopUpdate(c *gin.Context) {
fail(c, http.StatusInternalServerError, "保存店铺失败,原配置保持不变。")
return
}
redirectShops(c, "店铺名称和渠道关联已保存", "")
redirectShops(c, "店铺名称已保存,历史数据关联已重建", "")
}
func (h *Handler) ShopStatus(c *gin.Context) {
@@ -60,26 +58,9 @@ func (h *Handler) ShopStatus(c *gin.Context) {
fail(c, http.StatusInternalServerError, "更新店铺状态失败,原状态保持不变。")
return
}
message := "业务店铺已停用;SYB 不再同步该店铺"
message := "店铺已停用;SYB 不再同步该店铺"
if enabled {
message = "业务店铺已启用"
}
redirectShops(c, message, "")
}
func (h *Handler) ShopSybStatus(c *gin.Context) {
enabled := c.PostForm("enabled") == "1"
if err := service.SetShopSybEnabled(h.db, currentUser(c), c.PostForm("shop_id"), enabled, time.Now()); err != nil {
if service.IsValidationError(err) {
redirectShops(c, "", err.Error())
return
}
fail(c, http.StatusInternalServerError, "更新 SYB 同步状态失败,原状态保持不变。")
return
}
message := "SYB 同步已停用"
if enabled {
message = "SYB 同步已启用"
message = "店铺已启用"
}
redirectShops(c, message, "")
}
+41 -69
View File
@@ -18,7 +18,8 @@ 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("shop_name"), c.Query("status"), c.Query("shop"), c.Query("image"), c.Query("store"), c.Query("deleted"), c.Query("page"), c.Query("msg"))
h.renderShopeeList(c, c.Query("q"), c.Query("search_field"), c.Query("status"),
c.Query("deleted"), c.Query("unlinked"), c.Query("page"), c.Query("msg"))
}
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
@@ -45,27 +46,24 @@ 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"),
"ShopNameKeyword": strings.TrimSpace(c.Query("shop_name")),
"ShopFilter": service.ParseShopeePresence(c.Query("shop")),
"ImageFilter": service.ParseShopeePresence(c.Query("image")),
"StoreFilter": strings.TrimSpace(c.Query("store")),
"CurrentPage": service.ParsePage(c.Query("page")),
"Keyword": c.Query("q"), "SearchField": service.ParseShopeeSearchField(c.Query("search_field")),
"StatusFilter": service.ParseShopeeStatus(c.Query("status")),
"DeletedFilter": currentUser(c).IsAdmin() && c.Query("deleted") == "1",
"UnlinkedFilter": c.Query("unlinked") == "1",
"CurrentPage": service.ParsePage(c.Query("page")),
})
}
// renderShopeeList 统一渲染蝦皮列表;数据写入改由商品目录接口负责。
//
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留关键词和三个下拉筛选,
// 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#150)。
func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw, shopRaw, imageRaw, storeRaw, deletedRaw, pageRaw, msg string) {
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留状态、分类、关键词和内部范围,
// 所以这里把归一后的有效值透传回模板去拼下一页的链接(工单 #43、#213)。
func (h *Handler) renderShopeeList(c *gin.Context, keyword, searchFieldRaw, statusRaw, deletedRaw, unlinkedRaw, pageRaw, msg string) {
filter := repository.ShopeeFilter{
Keyword: keyword,
ShopName: strings.TrimSpace(shopName),
Status: service.ParseShopeeStatus(statusRaw),
Shop: service.ParseShopeePresence(shopRaw),
Image: service.ParseShopeePresence(imageRaw),
StoreID: strings.TrimSpace(storeRaw),
Keyword: strings.TrimSpace(keyword),
SearchField: service.ParseShopeeSearchField(searchFieldRaw),
Status: service.ParseShopeeStatus(statusRaw),
Unlinked: unlinkedRaw == "1",
}
if currentUser(c).IsAdmin() && deletedRaw == "1" {
filter.Deleted = true
@@ -86,60 +84,42 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, shopName, statusRaw,
"读取可选客户端失败,数据没有被改动。刷新页面重试。")
return
}
shopOptions, err := service.ShopOptions(h.db)
if err != nil {
fail(c, http.StatusInternalServerError, "读取业务店铺失败,数据没有被改动。")
return
}
status := msg
if status == "" {
status = shopeeStatusLine(filter.Status, result)
}
values := url.Values{}
if keyword != "" {
values.Set("goods_id", keyword)
}
if filter.ShopName != "" {
values.Set("shop_name", filter.ShopName)
if filter.Keyword != "" {
values.Set("q", filter.Keyword)
}
values.Set("search_field", filter.SearchField)
if filter.Status != "" {
values.Set("status", filter.Status)
}
if filter.Shop != "" {
values.Set("shop", filter.Shop)
}
if filter.Image != "" {
values.Set("image", filter.Image)
}
if filter.StoreID != "" {
values.Set("store", filter.StoreID)
}
if filter.Deleted {
values.Set("deleted", "1")
}
if filter.Unlinked {
values.Set("unlinked", "1")
}
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
"Keyword": keyword,
"ShopNameKeyword": filter.ShopName,
"StatusFilter": filter.Status,
"StatusOptions": service.ShopeeStatusOptions(),
"ShopFilter": filter.Shop,
"ShopOptions": service.ShopeePresenceOptions("有店铺", "无店铺"),
"ImageFilter": filter.Image,
"ImageOptions": service.ShopeePresenceOptions("有图片", "无图片"),
"StoreFilter": filter.StoreID,
"StoreOptions": shopOptions,
"DeletedFilter": filter.Deleted,
"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),
"AssignableClients": assignableClients,
"Keyword": filter.Keyword,
"SearchField": filter.SearchField,
"SearchFieldOptions": service.ShopeeSearchFieldOptions(),
"StatusFilter": filter.Status,
"StatusOptions": service.ShopeeStatusOptions(),
"DeletedFilter": filter.Deleted,
"UnlinkedFilter": filter.Unlinked,
"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),
"AssignableClients": assignableClients,
}))
}
@@ -250,27 +230,19 @@ func (h *Handler) ShopeeCollectBatch(c *gin.Context) {
func (h *Handler) shopeeRedirect(c *gin.Context, msg string) {
params := url.Values{}
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("q")); value != "" {
params.Set("q", value)
}
params.Set("search_field", service.ParseShopeeSearchField(c.PostForm("search_field")))
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("store")); value != "" {
params.Set("store", value)
}
if currentUser(c).IsAdmin() && c.PostForm("deleted") == "1" {
params.Set("deleted", "1")
}
if c.PostForm("unlinked") == "1" {
params.Set("unlinked", "1")
}
if value := strings.TrimSpace(c.PostForm("page")); value != "" {
params.Set("page", value)
}
-1
View File
@@ -94,7 +94,6 @@ func Register(r *gin.Engine, db *sql.DB, onlineThreshold time.Duration) {
shops.POST("/create", h.ShopCreate)
shops.POST("/update", h.ShopUpdate)
shops.POST("/status", h.ShopStatus)
shops.POST("/syb-status", h.ShopSybStatus)
shops.POST("/delete", h.ShopDelete)
// 4. 采集采购