Files
novalon-website/scripts/audit/ui-audit-report.mjs
T
zhangxiang fdb8a5afe1 chore(audit): UI 审计工具链增强与噪音根治
- ui-audit.mjs:路由发现扩展至 33 路由(standalone 构建产物 + erp-upgrade 系列);
  GA/gtm 请求 mock(gtag/js 空 JS + collect 204);第三方统计噪音与同源
  ERR_ABORTED(重调度成功/prefetch)降级 INFO;LCP/CLS 断言按系统负载门控
  (load >= 5 跳过并注明,防测量环境假阳性);LCP 超阈值热缓存 reload 复测
  取优(lcpCold 留存冷值);clip 探针 sr-only 白名单;onReqFail 防御性修复
- ui-audit-report.mjs(新增):多主题探针聚合 HTML 报告,含截图画廊与 INFO 分级
- hardcoded-color-audit.mjs:bg-black/50 半透明遮罩误报修正(负向前瞻)+ 白名单
- 新增 brand-usage-scan.mjs / brand-ink-migrate.mjs(品牌令牌迁移辅助)
2026-09-05 08:20:59 +08:00

464 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Dogfood UI 审计报告生成器
*
* 把 ui-audit.mjs 的多主题探针数据 + hardcoded-color-audit.mjs 的静态扫描结果,
* 汇总成一份可阅读的 HTML 报告(含截图画廊与逐路由明细),供人工判定与排期。
*
* 用法:
* node scripts/audit/ui-audit-report.mjs
* node scripts/audit/ui-audit-report.mjs --light <dir> --dark <dir> --color <json> --out <dir>
* node scripts/audit/ui-audit-report.mjs --notes <html片段文件> # 注入「根因与修复建议」章节
*
* 默认行为:在 dogfood-ui-audit/ 下自动取最新一轮浅色与深色目录,
* 颜色数据取 dogfood-color-audit/color-audit.json,报告输出到 dogfood-ui-audit/report/index.html
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '../..');
const AUDIT_ROOT = path.join(ROOT, 'dogfood-ui-audit');
// ---------------------------------------------------------------- 参数
function parseArgs(argv) {
const a = { light: null, dark: null, color: null, out: null, notes: null };
for (let i = 2; i < argv.length; i++) {
if (argv[i] === '--notes') a.notes = argv[++i];
else if (argv[i] === '--light') a.light = argv[++i];
else if (argv[i] === '--dark') a.dark = argv[++i];
else if (argv[i] === '--color') a.color = argv[++i];
else if (argv[i] === '--out') a.out = argv[++i];
}
return a;
}
/**
* 取 AUDIT_ROOT 下最新一轮审计产物目录。
* 只认 `<ISO时间戳>[-dark]` 命名(目录名字典序 = 时间序),
* 避免被 logo-compare 一类混入的手工目录顶掉。
*/
function latestDir(predicate) {
if (!fs.existsSync(AUDIT_ROOT)) return null;
const dirs = fs.readdirSync(AUDIT_ROOT)
.filter(n => /^\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}(-dark)?$/.test(n))
.filter(n => fs.statSync(path.join(AUDIT_ROOT, n)).isDirectory())
.filter(predicate)
.sort();
return dirs.length ? path.join(AUDIT_ROOT, dirs[dirs.length - 1]) : null;
}
function loadProbes(dir) {
if (!dir) return null;
const f = path.join(dir, 'probes.json');
if (!fs.existsSync(f)) return null;
return { dir: path.relative(ROOT, dir), data: JSON.parse(fs.readFileSync(f, 'utf8')) };
}
const esc = s => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
// ---------------------------------------------------------------- 聚合
function summarize(run) {
if (!run) return null;
const bySev = { P1: 0, P2: 0, P3: 0, INFO: 0 };
const byCat = {};
const routes = new Map();
let contrastTotal = 0, pageErrors = 0, consoleErrors = 0, failedReq = 0;
let touchTotal = 0, altTotal = 0, labelTotal = 0, noFocusTotal = 0, clipTotal = 0;
const lcpList = [], clsList = [], loadList = [];
const worst = { contrast: null, overflow: null, cls: null };
for (const r of run.data) {
const counts = { P1: 0, P2: 0, P3: 0 };
for (const i of (r.issues || [])) {
counts[i.sev] = (counts[i.sev] || 0) + 1;
bySev[i.sev] = (bySev[i.sev] || 0) + 1;
byCat[i.cat] = (byCat[i.cat] || 0) + 1;
}
routes.set(r.route, counts);
for (const vpName of Object.keys(r.viewports || {})) {
const v = r.viewports[vpName] || {};
const p = v.probe || {};
const nC = (p.contrast || []).length;
contrastTotal += nC;
for (const c of (p.contrast || [])) {
if (!worst.contrast || c.ratio < worst.contrast.ratio) worst.contrast = { ...c, route: r.route, vp: vpName };
}
if (p.overflow?.doc) {
const delta = p.overflow.doc.delta;
if (!worst.overflow || delta > worst.overflow.delta)
worst.overflow = { ...p.overflow.doc, route: r.route, vp: vpName };
}
pageErrors += (v.pageErrors || []).length;
consoleErrors += (v.consoleErrors || []).length;
failedReq += (v.failedRequests || []).length;
touchTotal += (p.touch || []).filter(t => t.w < 24 || t.h < 24).length;
altTotal += (p.images || []).length;
labelTotal += (p.labels || []).length;
noFocusTotal += ((v.focus || []).filter(f => f.outlineStyle === 'none' && !f.boxShadow)).length;
clipTotal += (p.clipped || []).length;
if (v.perf) {
if (v.perf.lcp) lcpList.push({ route: r.route, vp: vpName, v: v.perf.lcp });
if (typeof v.perf.cls === 'number') {
clsList.push({ route: r.route, vp: vpName, v: v.perf.cls });
if (!worst.cls || v.perf.cls > worst.cls.v) worst.cls = { v: v.perf.cls, route: r.route, vp: vpName };
}
}
if (v.loadMs) loadList.push(v.loadMs);
}
}
const avg = a => (a.length ? Math.round(a.reduce((x, y) => x + y, 0) / a.length) : null);
const max = a => (a.length ? a.reduce((x, y) => (y.v > x.v ? y : x)) : null);
return {
dir: run.dir, bySev, byCat, routes,
routeCount: run.data.length,
metrics: {
contrastTotal, pageErrors, consoleErrors, failedReq, touchTotal,
altTotal, labelTotal, noFocusTotal, clipTotal,
lcpAvg: avg(lcpList.map(x => x.v)), lcpMax: max(lcpList),
clsMax: worst.cls, loadAvg: avg(loadList),
navErrors: run.data.filter(r => Object.values(r.viewports || {}).some(v => v.navError)).length,
},
worst,
};
}
// ---------------------------------------------------------------- HTML
const SEV_ORDER = ['P1', 'P2', 'P3', 'INFO'];
const SEV_LABEL = {
P1: 'P1 · 阻断/高危',
P2: 'P2 · 应修',
P3: 'P3 · 优化',
INFO: 'INFO · 环境噪音(不计缺陷)',
};
function issuesBySev(run) {
const map = { P1: [], P2: [], P3: [], INFO: [] };
for (const r of run.data) for (const i of (r.issues || [])) (map[i.sev] || (map[i.sev] = [])).push({ route: r.route, ...i });
return map;
}
/** 把同 message 前缀的问题聚成"共性问题",避免逐路由刷屏 */
function groupCommon(list) {
const g = new Map();
for (const it of list) {
const key = it.cat + '|' + it.msg.replace(/\d+/g, 'N').replace(/(最低.*?/g, '');
if (!g.has(key)) g.set(key, { cat: it.cat, tpl: it.msg.replace(/\d+/g, 'N'), routes: [], samples: [], count: 0 });
const e = g.get(key);
e.count++;
if (e.routes.length < 12) e.routes.push(it.route);
if (e.samples.length < 3) for (const d of (it.detail || [])) if (e.samples.length < 6) e.samples.push(d);
}
return [...g.values()].sort((a, b) => b.count - a.count);
}
function renderScreenshots(run, label) {
if (!run) return '';
const shots = [];
for (const r of run.data) {
const d = r.viewports?.desktop, m = r.viewports?.mobile;
if (d?.screenshot) shots.push({ route: r.route, vp: 'desktop', src: d.screenshot });
if (m?.screenshot) shots.push({ route: r.route, vp: 'mobile', src: m.screenshot });
}
if (!shots.length) return '';
const rel = p => '../' + p.split(path.sep).join('/');
return `
<details class="gallery" ${label === '浅色' ? 'open' : ''}>
<summary>${esc(label)}主题截图(${shots.length} 张)</summary>
<div class="shot-grid">
${shots.map(s => `
<figure>
<img loading="lazy" src="${esc(rel(s.src))}" alt="${esc(s.route)} ${esc(s.vp)}">
<figcaption>${esc(s.route)} <span class="vp">${esc(s.vp)}</span></figcaption>
</figure>`).join('')}
</div>
</details>`;
}
function buildHtml({ light, dark, color, notes }) {
const L = summarize(light), D = summarize(dark);
const generatedAt = new Date().toLocaleString('zh-CN', { hour12: false });
// 深色相对浅色新增的问题面(按路由)
const newInDark = [];
if (L && D) {
for (const [route, dc] of D.routes) {
const lc = L.routes.get(route) || { P1: 0, P2: 0, P3: 0 };
const delta = SEV_ORDER.reduce((s, k) => s + ((dc[k] || 0) - (lc[k] || 0)), 0);
if (delta > 0) newInDark.push({ route, delta, light: lc, dark: dc });
}
newInDark.sort((a, b) => b.delta - a.delta);
}
const colorRows = (color?.allViolations || []).filter(v => v.sev === 'P0' || v.sev === 'P1');
// 逐路由对比表
const allRoutes = new Set([...(L ? L.routes.keys() : []), ...(D ? D.routes.keys() : [])]);
const routeRows = [...allRoutes].sort().map(route => {
const lc = L?.routes.get(route) || { P1: 0, P2: 0, P3: 0 };
const dc = D?.routes.get(route) || { P1: 0, P2: 0, P3: 0 };
const tot = o => (o.P1 || 0) + (o.P2 || 0) + (o.P3 || 0);
const detail = (run, route) => (run?.data.find(r => r.route === route)?.issues || [])
.map(i => `[${i.sev}] ${i.cat}${i.msg}`);
return { route, lc, dc, lt: tot(lc), dt: tot(dc), lIssues: detail(light, route), dIssues: detail(dark, route) };
});
const worstContrast = [L, D].filter(Boolean).map(s => s?.worst?.contrast).filter(Boolean)
.sort((a, b) => a.ratio - b.ratio)[0];
return `<!DOCTYPE html>
<html lang="zh-CN" data-theme="dark">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Novalon 官网 · Dogfood UI 审计报告</title>
<style>
:root {
--bg: #0d1117; --panel: #161b22; --panel-2: #1c2128; --line: #30363d;
--fg: #e6edf3; --fg-dim: #9198a1; --fg-mute: #6e7681;
--p1: #f85149; --p2: #d29922; --p3: #58a6ff; --ok: #3fb950; --accent: #c41e3a;
}
* { box-sizing: border-box; }
body { margin:0; background:var(--bg); color:var(--fg);
font: 15px/1.65 -apple-system, "PingFang SC", "Helvetica Neue", "Microsoft YaHei", sans-serif; }
.wrap { max-width: 1180px; margin: 0 auto; padding: 40px 24px 96px; }
h1 { font-size: 28px; margin: 0 0 6px; letter-spacing: .5px; }
h2 { font-size: 20px; margin: 44px 0 14px; padding-bottom: 8px; border-bottom: 1px solid var(--line); }
h3 { font-size: 16px; margin: 26px 0 10px; }
.sub { color: var(--fg-mute); font-size: 13px; }
.cards { display:grid; grid-template-columns: repeat(auto-fit,minmax(160px,1fr)); gap:12px; margin: 22px 0 8px; }
.card { background: var(--panel); border:1px solid var(--line); border-radius:10px; padding:14px 16px; }
.card .n { font-size: 26px; font-weight: 650; line-height:1.2; }
.card .l { color: var(--fg-dim); font-size: 12.5px; margin-top:2px; }
.card.p1 .n { color: var(--p1); } .card.p2 .n { color: var(--p2); }
.card.p3 .n { color: var(--p3); } .card.ok .n { color: var(--ok); }
table { width:100%; border-collapse: collapse; margin: 10px 0 4px; font-size: 13.5px; }
th, td { text-align:left; padding: 7px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
th { color: var(--fg-dim); font-weight: 600; background: var(--panel-2); position: sticky; top: 0; }
td code, .mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 12.5px; }
.tag { display:inline-block; padding: 1px 7px; border-radius: 20px; font-size: 11.5px; font-weight: 600; }
.t-P1 { background: rgba(248,81,73,.15); color: var(--p1); border:1px solid rgba(248,81,73,.35); }
.t-P2 { background: rgba(210,153,34,.15); color: var(--p2); border:1px solid rgba(210,153,34,.35); }
.t-P3 { background: rgba(88,166,255,.15); color: var(--p3); border:1px solid rgba(88,166,255,.35); }
.t-INFO { background: rgba(139,148,158,.15); color: #8b949e; border:1px solid rgba(139,148,158,.35); }
.muted { color: var(--fg-mute); }
.panel { background: var(--panel); border:1px solid var(--line); border-radius:10px; padding: 16px 18px; margin: 14px 0; }
ul.tight { margin: 6px 0; padding-left: 20px; }
ul.tight li { margin: 3px 0; }
details.gallery { margin: 12px 0; border:1px solid var(--line); border-radius:10px; background: var(--panel); }
details.gallery > summary { padding: 12px 16px; cursor: pointer; font-weight: 600; }
.shot-grid { display:grid; grid-template-columns: repeat(auto-fill,minmax(210px,1fr)); gap:12px; padding: 4px 16px 16px; }
.shot-grid figure { margin:0; }
.shot-grid img { width:100%; border:1px solid var(--line); border-radius:6px; background:#fff; display:block; }
.shot-grid figcaption { font-size: 12px; color: var(--fg-dim); margin-top:5px; }
.vp { color: var(--fg-mute); }
.split { display:grid; grid-template-columns: 1fr 1fr; gap: 16px; }
@media (max-width: 820px) { .split { grid-template-columns: 1fr; } }
.kv { color: var(--fg-dim); }
.warn { border-left: 3px solid var(--p2); background: rgba(210,153,34,.08); padding: 10px 14px; border-radius: 0 8px 8px 0; margin: 12px 0; }
.good { border-left: 3px solid var(--ok); background: rgba(63,185,80,.08); padding: 10px 14px; border-radius: 0 8px 8px 0; margin: 12px 0; }
a { color: var(--p3); }
details.route { margin: 4px 0; }
details.route > summary { cursor:pointer; padding: 4px 0; }
details.route ul { margin: 4px 0 10px; }
</style>
</head>
<body>
<div class="wrap">
<h1>Novalon 官网 · Dogfood UI 审计报告</h1>
<div class="sub">生成时间 ${esc(generatedAt)} · 数据源:Playwright 真实浏览器探针(多路由 × 多视口 × 浅深双主题)+ 静态硬编码色扫描</div>
<h2>1. 审计范围与结论</h2>
<div class="panel">
<table>
<tr><th style="width:24%">项目</th><th>浅色主题</th><th>深色主题</th></tr>
<tr><td class="kv">数据目录</td><td class="mono">${esc(L?.dir || '-')}</td><td class="mono">${esc(D?.dir || '-')}</td></tr>
<tr><td class="kv">路由数</td><td>${L?.routeCount ?? '-'}</td><td>${D?.routeCount ?? '-'}</td></tr>
<tr><td class="kv">视口</td><td colspan="2">desktop 1440×900 · tablet 768×1024(核心页) · mobile 390×844</td></tr>
<tr><td class="kv">探针维度</td><td colspan="2">对比度 · 横向溢出 · 触控目标 · 图片 alt · 标题层级 · 表单 label · 文本裁剪 · 键盘焦点 · console/网络异常 · LCP/CLS</td></tr>
</table>
</div>
<div class="cards">
<div class="card p1"><div class="n">${(L?.bySev.P1 || 0) + (D?.bySev.P1 || 0)}</div><div class="l">P1 高危问题(浅+深)</div></div>
<div class="card p2"><div class="n">${(L?.bySev.P2 || 0) + (D?.bySev.P2 || 0)}</div><div class="l">P2 应修问题</div></div>
<div class="card p3"><div class="n">${(L?.bySev.P3 || 0) + (D?.bySev.P3 || 0)}</div><div class="l">P3 优化项</div></div>
<div class="card"><div class="n" style="color:#8b949e">${(L?.bySev.INFO || 0) + (D?.bySev.INFO || 0)}</div><div class="l">环境噪音(INFO,不计缺陷)</div></div>
<div class="card ok"><div class="n">${(L?.metrics.navErrors ?? 0) + (D?.metrics.navErrors ?? 0)}</div><div class="l">路由加载失败</div></div>
<div class="card ok"><div class="n">${(L?.metrics.pageErrors ?? 0) + (D?.metrics.pageErrors ?? 0)}</div><div class="l">未捕获 JS 异常</div></div>
</div>
${((L?.bySev.P1 || 0) + (D?.bySev.P1 || 0)) === 0
? '<div class="good"><strong>无 P1 阻断问题。</strong>本次探针未发现加载失败、未捕获异常或致命可用性缺陷;剩余均为分级优化项。</div>'
: '<div class="warn"><strong>存在 P1 问题,优先处理。</strong>详见第 3 节共性清单。</div>'}
<h2>2. 关键指标</h2>
<div class="split">
<div class="panel">
<h3 style="margin-top:0">浅色主题</h3>
${L ? metricTable(L) : '<p class="muted">无数据</p>'}
</div>
<div class="panel">
<h3 style="margin-top:0">深色主题</h3>
${D ? metricTable(D) : '<p class="muted">无数据</p>'}
</div>
</div>
${worstContrast ? `<div class="warn"><strong>全局最低对比度</strong>${esc(worstContrast.ratio)}:1(需 ${esc(worstContrast.need)}:1
— <code>${esc(worstContrast.route)}</code> ${esc(worstContrast.vp)} · 元素 <code>${esc(worstContrast.sel)}</code> · 文案「${esc(worstContrast.text)}」</div>` : ''}
<h2>3. 共性问题清单(按影响面排序)</h2>
${[L, D].map((s, i) => s ? `
<h3>${i === 0 ? '浅色' : '深色'}主题</h3>
${SEV_ORDER.map(sev => {
const list = issuesBySev(i === 0 ? light : dark)[sev];
if (!list.length) return '';
const groups = groupCommon(list);
return `<div class="panel">
<div style="margin-bottom:6px"><span class="tag t-${sev}">${esc(SEV_LABEL[sev])}</span>
<span class="muted" style="margin-left:8px">${list.length} 条 · 归为 ${groups.length} 类</span></div>
<table><tr><th style="width:20%">维度</th><th style="width:52%">问题</th><th>涉及路由</th></tr>
${groups.map(g => `<tr><td>${esc(g.cat)}</td><td>${esc(g.tpl)}
${g.samples.length ? `<details class="route"><summary class="muted" style="font-size:12px">样本</summary><ul class="tight">${g.samples.slice(0, 4).map(s => `<li class="mono">${esc(s)}</li>`).join('')}</ul></details>` : ''}
</td><td class="mono muted">${esc(g.routes.join('<br>'))}</td></tr>`).join('')}
</table></div>`;
}).join('')}` : '').join('')}
<h2>4. 深色主题专项(暗黑模式成熟度)</h2>
<div class="panel">
<p style="margin-top:0">深色主题由 Playwright <code>colorScheme: 'dark'</code> 驱动,走项目三态偏好的 system 解析链路,
与 e2e 视觉回归口径一致;不使用事后 <code>setAttribute('data-theme')</code>(会被挂载效果覆盖)。</p>
${newInDark.length ? `
<table>
<tr><th>路由</th><th style="width:110px">浅色问题数</th><th style="width:110px">深色问题数</th><th style="width:90px">增量</th></tr>
${newInDark.slice(0, 20).map(x => `<tr><td class="mono">${esc(x.route)}</td>
<td>${(x.light.P1 || 0)}+${(x.light.P2 || 0)}+${(x.light.P3 || 0)}</td>
<td>${(x.dark.P1 || 0)}+${(x.dark.P2 || 0)}+${(x.dark.P3 || 0)}</td>
<td style="color:var(--p2)">+${x.delta}</td></tr>`).join('')}
</table>
<p class="muted" style="font-size:12.5px">计数格式:P1+P2+P3。增量 &gt; 0 表示该路由在深色下暴露了额外问题(典型为对比度不足、硬编码白底不翻转)。</p>`
: '<div class="good">深色与浅色问题面一致,未发现深色专属回归。</div>'}
</div>
${notes ? `<h2>5. 根因与修复建议(人工判定)</h2><div class="panel">${notes}</div>` : ''}
<h2>6. 静态硬编码色扫描(暗黑 Phase 2 剩余)</h2>
<div class="panel">
<p style="margin-top:0">Tailwind 原生 <code>bg-white</code> / <code>bg-gray-*</code> 是硬编码值,深色下不翻转 → 整页恒白。
扫描 ${esc(color?.scannedFiles ?? '-')} 个文件,命中 <code>P0 ${esc(color?.counts?.P0 ?? '-')}</code> /
<code>P1 ${esc(color?.counts?.P1 ?? '-')}</code> / <code>INFO ${esc(color?.counts?.INFO ?? '-')}</code>
<span class="muted">INFO 多为 <code>bg-white/10</code> 一类半透明叠加,属正确用法,非缺陷)</span></p>
${colorRows.length ? `
<table>
<tr><th style="width:60px">级别</th><th style="width:44%">文件</th><th style="width:70px">行</th><th style="width:130px">类名</th><th>原因</th></tr>
${colorRows.map(v => `<tr>
<td><span class="tag t-${v.sev}">${esc(v.sev)}</span></td>
<td class="mono">${esc(v.file)}</td>
<td class="mono">${esc(v.ln)}</td>
<td class="mono">${esc(v.cls)}</td>
<td class="muted">${esc(v.why)}</td></tr>`).join('')}
</table>
<p class="muted" style="font-size:12.5px">修复映射约定:<code>bg-bg-primary</code> → 页面/大块骨架;<code>bg-bg-elevated</code> → 带边框的卡片/表单/图标盒。
浅色下 <code>bg-white</code> 与两者同值 → 改动在浅色主题零像素差异,只修深色。</p>`
: '<div class="good">无 P0/P1 硬编码色残留。</div>'}
</div>
<h2>7. 逐路由明细</h2>
<div class="panel" style="padding:0;overflow-x:auto">
<table>
<tr><th style="width:30%">路由</th><th style="width:90px">浅色 P1/P2/P3</th><th style="width:90px">深色 P1/P2/P3</th><th>问题摘要(深色)</th></tr>
${routeRows.map(r => `<tr>
<td class="mono">${esc(r.route)}</td>
<td>${badge(r.lc)}</td>
<td>${badge(r.dc)}</td>
<td class="muted">${r.dIssues.length
? `<details class="route"><summary>${esc(r.dIssues.length)} 条</summary><ul class="tight">${r.dIssues.map(x => `<li>${esc(x)}</li>`).join('')}</ul></details>`
: '<span style="color:var(--ok)">—</span>'}</td>
</tr>`).join('')}
</table>
</div>
<h2>8. 截图</h2>
${renderScreenshots(light, '浅色')}
${renderScreenshots(dark, '深色')}
<h2>9. 复现方式</h2>
<div class="panel">
<pre class="mono" style="margin:0;white-space:pre-wrap"># 1) 起本地站点(URL 必须是 localhost127.0.0.1 会被 allowedDevOrigins 拒绝 → chunk 403 → hydration 失败)
npm run dev
# 2) 浅色全站审计
node scripts/audit/ui-audit.mjs
# 3) 深色全站审计(核心页含整页长图)
node scripts/audit/ui-audit.mjs --theme dark --full-page
# 4) 静态硬编码色盘点
node scripts/audit/hardcoded-color-audit.mjs
# 5) 生成本报告
node scripts/audit/ui-audit-report.mjs</pre>
</div>
</div>
</body>
</html>`;
}
function badge(c) {
const t = (c.P1 || 0) + (c.P2 || 0) + (c.P3 || 0);
if (!t) return '<span style="color:var(--ok)">0</span>';
return `<span class="tag t-P1">${c.P1 || 0}</span> <span class="tag t-P2">${c.P2 || 0}</span> <span class="tag t-P3">${c.P3 || 0}</span>`;
}
function metricTable(s) {
const m = s.metrics;
return `<table>
<tr><td class="kv">对比度违规</td><td>${m.contrastTotal}</td></tr>
<tr><td class="kv">未捕获 JS 异常</td><td>${m.pageErrors}</td></tr>
<tr><td class="kv">console 错误</td><td>${m.consoleErrors}</td></tr>
<tr><td class="kv">请求失败</td><td>${m.failedReq}</td></tr>
<tr><td class="kv">触控目标 &lt;24px</td><td>${m.touchTotal}</td></tr>
<tr><td class="kv">图片 alt 问题</td><td>${m.altTotal}</td></tr>
<tr><td class="kv">表单缺 label</td><td>${m.labelTotal}</td></tr>
<tr><td class="kv">键盘聚焦无指示</td><td>${m.noFocusTotal}</td></tr>
<tr><td class="kv">文本被裁剪</td><td>${m.clipTotal}</td></tr>
<tr><td class="kv">LCP 均值 / 最大</td><td>${m.lcpAvg ?? '-'} ms / ${m.lcpMax ? m.lcpMax.v + ' ms' + m.lcpMax.route + ' ' + m.lcpMax.vp + '' : '-'}</td></tr>
<tr><td class="kv">CLS 最大</td><td>${m.clsMax ? m.clsMax.v + '' + m.clsMax.route + '' : '0'}</td></tr>
<tr><td class="kv">页面平均加载</td><td>${m.loadAvg ?? '-'} ms</td></tr>
</table>`;
}
// ---------------------------------------------------------------- main
const args = parseArgs(process.argv);
const lightDir = args.light || latestDir(n => !n.endsWith('-dark'));
const darkDir = args.dark || latestDir(n => n.endsWith('-dark'));
if (process.env.REPORT_DEBUG) {
console.error('[debug] ROOT =', ROOT);
console.error('[debug] AUDIT_ROOT =', AUDIT_ROOT, fs.existsSync(AUDIT_ROOT));
console.error('[debug] lightDir =', lightDir, 'darkDir =', darkDir);
}
const colorFile = args.color || path.join(ROOT, 'dogfood-color-audit', 'color-audit.json');
const light = loadProbes(lightDir);
const dark = loadProbes(darkDir);
const color = fs.existsSync(colorFile) ? JSON.parse(fs.readFileSync(colorFile, 'utf8')) : null;
if (!light && !dark) {
console.error('未找到任何探针数据(dogfood-ui-audit/ 下无 probes.json)。请先运行 ui-audit.mjs。');
process.exit(1);
}
const outDir = args.out || path.join(AUDIT_ROOT, 'report');
fs.mkdirSync(outDir, { recursive: true });
const notes = args.notes && fs.existsSync(args.notes) ? fs.readFileSync(args.notes, 'utf8') : null;
const outFile = path.join(outDir, 'index.html');
fs.writeFileSync(outFile, buildHtml({ light, dark, color, notes }));
console.log(`✓ 报告已生成:${path.relative(ROOT, outFile)}`);
console.log(` 浅色数据:${light ? light.dir : '无'}`);
console.log(` 深色数据:${dark ? dark.dir : '无'}`);
console.log(` 颜色数据:${color ? path.relative(ROOT, colorFile) : '无'}`);