Bladeren bron

feat(收款): 进件页改成「我的商户」列表 —— 每行一个下一步,操作员不用选

进件页原来是一张 41 字段的大表单,操作员打开就得自己判断「我现在该干嘛」。
现在第一屏是列表:进度列四档(待提交审核 / 审核中 / 可以收款了 / 审核未通过),
每行**恰好一个主按钮**,由进度决定 —— 不需要选。列表为空时才显示引导 +「新建商户」。
41 字段表单保留,只在点「新建商户」/「改资料」时以抽屉展开(destroy-on-close)。

面向租户的模板文案里没有「进件 / 映射 / mchNo / 上游」这些词(11 个禁词实测全 0)。

落地时定下的两处(计划里没写):
- 规则是「每行恰好一个主按钮 + 至多一个文字按钮」,文字按钮只在**非**「可以收款了」时出现。
  一句话:非 APPROVED 的行都必须留着「提交审核」入口 —— 它是把资料送审的**唯一**
  一条腿,少了它,「审核未通过」的行改完资料就再也走不到送审那一步,按钮会永远停在
  「改资料」上。反面:APPROVED **不给**重新提交(再送一次没意义,误点一次是不可回滚的上游调用)。
- 「改资料」入口改成从干净表单起步。原形状是「切模式不清表单」,于是从建号结果那条
  链接跳过去时,建号表单里那 41 个值会**原样进更新报文**。旧台子的 X6 标签警告的正是
  这件事,而它的断言查的却是相反的事(`=== "建号时填的"`),一并订正为标签的说法。

验证(每条都有实测读数,见 .claude/plan/2026-09-23-lsy-simple-flow.md Task 3):
- `pnpm ts:check`:exit 2 / 325 行,与改前**逐字节相同**(本仓该命令有非零历史基线,
  命令恒 exit 2 ⇒ 只能说「零新增」,不能说「跑绿了」)。同谓词正控往页面塞一行真类型错
  ⇒ 多且只多 1 行(325 → 326 → 325),**顺带证明这页真在 ts:check 的扫描范围内**
- `pnpm build`:exit 0。产物里查得到本页 7 条独有串、全在同一个 chunk;第 8 个候选被脚本
  **自己判成「不是本页独有,本读数不成立」**(它在 AccountOverview.vue 里也有),对照串 0/0
- 执行级台子 `fe-update-harness`:43 → **89 条,89/0**
- 两向变异 `fe-task3-mutate`:3 个真变异各打中预期用例、1 个**无害变异仍绿**、
  4 轮还原 sha 全部逐字节回基线

台子自身的两处订正(理由都写在代码注释里,别当漏做补回去):
- 删掉一条**恒真的空断言**(原 S3「409 不置成功提示」):`submitCommit` 在调用**之前**
  就清空了 `committedMchNo` ⇒ 一切失败路径上它都是空串 ⇒ 该断言对**任何**变异恒真。
  它是在变异把 409 码值改掉后**照绿**才暴露的。
- 改掉一处**会让量具全黑的脆弱写法**(L2 原用 `b.list.value[0].mch_no`):L1 一红就抛
  TypeError,整轮崩掉、SUMMARY 与后面所有用例全丢 —— 恰好在最需要读数的时候量具失效。

台子改动(.claude/plan/2026-09-23-lsy-onboarding-B-harness/):
- `fe-update-harness.mjs` 补 P/A/C/S/L 五段(进度映射 / 每行按钮 / 提交报文 / 409 / 列表读法)
- `fe-task3-mutate.mjs` 新增(两向变异驱动,每轮按字节还原并复核 sha256)
- `README.md` 随之更新:`pass=43` → `89`、登记新驱动、指纹表补 CRLF 侧 sha256,
  并订正三处随本轮改动而失真的旧读数(stdout 行数、「四支」、第 0 节那句「都不写生产文件」)
alphaH 3 weken geleden
bovenliggende
commit
0ad00abed5

+ 88 - 21
.claude/plan/2026-09-23-lsy-onboarding-B-harness/README.md

@@ -1,11 +1,19 @@
-# 批次三-B 的四支验证台 —— 怎么跑、看什么
+# 批次三-B 的验证台 —— 怎么跑、看什么
 
-这四支是**验收用**的:不连库、不连上游、**不发任何网络请求**,也不需要登录凭据。
+这五支是**验收用**的:不连库、不连上游、**不发任何网络请求**,也不需要登录凭据。
 它们直接编译真的 Vue SFC、跑 `frontend/` 里那份真的 `vue`、驱动页面的真 `setup()`,
 只把 `@/api` 与上传子组件桩掉。
 
 来源与搬迁经过见 `.claude/plan/2026-09-23-lsy-onboarding-B-交付说明.md` §四 4。
-**本 README 的每一条命令与读数都是 2026-09-23 在交付提交 `20a222b` 上实跑取的,不是转述。**
+**本 README 的每一条命令与读数都是 2026-09-23 实跑取的,不是转述**,但取自**两个时点**:
+
+- 第 1~4 节里除 `fe-update-harness.mjs` 之外的读数,取自交付提交 `20a222b`;
+- `fe-update-harness.mjs` 与第 3 节的 `fe-task3-mutate.mjs`,是在**「乐收赢全流程傻瓜化」Task 3**
+  (进件页重写成「我的商户」列表,计划见 `.claude/plan/2026-09-23-lsy-simple-flow.md`)里
+  扩写 / 新增的,读数取自该任务**尚未提交的工作区**:页面
+  `frontend/src/views/module_payment/collection/onboarding/index.vue`
+  = 58650 字节 / sha256 `ef04f76615739c3712f38e874223e0fc3e0d13df76ac75afea3aca0e684d6356`(LF 形态,
+  `git ls-files --eol` 实测 `i/lf w/lf`;CRLF 形态 60041 字节,见第 4 节)。
 
 ---
 
@@ -16,8 +24,9 @@
   台子内部用的是绝对路径,换 cwd 不影响结果(已实测:从 `D:\` 跑同样通过)。
 - 依赖 `frontend/node_modules`:台子用 `createRequire` 指到 `frontend/package.json` 来加载 `vue`
   与 `@vue/compiler-sfc` ⇒ 那个目录不存在时**加载这一关就过不去**,先 `pnpm i`。
-- **跑之前 `git status -- frontend/` 应当是干净的**,跑完还应当是干净的(下面每支都不写生产文件,
-  `fe-update-mutate` 的 `restore` 分支也一样)。
+- **跑之前 `git status -- frontend/` 应当是干净的**。第 1、2 节那三支**只读**,跑完仍干净;
+  第 3 节两台变异驱动**会临时改生产文件**(3a 自己还原,3b 要手动 `restore`)——
+  所以「跑完还干净」这句只对前三支成立。
 
 ## 1. 两支要传页面路径的台子(**不传会崩**)
 
@@ -43,24 +52,41 @@ node .claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-update-harness.mjs "$PG
 
 ```
 SUMMARY <页面路径>: pass=25 fail=0      ← task-9-fix1-harness.mjs
-SUMMARY <页面路径>: pass=43 fail=0      ← fe-update-harness.mjs
+SUMMARY <页面路径>: pass=89 fail=0      ← fe-update-harness.mjs(Task 3 之前是 43)
 ```
 
 有失败时同一行会追加失败用例名:`... fail=2 -> <用例名> | <用例名>`。
 
-交付提交 `20a222b` 上的实测读数就是上面这两个(各 1 次,`exit=0`,`frontend/` 跑完仍干净)。
+上面两个读数都是 2026-09-23 实跑的(各 1 次,`exit=0`):`pass=25` 取自交付提交 `20a222b`;
+`pass=89` 取自「傻瓜化」Task 3 的工作区(页面指纹见文首)。
 
-★ 顺手可以把 stderr 丢掉,屏幕只剩 PASS 与 `SUMMARY`(实测:`2>/dev/null` 后 stdout 为 27 行
-且不含 `[Vue warn]`)—— 台子的告警与桩报错**都在 stderr**,stdout 是干净的判据流。
+★ 顺手可以把 stderr 丢掉,屏幕只剩 PASS 与 `SUMMARY`(实测:`fe-update-harness` 加 `2>/dev/null` 后
+stdout **91 行**、`grep -c "Vue warn"` = **0**;这个行数随用例数走,43 条那会儿是 27 行)
+—— 台子的告警与桩报错**都在 stderr**,stdout 是干净的判据流。
 
 ### 各自钉什么
 
 - **`task-9-fix1-harness.mjs`(25 条)**:进件页 Task 9 那处修复(商户号回显 / 警示)的判定逻辑,
   含两条**正对照**(G4「正常商户号 ⇒ 显示出来」、G5「正常商户号 ⇒ 不置警示」)
   —— 没有正对照的话「一律不显示」也会全绿。
-- **`fe-update-harness.mjs`(43 条)**:R-7c「建号 / 更新」两个模式各自的报文形状
-  (U1/U1b 更新报文不含 `merchant_name_business`;Z5/Z6 更新集与建号集里都没有
-  `category` / `tenant_id` / `rate_code`)。
+- **`fe-update-harness.mjs`(89 条)**:同一支台子,两批被测逻辑。原 43 条是 R-7c 的
+  「建号 / 更新」两个模式的报文形状 —— `U.` 更新腿键白名单(U1/U1b:不含 `merchant_name_business`)、
+  `V.` 建号腿正对照(一个都没被改坏)、`W.` 渲染投影、`Y.` 更新腿的失败与形状、
+  `Z.` 键集(Z5/Z6:更新集与建号集里都没有 `category` / `tenant_id` / `rate_code`)。
+  Task 3 新增的五段钉的是**重写后新出现的**判定:
+
+  | 段 | 钉什么 |
+  |---|---|
+  | `X.` | 打开两张表单的入口 —— 新建从**干净表单**起步;「改资料」模式与商户号预填,但**不**把建号表单的值带过去 |
+  | `P.` | 进度列:四个 `review_status` 逐字映射,**表外的值一律不猜**(正对照:未知值 ⇒ 空) |
+  | `A.` | 每行**恰好一个**主按钮 + 至多一个文字按钮;A7 是正对照(APPROVED **没有**提交审核入口)、A5 是基数闸(五档各恰好一个主按钮) |
+  | `C.` | 提交审核报文:6 个业务字段 + `mch_no`,空白整个省掉;12 个资质码表;大写 `L` 那个坑 |
+  | `S.` | 409 **当成「已提交」**而不是失败(S2 关弹窗、S4 常驻警示);**另配一个 400 正对照**,证明它认的是那个码值而不是「任何失败都当 409」 |
+  | `L.` | 列表读法要解**两层** `.data`(响应拦截器不解包);L5 是正对照 —— 只解一层时读不到行 |
+
+  ★ 台子里有**一处已删的空断言**(原 `S3`)和**一处已改的脆弱写法**(`L2` 原用 `[0].mch_no`),
+  两处的理由都写在代码注释里,别当漏做补回去。详见
+  `.claude/plan/2026-09-23-lsy-simple-flow.md` Task 3 的「变异暴露出的两条我自己的问题」。
 
 ### 已知局限(别把它的绿读大)
 
@@ -80,13 +106,42 @@ node .claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-update-keycheck.mjs
 **退出码 0** + 末行 `SUMMARY keycheck: 全绿`。它比对前后端两侧的字段键名集合,
 确认没有拼错的键(跨端键名拼错在运行时只会表现为「字段悄悄没发出去」)。
 
-★ 这支是四支里**唯一搬过家时改过内容**的一支:原先写死了兄弟台子的工作区绝对路径,
+★ 这支是五支里**唯一搬过家时改过内容**的一支:原先写死了兄弟台子的工作区绝对路径,
 搬完就成了一个已删除的文件 ⇒ 改成按**自身位置**相对定位
 (`fileURLToPath(new URL("./fe-update-harness.mjs", import.meta.url))`)。
 所以它必须与 `fe-update-harness.mjs` **待在同一个目录**里。
 
 ## 3. 变异驱动(跑之前先看清楚)
 
+**两台驱动形状不同,别互相套用**:`fe-update-mutate.mjs` 是「一次一个变异 + 手动 `restore`」,
+`fe-task3-mutate.mjs` 是「四轮一口气跑完 + 每轮自己还原」。
+
+### 3a. `fe-task3-mutate.mjs` —— Task 3 的**两向**变异,一条命令跑完
+
+```bash
+node .claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-task3-mutate.mjs
+```
+
+不需要参数,**也不需要事后 `restore`**:它每一轮都在 `finally` 里**按字节还原**,
+还原后再复核 sha256 是否回到基线,末行还会再打一次「基线复核 sha …(未被留脏)」。
+
+判据是最后那行:`MUTATE-SUMMARY: 全绿`。四个变异各自预期红的用例:
+
+| 变异 | 打的是 | 预期该红 |
+|---|---|---|
+| `M-APPROVED-ALSO-SUBMIT` | APPROVED 那档也挂提交审核入口 | A7、A8 |
+| `M-409-CODE-CHANGED` | 把 409 换成一个表外的码值 | S2、S4 |
+| `M-LIST-ONE-LAYER` | 列表只解一层 `.data` | L1、L2、**L5(正对照自己也红)** |
+| `H-REORDER-DECLS` | **无害**:换个声明顺序 | (无,必须仍 89/0) |
+
+最后一行那格是这台子存在的理由:**没有它,「该红的都红了」也可能只是台子对任何改动都敏感**。
+
+**几个会让读数作废、但驱动器自己会拦下来的情况**(都会打印出来并计入非 0 退出码):
+锚点命中次数 ≠ 1(说明变异打偏了,**不会**去改文件)、还原后 sha ≠ 基线、台子**没跑完**
+(`spawnSync` ⇒ 变异把台子打崩时驱动器**不跟着死**,这一档单独判,不会被读成「没红 ⇒ 通过」)。
+
+### 3b. `fe-update-mutate.mjs` —— R-7c 的变异,**需要手动还原**
+
 ```bash
 node .claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-update-mutate.mjs restore
 ```
@@ -97,19 +152,31 @@ node .claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-update-mutate.mjs resto
   `frontend/src/api/module_payment/onboarding.ts`),并在同目录留 `.mut-bak`。
   跑完**必须**用 `restore` 还原,再用 `git status -- frontend/` 确认干净。
 
-**验收不需要跑变异。** 想跑的话按「先 `git status` 干净 → 变异 → 跑第 1 节的两支台子看读数变化 → `restore` → 再 `git status` 干净」的顺序。
+**验收不需要跑变异。**
+- 跑 3a:一条命令,跑完看末行 `MUTATE-SUMMARY`,**不用再 `restore`**,末行会自己报「未被留脏」。
+- 跑 3b:按「先 `git status` 干净 → 变异 → 跑第 1 节的两支台子看读数变化 → `restore` → 再 `git status` 干净」的顺序。
+- 两台驱动都**只动工作区文件、不动索引**,所以跑完 `git status` 该与跑前**逐行相同**。
+  (跑 3a 实测过:跑前跑后 `git status --porcelain` `diff` **无差异**、`git diff --cached --stat` 空。)
 
 ## 4. 核指纹时注意行尾
 
 本仓 `core.autocrlf = true` 且**没有 `.gitattributes`** ⇒ **重新 checkout 会把工作区文件抹成 CRLF**。
 两种形态**都实测过**,不是按行数推算的(LF 即当前工作区形态):
 
-| 文件 | LF 字节 | LF sha256 |
-|---|---|---|
-| `task-9-fix1-harness.mjs` | 10796 | `7f07de4099c96a23c56c1238aeb002857ed8bc10c83458feab5b979cac5bc095` |
-| `fe-update-harness.mjs` | 12783 | `5b91fa4b452d6aa6816ec523c2c057d28422a3a055d2e725c87af74b63984dad` |
-| `fe-update-keycheck.mjs` | 6874 | `3d4033167ed8950cda71c3396d77fe4968f70aafef915f2616193a675eba1767` |
-| `fe-update-mutate.mjs` | 4197 | `a3e68dff6d58fd86a6b219c2de50f98493434bc929c544d9f77bb1af9565a330` |
+| 文件 | LF 字节 | LF sha256 | CRLF 字节 | CRLF sha256 |
+|---|---|---|---|---|
+| `task-9-fix1-harness.mjs` | 10796 | `7f07de4099c96a23c56c1238aeb002857ed8bc10c83458feab5b979cac5bc095` | 11049 | `81271e98d40456c3a594669e796ff3d49681eee9b2491bf8ba1766f09e4a362f` |
+| `fe-update-harness.mjs` | 24707 | `a93e0f8e4dd86340f8db29766ecc6336335e00a9b3e190e219135bb296805150` | 25189 | `17e1bd83b73123bd1a941c0f0f1cdc6396e0c513638cad47753ef86baa1ef81d` |
+| `fe-update-keycheck.mjs` | 6874 | `3d4033167ed8950cda71c3396d77fe4968f70aafef915f2616193a675eba1767` | 7002 | `2e1eb43d829ea7e4aabc330c77d4a5605c4b4672a580d21ace255fbfa26325e4` |
+| `fe-update-mutate.mjs` | 4197 | `a3e68dff6d58fd86a6b219c2de50f98493434bc929c544d9f77bb1af9565a330` | 4294 | `fafacc0c8e41091a870054098c2de20dc46f90670890983dcd5101f9d8d65621` |
+| `fe-task3-mutate.mjs` | 6575 | `4003028451629bcbef57cf40bfc99c67e7879084640cfdf4b5cd8cfa7abb42dc` | 6706 | `6640b0083e02066ebccc85d34c99064d7d2ec9af5ae85cedf9eff77228c87ddf` |
+
+被测页面(Task 3 那两行的读数钉在它的这个版本上):
+
+| 文件 | LF 字节 | LF sha256 | CRLF 字节 | CRLF sha256 |
+|---|---|---|---|---|
+| `collection/onboarding/index.vue` | 58650 | `ef04f76615739c3712f38e874223e0fc3e0d13df76ac75afea3aca0e684d6356` | 60041 | `34e3cb98fd44c8835e26f79d7e3805d3a7a9b42be65fc01e5510dd12d90dfea8` |
 
-CRLF 形态:11049 / 13057 / 7002 / 4294 字节,sha256 见交付说明 §四 4。
 **核出 CRLF 那组数不是文件被改过,是行尾。**
+(上面两张表共 12 个读数,是用同一个换算脚本一次跑出来的:把读入的字节按 `\r\n` ↔ `\n` 互转后各自 sha256。
+该脚本对 `task-9-fix1-harness.mjs` 复现出的 CRLF 值 `81271e98…` 与早先独立量到的一致 ⇒ 换算方式没跑偏。)

+ 131 - 0
.claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-task3-mutate.mjs

@@ -0,0 +1,131 @@
+/**
+ * Task 3 新逻辑的**两向变异**驱动器。
+ *
+ * 形状抄自同目录 `fe-update-mutate.mjs`(那里有「先看 git status 干净」那段注意事项)。
+ *
+ * 为什么需要它:`fe-update-harness.mjs` 绿 90 条**本身不证明**那些断言有牙 ——
+ * 一条恒真的断言与一条真钉子在这份读数上长得一模一样。这里按两向走:
+ *   · 该红的必须红(三个真变异各打一处本轮新写的判定)
+ *   · **无害改动必须实测仍绿**(换两行没有依赖关系的声明顺序)—— 否则红可能只是
+ *     「台子对任何改动都敏感」,而不是「这条断言钉住了这件事」
+ *
+ * ★ 每一轮都:改前存字节 → 断言替换**确实生效**(命中次数)→ 跑台子 → **按字节还原**
+ *   → 复核 sha256 回到改前。还原动作本身也要被验证(见 memory「变异验证要两向」第三种)。
+ *
+ * 用法:node fe-task3-mutate.mjs
+ * 退出码:只有「还原后 sha 不符」或「无害变异竟然红了」才非 0。
+ */
+import fs from "node:fs";
+import crypto from "node:crypto";
+import { spawnSync } from "node:child_process";
+import { fileURLToPath } from "node:url";
+
+const HERE = fileURLToPath(new URL(".", import.meta.url));
+const HARNESS = HERE + "fe-update-harness.mjs";
+const PAGE = "D:/project2/payment-platform/frontend/src/views/module_payment/collection/onboarding/index.vue";
+
+const sha = (buf) => crypto.createHash("sha256").update(buf).digest("hex");
+
+const MUTATIONS = [
+  {
+    id: "M-APPROVED-ALSO-SUBMIT",
+    why: "把「可以收款了」那一档也挂上提交审核入口",
+    expectRed: ["A7", "A8"],
+    find: `    case "APPROVED":\n      return [{ ...edit, primary: true }];`,
+    repl: `    case "APPROVED":\n      return [{ ...edit, primary: true }, submit];`,
+  },
+  {
+    id: "M-409-CODE-CHANGED",
+    why: "把 409 那个码值换成表外的值 ⇒ 409 那条分支永不进入",
+    // ★ 起初这里写的是 ["S2","S3","S4"],实测 **S3 没红** ⇒ 追下去发现 S3 是条恒真的断言
+    //   (理由写在台子里 S3 被删处)。这条 `expectRed` 按**实测**改,不是按我当初的预期改。
+    expectRed: ["S2", "S4"],
+    find: `if (e?.data?.code === 409) {`,
+    repl: `if (e?.data?.code === 999999) {`,
+  },
+  {
+    id: "M-LIST-ONE-LAYER",
+    why: "列表只解一层 `.data`(把 payload 抬高一层的读法)",
+    expectRed: ["L1", "L2"],
+    // ★ 锚点必须**唯一**:`res.data.data?.list` 这个串在同一文件的 javadoc 里也出现过一次
+    //   ⇒ 裸用它会让替换打到注释上(驱动器的命中次数护栏第一次就是这么把它拦下来的)。
+    find: `list.value = res.data.data?.list || [];`,
+    repl: `list.value = res.data?.list || [];`,
+  },
+  {
+    id: "H-REORDER-DECLS",
+    why: "无害:把两个没有依赖关系的声明换个顺序(行数不变)",
+    expectRed: [],
+    find: `  const submit: RowAction = { text: "提交审核", primary: false, run: openCommit };\n  const edit: RowAction = { text: "改资料", primary: false, run: (r) => openUpdate(r.mch_no) };`,
+    repl: `  const edit: RowAction = { text: "改资料", primary: false, run: (r) => openUpdate(r.mch_no) };\n  const submit: RowAction = { text: "提交审核", primary: false, run: openCommit };`,
+  },
+];
+
+const original = fs.readFileSync(PAGE);
+const baseSha = sha(original);
+console.log(`基线 sha256 = ${baseSha}`);
+console.log(`基线字节   = ${original.length}`);
+
+function runHarness() {
+  // ★ 用 spawnSync 而不是 execFileSync:变异体**有可能把台子打崩**,而崩掉的读数依然有用
+  //   (「预期该红的」可以从崩溃前的 FAIL 行里认出来)。execFileSync 遇到非 0 退出码会**抛异常**
+  //   ⇒ 驱动器自己死掉,后面几个变异一个都不跑。实测踩过:M-LIST-ONE-LAYER 让 L2 抛了
+  //   `TypeError: Cannot read properties of undefined`,驱动器当场终止。
+  const r = spawnSync(process.execPath, [HARNESS, PAGE], {
+    encoding: "utf8",
+    stdio: ["ignore", "pipe", "ignore"], // stderr 丢掉(桩的告警都在那边)
+  });
+  const out = r.stdout || "";
+  const m = out.match(/^SUMMARY .*$/m);
+  const fail = (out.match(/^  FAIL {2}(.+?) {2}\(actual=/gm) || []).map((s) =>
+    s.replace(/^  FAIL {2}/, "").replace(/ {2}\(actual=$/, "")
+  );
+  return {
+    summary: m ? m[0] : `**台子没跑完(status=${r.status})⇒ 没打 SUMMARY**`,
+    fail,
+    complete: !!m,
+  };
+}
+
+let bad = 0;
+for (const mut of MUTATIONS) {
+  const src = original.toString("utf8");
+  const hits = src.split(mut.find).length - 1;
+  if (hits !== 1) {
+    console.log(`\n[${mut.id}] **变异体没生效**(锚点命中 ${hits} 次,应为 1)—— 读数作废`);
+    bad++;
+    continue;
+  }
+  fs.writeFileSync(PAGE, src.replace(mut.find, mut.repl), "utf8");
+  const mutatedSha = sha(fs.readFileSync(PAGE));
+  let res;
+  try {
+    res = runHarness();
+  } finally {
+    fs.writeFileSync(PAGE, original); // 按字节还原
+  }
+  const backSha = sha(fs.readFileSync(PAGE));
+  const restored = backSha === baseSha;
+
+  const missing = mut.expectRed.filter((tag) => !res.fail.some((f) => f.startsWith(tag)));
+  // ★ 「没跑完」必须自成一档:那时 `fail` 是从崩溃前的残缺 stdout 里抠出来的,
+  //   拿它去判「无害改动仍绿」会把「台子崩了」读成「没红 ⇒ 通过」。
+  let verdict;
+  if (!restored) verdict = "**还原失败**";
+  else if (!res.complete) verdict = "**台子没跑完 ⇒ 读数不完整(先看它崩在哪)**";
+  else if (mut.expectRed.length === 0)
+    verdict = res.fail.length === 0 ? "OK(无害改动仍绿)" : "**无害改动竟红了 ⇒ 台子对无关改动也敏感**";
+  else verdict = missing.length === 0 ? "OK(该红的都红了)" : `**预期该红却没红:${missing.join(", ")}**`;
+  if (verdict.includes("**")) bad++;
+
+  console.log(`\n[${mut.id}] ${mut.why}`);
+  console.log(`  变异后 sha = ${mutatedSha}`);
+  console.log(`  ${res.summary}`);
+  console.log(`  红的用例   = ${res.fail.length ? res.fail.join(" | ") : "(无)"}`);
+  console.log(`  还原后 sha = ${backSha} ${restored ? "== 基线 OK" : "**!= 基线**"}`);
+  console.log(`  ⇒ ${verdict}`);
+}
+
+console.log(`\n基线复核 sha = ${sha(fs.readFileSync(PAGE))} ${sha(fs.readFileSync(PAGE)) === baseSha ? "(未被留脏)" : "**留脏**"}`);
+console.log(bad === 0 ? "\nMUTATE-SUMMARY: 全绿" : `\nMUTATE-SUMMARY: ${bad} 处不合格`);
+process.exitCode = bad === 0 ? 0 : 1;

+ 225 - 17
.claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-update-harness.mjs

@@ -83,6 +83,16 @@ function makeApi(state) {
       state.updated = body;
       return state.update(body);
     },
+    // ★ Task 3 新增的两条腿(列表 / 提交审核)。原版台子没有它们 —— 页面现在要打 `/page`
+    //   与 `/commit`,不补桩的话只要真跑到 `loadList` / `submitCommit` 就是 TypeError。
+    getOnboardingPage: async (params) => {
+      state.pageParams = params;
+      return state.page ? state.page() : ok({ list: [], total: 0 });
+    },
+    commitOnboardingMerchant: async (body) => {
+      state.committed = body;
+      return state.commit(body);
+    },
   };
 }
 
@@ -113,7 +123,11 @@ function checkAbsent(name, body, key) {
 
 console.log(`=== ${LABEL} ===`);
 
-const fresh = () => ({ create: async () => ok(null), update: async () => ok(null) });
+const fresh = () => ({
+  create: async () => ok(null),
+  update: async () => ok(null),
+  commit: async () => ok(null),
+});
 
 // ---- U. 更新腿报文的键白名单(本台的主症:多一个键 = HTTP 500,少发 = 上游不改) ----
 {
@@ -122,9 +136,11 @@ const fresh = () => ({ create: async () => ok(null), update: async () => ok(null
   b.mode.value = "update";
   b.form.mch_no = "  Y52211090004  ";
   b.form.account_card_num = "6222000000000000";
-  // ★★ 手动把三个**绝不该发**的键塞进表单模型 —— 只有塞进去,「白名单在挡」这句话才不是空的。
-  //   前两个的来路是「用户在建号模式填过,切模式不清表单」(`switchMode` 有意不清);
-  //   后两个的来路是「有人图省事把封盖字段接进了表单」。
+  // ★★ 手动把五个**绝不该发**的键塞进表单模型 —— 只有塞进去,「白名单在挡」这句话才不是空的。
+  //   前三个的来路是「有人图省事把封盖/更新页没有的字段接进了表单」;
+  //   后两个的来路是「有人把两道封盖的字段当普通字段接了进来」。
+  //   (Task 3 之后 `openUpdate` 会清空表单,所以这条路不再是「切模式留下的」——
+  //     但白名单挡的是**模型里有没有这个键**,与它怎么进去的无关,塞进去照样是最硬的量法。)
   b.form.merchant_name_business = "某某公司";
   b.form.lease_contract = "https://oss/lease.png";
   b.form.category = "MICRO";
@@ -155,7 +171,7 @@ const fresh = () => ({ create: async () => ok(null), update: async () => ok(null
   b.form.merchant_name = "某某";
   b.form.merchant_name_business = "某某公司";
   b.form.lease_contract = "https://oss/lease.png";
-  b.form.mch_no = "不该出现在建号报文里"; // 切过模式留下的
+  b.form.mch_no = "不该出现在建号报文里"; // 手工塞的(同上:白名单挡的是模型里有没有这个键)
   await b.handleSubmit();
   const body = state.created;
   check("V1 正对照:建号报文**仍然带** category", body?.category, "MICRO");
@@ -188,32 +204,224 @@ const fresh = () => ({ create: async () => ok(null), update: async () => ok(null
   check("W7 更新模式:mcc 分区不受影响(merchant_mcc_code 在更新 DTO 上)", b.visibleFields(mcc).map((f) => f.key), ["merchant_mcc_code"]);
 }
 
-// ---- X. 切模式 / 跳转入口 ----
+// ---- X. 打开两张表单的入口(原「切模式」段 —— Task 3 把那个单选开关删了) ----
+//
+// ★ 为什么整段重写而不是删:原 X1–X3 钉的是 `switchMode()`「误点一下不能毁掉 41 个框」。
+//   新形状里**没有**可误点的模式开关(模式由「点的是哪颗按钮」决定,见 `openCreate`/`openUpdate`),
+//   那条契约于是没有对应物了;取而代之的新契约**方向相反**:
+//   打开一张表单必须以**干净表单**起步 —— 否则「改资料」会把上一张建号表单的 41 个值
+//   当作增量更新发出去,那正是原 X6 **标签**警告的「等于按建号值再覆盖一遍」。
+//   ★ 原 X6 的标签是这么写的,而它的断言(`=== "建号时填的"`)查的是**相反**的事:
+//     标签对、断言错。本段把断言改成标签的说法,并把标签里那句话拆成两条可查的读数。
 {
   const b = mount(fresh());
-  b.form.merchant_name = "填了一半";
+  b.form.merchant_name = "上次建号填的";
   b.form.category = "MICRO";
   b.createdMchNo.value = "M20260923";
-  b.switchMode();
-  check("X1 切模式**不清**已填的值(误点一下不能毁掉 41 个框)", b.form.merchant_name, "填了一半");
-  check("X2 切模式清掉上一次的建号结果提示", b.createdMchNo.value, "");
-  check("X3 切模式清掉「没回商户号」告警", b.submitNoMchNo.value, false);
+  b.submitNoMchNo.value = true;
+  b.openCreate();
+  check("X1 新建入口:模式是 create", b.mode.value, "create");
+  check("X2 新建入口:表单是**干净的**(不带上一次建号的值)", b.form.merchant_name, "");
+  check("X3 新建入口:清掉上一次的建号结果提示", b.createdMchNo.value, "");
+  check("X4 新建入口:清掉「没回商户号」告警", b.submitNoMchNo.value, false);
 }
 {
   const b = mount(fresh());
   b.form.merchant_name = "建号时填的";
   b.form.lease_contract = "https://oss/lease.png";
-  b.startUpdateFor("Y52211090004");
-  check("X4 跳转入口:模式切到 update", b.mode.value, "update");
-  check("X5 跳转入口:商户号已预填", b.form.mch_no, "Y52211090004");
-  check("X6 跳转入口**不**把建号表单的值带过去(否则等于按建号值再覆盖一遍)", b.form.merchant_name, "建号时填的");
+  b.openUpdate("Y52211090004");
+  check("X5 改资料入口:模式切到 update", b.mode.value, "update");
+  check("X6 改资料入口:商户号已预填", b.form.mch_no, "Y52211090004");
+  check("X7 改资料入口**不**把建号表单的值带过去(否则等于按建号值再覆盖一遍)", b.form.merchant_name, "");
+  check("X8 改资料入口:那个被排除的键也不带过去", b.form.lease_contract, "");
 }
 {
   const b = mount(fresh());
   b.mode.value = "update";
-  check("X7 更新模式只钉商户号一条必填", Object.keys(b.rules.value), ["mch_no"]);
+  check("X9 更新模式只钉商户号一条必填", Object.keys(b.rules.value), ["mch_no"]);
   b.mode.value = "create";
-  check("X8 建号模式只钉商户类别一条必填(没被本次改动动过)", Object.keys(b.rules.value), ["category"]);
+  check("X10 建号模式只钉商户类别一条必填(没被本次改动动过)", Object.keys(b.rules.value), ["category"]);
+}
+
+// ---- P. 进度列:四个词逐字 = 后端本地词表 ----
+{
+  const b = mount(fresh());
+  check(
+    "P1 进度表恰好四个键(不另立第五个词)",
+    Object.keys(b.PROGRESS).sort(),
+    ["APPROVED", "PENDING", "REJECTED", "SUBMITTED"]
+  );
+  check("P2 PENDING ⇒ 待提交审核", b.progressOf({ review_status: "PENDING" })?.text, "待提交审核");
+  check("P3 SUBMITTED ⇒ 审核中", b.progressOf({ review_status: "SUBMITTED" })?.text, "审核中");
+  check("P4 APPROVED ⇒ 可以收款了", b.progressOf({ review_status: "APPROVED" })?.text, "可以收款了");
+  check("P5 REJECTED ⇒ 审核未通过", b.progressOf({ review_status: "REJECTED" })?.text, "审核未通过");
+  // ★ 正对照:表外的值必须**不**被静默当成某一档(拿到 null,页面渲染「状态未知」)
+  check("P6 正对照:表外的值 ⇒ null(不静默归一)", b.progressOf({ review_status: "WHATEVER" }), null);
+  check("P7 review_status 为 null ⇒ 也走「状态未知」", b.progressOf({ review_status: null }), null);
+}
+
+// ---- A. 每行一个下一步:本页最要紧的判定 ----
+//
+// 判错的代价可量化:主按钮指错 ⇒ 操作员卡死;把「提交审核」给到不该给的行
+// ⇒ 上游再收一次**无法回滚**的提交。
+const acts = (b, status) => b.actionsFor({ mch_no: "M1", review_status: status });
+const primaryOf = (b, status) => acts(b, status).filter((a) => a.primary).map((a) => a.text);
+const allOf = (b, status) => acts(b, status).map((a) => a.text);
+{
+  const b = mount(fresh());
+
+  check("A1 PENDING ⇒ 主按钮是「提交审核」", primaryOf(b, "PENDING"), ["提交审核"]);
+  check("A2 SUBMITTED ⇒ 主按钮是「改资料」", primaryOf(b, "SUBMITTED"), ["改资料"]);
+  check("A3 APPROVED ⇒ 主按钮是「改资料」", primaryOf(b, "APPROVED"), ["改资料"]);
+  check("A4 REJECTED ⇒ 主按钮是「改资料」", primaryOf(b, "REJECTED"), ["改资料"]);
+  // ★ 这条是「每行恰好一个主按钮」的**总闸**:任何一档给出两个主按钮,操作员就得做选择题
+  check(
+    "A5 五种状态**每档恰好一个**主按钮(操作员不需要选)",
+    ["PENDING", "SUBMITTED", "APPROVED", "REJECTED", "WHATEVER"].map((s) => primaryOf(b, s).length),
+    [1, 1, 1, 1, 1]
+  );
+  // ★ 非「可以收款了」的行都必须留一条「提交审核」的路 —— 少了它,被驳回的行改完资料
+  //   就再也走不到送审那一步(按钮会一直停在「改资料」上)
+  check(
+    "A6 PENDING/SUBMITTED/REJECTED 都有提交审核入口",
+    ["PENDING", "SUBMITTED", "REJECTED"].map((s) => allOf(b, s).includes("提交审核")),
+    [true, true, true]
+  );
+  // ★ 反面:已经能收款的行**不给**重新提交 —— 再送一次没有意义,而误点一次的代价不可回滚
+  check("A7 正对照:APPROVED **没有**提交审核入口", allOf(b, "APPROVED").includes("提交审核"), false);
+  check("A8 APPROVED 只有一个按钮", allOf(b, "APPROVED"), ["改资料"]);
+  // ★ 表外的值:不猜该送审还是该改资料 ⇒ 主按钮给保守的「改资料」,但**保留**送审入口,免得这行变死路
+  check("A9 表外状态:主按钮给保守的「改资料」(不猜)", primaryOf(b, "WHATEVER"), ["改资料"]);
+  check("A10 表外状态:仍保留提交审核入口(不变死路)", allOf(b, "WHATEVER").includes("提交审核"), true);
+  check("A11 review_status 为 null 时也有按钮可点(不返空数组)", acts(b, null).length > 0, true);
+}
+
+// ---- C. 提交审核报文:6 个业务字段 + mch_no,空白整个省掉 ----
+{
+  const b = mount(fresh());
+  check("C1 提交报文的业务字段恰好 6 个", b.COMMIT_KEYS.length, 6);
+  check(
+    "C2 六个键逐字 = 后端 DTO 字段名(错一个就是 HTTP 500)",
+    b.COMMIT_KEYS.slice().sort(),
+    ["latitude", "longitude", "mcht_aptitude", "mcht_aptitude_note", "mcht_landuse", "store_loca"]
+  );
+  // ★ 服务层发给上游的是 `store_Loca` / `mcht_Landuse`(L 大写,进签名原文);**入站**收的是小写。
+  //   把上游那个写法抄进请求体 = 未知属性 = HTTP 500,不是「静默当没传」。
+  check(
+    "C3 入站键名里**没有**上游那个大写 L 的写法",
+    b.COMMIT_KEYS.filter((k) => k === "store_Loca" || k === "mcht_Landuse"),
+    []
+  );
+  check("C4 资质码表 = 12 项(上游 01–12)", b.APTITUDE_OPTIONS.length, 12);
+  check("C5 码表首尾逐字", [b.APTITUDE_OPTIONS[0].value, b.APTITUDE_OPTIONS[11].value], ["01", "12"]);
+}
+{
+  const state = fresh();
+  const b = mount(state);
+  b.openCommit({ mch_no: "M20260923", review_status: "PENDING" });
+  b.commitForm.mcht_aptitude = "07";
+  b.commitForm.longitude = "116.397";
+  await b.submitCommit();
+  const body = state.committed;
+  check("C6 报文含 mch_no 且来自那一行(不让操作员手输)", body?.mch_no, "M20260923");
+  check("C7 填了的原样发出", body?.mcht_aptitude, "07");
+  check("C8 没填的项整个省掉(**不送空串**)", KEYS(body), ["longitude", "mch_no", "mcht_aptitude"]);
+  checkAbsent("C9 …用 hasOwnProperty 再确认空串没被送出去", body, "store_loca");
+  check("C10 提交成功后关掉弹窗", b.commitVisible.value, false);
+  check("C11 提交成功 ⇒ 不置 409 那条提示", b.commitSyncFailedMchNo.value, "");
+  check("C12 提交成功 ⇒ 「已提交」提示用的是那一行的商户号", b.committedMchNo.value, "M20260923");
+}
+
+// ---- S. 提交审核的 409:**必须当成「已提交」**,不能当成失败 ----
+//
+// ★★ 后端 `markSubmittedOrSayWhy` 特意造的那条 409:上游**已经收到**这次提交了,
+//    只是本地状态没同步上。当成失败 ⇒ 操作员停在原地再点一次 ⇒ 上游再收一次,**无法回滚**。
+{
+  const state = {
+    ...fresh(),
+    commit: async () => {
+      // 拦截器对信封失败走 `Promise.reject(response)` ⇒ 抛出来的就是那个 AxiosResponse
+      throw { data: { code: 409, msg: "入网信息已提交(商户号 M1),但本地状态未同步…" } };
+    },
+  };
+  const b = mount(state);
+  b.openCommit({ mch_no: "M1", review_status: "PENDING" });
+  b.commitForm.mcht_aptitude = "07";
+  b.commitForm.mcht_aptitude_note = "无";
+  b.commitForm.store_loca = "XX路1号";
+  b.commitForm.mcht_landuse = "租赁";
+  b.commitForm.longitude = "116.397";
+  b.commitForm.latitude = "39.908";
+  let escaped = false;
+  try {
+    await b.submitCommit();
+  } catch {
+    escaped = true;
+  }
+  check("S1 409:rejection 不外逃到页面", escaped, false);
+  check("S2 409:关掉弹窗(按「已提交」对待,不让操作员停在原地)", b.commitVisible.value, false);
+  // ★ 这里原有一条 S3「409 不置成功提示」(`committedMchNo === ""`),**已删** —— 变异实测它没有牙:
+  //   把 409 那个码值改成表外的值(那条分支永不进入)后,S2/S4 都红了,**S3 照绿**。
+  //   成因:`submitCommit` 在**调用之前**就把 `committedMchNo` 置成 `""`,于是**一切失败路径**
+  //   上它都是空串 ⇒ 这条断言分辨不出「走了 409 分支」与「根本没走」。它对任何变异都恒真。
+  //   「没当成成功」这件事由 S4(警示位**必须**被置上)与 S2(弹窗**必须**关掉)钉住,够用。
+  check("S4 409:置那条常驻警示,且带商户号", b.commitSyncFailedMchNo.value, "M1");
+  check("S5 409:提交态已复位", b.commitSubmitting.value, false);
+}
+{
+  // ★ 正对照:同一条路上换个非 409 的信封码,必须**不**走 409 那条分支
+  const state = {
+    ...fresh(),
+    commit: async () => {
+      throw { data: { code: 400, msg: "进件必填项缺失(共 2 项):…" } };
+    },
+  };
+  const b = mount(state);
+  b.openCommit({ mch_no: "M2", review_status: "PENDING" });
+  try {
+    await b.submitCommit();
+  } catch {}
+  check("S6 正对照:400 ⇒ **不**置 409 那条警示", b.commitSyncFailedMchNo.value, "");
+  check("S7 正对照:400 ⇒ 也不当成功", b.committedMchNo.value, "");
+  check("S8 正对照:400 ⇒ 提交态照样复位", b.commitSubmitting.value, false);
+}
+
+// ---- L. 列表读法:两层 `.data`(响应拦截器不解包) ----
+{
+  const ROW = { id: "1", mch_no: "M1", merchant_name: "甲店", review_status: "PENDING" };
+  const state = {
+    ...fresh(),
+    page: async () => ok({ list: [ROW], total: 7 }),
+  };
+  const b = mount(state);
+  await b.loadList();
+  check("L1 行取自 res.data.data.list", b.list.value.length, 1);
+  // ★ 取下标必须带 `?.`:变异实测(M-LIST-ONE-LAYER)里 L1 一红,`list.value` 就是空数组,
+  //   写成 `b.list.value[0].mch_no` 会抛 TypeError ⇒ **整轮崩掉**、SUMMARY 与后面所有用例全丢。
+  //   一条断言失败绝不能把量具打成全黑 —— 那正是最需要读数的时候。
+  check("L2 行内容原样", b.list.value[0]?.mch_no, "M1");
+  check("L3 total 取自 res.data.data.total", b.total.value, 7);
+  check("L4 分页参数是 camelCase 的 pageNo/pageSize", [state.pageParams.pageNo, state.pageParams.pageSize], [1, 20]);
+}
+{
+  // ★ 正对照:把 payload 抬一层(模拟「只解了一层」的回法)⇒ 必须**读不到**行。
+  //   没有这一条,「L1 读到 1 行」不能证明它读的是两层 —— 碰巧读一层也可能对。
+  const ROW = { id: "1", mch_no: "M1" };
+  const state = {
+    ...fresh(),
+    page: async () => ({ data: { code: 0, list: [ROW], total: 7 } }),
+  };
+  const b = mount(state);
+  await b.loadList();
+  check("L5 正对照:payload 只解一层时读不到行(证明它真的在读两层)", b.list.value.length, 0);
+  check("L6 正对照:total 同理为 0", b.total.value, 0);
+}
+{
+  const state = { ...fresh(), page: async () => ok({ list: [], total: 0 }) };
+  const b = mount(state);
+  b.query.pageNo = 3;
+  b.handleSearch();
+  check("L7 查询把页码打回第 1 页", b.query.pageNo, 1);
 }
 
 // ---- Y. 更新腿的失败/形状 ----

+ 40 - 5
.claude/plan/2026-09-23-lsy-simple-flow.md

@@ -126,11 +126,46 @@
 - 现有 41 字段大表单**保留**,但只在点「新建商户」/「改资料」时以抽屉或整页展开。
 - **不出现**「进件」「映射」「mchNo」「商户号由服务端产出」这类词。
 
-- [ ] **Step 1** 列表 + 空态引导
-- [ ] **Step 2** 进度列 + 单主按钮逻辑
-- [ ] **Step 3** 提交入网小表单
-- [ ] **Step 4** 建号/改资料表单接进来
-- [ ] **Step 5** 验证:`pnpm build` exit 0 + 产物里查得到本页独有字符串(同谓词正控)
+- [x] **Step 1** 列表 + 空态引导
+- [x] **Step 2** 进度列 + 单主按钮逻辑
+- [x] **Step 3** 提交审核小表单(6 字段)
+- [x] **Step 4** 建号/改资料表单接进来(`el-drawer` + `destroy-on-close`)
+- [x] **Step 5** 验证:见下
+
+**落地时定下的两处(计划里没写、实现时才发现)**:
+
+1. **每行「恰好一个主按钮 + 至多一个文字按钮」**,文字按钮只在**非「可以收款了」**时出现。
+   规则一句话:**非 APPROVED 的行都必须留着「提交审核」入口** —— 提交审核是把资料送审的**唯一**
+   一条腿,少了它,「审核未通过」的行改完资料就再也走不到送审那一步,按钮会永远停在「改资料」上。
+   反面:APPROVED **不给**重新提交(再送一次没意义,而误点一次是不可回滚的上游调用)。
+   表外的 `review_status` 不猜哪条是下一步,主按钮给保守的「改资料」,但**保留**送审入口免得变死路。
+2. **「改资料」入口改成从干净表单起步**(`openUpdate` 先 `prepareForm()`)。原形状是「切模式不清表单」,
+   于是从建号结果那条链接跳过去时,建号表单里那 41 个值会**原样进更新报文**
+   —— 那正是既有的 X6 **标签**警告的「等于按建号值再覆盖一遍」。旧台子的 X6 标签这么写、
+   **断言却查相反的事**(`=== "建号时填的"`),本次一并订正为标签的说法。
+
+**读数(2026-09-23,`git status` 干净的工作区上)**:
+
+| 量具 | 读数 |
+|---|---|
+| `pnpm ts:check` | exit 2 / **325 行**,与改前**逐字节相同**(本仓该命令有非零历史基线,故只能同机差分) |
+| 同谓词正控 | 往页面尾部塞 `const _t3probe: number = actionsFor;` ⇒ **多且只多 1 行**:`index.vue(1325,7): error TS2322: Type '(row: OnboardingMerchantRow) => RowAction[]' is not assignable to type 'number'`(325→326);撤掉回 325 行 / 逐字节相同 / `grep -c _t3probe` = 0。★ 这条同时证明**这页真在 ts:check 的扫描范围内**(否则「无差异」是量具没读这个文件) |
+| `pnpm build` | **exit 0**(59.26s) |
+| 产物串(同谓词正控) | 8 个候选里 **7 个**满足严格谓词(`src` 里只出现在本页 **且** `dist` 里查得到,全在同一个 chunk);第 8 个「审核未通过」被脚本**自己判成「不是本页独有,本读数不成立」**(它在 `AccountOverview.vue` 里也有)—— 这正是该谓词在起作用的样子;对照串 `zzz_t3_absent_control_zzz` **0/0** |
+| keep-alive 组件名 | `CollectionOnboarding` 在产物里;对照名 `AccountOverview` 命中 3 个文件 ⇒ 量具会区分(★ 丢了它页面不被缓存**且不报错**,所以必须单独量) |
+| GC1 文案 | 模板段 11 个禁词(含上游名号两种写法与 `LESHOUYING_*` 罗马字)**全 0**;正控:模板段 1395 字符、含主标题「我的商户」 |
+| 执行级台子 | `task-9-fix1-harness` **25/0**(未改动);`fe-update-harness` **89/0**(原 43 条 → 本轮扩到 89);`fe-update-keycheck` 全绿 |
+| 两向变异 | `fe-task3-mutate.mjs` **4 轮全绿**(3 个真变异各打中预期用例、1 个无害变异仍绿、4 轮还原 sha 全部逐字节回基线) |
+| 台子 README | `.claude/plan/2026-09-23-lsy-onboarding-B-harness/README.md` 随之更新:`pass=43` → `89`、登记 `fe-task3-mutate.mjs`(新增 3a 节)、指纹表补上本轮两个文件并**加入 CRLF 侧 sha256**(原先只指去交付说明)。顺手订正三处随本轮改动而失真的读数:stdout 行数 27 → **91**(实测,随用例数走)、「四支」→ 五支、以及第 0 节那句「下面每支都不写生产文件」(对两台变异驱动是假的) |
+
+**变异暴露出的两条我自己的问题(都已修,留在台子里当记录)**:
+
+- **一条空洞断言**:我写的 S3「409 不置成功提示」在把 409 码值改掉后**照绿**。追下去:`submitCommit`
+  在调用**之前**就把 `committedMchNo` 置空 ⇒ 一切失败路径上它都是空串 ⇒ 该断言分辨不出
+  「走了 409 分支」与「根本没走」。它对**任何**变异恒真。**已删**,不留它冒充钉子。
+  (→ 我的 `expectRed` 也按**实测**改,不是按我当初的预期改。)
+- **一条会让量具全黑的脆弱写法**:`b.list.value[0].mch_no` 在 L1 一红时抛 `TypeError` ⇒ 整轮崩掉,
+  SUMMARY 与后面所有用例全丢 —— 恰好在最需要读数的时候量具失效。改成 `?.`。
 
 ---
 

+ 521 - 93
frontend/src/views/module_payment/collection/onboarding/index.vue

@@ -1,61 +1,173 @@
 <template>
   <div class="app-container">
-    <el-alert type="info" :closable="false" style="margin-bottom: 12px" :title="MODE_HINT[mode]" />
+    <!-- ==================== 第一屏就是列表 ====================
+         这一页从「一张 41 字段的大表单」改成了「列表打头 + 每行一个下一步」:
+         操作员进来先看见自己有哪些商户、各自卡在哪一步、下一步该点哪个按钮。
+         大表单还在,但只在点「新建商户」/「改资料」时才展开。 -->
+    <el-card shadow="never">
+      <div class="list-head">
+        <div>
+          <h3 class="list-head__title">我的商户</h3>
+          <p class="list-head__sub">
+            在这里创建商户、把资料交给支付渠道审核 —— 审核通过后就能收款。
+          </p>
+        </div>
+        <el-button type="primary" @click="openCreate">+ 新建商户</el-button>
+      </div>
+      <el-form :inline="true" @submit.prevent>
+        <el-form-item label="搜索">
+          <el-input
+            v-model="query.keyword"
+            placeholder="商户号 / 商户名"
+            clearable
+            style="width: 260px"
+            @keyup.enter="handleSearch"
+          />
+        </el-form-item>
+        <el-form-item>
+          <el-button type="primary" @click="handleSearch">查询</el-button>
+        </el-form-item>
+      </el-form>
+    </el-card>
+
+    <!-- ==================== 上一次操作的结果(常驻到下一次操作) ====================
+         toast 会自己消失,而这几条是「下一步该干嘛」的依据,必须留得住。 -->
 
-    <!-- 商户号是**建号腿**唯一的产出:toast 会自己消失,这里**常驻**到下一次提交 -->
-    <el-alert v-if="createdMchNo" type="success" :closable="false" style="margin-bottom: 12px">
+    <!-- 建号成功:本页唯一的产出是商户号,而**下一步是提交审核** —— 两件事一起说 -->
+    <el-alert v-if="createdMchNo" type="success" :closable="false" style="margin: 12px 0">
       <template #title>
-        提交成功,商户号:
-        <strong class="mch-no">{{ createdMchNo }}</strong>
+        商户已建好,商户号:<strong class="mch-no">{{ createdMchNo }}</strong>
+        —— 下一步:点它那一行的「提交审核」。
       </template>
       <template #default>
-        <!-- 建号后当场发现填错,是更新腿最常见的入口 —— 这里少一次「回头找商户号」 -->
-        <el-button link type="primary" @click="startUpdateFor(createdMchNo)">
+        <el-button link type="primary" @click="openUpdate(createdMchNo)">
           资料填错了?改这个商户
         </el-button>
       </template>
     </el-alert>
 
-    <!-- 更新腿**不回**商户号(`Result<Void>`),所以这里显示的是**请求里那个**,不是响应里的 -->
-    <el-alert v-if="updatedMchNo" type="success" :closable="false" style="margin-bottom: 12px">
+    <!-- 资料已改(更新腿**不回**商户号,`Result<Void>`)⇒ 这里显示的是**请求里那个** -->
+    <el-alert v-if="updatedMchNo" type="success" :closable="false" style="margin: 12px 0">
       <template #title>
-        已提交更新:
-        <strong class="mch-no">{{ updatedMchNo }}</strong>
-        —— 表单里留空的项,上游原值未变。
+        已提交更新:<strong class="mch-no">{{ updatedMchNo }}</strong>
+        —— 表单里留空的项,渠道那边的原值未变。
       </template>
     </el-alert>
 
-    <!-- 信封说成功、却没有商户号:既不能说成功(拿不到号),也不能说失败(可能已经建出来了) -->
+    <!-- 审核已提交 -->
     <el-alert
-      v-if="submitNoMchNo"
+      v-if="committedMchNo"
+      type="success"
+      :closable="false"
+      style="margin: 12px 0"
+      :title="`已提交审核:${committedMchNo} —— 审核通过后就能收款了。`"
+    />
+
+    <!-- ★★ 409:**上游已经收到这次提交了**,只是本地状态没同步上。
+         这条**不能**报成「提交失败」:操作员会再点一次,而平台那边会再收一次、且无法回滚。
+         后端文案逐字含「不得当作失败直接重试」,这里把它翻成一句行动指令。 -->
+    <el-alert
+      v-if="commitSyncFailedMchNo"
       type="warning"
       :closable="false"
       show-icon
-      style="margin-bottom: 12px"
-      title="提交已被受理,但响应里没有商户号 —— 请先核对是否已建号,直接重提会再建一个商户。"
+      style="margin: 12px 0"
+      :title="`已提交给支付渠道(${commitSyncFailedMchNo}),但本地状态没同步上。请**刷新列表**核对后再操作 —— 直接重试会让平台再收一次。`"
     />
 
+    <!-- 信封说成功、却没有商户号:既不能说成功(拿不到号),也不能说失败(可能已经建出来了) -->
     <el-alert
-      v-if="cascadeFailed"
-      type="error"
+      v-if="submitNoMchNo"
+      type="warning"
       :closable="false"
       show-icon
-      style="margin-bottom: 12px"
-      title="省市区 / 行业类目 未能加载,这两组级联为空时无法完成填写"
+      style="margin: 12px 0"
+      title="提交已被受理,但响应里没有商户号 —— 请先核对是否已建号,直接重提会再建一个商户。"
+    />
+
+    <el-card shadow="never" style="margin-top: 12px">
+      <el-table v-loading="loading" :data="list" border stripe>
+        <el-table-column prop="mch_no" label="商户号" min-width="170" />
+        <el-table-column label="商户名" min-width="200">
+          <template #default="{ row }">{{ row.merchant_name || "—" }}</template>
+        </el-table-column>
+        <el-table-column label="进度" width="150">
+          <template #default="{ row }">
+            <el-tooltip :disabled="!!progressOf(row)" :content="row.review_status || '(空)'">
+              <el-tag :type="progressOf(row)?.tag ?? 'info'" size="small">
+                {{ progressOf(row)?.text ?? "状态未知" }}
+              </el-tag>
+            </el-tooltip>
+          </template>
+        </el-table-column>
+        <!-- ★ 每行**恰好一个主按钮**,由进度判定(`actionsFor` 是唯一那张表)——
+             操作员不需要在多个动作之间做选择。非主按钮只在「还差另一条腿走不完」时出现。 -->
+        <el-table-column label="操作" width="230" fixed="right">
+          <template #default="{ row }">
+            <el-button
+              v-for="a in actionsFor(row)"
+              :key="a.text"
+              type="primary"
+              :link="!a.primary"
+              @click="a.run(row)"
+            >
+              {{ a.text }}
+            </el-button>
+          </template>
+        </el-table-column>
+
+        <!-- 空态:这是**唯一**引导新建的地方之外的第二处(另一处是右上角那颗按钮) -->
+        <template #empty>
+          <el-empty description="还没有商户">
+            <el-button type="primary" @click="openCreate">新建商户</el-button>
+          </el-empty>
+        </template>
+      </el-table>
+
+      <el-pagination
+        v-model:current-page="query.pageNo"
+        v-model:page-size="query.pageSize"
+        :total="total"
+        :page-sizes="[10, 20, 50]"
+        layout="total, sizes, prev, pager, next, jumper"
+        style="margin-top: 12px; justify-content: flex-end"
+        @change="loadList"
+      />
+    </el-card>
+
+    <!-- ==================== 大表单抽屉(新建 / 改资料) ====================
+         ★ 模式由**打开它的那颗按钮**决定,不给操作员一个「建号/更新」单选 ——
+           那是给开发者看的开关,选错了的代价是把资料发去改别人的商户。
+         ★ `destroy-on-close`:每次打开都是**全新的表单 DOM**;表单模型在页面作用域里,
+           由 `prepareForm` 显式复位(两者各管一段,缺一不可)。 -->
+    <el-drawer
+      v-model="formVisible"
+      :title="mode === 'create' ? '新建商户' : `修改资料:${form.mch_no}`"
+      size="80%"
+      destroy-on-close
     >
-      <template #default>
-        <el-button link type="primary" @click="reloadCascades">重新加载</el-button>
-      </template>
-    </el-alert>
+      <el-alert
+        type="info"
+        :closable="false"
+        style="margin-bottom: 12px"
+        :title="MODE_HINT[mode]"
+      />
+
+      <el-alert
+        v-if="cascadeFailed"
+        type="error"
+        :closable="false"
+        show-icon
+        style="margin-bottom: 12px"
+        title="省市区 / 行业类目 未能加载,这两组级联为空时无法完成填写"
+      >
+        <template #default>
+          <el-button link type="primary" @click="reloadCascades">重新加载</el-button>
+        </template>
+      </el-alert>
 
-    <el-card shadow="never">
-      <!-- ★ 两种模式**共用**这一张表单、这一套级联。不另起页面/弹窗:更新腿的字段集是
+      <!-- ★ 两种模式**共用**这一张表单、这一套级联。不另起一份:更新腿的字段集是
            建号腿那张表**减去两个**(见 `UPDATE_EXCLUDED_KEYS`),另起一份必然漂移。 -->
-      <el-radio-group v-model="mode" style="margin-bottom: 16px" @change="switchMode">
-        <el-radio-button value="create">建号(新建商户)</el-radio-button>
-        <el-radio-button value="update">更新(改已有商户资料)</el-radio-button>
-      </el-radio-group>
-
       <el-form
         ref="formRef"
         :model="form"
@@ -64,7 +176,7 @@
         label-position="right"
         v-loading="submitting"
       >
-        <!-- 商户号:**只有更新模式**有 —— 建号时它由服务端产出,不是输入 -->
+        <!-- 商户号:**只有改资料模式**显示它 —— 新建时它由服务端产出,不是输入 -->
         <el-form-item v-if="mode === 'update'" label="商户号" prop="mch_no">
           <el-input
             v-model="form.mch_no"
@@ -73,7 +185,7 @@
           />
         </el-form-item>
 
-        <!-- 商户类别:**只有建号模式**有 —— 更新页的参数表里压根没有它
+        <!-- 商户类别:**只有新建模式**有 —— 更新页的参数表里压根没有它
              (`merchant_type` 不在那张表里 ⇒ 建号腿「一个 category 派生两个输出」在更新腿不成立) -->
         <el-form-item v-if="mode === 'create'" label="商户类别" prop="category">
           <el-select v-model="form.category" placeholder="请选择商户类别" style="width: 320px">
@@ -255,11 +367,68 @@
         <div class="form-actions">
           <el-button @click="handleReset">重置</el-button>
           <el-button type="primary" :loading="submitting" @click="handleSubmit">
-            {{ mode === "update" ? "提交更新" : "提交进件" }}
+            {{ mode === "update" ? "保存修改" : "创建商户" }}
           </el-button>
         </div>
       </el-form>
-    </el-card>
+    </el-drawer>
+
+    <!-- ==================== 提交审核(6 个字段) ====================
+         键名逐字 = `LsyCommitMerchantInfoDTO` 的字段名经全局 SNAKE_CASE 的结果,
+         详见 `api/module_payment/onboarding.ts` 的 `commitOnboardingMerchant`。 -->
+    <el-dialog v-model="commitVisible" title="提交审核" width="600px">
+      <el-alert type="info" :closable="false" style="margin-bottom: 12px">
+        <template #title>
+          把商户号
+          <strong class="mch-no">{{ commitRow?.mch_no }}</strong>
+          的资料提交给支付渠道审核。六项都是必填。
+        </template>
+      </el-alert>
+
+      <el-form ref="commitFormRef" :model="commitForm" :rules="commitRules" label-width="140px">
+        <el-form-item :label="COMMIT_LABELS.mcht_aptitude" prop="mcht_aptitude">
+          <!-- ★ 十二个码值是**照抄**上游资质表的原文(见 `LsyCommitMerchantInfoDTO` 的 javadoc)。
+               `filterable allow-create` 是给「上游哪天加码」留的口子 —— 纯下拉会把新码堵死在页面上。 -->
+          <el-select
+            v-model="commitForm.mcht_aptitude"
+            placeholder="请选择商户资质"
+            filterable
+            allow-create
+            default-first-option
+            style="width: 100%"
+          >
+            <el-option
+              v-for="a in APTITUDE_OPTIONS"
+              :key="a.value"
+              :label="`${a.value} ${a.label}`"
+              :value="a.value"
+            />
+          </el-select>
+        </el-form-item>
+        <el-form-item :label="COMMIT_LABELS.mcht_aptitude_note" prop="mcht_aptitude_note">
+          <el-input v-model="commitForm.mcht_aptitude_note" placeholder="资质说明,例如「无」" />
+        </el-form-item>
+        <el-form-item :label="COMMIT_LABELS.store_loca" prop="store_loca">
+          <el-input v-model="commitForm.store_loca" placeholder="门店所在位置" />
+        </el-form-item>
+        <el-form-item :label="COMMIT_LABELS.mcht_landuse" prop="mcht_landuse">
+          <el-input v-model="commitForm.mcht_landuse" placeholder="营业用地,例如「自有」/「租赁」" />
+        </el-form-item>
+        <el-form-item :label="COMMIT_LABELS.longitude" prop="longitude">
+          <el-input v-model="commitForm.longitude" placeholder="例如 116.397" />
+        </el-form-item>
+        <el-form-item :label="COMMIT_LABELS.latitude" prop="latitude">
+          <el-input v-model="commitForm.latitude" placeholder="例如 39.908" />
+        </el-form-item>
+      </el-form>
+
+      <template #footer>
+        <el-button @click="commitVisible = false">取消</el-button>
+        <el-button type="primary" :loading="commitSubmitting" @click="submitCommit">
+          提交审核
+        </el-button>
+      </template>
+    </el-dialog>
   </div>
 </template>
 
@@ -275,14 +444,17 @@ defineOptions({
 import { computed, onMounted, reactive, ref } from "vue";
 import {
   MERCHANT_CATEGORY_OPTIONS,
+  commitOnboardingMerchant,
   createOnboardingMerchant,
   getOnboardingAreas,
   getOnboardingMcc,
+  getOnboardingPage,
   updateOnboardingMerchant,
   uploadOnboardingImage,
   type OnboardingArea,
   type OnboardingLevel,
   type OnboardingMcc,
+  type OnboardingMerchantRow,
 } from "@/api/module_payment/onboarding";
 import SingleImageUpload from "@/components/Upload/SingleImageUpload.vue";
 
@@ -297,6 +469,266 @@ import SingleImageUpload from "@/components/Upload/SingleImageUpload.vue";
 const UPLOAD_MAX_MB = 2;
 const UPLOAD_ACCEPT = ".jpg,.jpeg,.png,.bmp";
 
+// ==================== 列表 ====================
+
+const query = reactive({ pageNo: 1, pageSize: 20, keyword: "" });
+const list = ref<OnboardingMerchantRow[]>([]);
+const total = ref(0);
+const loading = ref(false);
+
+/**
+ * 读法照已上线的 `collection/merchant/index.vue`:`res.data.data?.list` / `?.total`
+ * (响应拦截器**不解包** ⇒ 两层 `.data`;分页字段名是 `list`/`total`,不是 `records`/`rows`)。
+ */
+async function loadList() {
+  loading.value = true;
+  try {
+    const res: any = await getOnboardingPage({ ...query });
+    list.value = res.data.data?.list || [];
+    total.value = res.data.data?.total || 0;
+  } finally {
+    loading.value = false;
+  }
+}
+
+function handleSearch() {
+  query.pageNo = 1;
+  loadList();
+}
+
+/**
+ * 进度 → 文案。**四个键逐字 = `LsyMerchantReviewStatus` 的四个字面值**,别在这里造第五个词。
+ *
+ * ★ 前三个键是**本地词表**(`PENDING` 由建号写、`SUBMITTED` 由提交审核的记号写、
+ *   `APPROVED`/`REJECTED` 由审核结果回调写)。上游自己有另一套 `reviewed_status` 码值,
+ *   本页**不碰** —— 上游那套在同一页里有互相冲突的 0–6 两张表,映射谁都是在猜
+ *   (见 `LsyOnboardingService.reportQuery` 的 javadoc)。
+ * ★ 表里没有的值 ⇒ 显示「状态未知」,并把原始值放进 tooltip(不静默当成某一档)。
+ */
+const PROGRESS: Record<string, { text: string; tag: "success" | "info" | "warning" | "danger" }> = {
+  PENDING: { text: "待提交审核", tag: "warning" },
+  SUBMITTED: { text: "审核中", tag: "info" },
+  APPROVED: { text: "可以收款了", tag: "success" },
+  REJECTED: { text: "审核未通过", tag: "danger" },
+};
+
+function progressOf(row: OnboardingMerchantRow) {
+  return PROGRESS[row.review_status ?? ""] ?? null;
+}
+
+interface RowAction {
+  text: string;
+  primary: boolean;
+  run: (row: OnboardingMerchantRow) => void;
+}
+
+/**
+ * 每行该显示哪几个按钮 —— **本页唯一一处「下一步是什么」的判定**。
+ *
+ * ★ 只有一条规则:**恰好一个主按钮**(由进度决定,操作员不需要选),
+ *   外加**至多一个**文字按钮,且它只在「非『可以收款了』」时出现。
+ *
+ *   为什么非「可以收款了」的行都要留一个「提交审核」入口:提交审核是把资料送审的**唯一**
+ *   一条腿。少了它,「审核未通过」的行改完资料就再也走不到送审那一步 —— 按钮会一直停在
+ *   「改资料」上,操作员只能反复点它。
+ * ★ 「可以收款了」**不给**重新提交:那一档再送一次没有意义,而误点一次的代价是一次
+ *   不可回滚的上游调用。
+ * ★ 主按钮的取法:卡在「待提交审核」的下一步是**送审**;其余各档的下一步都是**先改资料**
+ *   (审核中/被驳回时,不改就再送一次几乎必然还是同一个结果)。
+ */
+function actionsFor(row: OnboardingMerchantRow): RowAction[] {
+  const submit: RowAction = { text: "提交审核", primary: false, run: openCommit };
+  const edit: RowAction = { text: "改资料", primary: false, run: (r) => openUpdate(r.mch_no) };
+
+  switch (row.review_status) {
+    case "PENDING":
+      return [{ ...submit, primary: true }, edit];
+    case "APPROVED":
+      return [{ ...edit, primary: true }];
+    case "SUBMITTED":
+    case "REJECTED":
+      return [{ ...edit, primary: true }, submit];
+    default:
+      // 表里没有的值:不猜下一步是送审还是改资料,给保守的那条(改资料),
+      // 并保留送审入口,免得这一行变成死路。
+      return [{ ...edit, primary: true }, submit];
+  }
+}
+
+// ==================== 打开两张表单 ====================
+
+const formVisible = ref(false);
+/** 大表单的模式 —— **只由打开它的那颗按钮决定**,页面上没有切换开关。 */
+const mode = ref<"create" | "update">("create");
+
+function openCreate() {
+  mode.value = "create";
+  prepareForm();
+  formVisible.value = true;
+}
+
+function openUpdate(mchNo: string) {
+  mode.value = "update";
+  prepareForm();
+  // ★ 只预填 `mch_no`,**不**把上一次表单里那一堆值带过去:更新是增量的,
+  //   带过去等于「按上次填的再覆盖一遍」,而不是「只改要改的那一项」。
+  form.mch_no = mchNo;
+  formVisible.value = true;
+}
+
+/**
+ * 每次打开抽屉前复位。`el-drawer` 的 `destroy-on-close` 换掉的是 **DOM**,
+ * 而 `form` 是页面作用域里的 `reactive` ⇒ 两件事各管一段,缺一不可。
+ *
+ * ★ 复位**结果提示**:那是「上一次那个动作」的结论,留着会让人以为这次已经做过了。
+ * ★ 级联**只拉一次**:`cascadesLoaded` 之后复用(下拉的选项与表单无关,重进页面才该重拉)。
+ *   失败时的重试入口是抽屉里那条红色提示上的「重新加载」。
+ */
+function prepareForm() {
+  Object.assign(form, emptyForm());
+  activeSections.value = SECTIONS.map((s) => s.key);
+  resetCascadePicks();
+  createdMchNo.value = "";
+  updatedMchNo.value = "";
+  submitNoMchNo.value = false;
+  if (!cascadesLoaded.value) {
+    cascadesLoaded.value = true;
+    loadProvinces();
+    loadMccLevel1();
+  }
+}
+
+// ==================== 提交审核 ====================
+
+const commitVisible = ref(false);
+const commitRow = ref<OnboardingMerchantRow | null>(null);
+const commitFormRef = ref();
+const commitSubmitting = ref(false);
+const committedMchNo = ref("");
+const commitSyncFailedMchNo = ref("");
+
+/**
+ * 六个业务字段的**线上键名逐字**(`LsyCommitMerchantInfoDTO` 的字段名经全局 SNAKE_CASE)。
+ * 中括号里是页面上给操作员看的中文 —— 前五个逐字 = 服务端 `requireText` 报错时用的标签。
+ *
+ * ★ 第六个(`latitude`)**两处措辞不同**:服务端那侧逐字是「营业地址**维度**」
+ *   (上游说明列的错字,那边按「标签逐字取说明列」的纪律照抄,`LsyOnboardingService` 里有注释)。
+ *   页面这里用正确的「纬度」—— 本页的必填校验会先拦住,所以操作员通常看不到服务端那句话;
+ *   万一看到,指的是同一个框。
+ */
+const COMMIT_LABELS: Record<string, string> = {
+  mcht_aptitude: "商户资质",
+  mcht_aptitude_note: "商户资质备注",
+  store_loca: "门店所在位置",
+  mcht_landuse: "营业用地",
+  longitude: "营业地址经度",
+  latitude: "营业地址纬度",
+};
+
+/** 提交审核报文的字段清单 —— 与 `COMMIT_LABELS` 同键集,**一处派生、一处校验**。 */
+const COMMIT_KEYS = Object.keys(COMMIT_LABELS);
+
+function emptyCommit(): Record<string, string> {
+  const f: Record<string, string> = {};
+  for (const k of COMMIT_KEYS) f[k] = "";
+  return f;
+}
+
+const commitForm = reactive<Record<string, string>>(emptyCommit());
+
+/**
+ * 六项**全部必填**(上游那页参数表逐个标了「必填=是」,服务端 `requireText` 六条一一对应)。
+ * 这里不像大表单那样「只钉一条」,因为那边是**按类别分档**的必填矩阵(抄一份就是两个真值来源),
+ * 而这边是**无条件**的六条,且标签与服务端逐字相同。
+ * `trigger` 带上 `change`:`mcht_aptitude` 是下拉,只挂 `blur` 永远不会触发。
+ */
+const commitRules = Object.fromEntries(
+  COMMIT_KEYS.map((k) => [
+    k,
+    [{ required: true, message: `请填写${COMMIT_LABELS[k]}`, trigger: ["blur", "change"] }],
+  ])
+);
+
+/**
+ * 商户资质的十二个码值 —— **照抄** `LsyCommitMerchantInfoDTO` 的 javadoc(那边逐字抄自上游参数表)。
+ * ★ 只送 `value`(码),`label` 只用于显示 —— 与 `MERCHANT_CATEGORY_OPTIONS` 同一条纪律。
+ * ★ 本仓**不校验**这张表(上游加码是预期内的事),所以下拉开了 `allow-create`。
+ */
+const APTITUDE_OPTIONS = [
+  { value: "01", label: "央企、国企" },
+  { value: "02", label: "央企国企子公司" },
+  { value: "03", label: "上市公司" },
+  { value: "04", label: "上市公司子公司" },
+  { value: "05", label: "全国连锁企业" },
+  { value: "06", label: "当地连锁企业" },
+  { value: "07", label: "一般商户" },
+  { value: "08", label: "五星级及以上或同等级商务酒店" },
+  { value: "09", label: "四星级或同等级商务酒店" },
+  { value: "10", label: "准四星酒店" },
+  { value: "11", label: "三星酒店" },
+  { value: "12", label: "二星级及以下酒店宾馆" },
+] as const;
+
+function openCommit(row: OnboardingMerchantRow) {
+  commitRow.value = row;
+  Object.assign(commitForm, emptyCommit());
+  committedMchNo.value = "";
+  commitSyncFailedMchNo.value = "";
+  commitVisible.value = true;
+}
+
+/**
+ * 提交审核报文 —— **空白整个省掉,不送空串**(见 `api/module_payment/onboarding.ts` 文件头 ★★:
+ * 后端只剔 `null`,而空串会进上游签名原文并照发)。六项都被页面校验过,正常不会有空白;
+ * 真漏了也只会落到服务端那条按中文标签报出来的 400,而不是把空串发上去。
+ */
+function buildCommitBody(): Record<string, string> {
+  const body: Record<string, string> = { mch_no: commitRow.value?.mch_no ?? "" };
+  for (const k of COMMIT_KEYS) {
+    const v = (commitForm[k] ?? "").trim();
+    if (v !== "") body[k] = v;
+  }
+  return body;
+}
+
+async function submitCommit() {
+  try {
+    await commitFormRef.value?.validate();
+  } catch {
+    // 校验没过:`el-form-item` 已就地标红,不再弹 toast
+    return;
+  }
+
+  commitSubmitting.value = true;
+  committedMchNo.value = "";
+  commitSyncFailedMchNo.value = "";
+  try {
+    await commitOnboardingMerchant(buildCommitBody());
+    committedMchNo.value = commitRow.value?.mch_no ?? "";
+    commitVisible.value = false;
+  } catch (e: any) {
+    // ★★ 409 是**后端特意造出来**的那一条:上游已经收到这次提交了,只是本地状态没同步上
+    //    (`LeshouyingOnboardingController.markSubmittedOrSayWhy`)。它**不是**失败 ——
+    //    当成失败处理会让操作员原地再点一次,而平台那边会再收一次、且无法回滚。
+    //    ⇒ 按「已提交」对待:关掉弹窗、刷新列表、把这句话留在页面上。
+    // ★ 读法:拦截器对信封失败走的是 `Promise.reject(response)`,所以 `e` 就是那个
+    //   AxiosResponse ⇒ 这里读 `e.data.code`(**不是** `e.response.data.code`,那是网络层错误的形状)。
+    //   非网络层的错误(超时等)reject 的是 `Error`,没有 `.data` ⇒ `?.` 兜住。
+    if (e?.data?.code === 409) {
+      commitSyncFailedMchNo.value = commitRow.value?.mch_no ?? "";
+      commitVisible.value = false;
+    }
+    // 其余失败:文案由拦截器统一弹(必填缺失 / 非本租户 404 / 上游失败 502 都在 `msg` 里)
+    // ⇒ 页面**不重复弹**,也不把它当成功往下走。这里只是不让 rejection 逃逸成未处理的拒绝。
+  } finally {
+    commitSubmitting.value = false;
+    // 两条路(成功 / 409)都要刷新列表 —— 列表上那一列进度正是这次操作的结果
+    await loadList();
+  }
+}
+
+// ==================== 大表单的字段清单 ====================
+
 /** 一个字段:`key` 逐字 = 线上报文键名(snake_case),`label` 逐字 = 服务端那张中文标签表。 */
 interface Field {
   key: string;
@@ -325,7 +757,7 @@ const MCC_FIELD: Field = { key: "merchant_mcc_code", label: "商户行业类目c
 const UPLOAD_HINT = "支持 jpg/jpeg/png/bmp、单张不超过 2MB";
 
 /**
- * ★★ 本页的**唯一**一张字段清单:分区折叠的渲染、以及建号报文的键白名单,都从它推导
+ * ★★ 本页大表单的**唯一**一张字段清单:分区折叠的渲染、以及建号报文的键白名单,都从它推导
  *   (见 `CREATE_KEYS`)—— 两处各写一份必然漂移。
  *
  * `label` **逐字**抄自服务端那张中文标签表(覆盖该页 62 个参数、javadoc 明说用途之一是
@@ -470,18 +902,16 @@ const UPDATE_EXCLUDED_KEYS: readonly string[] = ["merchant_name_business", "leas
 const UPDATE_KEYS: string[] = CREATE_KEYS.filter((k) => !UPDATE_EXCLUDED_KEYS.includes(k));
 
 /**
- * 本页两种模式 —— **同一张表、同一套级联、两个出口**(`/create` 与 `/update`)。
+ * 大表单两种模式 —— **同一张表、同一套级联、两个出口**(`/create` 与 `/update`)。
  *
- * ★ 不另起一个页面或弹窗:更新腿的字段集就是建号腿那张表减去两个,另起一份必然漂移,
+ * ★ 不另起一份:更新腿的字段集就是建号腿那张表减去两个,另起一份必然漂移,
  *   而漂移的症状是「同一份资料在两处能填的格不一样」且不报错。
  */
-const mode = ref<"create" | "update">("create");
-
 const MODE_HINT: Record<"create" | "update", string> = {
   create:
-    "按所选商户类别填写资料,提交后由服务端建号。必填项按类别分档,漏填时服务端会在提交后一次列全(按中文字段名)—— 照那份清单补齐即可。",
+    "按所选商户类别填写资料,提交后由服务端建号。必填项按类别分档,漏填时服务端会在提交后一次列全(按中文字段名)—— 照那份清单补齐即可。建好后**下一步是提交审核**。",
   update:
-    "更新已有商户的上游资料。★ 上游是增量更新:这里留空 = 不改这一项,不是清空 —— 只想改哪几项就只填哪几项;下拉与图片同理,没动过的不会被提交。",
+    "修改已有商户在渠道那边的资料。★ 渠道是增量更新:这里留空 = 不改这一项,不是清空 —— 只想改哪几项就只填哪几项;下拉与图片同理,没动过的不会被提交。",
 };
 
 /** 更新模式下该分区要渲染的字段 —— `SECTIONS` 仍是**唯一**那张表,这里只是投影。 */
@@ -492,7 +922,7 @@ function visibleFields(s: Section): Field[] {
 }
 
 function emptyForm(): Record<string, string> {
-  // `mch_no` 只有更新模式用得上(建号时它是服务端的**产出**);不填它 ⇒ 建号报文不带这个键
+  // `mch_no` 只有改资料模式用得上(新建时它是服务端的**产出**);不填它 ⇒ 建号报文不带这个键
   const f: Record<string, string> = { category: "", mch_no: "" };
   for (const key of CREATE_KEYS) {
     f[key] = "";
@@ -512,12 +942,14 @@ const submitNoMchNo = ref(false);
  */
 const updatedMchNo = ref("");
 const cascadeFailed = ref(false);
+/** 级联只在**第一次**打开抽屉时拉;之后复用(下拉选项与表单无关)。 */
+const cascadesLoaded = ref(false);
 
 /** 默认**全部展开**(理由见模板上的注释)。 */
 const activeSections = ref<string[]>(SECTIONS.map((s) => s.key));
 
 /**
- * ★ 页面**只**钉 `category` 一条规则,**不做**必填矩阵的副本。
+ * ★ 大表单页面**只**钉 `category` 一条规则,**不做**必填矩阵的副本。
  *
  * 为什么只钉它:类别为空时服务端报的缺失项是两个**派生**字段(商户类型 / 账户类型),
  * 它们 DTO 上没有、页面上也没有输入框 ⇒ 操作者会看到两个自己从未见过的名字。
@@ -571,6 +1003,18 @@ const mcc3Name = computed(
   () => mccOptions3.value.find((m) => m.mcc_code === mcc3.value)?.mcc_name || ""
 );
 
+/** 复位两组级联的**选项与已选**(表单复位时必调,理由见 `handleReset`)。 */
+function resetCascadePicks() {
+  cityOptions.value = [];
+  countyOptions.value = [];
+  mcc1.value = "";
+  mcc2.value = "";
+  mcc3.value = "";
+  mccOptions2.value = [];
+  mccOptions3.value = [];
+  clearLeaf();
+}
+
 /**
  * 拉一次级联腿。
  *
@@ -597,7 +1041,7 @@ async function loadCascade<T>(fetcher: () => Promise<unknown>): Promise<T[] | nu
 async function loadProvinces() {
   // ★ `level="1"` ⇒ **字面上就不给 `parentCode` 这个键**(不是给空串、也不是给 undefined)。
   // ★ 空值兜底(`?? []`)只针对**失败**(`null`);行政区这三级的「空列表」没有「末级」语义,
-  // 不像类目那样会被读成结论,所以这里不必再区分。
+  //   不像类目那样会被读成结论,所以这里不必再区分。
   provinceOptions.value =
     (await loadCascade<OnboardingArea>(() => getOnboardingAreas({ level: "1" }))) ?? [];
 }
@@ -768,7 +1212,7 @@ async function uploadOnboardingFile(fd: FormData): Promise<string> {
   return url;
 }
 
-// ==================== 提交 ====================
+// ==================== 大表单提交 ====================
 
 /**
  * ★ 空白字段**整个省掉**,不送空串:后端只剔 `null`,而**空串会进上游签名原文并照发**
@@ -791,8 +1235,8 @@ function buildCreateBody(): Record<string, string> {
  * ★ 与 `buildCreateBody` 共用「空白字段整个省掉」这条判据(理由见上)。**不共用函数**是因为
  *   两个出口的键集不同,而且这次的「省掉」还多一层语义:上游是**增量更新**,
  *   留空 = 保持原值 ⇒ 省掉正是正确行为,不是「少发了」。
- * ★★ 切模式**不清表单**(见 `switchMode`)⇒ `form` 里此刻可能还留着建号模式打进去的
- *   `merchant_name_business` / `lease_contract` 值。`UPDATE_KEYS` 不含它们,**物理上发不出去** ——
+ * ★★ `form` 里此刻可能还留着上一次新建时打进去的 `merchant_name_business` / `lease_contract` 值
+ *   (`prepareForm` 确实会清,但**不要依赖它**)。`UPDATE_KEYS` 不含这两个键,**物理上发不出去** ——
  *   这就是「白名单」而非 `{ ...form }` 的意义:那道 500 由白名单挡,不靠「用户没填过」这个假设。
  */
 function buildUpdateBody(): Record<string, string> {
@@ -820,11 +1264,11 @@ async function handleSubmit() {
 }
 
 /**
- * 更新腿。与建号腿的三处**有意不同**:
+ * 改资料腿。与新建腿的三处**有意不同**:
  *  ① 应答是 `Result<Void>` ⇒ **没有**商户号可读,也就没有「没回商户号」那条告警
- *     (那条的语义是「本页唯一的产出没了」,在更新腿没有对应物);
+ *     (那条的语义是「本页唯一的产出没了」,在这条腿上没有对应物);
  *  ② 商户号是**输入**(`form.mch_no`)而不是产出,提示里显示的是请求里的那个;
- *  ③ 提交完**不清表单**(与建号腿一致)⇒ 一条常驻提示说明「哪几项没被提交」,
+ *  ③ 提交完**不清表单**(与新建腿一致)⇒ 一条常驻提示说明「哪几项没被提交」,
  *     因为从表单上看不出「留空 = 不改」。
  */
 async function submitUpdate() {
@@ -832,9 +1276,11 @@ async function submitUpdate() {
   updatedMchNo.value = "";
   try {
     await updateOnboardingMerchant(buildUpdateBody());
-    // ★ 这里**不读** `res.data?.data?.mch_no` —— 更新腿回的是 `Result<Void>`,`data` 是 `null`,
+    // ★ 这里**不读** `res.data?.data?.mch_no` —— 这条腿回的是 `Result<Void>`,`data` 是 `null`,
     //   读了只会拿到 `undefined` 并把成功显示成「拿不到商户号」。
     updatedMchNo.value = form.mch_no.trim();
+    formVisible.value = false;
+    await loadList();
   } catch {
     // 失败文案由拦截器统一弹(两道封盖的 400、非本租户的 404、上游失败的 502 都在 `msg` 里)
     // ⇒ 页面**不重复弹**,也不把它当成功往下走。这里只是不让 rejection 逃逸成未处理的 Promise 拒绝。
@@ -856,6 +1302,8 @@ async function submitCreate() {
     //   但**不**当失败往下走:那会诱导重提,而商户很可能已经建出来了 ⇒ 落成一条常驻提示,
     //   让人先去核对。这就是为什么它不走上面那个 `catch`(那条路的语义是「这次没成」)。
     submitNoMchNo.value = createdMchNo.value === "";
+    formVisible.value = false;
+    await loadList();
   } catch {
     // 失败文案由拦截器统一弹(必填缺失清单就在 `msg` 里)⇒ 页面**不重复弹**,
     // 也不把它当成功往下走。这里只是不让 rejection 逃逸成未处理的 Promise 拒绝。
@@ -867,56 +1315,36 @@ async function submitCreate() {
 function handleReset() {
   Object.assign(form, emptyForm());
   // 级联的**选项**是独立的 ref、不随表单模型复位,得单独清
-  cityOptions.value = [];
-  countyOptions.value = [];
-  mcc1.value = "";
-  mcc2.value = "";
-  mcc3.value = "";
-  mccOptions2.value = [];
-  mccOptions3.value = [];
-  clearLeaf();
+  resetCascadePicks();
   createdMchNo.value = "";
   submitNoMchNo.value = false;
   updatedMchNo.value = "";
   formRef.value?.clearValidate();
 }
 
-/**
- * 切模式:**不动**表单里已填的值 —— 41 个框,误点一下清空是不可逆的。
- * 代价是建号模式填的 `merchant_name_business` / `lease_contract` 会留在模型里,
- * 但那条路径由 `buildUpdateBody` 的**键白名单**堵死(不靠「用户没填过」这个假设)。
- *
- * ★ 只清**上一次提交的结果提示**:那是「上一次那个动作」的结论,
- *   留着会让人以为刚切换到的这个模式已经提交过了。
- */
-function switchMode() {
-  createdMchNo.value = "";
-  submitNoMchNo.value = false;
-  updatedMchNo.value = "";
-  formRef.value?.clearValidate();
-}
-
-/**
- * 从「刚建完号」那条常驻提示跳进更新模式,并带上商户号 —— 建号后当场发现填错,
- * 是更新腿最常见的入口,这里省掉一次「回头把商户号抄过来」。
- *
- * ★ 只预填 `mch_no`,**不**把建号表单里那一堆值带过去:更新是增量的,
- *   带过去等于「按建号时填的再覆盖一遍」,而不是「只改填错的那一项」。
- */
-function startUpdateFor(mchNo: string) {
-  mode.value = "update";
-  form.mch_no = mchNo;
-  switchMode();
-}
-
-onMounted(() => {
-  // 两组级联与表单无关,进页面就拉(失败由 `loadCascade` 落成页面上那条常驻提示)
-  loadProvinces();
-  loadMccLevel1();
-});
+onMounted(loadList);
 </script>
 
 <style scoped lang="scss">
+.list-head {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  gap: 16px;
+
+  &__title {
+    margin: 0;
+    font-size: 16px;
+    font-weight: 600;
+  }
+
+  &__sub {
+    margin: 4px 0 16px;
+    font-size: 13px;
+    color: var(--el-text-color-secondary);
+  }
+}
+
 .section-title {
   font-weight: 600;
 }