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(品牌令牌迁移辅助)
This commit is contained in:
2026-09-20 11:50:58 +08:00
parent 5ecab7b15d
commit 441a748f32
5 changed files with 768 additions and 28 deletions
+56
View File
@@ -0,0 +1,56 @@
/**
* text-brand → text-brand-ink 批量迁移(暗黑模式品牌文字色修复)
*
* 背景:`text-brand` 与 `bg-brand` 原本共用 `--color-brand-rgb`,无法只在深色下提亮文字
* (提亮会连按钮底色一起改,红底白字 5.58:1 → 3.75:1)。故拆分出文字专用通道
* `--color-brand-ink-rgb`,本脚本把「文字用法」迁到新 utility `text-brand-ink`。
*
* 匹配规则:
* - 只匹配独立的 `text-brand`(后不接 `-` 或字母数字),保留 `text-brand-light` 等子 token
* - 保留变体前缀(hover: / focus: / group-hover: / sm: 等)与透明度修饰符(/80)
*
* 用法:
* node scripts/audit/brand-ink-migrate.mjs # 预演,只打印不落盘
* node scripts/audit/brand-ink-migrate.mjs --apply # 实际写入
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const APPLY = process.argv.includes('--apply');
const EXTS = new Set(['.tsx', '.ts', '.css', '.jsx', '.js', '.mdx']);
const SKIP = new Set(['node_modules', '.next', '.git', '_archive', 'dist', 'coverage']);
function walk(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(p, out); }
else if (EXTS.has(path.extname(e.name))) out.push(p);
}
return out;
}
// 后不接 `-` 或字母数字 → 排除 text-brand-light / text-brand-ink(幂等)
const re = /text-brand(?![-\w])/g;
let fileCount = 0, hitCount = 0;
const perFile = [];
for (const f of walk(path.join(ROOT, 'src'))) {
const before = fs.readFileSync(f, 'utf8');
const after = before.replace(re, 'text-brand-ink');
if (after === before) continue;
const n = (before.match(re) || []).length;
hitCount += n;
fileCount++;
perFile.push({ f: path.relative(ROOT, f), n });
if (APPLY) fs.writeFileSync(f, after);
}
perFile.sort((a, b) => b.n - a.n);
console.log(`${APPLY ? '已写入' : '预演(未落盘)'}${hitCount} 处 / ${fileCount} 文件`);
for (const { f, n } of perFile.slice(0, 20)) console.log(String(n).padStart(4), f);
if (perFile.length > 20) console.log(` ... 另有 ${perFile.length - 20} 个文件`);
if (!APPLY) console.log('\n确认无误后加 --apply 执行。');
+60
View File
@@ -0,0 +1,60 @@
/**
* 品牌色 utility 用量盘点(为「文本令牌 / 底色令牌」拆分提供决策依据)
*
* 统计 src 下所有 `(?:变体前缀:)?(属性)-brand(?:-子token)?(/透明度)?` 的出现次数与文件分布。
* 用法:node scripts/audit/brand-usage-scan.mjs [--files]
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..');
const EXTS = new Set(['.tsx', '.ts', '.css', '.jsx', '.js', '.mdx']);
const SKIP = new Set(['node_modules', '.next', '.git', '_archive', 'dist', 'coverage']);
function walk(dir, out = []) {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(p, out); }
else if (EXTS.has(path.extname(e.name))) out.push(p);
}
return out;
}
const VARIANTS = '(?:hover:|focus:|focus-visible:|active:|group-hover:|dark:|sm:|md:|lg:|xl:)*';
const PROPS = '(bg|text|border|from|to|via|fill|stroke|ring|divide|decoration|outline|shadow|placeholder|caret|accent)';
const re = new RegExp(`(?:^|[\\s\`"'])${VARIANTS}${PROPS}-(brand(?:-[a-z]+)?)(?:/[0-9]+)?(?=[\\s\`"':/]|$)`, 'g');
const byToken = new Map();
const byFile = new Map();
const byPropToken = new Map();
for (const f of walk(path.join(ROOT, 'src'))) {
let s;
try { s = fs.readFileSync(f, 'utf8'); } catch { continue; }
let m;
while ((m = re.exec(s))) {
const key = `${m[1]}-${m[2]}`;
byToken.set(key, (byToken.get(key) || 0) + 1);
byPropToken.set(m[1], (byPropToken.get(m[1]) || 0) + 1);
byFile.set(f, (byFile.get(f) || 0) + 1);
}
}
const sortDesc = a => [...a.entries()].sort((x, y) => y[1] - x[1]);
console.log('=== brand utility 用量(src 全量)===');
for (const [k, n] of sortDesc(byToken)) console.log(String(n).padStart(5), k);
const total = [...byToken.values()].reduce((s, n) => s + n, 0);
console.log('总计'.padStart(5), total);
console.log('\n=== 按属性聚合 ===');
for (const [k, n] of sortDesc(byPropToken)) console.log(String(n).padStart(5), k);
if (process.argv.includes('--files')) {
console.log('\n=== 文件分布 Top 25 ===');
for (const [f, n] of sortDesc(byFile).slice(0, 25)) {
console.log(String(n).padStart(5), path.relative(ROOT, f));
}
}
+44 -8
View File
@@ -44,9 +44,10 @@ const RULES = [
// 背景:浅色背景在深底上变成白块 —— P0
{ re: new RegExp(`\\bbg-${NEUTRAL}-(?:50|100|200|300)(?!/)\\b`, 'g'), sev: 'P0', why: '浅色背景不翻转' },
{ re: /\bbg-white(?!\/)\b/g, sev: 'P0', why: '纯白背景不翻转' },
{ re: /\bbg-black(?!\/)\b/g, sev: 'P0', why: '纯黑背景不翻转' },
// 背景:深色背景在浅色主题下是黑块 —— P0(对称违规)
{ re: new RegExp(`\\bbg-${NEUTRAL}-(?:800|900|950)\\b`, 'g'), sev: 'P0', why: '深色背景不翻转' },
{ re: /\bbg-black\b/g, sev: 'P0', why: '纯黑背景不翻转' },
{ re: /\bbg-black(?!\/)\b/g, sev: 'P0', why: '纯黑背景不翻转' },
{ re: new RegExp(`\\bbg-${NEUTRAL}-(?:400|500|600|700)\\b`, 'g'), sev: 'P1', why: '中性背景不翻转' },
// 文字:深色文字在深底上不可读 —— P0
@@ -78,6 +79,27 @@ function areaOf(file) {
return 'other';
}
// 恒定深色区块排除清单(2026-09-04 修正)
// 判据:这些文件的**根容器**使用 bg-dark-bg(深色专用 token**不随主题翻转**),
// 即该区块在设计上恒为深色。区块内的中性硬编码色(text-gray-400 / bg-gray-900 /
// border-gray-800)在恒定深底上是**正确用法**,不是「不翻转的 bug」。
// 未加此清单会把 footer 的 10 处正确用法误判为 P0/P1。
const CONSTANT_DARK = [
{ file: 'src/components/layout/footer.tsx', reason: '根容器 bg-dark-bg,恒定深色区块' },
];
// 恒定品牌色区块内的白色元素(反转设计)排除清单(2026-09-04 补)
// 判据:这些元素**所在区块恒定深色/品牌红**bg-brand-section / style backgroundColor var(--color-brand)
// 两主题都不翻转),区块上的白底元素 + 品牌红实色文字是高对比**反转设计**,不是「不翻转 bug」。
// 若未来这些区块改为随主题翻转,需同步删除对应条目。
const ALLOW_INVERTED_ON_BRAND = [
{ file: 'src/components/sections/case-detail-page.tsx', frag: 'bg-white rounded-full', reason: '品牌红指标块上的白底「核心指标」角标' },
{ file: 'src/components/detail/detail-cta-section.tsx', frag: 'px-10 py-4 bg-white text-brand', reason: '品牌深红 CTA 区块上的白底主按钮' },
{ file: 'src/components/ui/slider.tsx', frag: 'border-brand bg-white', reason: '滑轨上的白底圆钮(thumb),深色下仍需高对比可拖拽' },
{ file: 'src/components/ui/switch.tsx', frag: 'rounded-full bg-white shadow-lg ring-0', reason: '开关上的白底圆钮(thumb),深色下仍需高对比可拖拽' },
];
const constantDarkSet = new Set(CONSTANT_DARK.map((c) => c.file));
const rows = [];
for (const f of files) {
let src;
@@ -92,33 +114,47 @@ for (const f of files) {
for (const rule of RULES) {
rule.re.lastIndex = 0;
// 反转设计行命中判定(须在 push 前算好,供行内违规降级 NA)
const inverted = ALLOW_INVERTED_ON_BRAND.find((a) => a.file === f && line.includes(a.frag));
const downgrade = inverted && (rule.sev === 'P0' || rule.sev === 'P1' || rule.sev === 'P2');
for (const m of line.matchAll(rule.re)) {
const key = `${ln}:${m[0]}`;
if (seen.has(key)) continue;
seen.add(key);
rows.push({
file: f, ln, cls: m[0], sev: rule.sev, why: rule.why,
file: f, ln, cls: m[0],
sev: downgrade ? 'NA' : rule.sev,
why: downgrade ? '反转设计(恒定品牌/深色区块上的白元素)' : rule.why,
area: areaOf(f),
excludedReason: downgrade ? inverted.reason : undefined,
});
}
}
});
}
// 应用排除:恒定深色区块内的中性色降级为 NA(不计违规,保留记录可追溯)
for (const r of rows) {
if (constantDarkSet.has(r.file) && (r.sev === 'P0' || r.sev === 'P1' || r.sev === 'P2')) {
r.sev = 'NA';
r.excludedReason = CONSTANT_DARK.find((c) => c.file === r.file)?.reason ?? '';
}
}
// 聚合
const byFile = {};
for (const r of rows) {
byFile[r.file] ??= { P0: 0, P1: 0, P2: 0, INFO: 0, area: r.area, items: [] };
byFile[r.file] ??= { P0: 0, P1: 0, P2: 0, INFO: 0, NA: 0, area: r.area, items: [] };
byFile[r.file][r.sev]++;
byFile[r.file].items.push(r);
}
const counts = { P0: 0, P1: 0, P2: 0, INFO: 0 };
const counts = { P0: 0, P1: 0, P2: 0, INFO: 0, NA: 0 };
for (const r of rows) counts[r.sev]++;
const byArea = {};
for (const r of rows) {
byArea[r.area] ??= { P0: 0, P1: 0, P2: 0, INFO: 0 };
byArea[r.area] ??= { P0: 0, P1: 0, P2: 0, INFO: 0, NA: 0 };
byArea[r.area][r.sev]++;
}
@@ -150,10 +186,10 @@ md.push(`| P1 | ${counts.P1} | 边框 / 中性色不翻转 |`);
md.push(`| P2 | ${counts.P2} | 占位符等次要 |`);
md.push(`| INFO | ${counts.INFO} | 需人工判(多为正确用法) |\n`);
md.push('## 按区域(分期依据)\n');
md.push('| 区域 | P0 | P1 | P2 | INFO |');
md.push('|---|---|---|---|---|');
md.push('| 区域 | P0 | P1 | P2 | INFO | NA |');
md.push('|---|---|---|---|---|---|');
for (const [area, c] of Object.entries(byArea).sort((a, b) => b[1].P0 - a[1].P0)) {
md.push(`| ${area} | ${c.P0} | ${c.P1} | ${c.P2} | ${c.INFO} |`);
md.push(`| ${area} | ${c.P0} | ${c.P1} | ${c.P2} | ${c.INFO} | ${c.NA ?? 0} |`);
}
md.push('');
md.push('## P0 文件排行(前 25\n');
+463
View File
@@ -0,0 +1,463 @@
/**
* 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) : '无'}`);
+145 -20
View File
@@ -11,12 +11,17 @@
* node scripts/audit/ui-audit.mjs --routes / /contact # 指定路由
* node scripts/audit/ui-audit.mjs --viewports desktop # 指定视口
* node scripts/audit/ui-audit.mjs --no-shot # 只跑探针不截图
* node scripts/audit/ui-audit.mjs --theme dark # 按深色主题审计(对比度/背景翻转在此暴露)
*
* 输出:dogfood-ui-audit/<timestamp>/{screenshots,probes.json,summary.md}
* 主题驱动说明:用 Playwright context 的 colorScheme 驱动,走项目三态偏好的 system 档解析链路,
* 与 e2e 视觉回归口径一致;**不要**在页面加载后 setAttribute('data-theme'),会被挂载效果覆盖。
*
* 输出:dogfood-ui-audit/<timestamp>[-<theme>]/{screenshots,probes.json,summary.md}
*/
import { chromium } from 'playwright';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -25,7 +30,7 @@ const ROOT = path.resolve(__dirname, '../..');
// ---------------------------------------------------------------- 参数解析
function parseArgs(argv) {
const args = { routes: null, viewports: null, noShot: false, fullPage: false, limit: null };
const args = { routes: null, viewports: null, noShot: false, fullPage: false, limit: null, theme: 'light' };
for (let i = 2; i < argv.length; i++) {
const a = argv[i];
if (a === '--routes') args.routes = collectUntilFlag(argv, ++i);
@@ -33,6 +38,7 @@ function parseArgs(argv) {
else if (a === '--limit') args.limit = Number(argv[++i]);
else if (a === '--no-shot') args.noShot = true;
else if (a === '--full-page') args.fullPage = true;
else if (a === '--theme') { args.theme = argv[i + 1] === 'dark' ? 'dark' : 'light'; i++; }
}
return args;
}
@@ -53,7 +59,7 @@ const VIEWPORTS = {
/** 核心页:额外跑 tablet 视口 + 全页长图 */
const TIER1 = new Set(['/', '/products', '/solutions', '/services', '/cases', '/contact', '/about', '/news']);
/** 静态已知路由 */
/** 静态已知路由(列表页链接不可达的页面必须显式列出,否则审计发现不到 —— 2026-09-04 修复盲区) */
const KNOWN_ROUTES = [
'/',
'/about',
@@ -63,6 +69,8 @@ const KNOWN_ROUTES = [
'/methodology',
'/news',
'/products',
'/products/erp-upgrade',
'/products/erp-upgrade-v3',
'/services',
'/solutions',
'/team',
@@ -70,6 +78,28 @@ const KNOWN_ROUTES = [
'/terms',
];
/** 独立产品页(/products/standalone/[id])的静态生成实例;id 来自 CMS,build 后从 .next 产物发现 */
const STANDALONE_ROUTE_PREFIX = '/products/standalone/';
function discoverStandaloneSlugs() {
// distDir 由 next.config.mjs 决定(默认 'dist',可用 NEXT_DIST_DIR 覆盖)
const base = path.join(ROOT, process.env.NEXT_DIST_DIR || 'dist', 'server', 'app', 'products', 'standalone');
if (!fs.existsSync(base)) return [];
const slugs = [];
const walk = (dir) => {
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (/\.(html|body|meta)$/.test(e.name)) {
const rel = path.relative(base, path.dirname(p));
if (rel && rel !== '.' && !rel.includes('[')) slugs.push(rel);
}
}
};
walk(base);
return [...new Set(slugs)].sort();
}
/** 列表页 → 用于发现动态详情路由 */
const LIST_ROUTES = ['/cases', '/news', '/products', '/services', '/solutions'];
@@ -272,6 +302,8 @@ const PAGE_PROBE = `(() => {
for (const el of document.querySelectorAll('body *')) {
if (clipCount >= 15) break;
if (!isVisible(el)) continue;
// sr-only 是刻意的屏幕阅读器专用隐藏(1px 裁剪),不算缺陷
if (el.classList.contains('sr-only')) continue;
const cs = getComputedStyle(el);
if (cs.overflow === 'visible' || cs.overflowY === 'visible') continue;
if (el.scrollHeight > el.clientHeight + 4 && el.clientHeight > 0) {
@@ -349,16 +381,31 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
const consoleErrors = [];
const pageErrors = [];
const failedRequests = [];
const finishedUrls = new Set();
const onConsole = m => { if (m.type() === 'error') consoleErrors.push(m.text().slice(0, 200)); };
const onPageErr = e => pageErrors.push(String(e.message || e).slice(0, 200));
const onReqFail = r => failedRequests.push({
url: r.url().slice(0, 140), method: r.method(),
failure: r.failure()?.errorText || null,
status: r.response()?.status() ?? null,
});
const onReqFail = r => {
// 请求失败时 response() 可能返回部分初始化的对象(无 status 方法),需防御
let st = null;
try {
const resp = r.response();
if (resp && typeof resp.status === 'function') st = resp.status();
} catch { /* 部分初始化时忽略 */ }
let rt = null;
try { rt = r.resourceType(); } catch { /* ignore */ }
failedRequests.push({
url: r.url().slice(0, 140), method: r.method(),
failure: r.failure()?.errorText || null,
status: st,
resourceType: rt,
});
};
// 记录成功完成的请求 URL:用于判定 ERR_ABORTED 是否为「取消后重调度成功」
const onReqDone = r => { try { finishedUrls.add(r.url()); } catch { /* ignore */ } };
page.on('console', onConsole);
page.on('pageerror', onPageErr);
page.on('requestfailed', onReqFail);
page.on('requestfinished', onReqDone);
let navStatus = null;
let navError = null;
@@ -401,6 +448,20 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
});
} catch { /* ignore */ }
// LCP 单次采样对系统抖动敏感:超阈值时热缓存 reload 复测一次取较优值,防假阳性
//2026-09-05 案例:healthcare 深色单次 2660ms,复测 3 次全 1292-1664ms
if (vpResult.perf?.lcp && vpResult.perf.lcp > 2500) {
try {
await page.reload({ waitUntil: 'load', timeout: 60000 });
await page.waitForTimeout(1500);
const lcp2 = await page.evaluate(() => (window.__audit?.lcp ? Math.round(window.__audit.lcp) : null));
if (lcp2) {
vpResult.perf.lcpCold = vpResult.perf.lcp;
vpResult.perf.lcp = Math.min(vpResult.perf.lcp, lcp2);
}
} catch { /* ignore */ }
}
// Tab 焦点可见性(UE 键盘可达性采样)
if (vpName === 'desktop') {
try {
@@ -439,10 +500,12 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
vpResult.consoleErrors = consoleErrors.slice(0, 12);
vpResult.pageErrors = pageErrors.slice(0, 8);
vpResult.failedRequests = failedRequests.slice(0, 12);
vpResult.finishedUrls = [...finishedUrls];
page.off('console', onConsole);
page.off('pageerror', onPageErr);
page.off('requestfailed', onReqFail);
page.off('requestfinished', onReqDone);
result.viewports[vpName] = vpResult;
}
@@ -450,6 +513,10 @@ async function auditRoute(page, ctx, route, viewports, args, outDir) {
return result;
}
// 性能断言有效性(main 里按系统负载设定):load 过高时 LCP/CLS 采样不可信
let PERF_ASSERT_VALID = true;
let PERF_LOAD_AT_START = 0;
function scoreRoute(r) {
const issues = [];
const d = r.viewports.desktop || {};
@@ -462,12 +529,34 @@ function scoreRoute(r) {
if ((d.navStatus && d.navStatus >= 400) || (m.navStatus && m.navStatus >= 400))
issues.push({ sev: 'P1', cat: '可用性', msg: `HTTP ${d.navStatus || m.navStatus}` });
const cerr = [...(d.consoleErrors || []), ...(m.consoleErrors || [])];
// 第三方统计噪音(GA/gtm beacon 等):沙箱无外网时必然失败,非站点缺陷 → INFO。
// 2026-09-04 全量终验:238 条错误 100% 为 google 系 beacon,非 GA 项 0。
const THIRD_PARTY_NOISE_RE = /google-analytics\.com|googletagmanager\.com|google\.com\/g\/collect|doubleclick\.net|\/gtag\//i;
const cerrAll = [...(d.consoleErrors || []), ...(m.consoleErrors || [])];
const cerr = cerrAll.filter(e => !THIRD_PARTY_NOISE_RE.test(String(e)));
const cerrNoise = cerrAll.length - cerr.length;
if (cerr.length) issues.push({ sev: 'P2', cat: 'UE/稳定性', msg: `console 错误 ${cerr.length}`, detail: cerr.slice(0, 3) });
if (cerrNoise) issues.push({ sev: 'INFO', cat: '环境噪音', msg: `第三方统计 console 错误 ${cerrNoise} 条(GA beacon 外网不可达,非站点缺陷)` });
const perr = [...(d.pageErrors || []), ...(m.pageErrors || [])];
if (perr.length) issues.push({ sev: 'P1', cat: 'UE/稳定性', msg: `未捕获异常 ${perr.length}`, detail: perr.slice(0, 3) });
const rfail = [...(d.failedRequests || []), ...(m.failedRequests || [])];
const rfailAll = [...(d.failedRequests || []), ...(m.failedRequests || [])];
const rfailNoise = rfailAll.filter(f => THIRD_PARTY_NOISE_RE.test(String(f.url || '')));
// net::ERR_ABORTED = 客户端主动取消。结合两类证据判定是否需要留痕:
// ① 同 URL 后来成功(requestfinished 关联)→ 重调度,完全无害,不留痕
// ② prefetch 资源被打断 → 本身无用户影响,不留痕
// ③ 其余 abort → INFO 留痕(2026-09-05 实测 /methodology 三图 abort 后 complete=true、URL 全 200
const finishedSet = new Set([...(d.finishedUrls || []), ...(m.finishedUrls || [])]);
const abortedAndGone = f =>
!THIRD_PARTY_NOISE_RE.test(String(f.url || '')) &&
f.failure === 'net::ERR_ABORTED' &&
(f.resourceType === 'prefetch' || finishedSet.has(f.url));
const rfailAbort = rfailAll.filter(f =>
!THIRD_PARTY_NOISE_RE.test(String(f.url || '')) &&
f.failure === 'net::ERR_ABORTED' && !abortedAndGone(f));
const rfail = rfailAll.filter(f => !THIRD_PARTY_NOISE_RE.test(String(f.url || '')) && f.failure !== 'net::ERR_ABORTED');
if (rfail.length) issues.push({ sev: 'P2', cat: 'UE/稳定性', msg: `请求失败 ${rfail.length}`, detail: rfail.slice(0, 3) });
if (rfailAbort.length) issues.push({ sev: 'INFO', cat: '环境噪音', msg: `浏览器取消的请求 ${rfailAbort.length} 条(prefetch/懒加载调度,资源实测可服务)` });
if (rfailNoise.length) issues.push({ sev: 'INFO', cat: '环境噪音', msg: `第三方统计请求失败 ${rfailNoise.length} 条(GA beacon 外网不可达,非站点缺陷)` });
const cr = [...(p.contrast || []), ...(pm.contrast || [])];
if (cr.length) {
@@ -505,19 +594,23 @@ function scoreRoute(r) {
const noFocus = (d.focus || []).filter(f => f.outlineStyle === 'none' && !f.boxShadow);
if (noFocus.length) issues.push({ sev: 'P2', cat: 'UE/键盘可达', msg: `聚焦无可见指示 ${noFocus.length}`, detail: noFocus.slice(0, 4).map(f => `<${f.tag}> "${f.text || ''}"`) });
const cls = Math.max(d.perf?.cls || 0, m.perf?.cls || 0);
if (cls > 0.1) issues.push({ sev: 'P2', cat: 'UE/性能', msg: `CLS ${cls} 超过 0.1 阈值` });
const lcp = Math.max(d.perf?.lcp || 0, m.perf?.lcp || 0);
if (lcp > 2500) issues.push({ sev: 'P3', cat: 'UE/性能', msg: `LCP ${lcp}ms 超过 2500ms` });
// 性能断言有效性门控:系统 load 过高时 LCP/CLS 采样不可信(实测 load 9.87 时整轮膨胀至 2500-3652ms
// 而 TTFB 正常),此时跳过断言而非产出假阳性。阈值取 5(单数 machine 一般 <2,共享开发机留余量)。
if (PERF_ASSERT_VALID) {
const cls = Math.max(d.perf?.cls || 0, m.perf?.cls || 0);
if (cls > 0.1) issues.push({ sev: 'P2', cat: 'UE/性能', msg: `CLS ${cls} 超过 0.1 阈值` });
const lcp = Math.max(d.perf?.lcp || 0, m.perf?.lcp || 0);
if (lcp > 2500) issues.push({ sev: 'P3', cat: 'UE/性能', msg: `LCP ${lcp}ms 超过 2500ms` });
}
if (!p.meta?.lang) issues.push({ sev: 'P2', cat: 'UI/可访问性', msg: 'html 缺少 lang 属性' });
if (p.meta?.titleLen === 0) issues.push({ sev: 'P2', cat: 'UX/SEO', msg: '页面 title 为空' });
return issues;
}
function buildSummary(all) {
function buildSummary(all, theme) {
const lines = [];
const counts = { P1: 0, P2: 0, P3: 0 };
const counts = { P1: 0, P2: 0, P3: 0, INFO: 0 };
const byCat = {};
for (const r of all) {
for (const i of r.issues) {
@@ -525,12 +618,17 @@ function buildSummary(all) {
byCat[i.cat] = (byCat[i.cat] || 0) + 1;
}
}
lines.push('# Dogfood UI/UX/UE 审计 · 自动探针汇总', '');
lines.push(`# Dogfood UI/UX/UE 审计 · 自动探针汇总${theme === 'dark' ? '深色主题' : '浅色主题'}`, '');
lines.push(`生成时间:${new Date().toISOString()}`);
lines.push(`审计主题:${theme === 'dark' ? 'darkcolorScheme 驱动)' : 'light'}`);
lines.push(`审计路由:${all.length}`, '');
if (!PERF_ASSERT_VALID) {
lines.push(`> ⚠ 系统负载过高(load ${PERF_LOAD_AT_START.toFixed(2)} ≥ 5),LCP/CLS 断言已跳过(采样不可信,防假阳性)。`, '');
}
lines.push('## 问题计数', '');
lines.push('| 等级 | 数量 |', '|---|:---:|');
lines.push(`| P1 | ${counts.P1} |`, `| P2 | ${counts.P2} |`, `| P3 | ${counts.P3} |`, '');
lines.push(`| P1 | ${counts.P1} |`, `| P2 | ${counts.P2} |`, `| P3 | ${counts.P3} |`,
counts.INFO ? `| INFO(环境噪音,不计缺陷) | ${counts.INFO} |` : '', '');
lines.push('## 按维度分布', '');
lines.push('| 维度 | 数量 |', '|---|:---:|');
for (const [c, n] of Object.entries(byCat).sort((a, b) => b[1] - a[1])) lines.push(`| ${c} | ${n} |`);
@@ -549,17 +647,39 @@ function buildSummary(all) {
async function main() {
const args = parseArgs(process.argv);
console.log(`→ 审计主题:${args.theme}`);
// 性能断言门控:load ≥ 5 视为测量环境不可信(见 scoreRoute 注释)
PERF_LOAD_AT_START = os.loadavg()[0];
PERF_ASSERT_VALID = PERF_LOAD_AT_START < 5;
console.log(`→ 系统 load${PERF_LOAD_AT_START.toFixed(2)}LCP/CLS 断言${PERF_ASSERT_VALID ? '启用' : '跳过'}`);
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const outDir = path.join(ROOT, 'dogfood-ui-audit', stamp);
const outDir = path.join(ROOT, 'dogfood-ui-audit', args.theme === 'dark' ? `${stamp}-dark` : stamp);
fs.mkdirSync(outDir, { recursive: true });
const browser = await chromium.launch({ headless: true });
// --no-proxy-server:本机有系统代理,Chromium 默认继承会把 localhost 请求送进隧道导致 chunk 403
const browser = await chromium.launch({ headless: true, args: ['--no-proxy-server'] });
const context = await browser.newContext({
viewport: VIEWPORTS.desktop,
deviceScaleFactor: 1,
ignoreHTTPSErrors: true,
// 主题由 colorScheme 驱动(走 system 档解析),不要事后改 data-theme
colorScheme: args.theme === 'dark' ? 'dark' : 'light',
// 不加载已有登录态:审计的是访客视角
});
// 第三方统计噪音根治:mock 掉 GA/gtm 请求。沙箱无外网时 beacon 必然失败(console 错误 +
// failedRequests),但与 UI 审计无关。站点 gtag() 为内联定义(仅 push dataLayer),
// mock 空 JS 不会引发未捕获异常;collect 类 beacon 返回 204。
await context.route(
/google-analytics\.com|googletagmanager\.com|google\.com\/g\/collect|doubleclick\.net/i,
route => {
const url = route.request().url();
if (/\/gtag\/js/.test(url)) {
route.fulfill({ status: 200, contentType: 'application/javascript', body: '/* ga-mock */' });
} else {
route.fulfill({ status: 204, body: '' });
}
}
);
// 注入 LCP / CLS 观察者
await context.addInitScript(() => {
window.__audit = { lcp: 0, cls: 0 };
@@ -576,6 +696,11 @@ async function main() {
if (!routes) {
console.log('→ 发现路由中...');
routes = await discoverRoutes(page);
const standalone = discoverStandaloneSlugs().map((s) => STANDALONE_ROUTE_PREFIX + s);
if (standalone.length) {
console.log(`→ 从构建产物发现独立产品页 ${standalone.length} 个:\n ${standalone.join('\n ')}`);
routes.push(...standalone);
}
}
if (args.limit) routes = routes.slice(0, args.limit);
console.log(`→ 共 ${routes.length} 个路由:\n ${routes.join('\n ')}\n`);