// P20 结构对拍:把 v2.9.2 原文页(本地经典页,jsdom 真跑)与 Vue SSR 输出逐页签比「结构骨架」 // = #root 下每个元素的 `tag.class…`(忽略 svg/canvas/script/style 与文本) // 用法:node tools/struct_diff.mjs [gateway] [tab…] 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://106.120.102.238:28084' const TABS = process.argv.slice(3).length ? process.argv.slice(3) : ['overview', 'energy', 'component', 'fault', 'vibration', 'generation', 'decision', 'assistant', 'report', 'system'] const CLASSIC = resolve(HERE, '../../../release/web/detail_v2.html') const norm = (s) => String(s || '').replace(/\s+/g, ' ').trim() 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 // ★P23:Vue 的 SVG 图外壳(旧页直接内联 SVG,无包裹)⇒ 本层视为透明,不参与骨架 if (tag === 'div' && (c.getAttribute('class') || '').includes('legacy-chart')) { walk(c); continue } // ★P27:`v-unwrap` 宿主在运行时被解包(SSR 不执行指令)⇒ 判据等价解包 if ((c.getAttribute('class') || '').includes('unwrap-host')) { walk(c); continue } const cls = (c.getAttribute('class') || '').split(/\s+/).filter(Boolean).sort().join('.') out.push(tag + (cls ? '.' + cls : '')) walk(c) } } walk(root) return out } const API_KEEP = ['/api/maint_survey', '/api/facts', '/api/ont_chain', '/api/ask_models', '/api/ask_status', '/api/vibcms', '/api/turbine', '/api/problem', '/api/channel'] function installFetch(window, state) { state.apis = state.apis || {} state.cache = state.cache || new Map() 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 (state.cache.has(url)) { // ★P36 同轮同 URL 复用 const c = state.cache.get(url) return { ok: true, status: 200, headers: new Map(), text: async () => c, json: async () => JSON.parse(c) } } const r = await fetch(url, init) const body = await r.text() if (r.ok) state.cache.set(url, body) let j = null try { j = JSON.parse(body) } catch { /* ignore */ } const path = url.slice(GATEWAY.length).split('?')[0] if (path === '/api/fleet' && !state.fleet) state.fleet = j if (path === '/api/vibcms' && !state.cms) state.cms = j if (API_KEEP.includes(path) && !state.apis[path]) state.apis[path] = j return { ok: r.ok, status: r.status, headers: r.headers, text: async () => body, json: async () => j } } } /** ★P36 预热:等网关把 fleet/curves 算定(避免基线页落到 pending/骨架)。 */ async function warmup(tries = 24, gapMs = 4000) { let fleet = null for (let k = 0; k < tries; k++) { try { const r = await fetch(GATEWAY + '/api/fleet') if (r.ok) { const j = await r.json() const settled = !j.pending && !j.m9_pending && !j.fleet_pending if (settled) { fleet = j; break } } } catch { /* 继续等 */ } await new Promise((x) => setTimeout(x, gapMs)) } return fleet } /** generation 前置:把 /api/curves 追到出图(与呈现对拍同口径)。 */ async function awaitCurves(tries = 20, gapMs = 3000) { for (let k = 0; k < tries; k++) { const r = await fetch(GATEWAY + '/api/curves') if (r.ok) { 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 出图超时') } async function baseline(tab) { const state = {} if (!state.warmed) { await warmup(); state.warmed = true } if (tab === 'generation') { state.curves = await awaitCurves(); console.log(' [i] curves figs ' + (state.curves.figs || []).length) } const html = readFileSync(CLASSIC, 'utf8') const dom = new JSDOM(html, { url: GATEWAY + `/detail/v2#tab=${tab}`, runScripts: 'dangerously', pretendToBeVisual: true, virtualConsole: new VirtualConsole(), beforeParse: (w) => installFetch(w, state), }) const doc = dom.window.document 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)) } // ★P36 等 DOM 稳定:连续 3 次子元素数一致才认为渲染定稿 let last = -1, same = 0 for (let i = 0; i < 60; i++) { const root = doc.getElementById('root') const n = root ? root.querySelectorAll('*').length : 0 if (n === last) { if (++same >= 3) break } else { same = 0 } last = n await new Promise((r) => setTimeout(r, 700)) } return { root: doc.getElementById('root'), data: state.fleet, cms: state.cms ?? null, curves: state.curves ?? null, apis: state.apis ?? {} } } async function vueSkel(tab, data, cms, curves, apis) { const entry = pathToFileURL(resolve(HERE, '../target/ssr/entry-server.js')).href const html = await (await import(entry)).renderTab(data, tab, null, cms, curves, apis) const dom = new JSDOM(`
${html}
`) const wrap = dom.window.document.getElementById('wrap') const nav = wrap.querySelector('.wb-tabs') if (nav) nav.remove() return skeletonIn(wrap) } let bad = 0 for (const tab of TABS) { const { root, data, cms, curves, apis } = await baseline(tab) if (!root) { console.log(`[X] ${tab}: 基线未渲染`); bad++; continue } const a = skeletonIn(root) const b = await vueSkel(tab, data, cms, curves, apis) if (a.length === b.length && a.every((x, i) => x === b[i])) { console.log(`[OK] ${tab} 结构一致(${a.length} 个元素)`) } else { bad++ const diffs = [] for (let i = 0; i < Math.max(a.length, b.length) && diffs.length < 4; i++) { if (a[i] !== b[i]) diffs.push(`#${i} 基准=${a[i] || '∅'} · Vue=${b[i] || '∅'}`) } console.log(`[X] ${tab} 元素数 基准 ${a.length} · Vue ${b.length}`) for (const d of diffs) console.log(` ${d}`) } } console.log(`\n结构对拍结论: ${TABS.length - bad}/${TABS.length} 页签结构一致`) process.exit(bad ? 9 : 0)