- 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(品牌令牌迁移辅助)
205 lines
10 KiB
JavaScript
205 lines
10 KiB
JavaScript
// 硬编码颜色审计(暗黑模式 Phase 2 盘点)
|
||
//
|
||
// 权威约束:CONTEXT.md / globals.css
|
||
// - 项目 token(bg-bg-primary / text-text-primary / border-border-primary 等)
|
||
// 已重映射到 --color-*-rgb,在 html[data-theme='dark'] 下**会翻转**。
|
||
// - Tailwind 原生色(bg-white / bg-gray-100 / text-gray-900 等)是**硬编码值**,
|
||
// 深色主题下**不翻转** → 浅底/深字在深底上不可读。
|
||
//
|
||
// 判定准则(务必区分,勿一刀切):
|
||
// P0 背景类浅色 bg-white / bg-gray-50..300 → 深底上仍是白块,视觉破损
|
||
// P0 文字类深色 text-gray-600..900 / text-black → 深底上对比度不足
|
||
// P1 边框浅色 border-gray-100..300 → 深底上过亮,层次错乱
|
||
// INFO 需人工判 text-white / border-white / bg-black
|
||
// (品牌红按钮白字、深色覆盖层白字是**正确**的,不是违规)
|
||
// INFO 半透明叠加 bg-white/10 / bg-black/20 等带 alpha 修饰符
|
||
// ——alpha 叠加是相对底色取混合,浅深两主题下**都成立**,非违规
|
||
//
|
||
// 排除:_archive / *.test.* / *.spec.* / node_modules
|
||
//
|
||
// CLI: node scripts/audit/hardcoded-color-audit.mjs [--json]
|
||
|
||
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||
import { execSync } from 'node:child_process';
|
||
|
||
const OUT = 'dogfood-color-audit';
|
||
mkdirSync(OUT, { recursive: true });
|
||
|
||
const files = execSync(
|
||
"find src -type f \\( -name '*.ts' -o -name '*.tsx' \\) " +
|
||
"! -path '*/_archive/*' ! -name '*.test.*' ! -name '*.spec.*' | sort",
|
||
{ encoding: 'utf8' }
|
||
).trim().split('\n');
|
||
|
||
// Tailwind 原生中性色(会随主题翻转的 token 色不在其中)
|
||
const NEUTRAL = '(?:white|black|gray|slate|neutral|zinc|stone)';
|
||
|
||
// 规则表:顺序敏感,先匹配更具体的
|
||
const RULES = [
|
||
// 半透明叠加层:alpha 混合相对底色取值,浅深两主题下都成立 —— 非违规
|
||
// (务必放在实色规则之前,并用 (?!\/) 排除实色规则误吃 alpha 写法)
|
||
{ re: /\bbg-(?:white|black)\/\d+\b/g, sev: 'INFO', why: '半透明叠加层,两主题均成立' },
|
||
{ re: new RegExp(`\\b(?:text|border|divide)-${NEUTRAL}-\\d+/\\d+\\b`, 'g'), sev: 'INFO', why: '半透明叠加层,两主题均成立' },
|
||
|
||
// 背景:浅色背景在深底上变成白块 —— 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: new RegExp(`\\bbg-${NEUTRAL}-(?:400|500|600|700)\\b`, 'g'), sev: 'P1', why: '中性背景不翻转' },
|
||
|
||
// 文字:深色文字在深底上不可读 —— P0
|
||
{ re: new RegExp(`\\btext-${NEUTRAL}-(?:600|700|800|900|950)\\b`, 'g'), sev: 'P0', why: '深色文字在深底上对比度不足' },
|
||
{ re: /\btext-black\b/g, sev: 'P0', why: '黑色文字在深底上不可读' },
|
||
{ re: new RegExp(`\\btext-${NEUTRAL}-(?:400|500)\\b`, 'g'), sev: 'P1', why: '中性文字不翻转' },
|
||
// 文字:浅色文字需人工判(品牌红按钮 / 深色覆盖层上是正确的)
|
||
{ re: /\btext-white\b/g, sev: 'INFO', why: '需人工判:品牌红按钮/深色覆盖层上为正确用法' },
|
||
{ re: new RegExp(`\\btext-${NEUTRAL}-(?:100|200|300)\\b`, 'g'), sev: 'INFO', why: '需人工判:深色底上为正确用法' },
|
||
|
||
// 边框:浅色边框在深底上过亮 —— P1
|
||
{ re: new RegExp(`\\bborder-${NEUTRAL}-(?:100|200|300)\\b`, 'g'), sev: 'P1', why: '浅色边框不翻转' },
|
||
{ re: new RegExp(`\\bborder-${NEUTRAL}-(?:700|800|900)\\b`, 'g'), sev: 'P1', why: '深色边框不翻转' },
|
||
{ re: /\bborder-white\b/g, sev: 'INFO', why: '需人工判' },
|
||
{ re: new RegExp(`\\bborder-${NEUTRAL}-(?:400|500|600)\\b`, 'g'), sev: 'P2', why: '中性边框不翻转' },
|
||
|
||
// 分割线 / 渐变 / 描边
|
||
{ re: new RegExp(`\\bdivide-${NEUTRAL}-(?:100|200|300)\\b`, 'g'), sev: 'P1', why: '分割线不翻转' },
|
||
{ re: new RegExp(`\\bplaceholder-${NEUTRAL}-\\d+\\b`, 'g'), sev: 'P2', why: '占位符文字不翻转' },
|
||
];
|
||
|
||
/** 按路径归类区域,便于分期推进 */
|
||
function areaOf(file) {
|
||
if (file.includes('/admin') || file.includes('app/admin')) return 'admin';
|
||
if (file.includes('/(marketing)/')) return 'marketing';
|
||
if (file.includes('/components/ui/')) return 'ui-kit';
|
||
if (file.includes('/components/layout/')) return 'layout';
|
||
if (file.includes('/components/')) return 'components';
|
||
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;
|
||
try { src = readFileSync(f, 'utf8'); } catch { continue; }
|
||
const lines = src.split('\n');
|
||
const seen = new Set(); // 同一行同一 class 只记一次
|
||
|
||
lines.forEach((line, i) => {
|
||
const ln = i + 1;
|
||
const trimmed = line.trim();
|
||
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return;
|
||
|
||
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: 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, 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, 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, NA: 0 };
|
||
byArea[r.area][r.sev]++;
|
||
}
|
||
|
||
const report = {
|
||
generatedAt: new Date().toISOString(),
|
||
scannedFiles: files.length,
|
||
totalMatches: rows.length,
|
||
counts,
|
||
byArea,
|
||
filesWithViolations: Object.entries(byFile)
|
||
.filter(([, v]) => v.P0 + v.P1 + v.P2 > 0)
|
||
.map(([file, v]) => ({ file, area: v.area, P0: v.P0, P1: v.P1, P2: v.P2, INFO: v.INFO }))
|
||
.sort((a, b) => (b.P0 - a.P0) || (b.P1 - a.P1) || (b.P2 - a.P2)),
|
||
allViolations: rows.filter((r) => r.sev !== 'INFO'),
|
||
};
|
||
|
||
writeFileSync(`${OUT}/color-audit.json`, JSON.stringify(report, null, 2));
|
||
|
||
// Markdown 摘要
|
||
const md = [];
|
||
md.push('# 硬编码颜色审计(暗黑模式 Phase 2 盘点)\n');
|
||
md.push(`- 扫描文件:${files.length}(排除 _archive / test / spec)`);
|
||
md.push(`- 匹配总数:${rows.length}\n`);
|
||
md.push('## 按严重度\n');
|
||
md.push('| 级别 | 数量 | 含义 |');
|
||
md.push('|---|---|---|');
|
||
md.push(`| **P0** | ${counts.P0} | 深底上白块 / 深字不可读(真 bug) |`);
|
||
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 | 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} | ${c.NA ?? 0} |`);
|
||
}
|
||
md.push('');
|
||
md.push('## P0 文件排行(前 25)\n');
|
||
md.push('| 文件 | 区域 | P0 | P1 | P2 |');
|
||
md.push('|---|---|---|---|---|');
|
||
for (const f of report.filesWithViolations.slice(0, 25)) {
|
||
md.push(`| \`${f.file}\` | ${f.area} | ${f.P0} | ${f.P1} | ${f.P2} |`);
|
||
}
|
||
writeFileSync(`${OUT}/color-audit.md`, md.join('\n'));
|
||
|
||
console.log(md.join('\n'));
|
||
console.log(`\n完整数据:${OUT}/color-audit.json`);
|