diff --git a/admin/handler/web/catalog.go b/admin/handler/web/catalog.go index 7e5aec9..ff9e2c6 100644 --- a/admin/handler/web/catalog.go +++ b/admin/handler/web/catalog.go @@ -11,7 +11,8 @@ import ( ) func (h *Handler) CatalogHistory(c *gin.Context) { - result, err := service.ListCatalogHistory(h.db, c.Query("source"), c.Query("status"), service.ParsePage(c.Query("page"))) + pageSize := service.ParsePageSize(c.Query("page_size")) + result, err := service.ListCatalogHistoryWithPageSize(h.db, c.Query("source"), c.Query("status"), service.ParsePage(c.Query("page")), pageSize) if err != nil { fail(c, 500, "读取商品目录导入记录失败,数据没有被改动。请刷新后重试。") return @@ -23,7 +24,7 @@ func (h *Handler) CatalogHistory(c *gin.Context) { if result.Status != "" { values.Set("status", result.Status) } - c.HTML(http.StatusOK, "catalog/list", page(c, "catalog", "商品目录导入记录", gin.H{"Rows": result.Rows, "Sources": result.Sources, "Source": result.Source, "StatusFilter": result.Status, "StatusOptions": service.CatalogStatusOptions(), "Status": fmt.Sprintf("共 %d 条 · 第 %d/%d 页", result.Total, result.Page, result.TotalPages), "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode())})) + c.HTML(http.StatusOK, "catalog/list", page(c, "catalog", "商品目录导入记录", gin.H{"Rows": result.Rows, "Sources": result.Sources, "Source": result.Source, "StatusFilter": result.Status, "StatusOptions": service.CatalogStatusOptions(), "Status": fmt.Sprintf("共 %d 条 · 第 %d/%d 页", result.Total, result.Page, result.TotalPages), "CurrentPageSize": pageSize, "Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode())})) } func (h *Handler) CatalogHistoryDetail(c *gin.Context) { diff --git a/admin/handler/web/inner_code.go b/admin/handler/web/inner_code.go index 21d6fd5..9bab851 100644 --- a/admin/handler/web/inner_code.go +++ b/admin/handler/web/inner_code.go @@ -28,7 +28,9 @@ func (h *Handler) InnerCodeList(c *gin.Context) { } status := strings.TrimSpace(c.Query("status")) keyword := strings.TrimSpace(c.Query("q")) - result, err := service.ListInnerCodePage(h.db, businessDate, status, keyword, service.ParsePage(c.Query("page"))) + pageSize := service.ParsePageSize(c.Query("page_size")) + result, err := service.ListInnerCodePageWithPageSize(h.db, businessDate, status, keyword, + service.ParsePage(c.Query("page")), pageSize) if err != nil { fail(c, http.StatusInternalServerError, "读取档口入库码列表失败,请稍后刷新重试。") return @@ -41,17 +43,18 @@ func (h *Handler) InnerCodeList(c *gin.Context) { query.Set("q", keyword) } c.HTML(http.StatusOK, "inner_code/list", page(c, "inner-codes", "档口入库码", gin.H{ - "BusinessDate": businessDate, - "StatusFilter": result.Status, - "Keyword": keyword, - "StatusOptions": service.InnerCodeStatusOptions(), - "Rows": result.Rows, - "HasAny": result.Counts.Total > 0, - "IsFiltered": result.IsFiltered, - "CurrentPage": result.Page, - "Message": strings.TrimSpace(c.Query("message")), - "Status": service.InnerCodeStatusMessage(result), - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, query.Encode()), + "BusinessDate": businessDate, + "StatusFilter": result.Status, + "Keyword": keyword, + "StatusOptions": service.InnerCodeStatusOptions(), + "Rows": result.Rows, + "HasAny": result.Counts.Total > 0, + "IsFiltered": result.IsFiltered, + "CurrentPage": result.Page, + "CurrentPageSize": pageSize, + "Message": strings.TrimSpace(c.Query("message")), + "Status": service.InnerCodeStatusMessage(result), + "Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, query.Encode()), })) } @@ -141,8 +144,8 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) { h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "请先勾选要匹配的记录。") return } - if len(ids) > service.PageSize { - h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "一次最多匹配当前页 20 条记录,请刷新后重新勾选。") + if len(ids) > service.InnerCodeRemoteBatchLimit { + h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "一次最多匹配 20 条记录,请减少勾选后重试。") return } cfg, err := config.Load() @@ -272,6 +275,7 @@ func (h *Handler) innerCodeSybClient() (*syb.Client, string) { func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keyword string, pageNumber int, message string) { values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}, "message": {message}} + values.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size")))) if strings.TrimSpace(status) != "" { values.Set("status", strings.TrimSpace(status)) } diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index 22a7e7c..0128190 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -53,9 +53,10 @@ func sybLoginHint(ocrURL string) string { // 是配置错了还是服务挂了,不能弹一个空的手工输入框了事。 func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, msg, loginReason string) { pageNum := service.ParsePage(pageRaw) + pageSize := service.ParsePageSize(c.Query("page_size")) shop := strings.TrimSpace(c.Query("shop")) stage := service.ParseSybStage(c.Query("stage")) - result, err := service.ListSybOrdersView(h.db, keyword, shop, stage, pageNum) + result, err := service.ListSybOrdersViewWithPageSize(h.db, keyword, shop, stage, pageNum, pageSize) if err != nil { if !service.IsValidationError(err) || result == nil { fail(c, http.StatusInternalServerError, @@ -131,6 +132,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, if shop != "" { values.Set("shop", shop) } + values.Set("page_size", strconv.Itoa(pageSize)) detailValues := url.Values{} if keyword != "" { detailValues.Set("order_no", keyword) @@ -141,6 +143,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, if shop != "" { detailValues.Set("shop", shop) } + detailValues.Set("page_size", strconv.Itoa(pageSize)) detailValues.Set("page", strconv.Itoa(result.Page)) // `[必须]` 密码不读出来显示、也不回显到 HTML,见工单 #46。 @@ -214,6 +217,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, query.Set("stage", stage) } query.Set("page", strconv.Itoa(result.Page)) + query.Set("page_size", strconv.Itoa(pageSize)) query.Set("date_from", dateFrom) query.Set("date_to", dateTo) return "/syb?" + query.Encode() @@ -247,6 +251,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, "StageFilter": stage, "StageOptions": service.SybStageOptions(), "CurrentPage": result.Page, + "CurrentPageSize": pageSize, "Rows": result.Rows, "PurchaseRows": purchaseRows, "OpenSybID": openSybID, @@ -273,7 +278,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, keyword, shop, stage, dateFrom, dateTo), "HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages, keyword, shop, stage, dateFrom, dateTo), - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()), "DetailURL": "/syb/detail?" + detailValues.Encode(), "AssignableClients": purchaseClients.Rows, "PurchaseClientCount": purchaseClients.SelectableCount, @@ -314,7 +319,8 @@ func (h *Handler) SybDetail(c *gin.Context) { c.HTML(http.StatusOK, "syb/detail_modal", gin.H{ "D": detail, "CSRFToken": csrfToken(c), "AssignableClients": assignableClients, "Keyword": c.Query("order_no"), "ShopFilter": c.Query("shop"), "StageFilter": c.Query("stage"), - "CurrentPage": service.ParsePage(c.Query("page")), + "CurrentPage": service.ParsePage(c.Query("page")), + "CurrentPageSize": service.ParsePageSize(c.Query("page_size")), }) } @@ -678,6 +684,7 @@ func (h *Handler) sybRedirectAfterStart(c *gin.Context, msg string) { if stage := strings.TrimSpace(c.PostForm("stage")); stage != "" { params.Set("stage", stage) } + params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size")))) c.Redirect(http.StatusSeeOther, "/syb?"+params.Encode()) } @@ -715,6 +722,7 @@ func appendSybFormState(params url.Values, c *gin.Context) { params.Set(name, value) } } + params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size")))) } // SybAssociatePdd 从货运单上下文关联 PDD 商品,实际业务规则与蝦皮详情共用。 @@ -869,6 +877,7 @@ func (h *Handler) SybDelete(c *gin.Context) { // TaskList 渲染「采集采购」列表页。 func (h *Handler) TaskList(c *gin.Context) { actor := currentUser(c) + pageSize := service.ParsePageSize(c.Query("page_size")) creatorFilter := c.Query("creator") filter := repository.TaskFilter{ Type: service.ParseTaskType(c.Query("type")), @@ -876,7 +885,8 @@ func (h *Handler) TaskList(c *gin.Context) { Keyword: c.Query("q"), } - result, err := service.ListTasksViewForUser(h.db, actor, filter, creatorFilter, service.ParsePage(c.Query("page"))) + result, err := service.ListTasksViewForUserWithPageSize(h.db, actor, filter, creatorFilter, + service.ParsePage(c.Query("page")), pageSize) if err != nil { fail(c, http.StatusInternalServerError, "读取任务列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") @@ -908,20 +918,22 @@ func (h *Handler) TaskList(c *gin.Context) { if actor.IsAdmin() && creatorFilter != "" { values.Set("creator", creatorFilter) } + values.Set("page_size", strconv.Itoa(pageSize)) c.HTML(http.StatusOK, "task/list", page(c, "tasks", "采集采购", gin.H{ - "Rows": result.Rows, - "Keyword": filter.Keyword, - "Status": statusLine, - "TypeFilter": string(filter.Type), - "StatusFilter": string(filter.Status), - "TypeOptions": service.TaskTypeOptions(), - "StatusOptions": service.TaskStatusOptions(), - "ShowCreator": actor.IsAdmin(), - "CreatorFilter": creatorFilter, - "CreatorOptions": creatorOptions, - "IsFiltered": result.IsFiltered, - "CurrentPage": result.Page, - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "Rows": result.Rows, + "Keyword": filter.Keyword, + "Status": statusLine, + "TypeFilter": string(filter.Type), + "StatusFilter": string(filter.Status), + "TypeOptions": service.TaskTypeOptions(), + "StatusOptions": service.TaskStatusOptions(), + "ShowCreator": actor.IsAdmin(), + "CreatorFilter": creatorFilter, + "CreatorOptions": creatorOptions, + "IsFiltered": result.IsFiltered, + "CurrentPage": result.Page, + "CurrentPageSize": pageSize, + "Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()), })) } @@ -992,6 +1004,7 @@ func (h *Handler) taskRedirect(c *gin.Context, msg string) { if p := c.PostForm("page"); p != "" { params.Set("page", p) } + params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size")))) if creator := c.PostForm("creator"); creator != "" && currentUser(c).IsAdmin() { params.Set("creator", creator) } @@ -1018,9 +1031,10 @@ func (h *Handler) taskRedirect(c *gin.Context, msg string) { func (h *Handler) ClientList(c *gin.Context) { keyword := c.Query("name") actor := currentUser(c) + pageSize := service.ParsePageSize(c.Query("page_size")) - result, err := service.ListClientPageForUser(h.db, actor, keyword, h.onlineThreshold, - service.ParsePage(c.Query("page"))) + result, err := service.ListClientPageForUserWithPageSize(h.db, actor, keyword, h.onlineThreshold, + service.ParsePage(c.Query("page")), pageSize) if err != nil { fail(c, http.StatusInternalServerError, "读取客户端列表失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。") @@ -1044,12 +1058,14 @@ func (h *Handler) ClientList(c *gin.Context) { if strings.TrimSpace(keyword) != "" { values.Set("name", keyword) } + values.Set("page_size", strconv.Itoa(pageSize)) c.HTML(http.StatusOK, "client/list", page(c, "clients", "客户端列表", gin.H{ "Keyword": keyword, "Rows": result.Rows, "Status": status, "Purchasers": purchasers, "Message": c.Query("msg"), "Error": c.Query("error"), - "CurrentPage": result.Page, - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), - "IsFiltered": strings.TrimSpace(keyword) != "", + "CurrentPage": result.Page, + "CurrentPageSize": pageSize, + "Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()), + "IsFiltered": strings.TrimSpace(keyword) != "", })) } @@ -1102,6 +1118,7 @@ func redirectClients(c *gin.Context, message, errorMessage string) { if page := c.PostForm("page"); page != "" { query.Set("page", page) } + query.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size")))) if message != "" { query.Set("msg", message) } diff --git a/admin/handler/web/pagination_test.go b/admin/handler/web/pagination_test.go index 8f86bbc..88f3092 100644 --- a/admin/handler/web/pagination_test.go +++ b/admin/handler/web/pagination_test.go @@ -43,61 +43,67 @@ func assertRedirectQuery(t *testing.T, recorder *httptest.ResponseRecorder, want func Test列表写操作跳转保留筛选和页码(t *testing.T) { t.Run("蝦皮", func(t *testing.T) { context, recorder := listPostContext(t, "/shopee/save", url.Values{ - "q": {"店铺 A"}, "search_field": {"shop_name"}, "status": {"no_link"}, "unlinked": {"1"}, "page": {"2"}, + "q": {"店铺 A"}, "search_field": {"shop_name"}, "status": {"no_link"}, "unlinked": {"1"}, "page": {"2"}, "page_size": {"50"}, }) (&Handler{}).shopeeRedirect(context, "完成") assertRedirectQuery(t, recorder, "/shopee", map[string]string{ - "q": "店铺 A", "search_field": "shop_name", "status": "no_link", "unlinked": "1", "page": "2", "msg": "完成", + "q": "店铺 A", "search_field": "shop_name", "status": "no_link", "unlinked": "1", "page": "2", "page_size": "50", "msg": "完成", }) }) t.Run("顺运宝", func(t *testing.T) { context, recorder := listPostContext(t, "/syb/associate-pdd", url.Values{ - "order_no": {"SYB-1"}, "shop": {"测试店铺"}, "stage": {"pdd_missing"}, "page": {"3"}, + "order_no": {"SYB-1"}, "shop": {"测试店铺"}, "stage": {"pdd_missing"}, "page": {"3"}, "page_size": {"100"}, }) (&Handler{}).sybRedirect(context, "完成") assertRedirectQuery(t, recorder, "/syb", map[string]string{ - "order_no": "SYB-1", "shop": "测试店铺", "stage": "pdd_missing", "page": "3", "msg": "完成", + "order_no": "SYB-1", "shop": "测试店铺", "stage": "pdd_missing", "page": "3", "page_size": "100", "msg": "完成", }) }) t.Run("PDD", func(t *testing.T) { context, recorder := listPostContext(t, "/pdd/delete", url.Values{ - "q": {"737"}, "status": {"pending"}, "page": {"3"}, + "q": {"737"}, "status": {"pending"}, "page": {"3"}, "page_size": {"50"}, }) (&Handler{}).pddRedirect(context, "完成") assertRedirectQuery(t, recorder, "/pdd", map[string]string{ - "q": "737", "status": "pending", "page": "3", "msg": "完成", + "q": "737", "status": "pending", "page": "3", "page_size": "50", "msg": "完成", }) }) t.Run("任务", func(t *testing.T) { context, recorder := listPostContext(t, "/tasks/delete", url.Values{ - "q": {"TASK"}, "type": {"collect"}, "status": {"pending"}, "creator": {"buyer-a"}, "page": {"2"}, + "q": {"TASK"}, "type": {"collect"}, "status": {"pending"}, "creator": {"buyer-a"}, "page": {"2"}, "page_size": {"100"}, }) context.Set(currentUserKey, &model.User{Role: model.RoleAdmin}) (&Handler{}).taskRedirect(context, "完成") assertRedirectQuery(t, recorder, "/tasks", map[string]string{ - "q": "TASK", "type": "collect", "status": "pending", "creator": "buyer-a", "page": "2", "msg": "完成", + "q": "TASK", "type": "collect", "status": "pending", "creator": "buyer-a", "page": "2", "page_size": "100", "msg": "完成", }) }) t.Run("客户端", func(t *testing.T) { context, recorder := listPostContext(t, "/clients/assign", url.Values{ - "name": {"仓库"}, "page": {"4"}, + "name": {"仓库"}, "page": {"4"}, "page_size": {"50"}, }) redirectClients(context, "完成", "") assertRedirectQuery(t, recorder, "/clients", map[string]string{ - "name": "仓库", "page": "4", "msg": "完成", + "name": "仓库", "page": "4", "page_size": "50", "msg": "完成", }) }) t.Run("用户", func(t *testing.T) { context, recorder := listPostContext(t, "/users/status", url.Values{ - "q": {"buyer"}, "list_status": {"active"}, "page": {"5"}, + "q": {"buyer"}, "list_status": {"active"}, "page": {"5"}, "page_size": {"100"}, }) redirectUsers(context, "完成", "") assertRedirectQuery(t, recorder, "/users", map[string]string{ - "q": "buyer", "status": "active", "page": "5", "msg": "完成", + "q": "buyer", "status": "active", "page": "5", "page_size": "100", "msg": "完成", }) }) } +func Test列表写操作跳转归一化非法每页条数(t *testing.T) { + context, recorder := listPostContext(t, "/pdd/delete", url.Values{"page_size": {"9999"}}) + (&Handler{}).pddRedirect(context, "") + assertRedirectQuery(t, recorder, "/pdd", map[string]string{"page_size": "20"}) +} + func Test蝦皮分页详情和状态条保留分类搜索(t *testing.T) { values := url.Values{ "q": {"店铺 A"}, "search_field": {"shop_name"}, "status": {"has_link"}, "unlinked": {"1"}, diff --git a/admin/handler/web/pdd.go b/admin/handler/web/pdd.go index f246793..35f01cb 100644 --- a/admin/handler/web/pdd.go +++ b/admin/handler/web/pdd.go @@ -28,8 +28,13 @@ func (h *Handler) PddList(c *gin.Context) { func (h *Handler) renderPddList(c *gin.Context, keyword, statusRaw, pageRaw, message string, importResult *service.PddImportResult) { status := service.ParseCollectStatus(statusRaw) pageNum := service.ParsePage(pageRaw) + pageSizeRaw := c.Query("page_size") + if pageSizeRaw == "" && c.Request.Method == http.MethodPost { + pageSizeRaw = c.PostForm("page_size") + } + pageSize := service.ParsePageSize(pageSizeRaw) - result, err := service.ListPddProducts(h.db, keyword, status, pageNum) + result, err := service.ListPddProductsWithPageSize(h.db, keyword, status, pageNum, pageSize) if err != nil { fail(c, http.StatusInternalServerError, "读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") @@ -59,6 +64,7 @@ func (h *Handler) renderPddList(c *gin.Context, keyword, statusRaw, pageRaw, mes if status != "" { values.Set("status", string(status)) } + values.Set("page_size", strconv.Itoa(pageSize)) detailValues := url.Values{} for key, entries := range values { detailValues[key] = append([]string(nil), entries...) @@ -73,7 +79,8 @@ func (h *Handler) renderPddList(c *gin.Context, keyword, statusRaw, pageRaw, mes "HasAnyProducts": result.Total > 0, "IsFiltered": result.IsFiltered, "CurrentPage": result.Page, - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()), + "CurrentPageSize": pageSize, "DetailURL": "/pdd/detail?" + detailValues.Encode(), "AssignableClients": assignableClients, "ImportResult": importResult, @@ -112,7 +119,8 @@ func (h *Handler) PddDetail(c *gin.Context) { "CSRFToken": csrfToken(c), "AssignableClients": assignableClients, "Keyword": c.Query("q"), "StatusFilter": c.Query("status"), - "CurrentPage": service.ParsePage(c.Query("page")), + "CurrentPage": service.ParsePage(c.Query("page")), + "CurrentPageSize": service.ParsePageSize(c.Query("page_size")), }) } @@ -291,6 +299,7 @@ func (h *Handler) pddRedirect(c *gin.Context, msg string) { if p := c.PostForm("page"); p != "" { params.Set("page", p) } + params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size")))) if msg != "" { params.Set("msg", msg) } diff --git a/admin/handler/web/shopee.go b/admin/handler/web/shopee.go index b288fd8..cc243c8 100644 --- a/admin/handler/web/shopee.go +++ b/admin/handler/web/shopee.go @@ -48,10 +48,11 @@ func (h *Handler) ShopeeDetail(c *gin.Context) { c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{ "D": detail, "CSRFToken": csrfToken(c), "Keyword": c.Query("q"), "SearchField": service.ParseShopeeSearchField(c.Query("search_field")), - "StatusFilter": service.ParseShopeeStatus(c.Query("status")), - "DeletedFilter": currentUser(c).IsAdmin() && c.Query("deleted") == "1", - "UnlinkedFilter": c.Query("unlinked") == "1", - "CurrentPage": service.ParsePage(c.Query("page")), + "StatusFilter": service.ParseShopeeStatus(c.Query("status")), + "DeletedFilter": currentUser(c).IsAdmin() && c.Query("deleted") == "1", + "UnlinkedFilter": c.Query("unlinked") == "1", + "CurrentPage": service.ParsePage(c.Query("page")), + "CurrentPageSize": service.ParsePageSize(c.Query("page_size")), }) } @@ -72,8 +73,9 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, searchFieldRaw, stat // 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数, // 局部变量会把它遮住导致编译失败。 pageNum := service.ParsePage(pageRaw) + pageSize := service.ParsePageSize(c.Query("page_size")) - result, err := service.ListShopeeProducts(h.db, filter, pageNum) + result, err := service.ListShopeeProductsWithPageSize(h.db, filter, pageNum, pageSize) if err != nil { fail(c, http.StatusInternalServerError, "读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") @@ -104,6 +106,7 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, searchFieldRaw, stat if filter.Unlinked { values.Set("unlinked", "1") } + values.Set("page_size", strconv.Itoa(pageSize)) c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{ "Keyword": filter.Keyword, @@ -117,8 +120,9 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, searchFieldRaw, stat "Status": status, "HasAnyProducts": result.HasAnyProducts, "IsFiltered": result.IsFiltered, - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()), "CurrentPage": result.Page, + "CurrentPageSize": pageSize, "DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page), "AutoOpenDetailID": strings.TrimSpace(c.Query("open_id")), "AssignableClients": assignableClients, @@ -264,6 +268,7 @@ func (h *Handler) shopeeRedirect(c *gin.Context, msg string) { if value := strings.TrimSpace(c.PostForm("page")); value != "" { params.Set("page", value) } + params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size")))) if value := strings.TrimSpace(c.PostForm("open_id")); value != "" { params.Set("open_id", value) } diff --git a/admin/handler/web/user.go b/admin/handler/web/user.go index 1177658..ffca848 100644 --- a/admin/handler/web/user.go +++ b/admin/handler/web/user.go @@ -36,11 +36,12 @@ func (h *Handler) UserList(c *gin.Context) { func (h *Handler) renderUserList(c *gin.Context, httpStatus int, message, errorMessage string, needCreate bool, createUsername string) { keyword := strings.TrimSpace(userListValue(c, "q")) listStatus := userListValue(c, "status") + pageSize := service.ParsePageSize(userListValue(c, "page_size")) if c.Request.Method == http.MethodPost { listStatus = c.PostForm("list_status") } - result, err := service.ListUsers(h.db, currentUser(c), keyword, - model.UserStatus(listStatus), service.ParsePage(userListValue(c, "page"))) + result, err := service.ListUsersWithPageSize(h.db, currentUser(c), keyword, + model.UserStatus(listStatus), service.ParsePage(userListValue(c, "page")), pageSize) if err != nil { fail(c, http.StatusInternalServerError, "读取用户列表失败,账号数据没有被改动。刷新后重试。") return @@ -55,10 +56,11 @@ func (h *Handler) renderUserList(c *gin.Context, httpStatus int, message, errorM status := fmt.Sprintf("共 %d 个用户 · 第 %d/%d 页", result.Total, result.Page, result.TotalPages) c.HTML(httpStatus, "user/list", page(c, "users", "用户管理", gin.H{ "Rows": result.Rows, "Keyword": result.Keyword, "Status": status, - "Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), + "Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, values.Encode()), "StatusFilter": result.Status, "Message": message, "Error": errorMessage, "NeedCreate": needCreate, "CreateUsername": createUsername, - "CurrentPage": result.Page, + "CurrentPage": result.Page, + "CurrentPageSize": pageSize, })) } @@ -135,6 +137,7 @@ func redirectUsers(c *gin.Context, message, errorMessage string) { if page := c.PostForm("page"); page != "" { query.Set("page", page) } + query.Set("page_size", fmt.Sprintf("%d", service.ParsePageSize(c.PostForm("page_size")))) if message != "" { query.Set("msg", message) } diff --git a/admin/inner_code_template_test.go b/admin/inner_code_template_test.go index 245d928..7183ab1 100644 --- a/admin/inner_code_template_test.go +++ b/admin/inner_code_template_test.go @@ -76,7 +76,7 @@ func TestInnerCodeTemplate_筛选顺序与紧凑宽度(t *testing.T) { `id="inner-code-status"`, `id="inner-code-date" type="date" name="date"`, `id="inner-code-keyword"`, - `class="button-link" href="/inner-codes?date={{.BusinessDate}}">清除`, + `class="button-link" href="/inner-codes?date={{.BusinessDate}}&page_size={{.CurrentPageSize}}">清除`, ``, } lastIndex := -1 diff --git a/admin/service/catalog_history.go b/admin/service/catalog_history.go index 19987a2..e72a96b 100644 --- a/admin/service/catalog_history.go +++ b/admin/service/catalog_history.go @@ -34,15 +34,21 @@ func ParseCatalogStatus(raw string) model.CatalogImportStatus { } func ListCatalogHistory(q repository.Execer, source, statusRaw string, page int) (CatalogHistoryResult, error) { + return ListCatalogHistoryWithPageSize(q, source, statusRaw, page, DefaultPageSize) +} + +// ListCatalogHistoryWithPageSize 按白名单页容量返回商品目录导入记录。 +func ListCatalogHistoryWithPageSize(q repository.Execer, source, statusRaw string, page, pageSize int) (CatalogHistoryResult, error) { + pageSize = NormalizePageSize(pageSize) status := ParseCatalogStatus(statusRaw) totalPages := 1 _, total, err := repository.ListCatalogImportRuns(q, strings.TrimSpace(source), status, 1, 0) if err != nil { return CatalogHistoryResult{}, err } - totalPages = TotalPages(total) + totalPages = TotalPages(total, pageSize) page = ClampPage(page, totalPages) - runs, _, err := repository.ListCatalogImportRuns(q, strings.TrimSpace(source), status, PageSize, (page-1)*PageSize) + runs, _, err := repository.ListCatalogImportRuns(q, strings.TrimSpace(source), status, pageSize, (page-1)*pageSize) if err != nil { return CatalogHistoryResult{}, err } diff --git a/admin/service/inner_code_apply.go b/admin/service/inner_code_apply.go index 0958b15..4b49203 100644 --- a/admin/service/inner_code_apply.go +++ b/admin/service/inner_code_apply.go @@ -42,8 +42,8 @@ func ApplyInnerCodes(ctx context.Context, db *sql.DB, writer InnerCodeWriter, id if len(ids) == 0 { return nil, fmt.Errorf("没有选择可回写记录") } - if len(ids) > PageSize { - return nil, fmt.Errorf("单次最多回写当前页 %d 条记录", PageSize) + if len(ids) > InnerCodeRemoteBatchLimit { + return nil, fmt.Errorf("单次最多回写 %d 条记录", InnerCodeRemoteBatchLimit) } result := &InnerCodeApplyResult{Requested: len(ids)} for _, id := range ids { diff --git a/admin/service/inner_code_apply_test.go b/admin/service/inner_code_apply_test.go index dc83a32..5789108 100644 --- a/admin/service/inner_code_apply_test.go +++ b/admin/service/inner_code_apply_test.go @@ -20,6 +20,16 @@ type fakeInnerCodeWriter struct { updatedCode string } +func TestApplyInnerCodes_远程批量上限不随页面容量放宽(t *testing.T) { + ids := make([]int64, InnerCodeRemoteBatchLimit+1) + for i := range ids { + ids[i] = int64(i + 1) + } + if _, err := ApplyInnerCodes(context.Background(), nil, nil, ids, "user-1"); err == nil { + t.Fatal("超过 20 条时应该在访问数据库或顺运宝前拒绝") + } +} + func (f *fakeInnerCodeWriter) DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error) { f.readCount++ if f.readErr != nil { diff --git a/admin/service/inner_code_delete.go b/admin/service/inner_code_delete.go index 0b0f502..67c56b7 100644 --- a/admin/service/inner_code_delete.go +++ b/admin/service/inner_code_delete.go @@ -31,8 +31,8 @@ func DeleteInnerCodes(db *sql.DB, ids []int64, actorUserID string, now time.Time if len(unique) == 0 { return 0, fmt.Errorf("没有有效的档口入库码记录") } - if len(unique) > PageSize { - return 0, fmt.Errorf("一次最多删除当前页 %d 条记录", PageSize) + if len(unique) > InnerCodeDeleteBatchLimit { + return 0, fmt.Errorf("一次最多删除当前页 %d 条记录", InnerCodeDeleteBatchLimit) } return repository.SoftDeleteInnerCodeRecords(db, unique, actorUserID, now.UTC().Format(model.TimeLayout)) } diff --git a/admin/service/inner_code_page.go b/admin/service/inner_code_page.go index ab846af..bdbd255 100644 --- a/admin/service/inner_code_page.go +++ b/admin/service/inner_code_page.go @@ -27,6 +27,13 @@ var innerCodeStatusOptions = []InnerCodeStatusOption{ {string(model.InnerCodeNeedsCheck), "需核对"}, } +// 档口入库码的批量上限是业务安全限制,不能再复用界面每页条数。 +// 删除只改本地数据库,可覆盖最大单页;匹配和回写会访问顺运宝,继续限制 20 条。 +const ( + InnerCodeDeleteBatchLimit = 100 + InnerCodeRemoteBatchLimit = 20 +) + // InnerCodeRowView 是正式页面的一行。 type InnerCodeRowView struct { ID int64 @@ -66,8 +73,14 @@ func InnerCodeStatusOptions() []InnerCodeStatusOption { return append([]InnerCodeStatusOption(nil), innerCodeStatusOptions...) } -// ListInnerCodePage 校验筛选并返回统一 20 条 SQL 分页结果。 +// ListInnerCodePage 使用默认页容量返回档口入库码列表。 func ListInnerCodePage(db *sql.DB, businessDate, status, keyword string, requestedPage int) (*InnerCodeListPage, error) { + return ListInnerCodePageWithPageSize(db, businessDate, status, keyword, requestedPage, DefaultPageSize) +} + +// ListInnerCodePageWithPageSize 校验筛选并按白名单页容量执行 SQL 分页。 +func ListInnerCodePageWithPageSize(db *sql.DB, businessDate, status, keyword string, requestedPage, pageSize int) (*InnerCodeListPage, error) { + pageSize = NormalizePageSize(pageSize) status = strings.TrimSpace(status) if !validInnerCodeStatusFilter(status) { status = "" @@ -78,9 +91,9 @@ func ListInnerCodePage(db *sql.DB, businessDate, status, keyword string, request if err != nil { return nil, err } - totalPages := TotalPages(total) + totalPages := TotalPages(total, pageSize) page := ClampPage(requestedPage, totalPages) - records, err := repository.ListInnerCodeRecords(db, filter, PageSize, (page-1)*PageSize) + records, err := repository.ListInnerCodeRecords(db, filter, pageSize, (page-1)*pageSize) if err != nil { return nil, err } diff --git a/admin/service/pagination.go b/admin/service/pagination.go index e8c3efa..758ffc7 100644 --- a/admin/service/pagination.go +++ b/admin/service/pagination.go @@ -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) diff --git a/admin/service/pagination_test.go b/admin/service/pagination_test.go index f6feb1e..d9f6646 100644 --- a/admin/service/pagination_test.go +++ b/admin/service/pagination_test.go @@ -20,27 +20,50 @@ func TestParsePage_合法值原样返回(t *testing.T) { } } +func TestParsePageSize_只接受白名单(t *testing.T) { + for _, in := range []string{"20", "50", "100", " 50 "} { + want := map[string]int{"20": 20, "50": 50, "100": 100, " 50 ": 50}[in] + if got := ParsePageSize(in); got != want { + t.Errorf("ParsePageSize(%q) = %d,想要 %d", in, got, want) + } + } + for _, in := range []string{"", "0", "21", "200", "abc"} { + if got := ParsePageSize(in); got != DefaultPageSize { + t.Errorf("ParsePageSize(%q) = %d,非法值应回退 %d", in, got, DefaultPageSize) + } + } +} + func TestTotalPages_总数为0返回1不是0(t *testing.T) { - if got := TotalPages(0); got != 1 { + if got := TotalPages(0, 100); got != 1 { t.Errorf("TotalPages(0) = %d,想要 1(不能出现『第 1/0 页』)", got) } } func TestTotalPages_按PageSize向上取整(t *testing.T) { cases := map[int]int{ - 1: 1, - PageSize: 1, - PageSize + 1: 2, - PageSize * 2: 2, - 5195: 260, // 工单 #43 实测样本量:5195 条 / 20 条一页 = 260 页 + 1: 1, + DefaultPageSize: 1, + DefaultPageSize + 1: 2, + DefaultPageSize * 2: 2, + 5195: 260, // 工单 #43 实测样本量:5195 条 / 20 条一页 = 260 页 } for total, want := range cases { - if got := TotalPages(total); got != want { + if got := TotalPages(total, DefaultPageSize); got != want { t.Errorf("TotalPages(%d) = %d,想要 %d", total, got, want) } } } +func TestTotalPages_按选择的每页条数计算(t *testing.T) { + if got := TotalPages(101, 50); got != 3 { + t.Errorf("TotalPages(101, 50) = %d,想要 3", got) + } + if got := TotalPages(101, 100); got != 2 { + t.Errorf("TotalPages(101, 100) = %d,想要 2", got) + } +} + func TestClampPage_越界兜到最后一页(t *testing.T) { if got := ClampPage(9999, 260); got != 260 { t.Errorf("ClampPage(9999, 260) = %d,想要 260(兜到最后一页,不是空表格)", got) @@ -62,7 +85,7 @@ func TestClampPage_合法范围原样返回(t *testing.T) { } func TestNewPaginationView_首页禁用上一页和首页(t *testing.T) { - v := NewPaginationView(1, 260, "") + v := NewPaginationView(1, 20, 260, "") if v.HasPrev { t.Error("第 1 页时 HasPrev 应为 false(首页/上一页要禁用)") } @@ -72,7 +95,7 @@ func TestNewPaginationView_首页禁用上一页和首页(t *testing.T) { } func TestNewPaginationView_末页禁用下一页和末页(t *testing.T) { - v := NewPaginationView(260, 260, "") + v := NewPaginationView(260, 20, 260, "") if v.HasPrev != true { t.Error("最后一页时 HasPrev 应为 true(前面还有页)") } @@ -82,25 +105,31 @@ func TestNewPaginationView_末页禁用下一页和末页(t *testing.T) { } func TestNewPaginationView_总数为0时首尾都禁用(t *testing.T) { - v := NewPaginationView(1, 1, "") + v := NewPaginationView(1, 20, 1, "") if v.HasPrev || v.HasNext { t.Errorf("只有 1 页时前后都应禁用,HasPrev=%v HasNext=%v", v.HasPrev, v.HasNext) } } func TestNewPaginationView_URL带上筛选条件(t *testing.T) { - v := NewPaginationView(2, 260, "status=no_link") - if v.PrevURL != "?status=no_link&page=1" { - t.Errorf("PrevURL = %q,想要 ?status=no_link&page=1", v.PrevURL) + v := NewPaginationView(2, 50, 260, "status=no_link&page_size=50") + if v.PrevURL != "?page_size=50&status=no_link&page=1" { + t.Errorf("PrevURL = %q,想要保留筛选和 page_size", v.PrevURL) } - if v.NextURL != "?status=no_link&page=3" { - t.Errorf("NextURL = %q,想要 ?status=no_link&page=3", v.NextURL) + if v.NextURL != "?page_size=50&status=no_link&page=3" { + t.Errorf("NextURL = %q,想要保留筛选和 page_size", v.NextURL) } - if v.FirstURL != "?status=no_link&page=1" { - t.Errorf("FirstURL = %q,想要 ?status=no_link&page=1", v.FirstURL) + if v.FirstURL != "?page_size=50&status=no_link&page=1" { + t.Errorf("FirstURL = %q,想要保留筛选和 page_size", v.FirstURL) } - if v.LastURL != "?status=no_link&page=260" { - t.Errorf("LastURL = %q,想要 ?status=no_link&page=260", v.LastURL) + if v.LastURL != "?page_size=50&status=no_link&page=260" { + t.Errorf("LastURL = %q,想要保留筛选和 page_size", v.LastURL) + } + if v.PageSize != 50 || !v.PageSizeOptions[1].Selected { + t.Fatalf("当前页容量选择不正确: %+v", v) + } + if len(v.FilterFields) != 1 || v.FilterFields[0].Name != "status" { + t.Fatalf("切换容量应只保留筛选且不保留 page/page_size: %+v", v.FilterFields) } } diff --git a/admin/service/pdd.go b/admin/service/pdd.go index 80e6737..0d6aede 100644 --- a/admin/service/pdd.go +++ b/admin/service/pdd.go @@ -249,19 +249,25 @@ type PddListResult struct { // ListPddProducts 查列表并把每一行翻成界面文字。 func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus, requestedPage int) (*PddListResult, error) { + return ListPddProductsWithPageSize(db, keyword, status, requestedPage, DefaultPageSize) +} + +// ListPddProductsWithPageSize 按白名单页容量返回 PDD 商品列表。 +func ListPddProductsWithPageSize(db *sql.DB, keyword string, status model.CollectStatus, requestedPage, pageSize int) (*PddListResult, error) { + pageSize = NormalizePageSize(pageSize) page := requestedPage if page < 1 { page = 1 } - rows, filteredTotal, err := repository.ListPddProducts(db, keyword, status, PageSize, (page-1)*PageSize) + rows, filteredTotal, err := repository.ListPddProducts(db, keyword, status, pageSize, (page-1)*pageSize) if err != nil { return nil, err } - totalPages := TotalPages(filteredTotal) + totalPages := TotalPages(filteredTotal, pageSize) clampedPage := ClampPage(page, totalPages) if clampedPage != page { page = clampedPage - rows, _, err = repository.ListPddProducts(db, keyword, status, PageSize, (page-1)*PageSize) + rows, _, err = repository.ListPddProducts(db, keyword, status, pageSize, (page-1)*pageSize) if err != nil { return nil, err } diff --git a/admin/service/pdd_page_test.go b/admin/service/pdd_page_test.go index e9c3412..8e624fd 100644 --- a/admin/service/pdd_page_test.go +++ b/admin/service/pdd_page_test.go @@ -40,6 +40,32 @@ func TestListPddProducts_统一每页20条并收敛越界页(t *testing.T) { } } +func TestListPddProducts_可选每页50条(t *testing.T) { + db := newTestDB(t) + for i := 0; i < 55; i++ { + goodsID := fmt.Sprintf("73711654%04d", i) + if _, err := CreatePddProduct(db, + "https://mobile.yangkeduo.com/goods.html?goods_id="+goodsID); err != nil { + t.Fatalf("创建第 %d 个商品失败: %v", i, err) + } + } + + first, err := ListPddProductsWithPageSize(db, "", "", 1, 50) + if err != nil { + t.Fatal(err) + } + if len(first.Rows) != 50 || first.TotalPages != 2 { + t.Fatalf("50 条分页第 1 页不对: rows=%d pages=%d", len(first.Rows), first.TotalPages) + } + second, err := ListPddProductsWithPageSize(db, "", "", 2, 50) + if err != nil { + t.Fatal(err) + } + if len(second.Rows) != 5 || second.Page != 2 { + t.Fatalf("50 条分页第 2 页不对: rows=%d page=%d", len(second.Rows), second.Page) + } +} + // ── 链接解析 ─────────────────────────────────────────── func TestParsePddGoodsID_认得的写法(t *testing.T) { diff --git a/admin/service/service.go b/admin/service/service.go index 912ca18..42c3526 100644 --- a/admin/service/service.go +++ b/admin/service/service.go @@ -166,6 +166,12 @@ type ClientListResult struct { // ListClientPageForUser 按网页登录身份、搜索词和统一页大小返回客户端列表。 func ListClientPageForUser(db *sql.DB, actor *model.User, keyword string, threshold time.Duration, requestedPage int) (*ClientListResult, error) { + return ListClientPageForUserWithPageSize(db, actor, keyword, threshold, requestedPage, DefaultPageSize) +} + +// ListClientPageForUserWithPageSize 按白名单页容量返回当前账号可见客户端。 +func ListClientPageForUserWithPageSize(db *sql.DB, actor *model.User, keyword string, threshold time.Duration, requestedPage, pageSize int) (*ClientListResult, error) { + pageSize = NormalizePageSize(pageSize) visibleUserID, err := visibleClientUserID(actor) if err != nil { return nil, err @@ -175,15 +181,15 @@ func ListClientPageForUser(db *sql.DB, actor *model.User, keyword string, thresh if page < 1 { page = 1 } - rows, total, err := repository.ListClientsForUserPage(db, keyword, visibleUserID, PageSize, (page-1)*PageSize) + rows, total, err := repository.ListClientsForUserPage(db, keyword, visibleUserID, pageSize, (page-1)*pageSize) if err != nil { return nil, err } - totalPages := TotalPages(total) + totalPages := TotalPages(total, pageSize) clampedPage := ClampPage(page, totalPages) if clampedPage != page { page = clampedPage - rows, _, err = repository.ListClientsForUserPage(db, keyword, visibleUserID, PageSize, (page-1)*PageSize) + rows, _, err = repository.ListClientsForUserPage(db, keyword, visibleUserID, pageSize, (page-1)*pageSize) if err != nil { return nil, err } diff --git a/admin/service/shopee_list.go b/admin/service/shopee_list.go index e5d6138..ea5527a 100644 --- a/admin/service/shopee_list.go +++ b/admin/service/shopee_list.go @@ -81,15 +81,21 @@ type ShopeeListResult struct { // (repository.CountShopeeProductsFiltered),分页页数据此算出, // page 越界时兜到最后一页,见 service/pagination.go(工单 #43)。 func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*ShopeeListResult, error) { + return ListShopeeProductsWithPageSize(db, filter, page, DefaultPageSize) +} + +// ListShopeeProductsWithPageSize 按白名单页容量返回蝦皮商品列表。 +func ListShopeeProductsWithPageSize(db *sql.DB, filter repository.ShopeeFilter, page, pageSize int) (*ShopeeListResult, error) { + pageSize = NormalizePageSize(pageSize) total, err := repository.CountShopeeProductsFiltered(db, filter) if err != nil { return nil, err } - totalPages := TotalPages(total) + totalPages := TotalPages(total, pageSize) page = ClampPage(page, totalPages) - offset := (page - 1) * PageSize + offset := (page - 1) * pageSize - rows, err := repository.ListShopeeProducts(db, filter, PageSize, offset) + rows, err := repository.ListShopeeProducts(db, filter, pageSize, offset) if err != nil { return nil, err } @@ -105,7 +111,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (* HasAnyProducts: hasAny > 0, IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "" || filter.Deleted || filter.Unlinked, Page: page, - PageSize: PageSize, + PageSize: pageSize, TotalPages: totalPages, } for _, r := range rows { diff --git a/admin/service/syb.go b/admin/service/syb.go index 0922691..e24ad21 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -1246,6 +1246,12 @@ func ParseSybOrderNos(raw string) ([]string, error) { // `[必须]` 处理阶段由顺运宝规格键、PDD 关联、采集状态和规格映射实时推导, // 不额外保存一份容易过期的状态字段。 func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) { + return ListSybOrdersViewWithPageSize(db, keyword, shop, stage, page, DefaultPageSize) +} + +// ListSybOrdersViewWithPageSize 按白名单页容量查询顺运宝明细。 +func ListSybOrdersViewWithPageSize(db *sql.DB, keyword, shop, stage string, page, pageSize int) (*SybListResult, error) { + pageSize = NormalizePageSize(pageSize) shop = strings.TrimSpace(shop) stage = ParseSybStage(stage) orderNos, err := ParseSybOrderNos(keyword) @@ -1264,18 +1270,18 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL stage == SybStageAIMatched || stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted || stage == SybStagePurchaseReview || stage == SybStagePurchaseBlocked { - return listAdvancedSybStage(db, orderNos, shop, stage, page) + return listAdvancedSybStage(db, orderNos, shop, stage, page, pageSize) } filter := repository.SybOrderFilter{OrderNos: orderNos, Shop: shop, Stage: stage} total, err := repository.CountSybOrders(db, filter) if err != nil { return nil, err } - totalPages := TotalPages(total) + totalPages := TotalPages(total, pageSize) page = ClampPage(page, totalPages) - offset := (page - 1) * PageSize + offset := (page - 1) * pageSize - rows, err := repository.ListSybOrderContexts(db, filter, PageSize, offset) + rows, err := repository.ListSybOrderContexts(db, filter, pageSize, offset) if err != nil { return nil, err } @@ -1311,7 +1317,7 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL // 这些阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的 // OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表 // 和其余阶段仍在数据库分页。 -func listAdvancedSybStage(db *sql.DB, orderNos []string, shop, stage string, page int) (*SybListResult, error) { +func listAdvancedSybStage(db *sql.DB, orderNos []string, shop, stage string, page, pageSize int) (*SybListResult, error) { contexts, err := repository.ListSybOrderContexts(db, repository.SybOrderFilter{OrderNos: orderNos, Shop: shop}, -1, 0) if err != nil { @@ -1329,13 +1335,13 @@ func listAdvancedSybStage(db *sql.DB, orderNos []string, shop, stage string, pag } } total := len(filtered) - totalPages := TotalPages(total) + totalPages := TotalPages(total, pageSize) page = ClampPage(page, totalPages) - start := (page - 1) * PageSize + start := (page - 1) * pageSize if start > total { start = total } - end := start + PageSize + end := start + pageSize if end > total { end = total } diff --git a/admin/service/task.go b/admin/service/task.go index db84b5d..565f802 100644 --- a/admin/service/task.go +++ b/admin/service/task.go @@ -322,6 +322,12 @@ type TaskListResult struct { // `[必须]` 底部统计要跟随当前筛选,所以 Counts 和 Rows 用的是**同一个** // filter,见 #19。 func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int) (*TaskListResult, error) { + return ListTasksViewWithPageSize(db, filter, requestedPage, DefaultPageSize) +} + +// ListTasksViewWithPageSize 按白名单页容量返回任务列表。 +func ListTasksViewWithPageSize(db *sql.DB, filter repository.TaskFilter, requestedPage, pageSize int) (*TaskListResult, error) { + pageSize = NormalizePageSize(pageSize) counts, err := repository.CountTasksByStatus(db, filter) if err != nil { return nil, err @@ -330,9 +336,9 @@ func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int) for _, n := range counts { total += n } - totalPages := TotalPages(total) + totalPages := TotalPages(total, pageSize) page := ClampPage(requestedPage, totalPages) - rows, err := repository.ListTasks(db, filter, PageSize, (page-1)*PageSize) + rows, err := repository.ListTasks(db, filter, pageSize, (page-1)*pageSize) if err != nil { return nil, err } @@ -371,6 +377,11 @@ func taskCreatorText(username string) string { // ListTasksViewForUser 在服务端固定当前账号范围,忽略采购员伪造的创建人筛选。 func ListTasksViewForUser(db *sql.DB, actor *model.User, filter repository.TaskFilter, creatorValue string, requestedPage int) (*TaskListResult, error) { + return ListTasksViewForUserWithPageSize(db, actor, filter, creatorValue, requestedPage, DefaultPageSize) +} + +// ListTasksViewForUserWithPageSize 固定账号可见范围后按白名单页容量返回任务。 +func ListTasksViewForUserWithPageSize(db *sql.DB, actor *model.User, filter repository.TaskFilter, creatorValue string, requestedPage, pageSize int) (*TaskListResult, error) { visibleUserID, err := visibleClientUserID(actor) if err != nil { return nil, err @@ -386,7 +397,7 @@ func ListTasksViewForUser(db *sql.DB, actor *model.User, filter repository.TaskF filter.CreatorUserID = creatorValue } } - return ListTasksView(db, filter, requestedPage) + return ListTasksViewWithPageSize(db, filter, requestedPage, pageSize) } // StatusLine 拼底部状态条,形如: diff --git a/admin/service/user.go b/admin/service/user.go index 8ff5d18..85c39e8 100644 --- a/admin/service/user.go +++ b/admin/service/user.go @@ -23,8 +23,14 @@ type UserListResult struct { TotalPages int } -// ListUsers 搜索并分页。用户页沿用全站统一的每页 20 条规则。 +// ListUsers 使用默认页容量搜索并分页。 func ListUsers(db *sql.DB, actor *model.User, keyword string, status model.UserStatus, requestedPage int) (*UserListResult, error) { + return ListUsersWithPageSize(db, actor, keyword, status, requestedPage, DefaultPageSize) +} + +// ListUsersWithPageSize 按白名单页容量搜索并分页。 +func ListUsersWithPageSize(db *sql.DB, actor *model.User, keyword string, status model.UserStatus, requestedPage, pageSize int) (*UserListResult, error) { + pageSize = NormalizePageSize(pageSize) if actor == nil || !actor.IsAdmin() { return nil, ErrAdminRequired } @@ -36,15 +42,15 @@ func ListUsers(db *sql.DB, actor *model.User, keyword string, status model.UserS if page < 1 { page = 1 } - rows, total, err := repository.ListUsers(db, keyword, status, PageSize, (page-1)*PageSize) + rows, total, err := repository.ListUsers(db, keyword, status, pageSize, (page-1)*pageSize) if err != nil { return nil, err } - totalPages := TotalPages(total) + totalPages := TotalPages(total, pageSize) clampedPage := ClampPage(page, totalPages) if clampedPage != page { page = clampedPage - rows, _, err = repository.ListUsers(db, keyword, status, PageSize, (page-1)*PageSize) + rows, _, err = repository.ListUsers(db, keyword, status, pageSize, (page-1)*pageSize) if err != nil { return nil, err } diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 7fed973..9ab9750 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -675,6 +675,26 @@ select { margin-left: 0; flex: 0 0 auto; } +.page-size-form { + display: inline-flex; + align-items: center; + gap: 5px; + margin-right: 2px; + white-space: nowrap; +} +.page-size-form label { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 13px; +} +.page-size-form select, +.page-size-form button { + min-height: 28px; + font-size: 13px; +} +.page-size-form select { padding: 3px 6px; } +.page-size-form button { padding: 3px 8px; } .pagination a, .pagination span.disabled { padding: 4px 10px; @@ -690,7 +710,7 @@ select { body { padding-bottom: 0; } .statusbar { position: static; flex-wrap: wrap; gap: 6px 12px; } .statusbar > span:first-child { flex-basis: 100%; } - .pagination { width: 100%; justify-content: flex-end; margin-left: 0; } + .pagination { width: 100%; justify-content: flex-end; margin-left: 0; flex-wrap: wrap; } } .pagination a { color: #1f6feb; background: #fff; } .pagination a:hover { background: #f0f2f4; } diff --git a/admin/syb_interaction_template_test.go b/admin/syb_interaction_template_test.go index 370ade3..736bd5d 100644 --- a/admin/syb_interaction_template_test.go +++ b/admin/syb_interaction_template_test.go @@ -57,6 +57,7 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { "Rows": rows, "PurchaseRows": rows, "DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(), "SyncDateFrom": "2026-08-12", "SyncDateTo": "2026-08-13", + "CurrentPageSize": 20, "Keyword": "ORDER-READY,ORDER-MISSING", "OrderNoFeedback": "已输入 2 个订单号;当前筛选命中 1 个订单、1 条商品明细。 当前筛选未命中:ORDER-MISSING", } @@ -107,7 +108,7 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { `role="status">已输入 2 个订单号`, `当前筛选未命中:ORDER-MISSING`, `href="/syb?date_from=2026-08-12`, - `date_to=2026-08-13">清除`, + `date_to=2026-08-13&page_size=20">清除`, `class="table-wrap syb-table-wrap"`, `class="syb-page-body"`, `