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
+3 -2
View File
@@ -11,7 +11,8 @@ import (
) )
func (h *Handler) CatalogHistory(c *gin.Context) { 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 { if err != nil {
fail(c, 500, "读取商品目录导入记录失败,数据没有被改动。请刷新后重试。") fail(c, 500, "读取商品目录导入记录失败,数据没有被改动。请刷新后重试。")
return return
@@ -23,7 +24,7 @@ func (h *Handler) CatalogHistory(c *gin.Context) {
if result.Status != "" { if result.Status != "" {
values.Set("status", 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) { func (h *Handler) CatalogHistoryDetail(c *gin.Context) {
+18 -14
View File
@@ -28,7 +28,9 @@ func (h *Handler) InnerCodeList(c *gin.Context) {
} }
status := strings.TrimSpace(c.Query("status")) status := strings.TrimSpace(c.Query("status"))
keyword := strings.TrimSpace(c.Query("q")) 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 { if err != nil {
fail(c, http.StatusInternalServerError, "读取档口入库码列表失败,请稍后刷新重试。") fail(c, http.StatusInternalServerError, "读取档口入库码列表失败,请稍后刷新重试。")
return return
@@ -41,17 +43,18 @@ func (h *Handler) InnerCodeList(c *gin.Context) {
query.Set("q", keyword) query.Set("q", keyword)
} }
c.HTML(http.StatusOK, "inner_code/list", page(c, "inner-codes", "档口入库码", gin.H{ c.HTML(http.StatusOK, "inner_code/list", page(c, "inner-codes", "档口入库码", gin.H{
"BusinessDate": businessDate, "BusinessDate": businessDate,
"StatusFilter": result.Status, "StatusFilter": result.Status,
"Keyword": keyword, "Keyword": keyword,
"StatusOptions": service.InnerCodeStatusOptions(), "StatusOptions": service.InnerCodeStatusOptions(),
"Rows": result.Rows, "Rows": result.Rows,
"HasAny": result.Counts.Total > 0, "HasAny": result.Counts.Total > 0,
"IsFiltered": result.IsFiltered, "IsFiltered": result.IsFiltered,
"CurrentPage": result.Page, "CurrentPage": result.Page,
"Message": strings.TrimSpace(c.Query("message")), "CurrentPageSize": pageSize,
"Status": service.InnerCodeStatusMessage(result), "Message": strings.TrimSpace(c.Query("message")),
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, query.Encode()), "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, "请先勾选要匹配的记录。") h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "请先勾选要匹配的记录。")
return return
} }
if len(ids) > service.PageSize { if len(ids) > service.InnerCodeRemoteBatchLimit {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "一次最多匹配当前页 20 条记录,请刷新后重新勾选。") h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "一次最多匹配 20 条记录,请减少勾选后重试。")
return return
} }
cfg, err := config.Load() 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) { 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 := 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) != "" { if strings.TrimSpace(status) != "" {
values.Set("status", strings.TrimSpace(status)) values.Set("status", strings.TrimSpace(status))
} }
+39 -22
View File
@@ -53,9 +53,10 @@ func sybLoginHint(ocrURL string) string {
// 是配置错了还是服务挂了,不能弹一个空的手工输入框了事。 // 是配置错了还是服务挂了,不能弹一个空的手工输入框了事。
func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, msg, loginReason string) { func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, msg, loginReason string) {
pageNum := service.ParsePage(pageRaw) pageNum := service.ParsePage(pageRaw)
pageSize := service.ParsePageSize(c.Query("page_size"))
shop := strings.TrimSpace(c.Query("shop")) shop := strings.TrimSpace(c.Query("shop"))
stage := service.ParseSybStage(c.Query("stage")) 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 err != nil {
if !service.IsValidationError(err) || result == nil { if !service.IsValidationError(err) || result == nil {
fail(c, http.StatusInternalServerError, fail(c, http.StatusInternalServerError,
@@ -131,6 +132,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
if shop != "" { if shop != "" {
values.Set("shop", shop) values.Set("shop", shop)
} }
values.Set("page_size", strconv.Itoa(pageSize))
detailValues := url.Values{} detailValues := url.Values{}
if keyword != "" { if keyword != "" {
detailValues.Set("order_no", keyword) detailValues.Set("order_no", keyword)
@@ -141,6 +143,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
if shop != "" { if shop != "" {
detailValues.Set("shop", shop) detailValues.Set("shop", shop)
} }
detailValues.Set("page_size", strconv.Itoa(pageSize))
detailValues.Set("page", strconv.Itoa(result.Page)) detailValues.Set("page", strconv.Itoa(result.Page))
// `[必须]` 密码不读出来显示、也不回显到 HTML,见工单 #46。 // `[必须]` 密码不读出来显示、也不回显到 HTML,见工单 #46。
@@ -214,6 +217,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
query.Set("stage", stage) query.Set("stage", stage)
} }
query.Set("page", strconv.Itoa(result.Page)) query.Set("page", strconv.Itoa(result.Page))
query.Set("page_size", strconv.Itoa(pageSize))
query.Set("date_from", dateFrom) query.Set("date_from", dateFrom)
query.Set("date_to", dateTo) query.Set("date_to", dateTo)
return "/syb?" + query.Encode() return "/syb?" + query.Encode()
@@ -247,6 +251,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
"StageFilter": stage, "StageFilter": stage,
"StageOptions": service.SybStageOptions(), "StageOptions": service.SybStageOptions(),
"CurrentPage": result.Page, "CurrentPage": result.Page,
"CurrentPageSize": pageSize,
"Rows": result.Rows, "Rows": result.Rows,
"PurchaseRows": purchaseRows, "PurchaseRows": purchaseRows,
"OpenSybID": openSybID, "OpenSybID": openSybID,
@@ -273,7 +278,7 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw,
keyword, shop, stage, dateFrom, dateTo), keyword, shop, stage, dateFrom, dateTo),
"HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages, "HistoryFetchPagination": sybHistoryPartialPagination(history.Page, history.TotalPages,
keyword, shop, stage, dateFrom, dateTo), 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(), "DetailURL": "/syb/detail?" + detailValues.Encode(),
"AssignableClients": purchaseClients.Rows, "AssignableClients": purchaseClients.Rows,
"PurchaseClientCount": purchaseClients.SelectableCount, "PurchaseClientCount": purchaseClients.SelectableCount,
@@ -314,7 +319,8 @@ func (h *Handler) SybDetail(c *gin.Context) {
c.HTML(http.StatusOK, "syb/detail_modal", gin.H{ c.HTML(http.StatusOK, "syb/detail_modal", gin.H{
"D": detail, "CSRFToken": csrfToken(c), "AssignableClients": assignableClients, "D": detail, "CSRFToken": csrfToken(c), "AssignableClients": assignableClients,
"Keyword": c.Query("order_no"), "ShopFilter": c.Query("shop"), "StageFilter": c.Query("stage"), "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 != "" { if stage := strings.TrimSpace(c.PostForm("stage")); stage != "" {
params.Set("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()) 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(name, value)
} }
} }
params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size"))))
} }
// SybAssociatePdd 从货运单上下文关联 PDD 商品,实际业务规则与蝦皮详情共用。 // SybAssociatePdd 从货运单上下文关联 PDD 商品,实际业务规则与蝦皮详情共用。
@@ -869,6 +877,7 @@ func (h *Handler) SybDelete(c *gin.Context) {
// TaskList 渲染「采集采购」列表页。 // TaskList 渲染「采集采购」列表页。
func (h *Handler) TaskList(c *gin.Context) { func (h *Handler) TaskList(c *gin.Context) {
actor := currentUser(c) actor := currentUser(c)
pageSize := service.ParsePageSize(c.Query("page_size"))
creatorFilter := c.Query("creator") creatorFilter := c.Query("creator")
filter := repository.TaskFilter{ filter := repository.TaskFilter{
Type: service.ParseTaskType(c.Query("type")), Type: service.ParseTaskType(c.Query("type")),
@@ -876,7 +885,8 @@ func (h *Handler) TaskList(c *gin.Context) {
Keyword: c.Query("q"), 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 { if err != nil {
fail(c, http.StatusInternalServerError, fail(c, http.StatusInternalServerError,
"读取任务列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") "读取任务列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
@@ -908,20 +918,22 @@ func (h *Handler) TaskList(c *gin.Context) {
if actor.IsAdmin() && creatorFilter != "" { if actor.IsAdmin() && creatorFilter != "" {
values.Set("creator", creatorFilter) values.Set("creator", creatorFilter)
} }
values.Set("page_size", strconv.Itoa(pageSize))
c.HTML(http.StatusOK, "task/list", page(c, "tasks", "采集采购", gin.H{ c.HTML(http.StatusOK, "task/list", page(c, "tasks", "采集采购", gin.H{
"Rows": result.Rows, "Rows": result.Rows,
"Keyword": filter.Keyword, "Keyword": filter.Keyword,
"Status": statusLine, "Status": statusLine,
"TypeFilter": string(filter.Type), "TypeFilter": string(filter.Type),
"StatusFilter": string(filter.Status), "StatusFilter": string(filter.Status),
"TypeOptions": service.TaskTypeOptions(), "TypeOptions": service.TaskTypeOptions(),
"StatusOptions": service.TaskStatusOptions(), "StatusOptions": service.TaskStatusOptions(),
"ShowCreator": actor.IsAdmin(), "ShowCreator": actor.IsAdmin(),
"CreatorFilter": creatorFilter, "CreatorFilter": creatorFilter,
"CreatorOptions": creatorOptions, "CreatorOptions": creatorOptions,
"IsFiltered": result.IsFiltered, "IsFiltered": result.IsFiltered,
"CurrentPage": result.Page, "CurrentPage": result.Page,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), "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 != "" { if p := c.PostForm("page"); p != "" {
params.Set("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() { if creator := c.PostForm("creator"); creator != "" && currentUser(c).IsAdmin() {
params.Set("creator", creator) params.Set("creator", creator)
} }
@@ -1018,9 +1031,10 @@ func (h *Handler) taskRedirect(c *gin.Context, msg string) {
func (h *Handler) ClientList(c *gin.Context) { func (h *Handler) ClientList(c *gin.Context) {
keyword := c.Query("name") keyword := c.Query("name")
actor := currentUser(c) actor := currentUser(c)
pageSize := service.ParsePageSize(c.Query("page_size"))
result, err := service.ListClientPageForUser(h.db, actor, keyword, h.onlineThreshold, result, err := service.ListClientPageForUserWithPageSize(h.db, actor, keyword, h.onlineThreshold,
service.ParsePage(c.Query("page"))) service.ParsePage(c.Query("page")), pageSize)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, fail(c, http.StatusInternalServerError,
"读取客户端列表失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。") "读取客户端列表失败,数据没有被改动。请稍后重试,或查看 data/logs/ 里的日志。")
@@ -1044,12 +1058,14 @@ func (h *Handler) ClientList(c *gin.Context) {
if strings.TrimSpace(keyword) != "" { if strings.TrimSpace(keyword) != "" {
values.Set("name", keyword) values.Set("name", keyword)
} }
values.Set("page_size", strconv.Itoa(pageSize))
c.HTML(http.StatusOK, "client/list", page(c, "clients", "客户端列表", gin.H{ c.HTML(http.StatusOK, "client/list", page(c, "clients", "客户端列表", gin.H{
"Keyword": keyword, "Rows": result.Rows, "Status": status, "Keyword": keyword, "Rows": result.Rows, "Status": status,
"Purchasers": purchasers, "Message": c.Query("msg"), "Error": c.Query("error"), "Purchasers": purchasers, "Message": c.Query("msg"), "Error": c.Query("error"),
"CurrentPage": result.Page, "CurrentPage": result.Page,
"Pagination": service.NewPaginationView(result.Page, result.TotalPages, values.Encode()), "CurrentPageSize": pageSize,
"IsFiltered": strings.TrimSpace(keyword) != "", "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 != "" { if page := c.PostForm("page"); page != "" {
query.Set("page", page) query.Set("page", page)
} }
query.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size"))))
if message != "" { if message != "" {
query.Set("msg", message) query.Set("msg", message)
} }
+18 -12
View File
@@ -43,61 +43,67 @@ func assertRedirectQuery(t *testing.T, recorder *httptest.ResponseRecorder, want
func Test列表写操作跳转保留筛选和页码(t *testing.T) { func Test列表写操作跳转保留筛选和页码(t *testing.T) {
t.Run("蝦皮", func(t *testing.T) { t.Run("蝦皮", func(t *testing.T) {
context, recorder := listPostContext(t, "/shopee/save", url.Values{ 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, "完成") (&Handler{}).shopeeRedirect(context, "完成")
assertRedirectQuery(t, recorder, "/shopee", map[string]string{ 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) { t.Run("顺运宝", func(t *testing.T) {
context, recorder := listPostContext(t, "/syb/associate-pdd", url.Values{ 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, "完成") (&Handler{}).sybRedirect(context, "完成")
assertRedirectQuery(t, recorder, "/syb", map[string]string{ 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) { t.Run("PDD", func(t *testing.T) {
context, recorder := listPostContext(t, "/pdd/delete", url.Values{ 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, "完成") (&Handler{}).pddRedirect(context, "完成")
assertRedirectQuery(t, recorder, "/pdd", map[string]string{ 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) { t.Run("任务", func(t *testing.T) {
context, recorder := listPostContext(t, "/tasks/delete", url.Values{ 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}) context.Set(currentUserKey, &model.User{Role: model.RoleAdmin})
(&Handler{}).taskRedirect(context, "完成") (&Handler{}).taskRedirect(context, "完成")
assertRedirectQuery(t, recorder, "/tasks", map[string]string{ 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) { t.Run("客户端", func(t *testing.T) {
context, recorder := listPostContext(t, "/clients/assign", url.Values{ context, recorder := listPostContext(t, "/clients/assign", url.Values{
"name": {"仓库"}, "page": {"4"}, "name": {"仓库"}, "page": {"4"}, "page_size": {"50"},
}) })
redirectClients(context, "完成", "") redirectClients(context, "完成", "")
assertRedirectQuery(t, recorder, "/clients", map[string]string{ assertRedirectQuery(t, recorder, "/clients", map[string]string{
"name": "仓库", "page": "4", "msg": "完成", "name": "仓库", "page": "4", "page_size": "50", "msg": "完成",
}) })
}) })
t.Run("用户", func(t *testing.T) { t.Run("用户", func(t *testing.T) {
context, recorder := listPostContext(t, "/users/status", url.Values{ 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, "完成", "") redirectUsers(context, "完成", "")
assertRedirectQuery(t, recorder, "/users", map[string]string{ 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) { func Test蝦皮分页详情和状态条保留分类搜索(t *testing.T) {
values := url.Values{ values := url.Values{
"q": {"店铺 A"}, "search_field": {"shop_name"}, "status": {"has_link"}, "unlinked": {"1"}, "q": {"店铺 A"}, "search_field": {"shop_name"}, "status": {"has_link"}, "unlinked": {"1"},
+12 -3
View File
@@ -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) { func (h *Handler) renderPddList(c *gin.Context, keyword, statusRaw, pageRaw, message string, importResult *service.PddImportResult) {
status := service.ParseCollectStatus(statusRaw) status := service.ParseCollectStatus(statusRaw)
pageNum := service.ParsePage(pageRaw) 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 { if err != nil {
fail(c, http.StatusInternalServerError, fail(c, http.StatusInternalServerError,
"读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") "读取 PDD 商品列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
@@ -59,6 +64,7 @@ func (h *Handler) renderPddList(c *gin.Context, keyword, statusRaw, pageRaw, mes
if status != "" { if status != "" {
values.Set("status", string(status)) values.Set("status", string(status))
} }
values.Set("page_size", strconv.Itoa(pageSize))
detailValues := url.Values{} detailValues := url.Values{}
for key, entries := range values { for key, entries := range values {
detailValues[key] = append([]string(nil), entries...) 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, "HasAnyProducts": result.Total > 0,
"IsFiltered": result.IsFiltered, "IsFiltered": result.IsFiltered,
"CurrentPage": result.Page, "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(), "DetailURL": "/pdd/detail?" + detailValues.Encode(),
"AssignableClients": assignableClients, "AssignableClients": assignableClients,
"ImportResult": importResult, "ImportResult": importResult,
@@ -112,7 +119,8 @@ func (h *Handler) PddDetail(c *gin.Context) {
"CSRFToken": csrfToken(c), "CSRFToken": csrfToken(c),
"AssignableClients": assignableClients, "AssignableClients": assignableClients,
"Keyword": c.Query("q"), "StatusFilter": c.Query("status"), "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 != "" { if p := c.PostForm("page"); p != "" {
params.Set("page", p) params.Set("page", p)
} }
params.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size"))))
if msg != "" { if msg != "" {
params.Set("msg", msg) params.Set("msg", msg)
} }
+11 -6
View File
@@ -48,10 +48,11 @@ func (h *Handler) ShopeeDetail(c *gin.Context) {
c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{ c.HTML(http.StatusOK, "shopee/detail_modal", gin.H{
"D": detail, "CSRFToken": csrfToken(c), "D": detail, "CSRFToken": csrfToken(c),
"Keyword": c.Query("q"), "SearchField": service.ParseShopeeSearchField(c.Query("search_field")), "Keyword": c.Query("q"), "SearchField": service.ParseShopeeSearchField(c.Query("search_field")),
"StatusFilter": service.ParseShopeeStatus(c.Query("status")), "StatusFilter": service.ParseShopeeStatus(c.Query("status")),
"DeletedFilter": currentUser(c).IsAdmin() && c.Query("deleted") == "1", "DeletedFilter": currentUser(c).IsAdmin() && c.Query("deleted") == "1",
"UnlinkedFilter": c.Query("unlinked") == "1", "UnlinkedFilter": c.Query("unlinked") == "1",
"CurrentPage": service.ParsePage(c.Query("page")), "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, ...) 渲染辅助函数, // 不叫 page:本文件末尾要调用同名的 page(c, ...) 渲染辅助函数,
// 局部变量会把它遮住导致编译失败。 // 局部变量会把它遮住导致编译失败。
pageNum := service.ParsePage(pageRaw) 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 { if err != nil {
fail(c, http.StatusInternalServerError, fail(c, http.StatusInternalServerError,
"读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") "读取蝦皮数据失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。")
@@ -104,6 +106,7 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, searchFieldRaw, stat
if filter.Unlinked { if filter.Unlinked {
values.Set("unlinked", "1") values.Set("unlinked", "1")
} }
values.Set("page_size", strconv.Itoa(pageSize))
c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{ c.HTML(http.StatusOK, "shopee/list", page(c, "shopee", "蝦皮数据", gin.H{
"Keyword": filter.Keyword, "Keyword": filter.Keyword,
@@ -117,8 +120,9 @@ func (h *Handler) renderShopeeList(c *gin.Context, keyword, searchFieldRaw, stat
"Status": status, "Status": status,
"HasAnyProducts": result.HasAnyProducts, "HasAnyProducts": result.HasAnyProducts,
"IsFiltered": result.IsFiltered, "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, "CurrentPage": result.Page,
"CurrentPageSize": pageSize,
"DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page), "DetailURL": "/shopee/detail?" + detailValuesForShopee(values, result.Page),
"AutoOpenDetailID": strings.TrimSpace(c.Query("open_id")), "AutoOpenDetailID": strings.TrimSpace(c.Query("open_id")),
"AssignableClients": assignableClients, "AssignableClients": assignableClients,
@@ -264,6 +268,7 @@ func (h *Handler) shopeeRedirect(c *gin.Context, msg string) {
if value := strings.TrimSpace(c.PostForm("page")); value != "" { if value := strings.TrimSpace(c.PostForm("page")); value != "" {
params.Set("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 != "" { if value := strings.TrimSpace(c.PostForm("open_id")); value != "" {
params.Set("open_id", value) params.Set("open_id", value)
} }
+7 -4
View File
@@ -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) { func (h *Handler) renderUserList(c *gin.Context, httpStatus int, message, errorMessage string, needCreate bool, createUsername string) {
keyword := strings.TrimSpace(userListValue(c, "q")) keyword := strings.TrimSpace(userListValue(c, "q"))
listStatus := userListValue(c, "status") listStatus := userListValue(c, "status")
pageSize := service.ParsePageSize(userListValue(c, "page_size"))
if c.Request.Method == http.MethodPost { if c.Request.Method == http.MethodPost {
listStatus = c.PostForm("list_status") listStatus = c.PostForm("list_status")
} }
result, err := service.ListUsers(h.db, currentUser(c), keyword, result, err := service.ListUsersWithPageSize(h.db, currentUser(c), keyword,
model.UserStatus(listStatus), service.ParsePage(userListValue(c, "page"))) model.UserStatus(listStatus), service.ParsePage(userListValue(c, "page")), pageSize)
if err != nil { if err != nil {
fail(c, http.StatusInternalServerError, "读取用户列表失败,账号数据没有被改动。刷新后重试。") fail(c, http.StatusInternalServerError, "读取用户列表失败,账号数据没有被改动。刷新后重试。")
return 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) status := fmt.Sprintf("共 %d 个用户 · 第 %d/%d 页", result.Total, result.Page, result.TotalPages)
c.HTML(httpStatus, "user/list", page(c, "users", "用户管理", gin.H{ c.HTML(httpStatus, "user/list", page(c, "users", "用户管理", gin.H{
"Rows": result.Rows, "Keyword": result.Keyword, "Status": status, "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, "StatusFilter": result.Status, "Message": message, "Error": errorMessage,
"NeedCreate": needCreate, "CreateUsername": createUsername, "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 != "" { if page := c.PostForm("page"); page != "" {
query.Set("page", page) query.Set("page", page)
} }
query.Set("page_size", fmt.Sprintf("%d", service.ParsePageSize(c.PostForm("page_size"))))
if message != "" { if message != "" {
query.Set("msg", message) query.Set("msg", message)
} }
+1 -1
View File
@@ -76,7 +76,7 @@ func TestInnerCodeTemplate_筛选顺序与紧凑宽度(t *testing.T) {
`id="inner-code-status"`, `id="inner-code-status"`,
`id="inner-code-date" type="date" name="date"`, `id="inner-code-date" type="date" name="date"`,
`id="inner-code-keyword"`, `id="inner-code-keyword"`,
`class="button-link" href="/inner-codes?date={{.BusinessDate}}">清除</a>`, `class="button-link" href="/inner-codes?date={{.BusinessDate}}&page_size={{.CurrentPageSize}}">清除</a>`,
`<button type="submit">搜索</button>`, `<button type="submit">搜索</button>`,
} }
lastIndex := -1 lastIndex := -1
+8 -2
View File
@@ -34,15 +34,21 @@ func ParseCatalogStatus(raw string) model.CatalogImportStatus {
} }
func ListCatalogHistory(q repository.Execer, source, statusRaw string, page int) (CatalogHistoryResult, error) { 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) status := ParseCatalogStatus(statusRaw)
totalPages := 1 totalPages := 1
_, total, err := repository.ListCatalogImportRuns(q, strings.TrimSpace(source), status, 1, 0) _, total, err := repository.ListCatalogImportRuns(q, strings.TrimSpace(source), status, 1, 0)
if err != nil { if err != nil {
return CatalogHistoryResult{}, err return CatalogHistoryResult{}, err
} }
totalPages = TotalPages(total) totalPages = TotalPages(total, pageSize)
page = ClampPage(page, totalPages) 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 { if err != nil {
return CatalogHistoryResult{}, err return CatalogHistoryResult{}, err
} }
+2 -2
View File
@@ -42,8 +42,8 @@ func ApplyInnerCodes(ctx context.Context, db *sql.DB, writer InnerCodeWriter, id
if len(ids) == 0 { if len(ids) == 0 {
return nil, fmt.Errorf("没有选择可回写记录") return nil, fmt.Errorf("没有选择可回写记录")
} }
if len(ids) > PageSize { if len(ids) > InnerCodeRemoteBatchLimit {
return nil, fmt.Errorf("单次最多回写当前页 %d 条记录", PageSize) return nil, fmt.Errorf("单次最多回写 %d 条记录", InnerCodeRemoteBatchLimit)
} }
result := &InnerCodeApplyResult{Requested: len(ids)} result := &InnerCodeApplyResult{Requested: len(ids)}
for _, id := range ids { for _, id := range ids {
+10
View File
@@ -20,6 +20,16 @@ type fakeInnerCodeWriter struct {
updatedCode string 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) { func (f *fakeInnerCodeWriter) DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error) {
f.readCount++ f.readCount++
if f.readErr != nil { if f.readErr != nil {
+2 -2
View File
@@ -31,8 +31,8 @@ func DeleteInnerCodes(db *sql.DB, ids []int64, actorUserID string, now time.Time
if len(unique) == 0 { if len(unique) == 0 {
return 0, fmt.Errorf("没有有效的档口入库码记录") return 0, fmt.Errorf("没有有效的档口入库码记录")
} }
if len(unique) > PageSize { if len(unique) > InnerCodeDeleteBatchLimit {
return 0, fmt.Errorf("一次最多删除当前页 %d 条记录", PageSize) return 0, fmt.Errorf("一次最多删除当前页 %d 条记录", InnerCodeDeleteBatchLimit)
} }
return repository.SoftDeleteInnerCodeRecords(db, unique, actorUserID, now.UTC().Format(model.TimeLayout)) return repository.SoftDeleteInnerCodeRecords(db, unique, actorUserID, now.UTC().Format(model.TimeLayout))
} }
+16 -3
View File
@@ -27,6 +27,13 @@ var innerCodeStatusOptions = []InnerCodeStatusOption{
{string(model.InnerCodeNeedsCheck), "需核对"}, {string(model.InnerCodeNeedsCheck), "需核对"},
} }
// 档口入库码的批量上限是业务安全限制,不能再复用界面每页条数。
// 删除只改本地数据库,可覆盖最大单页;匹配和回写会访问顺运宝,继续限制 20 条。
const (
InnerCodeDeleteBatchLimit = 100
InnerCodeRemoteBatchLimit = 20
)
// InnerCodeRowView 是正式页面的一行。 // InnerCodeRowView 是正式页面的一行。
type InnerCodeRowView struct { type InnerCodeRowView struct {
ID int64 ID int64
@@ -66,8 +73,14 @@ func InnerCodeStatusOptions() []InnerCodeStatusOption {
return append([]InnerCodeStatusOption(nil), innerCodeStatusOptions...) return append([]InnerCodeStatusOption(nil), innerCodeStatusOptions...)
} }
// ListInnerCodePage 校验筛选并返回统一 20 条 SQL 分页结果。 // ListInnerCodePage 使用默认页容量返回档口入库码列表。
func ListInnerCodePage(db *sql.DB, businessDate, status, keyword string, requestedPage int) (*InnerCodeListPage, error) { 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) status = strings.TrimSpace(status)
if !validInnerCodeStatusFilter(status) { if !validInnerCodeStatusFilter(status) {
status = "" status = ""
@@ -78,9 +91,9 @@ func ListInnerCodePage(db *sql.DB, businessDate, status, keyword string, request
if err != nil { if err != nil {
return nil, err return nil, err
} }
totalPages := TotalPages(total) totalPages := TotalPages(total, pageSize)
page := ClampPage(requestedPage, totalPages) 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 { if err != nil {
return nil, err return nil, err
} }
+90 -23
View File
@@ -6,15 +6,19 @@
package service package service
import ( import (
"net/url"
"slices"
"strconv" "strconv"
"strings" "strings"
) )
// PageSize 是全项目统一的每页条数。 // DefaultPageSize 是主列表没有指定 page_size 时使用的每页条数。
// //
// `[必须]` 固定 20:见 docs/admin/05-ui-specification.md §3.2—— // 20 行在 1366×768 上通常不需要滚动,因此继续作为默认值;实际页面请求必须
// 20 行在 1366×768 上正好一屏不用滚动。 // 通过 ParsePageSize 白名单解析。
const PageSize = 20 const DefaultPageSize = 20
var allowedPageSizes = [...]int{20, 50, 100}
// ParsePage 解析 URL 上的 page 查询参数。 // ParsePage 解析 URL 上的 page 查询参数。
// //
@@ -29,15 +33,36 @@ func ParsePage(s string) int {
return n 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 页", // `[必须]` 总数为 0 时返回 1,不是 0——页面要显示"第 1/1 页",
// 不能出现"第 1/0 页"(工单 #43)。 // 不能出现"第 1/0 页"(工单 #43)。
func TotalPages(total int) int { func TotalPages(total, pageSize int) int {
if total <= 0 { if total <= 0 {
return 1 return 1
} }
return (total + PageSize - 1) / PageSize pageSize = NormalizePageSize(pageSize)
return (total + pageSize - 1) / pageSize
} }
// ClampPage 把请求的页码限制在 [1, totalPages] 范围内。 // ClampPage 把请求的页码限制在 [1, totalPages] 范围内。
@@ -63,39 +88,81 @@ func ClampPage(page, totalPages int) int {
// 会把动态内容当成单个参数值整体转义,问号和等号会被转成 %3F/%3D, // 会把动态内容当成单个参数值整体转义,问号和等号会被转成 %3F/%3D,
// 链接直接失效——这是本工单实测踩到的一个坑,见 pagination_test.go。 // 链接直接失效——这是本工单实测踩到的一个坑,见 pagination_test.go。
type PaginationView struct { type PaginationView struct {
Page int Page int
TotalPages int PageSize int
HasPrev bool TotalPages int
HasNext bool HasPrev bool
FirstURL string HasNext bool
PrevURL string FirstURL string
NextURL string PrevURL string
LastURL 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 根据当前页、总页数和筛选查询串组装分页控件视图。 // NewPaginationView 根据当前页、总页数和筛选查询串组装分页控件视图。
// //
// baseQuery 是不含 page 的查询串(例如 url.Values{"status": {"no_link"}}.Encode() // baseQuery 是当前筛选查询串,由调用方(handler)负责把有效筛选编码进去;
// 的结果),由调用方(handler)负责把当前筛选和关键词编码进去—— // 本函数会覆盖 page_size 并移除 page,避免旧参数污染生成的链接。
// 这样翻页时筛选和关键词才不会丢,见工单 #43。 func NewPaginationView(page, pageSize, totalPages int, baseQuery string) PaginationView {
func NewPaginationView(page, totalPages int, baseQuery string) PaginationView { pageSize = NormalizePageSize(pageSize)
v := PaginationView{ v := PaginationView{
Page: page, Page: page,
PageSize: pageSize,
TotalPages: totalPages, TotalPages: totalPages,
HasPrev: page > 1, HasPrev: page > 1,
HasNext: page < totalPages, 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 { if v.HasPrev {
v.PrevURL = PaginationURL(baseQuery, page-1) v.PrevURL = PaginationURL(encodedQuery, page-1)
} }
if v.HasNext { 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 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 把筛选查询串和目标页码拼成一个完整的相对链接("?...")。 // PaginationURL 把筛选查询串和目标页码拼成一个完整的相对链接("?...")。
func PaginationURL(baseQuery string, page int) string { func PaginationURL(baseQuery string, page int) string {
q := "page=" + strconv.Itoa(page) q := "page=" + strconv.Itoa(page)
+48 -19
View File
@@ -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) { 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) t.Errorf("TotalPages(0) = %d,想要 1(不能出现『第 1/0 页』)", got)
} }
} }
func TestTotalPages_按PageSize向上取整(t *testing.T) { func TestTotalPages_按PageSize向上取整(t *testing.T) {
cases := map[int]int{ cases := map[int]int{
1: 1, 1: 1,
PageSize: 1, DefaultPageSize: 1,
PageSize + 1: 2, DefaultPageSize + 1: 2,
PageSize * 2: 2, DefaultPageSize * 2: 2,
5195: 260, // 工单 #43 实测样本量:5195 条 / 20 条一页 = 260 页 5195: 260, // 工单 #43 实测样本量:5195 条 / 20 条一页 = 260 页
} }
for total, want := range cases { 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) 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) { func TestClampPage_越界兜到最后一页(t *testing.T) {
if got := ClampPage(9999, 260); got != 260 { if got := ClampPage(9999, 260); got != 260 {
t.Errorf("ClampPage(9999, 260) = %d,想要 260(兜到最后一页,不是空表格)", got) t.Errorf("ClampPage(9999, 260) = %d,想要 260(兜到最后一页,不是空表格)", got)
@@ -62,7 +85,7 @@ func TestClampPage_合法范围原样返回(t *testing.T) {
} }
func TestNewPaginationView_首页禁用上一页和首页(t *testing.T) { func TestNewPaginationView_首页禁用上一页和首页(t *testing.T) {
v := NewPaginationView(1, 260, "") v := NewPaginationView(1, 20, 260, "")
if v.HasPrev { if v.HasPrev {
t.Error("第 1 页时 HasPrev 应为 false(首页/上一页要禁用)") t.Error("第 1 页时 HasPrev 应为 false(首页/上一页要禁用)")
} }
@@ -72,7 +95,7 @@ func TestNewPaginationView_首页禁用上一页和首页(t *testing.T) {
} }
func TestNewPaginationView_末页禁用下一页和末页(t *testing.T) { func TestNewPaginationView_末页禁用下一页和末页(t *testing.T) {
v := NewPaginationView(260, 260, "") v := NewPaginationView(260, 20, 260, "")
if v.HasPrev != true { if v.HasPrev != true {
t.Error("最后一页时 HasPrev 应为 true(前面还有页)") t.Error("最后一页时 HasPrev 应为 true(前面还有页)")
} }
@@ -82,25 +105,31 @@ func TestNewPaginationView_末页禁用下一页和末页(t *testing.T) {
} }
func TestNewPaginationView_总数为0时首尾都禁用(t *testing.T) { func TestNewPaginationView_总数为0时首尾都禁用(t *testing.T) {
v := NewPaginationView(1, 1, "") v := NewPaginationView(1, 20, 1, "")
if v.HasPrev || v.HasNext { if v.HasPrev || v.HasNext {
t.Errorf("只有 1 页时前后都应禁用,HasPrev=%v HasNext=%v", v.HasPrev, v.HasNext) t.Errorf("只有 1 页时前后都应禁用,HasPrev=%v HasNext=%v", v.HasPrev, v.HasNext)
} }
} }
func TestNewPaginationView_URL带上筛选条件(t *testing.T) { func TestNewPaginationView_URL带上筛选条件(t *testing.T) {
v := NewPaginationView(2, 260, "status=no_link") v := NewPaginationView(2, 50, 260, "status=no_link&page_size=50")
if v.PrevURL != "?status=no_link&page=1" { if v.PrevURL != "?page_size=50&status=no_link&page=1" {
t.Errorf("PrevURL = %q,想要 ?status=no_link&page=1", v.PrevURL) t.Errorf("PrevURL = %q,想要保留筛选和 page_size", v.PrevURL)
} }
if v.NextURL != "?status=no_link&page=3" { if v.NextURL != "?page_size=50&status=no_link&page=3" {
t.Errorf("NextURL = %q,想要 ?status=no_link&page=3", v.NextURL) t.Errorf("NextURL = %q,想要保留筛选和 page_size", v.NextURL)
} }
if v.FirstURL != "?status=no_link&page=1" { if v.FirstURL != "?page_size=50&status=no_link&page=1" {
t.Errorf("FirstURL = %q,想要 ?status=no_link&page=1", v.FirstURL) t.Errorf("FirstURL = %q,想要保留筛选和 page_size", v.FirstURL)
} }
if v.LastURL != "?status=no_link&page=260" { if v.LastURL != "?page_size=50&status=no_link&page=260" {
t.Errorf("LastURL = %q,想要 ?status=no_link&page=260", v.LastURL) 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)
} }
} }
+9 -3
View File
@@ -249,19 +249,25 @@ type PddListResult struct {
// ListPddProducts 查列表并把每一行翻成界面文字。 // ListPddProducts 查列表并把每一行翻成界面文字。
func ListPddProducts(db *sql.DB, keyword string, status model.CollectStatus, requestedPage int) (*PddListResult, error) { 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 page := requestedPage
if page < 1 { if page < 1 {
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 { if err != nil {
return nil, err return nil, err
} }
totalPages := TotalPages(filteredTotal) totalPages := TotalPages(filteredTotal, pageSize)
clampedPage := ClampPage(page, totalPages) clampedPage := ClampPage(page, totalPages)
if clampedPage != page { if clampedPage != page {
page = clampedPage 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 { if err != nil {
return nil, err return nil, err
} }
+26
View File
@@ -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) { func TestParsePddGoodsID_认得的写法(t *testing.T) {
+9 -3
View File
@@ -166,6 +166,12 @@ type ClientListResult struct {
// ListClientPageForUser 按网页登录身份、搜索词和统一页大小返回客户端列表。 // ListClientPageForUser 按网页登录身份、搜索词和统一页大小返回客户端列表。
func ListClientPageForUser(db *sql.DB, actor *model.User, keyword string, threshold time.Duration, requestedPage int) (*ClientListResult, error) { 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) visibleUserID, err := visibleClientUserID(actor)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -175,15 +181,15 @@ func ListClientPageForUser(db *sql.DB, actor *model.User, keyword string, thresh
if page < 1 { if page < 1 {
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 { if err != nil {
return nil, err return nil, err
} }
totalPages := TotalPages(total) totalPages := TotalPages(total, pageSize)
clampedPage := ClampPage(page, totalPages) clampedPage := ClampPage(page, totalPages)
if clampedPage != page { if clampedPage != page {
page = clampedPage 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 { if err != nil {
return nil, err return nil, err
} }
+10 -4
View File
@@ -81,15 +81,21 @@ type ShopeeListResult struct {
// (repository.CountShopeeProductsFiltered),分页页数据此算出, // (repository.CountShopeeProductsFiltered),分页页数据此算出,
// page 越界时兜到最后一页,见 service/pagination.go(工单 #43)。 // page 越界时兜到最后一页,见 service/pagination.go(工单 #43)。
func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*ShopeeListResult, error) { 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) total, err := repository.CountShopeeProductsFiltered(db, filter)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totalPages := TotalPages(total) totalPages := TotalPages(total, pageSize)
page = ClampPage(page, totalPages) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -105,7 +111,7 @@ func ListShopeeProducts(db *sql.DB, filter repository.ShopeeFilter, page int) (*
HasAnyProducts: hasAny > 0, HasAnyProducts: hasAny > 0,
IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "" || filter.Deleted || filter.Unlinked, IsFiltered: strings.TrimSpace(filter.Keyword) != "" || filter.Status != "" || filter.Deleted || filter.Unlinked,
Page: page, Page: page,
PageSize: PageSize, PageSize: pageSize,
TotalPages: totalPages, TotalPages: totalPages,
} }
for _, r := range rows { for _, r := range rows {
+14 -8
View File
@@ -1246,6 +1246,12 @@ func ParseSybOrderNos(raw string) ([]string, error) {
// `[必须]` 处理阶段由顺运宝规格键、PDD 关联、采集状态和规格映射实时推导, // `[必须]` 处理阶段由顺运宝规格键、PDD 关联、采集状态和规格映射实时推导,
// 不额外保存一份容易过期的状态字段。 // 不额外保存一份容易过期的状态字段。
func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) { 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) shop = strings.TrimSpace(shop)
stage = ParseSybStage(stage) stage = ParseSybStage(stage)
orderNos, err := ParseSybOrderNos(keyword) orderNos, err := ParseSybOrderNos(keyword)
@@ -1264,18 +1270,18 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
stage == SybStageAIMatched || stage == SybStageAIMatched ||
stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted || stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted ||
stage == SybStagePurchaseReview || stage == SybStagePurchaseBlocked { 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} filter := repository.SybOrderFilter{OrderNos: orderNos, Shop: shop, Stage: stage}
total, err := repository.CountSybOrders(db, filter) total, err := repository.CountSybOrders(db, filter)
if err != nil { if err != nil {
return nil, err return nil, err
} }
totalPages := TotalPages(total) totalPages := TotalPages(total, pageSize)
page = ClampPage(page, totalPages) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -1311,7 +1317,7 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL
// 这些阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的 // 这些阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的
// OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表 // 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, contexts, err := repository.ListSybOrderContexts(db,
repository.SybOrderFilter{OrderNos: orderNos, Shop: shop}, -1, 0) repository.SybOrderFilter{OrderNos: orderNos, Shop: shop}, -1, 0)
if err != nil { if err != nil {
@@ -1329,13 +1335,13 @@ func listAdvancedSybStage(db *sql.DB, orderNos []string, shop, stage string, pag
} }
} }
total := len(filtered) total := len(filtered)
totalPages := TotalPages(total) totalPages := TotalPages(total, pageSize)
page = ClampPage(page, totalPages) page = ClampPage(page, totalPages)
start := (page - 1) * PageSize start := (page - 1) * pageSize
if start > total { if start > total {
start = total start = total
} }
end := start + PageSize end := start + pageSize
if end > total { if end > total {
end = total end = total
} }
+14 -3
View File
@@ -322,6 +322,12 @@ type TaskListResult struct {
// `[必须]` 底部统计要跟随当前筛选,所以 Counts 和 Rows 用的是**同一个** // `[必须]` 底部统计要跟随当前筛选,所以 Counts 和 Rows 用的是**同一个**
// filter,见 #19。 // filter,见 #19。
func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int) (*TaskListResult, error) { 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) counts, err := repository.CountTasksByStatus(db, filter)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -330,9 +336,9 @@ func ListTasksView(db *sql.DB, filter repository.TaskFilter, requestedPage int)
for _, n := range counts { for _, n := range counts {
total += n total += n
} }
totalPages := TotalPages(total) totalPages := TotalPages(total, pageSize)
page := ClampPage(requestedPage, totalPages) 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 { if err != nil {
return nil, err return nil, err
} }
@@ -371,6 +377,11 @@ func taskCreatorText(username string) string {
// ListTasksViewForUser 在服务端固定当前账号范围,忽略采购员伪造的创建人筛选。 // ListTasksViewForUser 在服务端固定当前账号范围,忽略采购员伪造的创建人筛选。
func ListTasksViewForUser(db *sql.DB, actor *model.User, filter repository.TaskFilter, creatorValue string, requestedPage int) (*TaskListResult, error) { 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) visibleUserID, err := visibleClientUserID(actor)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -386,7 +397,7 @@ func ListTasksViewForUser(db *sql.DB, actor *model.User, filter repository.TaskF
filter.CreatorUserID = creatorValue filter.CreatorUserID = creatorValue
} }
} }
return ListTasksView(db, filter, requestedPage) return ListTasksViewWithPageSize(db, filter, requestedPage, pageSize)
} }
// StatusLine 拼底部状态条,形如: // StatusLine 拼底部状态条,形如:
+10 -4
View File
@@ -23,8 +23,14 @@ type UserListResult struct {
TotalPages int TotalPages int
} }
// ListUsers 搜索并分页。用户页沿用全站统一的每页 20 条规则。 // ListUsers 使用默认页容量搜索并分页。
func ListUsers(db *sql.DB, actor *model.User, keyword string, status model.UserStatus, requestedPage int) (*UserListResult, error) { 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() { if actor == nil || !actor.IsAdmin() {
return nil, ErrAdminRequired return nil, ErrAdminRequired
} }
@@ -36,15 +42,15 @@ func ListUsers(db *sql.DB, actor *model.User, keyword string, status model.UserS
if page < 1 { if page < 1 {
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 { if err != nil {
return nil, err return nil, err
} }
totalPages := TotalPages(total) totalPages := TotalPages(total, pageSize)
clampedPage := ClampPage(page, totalPages) clampedPage := ClampPage(page, totalPages)
if clampedPage != page { if clampedPage != page {
page = clampedPage 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 { if err != nil {
return nil, err return nil, err
} }
+21 -1
View File
@@ -675,6 +675,26 @@ select {
margin-left: 0; margin-left: 0;
flex: 0 0 auto; 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 a,
.pagination span.disabled { .pagination span.disabled {
padding: 4px 10px; padding: 4px 10px;
@@ -690,7 +710,7 @@ select {
body { padding-bottom: 0; } body { padding-bottom: 0; }
.statusbar { position: static; flex-wrap: wrap; gap: 6px 12px; } .statusbar { position: static; flex-wrap: wrap; gap: 6px 12px; }
.statusbar > span:first-child { flex-basis: 100%; } .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 { color: #1f6feb; background: #fff; }
.pagination a:hover { background: #f0f2f4; } .pagination a:hover { background: #f0f2f4; }
+2 -1
View File
@@ -57,6 +57,7 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
"Rows": rows, "PurchaseRows": rows, "Rows": rows, "PurchaseRows": rows,
"DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(), "DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(),
"SyncDateFrom": "2026-08-12", "SyncDateTo": "2026-08-13", "SyncDateFrom": "2026-08-12", "SyncDateTo": "2026-08-13",
"CurrentPageSize": 20,
"Keyword": "ORDER-READY,ORDER-MISSING", "Keyword": "ORDER-READY,ORDER-MISSING",
"OrderNoFeedback": "已输入 2 个订单号;当前筛选命中 1 个订单、1 条商品明细。 当前筛选未命中:ORDER-MISSING", "OrderNoFeedback": "已输入 2 个订单号;当前筛选命中 1 个订单、1 条商品明细。 当前筛选未命中:ORDER-MISSING",
} }
@@ -107,7 +108,7 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) {
`role="status">已输入 2 个订单号`, `role="status">已输入 2 个订单号`,
`当前筛选未命中:ORDER-MISSING`, `当前筛选未命中:ORDER-MISSING`,
`href="/syb?date_from=2026-08-12`, `href="/syb?date_from=2026-08-12`,
`date_to=2026-08-13">清除</a>`, `date_to=2026-08-13&page_size=20">清除</a>`,
`class="table-wrap syb-table-wrap"`, `class="table-wrap syb-table-wrap"`,
`class="syb-page-body"`, `class="syb-page-body"`,
`<th class="syb-col-shop">店铺</th>`, `<th class="syb-col-shop">店铺</th>`,
+1
View File
@@ -2,6 +2,7 @@
<div class="toolbar"> <div class="toolbar">
<a class="button" href="/shopee">返回蝦皮数据</a> <a class="button" href="/shopee">返回蝦皮数据</a>
<form class="inline grow" method="get" action="/integrations/catalog"> <form class="inline grow" method="get" action="/integrations/catalog">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<label for="catalog-source">来源</label><select id="catalog-source" name="source"><option value="">全部来源</option>{{range .Sources}}<option value="{{.}}" {{if eq . $.Source}}selected{{end}}>{{.}}</option>{{end}}</select> <label for="catalog-source">来源</label><select id="catalog-source" name="source"><option value="">全部来源</option>{{range .Sources}}<option value="{{.}}" {{if eq . $.Source}}selected{{end}}>{{.}}</option>{{end}}</select>
<label for="catalog-status">状态</label><select id="catalog-status" name="status">{{range .StatusOptions}}<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>{{end}}</select> <label for="catalog-status">状态</label><select id="catalog-status" name="status">{{range .StatusOptions}}<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>{{end}}</select>
<button type="submit">筛选</button> <button type="submit">筛选</button>
+5 -1
View File
@@ -3,6 +3,7 @@
<div class="toolbar"> <div class="toolbar">
<form class="inline grow" method="get" action="/clients"> <form class="inline grow" method="get" action="/clients">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<label for="q">名称</label> <label for="q">名称</label>
<input id="q" type="text" name="name" value="{{.Keyword}}" placeholder="客户端名称"> <input id="q" type="text" name="name" value="{{.Keyword}}" placeholder="客户端名称">
<button type="submit">搜索</button> <button type="submit">搜索</button>
@@ -12,6 +13,7 @@
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="name" value="{{.Keyword}}"> <input type="hidden" name="name" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<button type="submit" class="danger" data-need-checked>删除</button> <button type="submit" class="danger" data-need-checked>删除</button>
</form>{{end}} </form>{{end}}
</div> </div>
@@ -59,6 +61,7 @@
<input type="hidden" name="client_id" value="{{.ClientID}}"> <input type="hidden" name="client_id" value="{{.ClientID}}">
<input type="hidden" name="name" value="{{$.Keyword}}"> <input type="hidden" name="name" value="{{$.Keyword}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
<button type="submit" class="danger">解绑</button> <button type="submit" class="danger">解绑</button>
</form>{{end}} </form>{{end}}
</div> </div>
@@ -68,7 +71,7 @@
<tr class="empty"> <tr class="empty">
<td colspan="{{if $.CurrentUser.IsAdmin}}8{{else}}6{{end}}"> <td colspan="{{if $.CurrentUser.IsAdmin}}8{{else}}6{{end}}">
{{if $.IsFiltered}}当前筛选条件下没有客户端。<br> {{if $.IsFiltered}}当前筛选条件下没有客户端。<br>
<small>换个名称或清空搜索词再试。<a href="/clients">查看全部</a></small> <small>换个名称或清空搜索词再试。<a href="/clients?page_size={{.CurrentPageSize}}">查看全部</a></small>
{{else if $.CurrentUser.IsAdmin}}还没有客户端。<br> {{else if $.CurrentUser.IsAdmin}}还没有客户端。<br>
<small>客户端第一次调用领取接口时会自动登记到这里,不需要手工添加。</small> <small>客户端第一次调用领取接口时会自动登记到这里,不需要手工添加。</small>
{{else}}当前没有绑定给你的客户端。<br><small>如需使用客户端,请联系管理员绑定。</small>{{end}} {{else}}当前没有绑定给你的客户端。<br><small>如需使用客户端,请联系管理员绑定。</small>{{end}}
@@ -107,6 +110,7 @@
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="name" value="{{.Keyword}}"> <input type="hidden" name="name" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button type="button" data-modal-close>取消</button> <button type="button" data-modal-close>取消</button>
+7 -1
View File
@@ -5,6 +5,7 @@
<form class="inline" method="post" action="/inner-codes/import" enctype="multipart/form-data" <form class="inline" method="post" action="/inner-codes/import" enctype="multipart/form-data"
data-upload-form> data-upload-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<button type="button" data-inner-code-file-open>选择 Excel</button> <button type="button" data-inner-code-file-open>选择 Excel</button>
<input id="inner-code-file" type="file" name="file" accept=".xlsx" required data-file-input hidden> <input id="inner-code-file" type="file" name="file" accept=".xlsx" required data-file-input hidden>
<span class="file-field" data-file-name>未选择文件</span> <span class="file-field" data-file-name>未选择文件</span>
@@ -12,6 +13,7 @@
</form> </form>
<form id="inner-code-filter" class="inline grow inner-code-filter-form" method="get" action="/inner-codes"> <form id="inner-code-filter" class="inline grow inner-code-filter-form" method="get" action="/inner-codes">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<label for="inner-code-status">状态</label> <label for="inner-code-status">状态</label>
<select id="inner-code-status" name="status"> <select id="inner-code-status" name="status">
{{range .StatusOptions}}<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>{{end}} {{range .StatusOptions}}<option value="{{.Value}}" {{if eq .Value $.StatusFilter}}selected{{end}}>{{.Text}}</option>{{end}}
@@ -21,7 +23,7 @@
<label class="visually-hidden" for="inner-code-keyword">搜索订单号、档口、规格或入库码</label> <label class="visually-hidden" for="inner-code-keyword">搜索订单号、档口、规格或入库码</label>
<input id="inner-code-keyword" class="inner-code-search" type="text" name="q" value="{{.Keyword}}" <input id="inner-code-keyword" class="inner-code-search" type="text" name="q" value="{{.Keyword}}"
placeholder="订单号 / 档口 / 规格 / 入库码"> placeholder="订单号 / 档口 / 规格 / 入库码">
<a class="button-link" href="/inner-codes?date={{.BusinessDate}}">清除</a> <a class="button-link" href="/inner-codes?date={{.BusinessDate}}&page_size={{.CurrentPageSize}}">清除</a>
<button type="submit">搜索</button> <button type="submit">搜索</button>
</form> </form>
@@ -31,6 +33,7 @@
<input type="hidden" name="status" value="{{.StatusFilter}}"> <input type="hidden" name="status" value="{{.StatusFilter}}">
<input type="hidden" name="q" value="{{.Keyword}}"> <input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
</form> </form>
<button type="submit" class="primary" form="inner-code-match-form" <button type="submit" class="primary" form="inner-code-match-form"
data-need-checked="inner-code-match" disabled>匹配已选 <span data-inner-code-match-count>0</span> 条</button> data-need-checked="inner-code-match" disabled>匹配已选 <span data-inner-code-match-count>0</span> 条</button>
@@ -41,6 +44,7 @@
<input type="hidden" name="status" value="{{.StatusFilter}}"> <input type="hidden" name="status" value="{{.StatusFilter}}">
<input type="hidden" name="q" value="{{.Keyword}}"> <input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
</form> </form>
<button type="button" data-modal-open="inner-code-apply-modal" data-need-checked="inner-code-apply" <button type="button" data-modal-open="inner-code-apply-modal" data-need-checked="inner-code-apply"
data-inner-code-apply-open disabled>回写已选 <span data-inner-code-selected-count>0</span> 条</button> data-inner-code-apply-open disabled>回写已选 <span data-inner-code-selected-count>0</span> 条</button>
@@ -53,6 +57,7 @@
<input type="hidden" name="status" value="{{.StatusFilter}}"> <input type="hidden" name="status" value="{{.StatusFilter}}">
<input type="hidden" name="q" value="{{.Keyword}}"> <input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
</form> </form>
<button type="submit" class="danger" form="inner-code-delete-form" <button type="submit" class="danger" form="inner-code-delete-form"
data-need-checked="inner-code-delete" disabled>删除已选 <span data-inner-code-delete-count>0</span> 条</button> data-need-checked="inner-code-delete" disabled>删除已选 <span data-inner-code-delete-count>0</span> 条</button>
@@ -106,6 +111,7 @@
<input type="hidden" name="status" value="{{$.StatusFilter}}"> <input type="hidden" name="status" value="{{$.StatusFilter}}">
<input type="hidden" name="q" value="{{$.Keyword}}"> <input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
<button type="submit" class="link-button">重新核对</button> <button type="submit" class="link-button">重新核对</button>
</form> </form>
{{else}}—{{end}} {{else}}—{{end}}
+13
View File
@@ -14,6 +14,19 @@
<span>{{.Status}}</span> <span>{{.Status}}</span>
{{if .Pagination}} {{if .Pagination}}
<nav class="pagination" aria-label="分页"> <nav class="pagination" aria-label="分页">
<form method="get" class="page-size-form">
{{range .Pagination.FilterFields}}
<input type="hidden" name="{{.Name}}" value="{{.Value}}">
{{end}}
<label>每页
<select name="page_size">
{{range .Pagination.PageSizeOptions}}
<option value="{{.Value}}"{{if .Selected}} selected{{end}}>{{.Value}} 条</option>
{{end}}
</select>
</label>
<button type="submit">应用</button>
</form>
{{if .Pagination.HasPrev}} {{if .Pagination.HasPrev}}
<a href="{{.Pagination.FirstURL}}">首页</a> <a href="{{.Pagination.FirstURL}}">首页</a>
<a href="{{.Pagination.PrevURL}}">上一页</a> <a href="{{.Pagination.PrevURL}}">上一页</a>
+2
View File
@@ -19,6 +19,7 @@
<input type="hidden" name="status" value="{{$.StatusFilter}}"> <input type="hidden" name="status" value="{{$.StatusFilter}}">
<input type="hidden" name="q" value="{{$.Keyword}}"> <input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
</form> </form>
<form method="post" action="/pdd/save"> <form method="post" action="/pdd/save">
@@ -27,6 +28,7 @@
<input type="hidden" name="status" value="{{$.StatusFilter}}"> <input type="hidden" name="status" value="{{$.StatusFilter}}">
<input type="hidden" name="q" value="{{$.Keyword}}"> <input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
<div class="modal-body"> <div class="modal-body">
<div class="field"> <div class="field">
+5 -1
View File
@@ -19,6 +19,7 @@
<input type="hidden" name="status" value="{{.StatusFilter}}" form="pdd-form"> <input type="hidden" name="status" value="{{.StatusFilter}}" form="pdd-form">
<input type="hidden" name="q" value="{{.Keyword}}" form="pdd-form"> <input type="hidden" name="q" value="{{.Keyword}}" form="pdd-form">
<input type="hidden" name="page" value="{{.CurrentPage}}" form="pdd-form"> <input type="hidden" name="page" value="{{.CurrentPage}}" form="pdd-form">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}" form="pdd-form">
{{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队, {{/* 文案是「创建采集任务」不是「采集」:点完只是排了个队,
真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}} 真正去手机上采要等客户端来领,可能几秒也可能几分钟 */}}
@@ -27,6 +28,7 @@
data-need-checked>创建采集任务</button> data-need-checked>创建采集任务</button>
<form class="inline grow" method="get" action="/pdd"> <form class="inline grow" method="get" action="/pdd">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<label for="status">采集状态</label> <label for="status">采集状态</label>
<select id="status" name="status"> <select id="status" name="status">
{{range .StatusOptions}} {{range .StatusOptions}}
@@ -147,7 +149,7 @@
<td colspan="9"> <td colspan="9">
{{if .IsFiltered}} {{if .IsFiltered}}
当前筛选条件下没有商品。<br> 当前筛选条件下没有商品。<br>
<small>换个采集状态或清空搜索词再试。<a href="/pdd">查看全部</a></small> <small>换个采集状态或清空搜索词再试。<a href="/pdd?page_size={{.CurrentPageSize}}">查看全部</a></small>
{{else}} {{else}}
还没有 PDD 商品。<br> 还没有 PDD 商品。<br>
<small>点左上角「添加 PDD 商品」粘贴单条链接,或用「批量导入」上传 Excel。</small> <small>点左上角「添加 PDD 商品」粘贴单条链接,或用「批量导入」上传 Excel。</small>
@@ -177,6 +179,7 @@
<input type="hidden" name="status" value="{{.StatusFilter}}"> <input type="hidden" name="status" value="{{.StatusFilter}}">
<input type="hidden" name="q" value="{{.Keyword}}"> <input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<div class="modal-body"> <div class="modal-body">
<div class="field"> <div class="field">
<label for="create-url">PDD 链接</label> <label for="create-url">PDD 链接</label>
@@ -206,6 +209,7 @@
</div> </div>
<form method="post" action="/pdd/import" enctype="multipart/form-data" data-upload-form> <form method="post" action="/pdd/import" enctype="multipart/form-data" data-upload-form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<div class="modal-body"> <div class="modal-body">
<div class="field"> <div class="field">
<label for="pdd-import-file">Excel 文件</label> <label for="pdd-import-file">Excel 文件</label>
+3
View File
@@ -27,6 +27,7 @@
<input type="hidden" name="deleted" value="{{if $.DeletedFilter}}1{{end}}"> <input type="hidden" name="deleted" value="{{if $.DeletedFilter}}1{{end}}">
<input type="hidden" name="unlinked" value="{{if $.UnlinkedFilter}}1{{end}}"> <input type="hidden" name="unlinked" value="{{if $.UnlinkedFilter}}1{{end}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
<div class="field"> <div class="field">
<label for="shopee-pdd-url">PDD 商品链接</label> <label for="shopee-pdd-url">PDD 商品链接</label>
<input id="shopee-pdd-url" type="url" name="pdd_url" value="{{.PddURL}}" required placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=..."> <input id="shopee-pdd-url" type="url" name="pdd_url" value="{{.PddURL}}" required placeholder="https://mobile.yangkeduo.com/goods.html?goods_id=...">
@@ -51,6 +52,7 @@
<input type="hidden" name="deleted" value="{{if $.DeletedFilter}}1{{end}}"> <input type="hidden" name="deleted" value="{{if $.DeletedFilter}}1{{end}}">
<input type="hidden" name="unlinked" value="{{if $.UnlinkedFilter}}1{{end}}"> <input type="hidden" name="unlinked" value="{{if $.UnlinkedFilter}}1{{end}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
{{if .CanCollect}} {{if .CanCollect}}
<button type="submit">{{if eq .CollectStatus "failed"}}重新创建采集任务{{else}}创建采集任务{{end}}</button> <button type="submit">{{if eq .CollectStatus "failed"}}重新创建采集任务{{else}}创建采集任务{{end}}</button>
{{else}} {{else}}
@@ -90,6 +92,7 @@
<input type="hidden" name="deleted" value="{{if $.DeletedFilter}}1{{end}}"> <input type="hidden" name="deleted" value="{{if $.DeletedFilter}}1{{end}}">
<input type="hidden" name="unlinked" value="{{if $.UnlinkedFilter}}1{{end}}"> <input type="hidden" name="unlinked" value="{{if $.UnlinkedFilter}}1{{end}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
<div class="sku-edit-grid"> <div class="sku-edit-grid">
<label class="sku-edit-field"><span>颜色</span><input type="text" name="color" value="{{.Color}}" maxlength="500" required></label> <label class="sku-edit-field"><span>颜色</span><input type="text" name="color" value="{{.Color}}" maxlength="500" required></label>
<label class="sku-edit-field"><span>尺码</span><input type="text" name="size" value="{{.Size}}" maxlength="500" required></label> <label class="sku-edit-field"><span>尺码</span><input type="text" name="size" value="{{.Size}}" maxlength="500" required></label>
+5 -3
View File
@@ -5,7 +5,7 @@
<div class="toolbar"> <div class="toolbar">
{{if .CurrentUser.IsAdmin}}<a class="button" href="/integrations/catalog">导入记录</a>{{end}} {{if .CurrentUser.IsAdmin}}<a class="button" href="/integrations/catalog">导入记录</a>{{end}}
{{if .CurrentUser.IsAdmin}} {{if .CurrentUser.IsAdmin}}
{{if .DeletedFilter}}<a class="button-link" href="/shopee">返回正常商品</a>{{else}}<a class="button-link" href="/shopee?deleted=1">回收站</a>{{end}} {{if .DeletedFilter}}<a class="button-link" href="/shopee?page_size={{.CurrentPageSize}}">返回正常商品</a>{{else}}<a class="button-link" href="/shopee?deleted=1&page_size={{.CurrentPageSize}}">回收站</a>{{end}}
{{end}} {{end}}
<form id="shopee-bulk-form" method="post" action="/shopee/collect-batch" hidden></form> <form id="shopee-bulk-form" method="post" action="/shopee/collect-batch" hidden></form>
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="shopee-bulk-form"> <input type="hidden" name="csrf_token" value="{{.CSRFToken}}" form="shopee-bulk-form">
@@ -15,11 +15,13 @@
<input type="hidden" name="deleted" value="{{if .DeletedFilter}}1{{end}}" form="shopee-bulk-form"> <input type="hidden" name="deleted" value="{{if .DeletedFilter}}1{{end}}" form="shopee-bulk-form">
<input type="hidden" name="unlinked" value="{{if .UnlinkedFilter}}1{{end}}" form="shopee-bulk-form"> <input type="hidden" name="unlinked" value="{{if .UnlinkedFilter}}1{{end}}" form="shopee-bulk-form">
<input type="hidden" name="page" value="{{.CurrentPage}}" form="shopee-bulk-form"> <input type="hidden" name="page" value="{{.CurrentPage}}" form="shopee-bulk-form">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}" form="shopee-bulk-form">
{{if not .DeletedFilter}} {{if not .DeletedFilter}}
<button type="button" data-modal-open="shopee-collect-modal" data-collect-open <button type="button" data-modal-open="shopee-collect-modal" data-collect-open
data-collect-form="shopee-bulk-form" data-need-checked>创建 PDD 采集任务</button> data-collect-form="shopee-bulk-form" data-need-checked>创建 PDD 采集任务</button>
{{end}} {{end}}
<form class="inline grow" method="get" action="/shopee"> <form class="inline grow" method="get" action="/shopee">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
{{if .DeletedFilter}}<input type="hidden" name="deleted" value="1">{{end}} {{if .DeletedFilter}}<input type="hidden" name="deleted" value="1">{{end}}
{{if .UnlinkedFilter}}<input type="hidden" name="unlinked" value="1">{{end}} {{if .UnlinkedFilter}}<input type="hidden" name="unlinked" value="1">{{end}}
{{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花, {{/* 状态筛选是刚需(找待补规格 / 找没填链接的),不是锦上添花,
@@ -52,7 +54,7 @@
{{end}} {{end}}
</div> </div>
{{if .UnlinkedFilter}}<p class="hint" role="status">当前只显示尚未登记到店铺管理的蝦皮商品。<a href="/shopee">查看全部正常商品</a></p>{{end}} {{if .UnlinkedFilter}}<p class="hint" role="status">当前只显示尚未登记到店铺管理的蝦皮商品。<a href="/shopee?page_size={{.CurrentPageSize}}">查看全部正常商品</a></p>{{end}}
{{/* ── 第二段:带勾选的表格 ──────────────────────────── */}} {{/* ── 第二段:带勾选的表格 ──────────────────────────── */}}
{{/* 一个商品一行(不是一个 SKU 一行),见工单 #41。 {{/* 一个商品一行(不是一个 SKU 一行),见工单 #41。
@@ -107,7 +109,7 @@
<td colspan="13"> <td colspan="13">
{{if .IsFiltered}} {{if .IsFiltered}}
当前条件下没有商品。请调整状态、分类或关键词。<br> 当前条件下没有商品。请调整状态、分类或关键词。<br>
<small><a href="/shopee">清除筛选条件</a></small> <small><a href="/shopee?page_size={{.CurrentPageSize}}">清除筛选条件</a></small>
{{else}} {{else}}
还没有数据。蝦皮与 PDD 对应数据由第三方脚本通过商品目录接口导入。<br> 还没有数据。蝦皮与 PDD 对应数据由第三方脚本通过商品目录接口导入。<br>
{{if $.CurrentUser.IsAdmin}}<small>可点击左上角「导入记录」查看脚本提交结果。</small>{{end}} {{if $.CurrentUser.IsAdmin}}<small>可点击左上角「导入记录」查看脚本提交结果。</small>{{end}}
+3
View File
@@ -30,6 +30,7 @@
<input type="hidden" name="shop" value="{{$.ShopFilter}}"> <input type="hidden" name="shop" value="{{$.ShopFilter}}">
<input type="hidden" name="stage" value="{{$.StageFilter}}"> <input type="hidden" name="stage" value="{{$.StageFilter}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
<div class="field"> <div class="field">
<label for="syb-pdd-url">PDD 商品链接或商品 ID</label> <label for="syb-pdd-url">PDD 商品链接或商品 ID</label>
<input id="syb-pdd-url" type="text" name="pdd_url" value="{{.PddURL}}" required placeholder="完整拼多多链接,或 737116531267"> <input id="syb-pdd-url" type="text" name="pdd_url" value="{{.PddURL}}" required placeholder="完整拼多多链接,或 737116531267">
@@ -49,6 +50,7 @@
<input type="hidden" name="shop" value="{{$.ShopFilter}}"> <input type="hidden" name="shop" value="{{$.ShopFilter}}">
<input type="hidden" name="stage" value="{{$.StageFilter}}"> <input type="hidden" name="stage" value="{{$.StageFilter}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
{{if .CanCollect}} {{if .CanCollect}}
<div class="field"> <div class="field">
<label for="syb-detail-collect-client">执行客户端</label> <label for="syb-detail-collect-client">执行客户端</label>
@@ -77,6 +79,7 @@
<input type="hidden" name="shop" value="{{$.ShopFilter}}"> <input type="hidden" name="shop" value="{{$.ShopFilter}}">
<input type="hidden" name="stage" value="{{$.StageFilter}}"> <input type="hidden" name="stage" value="{{$.StageFilter}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
<div class="field"> <div class="field">
<label for="syb-pdd-option">顺运宝 {{.ProductSpec}} 对应的 PDD 规格</label> <label for="syb-pdd-option">顺运宝 {{.ProductSpec}} 对应的 PDD 规格</label>
<select id="syb-pdd-option" name="pdd_option_key" required> <select id="syb-pdd-option" name="pdd_option_key" required>
+9 -2
View File
@@ -8,6 +8,7 @@
<input type="hidden" name="shop" value="{{.ShopFilter}}"> <input type="hidden" name="shop" value="{{.ShopFilter}}">
<input type="hidden" name="stage" value="{{.StageFilter}}"> <input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<label class="visually-hidden" for="sync-date-from">开始日期</label> <label class="visually-hidden" for="sync-date-from">开始日期</label>
<input id="sync-date-from" type="date" name="date_from" <input id="sync-date-from" type="date" name="date_from"
value="{{.SyncDateFrom}}" max="{{.Today}}" required> value="{{.SyncDateFrom}}" max="{{.Today}}" required>
@@ -29,6 +30,7 @@
<input type="hidden" name="shop" value="{{.ShopFilter}}"> <input type="hidden" name="shop" value="{{.ShopFilter}}">
<input type="hidden" name="stage" value="{{.StageFilter}}"> <input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<input type="hidden" name="date_from" value="{{.SyncDateFrom}}"> <input type="hidden" name="date_from" value="{{.SyncDateFrom}}">
<input type="hidden" name="date_to" value="{{.SyncDateTo}}"> <input type="hidden" name="date_to" value="{{.SyncDateTo}}">
</form> </form>
@@ -43,6 +45,7 @@
<input type="hidden" name="shop" value="{{.ShopFilter}}" form="syb-collect-form"> <input type="hidden" name="shop" value="{{.ShopFilter}}" form="syb-collect-form">
<input type="hidden" name="stage" value="{{.StageFilter}}" form="syb-collect-form"> <input type="hidden" name="stage" value="{{.StageFilter}}" form="syb-collect-form">
<input type="hidden" name="page" value="{{.CurrentPage}}" form="syb-collect-form"> <input type="hidden" name="page" value="{{.CurrentPage}}" form="syb-collect-form">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}" form="syb-collect-form">
<button type="button" data-modal-open="syb-collect-modal" data-collect-open <button type="button" data-modal-open="syb-collect-modal" data-collect-open
data-collect-form="syb-collect-form" data-collect-action="collect" data-collect-form="syb-collect-form" data-collect-action="collect"
data-need-checked="collect" aria-label="创建采集任务">创建采集</button> data-need-checked="collect" aria-label="创建采集任务">创建采集</button>
@@ -51,6 +54,7 @@
data-purchase-open aria-label="创建采购任务">创建采购</button> data-purchase-open aria-label="创建采购任务">创建采购</button>
<form class="inline grow syb-filter-form" method="get" action="/syb"> <form class="inline grow syb-filter-form" method="get" action="/syb">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<label for="stage">处理阶段</label> <label for="stage">处理阶段</label>
<select id="stage" name="stage"> <select id="stage" name="stage">
{{range .StageOptions}} {{range .StageOptions}}
@@ -65,7 +69,7 @@
class="syb-search-input syb-order-search-input" placeholder="多个可直接粘贴" maxlength="4000" class="syb-search-input syb-order-search-input" placeholder="多个可直接粘贴" maxlength="4000"
title="支持中英文逗号、分号、空格、Tab 或换行分隔,最多 100 个订单号" title="支持中英文逗号、分号、空格、Tab 或换行分隔,最多 100 个订单号"
{{if .OrderNoError}}aria-invalid="true" aria-describedby="order-no-error"{{end}}>{{.Keyword}}</textarea> {{if .OrderNoError}}aria-invalid="true" aria-describedby="order-no-error"{{end}}>{{.Keyword}}</textarea>
<a class="button-link" href="/syb?date_from={{urlquery .SyncDateFrom}}&date_to={{urlquery .SyncDateTo}}">清除</a> <a class="button-link" href="/syb?date_from={{urlquery .SyncDateFrom}}&date_to={{urlquery .SyncDateTo}}&page_size={{.CurrentPageSize}}">清除</a>
<button type="submit">搜索</button> <button type="submit">搜索</button>
</form> </form>
@@ -75,6 +79,7 @@
<input type="hidden" name="shop" value="{{.ShopFilter}}"> <input type="hidden" name="shop" value="{{.ShopFilter}}">
<input type="hidden" name="stage" value="{{.StageFilter}}"> <input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<button type="submit" class="danger" data-need-checked>删除</button> <button type="submit" class="danger" data-need-checked>删除</button>
</form> </form>
</div> </div>
@@ -182,7 +187,7 @@
<td colspan="12"> <td colspan="12">
{{if .IsFiltered}} {{if .IsFiltered}}
当前筛选条件下没有货运单明细。<br> 当前筛选条件下没有货运单明细。<br>
<small>换个店铺、订单号或清空筛选条件再试。<a href="/syb">查看全部</a></small> <small>换个店铺、订单号或清空筛选条件再试。<a href="/syb?page_size={{.CurrentPageSize}}">查看全部</a></small>
{{else}} {{else}}
还没有货运单明细。<br> 还没有货运单明细。<br>
<small>点上方「同步」从顺运宝拉取,第一次同步需要先登录(验证码需要手工输入)。</small> <small>点上方「同步」从顺运宝拉取,第一次同步需要先登录(验证码需要手工输入)。</small>
@@ -309,6 +314,7 @@
<input type="hidden" name="shop" value="{{.ShopFilter}}"> <input type="hidden" name="shop" value="{{.ShopFilter}}">
<input type="hidden" name="stage" value="{{.StageFilter}}"> <input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<div class="modal-body form-stack"> <div class="modal-body form-stack">
<div class="field"> <div class="field">
<label for="purchase-client">执行客户端</label> <label for="purchase-client">执行客户端</label>
@@ -389,6 +395,7 @@
<input type="hidden" name="shop" value="{{.ShopFilter}}"> <input type="hidden" name="shop" value="{{.ShopFilter}}">
<input type="hidden" name="stage" value="{{.StageFilter}}"> <input type="hidden" name="stage" value="{{.StageFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
{{/* 登录和 OCR 降级后必须把工具栏里已确认的日期范围原样带回来。 */}} {{/* 登录和 OCR 降级后必须把工具栏里已确认的日期范围原样带回来。 */}}
<input type="hidden" name="date_from" value="{{.SyncDateFrom}}"> <input type="hidden" name="date_from" value="{{.SyncDateFrom}}">
<input type="hidden" name="date_to" value="{{.SyncDateTo}}"> <input type="hidden" name="date_to" value="{{.SyncDateTo}}">
+4 -1
View File
@@ -6,6 +6,7 @@
所以类型/状态筛选放在关键词搜索前面,见 #19。 */}} 所以类型/状态筛选放在关键词搜索前面,见 #19。 */}}
<div class="toolbar"> <div class="toolbar">
<form class="inline" method="get" action="/tasks"> <form class="inline" method="get" action="/tasks">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<label for="type">类型</label> <label for="type">类型</label>
<select id="type" name="type"> <select id="type" name="type">
{{range .TypeOptions}} {{range .TypeOptions}}
@@ -30,6 +31,7 @@
</form> </form>
<form class="inline grow" method="get" action="/tasks"> <form class="inline grow" method="get" action="/tasks">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<input type="hidden" name="type" value="{{.TypeFilter}}"> <input type="hidden" name="type" value="{{.TypeFilter}}">
<input type="hidden" name="status" value="{{.StatusFilter}}"> <input type="hidden" name="status" value="{{.StatusFilter}}">
{{if .ShowCreator}}<input type="hidden" name="creator" value="{{.CreatorFilter}}">{{end}} {{if .ShowCreator}}<input type="hidden" name="creator" value="{{.CreatorFilter}}">{{end}}
@@ -49,6 +51,7 @@
{{if .ShowCreator}}<input type="hidden" name="creator" value="{{.CreatorFilter}}" form="task-form">{{end}} {{if .ShowCreator}}<input type="hidden" name="creator" value="{{.CreatorFilter}}" form="task-form">{{end}}
<input type="hidden" name="q" value="{{.Keyword}}" form="task-form"> <input type="hidden" name="q" value="{{.Keyword}}" form="task-form">
<input type="hidden" name="page" value="{{.CurrentPage}}" form="task-form"> <input type="hidden" name="page" value="{{.CurrentPage}}" form="task-form">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}" form="task-form">
<button type="submit" form="task-form" formaction="/tasks/delete" class="danger" <button type="submit" form="task-form" formaction="/tasks/delete" class="danger"
data-need-checked data-need-checked
@@ -97,7 +100,7 @@
<td colspan="{{if .ShowCreator}}10{{else}}9{{end}}"> <td colspan="{{if .ShowCreator}}10{{else}}9{{end}}">
{{if .IsFiltered}} {{if .IsFiltered}}
当前筛选条件下没有任务。<br> 当前筛选条件下没有任务。<br>
<small>换个类型、状态或关键词再试。<a href="/tasks">查看全部</a></small> <small>换个类型、状态或关键词再试。<a href="/tasks?page_size={{.CurrentPageSize}}">查看全部</a></small>
{{else}} {{else}}
还没有任务。<br> 还没有任务。<br>
<small>到「PDD 商品」勾选商品点「创建采集任务」, <small>到「PDD 商品」勾选商品点「创建采集任务」,
+5 -1
View File
@@ -3,6 +3,7 @@
<div class="toolbar"> <div class="toolbar">
<form class="inline grow" method="get" action="/users"> <form class="inline grow" method="get" action="/users">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
<label for="user-q">用户名</label> <label for="user-q">用户名</label>
<input id="user-q" class="search-narrow" type="text" name="q" <input id="user-q" class="search-narrow" type="text" name="q"
value="{{.Keyword}}" placeholder="搜索用户名"> value="{{.Keyword}}" placeholder="搜索用户名">
@@ -53,6 +54,7 @@
<input type="hidden" name="q" value="{{$.Keyword}}"> <input type="hidden" name="q" value="{{$.Keyword}}">
<input type="hidden" name="list_status" value="{{$.StatusFilter}}"> <input type="hidden" name="list_status" value="{{$.StatusFilter}}">
<input type="hidden" name="page" value="{{$.CurrentPage}}"> <input type="hidden" name="page" value="{{$.CurrentPage}}">
<input type="hidden" name="page_size" value="{{$.CurrentPageSize}}">
<button type="submit" {{if .IsActive}}class="danger"{{end}}>{{if .IsActive}}禁用{{else}}启用{{end}}</button> <button type="submit" {{if .IsActive}}class="danger"{{end}}>{{if .IsActive}}禁用{{else}}启用{{end}}</button>
</form> </form>
<button type="button" data-modal-open="reset-password-modal" <button type="button" data-modal-open="reset-password-modal"
@@ -64,7 +66,7 @@
{{else}} {{else}}
<tr class="empty"> <tr class="empty">
<td colspan="6"> <td colspan="6">
{{if .Keyword}}没有匹配的用户。<a href="/users">清除搜索条件</a> {{if .Keyword}}没有匹配的用户。<a href="/users?page_size={{.CurrentPageSize}}">清除搜索条件</a>
{{else}}还没有用户。{{end}} {{else}}还没有用户。{{end}}
</td> </td>
</tr> </tr>
@@ -102,6 +104,7 @@
<input type="hidden" name="q" value="{{.Keyword}}"> <input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="list_status" value="{{.StatusFilter}}"> <input type="hidden" name="list_status" value="{{.StatusFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button type="button" data-modal-close>取消</button> <button type="button" data-modal-close>取消</button>
@@ -137,6 +140,7 @@
<input type="hidden" name="q" value="{{.Keyword}}"> <input type="hidden" name="q" value="{{.Keyword}}">
<input type="hidden" name="list_status" value="{{.StatusFilter}}"> <input type="hidden" name="list_status" value="{{.StatusFilter}}">
<input type="hidden" name="page" value="{{.CurrentPage}}"> <input type="hidden" name="page" value="{{.CurrentPage}}">
<input type="hidden" name="page_size" value="{{.CurrentPageSize}}">
</div> </div>
<div class="modal-foot"> <div class="modal-foot">
<button type="button" data-modal-close>取消</button> <button type="button" data-modal-close>取消</button>
+17 -8
View File
@@ -63,7 +63,8 @@
见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。 见 §5.6。含糊其辞和吓唬人一样糟——两种都会让操作员不敢动手。
- `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。 - `[必须]` 删除、导入用 **POST**,不得用 GET。浏览器和插件会预取 GET 链接。
- `[建议]` 默认按 `更新时间 DESC` 排序。 - `[建议]` 默认按 `更新时间 DESC` 排序。
- `[必须]` 每页 **20** 条,超过分页,见 §3.2。搜索走数据库,不要一次查出来在内存里过滤。 - `[必须]` 默认每页 **20** 条,可选 50 或 100 条,超过分页,见 §3.2。
搜索走数据库,不要一次查出来在内存里过滤。
- `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。 - `[必须]` 长文本(商品标题)截断显示,鼠标悬停给完整内容。
- `[必须]` 空状态要分情况,文案不能都是"暂无数据": - `[必须]` 空状态要分情况,文案不能都是"暂无数据":
- 从没导入过 → "还没有数据,点左上角『导入』开始" - 从没导入过 → "还没有数据,点左上角『导入』开始"
@@ -93,10 +94,11 @@
### 3.2 分页 ### 3.2 分页
`[必须]` 六个主列表(蝦皮、PDD、顺运宝、采集采购、客户端、用户) `[必须]` 使用公共分页的列表(蝦皮、PDD、顺运宝、采集采购、客户端、用户、
**统一每页 20 条**(原来这里写的是"建议一页 50 条", 商品目录导入记录、档口入库码)统一提供 **20 / 50 / 100 条**三档,默认 20。
已按工单 #43 改为 20 并升级为 `[必须]`)——20 行在 1366×768 上正好一屏, 20 行在 1366×768 上通常不需要滚动;50 和 100 用于集中查看或批量勾选,最大值
不用滚动就能看完;50 条要滚动,操作员容易漏看最下面几行。 固定为 100,不提供“全部”或任意自定义数量。顺运宝同步记录是紧凑弹窗,继续固定
每页 10 条;顺运宝上游接口的抓取页容量也不是这里的界面设置。
蝦皮数据页(§4)是全项目第一个做分页的页面,本节是它定下的通用规则, 蝦皮数据页(§4)是全项目第一个做分页的页面,本节是它定下的通用规则,
其余主列表复用公共实现,不要各写一套: 其余主列表复用公共实现,不要各写一套:
@@ -108,6 +110,10 @@
而且不会报错、不容易发现(#19 已经踩过一次)。 而且不会报错、不容易发现(#19 已经踩过一次)。
- `[必须]` 页码参数是 `?page=N`,从 **1** 开始,不是 0——给操作员看的东西 - `[必须]` 页码参数是 `?page=N`,从 **1** 开始,不是 0——给操作员看的东西
不要 0-based。 不要 0-based。
- `[必须]` 每页条数参数是 `?page_size=N`,只接受 20、50、100;缺失、非数字或
其他值都回退为 20,不能把用户输入直接交给 SQL `LIMIT`。
- `[必须]` 修改每页条数时回到第 1 页;筛选、翻页、写操作跳转、F5、浏览器
前进后退和模块状态恢复都保留归一化后的 `page_size`。
- `[必须]` 越界要兜住: - `[必须]` 越界要兜住:
- `page` 非数字、`0`、负数 → 当作第 1 页; - `page` 非数字、`0`、负数 → 当作第 1 页;
- `page` 超过总页数 → 显示**最后一页**(有数据的那页),不是空表格; - `page` 超过总页数 → 显示**最后一页**(有数据的那页),不是空表格;
@@ -126,13 +132,16 @@
分页按钮或表格最后一行。 分页按钮或表格最后一行。
- `[必须]` 表格勾选只代表当前页。翻页后不保留勾选,也不提供跨页批量操作, - `[必须]` 表格勾选只代表当前页。翻页后不保留勾选,也不提供跨页批量操作,
避免操作员看不到将被处理的记录。 避免操作员看不到将被处理的记录。
- `[必须]` 每页条数只控制读取和展示,不能充当外部写操作的安全上限。档口入库码
匹配、回写等顺运宝远程操作继续使用独立的单次上限 20;修改页面容量不能自动
放宽它。
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义, - `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
操作员应该靠筛选定位,不是靠翻页数页码。 操作员应该靠筛选定位,不是靠翻页数页码。
公共实现放在 `admin/service/pagination.go`(`PageSize` 常量、 公共实现放在 `admin/service/pagination.go`(默认值与白名单、
`ParsePage` / `ClampPage` / `TotalPages` / `NewPaginationView`)和 `ParsePage` / `ParsePageSize` / `ClampPage` / `TotalPages` / `NewPaginationView`)和
`admin/templates/partials/footer.html`(分页控件,`Pagination` 字段 `admin/templates/partials/footer.html`(分页控件,`Pagination` 字段
为空时不渲染)。六个主列表全部直接复用,不要各写一份。 为空时不渲染)。所有列表直接复用,不要各写一份。
## 4. 蝦皮数据页 ## 4. 蝦皮数据页