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
+8 -1
View File
@@ -85,7 +85,14 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立
### 4.1 蝦皮数据模块
**顶部工具条:** 导入按钮、商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。
**顶部工具条:** 导入按钮、状态筛选(全部 / 待补规格 / 未填 PDD 链接 / 已填链接)、
商品 ID 搜索框、搜索按钮、删除按钮、批量采集按钮。
`[必须]` 状态筛选是**刚需**,不是锦上添花(工单 #43):本页的主要用途是维护
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID 搜索的话,
操作员得先知道 ID 才能搜——而"哪些商品需要处理"恰恰是不知道 ID 的时候才要问的。
实测样本 5195 个商品里只有 6 个待补规格,只做分页要翻 260 页才能找全,等于找不到。
界面细节和筛选条件的 SQL 见 [05 界面规范](05-ui-specification.md) §4.1。
**中间表格**(按 SKU 展开显示,数据来自商品表和 SKU 表联查):
+72 -2
View File
@@ -47,7 +47,7 @@
见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。
- `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。
- `[建议]` 默认按 `更新时间 DESC` 排序。
- `[建议]` 一页 50 条,超过分页。搜索走数据库,不要一次查出来在内存里过滤。
- `[必须]` 每页 **20** 条,超过分页,见 §3.2。搜索走数据库,不要一次查出来在内存里过滤。
- `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。
- `[必须]` 空状态要分情况,文案不能都是"暂无数据":
- 从没导入过 → "还没有数据,点左上角『导入』开始"
@@ -72,12 +72,50 @@
`[建议]` placeholder 要短。收到 30% 之后长文案显示不全——
真装不下就缩短文案,**不要把宽度调回去**。
### 3.2 分页
`[必须]` 五个模块**统一每页 20 条**(原来这里写的是"建议一页 50 条",
已按工单 #43 改为 20 并升级为 `[必须]`)——20 行在 1366×768 上正好一屏,
不用滚动就能看完;50 条要滚动,操作员容易漏看最下面几行。
蝦皮数据页(§4)是全项目第一个做分页的页面,本节是它定下的通用规则,
**其余四个页面照抄**,不要各写一套:
- `[必须]` 分页用 SQL 的 `LIMIT ? OFFSET ?`,**不得**把全量查出来在 Go 里
切片——这正是蝦皮数据页改之前一次吐 3.4MB HTML 的成因(实测 5195 行)。
- `[必须]` 总数用**单独的 `COUNT(*)`** 查询,并且和列表查询**共用同一套
筛选条件拼装函数**。分开写两份 `WHERE` 迟早会漏改一处,页码跟着算错,
而且不会报错、不容易发现(#19 已经踩过一次)。
- `[必须]` 页码参数是 `?page=N`,从 **1** 开始,不是 0——给操作员看的东西
不要 0-based。
- `[必须]` 越界要兜住:
- `page` 非数字、`0`、负数 → 当作第 1 页;
- `page` 超过总页数 → 显示**最后一页**(有数据的那页),不是空表格;
- 总数为 0 → 显示"第 1/1 页",不出现"第 1/0 页"。
- `[必须]` 底部状态条显示**全量总数**(当前筛选条件下的总数),不是本页
行数——"共 20 个商品"会让操作员以为总共就 20 个。筛选后显示筛选结果的
总数,例如"待补规格:6 个商品 · 第 1/1 页",不是本页凑出来的数字。
- `[必须]` 翻页时**保留当前筛选和关键词**——用查询参数原样带过去,
丢了的话操作员翻到第二页筛选就没了,会以为数据变了。
- `[必须]` 分页控件用 `<a href>`,**不要用 JS**。这是纯 GET 导航,
浏览器的前进后退和书签都该正常工作。
- `[必须]` 第一页时"首页 / 上一页"禁用,最后一页时"下一页 / 末页"禁用;
禁用要有**视觉 + 语义**区分(不可点的元素不要还是 `<a>`),不能只靠颜色。
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
操作员应该靠筛选定位,不是靠翻页数页码。
公共实现放在 `admin/service/pagination.go`(`PageSize` 常量、
`ParsePage` / `ClampPage` / `TotalPages` / `NewPaginationView`)和
`admin/templates/partials/footer.html`(分页控件,`Pagination` 字段
为空时不渲染,不影响还没做分页的页面),后面四个页面直接复用,
不要各写一份。
## 4. 蝦皮数据页
### 4.1 工具条
```text
[导入 Excel] [批量采集] 商品ID [________] [搜索] [删除]
[导入 Excel] [批量采集] 状态[全部▾] 商品ID [________] [搜索] [删除]
```
- **导入 Excel**:选文件 → POST 上传 → 显示结果统计
@@ -85,6 +123,21 @@
`[必须]` 失败行要列出行号和原因,不能静默跳过。
- **批量采集**:勾选若干行 → 服务端**按商品去重**后建采集任务。
`[必须]` 已是"采集中"的商品跳过,并在结果里说明跳过了几个。
- **状态筛选**(`[必须]`,工单 #43):全部 / 待补规格 / 未填 PDD 链接 / 已填链接。
这个筛选是**刚需**,不是锦上添花——本页主要用途是维护
(找出需要补规格的商品、找出还没关联 PDD 链接的商品),只靠商品 ID
搜索的话,操作员得先知道 ID 才能搜,而"哪些商品需要处理"恰恰是
不知道 ID 的时候才要问的。实测样本 5195 个商品里只有 6 个待补规格,
只做分页(260 页)翻不出来。
- 「待补规格」:`EXISTS (SELECT 1 FROM shopee_skus WHERE goods_id = 该商品 AND parse_ok = 0)`。
`[必须]` 用 `EXISTS`,不用 `JOIN` + `DISTINCT`——一个商品有多个失败
SKU 时 `JOIN` 会出重复行,`DISTINCT` 又会让分页的 `LIMIT/OFFSET`
行为难推理。
- 「未填 PDD 链接」:`pdd_goods_url IS NULL OR pdd_goods_url = ''`。
- 「已填链接」:`pdd_goods_url IS NOT NULL AND pdd_goods_url <> ''`。
- `[必须]` 筛选参数认不出来的一律当"全部",不报错——地址栏是用户可以
随便改的。
- `[必须]` 筛选与关键词搜索可以叠加,翻页时都要保留,见 §3.2。
### 4.2 表格列
@@ -145,6 +198,23 @@
新增的行 `is_manual = 1`,`[必须]` 后续导入**不得删除**它们。
### 4.5 底部状态条
```text
共 5195 个商品 · 第 1/260 页 [首页] [上一页] [下一页] [末页]
```
筛选后(例如状态选了"待补规格"):
```text
待补规格:6 个商品 · 第 1/1 页 [首页] [上一页] [下一页] [末页]
```
`[必须]` 分页与状态条的通用规则见 §3.2,不在这里重复。这里只强调
蝦皮页特有的一点:导入完成后状态条**显示导入结果**("导入完成:N 个商品 /
M 个 SKU,K 行解析失败"),覆盖掉正常的统计文案,并且回到第 1 页、清空筛选——
刚导入的数据从第一页就能看到,沿用旧筛选反而可能让人以为导入没生效。
## 5. PDD 商品页
页面 `objectName` 为 `pddProductPage`,路由 `/pdd`。