diff --git a/admin/handler/web/inner_code.go b/admin/handler/web/inner_code.go index 9bab851..75b8d42 100644 --- a/admin/handler/web/inner_code.go +++ b/admin/handler/web/inner_code.go @@ -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)) diff --git a/admin/handler/web/inner_code_test.go b/admin/handler/web/inner_code_test.go index 1a4c7b5..bc06046 100644 --- a/admin/handler/web/inner_code_test.go +++ b/admin/handler/web/inner_code_test.go @@ -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( diff --git a/admin/handler/web/pagination_test.go b/admin/handler/web/pagination_test.go index 88f3092..c1e7364 100644 --- a/admin/handler/web/pagination_test.go +++ b/admin/handler/web/pagination_test.go @@ -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) { diff --git a/admin/inner_code_template_test.go b/admin/inner_code_template_test.go index 5cb37db..a65a702 100644 --- a/admin/inner_code_template_test.go +++ b/admin/inner_code_template_test.go @@ -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": ``, + } + 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`, + `<script>alert("secret")</script>`, + `系统不会因本弹窗自动重复匹配或回写`, + } { + if !strings.Contains(body, want) { + t.Errorf("错误反馈弹窗缺少 %q", want) + } + } + if strings.Contains(body, ``) { + 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) { diff --git a/admin/main_test.go b/admin/main_test.go index f8f225f..1348001 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -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>应用`, + `data-page-size-loading role="status" hidden>加载中…`, } { if !strings.Contains(footerSource, want) { - t.Errorf("公共分页缺少渐进增强回退 %q", want) + t.Errorf("公共分页缺少自动刷新结构 %q", want) } } + if strings.Contains(footerSource, `data-page-size-submit`) || strings.Contains(footerSource, `>应用`) { + 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) { diff --git a/admin/service/inner_code_apply.go b/admin/service/inner_code_apply.go index 4b49203..b34bd81 100644 --- a/admin/service/inner_code_apply.go +++ b/admin/service/inner_code_apply.go @@ -42,8 +42,8 @@ func ApplyInnerCodes(ctx context.Context, db *sql.DB, writer InnerCodeWriter, id if len(ids) == 0 { return nil, fmt.Errorf("没有选择可回写记录") } - if len(ids) > 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 { diff --git a/admin/service/inner_code_apply_test.go b/admin/service/inner_code_apply_test.go index 5789108..dc22bff 100644 --- a/admin/service/inner_code_apply_test.go +++ b/admin/service/inner_code_apply_test.go @@ -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) } diff --git a/admin/service/inner_code_match.go b/admin/service/inner_code_match.go index 6b08501..f6a1f18 100644 --- a/admin/service/inner_code_match.go +++ b/admin/service/inner_code_match.go @@ -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 { diff --git a/admin/service/inner_code_match_test.go b/admin/service/inner_code_match_test.go index 9ecdf27..4c99260 100644 --- a/admin/service/inner_code_match_test.go +++ b/admin/service/inner_code_match_test.go @@ -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}}`, diff --git a/admin/service/inner_code_page.go b/admin/service/inner_code_page.go index bdbd255..4f201f6 100644 --- a/admin/service/inner_code_page.go +++ b/admin/service/inner_code_page.go @@ -27,11 +27,11 @@ var innerCodeStatusOptions = []InnerCodeStatusOption{ {string(model.InnerCodeNeedsCheck), "需核对"}, } -// 档口入库码的批量上限是业务安全限制,不能再复用界面每页条数。 -// 删除只改本地数据库,可覆盖最大单页;匹配和回写会访问顺运宝,继续限制 20 条。 +// 删除只改本地数据库,可覆盖最大单页;回写会修改顺运宝,继续限制 20 条。 +// 只读匹配不使用这个写操作上限,见 inner_code_match.go 的远端读取分批。 const ( InnerCodeDeleteBatchLimit = 100 - InnerCodeRemoteBatchLimit = 20 + InnerCodeApplyBatchLimit = 20 ) // InnerCodeRowView 是正式页面的一行。 diff --git a/admin/service/pagination.go b/admin/service/pagination.go index 758ffc7..4481210 100644 --- a/admin/service/pagination.go +++ b/admin/service/pagination.go @@ -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 { diff --git a/admin/service/pagination_test.go b/admin/service/pagination_test.go index d9f6646..1d93b4a 100644 --- a/admin/service/pagination_test.go +++ b/admin/service/pagination_test.go @@ -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 { diff --git a/admin/static/css/app.css b/admin/static/css/app.css index 9ab9750..36adc6b 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -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; diff --git a/admin/static/js/app.js b/admin/static/js/app.js index eae4e91..6ac1fc9 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -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(); diff --git a/admin/templates/inner_code/list.html b/admin/templates/inner_code/list.html index 21263d1..aa89f84 100644 --- a/admin/templates/inner_code/list.html +++ b/admin/templates/inner_code/list.html @@ -63,7 +63,33 @@ data-need-checked="inner-code-delete" disabled>删除已选 0 条 -{{if .Message}}
{{.Message}}
{{end}} +{{if .FeedbackSuccess}} +{{.FeedbackMessage}}
+系统不会因本弹窗自动重复匹配或回写。请按提示处理后重新选择记录。
+