/** * 「trade_status=16 超时关闭」前端转译的执行级验证台(2026-10-10)。 * * 【要证的三句话】 * A. 订单列表页:挂**真 SFC**(真模板),喂 trade_status="16" 的订单,状态列 * 渲染出「超时关闭」;且整个页面文本里**不再出现裸 "16"**。 * B. 订单详情弹层:驱动 detail / detailVisible 后,详情里的状态 tag 同样转译 * (列表与详情是模板里两处独立的映射点,测一处不能代表另一处)。 * C. 收银台 result computed:16 → 超时关闭(不再落进「表外状态原样显示」的兜底)。 * * 【两向读数】表里既有「必须转译」的 16,也有正控:已知状态(1 / 8)行为不变; * 表外的 "99" 仍**原样显示** —— 那是「表外不猜」的既定哲学,谁顺手把它删掉,A4/C4 该红。 * * 【配方来源】../2026-09-29-lsy-amount-floor-harness/amount-floor-harness.mjs: * 同一套「编译真 SFC + 打桩 import + typescript 转译 + new Function」。本台多两件: * ① 订单页要**渲染真模板**:compileScript(inlineTemplate:false) + 单独 compileTemplate * 出 render(**必须带 bindingMetadata**,script setup 的 __isScriptSetup 旗子会让 * runtime 拒绝走 _ctx 代理),二者拼回一个组件再挂; * ② el-table / el-tag / el-dialog / el-option 用「会转发插槽或记录读数」的桩 * (el-table 按 data 逐行把 row 塞进列插槽;el-tag 记 {type,text};el-option 记 * {label,value}),其余 el-* 直通默认插槽或渲染空。 * 模板里的 HAR_TRADE_STATUS_TEXT 等映射表来自**真 har.ts**(转译求值后取出), * 只有 HarAPI 本身换成桩(免得真发请求)。 * * 【A5b 为什么直接钉表值】两处 el-tag 都写成 `TAG[st] || 'info'` —— 兜底把 * 「TAG 表里 16 缺格」和「给了 info」渲染成同一个样子,变异体 M2(删掉这格) * 在渲染层分辨不出(2026-10-10 实测:只加渲染断言时 M2 全绿)。所以表值单独钉。 * * 【不设 process.exitCode】失败也退 0,只认最后那行 SUMMARY(同目录各台惯例)。 * * 用法:node har-status16-harness.mjs */ 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, compileTemplate } = require("@vue/compiler-sfc"); const ts = require("typescript"); const ORDER = FE + "src/views/module_payment/har/order/index.vue"; const CASHIER = FE + "src/views/module_payment/har/cashier/index.vue"; const HAR_TS = FE + "src/api/module_payment/har.ts"; // 无 DOM 台子的垫片:页面里用的是 `window.setTimeout` / `window.clearTimeout`。 if (typeof globalThis.window === "undefined") { globalThis.window = globalThis; } // ---------- 通用:翻 SFC 里的 import 成桩 ---------- const braceImport = (spec) => new RegExp(`import\\s*\\{([^}]*)\\}\\s*from\\s*['"]${spec}['"];?`, "g"); const braceAsConst = (slot) => (m, names) => { const clean = names .replace(/\btype\s+[\w$.]+\s*,?/g, "") .replace(/\bas\b/g, ":"); return `const {${clean}} = globalThis.__U.${slot};`; }; const transpile = (code, fileName) => ts.transpileModule(code, { compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext }, fileName, }).outputText; // ---------- 真 har.ts:取映射表(HarAPI 换成桩) ---------- const HAR_NAMES = [ "HAR_TRADE_STATUS_TEXT", "HAR_TRADE_STATUS_TAG", "HAR_TRADE_TYPE_OPTIONS", "HAR_ORDER_STATUS_PAID", "HAR_NOTIFY_STATUS_TEXT", "HAR_BIZ_UN_STAGE", ]; function evalRealHarModule() { let code = fs.readFileSync(HAR_TS, "utf8"); code = code.replace( /import\s+(\w+)\s+from\s*['"]@\/utils\/request['"];?/, "const $1 = globalThis.__U.request;" ); code = code.replace(/export\s+default\s+/g, "const __har_default = "); code = code.replace(/^\s*export\s+/gm, ""); if (/^\s*import\s/m.test(code) || /^\s*export\s/m.test(code)) { throw new Error("har.ts 还有没换掉的 import/export —— 配方要更新"); } code = transpile(code, "har.ts"); code += "\nglobalThis.__U.realHar = { __default: __har_default, " + HAR_NAMES.map((n) => `${n}: (typeof ${n} !== "undefined" ? ${n} : undefined)`).join(", ") + " };"; new Function(code)(); for (const n of HAR_NAMES) { if (globalThis.__U.realHar[n] === undefined) throw new Error("har.ts 里没有导出 " + n); } } // ---------- 桩:请求 / UI 库 / store ---------- const apiCalls = []; const apiStub = new Proxy( {}, { get: (_t, name) => name === "then" ? undefined : async (...args) => { apiCalls.push({ name: String(name), args }); return { data: { code: 0, data: { items: [], total: 0 } } }; }, } ); const callable = new Proxy(async () => undefined, { get: (_t, k) => (k === "then" ? undefined : callable), apply: () => Promise.resolve(), }); const epStub = new Proxy({}, { get: () => callable }); const noop = () => {}; const enterpriseStoreStub = () => ({ getEnterpriseList: [], loadEnterpriseList: async () => {}, }); // ---------- 无 DOM 渲染器:垫出足够的节点操作,让真模板能挂/能更新 ---------- const noopNode = () => {}; const renderer = vue.createRenderer({ createElement: (tag) => ({ kind: "el", tag, children: [], parent: null }), createText: (text) => ({ kind: "text", text, parent: null }), createComment: (text) => ({ kind: "comment", text, parent: null }), setText: (n, text) => { n.text = text; }, setElementText: (el, text) => { el.children = [{ kind: "text", text, parent: el }]; }, insert: (child, parent, anchor) => { child.parent = parent; if (anchor) { const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, child); } else { parent.children.push(child); } }, remove: (child) => { const p = child.parent; if (p) { const i = p.children.indexOf(child); if (i >= 0) p.children.splice(i, 1); } child.parent = null; }, parentNode: (n) => n.parent ?? null, nextSibling: (n) => { const p = n.parent; if (!p) return null; const i = p.children.indexOf(n); return i >= 0 ? p.children[i + 1] ?? null : null; }, patchProp: noopNode, // 属性/class/style 与本台断言无关(只读文本) insertStaticContent: (content, parent, anchor, ns, start, end) => { if (start && end) { // 缓存节点搬到新位置(同 DOM 语义) const move = (n) => { if (n.parent) renderer.remove ? null : null; if (n.parent) { const i = n.parent.children.indexOf(n); if (i >= 0) n.parent.children.splice(i, 1); } n.parent = parent; if (anchor) { const i = parent.children.indexOf(anchor); parent.children.splice(i < 0 ? parent.children.length : i, 0, n); } else { parent.children.push(n); } }; move(start); if (end !== start) move(end); return [start, end]; } const n = { kind: "static", html: String(content), children: [], parent: null }; n.parent = parent; parent.children.push(n); return [n, n]; }, }); // ---------- 桩组件:转发插槽的 el-table / el-tag / el-dialog / el-option ---------- const tagPairs = []; // el-tag 每次渲染记一对 {type, text} —— TAG 表就是靠它钉 const optionPairs = []; // el-option 每次渲染记一对 {label, value} —— 筛选下拉吃 TEXT 表 function collectText(nodes, out = []) { for (const n of Array.isArray(nodes) ? nodes : [nodes]) { if (n == null || n === false) continue; if (typeof n === "string" || typeof n === "number") out.push(String(n)); else if (Array.isArray(n)) collectText(n, out); else if (typeof n === "object" && n.children != null) collectText(n.children, out); } return out; } const ElTableStub = { name: "el-table", props: { data: { type: Array, default: () => [] } }, setup(props, { slots }) { return () => { const cols = slots.default ? slots.default().flat(Infinity) : []; const out = []; for (const row of props.data || []) { for (const col of cols) { const colSlots = col && col.children; if (colSlots && typeof colSlots === "object" && typeof colSlots.default === "function") { out.push(vue.h("div", { class: "cell" }, colSlots.default({ row }))); } } } return vue.h("div", out); }; }, }; const ElTagStub = { name: "el-tag", props: { type: { type: String, default: "" } }, setup(props, { slots }) { return () => { const kids = slots.default ? slots.default() : []; tagPairs.push({ type: props.type, text: collectText(kids).join("") }); return vue.h("span", kids); }; }, }; const ElOptionStub = { name: "el-option", props: { label: { type: String, default: "" }, value: { type: [String, Number], default: "" } }, setup(props) { return () => { optionPairs.push({ label: props.label, value: String(props.value) }); return null; // 选项的文字在真实组件里来自 label prop,槽里没有内容可渲染 }; }, }; const ElDialogStub = { name: "el-dialog", props: { modelValue: { type: Boolean, default: false } }, setup(props, { slots }) { return () => props.modelValue ? vue.h("div", [slots.default ? slots.default() : null, slots.footer ? slots.footer() : null]) : null; }, }; function registerStubs(app) { const passthrough = (name) => ({ name, setup: (_p, { slots }) => () => (slots.default ? slots.default() : null), }); for (const n of [ "el-alert", "el-button", "el-card", "el-date-picker", "el-descriptions", "el-descriptions-item", "el-empty", "el-form", "el-form-item", "el-input", "el-input-number", "el-pagination", "el-select", "el-table-column", ]) { app.component(n, passthrough(n)); } app.component("el-table", ElTableStub); app.component("el-tag", ElTagStub); app.component("el-option", ElOptionStub); app.component("el-dialog", ElDialogStub); app.directive("hasPerm", {}); app.directive("loading", {}); } // ---------- 编译 + 挂载:订单页(真模板) ---------- function compileOrder() { const src = fs.readFileSync(ORDER, "utf8"); const { descriptor, errors } = parse(src, { filename: ORDER }); if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; ")); const script = compileScript(descriptor, { id: "s16order", inlineTemplate: false }); let code = script.content; code = code .replace(braceImport("vue"), braceAsConst("vue")) .replace(braceImport("element-plus"), braceAsConst("ep")) .replace(/import\s+dayjs\s+from\s*['"]dayjs['"];?/, "const dayjs = globalThis.__U.dayjs;") .replace( /import\s+(\w+)\s*,\s*\{([^}]*)\}\s*from\s*['"]@\/api\/module_payment\/har['"];?/g, (_m, def, names) => { const clean = names .split(",") .map((s) => s.trim()) .filter((s) => s && !/^type\s/.test(s)); return ( `const ${def} = globalThis.__U.api;` + (clean.length ? ` const { ${clean.join(", ")} } = globalThis.__U.realHar;` : "") ); } ) .replace( /import\s*\{\s*useEnterpriseStore\s*\}\s*from\s*['"]@\/store\/modules\/enterprise\.store['"];?/, "const useEnterpriseStore = globalThis.__U.enterpriseStore;" ) .replace( /import\s*\{\s*useLiveRefetch\s*\}\s*from\s*['"]@\/composables['"];?/, "const useLiveRefetch = globalThis.__U.noop;" ); if (/^\s*import\s/m.test(code)) throw new Error("order 还有没换掉的 import:\n" + code.slice(0, 500)); code = transpile(code, "order.ts"); code = code.replace(/export\s+default/, "const __sfc ="); if (/^\s*export\s/m.test(code)) throw new Error("order 还有没换掉的 export"); code += "\nglobalThis.__U.orderSfc = __sfc;"; new Function(code)(); const tpl = compileTemplate({ source: descriptor.template.content, filename: ORDER, id: "s16order", // bindingMetadata 必须带:script setup 的 setup 结果带 __isScriptSetup 旗子, // runtime 的 hasSetupBinding 见到它就拒绝走 _ctx 代理(查不到 = undefined + 警告)。 // 带上它,模板才编成 $setup.xx 直取。2026-10-10 实测踩过一次。 compilerOptions: { bindingMetadata: script.bindings }, }); if (tpl.errors && tpl.errors.length) throw new Error("模板编译失败: " + tpl.errors.map(String).join("; ")); let tcode = tpl.code.replace(braceImport("vue"), braceAsConst("vue")); tcode = tcode.replace(/export\s+function\s+render/, "function render"); if (/^\s*(import|export)\s/m.test(tcode)) { throw new Error("模板码还有没换掉的 import/export:\n" + tcode.slice(0, 400)); } tcode = transpile(tcode, "order.render.ts"); tcode += "\nglobalThis.__U.orderRender = render;"; new Function(tcode)(); } function mountOrder() { const Real = globalThis.__U.orderSfc; let bindings = null; const Wrapped = { render: globalThis.__U.orderRender, setup(props, ctx) { bindings = Real.setup(props, ctx); return bindings; }, }; const root = { kind: "el", tag: "__root__", children: [], parent: null }; const app = renderer.createApp({ render: () => vue.h(Wrapped) }); registerStubs(app); app.mount(root); if (!bindings) throw new Error("setup 没有返回绑定"); return { bindings, root }; } // ---------- 编译 + 挂载:收银台(只驱动绑定,不渲染模板,同金额闸台) ---------- function compileCashier() { const src = fs.readFileSync(CASHIER, "utf8"); const { descriptor, errors } = parse(src, { filename: CASHIER }); if (errors && errors.length) throw new Error("SFC 解析失败: " + errors.map(String).join("; ")); let code = compileScript(descriptor, { id: "s16cashier", inlineTemplate: false }).content; code = code .replace(braceImport("vue"), braceAsConst("vue")) .replace(braceImport("element-plus"), braceAsConst("ep")) .replace(/import\s+(\w+)\s+from\s*['"]qrcode['"];?/, "const $1 = globalThis.__U.ep;") .replace( /import\s+(\w+)\s*,\s*\{([^}]*)\}\s*from\s*['"]@\/api\/module_payment\/har['"];?/g, (_m, def) => `const ${def} = globalThis.__U.api;` ); if (/^\s*import\s/m.test(code)) throw new Error("cashier 还有没换掉的 import:\n" + code.slice(0, 500)); code = transpile(code, "cashier.ts"); code = code.replace(/export\s+default/, "const __sfc ="); if (/^\s*export\s/m.test(code)) throw new Error("cashier 还有没换掉的 export"); code += "\nglobalThis.__U.cashierSfc = __sfc;"; new Function(code)(); } function mountCashier() { const Real = globalThis.__U.cashierSfc; let bindings = null; const Wrapped = { ...Real, render: () => null, // 模板不渲染(同金额闸台):本段只驱动 result / apply setup(props, ctx) { bindings = Real.setup(props, ctx); return bindings; }, }; renderer.createApp({ render: () => vue.h(Wrapped) }).mount({ kind: "el", tag: "__root__", children: [], parent: null }); if (!bindings) throw new Error("setup 没有返回绑定"); return bindings; } // ---------- 读数工具 ---------- function flattenText(node, out = []) { if (!node) return out; if (node.kind === "text") out.push(String(node.text)); else if (node.kind === "static") out.push(String(node.html).replace(/<[^>]*>/g, "")); for (const c of node.children || []) flattenText(c, out); return out; } const flush = async (n = 4) => { for (let i = 0; i < n; i++) { await new Promise((r) => setTimeout(r, 0)); await vue.nextTick(); } }; // ---------- 夹具(所有非状态字段都不含子串 "16",负向断言才干净) ---------- const MK = (no, status, extra = {}) => ({ id: "R-" + no, order_no: "APTEST-A" + no, out_biz_no: "BIZ-A" + no, price: "1.00", title: "样例单" + no, trade_status: status, trade_type: "alipay_un_stage", merchant_code: "M0001", created_time: "2026-10-10 09:00:00", notify_status: "FAILED", ...extra, }); const ROW16 = MK("1", "16"); const ROW8 = MK("2", "8"); const ROW1 = MK("3", "1"); const ROW99 = MK("4", "99"); const ROWS = [ROW16, ROW8, ROW1, ROW99]; // ---------- 跑 ---------- globalThis.__U = { vue, api: apiStub, ep: epStub, request: noop, dayjs: require("dayjs"), enterpriseStore: enterpriseStoreStub, noop, realHar: null, orderSfc: null, orderRender: null, cashierSfc: null, }; // orderList 要喂夹具行;其余接口空列表(merchantList / enterprise 等) const rawApi = apiStub; globalThis.__U.api = new Proxy( {}, { get: (_t, name) => name === "then" ? undefined : async (...args) => { apiCalls.push({ name: String(name), args }); if (name === "orderList") { return { data: { code: 0, data: { items: ROWS, total: ROWS.length } } }; } if (name === "orderDetail") { return { data: { code: 0, data: ROW16 } }; } return { data: { code: 0, data: { items: [], total: 0 } } }; }, } ); evalRealHarModule(); compileOrder(); compileCashier(); const results = []; const check = (tag, ok, detail) => { results.push({ tag, ok }); console.log(` ${tag.padEnd(4)} ${ok ? "PASS" : "FAIL"} ${detail}`); }; console.log(`har.ts = ${HAR_TS}`); console.log(`order = ${ORDER}`); console.log(`cashier = ${CASHIER}`); console.log(""); console.log("== A/B 订单列表页与详情弹层(真 SFC + 真模板) =="); const { bindings: bOrder, root: rootOrder } = mountOrder(); await flush(); const textsList = flattenText(rootOrder); const allList = textsList.join(" | "); check("A1", allList.includes("超时关闭"), "列表 16 行转译为「超时关闭」"); check("A2", !textsList.some((t) => t.includes("16")), "全页文本不含裸 \"16\""); check("A3", allList.includes("交易关闭"), "正控:8 行「交易关闭」仍是老文案"); check("A4", allList.includes("99"), "正控:表外值 99 仍原样显示(兜底没被删)"); check( "A5", tagPairs.some((p) => p.text === "超时关闭" && p.type === "info"), "tag 配对 {type:info, text:超时关闭} 存在" ); check( "A6", tagPairs.some((p) => p.text === "支付成功" && p.type === "success"), "正控:1 行仍是 {success, 支付成功}" ); check( "A5b", globalThis.__U.realHar.HAR_TRADE_STATUS_TAG["16"] === "info", "TAG 表里 16 显式给了 info(模板 `|| 'info'` 兜底让渲染分辨不出,直接钉表值)" ); check( "A7", optionPairs.some((o) => o.value === "16" && o.label === "超时关闭"), "筛选下拉(v-for 吃 TEXT 表)出现 {16 → 超时关闭}" ); check( "A8", optionPairs.some((o) => o.value === "8" && o.label === "交易关闭"), "正控:下拉里 8 仍是「交易关闭」" ); const tagCountBefore = tagPairs.length; bOrder.detail.value = ROW16; bOrder.detailVisible.value = true; await flush(); const added = tagPairs.slice(tagCountBefore); const textsAfterDetail = flattenText(rootOrder); check( "B1", added.some((p) => p.text === "超时关闭" && p.type === "info"), "详情弹层里的状态 tag 同款转译" ); check("B2", !textsAfterDetail.some((t) => t.includes("16")), "打开详情后全页文本仍不含裸 \"16\""); console.log(""); console.log("== C 收银台 result / apply =="); const bCashier = mountCashier(); await flush(); const r16 = (() => { bCashier.order.value = { trade_status: "16", price: "1.00" }; return bCashier.result.value; })(); check( "C1", r16.title === "超时关闭" && r16.tone === "muted" && String(r16.detail || "").includes("没有收到钱"), `16 → ${JSON.stringify(r16.title)} / ${JSON.stringify(r16.detail)}` ); bCashier.order.value = { trade_status: "1", rcv_amount: "1.00", price: "1.00" }; const r1 = bCashier.result.value; check("C2", r1.title === "收款成功" && r1.money === "1.00", `正控:1 → ${JSON.stringify(r1.title)} / money=${r1.money}`); bCashier.order.value = { trade_status: "8" }; const r8 = bCashier.result.value; check("C3", r8.title === "交易关闭", `正控:8 → ${JSON.stringify(r8.title)}`); bCashier.order.value = { trade_status: "99" }; const r99 = bCashier.result.value; check("C4", r99.title === "99", `正控:表外值 99 仍原样(兜底没被删)`); bCashier.phase.value = "waiting"; bCashier.apply = bCashier.apply; // 绑定即函数本身 bCashier.apply({ trade_status: "16", price: "1.00" }); check("C5", bCashier.phase.value === "done", "apply(16) 停轮询进 done(现状行为回归)"); console.log(""); const bad = results.filter((r) => !r.ok).length; console.log(`SUMMARY ${bad === 0 ? "PASS" : "FAIL"} 断言=${results.length} 不符=${bad}`); // 兜底退出:即使有残留定时器也不吊在事件循环上;失败也退 0,只认 SUMMARY。 process.exit(0);