feat: 蝦皮数据页分页与状态筛选 (#43)
导入真实样本后 /shopee 一次吐 3.4MB / 5195 行。但只加分页会把问题从 「5195 行糊在一起」变成「260 页里藏着 6 个」——那 6 个待补规格的商品 仍然找不到。所以分页和状态筛选一起做。 HTML 3.4MB → 16.7KB。 状态条显示全量而不是本页:「共 5195 个商品 · 第 1/260 页」。 显示「共 20 个商品」会让操作员以为总共就 20 个。筛选后显示筛选结果 总数:「待补规格:6 个商品」。 列表查询和 COUNT 共用同一套筛选条件拼装。分开写两份 WHERE,迟早 有天忘了给 COUNT 也加条件,页码算错而且没人发现(#19 踩过一次)。 page 越界兜到最后一页而不是显示空表格——空表格会让操作员以为数据没了。 总数为 0 时显示「第 1/1 页」,不出现「第 1/0 页」。 「待补规格」用 EXISTS 不用 JOIN+DISTINCT:一个商品有多个失败 SKU 时 JOIN 会出重复行,DISTINCT 又让 LIMIT/OFFSET 的行为难推理。 分页控件是 <a href> 纯 GET,浏览器前进后退和书签都正常。首末页用 <span class="disabled"> 禁用,语义上不再是链接,不只靠颜色区分。 这是全项目第一个分页页面,通用逻辑单独放 service/pagination.go 供 后面四页复用,规则写进 05 §3.2 而不是蝦皮页那一节(#34 踩过这个错)。 05 §3 的每页条数从「建议 50」改为「统一 20」并写明理由。 实现踩到 html/template 的 URL 上下文转义:夹在字面量 & 中间的动态内容 会被整体当成一个参数值转义,?/= 变成 %3F/%3D 让链接失效。改为在 Go 里 把整段 URL 拼好,模板作为单个 pipeline 输出,并加了回归测试。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"cmautobuy/admin/config"
|
||||
"cmautobuy/admin/repository"
|
||||
"cmautobuy/admin/service"
|
||||
)
|
||||
|
||||
@@ -19,7 +21,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("msg"), nil)
|
||||
h.renderShopeeList(c, c.Query("goods_id"), c.Query("status"), c.Query("page"), c.Query("msg"), nil)
|
||||
}
|
||||
|
||||
// ShopeeDetail 渲染双击行弹出的那个弹窗的**内容**(不是整页)。
|
||||
@@ -132,12 +134,25 @@ func (h *Handler) ShopeeImport(c *gin.Context) {
|
||||
if len(result.Failures) > 0 {
|
||||
msg += fmt.Sprintf(",%d 行解析失败(见下方列表)", len(result.Failures))
|
||||
}
|
||||
h.renderShopeeList(c, "", msg, result.Failures)
|
||||
// 导入完成后回到第 1 页、清空筛选——刚导入的数据从第一页就能看到,
|
||||
// 沿用筛选反而可能让人以为导入没生效(筛出来的还是老的几条)。
|
||||
h.renderShopeeList(c, "", "", "", msg, result.Failures)
|
||||
}
|
||||
|
||||
// renderShopeeList 是 ShopeeList 和 ShopeeImport 共用的渲染逻辑。
|
||||
func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures []service.ImportFailure) {
|
||||
result, err := service.ListShopeeProducts(h.db, keyword)
|
||||
//
|
||||
// `[必须]` 分页控件用 <a href> 纯 GET 导航,翻页要保留 keyword/statusRaw,
|
||||
// 所以这里把它们原样透传回模板去拼下一页的链接,不在这里丢掉(工单 #43)。
|
||||
func (h *Handler) renderShopeeList(c *gin.Context, keyword, statusRaw, pageRaw, msg string, failures []service.ImportFailure) {
|
||||
filter := repository.ShopeeFilter{
|
||||
Keyword: keyword,
|
||||
Status: service.ParseShopeeStatus(statusRaw),
|
||||
}
|
||||
// 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数,
|
||||
// 局部变量会把它遮住导致编译失败。
|
||||
pageNum := service.ParsePage(pageRaw)
|
||||
|
||||
result, err := service.ListShopeeProducts(h.db, filter, pageNum)
|
||||
if err != nil {
|
||||
fail(c, http.StatusInternalServerError,
|
||||
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
|
||||
@@ -146,22 +161,43 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, msg string, failures
|
||||
|
||||
status := msg
|
||||
if status == "" {
|
||||
status = fmt.Sprintf("共 %d 个商品", result.Total)
|
||||
if result.IsFiltered {
|
||||
status = fmt.Sprintf("筛选出 %d 条 / %s", len(result.Rows), status)
|
||||
}
|
||||
status = shopeeStatusLine(filter.Status, result)
|
||||
}
|
||||
|
||||
values := url.Values{}
|
||||
if keyword != "" {
|
||||
values.Set("goods_id", keyword)
|
||||
}
|
||||
if filter.Status != "" {
|
||||
values.Set("status", filter.Status)
|
||||
}
|
||||
|
||||
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
|
||||
"Keyword": keyword,
|
||||
"StatusFilter": filter.Status,
|
||||
"StatusOptions": service.ShopeeStatusOptions(),
|
||||
"Rows": result.Rows,
|
||||
"Status": status,
|
||||
"HasAnyProducts": result.Total > 0,
|
||||
"HasAnyProducts": result.HasAnyProducts,
|
||||
"IsFiltered": result.IsFiltered,
|
||||
"Failures": failures,
|
||||
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()),
|
||||
}))
|
||||
}
|
||||
|
||||
// shopeeStatusLine 组装底部状态条的默认文案(没有 msg 覆盖时)。
|
||||
//
|
||||
// `[必须]` 显示的是筛选后的**全量**总数(result.Total),不是本页行数,
|
||||
// 筛选时前缀用状态文字("待补规格:6 个商品"),不筛选时用"共",
|
||||
// 见工单 #43。
|
||||
func shopeeStatusLine(statusFilter string, result *service.ShopeeListResult) string {
|
||||
prefix := fmt.Sprintf("共 %d 个商品", result.Total)
|
||||
if text := service.ShopeeStatusLabel(statusFilter); text != "" {
|
||||
prefix = fmt.Sprintf("%s:%d 个商品", text, result.Total)
|
||||
}
|
||||
return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages)
|
||||
}
|
||||
|
||||
// ShopeeSave 保存编辑弹窗里的内容(PDD 链接、颜色、尺码、建议)。
|
||||
//
|
||||
// 这是**从蝦皮商品出发**录入 PDD 链接的入口,
|
||||
|
||||
Reference in New Issue
Block a user