diff --git a/admin/service/syb.go b/admin/service/syb.go index 36d010f..20dfd48 100644 --- a/admin/service/syb.go +++ b/admin/service/syb.go @@ -993,9 +993,9 @@ func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string case c.PddGoodsID == "" || c.PddCollectStatus == "": return SybStagePddMissing, "未关联 PDD", "下一步关联采购商品。", "关联 PDD" case c.PddCollectStatus == string(model.CollectPending): - return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集" + return SybStagePddPending, "PDD 待采集", "已关联 PDD 商品,尚未创建采集任务。", "创建采集任务" case c.PddCollectStatus == string(model.CollectCollecting): - return SybStagePddCollecting, "PDD 采集中", "客户端正在采集规格和价格,请稍后刷新。", "查看" + return SybStagePddCollecting, "PDD 采集中", "客户端正在采集规格和价格,请稍后刷新。", "查看采集状态" case c.PddCollectStatus == string(model.CollectFailed): help := "PDD 采集失败,可以重新创建采集任务。" if c.PddCollectMsg != "" { @@ -1003,13 +1003,13 @@ func sybStageFor(c repository.SybOrderContext) (stage, text, help, action string } return SybStagePddFailed, "PDD 采集失败", help, "重新采集" case c.HasActiveTask: - return SybStageTaskCreated, "已创建采购任务", "已有未结束的采购任务,请到采集采购页面查看。", "查看任务" + return SybStageTaskCreated, "已创建采购任务", "已有未结束的采购任务,请到采集采购页面查看。", "查看采购任务" case !mappingIsValid(c): return SybStageMappingPending, "规格待匹配", "PDD 数据已采集,下一步匹配采购规格。", "匹配规格" case c.Order.Quantity <= 0: return SybStagePurchaseBlocked, "采购数据异常", "顺运宝采购数量不是正数,不能创建采购任务。", "核对数据" default: - return SybStagePurchaseReady, "可创建采购任务", "规格映射有效,可以勾选并创建采购任务。", "核对规格" + return SybStagePurchaseReady, "可创建采购任务", "规格映射有效,可以创建采购任务。", "创建采购任务" } } diff --git a/admin/service/syb_workflow_test.go b/admin/service/syb_workflow_test.go index e37e30e..4e117bc 100644 --- a/admin/service/syb_workflow_test.go +++ b/admin/service/syb_workflow_test.go @@ -88,24 +88,25 @@ func TestSybStageFor_全部筛选阶段与逐行推导一致(t *testing.T) { } cases := []struct { want string + action string context repository.SybOrderContext }{ - {SybStageSpecMissing, copyWith(func(c *repository.SybOrderContext) { c.Order.SpecKey = "" })}, - {SybStagePddMissing, copyWith(func(c *repository.SybOrderContext) { c.PddGoodsID, c.PddCollectStatus = "", "" })}, - {SybStagePddPending, copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectPending) })}, - {SybStagePddCollecting, copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectCollecting) })}, - {SybStagePddFailed, copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectFailed) })}, - {SybStageMappingPending, copyWith(func(c *repository.SybOrderContext) { c.MappingOptionKey = "" })}, - {SybStageTaskCreated, copyWith(func(c *repository.SybOrderContext) { c.HasActiveTask = true })}, - {SybStagePurchaseBlocked, copyWith(func(c *repository.SybOrderContext) { c.Order.Quantity = 0 })}, - {SybStagePurchaseReady, ready}, + {SybStageSpecMissing, "核对数据", copyWith(func(c *repository.SybOrderContext) { c.Order.SpecKey = "" })}, + {SybStagePddMissing, "关联 PDD", copyWith(func(c *repository.SybOrderContext) { c.PddGoodsID, c.PddCollectStatus = "", "" })}, + {SybStagePddPending, "创建采集任务", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectPending) })}, + {SybStagePddCollecting, "查看采集状态", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectCollecting) })}, + {SybStagePddFailed, "重新采集", copyWith(func(c *repository.SybOrderContext) { c.PddCollectStatus = string(model.CollectFailed) })}, + {SybStageMappingPending, "匹配规格", copyWith(func(c *repository.SybOrderContext) { c.MappingOptionKey = "" })}, + {SybStageTaskCreated, "查看采购任务", copyWith(func(c *repository.SybOrderContext) { c.HasActiveTask = true })}, + {SybStagePurchaseBlocked, "核对数据", copyWith(func(c *repository.SybOrderContext) { c.Order.Quantity = 0 })}, + {SybStagePurchaseReady, "创建采购任务", ready}, } if got := len(SybStageOptions()) - 1; got != len(cases) { t.Fatalf("筛选项有 %d 个业务阶段,但测试只覆盖 %d 个", got, len(cases)) } for _, tc := range cases { stage, text, _, action := sybStageFor(tc.context) - if stage != tc.want || text != SybStageLabel(tc.want) || action == "" { + if stage != tc.want || text != SybStageLabel(tc.want) || action != tc.action { t.Fatalf("阶段 %s 推导错误: stage=%s text=%q action=%q", tc.want, stage, text, action) } } diff --git a/admin/static/js/app.js b/admin/static/js/app.js index 46404ec..39aca16 100644 --- a/admin/static/js/app.js +++ b/admin/static/js/app.js @@ -137,9 +137,9 @@ /* 采购确认弹窗只负责把服务端已经渲染的本页行按勾选结果显示出来。 规格有效性、价格和客户端权限仍由服务端重新校验。 */ function setupPurchaseModal() { - var openButton = document.querySelector("[data-purchase-open]"); + var openButtons = document.querySelectorAll("[data-purchase-open]"); var form = document.querySelector("[data-purchase-form]"); - if (!openButton || !form) return; + if (!openButtons.length || !form) return; var modeInputs = form.querySelectorAll("[data-execution-mode]"); var livePanel = form.querySelector("[data-live-confirm-panel]"); var liveAcknowledged = form.querySelector("[data-live-acknowledged]"); @@ -167,23 +167,36 @@ input.addEventListener("change", updateExecutionMode); }); updateExecutionMode(); - openButton.addEventListener("click", function () { - var selected = {}; - document.querySelectorAll("tbody input[name=ids]:checked").forEach(function (box) { - selected[box.value] = true; - }); - var shown = 0; - form.querySelectorAll("[data-purchase-row]").forEach(function (row) { - var enabled = !!selected[row.getAttribute("data-purchase-row")]; - row.hidden = !enabled; - row.querySelectorAll("input").forEach(function (input) { - input.disabled = !enabled; + openButtons.forEach(function (openButton) { + openButton.addEventListener("click", function () { + var selected = {}; + var singleID = openButton.getAttribute("data-purchase-id"); + if (singleID) { + selected[singleID] = true; + } else { + document.querySelectorAll("tbody input[name=ids]:checked").forEach(function (box) { + selected[box.value] = true; + }); + } + var shown = 0; + form.querySelectorAll("[data-purchase-row]").forEach(function (row) { + var enabled = !!selected[row.getAttribute("data-purchase-row")]; + row.hidden = !enabled; + row.querySelectorAll("input").forEach(function (input) { + input.disabled = !enabled; + }); + if (enabled) shown += 1; }); - if (enabled) shown += 1; + var empty = form.querySelector("[data-purchase-empty]"); + if (empty) empty.hidden = shown > 0; + + /* 每次打开都回到演练模式,避免上次关闭弹窗后遗留 live 选择。 */ + var dryRun = form.querySelector("[data-execution-mode][value=dry_run]"); + if (dryRun) dryRun.checked = true; + if (liveAcknowledged) liveAcknowledged.checked = false; + if (liveConfirmation) liveConfirmation.value = ""; + updateExecutionMode(); }); - var empty = form.querySelector("[data-purchase-empty]"); - if (empty) empty.hidden = shown > 0; - updateExecutionMode(); }); } diff --git a/admin/syb_interaction_template_test.go b/admin/syb_interaction_template_test.go index a98fa6b..ef7509c 100644 --- a/admin/syb_interaction_template_test.go +++ b/admin/syb_interaction_template_test.go @@ -14,12 +14,24 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { if err != nil { t.Fatal(err) } - rows := []service.SybOrderView{{ - SybID: "SYB-READY", OrderNo: "ORDER-READY", Title: "可采购商品", - ImageURL: "https://example.test/original.jpg", Stage: service.SybStagePurchaseReady, - StageText: "可创建采购任务", StageHelp: "规格映射有效", ActionText: "核对规格", - CanPurchase: true, DefaultMaxPrice: "39.90", - }} + rows := []service.SybOrderView{ + { + SybID: "SYB-READY", OrderNo: "ORDER-READY", Title: "可采购商品", + ImageURL: "https://example.test/original.jpg", Stage: service.SybStagePurchaseReady, + StageText: "可创建采购任务", StageHelp: "规格映射有效", ActionText: "创建采购任务", + CanPurchase: true, DefaultMaxPrice: "39.90", + }, + { + SybID: "SYB-TASK", OrderNo: "ORDER-TASK", Title: "已有任务商品", + Stage: service.SybStageTaskCreated, StageText: "已创建采购任务", + StageHelp: "已有任务", ActionText: "查看采购任务", + }, + { + SybID: "SYB-PDD", OrderNo: "ORDER-PDD", Title: "待关联商品", + Stage: service.SybStagePddMissing, StageText: "未关联 PDD", + StageHelp: "下一步关联采购商品", ActionText: "关联 PDD", + }, + } var output bytes.Buffer data := map[string]any{ "Title": "顺运宝数据", "Active": "syb", "CSRFToken": "test-csrf", @@ -34,6 +46,10 @@ func TestSybListTemplate_区分标题图片与阶段动作(t *testing.T) { `data-image-preview-url="https://example.test/original.jpg"`, `aria-label="查看 可采购商品 的原图"`, `id="image-preview-modal"`, + `data-purchase-id="SYB-READY"`, + `href="/tasks?type=purchase`, + `q=ORDER-TASK"`, + `data-detail-open-id="SYB-PDD"`, } { if !strings.Contains(body, want) { t.Errorf("顺运宝列表缺少 %q", want) diff --git a/admin/templates/syb/list.html b/admin/templates/syb/list.html index bd011f2..cda092d 100644 --- a/admin/templates/syb/list.html +++ b/admin/templates/syb/list.html @@ -127,7 +127,16 @@ {{else}}—{{end}} {{.StageText}}
{{.StageHelp}} - + + {{if eq .Stage "purchase_ready"}} + + {{else if eq .Stage "task_created"}} + {{.ActionText}} + {{else}} + + {{end}} + {{.UpdatedAt}} {{else}} diff --git a/docs/admin/05-ui-specification.md b/docs/admin/05-ui-specification.md index a1b551d..0b2b9f0 100644 --- a/docs/admin/05-ui-specification.md +++ b/docs/admin/05-ui-specification.md @@ -506,6 +506,9 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 采集失败、规格待匹配、可采购和已创建任务。 - 每行显示一个有文字的“下一步”按钮。商品标题是服务端渲染处理弹窗的明确入口, 支持鼠标和键盘;双击其他普通单元格不执行动作。图片入口只负责查看原图,不能打开处理详情。 +- “下一步”随实时阶段变化:未关联 PDD、待采集、采集中、采集失败、规格待匹配、 + 可创建采购任务、已创建采购任务和异常阶段分别显示对应动作。除“创建采购任务”和 + “查看采购任务”外,其余动作进入当前明细的处理弹窗。 - 规格原文为空时不显示匹配表单,明确提示“顺运宝未提供规格”,并禁止建立映射或创建采购任务。 - 完整货运单 JSON 不作为列显示,落在 `syb_data` 里,供后续排查用。 @@ -569,6 +572,12 @@ HTML 片段,只替换弹窗内部的表格、总数和分页。主货运单表 `[必须]` 创建前弹出确认框,让操作员选**分配给哪个客户端**,并确认价格上限。 价格上限默认从当前映射的 PDD 规格采集价带出,可改,**不允许为空**。 +可采购行的“下一步”显示“创建采购任务”。点击后复用同一个确认弹窗并只带入当前明细, +不改变表格复选框状态;顶部批量入口仍按已勾选明细打开。每次打开确认弹窗都默认回到 +“采购演练”,不得保留上一次未提交的真实下单选择。已有未结束任务的行显示 +“查看采购任务”并进入“采集采购”页,不得再次创建。服务端仍必须重新校验映射、数量、 +人民币价格上限、客户端权限、执行模式和重复任务,不能信任列表页的旧状态。 + ## 7. 采集采购页