From 441a748f32ba8dd0eb2b96f5a4af2c18abd1579f Mon Sep 17 00:00:00 2001 From: zhangxiang Date: Sat, 5 Sep 2026 08:20:59 +0800 Subject: [PATCH] =?UTF-8?q?chore(audit):=20UI=20=E5=AE=A1=E8=AE=A1?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E9=93=BE=E5=A2=9E=E5=BC=BA=E4=B8=8E=E5=99=AA?= =?UTF-8?q?=E9=9F=B3=E6=A0=B9=E6=B2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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(品牌令牌迁移辅助) --- scripts/audit/brand-ink-migrate.mjs | 56 +++ scripts/audit/brand-usage-scan.mjs | 60 +++ scripts/audit/hardcoded-color-audit.mjs | 52 ++- scripts/audit/ui-audit-report.mjs | 463 ++++++++++++++++++++++++ scripts/audit/ui-audit.mjs | 165 ++++++++- 5 files changed, 768 insertions(+), 28 deletions(-) create mode 100644 scripts/audit/brand-ink-migrate.mjs create mode 100644 scripts/audit/brand-usage-scan.mjs create mode 100644 scripts/audit/ui-audit-report.mjs 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 ` + `; +} + +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) : '无'}`); 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`);