/** * 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 --dark --color --out * node scripts/audit/ui-audit-report.mjs --notes # 注入「根因与修复建议」章节 * * 默认行为:在 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 下最新一轮审计产物目录。 * 只认 `[-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, '"'); // ---------------------------------------------------------------- 聚合 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 ` `; } 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 ` Novalon 官网 · Dogfood UI 审计报告

Novalon 官网 · Dogfood UI 审计报告

生成时间 ${esc(generatedAt)} · 数据源:Playwright 真实浏览器探针(多路由 × 多视口 × 浅深双主题)+ 静态硬编码色扫描

1. 审计范围与结论

项目浅色主题深色主题
数据目录${esc(L?.dir || '-')}${esc(D?.dir || '-')}
路由数${L?.routeCount ?? '-'}${D?.routeCount ?? '-'}
视口desktop 1440×900 · tablet 768×1024(核心页) · mobile 390×844
探针维度对比度 · 横向溢出 · 触控目标 · 图片 alt · 标题层级 · 表单 label · 文本裁剪 · 键盘焦点 · console/网络异常 · LCP/CLS
${(L?.bySev.P1 || 0) + (D?.bySev.P1 || 0)}
P1 高危问题(浅+深)
${(L?.bySev.P2 || 0) + (D?.bySev.P2 || 0)}
P2 应修问题
${(L?.bySev.P3 || 0) + (D?.bySev.P3 || 0)}
P3 优化项
${(L?.bySev.INFO || 0) + (D?.bySev.INFO || 0)}
环境噪音(INFO,不计缺陷)
${(L?.metrics.navErrors ?? 0) + (D?.metrics.navErrors ?? 0)}
路由加载失败
${(L?.metrics.pageErrors ?? 0) + (D?.metrics.pageErrors ?? 0)}
未捕获 JS 异常
${((L?.bySev.P1 || 0) + (D?.bySev.P1 || 0)) === 0 ? '
无 P1 阻断问题。本次探针未发现加载失败、未捕获异常或致命可用性缺陷;剩余均为分级优化项。
' : '
存在 P1 问题,优先处理。详见第 3 节共性清单。
'}

2. 关键指标

浅色主题

${L ? metricTable(L) : '

无数据

'}

深色主题

${D ? metricTable(D) : '

无数据

'}
${worstContrast ? `
全局最低对比度:${esc(worstContrast.ratio)}:1(需 ${esc(worstContrast.need)}:1) — ${esc(worstContrast.route)} ${esc(worstContrast.vp)} · 元素 ${esc(worstContrast.sel)} · 文案「${esc(worstContrast.text)}」
` : ''}

3. 共性问题清单(按影响面排序)

${[L, D].map((s, i) => s ? `

${i === 0 ? '浅色' : '深色'}主题

${SEV_ORDER.map(sev => { const list = issuesBySev(i === 0 ? light : dark)[sev]; if (!list.length) return ''; const groups = groupCommon(list); return `
${esc(SEV_LABEL[sev])} ${list.length} 条 · 归为 ${groups.length} 类
${groups.map(g => ``).join('')}
维度问题涉及路由
${esc(g.cat)}${esc(g.tpl)} ${g.samples.length ? `
样本
    ${g.samples.slice(0, 4).map(s => `
  • ${esc(s)}
  • `).join('')}
` : ''}
${esc(g.routes.join('
'))}
`; }).join('')}` : '').join('')}

4. 深色主题专项(暗黑模式成熟度)

深色主题由 Playwright colorScheme: 'dark' 驱动,走项目三态偏好的 system 解析链路, 与 e2e 视觉回归口径一致;不使用事后 setAttribute('data-theme')(会被挂载效果覆盖)。

${newInDark.length ? ` ${newInDark.slice(0, 20).map(x => ``).join('')}
路由浅色问题数深色问题数增量
${esc(x.route)} ${(x.light.P1 || 0)}+${(x.light.P2 || 0)}+${(x.light.P3 || 0)} ${(x.dark.P1 || 0)}+${(x.dark.P2 || 0)}+${(x.dark.P3 || 0)} +${x.delta}

计数格式:P1+P2+P3。增量 > 0 表示该路由在深色下暴露了额外问题(典型为对比度不足、硬编码白底不翻转)。

` : '
深色与浅色问题面一致,未发现深色专属回归。
'}
${notes ? `

5. 根因与修复建议(人工判定)

${notes}
` : ''}

6. 静态硬编码色扫描(暗黑 Phase 2 剩余)

Tailwind 原生 bg-white / bg-gray-* 是硬编码值,深色下不翻转 → 整页恒白。 扫描 ${esc(color?.scannedFiles ?? '-')} 个文件,命中 P0 ${esc(color?.counts?.P0 ?? '-')} / P1 ${esc(color?.counts?.P1 ?? '-')} / INFO ${esc(color?.counts?.INFO ?? '-')} (INFO 多为 bg-white/10 一类半透明叠加,属正确用法,非缺陷)

${colorRows.length ? ` ${colorRows.map(v => ``).join('')}
级别文件类名原因
${esc(v.sev)} ${esc(v.file)} ${esc(v.ln)} ${esc(v.cls)} ${esc(v.why)}

修复映射约定:bg-bg-primary → 页面/大块骨架;bg-bg-elevated → 带边框的卡片/表单/图标盒。 浅色下 bg-white 与两者同值 → 改动在浅色主题零像素差异,只修深色。

` : '
无 P0/P1 硬编码色残留。
'}

7. 逐路由明细

${routeRows.map(r => ``).join('')}
路由浅色 P1/P2/P3深色 P1/P2/P3问题摘要(深色)
${esc(r.route)} ${badge(r.lc)} ${badge(r.dc)} ${r.dIssues.length ? `
${esc(r.dIssues.length)} 条
    ${r.dIssues.map(x => `
  • ${esc(x)}
  • `).join('')}
` : ''}

8. 截图

${renderScreenshots(light, '浅色')} ${renderScreenshots(dark, '深色')}

9. 复现方式

# 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
`; } function badge(c) { const t = (c.P1 || 0) + (c.P2 || 0) + (c.P3 || 0); if (!t) return '0'; return `${c.P1 || 0} ${c.P2 || 0} ${c.P3 || 0}`; } function metricTable(s) { const m = s.metrics; return `
对比度违规${m.contrastTotal}
未捕获 JS 异常${m.pageErrors}
console 错误${m.consoleErrors}
请求失败${m.failedReq}
触控目标 <24px${m.touchTotal}
图片 alt 问题${m.altTotal}
表单缺 label${m.labelTotal}
键盘聚焦无指示${m.noFocusTotal}
文本被裁剪${m.clipTotal}
LCP 均值 / 最大${m.lcpAvg ?? '-'} ms / ${m.lcpMax ? m.lcpMax.v + ' ms(' + m.lcpMax.route + ' ' + m.lcpMax.vp + ')' : '-'}
CLS 最大${m.clsMax ? m.clsMax.v + '(' + m.clsMax.route + ')' : '0'}
页面平均加载${m.loadAvg ?? '-'} ms
`; } // ---------------------------------------------------------------- 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) : '无'}`);