/** * 呈现对拍(P11)· 多页签版:把**重构前的页面**与**Vue 迁移后的组件**放在同一份数据上比一遍。 * * 口径(实逮决定): * · `/detail/v2` 是浏览器里现渲的单页(HTML 只有壳 + 内联 app.js/charts.js + 语言包,无 window.__DATA__) * ⇒ 基线必须**让旧代码真跑一遍**:jsdom 加载现网页面,并在 **beforeParse** 装 fetch 桩转发到真实网关 * (桩晚装一句,旧页面就 `fetch is not defined`,基线全空)。 * · 页签由 hash 决定(`#tab=energy`)⇒ 基线按同一 hash 加载,两边渲染同页签。 * · Vue 侧用**同一份数据**(fetch 桩记录下的 /api/fleet 响应)经 SSR 渲成 HTML。 * · 比对: * ① 文本序列(`#root` 下所有文本节点,按 DOM 顺序、空白归一)—— 结构 + 文案一致; * ② 表格单元格文本(逐表、逐行); * ③ **图表数据**:旧页面内联 SVG 里的数字 vs Vue 侧 ECharts option 里 `__data__` 的数字 * (视觉实现不同是用户令「引入 ECharts」的结果,**数据必须同源**)。 * * 用法:node tools/render_parity.mjs [gateway] [tab1] [tab2] ... * 退出码:0 全一致 · 9 有不一致 · 2 基准页取不到。 */ import { readFileSync } from 'node:fs' import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, resolve } from 'node:path' import { JSDOM, VirtualConsole } from 'jsdom' const HERE = dirname(fileURLToPath(import.meta.url)) const GATEWAY = process.argv[2] || 'http://127.0.0.1:28084' /** ★P19:基线改为**本地 v2.9.2 原文页**(release/web/detail_v2.html,由 v2.9.2 自带 build.py 出页)。 */ /** ★P38 用户令新增的 4 个页签(有意为之,不算偏差)。 */ const INTENTIONAL_TABS = ['交付文档', '数据重算', '架构与资料', '门户'] const CLASSIC = resolve(HERE, '../../../release/web/detail_v2.html') const TABS = process.argv.slice(3).length ? process.argv.slice(3) : ['overview', 'energy', 'component', 'fault', 'vibration', 'generation', 'decision', 'assistant', 'report', 'system'] /** * 显式前置条件:把 `/api/curves` 追到**出图**那一份为止(首次常是 building)。 * 追不到就抛错 —— 宁可报错,也不要让"骨架屏 vs 出图"把对拍结果搞成不可复现。 */ async function awaitCurves(tries = 20, gapMs = 3000) { for (let k = 0; k < tries; k++) { const r = await fetch(GATEWAY + '/api/curves') if (!r.ok) throw new Error(`/api/curves ${r.status}`) const j = await r.json() if (!j.building && (j.figs || []).length) return j await new Promise((r2) => setTimeout(r2, gapMs)) } throw new Error('等 /api/curves 出图超时(仍是 building/无 figs)—— 先让服务端把曲线算完再对拍') } /** 取一份 /api/fleet —— 对拍两侧**共用这一份**(先取后喂,避免"页面缓存数据 vs 新取数据"的版本错位)。 */ async function fleetData(win = '') { const url = GATEWAY + '/api/fleet' + (win ? `?win=${encodeURIComponent(win)}` : '') const r = await fetch(url) if (!r.ok) throw new Error(`取数失败 ${r.status} ${url}`) return await r.json() } /** * 给 jsdom 装 fetch 桩: * · `/api/fleet` **一律返回 state.fleet**(加载页面前我们自己取好的那一份)—— 保证"旧页面"与"Vue" * 渲染的是同一份数据(实逮:页面有 pending 轮询,桩若记录"最后一次"响应,两边数据版本会不同, * 于是把"数据错位"误判成"文案不一致"); * · 其余请求照常转发到真实网关。 */ function installFetch(window, state) { window.fetch = async (input, init) => { const raw = typeof input === 'string' ? input : (input && input.url) || String(input) const url = raw.startsWith('http') ? raw : GATEWAY + (raw.startsWith('/') ? raw : '/' + raw) if (url.includes('/api/curves')) { // 数据源已在 baseline() 里前置取好(awaitCurves);这里只负责回放,保证两侧同源 if (!state.curves) { const r2 = await fetch(url, init) state.curves = JSON.parse(await r2.text()) state.curvesRaw = JSON.stringify(state.curves) } const body = state.curvesRaw return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => JSON.parse(body), text: async () => body } } if (url.includes('/api/vibcms')) { if (!state.cms) { // ★ 与 fleet 同理:只认第一次拿到的 const r1 = await fetch(url, init) state.cms = JSON.parse(await r1.text()) state.cmsRaw = JSON.stringify(state.cms) } const body = state.cmsRaw return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => JSON.parse(body), text: async () => body } } if (url.includes('/api/fleet')) { if (!state.fleet) { // ★ 只认**第一次**(页面 pending 轮询会再取,那是另一份) const r0 = await fetch(url, init) state.fleet = JSON.parse(await r0.text()) state.fleetRaw = JSON.stringify(state.fleet) } const body = state.fleetRaw return { ok: true, status: 200, headers: { get: () => 'application/json' }, json: async () => JSON.parse(body), text: async () => body } } const resp = await fetch(url, init) const body = await resp.text() // 通用接口快照:把页面请求过的 /api/* 首次响应按**路径**记下来(供 decision/assistant/report/system 复用) try { const u = new URL(url) if (u.pathname.includes('/api/')) { if (!state.apis) state.apis = {} if (!(u.pathname in state.apis)) state.apis[u.pathname] = JSON.parse(body) } } catch { /* 非 JSON 或非 URL:忽略 */ } return { ok: resp.ok, status: resp.status, headers: { get: (k) => resp.headers.get(k) }, json: async () => JSON.parse(body), text: async () => body, } } } const norm = (s) => String(s).replace(/\s+/g, ' ').trim() /** 文本节点序列(跳过画布/SVG —— 图形由"图表数据"那一项比)。 */ function textsIn(root) { const out = [] const walk = (n) => { for (const c of n.childNodes) { if (c.nodeType === 3) { const s = norm(c.textContent) if (s) out.push(s) } else if (c.nodeType === 1) { const tag = c.tagName.toLowerCase() if (tag === 'canvas' || tag === 'svg' || tag === 'script' || tag === 'style') continue walk(c) } } } walk(root) return out } /** ★P20 结构骨架:`#root` 下每个元素的 `tag.class1.class2` 序列(忽略 svg/canvas/script/style 与文本)。 */ function skeletonIn(root) { const out = [] const walk = (el) => { for (const c of Array.from(el.children)) { const tag = c.tagName.toLowerCase() if (tag === 'canvas' || tag === 'svg' || tag === 'script' || tag === 'style') continue const cls = (c.getAttribute('class') || '').split(/\s+/).filter(Boolean).sort().join('.') out.push(tag + (cls ? '.' + cls : '')) walk(c) } } walk(root) return out } /** 表格:每张表的行×单元格文本。 */ function tablesIn(root) { return Array.from(root.querySelectorAll('table')).map((tb) => Array.from(tb.querySelectorAll('tr')).map((tr) => Array.from(tr.querySelectorAll('th,td')).map((c) => norm(c.textContent)))) } /** 旧页面图表里的数字(内联 SVG 文本里的数值)。 */ function svgNumbers(root) { const out = [] for (const t of Array.from(root.querySelectorAll('svg text'))) { for (const m of norm(t.textContent).matchAll(/-?\d[\d,]*(?:\.\d+)?/g)) out.push(m[0].replace(/,/g, '')) } return out } /** Vue 侧:ECharts option 的 `__data__` 里的数字(数据级对拍)。 */ function useSvgNumbers(root) { return !root.querySelector('[data-chart]') } function chartNumbers(root) { const out = [] for (const el of Array.from(root.querySelectorAll('[data-chart]'))) { let opt = null try { opt = JSON.parse(el.getAttribute('data-chart') || 'null') } catch { /* ignore */ } const d = opt && opt.__data__ if (!d) continue const walk = (x) => { // 旧图把值直接写进 SVG 文本(txt(..., v))⇒ 这里也按**原值**取字符串,不取整 if (typeof x === 'number') { if (Number.isFinite(x)) out.push(String(x)); return } if (Array.isArray(x)) { x.forEach(walk); return } if (x && typeof x === 'object') { Object.values(x).forEach(walk) } } walk(d) } return out } async function baseline(tab, sub) { // ★ 不预先自取 fleet:记录**页面自己第一次**拿到的 /api/fleet,再用同一份喂 Vue(见 installFetch 注释) const state = {} // ★ generation:页面前置条件 —— 先把 /api/curves 追到出图,作为唯一数据源(见 awaitCurves) if (tab === 'generation') { state.curves = await awaitCurves() state.curvesRaw = JSON.stringify(state.curves) console.log(` [i] /api/curves 已出图:figs ${(state.curves.figs || []).length} 张`) } const html = readFileSync(CLASSIC, 'utf8') // ★P20:基线 = 本地 v2.9.2 原文页 const dom = new JSDOM(html, { url: GATEWAY + `/detail/v2#tab=${tab}`, runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: new VirtualConsole(), beforeParse: (window) => installFetch(window, state), }) const doc = dom.window.document // ★实逮:`#root` 初始就带一个 `.panel`(出错提示占位)——若拿 `.panel` 当"渲染好了"的判据, // 会在 app.js 取数之前就跳出循环,基线全空。判据改用 render() 一定产出的 `.page-head`, // 并且要求 fetch 桩已经记下 `/api/fleet` 的响应。 for (let i = 0; i < 120; i++) { const root = doc.getElementById('root') if (root && root.querySelector('.page-head')) break await new Promise((r) => setTimeout(r, 500)) } // 子视图:页签内部还有一层 pill(旧页面是 `data-fsub`);点一下再抽锚点 if (sub) { const pill = doc.querySelector(`#root [data-fsub="${sub}"]`) if (pill) { pill.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true })) // cms 子视图要等它异步取 /api/vibcms 再渲染 ⇒ 多等几轮 const waitMs = sub === 'cms' ? 4000 : 1200 await new Promise((r) => setTimeout(r, waitMs)) } else { console.log(` [i] 基准页没有子视图 pill data-fsub="${sub}"(按默认子视图比)`) } } return { doc, data: state.fleet, cms: state.cms ?? null, curves: state.curves ?? null, apis: state.apis ?? {}, root: doc.getElementById('root') } } async function vueAnchors(data, tab, sub, cms, curves, apis) { const entry = pathToFileURL(resolve(HERE, '../target/ssr/entry-server.js')).href const html = await (await import(entry)).renderTab(data, tab, sub, cms, curves, apis) const dom = new JSDOM(`