- 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(品牌令牌迁移辅助)
464 lines
23 KiB
JavaScript
464 lines
23 KiB
JavaScript
/**
|
||
* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
|
||
// ---------------------------------------------------------------- 聚合
|
||
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。增量 > 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 必须是 localhost,127.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">触控目标 <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) : '无'}`);
|