Selaa lähdekoodia

fix(乐收赢): 三个收款页的 keep-alive 组件名与菜单 route_name 对齐(此前静默失效)

`lsy-menu-rename.sql` 把库里菜单从 `Leshouying*` 改名成 `Collection*` 时,
只改了 `sys_menu`,没改页面里的 `defineOptions({ name })`。而:

  sys_menu.route_name → permission.store 造路由 name
                      → tags-view.store.addCachedView 推进 cachedViews
                      → AppMain 的 <keep-alive :include="cachedViews">

`include` 比的是**组件名**,推进去的却是**路由名** ⇒ 两者不等时那页的
`keep_alive=true` **静默失效**:vue 不报错、构建不报错,症状只是
「切走再切回来,筛选条件/分页全没了」。三个页面(order / refund / merchant)
一直是这个状态。

改页面而不是改库:页面名取自**活库** route_name(id 269/270/271,2026-09-23
只读实测),改库会让已发布的前端路由名漂移。这是 `lsy-cashier-menu.sql:32-40`
早就记下的修法,此前按「不相关的不动」留下未修,现按「只专注乐收赢、做成
傻瓜式交互」补上。

两向读数(`.claude/plan/2026-09-23-lsy-onboarding-B-harness/lsy-keepalive-harness.mjs`,
编真 SFC + 真 vue 挂真 <keep-alive>,数 setup() 跑几次):
- 修后五页各自自比 ⇒ 5/5 `SUMMARY PASS hit=1 miss=2`
- 变异:order 的 name 改回 `LeshouyingOrder` 而 include 仍喂 `CollectionOrder`
  ⇒ `SUMMARY FAIL hit=2 miss=2`(不缓存)⇒ 上面那些 PASS 是行为差别不是文字差别

顺带:merchant 页补上另外四页都有的那三行规则注释(它此前漏了)。

前端静态读数:`ts:check` 改前改后**逐字节相同**(329 行 / 4d85f23a…),
逐文件变异探针各命中 1 条(110,7 / 157,7 / 92,7);`pnpm build` rc=0,
dist 探针三个新名各出现 1 文件、三个旧名 0 命中。
alphaH 2 viikkoa sitten
vanhempi
sitoutus
bf0901673d

+ 68 - 0
.claude/plan/2026-09-23-lsy-onboarding-B-harness/README.md

@@ -180,3 +180,71 @@ node .claude/plan/2026-09-23-lsy-onboarding-B-harness/fe-update-mutate.mjs resto
 **核出 CRLF 那组数不是文件被改过,是行尾。**
 (上面两张表共 12 个读数,是用同一个换算脚本一次跑出来的:把读入的字节按 `\r\n` ↔ `\n` 互转后各自 sha256。
 该脚本对 `task-9-fix1-harness.mjs` 复现出的 CRLF 值 `81271e98…` 与早先独立量到的一致 ⇒ 换算方式没跑偏。)
+
+---
+
+## 5. `lsy-keepalive-harness.mjs` —— keep-alive 组件名匹配(2026-09-28 新增)
+
+**要证的一句话**:`<keep-alive :include>` 匹配的是**组件名**,而 `cachedViews` 里放的是
+**路由名**(= `sys_menu.route_name`)⇒ 两者不逐字相同时,那页的 `keep_alive=true`
+**静默失效**(vue 不报错,只是不缓存;症状是「切走再切回来,筛选条件/分页/正在等的二维码全没了」)。
+
+链路三跳(全部从源码量过,不是推的):
+`sys_menu.route_name` → `permission.store.ts` 造路由时 `name: item.route_name`
+→ `tags-view.store.ts` 的 `addCachedView` 把**路由名**推进 `cachedViews`
+→ `layouts/components/AppMain/index.vue:6` 的 `<keep-alive :include="cachedViews">` 拿它去比**组件名**。
+
+```bash
+node .claude/plan/2026-09-23-lsy-onboarding-B-harness/lsy-keepalive-harness.mjs \
+  "D:/project2/payment-platform/frontend/src/views/module_payment/collection/order/index.vue" order \
+  [可选的 include 覆盖值]
+```
+
+编**真** SFC(`@vue/compiler-sfc`)、用 `frontend/node_modules` 里那份**真** `vue` 挂进**真**
+`<keep-alive>`,数 `setup()` 跑了几次 —— 缓存命中后再激活**不会**重跑 `setup`。
+一次跑给**两向**读数:`include=[本页真名]` 期望 1;`include=[必然不匹配的串]` 期望 2(正对照,
+没有它,`setups=1` 也可能只是计数器没在工作)。
+
+### 读数(2026-09-28 实跑)
+
+五个页面各自自比,**5/5 PASS**(`hit=1 miss=2`):
+
+| 页面 | 组件名(修后) | 活库 `route_name` |
+|---|---|---|
+| `collection/order` | `CollectionOrder` | `CollectionOrder`(id=269) |
+| `collection/refund` | `CollectionRefund` | `CollectionRefund`(id=270) |
+| `collection/merchant` | `CollectionMerchant` | `CollectionMerchant`(id=271) |
+| `collection/cashier` | `CollectionCashier` | `CollectionCashier` |
+| `collection/onboarding` | `CollectionOnboarding` | `CollectionOnboarding`(id=272) |
+
+活库那一列取自 `lsy-cashier-menu.sql` 头部记的 **2026-09-23 只读实测**(id 269–272)。
+★ 前三个页面**在 2026-09-28 之前**写的是 `Leshouying*`(`lsy-menu-rename.sql` 把库从
+`Leshouying*` 改成 `Collection*` 时,SFC 的 `name` 没跟着改)—— 即**三个页面的 `keep_alive` 一直是失效的**。
+`lsy-cashier-menu.sql:32-40` 早记过这条,当时按「不相关的不动」留下未修。
+
+**变异(两向的另一半)**:把 order 页的 `name` 改回 `LeshouyingOrder`,`include` 仍喂
+`CollectionOrder` ⇒ **`SUMMARY FAIL hit=2 miss=2`**(不缓存)。这证明上面那些 `PASS` 是
+**行为差别**,不是文字差别。还原后 `git status -- frontend/` 只剩那三个待提交文件。
+
+### 已知局限(别把它的绿读大)
+
+- 模板**不渲染**(`inlineTemplate: false`)⇒ 它证明的是**缓存语义**,**不是**浏览器里长什么样。
+- 用 `createRenderer` 接一套哑节点操作,**不引 jsdom**。有的页面 `onMounted` 会碰 `window`
+  (如 cashier 页),台子里有一行 `globalThis.window = globalThis` 的垫片 ——
+  ★ 那是**台子**的垫片,不是页面的缺陷,别去改页面。
+- 桩是通用的(`@/api/*` 与 `element-plus` 都换成「取什么都是可调用、返回 Promise」的代理)
+  ⇒ 它验不到任何接口契约。**残留 import 会被一句兜底断言当场炸掉**,不会悄悄跑出假绿
+  (实测:`qrcode` 与 `SingleImageUpload` 两条就是这么被发现并补上的)。
+- 不设 `process.exitCode`(与同目录各台一致)⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。
+
+### 指纹
+
+| 文件 | LF 字节 | LF sha256 | CRLF 字节 | CRLF sha256 |
+|---|---|---|---|---|
+| `lsy-keepalive-harness.mjs` | 7718 | `2d5a9c9a677dafd1d57d84eae6e0afc8d11ce7526d0aae2e66dffa15327c7571` | 7888 | `cd97a29a36877aeb88656b2271d232e960850f7cc3c94329b256ed009a04d1fe` |
+| `collection/order/index.vue` | 10531 | `66b144354bb612081aeca65ef54b002c194f29ec2eab5d840fea7fa6ea7b9700` | 10789 | `1559fbb99ad173c7995f5491dfdd96def53b8b894dfc38fcb41ea30e73ef315a` |
+| `collection/refund/index.vue` | 4720 | `0a6c782061522baad7a97a794f51f78b214bcf960141aed6546e8a8291322b2b` | 4860 | `1c00b8b0f89f46adaf8d0a64999817415c16194bf2e5fe8218d3a72493df5ea7` |
+| `collection/merchant/index.vue` | 11178 | `2df3a00d5440b08487cd409d9923b72dd76fa5659fc75a436e4e900ebcb449b6` | 11431 | `bebaac71b14e2c331d32c96d7d55d83fbc255b8a9b4233307891bf1a3ab60942` |
+
+三个 `.vue` 的 `git ls-files --eol` 实测 `i/lf w/crlf`(工作区即 CRLF 形态);
+`.mjs` 是 2026-09-28 新写的,工作区当前是 LF(CR 数 0)—— 提交后随 `core.autocrlf=true` 会变 CRLF。

+ 170 - 0
.claude/plan/2026-09-23-lsy-onboarding-B-harness/lsy-keepalive-harness.mjs

@@ -0,0 +1,170 @@
+/**
+ * keep-alive 组件名匹配的执行级验证台。
+ *
+ * 【要证的一句话】`<keep-alive :include>` 匹配的是**组件名**,而 `cachedViews` 里放的是
+ * **路由名**(=`sys_menu.route_name`)⇒ 两者不逐字相同时,那页的 `keep_alive=true`
+ * **静默失效**(vue 不报错,只是不缓存)。乐收赢 `collection/` 下三个页面正是这个状态。
+ *
+ * 【为什么不能只看源码】「名字现在对上了」与「现在真的会缓存」是两句不同的话。
+ * 前者是文本读数,后者是运行时行为。本台测**后者**:编译**真**的 SFC(`@vue/compiler-sfc`)、
+ * 用 `frontend/node_modules` 里那份**真** `vue` 挂进**真** `<keep-alive>`,
+ * 数 `setup()` 被调用了几次 —— 缓存命中时再激活**不会**重跑 `setup`。
+ *
+ * 【两向读数一次跑完】`include` 给两个值:
+ *   ① 该页面真实的组件名      ⇒ 期望 setups=1(命中缓存)
+ *   ② 一个必然不匹配的串      ⇒ 期望 setups=2(未缓存)
+ * ② 是 ① 的正对照:没有它,setups=1 也可能只是「计数器根本没在工作」。
+ *
+ * 【不做 DOM】用 `createRenderer` 接一套哑节点操作,不引 jsdom。
+ * 【模板不渲染】(`inlineTemplate: false`)⇒ 本台证明的是**缓存语义**,不是页面长什么样。
+ * 【不设 process.exitCode】与同目录各台一致 ⇒ 失败也退 0,**只认最后那行 `SUMMARY`**。
+ *
+ * 用法:node lsy-keepalive-harness.mjs <index.vue 路径> [标签]
+ */
+import fs from "node:fs";
+import { createRequire } from "node:module";
+
+const FE = "D:/project2/payment-platform/frontend/";
+const require = createRequire(FE + "package.json");
+const vue = require("vue");
+const { parse, compileScript } = require("@vue/compiler-sfc");
+
+const SRC = process.argv[2];
+const LABEL = process.argv[3] || SRC;
+
+/** 把 SFC 编译成可 `new Function` 的普通脚本(配方抄自同目录 fe-update-harness.mjs)。 */
+function compileSfc(file) {
+  const src = fs.readFileSync(file, "utf8");
+  const { descriptor, errors } = parse(src, { filename: file });
+  if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; "));
+  let code = compileScript(descriptor, { id: "kaprobe", inlineTemplate: false }).content;
+
+  const braceImport = (spec) =>
+    new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g");
+  const asStub = (slot) => (_m, n) =>
+    `const {${n.replace(/\btype\s+[\w$.]+\s*,?/g, "").replace(/\bas\b/g, ":")}} = globalThis.__U.${slot};`;
+  code = code
+    // ★ 会有**两条** vue import(`defineOptions` 带出的 `defineComponent` 一条、文件自己一条)
+    //   ⇒ 正则必须带 `g`。
+    .replace(braceImport("vue"), asStub("vue"))
+    // 业务接口与 UI 库都只打桩(模板不渲染,它们不会有真行为)。
+    // ★ 下面那句兜底断言是**故意**留的:漏了哪个 import 会当场炸,而不是悄悄跑出假绿。
+    .replace(braceImport("@/api/module_payment/collection"), asStub("api"))
+    .replace(braceImport("@/api/module_payment/onboarding"), asStub("api"))
+    .replace(braceImport("element-plus"), asStub("ep"))
+    // 默认导入(`import QRCode from "qrcode"` / 子组件)—— 正则与上面那族**不同形**,
+    // ★ 漏掉它时上面那句兜底断言会当场炸(实测:cashier 页就是靠它发现的)。
+    .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, (_m, n) => `const ${n} = globalThis.__U.ep;`)
+    .replace(
+      /import\s+(\w+)\s+from\s*['"]@\/components\/Upload\/SingleImageUpload\.vue['"];?/,
+      (_m, n) => `const ${n} = globalThis.__U.ep;`
+    );
+
+  if (/^\s*import\s/m.test(code)) throw new Error("还有没换掉的 import:\n" + code.slice(0, 400));
+
+  const ts = require("typescript");
+  code = ts.transpileModule(code, {
+    compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
+    fileName: "index.ts",
+  }).outputText;
+  code = code.replace(/export\s+default/, "const __sfc =");
+  if (/^\s*export\s/m.test(code)) throw new Error("还有没换掉的 export");
+  code += "\nglobalThis.__U.sfc = __sfc;";
+  return code;
+}
+
+// ---------- 桩 ----------
+// 页面 `onMounted` 会打这几个接口;模板不渲染,所以只要它别抛就行。
+const okPage = () => ({ data: { code: 0, data: { list: [], total: 0 } } });
+const apiStub = new Proxy(
+  {},
+  { get: (_t, name) => (name === "then" ? undefined : async () => okPage()) }
+);
+
+// UI 库:任何名字拿到都是「可调用、可继续取属性、调用返回 Promise」的东西
+// (`ElMessageBox.confirm(...)` 要能 await)。
+const callable = new Proxy(async () => undefined, {
+  get: (_t, k) => (k === "then" ? undefined : callable),
+  apply: () => Promise.resolve(),
+});
+const epStub = new Proxy({}, { get: () => callable });
+
+// 无 DOM 台子的垫片:有的页面 `onMounted` 里会碰 `window`(如 `window.setInterval`)。
+// ★ 这是**台子**的垫片,不是页面的问题 —— 指出来免得后人把它当成缺陷去改页面。
+if (typeof globalThis.window === "undefined") {
+  globalThis.window = globalThis;
+}
+
+globalThis.__U = { vue, api: apiStub, ep: epStub, sfc: null };
+new Function(compileSfc(SRC))();
+
+const Real = globalThis.__U.sfc;
+const REAL_NAME = Real && Real.name;
+if (!REAL_NAME) throw new Error("编译出来的组件没有 name —— defineOptions 没被保留?");
+
+// ---------- 无 DOM 渲染器 ----------
+const node = () => ({ children: [] });
+const noop = () => {};
+const { createRenderer, h, KeepAlive, ref, nextTick } = vue;
+const renderer = createRenderer({
+  createElement: node,
+  createText: node,
+  createComment: node,
+  setText: noop,
+  setElementText: noop,
+  insert: noop,
+  remove: noop,
+  parentNode: () => null,
+  nextSibling: () => null,
+  patchProp: noop,
+});
+
+/** 数 setup 跑了几次 —— 缓存命中后再激活**不会**重跑 setup。 */
+async function countSetups(includeName) {
+  let setups = 0;
+  const Wrapped = {
+    ...Real,
+    render: () => null,
+    setup(props, ctx) {
+      setups++;
+      return Real.setup ? Real.setup(props, ctx) : undefined;
+    },
+  };
+  const Other = { name: "KaprobeOther", render: () => null };
+
+  // ★ 必须是 ref:普通变量不会被追踪,`__switch` 改完不会触发重渲染,
+  //   于是「没缓存」与「根本没切页」在读数上分不开。
+  const cur = ref("target");
+  const Host = {
+    setup() {
+      globalThis.__switch = (v) => {
+        cur.value = v;
+      };
+      return () => h(KeepAlive, { include: [includeName] }, () =>
+        cur.value === "target" ? h(Wrapped) : h(Other)
+      );
+    },
+  };
+
+  renderer.createApp(Host).mount(node());
+  await nextTick();
+  globalThis.__switch("other");
+  await nextTick();
+  globalThis.__switch("target");
+  await nextTick();
+  return setups;
+}
+
+// ★ 第 4 个参数可把「命中」那一轮喂成**别的**名字 —— 用来跑变异:
+//   把页面里的 name 改成旧值,而 include 仍喂活库那个名字,此时**必须**读出不缓存。
+//   不给就是自比(include 取本页真名)。
+const INCLUDE = process.argv[4] || REAL_NAME;
+
+const hit = await countSetups(INCLUDE);
+const miss = await countSetups("__kaprobe_never_matches__");
+const verdict = hit === 1 && miss === 2 ? "PASS" : "FAIL";
+
+console.log(`组件名=${REAL_NAME}  文件=${LABEL}  include=${INCLUDE}`);
+console.log(`  include=[${INCLUDE}]  setups=${hit}   期望 1(命中缓存)`);
+console.log(`  include=[必然不匹配的串]  setups=${miss}   期望 2(未缓存,正对照)`);
+console.log(`SUMMARY ${verdict} hit=${hit} miss=${miss} name=${REAL_NAME}`);

+ 6 - 1
frontend/src/views/module_payment/collection/merchant/index.vue

@@ -98,8 +98,13 @@
 </template>
 
 <script setup lang="ts">
+// ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
+//   而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
+//   不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
+//   ★ 本名取自**活库** sys_menu.route_name(id=271,2026-09-23 只读实测;读数记在
+//     `java/src/main/resources/db/lsy-cashier-menu.sql` 头部)。要改就改页面,别改库。
 defineOptions({
-  name: "LeshouyingMerchant",
+  name: "CollectionMerchant",
   inheritAttrs: false,
 });
 

+ 3 - 1
frontend/src/views/module_payment/collection/order/index.vue

@@ -148,8 +148,10 @@
 // ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
 //   而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
 //   不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
+//   ★ 本名取自**活库** sys_menu.route_name(id=269,2026-09-23 只读实测;读数记在
+//     `java/src/main/resources/db/lsy-cashier-menu.sql` 头部)。要改就改页面,别改库。
 defineOptions({
-  name: "LeshouyingOrder",
+  name: "CollectionOrder",
   inheritAttrs: false,
 });
 

+ 3 - 1
frontend/src/views/module_payment/collection/refund/index.vue

@@ -83,8 +83,10 @@
 // ★ 组件名必须与菜单 route_name 逐字相同:keep-alive 的 `include` 匹配的是**组件名**,
 //   而 tags-view 推进 `cachedViews` 的是**路由名**(`tags-view.store.ts:31-42`)。
 //   不写这一行,`<script setup>` 会按文件名推断成 "index" ⇒ keep_alive 永不生效(且不报错)。
+//   ★ 本名取自**活库** sys_menu.route_name(id=270,2026-09-23 只读实测;读数记在
+//     `java/src/main/resources/db/lsy-cashier-menu.sql` 头部)。要改就改页面,别改库。
 defineOptions({
-  name: "LeshouyingRefund",
+  name: "CollectionRefund",
   inheritAttrs: false,
 });