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