2026-08-08 11:40:07 +08:00
|
|
|
|
// 分页的通用规则。
|
|
|
|
|
|
//
|
|
|
|
|
|
// `[必须]` 这是全项目第一个做分页的页面(蝦皮数据页,工单 #43),
|
|
|
|
|
|
// 这里定的规则和常量供后面四个页面照抄,不要各写一份——分开写的话,
|
|
|
|
|
|
// 每页条数、越界兜底这些规则五个页面迟早会不一致。
|
|
|
|
|
|
package service
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-08-15 15:15:38 +08:00
|
|
|
|
"net/url"
|
|
|
|
|
|
"slices"
|
2026-08-08 11:40:07 +08:00
|
|
|
|
"strconv"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-15 15:15:38 +08:00
|
|
|
|
// DefaultPageSize 是主列表没有指定 page_size 时使用的每页条数。
|
2026-08-08 11:40:07 +08:00
|
|
|
|
//
|
2026-08-15 15:56:16 +08:00
|
|
|
|
// 采购员通常需要在同一页批量勾选较多记录,因此默认显示白名单中的最大值 100;
|
|
|
|
|
|
// 实际页面请求仍必须通过 ParsePageSize 白名单解析,不能把任意输入交给 SQL。
|
|
|
|
|
|
const DefaultPageSize = 100
|
2026-08-15 15:15:38 +08:00
|
|
|
|
|
|
|
|
|
|
var allowedPageSizes = [...]int{20, 50, 100}
|
2026-08-08 11:40:07 +08:00
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 15:15:38 +08:00
|
|
|
|
// ParsePageSize 解析 URL 上的 page_size,只接受界面提供的三个白名单值。
|
2026-08-15 15:56:16 +08:00
|
|
|
|
// 非法值回退为默认 100;SQL 查询仍只可能使用固定白名单值。
|
2026-08-15 15:15:38 +08:00
|
|
|
|
func ParsePageSize(s string) int {
|
|
|
|
|
|
n, err := strconv.Atoi(strings.TrimSpace(s))
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return DefaultPageSize
|
|
|
|
|
|
}
|
|
|
|
|
|
return NormalizePageSize(n)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NormalizePageSize 校验代码内部传入的页容量,规则和 URL 参数完全一致。
|
|
|
|
|
|
func NormalizePageSize(n int) int {
|
|
|
|
|
|
for _, allowed := range allowedPageSizes {
|
|
|
|
|
|
if n == allowed {
|
|
|
|
|
|
return n
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return DefaultPageSize
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// TotalPages 按实际 pageSize 把总数换算成总页数。
|
2026-08-08 11:40:07 +08:00
|
|
|
|
//
|
|
|
|
|
|
// `[必须]` 总数为 0 时返回 1,不是 0——页面要显示"第 1/1 页",
|
|
|
|
|
|
// 不能出现"第 1/0 页"(工单 #43)。
|
2026-08-15 15:15:38 +08:00
|
|
|
|
func TotalPages(total, pageSize int) int {
|
2026-08-08 11:40:07 +08:00
|
|
|
|
if total <= 0 {
|
|
|
|
|
|
return 1
|
|
|
|
|
|
}
|
2026-08-15 15:15:38 +08:00
|
|
|
|
pageSize = NormalizePageSize(pageSize)
|
|
|
|
|
|
return (total + pageSize - 1) / pageSize
|
2026-08-08 11:40:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-08-15 15:15:38 +08:00
|
|
|
|
Page int
|
|
|
|
|
|
PageSize int
|
|
|
|
|
|
TotalPages int
|
|
|
|
|
|
HasPrev bool
|
|
|
|
|
|
HasNext bool
|
|
|
|
|
|
FirstURL string
|
|
|
|
|
|
PrevURL string
|
|
|
|
|
|
NextURL string
|
|
|
|
|
|
LastURL string
|
|
|
|
|
|
PageSizeOptions []PageSizeOption
|
|
|
|
|
|
FilterFields []PaginationFilterField
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PageSizeOption 是“每页条数”下拉框的一项。
|
|
|
|
|
|
type PageSizeOption struct {
|
|
|
|
|
|
Value int
|
|
|
|
|
|
Selected bool
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PaginationFilterField 是切换每页条数时需要保留的一个 GET 筛选参数。
|
|
|
|
|
|
// page 和 page_size 不放在这里:切换容量必须从第 1 页重新开始。
|
|
|
|
|
|
type PaginationFilterField struct {
|
|
|
|
|
|
Name string
|
|
|
|
|
|
Value string
|
2026-08-08 11:40:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// NewPaginationView 根据当前页、总页数和筛选查询串组装分页控件视图。
|
|
|
|
|
|
//
|
2026-08-15 15:15:38 +08:00
|
|
|
|
// baseQuery 是当前筛选查询串,由调用方(handler)负责把有效筛选编码进去;
|
|
|
|
|
|
// 本函数会覆盖 page_size 并移除 page,避免旧参数污染生成的链接。
|
|
|
|
|
|
func NewPaginationView(page, pageSize, totalPages int, baseQuery string) PaginationView {
|
|
|
|
|
|
pageSize = NormalizePageSize(pageSize)
|
2026-08-08 11:40:07 +08:00
|
|
|
|
v := PaginationView{
|
|
|
|
|
|
Page: page,
|
2026-08-15 15:15:38 +08:00
|
|
|
|
PageSize: pageSize,
|
2026-08-08 11:40:07 +08:00
|
|
|
|
TotalPages: totalPages,
|
|
|
|
|
|
HasPrev: page > 1,
|
|
|
|
|
|
HasNext: page < totalPages,
|
|
|
|
|
|
}
|
2026-08-15 15:15:38 +08:00
|
|
|
|
query, err := url.ParseQuery(baseQuery)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
query = url.Values{}
|
|
|
|
|
|
}
|
|
|
|
|
|
query.Del("page")
|
|
|
|
|
|
query.Set("page_size", strconv.Itoa(pageSize))
|
|
|
|
|
|
encodedQuery := query.Encode()
|
|
|
|
|
|
v.FirstURL = PaginationURL(encodedQuery, 1)
|
|
|
|
|
|
v.LastURL = PaginationURL(encodedQuery, totalPages)
|
2026-08-08 11:40:07 +08:00
|
|
|
|
if v.HasPrev {
|
2026-08-15 15:15:38 +08:00
|
|
|
|
v.PrevURL = PaginationURL(encodedQuery, page-1)
|
2026-08-08 11:40:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
if v.HasNext {
|
2026-08-15 15:15:38 +08:00
|
|
|
|
v.NextURL = PaginationURL(encodedQuery, page+1)
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, size := range allowedPageSizes {
|
|
|
|
|
|
v.PageSizeOptions = append(v.PageSizeOptions, PageSizeOption{Value: size, Selected: size == pageSize})
|
|
|
|
|
|
}
|
|
|
|
|
|
query.Del("page_size")
|
|
|
|
|
|
for _, name := range sortedQueryKeys(query) {
|
|
|
|
|
|
for _, value := range query[name] {
|
|
|
|
|
|
v.FilterFields = append(v.FilterFields, PaginationFilterField{Name: name, Value: value})
|
|
|
|
|
|
}
|
2026-08-08 11:40:07 +08:00
|
|
|
|
}
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-15 15:15:38 +08:00
|
|
|
|
func sortedQueryKeys(values url.Values) []string {
|
|
|
|
|
|
keys := make([]string, 0, len(values))
|
|
|
|
|
|
for key := range values {
|
|
|
|
|
|
keys = append(keys, key)
|
|
|
|
|
|
}
|
|
|
|
|
|
slices.Sort(keys)
|
|
|
|
|
|
return keys
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-08 11:40:07 +08:00
|
|
|
|
// PaginationURL 把筛选查询串和目标页码拼成一个完整的相对链接("?...")。
|
|
|
|
|
|
func PaginationURL(baseQuery string, page int) string {
|
|
|
|
|
|
q := "page=" + strconv.Itoa(page)
|
|
|
|
|
|
if baseQuery != "" {
|
|
|
|
|
|
q = baseQuery + "&" + q
|
|
|
|
|
|
}
|
|
|
|
|
|
return "?" + q
|
|
|
|
|
|
}
|