feat: 优化批量匹配和分页默认值 (#244)

This commit is contained in:
chengma
2026-08-15 15:56:16 +08:00
parent f315b02d3d
commit ad5bd834d3
17 changed files with 340 additions and 82 deletions
+61 -24
View File
@@ -14,12 +14,18 @@ import (
"github.com/gin-gonic/gin"
"cmautobuy/admin/config"
"cmautobuy/admin/model"
"cmautobuy/admin/service"
"cmautobuy/admin/syb"
)
var innerCodeLocation = time.FixedZone("UTC+8", 8*60*60)
const (
innerCodeFeedbackSuccess = "success"
innerCodeFeedbackError = "error"
)
// InnerCodeList 渲染独立的档口入库码工作台。
func (h *Handler) InnerCodeList(c *gin.Context) {
businessDate := strings.TrimSpace(c.Query("date"))
@@ -28,6 +34,11 @@ func (h *Handler) InnerCodeList(c *gin.Context) {
}
status := strings.TrimSpace(c.Query("status"))
keyword := strings.TrimSpace(c.Query("q"))
feedbackKind := strings.TrimSpace(c.Query("feedback"))
feedbackMessage := strings.TrimSpace(c.Query("message"))
if feedbackKind != innerCodeFeedbackSuccess && feedbackKind != innerCodeFeedbackError {
feedbackKind, feedbackMessage = "", ""
}
pageSize := service.ParsePageSize(c.Query("page_size"))
result, err := service.ListInnerCodePageWithPageSize(h.db, businessDate, status, keyword,
service.ParsePage(c.Query("page")), pageSize)
@@ -52,7 +63,9 @@ func (h *Handler) InnerCodeList(c *gin.Context) {
"IsFiltered": result.IsFiltered,
"CurrentPage": result.Page,
"CurrentPageSize": pageSize,
"Message": strings.TrimSpace(c.Query("message")),
"FeedbackMessage": feedbackMessage,
"FeedbackSuccess": feedbackMessage != "" && feedbackKind == innerCodeFeedbackSuccess,
"FeedbackError": feedbackMessage != "" && feedbackKind == innerCodeFeedbackError,
"Status": service.InnerCodeStatusMessage(result),
"Pagination": service.NewPaginationView(result.Page, pageSize, result.TotalPages, query.Encode()),
}))
@@ -113,7 +126,7 @@ func (h *Handler) InnerCodeImport(c *gin.Context) {
}
message := fmt.Sprintf("导入完成:业务日期 %s,读取 %d 行,新增 %d 条,更新 %d 条,恢复 %d 条,同业务键合并 %d 行。",
result.BusinessDate, result.TotalRows, result.CreatedCount, result.UpdatedCount, result.RestoredCount, result.MergedRows)
h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, message)
h.innerCodeRedirect(c, result.BusinessDate, "", "", 1, innerCodeFeedbackSuccess, message)
}
// InnerCodeDelete 软删除当前页已选记录;不撤销任何顺运宝远端操作。
@@ -123,10 +136,11 @@ func (h *Handler) InnerCodeDelete(c *gin.Context) {
ids := parseInnerCodeIDs(c.PostFormArray("ids"))
count, err := service.DeleteInnerCodes(h.db, ids, currentUser(c).UserID, time.Now())
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "删除失败:"+err.Error()+";没有部分删除,请刷新后重试。")
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
"删除失败:"+err.Error()+";没有部分删除,请刷新后重试。")
return
}
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber,
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackSuccess,
fmt.Sprintf("已删除 %d 条档口入库码记录;顺运宝已写入的快递单号不会撤销。重新导入相同数据可恢复。", count))
}
@@ -136,26 +150,26 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) {
status, keyword := c.PostForm("status"), c.PostForm("q")
pageNumber := service.ParsePage(c.PostForm("page"))
if _, err := time.Parse("2006-01-02", businessDate); err != nil {
h.innerCodeRedirect(c, time.Now().In(innerCodeLocation).Format("2006-01-02"), status, keyword, 1, "业务日期无效,没有执行匹配。")
h.innerCodeRedirect(c, time.Now().In(innerCodeLocation).Format("2006-01-02"), status, keyword, 1,
innerCodeFeedbackError, "业务日期无效,没有执行匹配。")
return
}
ids := parseInnerCodeIDs(c.PostFormArray("ids"))
if len(ids) == 0 {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "请先勾选要匹配的记录。")
return
}
if len(ids) > service.InnerCodeRemoteBatchLimit {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "一次最多匹配 20 条记录,请减少勾选后重试。")
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
"请先勾选要匹配的记录。")
return
}
cfg, err := config.Load()
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "顺运宝配置不可用,没有执行匹配。")
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
"顺运宝配置不可用,没有执行匹配。")
return
}
client, err := syb.New(cfg.Syb.BaseURL)
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "顺运宝地址配置有误,没有执行匹配。")
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
"顺运宝地址配置有误,没有执行匹配。")
return
}
autoLoggedIn, message := ensureInnerCodeMatchSession(
@@ -163,12 +177,13 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) {
func() (string, bool) { return h.attemptSybAutoLogin(c, client, *cfg) },
)
if message != "" {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message)
return
}
result, err := service.PlanInnerCodeRecords(c.Request.Context(), h.db, client, businessDate, ids)
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "匹配失败:"+err.Error())
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
"匹配失败:"+err.Error()+";本次没有保存不完整的匹配结果,请按提示处理后重试。")
return
}
message = fmt.Sprintf("匹配完成:处理 %d 条,可回写 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条。",
@@ -176,7 +191,12 @@ func (h *Handler) InnerCodeMatch(c *gin.Context) {
if autoLoggedIn {
message = "顺运宝自动登录成功;" + message
}
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
feedbackKind := innerCodeFeedbackSuccess
if result.Failed > 0 || result.Skipped > 0 {
feedbackKind = innerCodeFeedbackError
message += " 请查看状态为“已跳过”或“失败”的记录,按逐行原因处理后重新勾选匹配。"
}
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, feedbackKind, message)
}
// ensureInnerCodeMatchSession 只负责“检查缓存 → 必要时自动登录”的编排。
@@ -207,17 +227,23 @@ func (h *Handler) InnerCodeApply(c *gin.Context) {
ids := parseInnerCodeIDs(c.PostFormArray("ids"))
client, message := h.innerCodeSybClient()
if message != "" {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message)
return
}
result, err := service.ApplyInnerCodes(c.Request.Context(), h.db, client, ids, currentUser(c).UserID)
if err != nil {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "回写未完成:"+err.Error())
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
"回写未完成:"+err.Error())
return
}
message = fmt.Sprintf("回写完成:选择 %d 条,成功 %d 条,已存在 %d 条,跳过 %d 条,失败 %d 条,需核对 %d 条。",
result.Requested, result.Updated, result.AlreadyFilled, result.Skipped, result.Failed, result.NeedsCheck)
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
feedbackKind := innerCodeFeedbackSuccess
if result.Failed > 0 || result.NeedsCheck > 0 {
feedbackKind = innerCodeFeedbackError
message += " 请查看失败或需核对的记录,确认远端结果后再继续。"
}
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, feedbackKind, message)
}
func parseInnerCodeIDs(rawIDs []string) []int64 {
@@ -240,19 +266,26 @@ func (h *Handler) InnerCodeRecheck(c *gin.Context) {
pageNumber := service.ParsePage(c.PostForm("page"))
id, err := strconv.ParseInt(strings.TrimSpace(c.PostForm("id")), 10, 64)
if err != nil || id <= 0 {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, "记录编号无效,没有执行核对。")
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError,
"记录编号无效,没有执行核对。")
return
}
client, message := h.innerCodeSybClient()
if message != "" {
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message)
return
}
_, message, err = service.RecheckInnerCode(c.Request.Context(), h.db, client, id)
recheckStatus, message, err := service.RecheckInnerCode(c.Request.Context(), h.db, client, id)
if err != nil {
message = "重新核对失败:" + err.Error()
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, innerCodeFeedbackError, message)
return
}
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, message)
feedbackKind := innerCodeFeedbackSuccess
if recheckStatus == model.InnerCodeNeedsCheck {
feedbackKind = innerCodeFeedbackError
}
h.innerCodeRedirect(c, businessDate, status, keyword, pageNumber, feedbackKind, message)
}
func (h *Handler) innerCodeSybClient() (*syb.Client, string) {
@@ -273,8 +306,12 @@ func (h *Handler) innerCodeSybClient() (*syb.Client, string) {
return client, ""
}
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}}
func (h *Handler) innerCodeRedirect(c *gin.Context, businessDate, status, keyword string, pageNumber int, feedbackKind, message string) {
values := url.Values{"date": {businessDate}, "page": {strconv.Itoa(pageNumber)}}
if message = strings.TrimSpace(message); message != "" {
values.Set("feedback", feedbackKind)
values.Set("message", message)
}
values.Set("page_size", strconv.Itoa(service.ParsePageSize(c.PostForm("page_size"))))
if strings.TrimSpace(status) != "" {
values.Set("status", strings.TrimSpace(status))
+45
View File
@@ -2,9 +2,15 @@ package web
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"testing"
"github.com/gin-gonic/gin"
"cmautobuy/admin/service"
)
@@ -15,6 +21,45 @@ func TestParseInnerCodeIDs_过滤无效并保持首次选择顺序(t *testing.T)
}
}
func TestInnerCodeRedirect_区分错误反馈并使用默认100条(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
form := url.Values{"page_size": {""}}
context.Request = httptest.NewRequest(http.MethodPost, "/inner-codes/match", strings.NewReader(form.Encode()))
context.Request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
(&Handler{}).innerCodeRedirect(context, "2026-08-15", "pending", "ORDER-1", 2,
innerCodeFeedbackError, "匹配失败:测试错误")
if context.Writer.Status() != http.StatusSeeOther {
t.Fatalf("状态码=%d,期望 303", context.Writer.Status())
}
location, err := url.Parse(recorder.Header().Get("Location"))
if err != nil {
t.Fatal(err)
}
query := location.Query()
if location.Path != "/inner-codes" || query.Get("feedback") != "error" ||
query.Get("message") != "匹配失败:测试错误" || query.Get("page_size") != "100" ||
query.Get("date") != "2026-08-15" || query.Get("status") != "pending" ||
query.Get("q") != "ORDER-1" || query.Get("page") != "2" {
t.Fatalf("反馈跳转参数不完整: %s", recorder.Header().Get("Location"))
}
}
func TestInnerCodeMatch_不再按20条提前拒绝只读匹配(t *testing.T) {
raw, err := os.ReadFile("inner_code.go")
if err != nil {
t.Fatal(err)
}
source := string(raw)
for _, forbidden := range []string{"InnerCodeRemoteBatchLimit", "一次最多匹配 20 条"} {
if strings.Contains(source, forbidden) {
t.Errorf("只读匹配仍包含旧限制 %q", forbidden)
}
}
}
func TestEnsureInnerCodeMatchSession_有效会话不自动登录(t *testing.T) {
loginCalls := 0
autoLoggedIn, message := ensureInnerCodeMatchSession(
+1 -1
View File
@@ -101,7 +101,7 @@ func Test列表写操作跳转保留筛选和页码(t *testing.T) {
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"})
assertRedirectQuery(t, recorder, "/pdd", map[string]string{"page_size": "100"})
}
func Test蝦皮分页详情和状态条保留分类搜索(t *testing.T) {
+59
View File
@@ -1,6 +1,7 @@
package main
import (
"bytes"
"html/template"
"os"
"strings"
@@ -35,6 +36,8 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) {
`data-need-checked="inner-code-delete"`, `data-confirm-delete-action="inner-code-delete"`,
`inner-code-delete {{if .CanMatch}}inner-code-match{{end}}{{if .CanApply}} inner-code-apply`,
`删除不会撤销顺运宝已写入的快递单号`, `匹配已选`, `aria-label="档口入库码记录列表"`,
`data-auto-dismiss-toast data-transient-feedback`, `role="alertdialog"`,
`id="inner-code-feedback-error-modal" data-auto-open-modal`,
} {
if !strings.Contains(page, want) {
t.Errorf("正式页面缺少 %s", want)
@@ -43,6 +46,62 @@ func TestInnerCodeTemplate_独立导航与安全表单(t *testing.T) {
if strings.Contains(page, `{{if not .CanMatch}}disabled{{end}}`) {
t.Fatal("所有状态都应允许勾选删除,不能再按 CanMatch 禁用复选框")
}
if strings.Contains(page, `inner-code-notice`) {
t.Fatal("档口入库码反馈不应继续占用列表上方布局")
}
}
func TestInnerCodeTemplate_错误弹窗自动转义且成功提示不占布局(t *testing.T) {
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
data := map[string]any{
"Title": "档口入库码", "Active": "inner-codes", "CSRFToken": "test-csrf",
"FeedbackError": true, "FeedbackMessage": `<script>alert("secret")</script>`,
}
if err := tmpl.ExecuteTemplate(&output, "inner_code/list", data); err != nil {
t.Fatal(err)
}
body := output.String()
for _, want := range []string{
`role="alertdialog"`, `data-auto-open-modal`,
`&lt;script&gt;alert(&#34;secret&#34;)&lt;/script&gt;`,
`系统不会因本弹窗自动重复匹配或回写`,
} {
if !strings.Contains(body, want) {
t.Errorf("错误反馈弹窗缺少 %q", want)
}
}
if strings.Contains(body, `<script>alert("secret")</script>`) {
t.Fatal("错误反馈未经 html/template 自动转义")
}
rawJS, err := os.ReadFile("static/js/app.js")
if err != nil {
t.Fatal(err)
}
js := string(rawJS)
for _, want := range []string{
`function setupTransientFeedback()`, `data-auto-dismiss-toast`,
`window.setTimeout(function ()`, `}, 4000);`,
`current.searchParams.delete(name)`, `window.history.replaceState`,
`setupTransientFeedback();`,
} {
if !strings.Contains(js, want) {
t.Errorf("临时反馈脚本缺少 %q", want)
}
}
cssRaw, err := os.ReadFile("static/css/app.css")
if err != nil {
t.Fatal(err)
}
css := string(cssRaw)
if !strings.Contains(css, `.inner-code-toast {`) || !strings.Contains(css, `position: fixed;`) {
t.Fatal("成功提示必须固定定位,不能占用表格布局")
}
}
func TestInnerCodeTemplate_模块状态白名单已登记(t *testing.T) {
+7 -4
View File
@@ -101,7 +101,7 @@ func TestModuleNavigation_按账号保存稳定列表状态且安全回退(t *te
}
}
func TestPageSizeSelect_切换后自动提交且保留无脚本回退(t *testing.T) {
func TestPageSizeSelect_切换后自动提交且无可见应用按钮(t *testing.T) {
footer, err := os.ReadFile("templates/partials/footer.html")
if err != nil {
t.Fatal(err)
@@ -110,12 +110,15 @@ func TestPageSizeSelect_切换后自动提交且保留无脚本回退(t *testing
for _, want := range []string{
`method="get" class="page-size-form" data-page-size-form`,
`name="page_size" data-page-size-select`,
`type="submit" data-page-size-submit>应用</button>`,
`data-page-size-loading role="status" hidden>加载中…</span>`,
} {
if !strings.Contains(footerSource, want) {
t.Errorf("公共分页缺少渐进增强回退 %q", want)
t.Errorf("公共分页缺少自动刷新结构 %q", want)
}
}
if strings.Contains(footerSource, `data-page-size-submit`) || strings.Contains(footerSource, `>应用</button>`) {
t.Fatal("公共分页不应继续显示重复的应用按钮")
}
js, err := os.ReadFile("static/js/app.js")
if err != nil {
@@ -128,7 +131,7 @@ func TestPageSizeSelect_切换后自动提交且保留无脚本回退(t *testing
`form.getAttribute("aria-busy") === "true"`,
`form.requestSubmit()`,
`if (beginSubmit()) form.submit()`,
`submit.textContent = "加载中…"`,
`loading.hidden = false`,
`setupPageSizeForms();`,
} {
if !strings.Contains(jsSource, want) {
+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) > InnerCodeRemoteBatchLimit {
return nil, fmt.Errorf("单次最多回写 %d 条记录", InnerCodeRemoteBatchLimit)
if len(ids) > InnerCodeApplyBatchLimit {
return nil, fmt.Errorf("单次最多回写 %d 条记录", InnerCodeApplyBatchLimit)
}
result := &InnerCodeApplyResult{Requested: len(ids)}
for _, id := range ids {
+1 -1
View File
@@ -21,7 +21,7 @@ type fakeInnerCodeWriter struct {
}
func TestApplyInnerCodes_远程批量上限不随页面容量放宽(t *testing.T) {
ids := make([]int64, InnerCodeRemoteBatchLimit+1)
ids := make([]int64, InnerCodeApplyBatchLimit+1)
for i := range ids {
ids[i] = int64(i + 1)
}
+31 -20
View File
@@ -21,6 +21,8 @@ type InnerCodeDetailReader interface {
DetailListByStock(context.Context, []int64) ([]syb.StockDetail, error)
}
const innerCodeReadBatchSize = 100
// InnerCodePlanResult 是一次规划的逐状态统计。
type InnerCodePlanResult struct {
Total int
@@ -65,9 +67,35 @@ func PlanInnerCodeRecords(ctx context.Context, db *sql.DB, reader InnerCodeDetai
}
stockIDsByOrder := innerCodeStockIDsByOrder(snapshots)
requested := uniqueInnerCodeStockIDs(records, stockIDsByOrder)
detailsByID, err := readInnerCodeDetails(ctx, reader, requested)
if err != nil {
return nil, err
}
plans := planInnerCodeRowsWithReserved(records, stockIDsByOrder, detailsByID, reservedDetails)
for _, plan := range plans {
switch plan.Status {
case model.InnerCodeReady:
result.Ready++
case model.InnerCodeAlreadyFilled:
result.AlreadyFilled++
case model.InnerCodeFailed:
result.Failed++
default:
result.Skipped++
}
}
if err := repository.SaveInnerCodePlans(db, plans, model.NowISO()); err != nil {
return nil, err
}
return result, nil
}
// readInnerCodeDetails 只限制单次顺运宝请求大小,不限制一次规划的总选择数量。
func readInnerCodeDetails(ctx context.Context, reader InnerCodeDetailReader, requested []int64) (map[int64]syb.StockDetail, error) {
detailsByID := make(map[int64]syb.StockDetail, len(requested))
for start := 0; start < len(requested); start += 100 {
end := start + 100
for start := 0; start < len(requested); start += innerCodeReadBatchSize {
end := start + innerCodeReadBatchSize
if end > len(requested) {
end = len(requested)
}
@@ -88,24 +116,7 @@ func PlanInnerCodeRecords(ctx context.Context, db *sql.DB, reader InnerCodeDetai
}
}
}
plans := planInnerCodeRowsWithReserved(records, stockIDsByOrder, detailsByID, reservedDetails)
for _, plan := range plans {
switch plan.Status {
case model.InnerCodeReady:
result.Ready++
case model.InnerCodeAlreadyFilled:
result.AlreadyFilled++
case model.InnerCodeFailed:
result.Failed++
default:
result.Skipped++
}
}
if err := repository.SaveInnerCodePlans(db, plans, model.NowISO()); err != nil {
return nil, err
}
return result, nil
return detailsByID, nil
}
func uniqueInnerCodeOrders(records []model.InnerCodeRecord) []string {
+34
View File
@@ -1,12 +1,46 @@
package service
import (
"context"
"testing"
"cmautobuy/admin/model"
"cmautobuy/admin/syb"
)
type batchingInnerCodeReader struct {
batches [][]int64
}
func (r *batchingInnerCodeReader) DetailListByStock(_ context.Context, ids []int64) ([]syb.StockDetail, error) {
r.batches = append(r.batches, append([]int64(nil), ids...))
result := make([]syb.StockDetail, 0, len(ids))
for _, id := range ids {
result = append(result, syb.StockDetail{ID: id})
}
return result, nil
}
func TestReadInnerCodeDetails_总量不限且每批最多100(t *testing.T) {
ids := make([]int64, 205)
for index := range ids {
ids[index] = int64(index + 1)
}
reader := &batchingInnerCodeReader{}
details, err := readInnerCodeDetails(context.Background(), reader, ids)
if err != nil {
t.Fatal(err)
}
if len(details) != len(ids) || len(reader.batches) != 3 {
t.Fatalf("只读匹配应处理全部选择并分 3 批,details=%d batches=%d", len(details), len(reader.batches))
}
for index, want := range []int{100, 100, 5} {
if len(reader.batches[index]) != want {
t.Errorf("第 %d 批数量=%d,期望 %d", index+1, len(reader.batches[index]), want)
}
}
}
func TestInnerCodeStockIDFromJSON(t *testing.T) {
for _, raw := range []string{
`{"stock":{"id":75104587},"detail":{"id":1}}`,
+3 -3
View File
@@ -27,11 +27,11 @@ var innerCodeStatusOptions = []InnerCodeStatusOption{
{string(model.InnerCodeNeedsCheck), "需核对"},
}
// 档口入库码的批量上限是业务安全限制,不能再复用界面每页条数。
// 删除只改本地数据库,可覆盖最大单页;匹配和回写会访问顺运宝,继续限制 20 条。
// 删除只改本地数据库,可覆盖最大单页;回写会修改顺运宝,继续限制 20 条。
// 只读匹配不使用这个写操作上限,见 inner_code_match.go 的远端读取分批。
const (
InnerCodeDeleteBatchLimit = 100
InnerCodeRemoteBatchLimit = 20
InnerCodeApplyBatchLimit = 20
)
// InnerCodeRowView 是正式页面的一行。
+4 -4
View File
@@ -14,9 +14,9 @@ import (
// DefaultPageSize 是主列表没有指定 page_size 时使用的每页条数。
//
// 20 行在 1366×768 上通常不需要滚动,因此继续作为默认值;实际页面请求必须
// 通过 ParsePageSize 白名单解析。
const DefaultPageSize = 20
// 采购员通常需要在同一页批量勾选较多记录,因此默认显示白名单中的最大值 100;
// 实际页面请求仍必须通过 ParsePageSize 白名单解析,不能把任意输入交给 SQL。
const DefaultPageSize = 100
var allowedPageSizes = [...]int{20, 50, 100}
@@ -34,7 +34,7 @@ func ParsePage(s string) int {
}
// ParsePageSize 解析 URL 上的 page_size,只接受界面提供的三个白名单值。
// 非法值回退为 20,避免有人手改 URL 造成一次读取过多数据。
// 非法值回退为默认 100;SQL 查询仍只可能使用固定白名单值。
func ParsePageSize(s string) int {
n, err := strconv.Atoi(strings.TrimSpace(s))
if err != nil {
+7 -1
View File
@@ -34,6 +34,12 @@ func TestParsePageSize_只接受白名单(t *testing.T) {
}
}
func TestParsePageSize_缺失时默认100(t *testing.T) {
if DefaultPageSize != 100 || ParsePageSize("") != 100 {
t.Fatalf("公共分页缺失参数时应默认 100,DefaultPageSize=%d got=%d", DefaultPageSize, ParsePageSize(""))
}
}
func TestTotalPages_总数为0返回1不是0(t *testing.T) {
if got := TotalPages(0, 100); got != 1 {
t.Errorf("TotalPages(0) = %d,想要 1(不能出现『第 1/0 页』)", got)
@@ -46,7 +52,7 @@ func TestTotalPages_按PageSize向上取整(t *testing.T) {
DefaultPageSize: 1,
DefaultPageSize + 1: 2,
DefaultPageSize * 2: 2,
5195: 260, // 工单 #43 实测样本量:5195 条 / 20 条一页 = 260 页
5195: 52, // 工单 #43 实测样本量:5195 条 / 100 条一页 = 52 页
}
for total, want := range cases {
if got := TotalPages(total, DefaultPageSize); got != want {
+16 -4
View File
@@ -639,7 +639,20 @@ input.wide { width: 100%; }
white-space: nowrap;
color: #57606a;
}
.inner-code-notice { flex: 0 0 auto; margin: -4px 0 10px; }
.inner-code-toast {
position: fixed;
right: 16px;
bottom: 56px;
z-index: 9;
max-width: min(520px, calc(100vw - 32px));
margin: 0;
padding: 10px 14px;
border: 1px solid #9ec5a8;
border-radius: 4px;
color: #155724;
background: #f0fff4;
box-shadow: 0 4px 16px rgba(0, 0, 0, .16);
}
.inner-code-table-wrap { flex: 1 1 auto; min-height: 180px; overflow: auto; scrollbar-gutter: stable; }
.inner-code-table { min-width: 1500px; }
.inner-code-table th { position: sticky; top: 0; z-index: 1; }
@@ -688,13 +701,12 @@ select {
gap: 4px;
font-size: 13px;
}
.page-size-form select,
.page-size-form button {
.page-size-form select {
min-height: 28px;
font-size: 13px;
}
.page-size-form select { padding: 3px 6px; }
.page-size-form button { padding: 3px 8px; }
.page-size-loading { font-size: 12px; color: #57606a; }
.pagination a,
.pagination span.disabled {
padding: 4px 10px;
+31 -8
View File
@@ -93,22 +93,18 @@
}
/* ── 每页条数立即生效 ─────────────────────────
普通 GET 表单仍是无 JavaScript 时的完整回退;脚本只做渐进增强,让采购员
选择 20/50/100 后不用再点一次“应用”。select 不能禁用,否则浏览器提交时
会丢掉 page_size。 */
采购员选择 20/50/100 后直接提交 GET 表单。select 不能禁用,否则浏览器
提交时会丢掉 page_size。 */
function setupPageSizeForms() {
document.querySelectorAll("[data-page-size-form]").forEach(function (form) {
var select = form.querySelector("[data-page-size-select]");
var submit = form.querySelector("[data-page-size-submit]");
var loading = form.querySelector("[data-page-size-loading]");
if (!select) return;
function beginSubmit() {
if (form.getAttribute("aria-busy") === "true") return false;
form.setAttribute("aria-busy", "true");
if (submit) {
submit.disabled = true;
submit.textContent = "加载中…";
}
if (loading) loading.hidden = false;
return true;
}
@@ -126,6 +122,32 @@
});
}
/* 操作结果通过 POST/Redirect/GET 返回。成功提示固定定位并自动消失,错误交给
通用弹窗;两者都不占表格高度。页面读到结果后清掉临时查询参数,避免 F5
重复弹出同一条反馈。 */
function setupTransientFeedback() {
var feedbacks = document.querySelectorAll("[data-transient-feedback]");
if (!feedbacks.length) return;
var paramsToRemove = {};
feedbacks.forEach(function (feedback) {
(feedback.getAttribute("data-transient-query") || "").split(/\s+/).forEach(function (name) {
if (name) paramsToRemove[name] = true;
});
if (feedback.hasAttribute("data-auto-dismiss-toast")) {
window.setTimeout(function () {
feedback.hidden = true;
feedback.remove();
}, 4000);
}
});
var current = new URL(window.location.href);
Object.keys(paramsToRemove).forEach(function (name) { current.searchParams.delete(name); });
var cleanURL = current.pathname + (current.search ? current.search : "") + current.hash;
window.history.replaceState(window.history.state, "", cleanURL);
}
/* ── 全选 / 反选 ──────────────────────────────
表头的勾选框控制本表格所有行的勾选框。 */
function setupCheckAll(root) {
@@ -888,6 +910,7 @@
document.addEventListener("DOMContentLoaded", function () {
setupModuleNavigationState();
setupPageSizeForms();
setupTransientFeedback();
document.querySelectorAll("table").forEach(setupCheckAll);
setupConfirmDelete();
setupConfirmSubmit();
+27 -1
View File
@@ -63,7 +63,33 @@
data-need-checked="inner-code-delete" disabled>删除已选 <span data-inner-code-delete-count>0</span> 条</button>
</div>
{{if .Message}}<p class="notice inner-code-notice" role="status" aria-live="polite">{{.Message}}</p>{{end}}
{{if .FeedbackSuccess}}
<div class="notice inner-code-toast" role="status" aria-live="polite"
data-auto-dismiss-toast data-transient-feedback data-transient-query="feedback message">
{{.FeedbackMessage}}
</div>
{{end}}
{{if .FeedbackError}}
<div class="modal-backdrop" id="inner-code-feedback-error-modal" data-auto-open-modal
data-transient-feedback data-transient-query="feedback message" hidden>
<section class="modal modal-narrow" role="alertdialog" aria-modal="true"
aria-labelledby="inner-code-feedback-error-title"
aria-describedby="inner-code-feedback-error-message">
<div class="modal-head">
<h2 id="inner-code-feedback-error-title">档口入库码操作未完成</h2>
<button type="button" class="modal-x" data-modal-close aria-label="关闭">×</button>
</div>
<div class="modal-body">
<p class="missing" id="inner-code-feedback-error-message">{{.FeedbackMessage}}</p>
<p class="hint">系统不会因本弹窗自动重复匹配或回写。请按提示处理后重新选择记录。</p>
</div>
<div class="modal-foot">
<button type="button" data-modal-close autofocus>关闭</button>
</div>
</section>
</div>
{{end}}
<div class="table-wrap inner-code-table-wrap" role="region" aria-label="档口入库码记录列表" tabindex="0">
<table class="inner-code-table">
+1 -1
View File
@@ -25,7 +25,7 @@
{{end}}
</select>
</label>
<button type="submit" data-page-size-submit>应用</button>
<span class="page-size-loading" data-page-size-loading role="status" hidden>加载中…</span>
</form>
{{if .Pagination.HasPrev}}
<a href="{{.Pagination.FirstURL}}">首页</a>
+10 -8
View File
@@ -95,8 +95,8 @@
### 3.2 分页
`[必须]` 使用公共分页的列表(蝦皮、PDD、顺运宝、采集采购、客户端、用户、
商品目录导入记录、档口入库码)统一提供 **20 / 50 / 100 条**三档,默认 20。
20 行在 1366×768 上通常不需要滚动;50 和 100 用于集中查看或批量勾选,最大值
商品目录导入记录、档口入库码)统一提供 **20 / 50 / 100 条**三档,默认 100。
默认 100 便于集中查看和批量勾选;20 和 50 用于降低单页密度,最大值
固定为 100,不提供“全部”或任意自定义数量。顺运宝同步记录是紧凑弹窗,继续固定
每页 10 条;顺运宝上游接口的抓取页容量也不是这里的界面设置。
@@ -111,12 +111,12 @@
- `[必须]` 页码参数是 `?page=N`,从 **1** 开始,不是 0——给操作员看的东西
不要 0-based。
- `[必须]` 每页条数参数是 `?page_size=N`,只接受 20、50、100;缺失、非数字或
其他值都回退为 20,不能把用户输入直接交给 SQL `LIMIT`。
其他值都回退为 100,不能把用户输入直接交给 SQL `LIMIT`。
- `[必须]` 修改每页条数时回到第 1 页;筛选、翻页、写操作跳转、F5、浏览器
前进后退和模块状态恢复都保留归一化后的 `page_size`。
- `[必须]` 选择 20、50、100 后立即提交 GET 表单并刷新,不要求采购员再点一次
“应用”。自动提交要显示加载状态并防重复;“应用”按钮仍保留为 JavaScript
不可用时的回退,不能把分页变成只有脚本才能使用的功能。
- `[必须]` 选择 20、50、100 后立即提交 GET 表单并刷新,不显示重复的“应用”按钮。
自动提交要显示加载状态并防重复;JavaScript 不可用时仍可使用已有分页链接,
只是不能切换每页条数。
- `[必须]` 越界要兜住:
- `page` 非数字、`0`、负数 → 当作第 1 页;
- `page` 超过总页数 → 显示**最后一页**(有数据的那页),不是空表格;
@@ -136,8 +136,8 @@
- `[必须]` 表格勾选只代表当前页。翻页后不保留勾选,也不提供跨页批量操作,
避免操作员看不到将被处理的记录。
- `[必须]` 每页条数只控制读取和展示,不能充当外部写操作的安全上限。档口入库码
匹配、回写等顺运宝远程操作继续使用独立的单次上限 20;修改页面容量不能自动
放宽它。
只读匹配允许处理当前页全部选中记录,内部每次最多读取 100 个顺运宝货运单;
回写会修改远端数据,继续使用独立的单次上限 20。
- `[建议]` 不做 `1 2 3 … N` 这种页码列表。页数一多列出来没有意义,
操作员应该靠筛选定位,不是靠翻页数页码。
@@ -959,6 +959,8 @@ API Key 使用密码输入框,只允许替换或清除。已保存值显示固
| 删除 | 二次确认框,写明"将删除 N 条,不可恢复" |
| 客户端绑定/转交 | 弹窗确认目标采购员;成功后刷新负责人并显示结果 |
| 客户端解绑 | 二次确认,写明可见范围立即变化、历史和既有任务保留 |
| 档口入库码操作成功 | 固定定位短提示,3–5 秒自动消失,不占表格高度 |
| 档口入库码匹配失败或部分失败 | 自动打开错误弹窗,说明失败数量、原因和重新处理方式 |
`[必须]` 报错要说清**哪一步失败、下一步做什么**,不要把 Go 的错误堆栈贴到页面上。
堆栈写日志。