导入真实样本后 /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>
107 lines
3.5 KiB
Go
107 lines
3.5 KiB
Go
// 分页的通用规则。
|
||
//
|
||
// `[必须]` 这是全项目第一个做分页的页面(蝦皮数据页,工单 #43),
|
||
// 这里定的规则和常量供后面四个页面照抄,不要各写一份——分开写的话,
|
||
// 每页条数、越界兜底这些规则五个页面迟早会不一致。
|
||
package service
|
||
|
||
import (
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// PageSize 是全项目统一的每页条数。
|
||
//
|
||
// `[必须]` 固定 20:见 docs/admin/05-ui-specification.md §3.2——
|
||
// 20 行在 1366×768 上正好一屏不用滚动。
|
||
const PageSize = 20
|
||
|
||
// ParsePage 解析 URL 上的 page 查询参数。
|
||
//
|
||
// `[必须]` 认不出来的一律当作第 1 页,不报错——用户手改地址栏或者用旧书签
|
||
// 不该把页面搞崩(工单 #43)。真正的越界(超过总页数)由 ClampPage 兜底,
|
||
// 这里只处理"不是正整数"的情况。
|
||
func ParsePage(s string) int {
|
||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||
if err != nil || n < 1 {
|
||
return 1
|
||
}
|
||
return n
|
||
}
|
||
|
||
// TotalPages 按 PageSize 把总数换算成总页数。
|
||
//
|
||
// `[必须]` 总数为 0 时返回 1,不是 0——页面要显示"第 1/1 页",
|
||
// 不能出现"第 1/0 页"(工单 #43)。
|
||
func TotalPages(total int) int {
|
||
if total <= 0 {
|
||
return 1
|
||
}
|
||
return (total + PageSize - 1) / PageSize
|
||
}
|
||
|
||
// ClampPage 把请求的页码限制在 [1, totalPages] 范围内。
|
||
//
|
||
// `[必须]` page 超过总页数时兜到最后一页(显示有数据的那一页),
|
||
// 不是显示空表格——操作员看到空表格会以为数据没了(工单 #43)。
|
||
func ClampPage(page, totalPages int) int {
|
||
if page < 1 {
|
||
return 1
|
||
}
|
||
if page > totalPages {
|
||
return totalPages
|
||
}
|
||
return page
|
||
}
|
||
|
||
// PaginationView 是分页控件要显示的全部内容,模板不做判断和算术。
|
||
//
|
||
// `[必须]` FirstURL/PrevURL/NextURL/LastURL 是**整段拼好的**相对链接
|
||
// (形如 "?status=no_link&page=3"),模板里必须整体作为单个 pipeline 输出
|
||
// (`<a href="{{.FirstURL}}">`),不要在模板里把筛选参数和 page 分开拼接。
|
||
// html/template 的上下文转义规则对"字面量 & 中间插一段动态内容"的情况,
|
||
// 会把动态内容当成单个参数值整体转义,问号和等号会被转成 %3F/%3D,
|
||
// 链接直接失效——这是本工单实测踩到的一个坑,见 pagination_test.go。
|
||
type PaginationView struct {
|
||
Page int
|
||
TotalPages int
|
||
HasPrev bool
|
||
HasNext bool
|
||
FirstURL string
|
||
PrevURL string
|
||
NextURL string
|
||
LastURL string
|
||
}
|
||
|
||
// NewPaginationView 根据当前页、总页数和筛选查询串组装分页控件视图。
|
||
//
|
||
// baseQuery 是不含 page 的查询串(例如 url.Values{"status": {"no_link"}}.Encode()
|
||
// 的结果),由调用方(handler)负责把当前筛选和关键词编码进去——
|
||
// 这样翻页时筛选和关键词才不会丢,见工单 #43。
|
||
func NewPaginationView(page, totalPages int, baseQuery string) PaginationView {
|
||
v := PaginationView{
|
||
Page: page,
|
||
TotalPages: totalPages,
|
||
HasPrev: page > 1,
|
||
HasNext: page < totalPages,
|
||
FirstURL: PaginationURL(baseQuery, 1),
|
||
LastURL: PaginationURL(baseQuery, totalPages),
|
||
}
|
||
if v.HasPrev {
|
||
v.PrevURL = PaginationURL(baseQuery, page-1)
|
||
}
|
||
if v.HasNext {
|
||
v.NextURL = PaginationURL(baseQuery, page+1)
|
||
}
|
||
return v
|
||
}
|
||
|
||
// PaginationURL 把筛选查询串和目标页码拼成一个完整的相对链接("?...")。
|
||
func PaginationURL(baseQuery string, page int) string {
|
||
q := "page=" + strconv.Itoa(page)
|
||
if baseQuery != "" {
|
||
q = baseQuery + "&" + q
|
||
}
|
||
return "?" + q
|
||
}
|