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:
chengma
2026-08-08 11:40:07 +08:00
co-authored by Claude Opus 5
parent 033bef0614
commit edfe39cc35
11 changed files with 734 additions and 42 deletions
+45 -9
View File
@@ -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 链接的入口,