|
|
@@ -0,0 +1,114 @@
|
|
|
+// 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
|
|
|
+ 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 || {}
|
|
|
+ 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)
|
|
|
+ const r = await fetch(url, init)
|
|
|
+ const body = await r.text()
|
|
|
+ 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 }
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+/** 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 (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))
|
|
|
+ }
|
|
|
+ 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(`<div id="wrap">${html}</div>`)
|
|
|
+ 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)
|