feat: 支持主列表选择每页条数 (#242)

This commit is contained in:
chengma
2026-08-15 15:15:38 +08:00
parent 8c09023434
commit b4558537d5
37 changed files with 479 additions and 160 deletions
+90 -23
View File
@@ -6,15 +6,19 @@
package service
import (
"net/url"
"slices"
"strconv"
"strings"
)
// PageSize 是全项目统一的每页条数。
// DefaultPageSize 是主列表没有指定 page_size 时使用的每页条数。
//
// `[必须]` 固定 20:见 docs/admin/05-ui-specification.md §3.2——
// 20 行在 1366×768 上正好一屏不用滚动。
const PageSize = 20
// 20 行在 1366×768 上通常不需要滚动,因此继续作为默认值;实际页面请求必须
// 通过 ParsePageSize 白名单解析。
const DefaultPageSize = 20
var allowedPageSizes = [...]int{20, 50, 100}
// ParsePage 解析 URL 上的 page 查询参数。
//
@@ -29,15 +33,36 @@ func ParsePage(s string) int {
return n
}
// TotalPages 按 PageSize 把总数换算成总页数。
// ParsePageSize 解析 URL 上的 page_size,只接受界面提供的三个白名单值。
// 非法值回退为 20,避免有人手改 URL 造成一次读取过多数据。
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 int) int {
func TotalPages(total, pageSize int) int {
if total <= 0 {
return 1
}
return (total + PageSize - 1) / PageSize
pageSize = NormalizePageSize(pageSize)
return (total + pageSize - 1) / pageSize
}
// ClampPage 把请求的页码限制在 [1, totalPages] 范围内。
@@ -63,39 +88,81 @@ func ClampPage(page, totalPages int) int {
// 会把动态内容当成单个参数值整体转义,问号和等号会被转成 %3F/%3D,
// 链接直接失效——这是本工单实测踩到的一个坑,见 pagination_test.go。
type PaginationView struct {
Page int
TotalPages int
HasPrev bool
HasNext bool
FirstURL string
PrevURL string
NextURL string
LastURL string
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 是不含 page 的查询串(例如 url.Values{"status": {"no_link"}}.Encode()
// 的结果),由调用方(handler)负责把当前筛选和关键词编码进去——
// 这样翻页时筛选和关键词才不会丢,见工单 #43。
func NewPaginationView(page, totalPages int, baseQuery string) PaginationView {
// 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,
FirstURL: PaginationURL(baseQuery, 1),
LastURL: PaginationURL(baseQuery, 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(baseQuery, page-1)
v.PrevURL = PaginationURL(encodedQuery, page-1)
}
if v.HasNext {
v.NextURL = PaginationURL(baseQuery, page+1)
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)