diff --git a/admin/handler/web/others.go b/admin/handler/web/others.go index d805ac9..d95840c 100644 --- a/admin/handler/web/others.go +++ b/admin/handler/web/others.go @@ -57,9 +57,18 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, stage := service.ParseSybStage(c.Query("stage")) result, err := service.ListSybOrdersView(h.db, keyword, shop, stage, pageNum) if err != nil { - fail(c, http.StatusInternalServerError, - "读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") - return + if !service.IsValidationError(err) || result == nil { + fail(c, http.StatusInternalServerError, + "读取顺运宝货运单列表失败,数据没有被改动。刷新页面重试;一直失败请把这句话报给维护者。") + return + } + } + orderNoError := "" + if err != nil { + orderNoError = err.Error() + } else { + // 有效输入统一回显成逗号分隔,避免 Excel 换行进入隐藏表单和分页 URL。 + keyword = result.OrderNoQuery } purchaseRows := append([]service.SybOrderView(nil), result.Rows...) openSybID := strings.TrimSpace(c.Query("open_syb_id")) @@ -192,6 +201,8 @@ func (h *Handler) renderSybListWithLoginReason(c *gin.Context, keyword, pageRaw, c.HTML(http.StatusOK, "syb/list", page(c, "syb", "顺运宝数据", gin.H{ "Keyword": keyword, + "OrderNoError": orderNoError, + "OrderNoFeedback": sybOrderNoFeedback(result), "ShopFilter": shop, "StageFilter": stage, "StageOptions": service.SybStageOptions(), @@ -344,7 +355,10 @@ func sybHistoryPartialURL(pageNum int, keyword, shop, stage, dateFrom, dateTo st // `[必须]` 显示筛选后的**全量**总数,不是本页行数,见工单 #43 定下的规则。 func sybStatusLine(result *service.SybListResult) string { prefix := fmt.Sprintf("共 %d 条货运单明细", result.Total) - if label := service.SybStageLabel(result.Stage); label != "" { + if result.OrderNoCount > 0 { + prefix = fmt.Sprintf("输入 %d 个订单号,当前筛选命中 %d 个订单、%d 条商品明细", + result.OrderNoCount, result.MatchedOrderCount, result.Total) + } else if label := service.SybStageLabel(result.Stage); label != "" { prefix = fmt.Sprintf("%s:%d 条", label, result.Total) } else if result.IsFiltered { prefix = fmt.Sprintf("筛选结果:%d 条货运单明细", result.Total) @@ -355,6 +369,18 @@ func sybStatusLine(result *service.SybListResult) string { return fmt.Sprintf("%s · 第 %d/%d 页", prefix, result.Page, result.TotalPages) } +func sybOrderNoFeedback(result *service.SybListResult) string { + if result == nil || result.OrderNoCount == 0 { + return "" + } + feedback := fmt.Sprintf("已输入 %d 个订单号;当前筛选命中 %d 个订单、%d 条商品明细。", + result.OrderNoCount, result.MatchedOrderCount, result.Total) + if len(result.UnmatchedOrderNos) > 0 { + feedback += " 当前筛选未命中:" + strings.Join(result.UnmatchedOrderNos, "、") + } + return feedback +} + // SybSync 点「同步」按钮的入口。 // // 流程见工单 #46/#47: diff --git a/admin/main_test.go b/admin/main_test.go index 3463fa0..6bd8d35 100644 --- a/admin/main_test.go +++ b/admin/main_test.go @@ -1,8 +1,10 @@ package main import ( + "fmt" "net/http" "net/http/httptest" + "net/url" "os" "strings" "testing" @@ -658,6 +660,41 @@ func TestMainPagesReturnOK(t *testing.T) { if err != nil { t.Fatalf("准备顺运宝详情测试数据失败: %v", err) } + multiOrderRequest := httptest.NewRequest(http.MethodGet, + "/syb?order_no="+url.QueryEscape("ORDER-PDD-ID-INPUT\nNOT-FOUND"), nil) + addAuth(multiOrderRequest) + multiOrderResponse := httptest.NewRecorder() + router.ServeHTTP(multiOrderResponse, multiOrderRequest) + multiOrderBody := multiOrderResponse.Body.String() + if multiOrderResponse.Code != http.StatusOK { + t.Fatalf("多订单号搜索应返回页面,实际 %d:%s", multiOrderResponse.Code, multiOrderBody) + } + for _, want := range []string{ + `>ORDER-PDD-ID-INPUT,NOT-FOUND`, + "已输入 2 个订单号", "当前筛选命中 1 个订单、1 条商品明细", "当前筛选未命中:NOT-FOUND", + } { + if !strings.Contains(multiOrderBody, want) { + t.Errorf("多订单号搜索响应缺少 %q", want) + } + } + + tooManyOrderNos := make([]string, 101) + for i := range tooManyOrderNos { + tooManyOrderNos[i] = fmt.Sprintf("ORDER-%03d", i) + } + invalidOrderRequest := httptest.NewRequest(http.MethodGet, + "/syb?order_no="+url.QueryEscape(strings.Join(tooManyOrderNos, ",")), nil) + addAuth(invalidOrderRequest) + invalidOrderResponse := httptest.NewRecorder() + router.ServeHTTP(invalidOrderResponse, invalidOrderRequest) + invalidOrderBody := invalidOrderResponse.Body.String() + if invalidOrderResponse.Code != http.StatusOK || + !strings.Contains(invalidOrderBody, "一次最多搜索 100 个不同订单号") || + !strings.Contains(invalidOrderBody, `aria-invalid="true"`) { + t.Fatalf("超量订单号应在页面就地提示且不返回 500,实际 %d:%s", + invalidOrderResponse.Code, invalidOrderBody) + } + detailRequest := httptest.NewRequest(http.MethodGet, "/syb/detail?id=SYB-PDD-ID-INPUT", nil) addAuth(detailRequest) detailResponse := httptest.NewRecorder() @@ -778,3 +815,21 @@ func TestModalBackdropRequiresPointerStart(t *testing.T) { } } } + +func TestSybOrderSearch_回车搜索且Shift回车保留换行(t *testing.T) { + js, err := staticFS.ReadFile("static/js/app.js") + if err != nil { + t.Fatalf("读取公共 JavaScript 失败: %v", err) + } + source := string(js) + for _, want := range []string{ + `document.querySelector("[data-syb-order-search]")`, + `event.key !== "Enter" || event.shiftKey || event.isComposing`, + `input.form.requestSubmit()`, + `setupSybOrderSearch();`, + } { + if !strings.Contains(source, want) { + t.Errorf("多订单号搜索缺少键盘守卫 %q", want) + } + } +} diff --git a/admin/repository/syb.go b/admin/repository/syb.go index cec2a7d..a9afb5b 100644 --- a/admin/repository/syb.go +++ b/admin/repository/syb.go @@ -530,18 +530,21 @@ func nullableText(s string) any { // SybOrderFilter 是货运单列表页支持的筛选条件。 type SybOrderFilter struct { - Keyword string // 匹配订单号或商品标题 - Shop string // 店铺名模糊匹配;空字符串表示全部 - Stage string // 处理阶段;空字符串表示全部 + OrderNos []string // 订单号精确匹配;多个值之间是 OR,空切片表示全部 + Shop string // 店铺名模糊匹配;空字符串表示全部 + Stage string // 处理阶段;空字符串表示全部 } func sybOrderFilterClause(filter SybOrderFilter) (string, []any) { var clauses []string var args []any - if kw := filter.Keyword; kw != "" { - like := "%" + escapeLike(kw) + "%" - clauses = append(clauses, `(so.order_no LIKE ? ESCAPE '!' OR so.title LIKE ? ESCAPE '!')`) - args = append(args, like, like) + if len(filter.OrderNos) > 0 { + placeholders := make([]string, len(filter.OrderNos)) + for i, orderNo := range filter.OrderNos { + placeholders[i] = "?" + args = append(args, orderNo) + } + clauses = append(clauses, `so.order_no IN (`+strings.Join(placeholders, ",")+`)`) } if shop := strings.TrimSpace(filter.Shop); shop != "" { clauses = append(clauses, `so.shop_name LIKE ? ESCAPE '!'`) @@ -752,6 +755,30 @@ func CountSybOrders(q Execer, filter SybOrderFilter) (int, error) { return n, nil } +// ListMatchedSybOrderNos 返回当前可由 SQL 表达的组合筛选实际命中的订单号。 +// 页面用它区分“输入了多少订单”和“命中了多少商品明细”;查询继续复用 +// idx_syb_orders_order,所有订单号都通过占位符传入。 +func ListMatchedSybOrderNos(q Execer, filter SybOrderFilter) ([]string, error) { + if len(filter.OrderNos) == 0 { + return nil, nil + } + where, args := sybOrderFilterClause(filter) + rows, err := q.Query(`SELECT DISTINCT so.order_no`+sybOrderContextFrom+where+` ORDER BY so.order_no`, args...) + if err != nil { + return nil, fmt.Errorf("查询顺运宝命中订单号失败: %w", err) + } + defer rows.Close() + matched := make([]string, 0, len(filter.OrderNos)) + for rows.Next() { + var orderNo string + if err := rows.Scan(&orderNo); err != nil { + return nil, fmt.Errorf("读取顺运宝命中订单号失败: %w", err) + } + matched = append(matched, orderNo) + } + return matched, rows.Err() +} + // CountSybOrdersTotal 统计全部货运单明细数量(不带筛选), // 供列表页判断"是否已经同步过任何数据"。 func CountSybOrdersTotal(q Execer) (int, error) { diff --git a/admin/repository/syb_test.go b/admin/repository/syb_test.go index c77b731..585937a 100644 --- a/admin/repository/syb_test.go +++ b/admin/repository/syb_test.go @@ -408,7 +408,7 @@ func TestInterruptRunningSybSyncRuns_只中断未完成记录(t *testing.T) { } } -func TestListSybOrders_订单标题店铺组合筛选(t *testing.T) { +func TestListSybOrders_多订单精确与店铺组合筛选(t *testing.T) { db := newSybTestDB(t) mustUpsert := func(sybID, orderNo, shop, title string) { if _, err := UpsertSybOrder(db, model.SybOrder{ @@ -420,28 +420,33 @@ func TestListSybOrders_订单标题店铺组合筛选(t *testing.T) { mustUpsert("SYB-A", "260728AAA", "台北服饰旗舰店", "纯棉上衣") mustUpsert("SYB-B", "260728BBB", "台南生活馆", "牛仔裤") - rows, err := ListSybOrders(db, SybOrderFilter{Keyword: "AAA"}, 20, 0) + rows, err := ListSybOrders(db, SybOrderFilter{OrderNos: []string{"260728AAA", "260728BBB"}}, 20, 0) if err != nil { t.Fatalf("查询失败: %v", err) } - if len(rows) != 1 || rows[0].SybID != "SYB-A" { - t.Fatalf("按订单号筛选应该只查到 SYB-A,实际 %+v", rows) + if len(rows) != 2 { + t.Fatalf("两个精确订单号应该查到两条,实际 %+v", rows) } - rows2, err := ListSybOrders(db, SybOrderFilter{Keyword: "牛仔"}, 20, 0) + rows2, err := ListSybOrders(db, SybOrderFilter{OrderNos: []string{"AAA", "牛仔"}}, 20, 0) if err != nil { t.Fatalf("查询失败: %v", err) } - if len(rows2) != 1 || rows2[0].SybID != "SYB-B" { - t.Fatalf("按标题筛选应该只查到 SYB-B,实际 %+v", rows2) + if len(rows2) != 0 { + t.Fatalf("订单号片段和商品标题都不应再命中,实际 %+v", rows2) } rows3, err := ListSybOrders(db, SybOrderFilter{Shop: " 服饰 "}, 20, 0) if err != nil || len(rows3) != 1 || rows3[0].SybID != "SYB-A" || rows3[0].ShopName != "台北服饰旗舰店" { t.Fatalf("按店铺模糊筛选错误:rows=%+v err=%v", rows3, err) } - count, err := CountSybOrders(db, SybOrderFilter{Keyword: "牛仔", Shop: "台南"}) + filter := SybOrderFilter{OrderNos: []string{"260728AAA", "260728BBB", "NOT-FOUND"}, Shop: "台南"} + count, err := CountSybOrders(db, filter) if err != nil || count != 1 { - t.Fatalf("标题与店铺组合 COUNT 错误:count=%d err=%v", count, err) + t.Fatalf("订单号与店铺组合 COUNT 错误:count=%d err=%v", count, err) + } + matched, err := ListMatchedSybOrderNos(db, filter) + if err != nil || len(matched) != 1 || matched[0] != "260728BBB" { + t.Fatalf("当前筛选命中订单号错误:matched=%v err=%v", matched, err) } } diff --git a/admin/service/purchase_workflow_test.go b/admin/service/purchase_workflow_test.go index aa9e2e5..7a2ae9b 100644 --- a/admin/service/purchase_workflow_test.go +++ b/admin/service/purchase_workflow_test.go @@ -199,7 +199,7 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) { if !detail.MappingValid { t.Fatal("保存后相同蝦皮商品规格 + 当前 PDD 商品应自动复用") } - list, err := ListSybOrdersView(db, "", "", SybStagePurchaseReady, 1) + list, err := ListSybOrdersView(db, "ORDER-SYB-1\nNOT-FOUND", "", SybStagePurchaseReady, 1) if err != nil { t.Fatal(err) } @@ -207,6 +207,10 @@ func TestSybMapping_动态维度保存并复用(t *testing.T) { list.Rows[0].DefaultTotalPrice != "39.90" { t.Fatalf("可采购阶段不对: %+v", list) } + if list.OrderNoCount != 2 || list.MatchedOrderCount != 1 || + len(list.UnmatchedOrderNos) != 1 || list.UnmatchedOrderNos[0] != "NOT-FOUND" { + t.Fatalf("高级阶段多订单号命中统计错误: %+v", list) + } row := list.Rows[0] if row.MappedPddChoice != "颜色:黑色 / 尺码:M码 / 款式:常规" || strings.Contains(row.MappedPddChoice, "白色") || row.MappingOptionKey != key || row.ContextVersion == "" { diff --git a/admin/service/syb.go b/admin/service/syb.go index 2436c6a..33c9049 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -23,6 +23,8 @@ import ( "strings" "sync" "time" + "unicode" + "unicode/utf8" "cmautobuy/admin/config" "cmautobuy/admin/model" @@ -1171,13 +1173,64 @@ func sybCollectingIsStale(c repository.SybOrderContext) bool { // SybListResult 是列表页要的全部数据。 type SybListResult struct { - Rows []SybOrderView - Total int - HasAny bool - IsFiltered bool - Stage string - Page int - TotalPages int + Rows []SybOrderView + Total int + HasAny bool + IsFiltered bool + Stage string + Page int + TotalPages int + OrderNoQuery string + OrderNoCount int + MatchedOrderCount int + UnmatchedOrderNos []string +} + +const ( + maxSybOrderNoQueryLen = 4000 + maxSybOrderNoCount = 100 + maxSybOrderNoLen = 191 +) + +// ParseSybOrderNos 把采购员粘贴的订单号列变成去重后的精确查询值。 +// 支持 Excel 换行/Tab,以及中英文逗号和分号;订单号本身不允许包含空白。 +func ParseSybOrderNos(raw string) ([]string, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, nil + } + if utf8.RuneCountInString(raw) > maxSybOrderNoQueryLen { + return nil, invalidFieldInput("order_no", "订单号输入不能超过 %d 个字符", maxSybOrderNoQueryLen) + } + parts := strings.FieldsFunc(raw, func(r rune) bool { + return unicode.IsSpace(r) || r == ',' || r == ',' || r == ';' || r == ';' + }) + if len(parts) == 0 { + return nil, invalidFieldInput("order_no", "请输入至少一个有效订单号") + } + seen := make(map[string]struct{}, len(parts)) + orderNos := make([]string, 0, len(parts)) + for _, part := range parts { + orderNo := strings.TrimSpace(part) + if orderNo == "" { + continue + } + if utf8.RuneCountInString(orderNo) > maxSybOrderNoLen { + return nil, invalidFieldInput("order_no", "订单号 %q 超过 %d 个字符", orderNo, maxSybOrderNoLen) + } + if _, exists := seen[orderNo]; exists { + continue + } + seen[orderNo] = struct{}{} + orderNos = append(orderNos, orderNo) + if len(orderNos) > maxSybOrderNoCount { + return nil, invalidFieldInput("order_no", "一次最多搜索 %d 个不同订单号", maxSybOrderNoCount) + } + } + if len(orderNos) == 0 { + return nil, invalidFieldInput("order_no", "请输入至少一个有效订单号") + } + return orderNos, nil } // ListSybOrdersView 按筛选条件分页查货运单明细列表,翻成界面文字。 @@ -1187,13 +1240,24 @@ type SybListResult struct { func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) { shop = strings.TrimSpace(shop) stage = ParseSybStage(stage) + orderNos, err := ParseSybOrderNos(keyword) + if err != nil { + hasAny, countErr := repository.CountSybOrdersTotal(db) + if countErr != nil { + return nil, countErr + } + return &SybListResult{ + Rows: make([]SybOrderView, 0), HasAny: hasAny > 0, IsFiltered: true, + Stage: stage, Page: 1, TotalPages: 1, OrderNoQuery: keyword, + }, err + } if stage == SybStagePddCollecting || stage == SybStagePddCollectingStale || stage == SybStageMappingPending || stage == SybStagePurchaseReady || stage == SybStageTaskCreated || stage == SybStagePurchaseCompleted || stage == SybStagePurchaseReview || stage == SybStagePurchaseBlocked { - return listAdvancedSybStage(db, keyword, shop, stage, page) + return listAdvancedSybStage(db, orderNos, shop, stage, page) } - filter := repository.SybOrderFilter{Keyword: keyword, Shop: shop, Stage: stage} + filter := repository.SybOrderFilter{OrderNos: orderNos, Shop: shop, Stage: stage} total, err := repository.CountSybOrders(db, filter) if err != nil { return nil, err @@ -1213,17 +1277,24 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL } result := &SybListResult{ - Rows: make([]SybOrderView, 0, len(rows)), - Total: total, - HasAny: hasAny > 0, - IsFiltered: strings.TrimSpace(keyword) != "" || shop != "" || stage != "", - Stage: stage, - Page: page, - TotalPages: totalPages, + Rows: make([]SybOrderView, 0, len(rows)), + Total: total, + HasAny: hasAny > 0, + IsFiltered: len(orderNos) > 0 || shop != "" || stage != "", + Stage: stage, + Page: page, + TotalPages: totalPages, + OrderNoQuery: strings.Join(orderNos, ","), + OrderNoCount: len(orderNos), } for _, context := range rows { result.Rows = append(result.Rows, sybOrderViewFor(context)) } + matchedOrderNos, err := repository.ListMatchedSybOrderNos(db, filter) + if err != nil { + return nil, err + } + setSybOrderMatchStats(result, orderNos, matchedOrderNos) return result, nil } @@ -1231,9 +1302,9 @@ func ListSybOrdersView(db *sql.DB, keyword, shop, stage string, page int) (*SybL // 这些阶段不能只靠 SQL 判断:同一个 option key 是否仍存在,需要走唯一的 // OptionKey 规范化逻辑。当前同步量是百到千级,先保证采购判断正确;普通列表 // 和其余阶段仍在数据库分页。 -func listAdvancedSybStage(db *sql.DB, keyword, shop, stage string, page int) (*SybListResult, error) { +func listAdvancedSybStage(db *sql.DB, orderNos []string, shop, stage string, page int) (*SybListResult, error) { contexts, err := repository.ListSybOrderContexts(db, - repository.SybOrderFilter{Keyword: keyword, Shop: shop}, -1, 0) + repository.SybOrderFilter{OrderNos: orderNos, Shop: shop}, -1, 0) if err != nil { return nil, err } @@ -1256,7 +1327,8 @@ func listAdvancedSybStage(db *sql.DB, keyword, shop, stage string, page int) (*S end = total } result := &SybListResult{Total: total, Stage: stage, Page: page, TotalPages: totalPages, - IsFiltered: true, Rows: make([]SybOrderView, 0, end-start)} + IsFiltered: true, Rows: make([]SybOrderView, 0, end-start), + OrderNoQuery: strings.Join(orderNos, ","), OrderNoCount: len(orderNos)} hasAny, err := repository.CountSybOrdersTotal(db) if err != nil { return nil, err @@ -1265,9 +1337,36 @@ func listAdvancedSybStage(db *sql.DB, keyword, shop, stage string, page int) (*S for _, context := range filtered[start:end] { result.Rows = append(result.Rows, sybOrderViewFor(context)) } + matchedOrderNos := make([]string, 0, len(orderNos)) + matchedSet := make(map[string]struct{}, len(orderNos)) + for _, context := range filtered { + if _, exists := matchedSet[context.Order.OrderNo]; exists { + continue + } + matchedSet[context.Order.OrderNo] = struct{}{} + matchedOrderNos = append(matchedOrderNos, context.Order.OrderNo) + } + setSybOrderMatchStats(result, orderNos, matchedOrderNos) return result, nil } +func setSybOrderMatchStats(result *SybListResult, orderNos, matchedOrderNos []string) { + if len(orderNos) == 0 { + return + } + matched := make(map[string]struct{}, len(matchedOrderNos)) + for _, orderNo := range matchedOrderNos { + matched[orderNo] = struct{}{} + } + result.MatchedOrderCount = len(matched) + result.UnmatchedOrderNos = make([]string, 0, len(orderNos)-len(matched)) + for _, orderNo := range orderNos { + if _, exists := matched[orderNo]; !exists { + result.UnmatchedOrderNos = append(result.UnmatchedOrderNos, orderNo) + } + } +} + func sybOrderViewFor(context repository.SybOrderContext) SybOrderView { o := context.Order v := SybOrderView{SybID: o.SybID, OrderNo: o.OrderNo, ShopName: o.ShopName, Title: o.Title, diff --git a/admin/service/syb_workflow_test.go b/admin/service/syb_workflow_test.go index 4429462..45fcd76 100644 --- a/admin/service/syb_workflow_test.go +++ b/admin/service/syb_workflow_test.go @@ -2,6 +2,8 @@ package service import ( "database/sql" + "fmt" + "strings" "testing" "cmautobuy/admin/model" @@ -71,7 +73,7 @@ func TestListSybOrdersView_空规格阻断且普通规格直接进入PDD阶段(t } } -func TestListSybOrdersView_店铺与标题和阶段组合筛选(t *testing.T) { +func TestListSybOrdersView_店铺与订单号和阶段组合筛选(t *testing.T) { db := newSyncTestDB(t) for _, order := range []model.SybOrder{ {SybID: "SHOP-A", OrderNo: "ORDER-A", ShopName: "台北服饰旗舰店", Title: "纯棉上衣", ProductSpec: "黑色,M", ShopeeGoodsID: "SP-A", Quantity: 1, SybData: `{}`}, @@ -81,15 +83,39 @@ func TestListSybOrdersView_店铺与标题和阶段组合筛选(t *testing.T) { t.Fatal(err) } } - result, err := ListSybOrdersView(db, "上衣", " 服饰 ", SybStagePddMissing, 1) + result, err := ListSybOrdersView(db, "ORDER-A,NOT-FOUND", " 服饰 ", SybStagePddMissing, 1) if err != nil || result.Total != 1 || len(result.Rows) != 1 || result.Rows[0].SybID != "SHOP-A" || result.Rows[0].ShopName != "台北服饰旗舰店" { t.Fatalf("组合筛选错误: result=%+v err=%v", result, err) } + if result.OrderNoQuery != "ORDER-A,NOT-FOUND" || result.OrderNoCount != 2 || + result.MatchedOrderCount != 1 || len(result.UnmatchedOrderNos) != 1 || result.UnmatchedOrderNos[0] != "NOT-FOUND" { + t.Fatalf("订单号命中统计错误: %+v", result) + } if !result.IsFiltered { t.Fatal("店铺条件应标记为筛选结果") } } +func TestParseSybOrderNos_多分隔符去重与限制(t *testing.T) { + got, err := ParseSybOrderNos(" A,B;C;D\tE\nA ") + if err != nil || strings.Join(got, ",") != "A,B,C,D,E" { + t.Fatalf("多分隔符解析错误: got=%v err=%v", got, err) + } + tooMany := make([]string, 101) + for i := range tooMany { + tooMany[i] = fmt.Sprintf("ORDER-%03d", i) + } + if _, err := ParseSybOrderNos(strings.Join(tooMany, ",")); !IsValidationError(err) || ValidationField(err) != "order_no" { + t.Fatalf("超过 100 个订单号应返回字段校验错误: %v", err) + } + if _, err := ParseSybOrderNos(strings.Repeat("A", 192)); !IsValidationError(err) { + t.Fatalf("超长单号应返回校验错误: %v", err) + } + if _, err := ParseSybOrderNos(strings.Repeat("A,", 2001)); !IsValidationError(err) { + t.Fatalf("超长原始输入应返回校验错误: %v", err) + } +} + func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) { key, err := OptionKey(map[string]string{"color": "黑色", "size": "M码", "style": "常规"}) if err != nil { diff --git a/admin/static/css/app.css b/admin/static/css/app.css index c9636a1..3cd24df 100644 --- a/admin/static/css/app.css +++ b/admin/static/css/app.css @@ -122,6 +122,11 @@ body.syb-page-body > .page { min-width: 138px; } .sync-range-message { margin: -4px 0 10px; } +.filter-feedback { + margin: -4px 2px 10px; + white-space: normal; + overflow-wrap: anywhere; +} /* 搜索框收窄到 30%。目前 PDD 商品页、顺运宝数据页、采集采购页在用; 蝦皮数据页和客户端列表页仍是撑满的。 这样搜索表单里就没有会伸展的元素了,多余空间留在按钮之后, @@ -134,6 +139,22 @@ body.syb-page-body > .page { flex: 0 0 140px; min-width: 140px; } +.toolbar textarea.syb-search-input { + flex: 0 0 140px; + width: 140px; + min-width: 140px; +} +.syb-order-search-input { + height: 30px; + min-height: 30px; + padding: 5px 8px; + border: 1px solid #ccd1d6; + border-radius: 3px; + font: inherit; + line-height: 18px; + resize: vertical; +} +.syb-order-search-input[aria-invalid="true"] { border-color: #b42318; } /* 蝦皮工具条同时放商品和店铺两个搜索框,各占剩余工具条的约 20%。 min-width 继续由通用输入框规则提供,窄窗口时交给工具条自然换行。 */ .toolbar input[type="text"].shopee-search-field { flex: 0 1 20%; } diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 253cd85..1d00b07 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -614,6 +614,18 @@ }); } + /* 订单号搜索使用 textarea 才能可靠接收 Excel 单列粘贴。 + 保留搜索框原有的 Enter 提交习惯;Shift+Enter 仍可手工换行。 */ + function setupSybOrderSearch() { + var input = document.querySelector("[data-syb-order-search]"); + if (!input || !input.form) return; + input.addEventListener("keydown", function (event) { + if (event.key !== "Enter" || event.shiftKey || event.isComposing) return; + event.preventDefault(); + input.form.requestSubmit(); + }); + } + document.addEventListener("DOMContentLoaded", function () { setupModuleNavigationState(); document.querySelectorAll("table").forEach(setupCheckAll); @@ -631,6 +643,7 @@ setupCaptchaRefresh(); setupSybSyncFeedback(); setupSybHistoryRefresh(); + setupSybOrderSearch(); syncButtons(); }); diff --git a/admin/syb_interaction_template_test.go b/admin/syb_interaction_template_test.go index 9d4356b..adbd8b1 100644 --- a/admin/syb_interaction_template_test.go +++ b/admin/syb_interaction_template_test.go @@ -56,6 +56,8 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { "Rows": rows, "PurchaseRows": rows, "DetailURL": "/syb/detail", "StageOptions": service.SybStageOptions(), "SyncDateFrom": "2026-08-12", "SyncDateTo": "2026-08-13", + "Keyword": "ORDER-READY,ORDER-MISSING", + "OrderNoFeedback": "已输入 2 个订单号;当前筛选命中 1 个订单、1 条商品明细。 当前筛选未命中:ORDER-MISSING", } if err := tmpl.ExecuteTemplate(&output, "syb/list", data); err != nil { t.Fatalf("渲染顺运宝列表失败: %v", err) @@ -87,6 +89,13 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { `>创建采集任务`, `

创建采集任务

`, `class="syb-search-input"`, + `订单号(可多条)`, + `textarea id="q" name="order_no" rows="1"`, + `data-syb-order-search`, + `maxlength="4000"`, + `支持中英文逗号、分号、空格、Tab 或换行分隔,最多 100 个订单号`, + `role="status">已输入 2 个订单号`, + `当前筛选未命中:ORDER-MISSING`, `href="/syb?date_from=2026-08-12`, `date_to=2026-08-13">清除`, `class="table-wrap syb-table-wrap"`, @@ -121,6 +130,31 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { } } +func TestSybListTemplate_订单号错误就地显示(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": "syb", "CSRFToken": "test-csrf", + "OrderNoError": "一次最多搜索 100 个不同订单号", + "Keyword": "ORDER-001", + } + if err := tmpl.ExecuteTemplate(&output, "syb/list", data); err != nil { + t.Fatalf("渲染顺运宝订单号错误失败: %v", err) + } + body := output.String() + for _, want := range []string{ + `aria-invalid="true"`, `aria-describedby="order-no-error"`, + `id="order-no-error"`, `role="alert">一次最多搜索 100 个不同订单号`, + } { + if !strings.Contains(body, want) { + t.Errorf("订单号错误提示缺少 %q", want) + } + } +} + func TestSybDetailTemplate_单条采集可选执行客户端(t *testing.T) { tmpl, err := template.ParseFS(templateFS, "templates/*/*.html") if err != nil { diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index e6e3ab0..c282026 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -45,9 +45,11 @@ - - + + 清除 @@ -62,6 +64,8 @@ +{{if .OrderNoError}}{{end}} +{{if .OrderNoFeedback}}

{{.OrderNoFeedback}}

{{end}} {{if .RangeError}}{{end}} {{if .RangeWarning}}

{{.RangeWarning}}

{{end}} diff --git a/docs/admin/01-requirements.md b/docs/admin/01-requirements.md index 298b94f..736283a 100644 --- a/docs/admin/01-requirements.md +++ b/docs/admin/01-requirements.md @@ -211,6 +211,13 @@ PDD 商品之所以单独一个模块,是因为它在数据上就是**独立 **顶部工具条:** 日期范围、同步、同步记录、创建采集任务、创建采购任务、处理阶段、 店铺和订单号筛选、清除、搜索、删除。店铺管理通过管理员主导航进入,不在工具条重复显示。 +订单号筛选支持一次精确搜索多条订单号。采购员可以直接粘贴 Excel 单列数据,也可以使用 +中英文逗号、分号、空格、Tab 或换行分隔;系统去除首尾空白并按首次出现顺序去重。 +每次最多接收 100 个不同订单号,原始输入最多 4000 个字符,单个订单号最多 191 个字符。 +订单号只与完整订单号匹配,不再同时模糊匹配商品标题。搜索结果显示输入订单数、当前店铺和 +处理阶段组合条件下命中的订单数、商品明细数,以及未命中的订单号;输入不合法时在搜索框附近 +提示,不返回服务器错误。分页和列表写操作返回时保留规范化后的订单号条件。 + - `[待定]` 同步按钮 MVP 阶段只做占位:点击后提示"同步功能待接入",不发请求。 **中间表格:** diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index 59e71d5..36ecc10 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -535,9 +535,14 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 记录时内容突然移动;F5 仍按浏览器默认行为刷新整个 SYB 页面。 `[必须]` 店铺和订单号搜索框桌面端固定约 140px,见 [§3.1](#31-搜索框宽度)。 -店铺是独立的模糊搜索条件,可与处理阶段、订单号/商品标题组合;输入会去除首尾空白。 +店铺是独立的模糊搜索条件,可与处理阶段和订单号组合;输入会去除首尾空白。 +订单号使用可接收多行粘贴的紧凑文本区,按完整订单号精确匹配,不再匹配商品标题。 +支持中英文逗号、分号、空格、Tab 和换行分隔,去重后最多 100 个订单号;原始输入最多 +4000 个字符,单个订单号最多 191 个字符。搜索后在工具条下方显示输入订单数、当前组合条件 +命中的订单数、商品明细数和未命中订单号;超限或无有效订单号时就地显示字段错误,不返回 500。 “清除”位于“搜索”左侧,清空处理阶段、店铺、订单号和页码,但保留当前同步日期范围。 翻页、同步、删除、详情内写操作、创建采集任务和创建采购任务返回列表时都要保留店铺条件。 +多订单号筛选只改变当前结果集,不增加跨页全选或“操作全部搜索结果”的能力。 ### 6.2 表格列