#!/usr/bin/env node /** * scripts/verify-viz.mjs —— v11b 三、P1:四类微图表(sparkline / 进度环·进度条 / 对比条 / 热力小格)实测。 * * 每类都要在**真实页面位置**出现(不是造组件放着),并且: * - 图不替代数字:每个图表元素旁边(父级两三层内)有可见数字文本; * - 图表本身 aria-hidden(读屏只读数字); * - 不引第三方图表库(静态 grep / chart.js / echarts / d3); * - 无渐变填充(图表元素的 background-image 里没有 gradient)。 * 每页打印:四类图表各几个、在哪个视图、尺寸;最后截图到 docs/screenshots/v11b/viz-*.png。 * * 用法:node scripts/verify-viz.mjs [--url …] [--shots docs/screenshots/v11b] */ import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; import { startServer, launch, evalIn, openView, table, ROOT, DEEP } from './v11b-lib.mjs'; const argv = process.argv.slice(2); const arg = (k, d) => (argv.includes(k) ? argv[argv.indexOf(k) + 1] : d); const SHOTS = arg('--shots', join(ROOT, 'docs/screenshots/v11b')); mkdirSync(SHOTS, { recursive: true }); const srv = arg('--url', '') ? null : await startServer(4198); const base = arg('--url', '') || srv.base; const browser = await launch(); const page = await browser.newPage({ viewport: { width: 1440, height: 960 }, deviceScaleFactor: 1 }); const cdp = await page.context().newCDPSession(page); await cdp.send('Network.setCacheDisabled', { cacheDisabled: true }); await page.emulateMedia({ reducedMotion: 'reduce' }); const ROUTES = ['/', '/pool', '/stages', '/reports', '/reports/voc', '/members', '/ledger']; /* 页面里执行的判定(字符串,塞进 page.evaluate):点名的四个自定义元素; 报告卡里的迷你环是 report-card 自己画的 ,单独算一类「环」 */ const KIND = { spark: "el.localName === 'kf-spark'", ring: "el.localName === 'kf-ring' || (el.localName === 'svg' && el.classList.contains('ring'))", bars: "el.localName === 'kf-bars'", heat: "el.localName === 'kf-heat'", }; const KIND_CN = { spark: 'sparkline', ring: '进度环', bars: '对比条', heat: '热力小格' }; const found = Object.fromEntries(Object.keys(KIND).map((k) => [k, []])); const px = (n) => `${Math.round(n * 10) / 10}px`; for (const r of ROUTES) { await openView(page, base, r, { me: '赵磊', wait: 700 }); const got = await page.evaluate(`(async () => { ${DEEP}\n` + ` const out = []; deepAll((el) => { const KINDS = ${JSON.stringify(KIND)}; for (const [k, src] of Object.entries(KINDS)) { if (!new Function('el', 'return (' + src + ')')(el) || !visible(el)) continue; const r = el.getBoundingClientRect(); if (r.width < 6 || r.height < 4) continue; /* 父级是同一类图表时只记最外层,避免重复计数 */ const p = el.parentElement; if (p && (p.localName === el.localName) && /^(kf-|.*heat$|.*ring$|.*bars$|.*spark$)/.test(p.localName)) continue; /* 读屏:图表 aria-hidden;数字在父级三层内(含 shadow 宿主) */ /* 宿主自己不挂 aria-hidden 时,看它内部真正的图(shadow root 里的第一个元素)是否 aria-hidden */ const inner = el.shadowRoot?.firstElementChild; const selfHidden = el.getAttribute('aria-hidden') === 'true' || !!el.querySelector('[aria-hidden="true"]') || inner?.getAttribute('aria-hidden') === 'true'; let host = el, num = ''; for (let i = 0; i < 4 && host; i++) { const t = [...host.childNodes].filter((n) => n.nodeType === 3).map((n) => n.data).join('') + (host === el ? '' : host.textContent); const m = t.match(/\\d+(?:\\.\\d+)?/); if (m) { num = m[0]; break; } host = host.parentElement || host.parentNode?.host || host.getRootNode()?.host || null; } const cs = getComputedStyle(el.localName === 'svg' ? el : el); const bg = cs.backgroundImage || ''; out.push({ kind: k, label: el.localName + (typeof el.className === 'string' && el.className ? '.' + el.className.trim().split(/ +/).join('.') : ''), w: r.width, h: r.height, hidden: selfHidden, num, grad: /gradient/.test(bg) }); } }); return out; })()`); for (const x of got) found[x.kind].push({ ...x, route: r }); await page.screenshot({ path: join(SHOTS, `viz-${r === '/' ? 'workbench' : r.slice(1).replace(/\//g, '-')}.png`), fullPage: true, animations: 'disabled' }); } console.log('## 每类图表的出现位置(路由 / 元素 / 尺寸 / 旁边数字 / aria-hidden / 渐变)\n'); const rows = []; for (const k of Object.keys(found)) { for (const x of found[k]) rows.push({ type: KIND_CN[k], route: x.route, el: x.label.slice(0, 22), size: `${px(x.w)}×${px(x.h)}`, num: x.num || '—', aria: x.hidden ? '✓' : '✗', grad: x.grad ? '有' : '无', }); } table(rows, [['类型', 'type'], ['路由', 'route'], ['元素', 'el'], ['尺寸', 'size'], ['旁数字', 'num'], ['aria-hidden', 'aria'], ['渐变', 'grad']]); const counts = Object.fromEntries(Object.keys(found).map((k) => [k, found[k].length])); console.log(`\n各类型计数:${Object.entries(counts).map(([k, n]) => `${KIND_CN[k]} ${n}`).join(' · ')}`); const fails = []; for (const [k, n] of Object.entries(counts)) if (n < 1) fails.push(`${KIND_CN[k]} 在 7 个路由里一处都没有`); const noNum = rows.filter((x) => x.num === '—'); if (noNum.length) fails.push(`${noNum.length} 个图表旁边没找到数字(图不能替代数字)`); const notHidden = rows.filter((x) => x.aria !== '✓'); if (notHidden.length) fails.push(`${notHidden.length} 个图表没有 aria-hidden`); const grad = rows.filter((x) => x.grad === '有'); if (grad.length) fails.push(`${grad.length} 个图表用了渐变填充`); /* 规格抽查:环直径 40–48 / 描边 3;条高 4 / 圆角 2;热力格 12–14 / 圆角 2;spark 宽 64–80 高 20(或小号 56×16) */ const perPage = []; for (const r of ['/members', '/reports/voc', '/ledger', '/']) { await openView(page, base, r, { me: '赵磊', wait: 600 }); const sp = await evalIn(page, ` const out = { ring: [], bars: [], heat: [], spark: [], steps: 0 }; deepAll((el) => { if (!visible(el)) return; if (el.localName === 'kf-ring' && el.getBoundingClientRect().width) { const c = el.shadowRoot?.querySelector('circle.fill'); out.ring.push([el.getBoundingClientRect().width, c ? getComputedStyle(c).strokeWidth : '?']); } if (el.localName === 'kf-bars') { const t = el.shadowRoot.querySelector('.t'); const f = el.shadowRoot.querySelector('.f'); out.bars.push([t.getBoundingClientRect().height, getComputedStyle(t).borderRadius, f ? getComputedStyle(f).borderRadius : '?']); } if (el.localName === 'kf-heat') { const i = el.shadowRoot.querySelector('.heat i[data-step]'); if (i) out.heat.push([i.getBoundingClientRect().width, getComputedStyle(i).borderRadius]); } if (el.localName === 'kf-spark') { const b = el.getBoundingClientRect(); out.spark.push([Math.round(b.width), Math.round(b.height)]); } }); return out; `); perPage.push([r, sp]); } console.log('\n## 规格抽查(按页)'); for (const [r, sp] of perPage) { const u = (arr) => [...new Set(arr)].join(' / ') || '—'; console.log(` ${r.padEnd(14)} 环 ${u(sp.ring.map((x) => `${x[0]}px/描边${x[1]}`))} | 对比条 ${u(sp.bars.map((x) => `高${x[0]}px/圆角${x[1]}`))} | 热力格 ${u(sp.heat.map((x) => `${x[0]}px/圆角${x[1]}`))} | spark ${u(sp.spark.map((x) => `${x[0]}×${x[1]}`))}`); } const all = perPage.flatMap(([, sp]) => ({ ring: sp.ring, bars: sp.bars, heat: sp.heat, spark: sp.spark })); const bad = []; if (!all.some((x) => x.ring.length)) bad.push('没有量到进度环'); for (const [d, s] of all.flatMap((x) => x.ring)) { if (!(d >= 40 && d <= 48)) bad.push(`环直径 ${d}px 不在 40–48`); if (parseFloat(s) !== 3) bad.push(`环描边 ${s} ≠ 3px`); } for (const [h, r1] of all.flatMap((x) => x.bars)) { if (h !== 4) bad.push(`对比条高 ${h} ≠ 4px`); if (r1 !== '2px') bad.push(`对比条圆角 ${r1} ≠ 2px`); } for (const [w, r1] of all.flatMap((x) => x.heat)) { if (!(w >= 12 && w <= 14)) bad.push(`热力格 ${w}px 不在 12–14`); if (r1 !== '2px') bad.push(`热力格圆角 ${r1} ≠ 2px`); } for (const [w, h] of all.flatMap((x) => x.spark)) { if (!((w >= 64 && w <= 80 && h === 20) || (w === 56 && h === 16))) bad.push(`spark ${w}×${h} 不在规格(64–80×20 / 56×16)`); } if (bad.length) fails.push(`规格不符:${[...new Set(bad)].join(';')}`); await browser.close(); srv?.kill(); if (fails.length) { console.log('\n失败项:'); for (const f of fails) console.log(` ✗ ${f}`); process.exit(1); } console.log('\n全部达标(exit 0)');