174 lines
5.5 KiB
Go
174 lines
5.5 KiB
Go
// 分页的通用规则。
|
||
//
|
||
// `[必须]` 这是全项目第一个做分页的页面(蝦皮数据页,工单 #43),
|
||
// 这里定的规则和常量供后面四个页面照抄,不要各写一份——分开写的话,
|
||
// 每页条数、越界兜底这些规则五个页面迟早会不一致。
|
||
package service
|
||
|
||
import (
|
||
"net/url"
|
||
"slices"
|
||
"strconv"
|
||
"strings"
|
||
)
|
||
|
||
// DefaultPageSize 是主列表没有指定 page_size 时使用的每页条数。
|
||
//
|
||
// 采购员通常需要在同一页批量勾选较多记录,因此默认显示白名单中的最大值 100;
|
||
// 实际页面请求仍必须通过 ParsePageSize 白名单解析,不能把任意输入交给 SQL。
|
||
const DefaultPageSize = 100
|
||
|
||
var allowedPageSizes = [...]int{20, 50, 100}
|
||
|
||
// 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
|
||
}
|
||
|
||
// ParsePageSize 解析 URL 上的 page_size,只接受界面提供的三个白名单值。
|
||
// 非法值回退为默认 100;SQL 查询仍只可能使用固定白名单值。
|
||
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 把总数换算成总页数。
|
||
//
|
||
// `[必须]` 总数为 0 时返回 1,不是 0——页面要显示"第 1/1 页",
|
||
// 不能出现"第 1/0 页"(工单 #43)。
|
||
func TotalPages(total, pageSize int) int {
|
||
if total <= 0 {
|
||
return 1
|
||
}
|
||
pageSize = NormalizePageSize(pageSize)
|
||
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
|
||
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
|
||
}
|
||
|
||
// NewPaginationView 根据当前页、总页数和筛选查询串组装分页控件视图。
|
||
//
|
||
// baseQuery 是当前筛选查询串,由调用方(handler)负责把有效筛选编码进去;
|
||
// 本函数会覆盖 page_size 并移除 page,避免旧参数污染生成的链接。
|
||
func NewPaginationView(page, pageSize, totalPages int, baseQuery string) PaginationView {
|
||
pageSize = NormalizePageSize(pageSize)
|
||
v := PaginationView{
|
||
Page: page,
|
||
PageSize: pageSize,
|
||
TotalPages: totalPages,
|
||
HasPrev: page > 1,
|
||
HasNext: page < totalPages,
|
||
}
|
||
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)
|
||
if v.HasPrev {
|
||
v.PrevURL = PaginationURL(encodedQuery, page-1)
|
||
}
|
||
if v.HasNext {
|
||
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})
|
||
}
|
||
}
|
||
return v
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// PaginationURL 把筛选查询串和目标页码拼成一个完整的相对链接("?...")。
|
||
func PaginationURL(baseQuery string, page int) string {
|
||
q := "page=" + strconv.Itoa(page)
|
||
if baseQuery != "" {
|
||
q = baseQuery + "&" + q
|
||
}
|
||
return "?" + q
|
||
}
|