diff --git a/scripts/audit/brand-ink-migrate.mjs b/scripts/audit/brand-ink-migrate.mjs
new file mode 100644
index 0000000..bf0739a
--- /dev/null
+++ b/scripts/audit/brand-ink-migrate.mjs
@@ -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 执行。');
diff --git a/scripts/audit/brand-usage-scan.mjs b/scripts/audit/brand-usage-scan.mjs
new file mode 100644
index 0000000..aa57ca0
--- /dev/null
+++ b/scripts/audit/brand-usage-scan.mjs
@@ -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));
+ }
+}
diff --git a/scripts/audit/hardcoded-color-audit.mjs b/scripts/audit/hardcoded-color-audit.mjs
index 2fe2364..a474267 100644
--- a/scripts/audit/hardcoded-color-audit.mjs
+++ b/scripts/audit/hardcoded-color-audit.mjs
@@ -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');
diff --git a/scripts/audit/ui-audit-report.mjs b/scripts/audit/ui-audit-report.mjs
new file mode 100644
index 0000000..cf9e505
--- /dev/null
+++ b/scripts/audit/ui-audit-report.mjs
@@ -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
--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 `
+
+ ${esc(label)}主题截图(${shots.length} 张)
+
+ ${shots.map(s => `
+
+
+ ${esc(s.route)} ${esc(s.vp)}
+ `).join('')}
+
+ `;
+}
+
+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 => `| ${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('')}` : '').join('')}
+
+
4. 深色主题专项(暗黑模式成熟度)
+
+
深色主题由 Playwright colorScheme: 'dark' 驱动,走项目三态偏好的 system 解析链路,
+ 与 e2e 视觉回归口径一致;不使用事后 setAttribute('data-theme')(会被挂载效果覆盖)。
+ ${newInDark.length ? `
+
+ | 路由 | 浅色问题数 | 深色问题数 | 增量 |
+ ${newInDark.slice(0, 20).map(x => `| ${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} |
`).join('')}
+
+
计数格式: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 => `
+ | ${esc(v.sev)} |
+ ${esc(v.file)} |
+ ${esc(v.ln)} |
+ ${esc(v.cls)} |
+ ${esc(v.why)} |
`).join('')}
+
+
修复映射约定:bg-bg-primary → 页面/大块骨架;bg-bg-elevated → 带边框的卡片/表单/图标盒。
+ 浅色下 bg-white 与两者同值 → 改动在浅色主题零像素差异,只修深色。
`
+ : '
无 P0/P1 硬编码色残留。
'}
+
+
+
7. 逐路由明细
+
+
+ | 路由 | 浅色 P1/P2/P3 | 深色 P1/P2/P3 | 问题摘要(深色) |
+ ${routeRows.map(r => `
+ | ${esc(r.route)} |
+ ${badge(r.lc)} |
+ ${badge(r.dc)} |
+ ${r.dIssues.length
+ ? `${esc(r.dIssues.length)} 条${r.dIssues.map(x => `- ${esc(x)}
`).join('')} `
+ : '—'} |
+
`).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) : '无'}`);
diff --git a/scripts/audit/ui-audit.mjs b/scripts/audit/ui-audit.mjs
index f11e0f4..32f434c 100644
--- a/scripts/audit/ui-audit.mjs
+++ b/scripts/audit/ui-audit.mjs
@@ -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//{screenshots,probes.json,summary.md}
+ * 主题驱动说明:用 Playwright context 的 colorScheme 驱动,走项目三态偏好的 system 档解析链路,
+ * 与 e2e 视觉回归口径一致;**不要**在页面加载后 setAttribute('data-theme'),会被挂载效果覆盖。
+ *
+ * 输出:dogfood-ui-audit/[-]/{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' ? 'dark(colorScheme 驱动)' : '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`);