feat: 支持主列表选择每页条数 (#242)
This commit is contained in:
+90
-23
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user