// 分页的通用规则。 // // `[必须]` 这是全项目第一个做分页的页面(蝦皮数据页,工单 #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 输出 // (``),不要在模板里把筛选参数和 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 }