From d959fa9a81b5432cc66ee4e86c5420d4593f00db Mon Sep 17 00:00:00 2001 From: zhangxiang Date: Fri, 4 Sep 2026 13:46:29 +0800 Subject: [PATCH] =?UTF-8?q?refactor(theme):=20=E8=90=A5=E9=94=80=E9=A1=B5?= =?UTF-8?q?=20bg-white=20token=20=E5=8C=96=EF=BC=88=E6=9A=97=E9=BB=91?= =?UTF-8?q?=E6=A8=A1=E5=BC=8F=20Phase=202=20=C2=B7=20marketing=20=E6=89=B9?= =?UTF-8?q?=E6=AC=A1=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:Tailwind 原生 bg-white 是硬编码值,深色主题下不翻转, 导致整页/整 section 恒为纯白,与转深色的页头页脚割裂。 (globals.css:--color-bg-primary-rgb 浅色 255 255 255 → 深色 10 14 20) 改动:10 文件 67 处 bg-white → 项目 token,按语义二分类 - bg-bg-primary (33 处):页面/大块骨架(min-h-screen 容器、
) - bg-bg-elevated (34 处):浮起层(带 border 的卡片、表单输入、图标盒、grid 单元格) --color-bg-elevated 浅色 #FFFFFF → 深色 #151B23,同样翻转。 浅色主题下 bg-white / bg-bg-primary / bg-bg-elevated 三者同值(纯白), 故本次改动在浅色主题下零像素差异,只修复深色主题。 新增审计工具: - scripts/audit/hardcoded-color-audit.mjs:硬编码颜色分级盘点 (含 alpha 误报修正:bg-white/NN 半透明叠加两主题均成立,判 INFO 非 P0) - scripts/audit/darkmode-bg-verify.mjs:直接验证背景随主题翻转 (浅色应纯白、深色应 rgb(10,14,20) 且无残留纯白块) 审计结果:marketing P0 67 → 0;全站 P0 97 → 30 (剩余 components 16 / other 8 / layout 3 / ui-kit 3 为第二批;admin 本就为 0) 门禁: - tsc 0 error - eslint 0 error(50 warnings 全为既有,改动文件未新增) - jest 129/130 套件通过,1548 通过 / 2 跳过 / **0 断言失败** 唯一失败 src/lib/admin-api.test.ts 为**沙箱环境限制非代码问题**: CODEBUDDY_BROKER_DENY 拦截 readFileSync,单独复验同样失败, 与本次改动零交集(改动仅限 src/app/(marketing)/ 下 10 个页面组件) - 暗黑模式背景验证 18/18 PASS(9 路由 × 浅深双版) --- .gitignore | 2 + scripts/audit/darkmode-bg-verify.mjs | 102 +++++++++++ scripts/audit/hardcoded-color-audit.mjs | 168 ++++++++++++++++++ .../(marketing)/cases/cases-content-v3.tsx | 8 +- .../contact/contact-content-v3.tsx | 30 ++-- .../methodology/methodology-content.tsx | 8 +- src/app/(marketing)/news/news-content-v3.tsx | 10 +- .../news/news-detail-content-v3.tsx | 6 +- .../erp-upgrade/erp-upgrade-content-v2.tsx | 16 +- .../products/products-content-v3.tsx | 16 +- .../solutions/solution-detail-content-v3.tsx | 16 +- .../solutions/solutions-content-v3.tsx | 10 +- src/app/(marketing)/team/team-content-v3.tsx | 14 +- 13 files changed, 339 insertions(+), 67 deletions(-) create mode 100644 scripts/audit/darkmode-bg-verify.mjs create mode 100644 scripts/audit/hardcoded-color-audit.mjs diff --git a/.gitignore b/.gitignore index 6ffc2b8..9f09aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -316,6 +316,8 @@ dogfood-output*/ dogfood-bain/ dogfood-motion-audit/ dogfood-b2-verify/ +# 硬编码颜色审计产物(暗黑模式 Phase 2)—— 只提交脚本与报告 +dogfood-color-audit/ # UI/UX/UE 审计采集产物(截图 + probes.json,单轮可达 39MB)—— 只提交脚本与报告 dogfood-ui-audit/ diff --git a/scripts/audit/darkmode-bg-verify.mjs b/scripts/audit/darkmode-bg-verify.mjs new file mode 100644 index 0000000..8a8e2db --- /dev/null +++ b/scripts/audit/darkmode-bg-verify.mjs @@ -0,0 +1,102 @@ +// 暗黑模式背景翻转验证(Phase 2 token 化专用) +// +// 用途:直接验证「背景随主题翻转」这一改动目标,而不只是验证「页面没报错」。 +// +// 机理(globals.css): +// --color-bg-primary-rgb 浅色 255 255 255 (L70) → 深色 10 14 20 (L409) +// --color-bg-elevated 浅色 #FFFFFF (L69) → 深色 #151B23 (L415) +// 因此浅色主题下 bg-bg-primary / bg-bg-elevated 都渲染为纯白 —— 与改动前的 +// bg-white **像素一致**(零回归);深色主题下才显现差异。 +// +// 断言: +// light : 根节点背景 == rgb(255, 255, 255)(与改动前一致) +// dark : 根节点背景 != rgb(255, 255, 255) 且亮度低(已翻转为深色) +// dark : 页面内不得残留大面积纯白块(抽样主要 section / 卡片) +// +// 环境约束(2026-09-02 踩坑固化): +// - 必须 localhost(非 127.0.0.1):Next dev allowedDevOrigins 白名单不含 127.0.0.1 +// - 必须 --no-proxy-server:本机系统代理会拦 403 +// +// CLI: node scripts/audit/darkmode-bg-verify.mjs [--routes /,/contact] [--out ] + +import { chromium } from 'playwright'; +import { writeFileSync, mkdirSync } from 'node:fs'; + +const args = process.argv.slice(2); +const getList = (flag, def) => { + const i = args.indexOf(flag); + return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1].split(',') : def; +}; +const ROUTES = getList('--routes', ['/', '/contact', '/products', '/solutions', '/team', '/cases', '/news', '/methodology', '/products/erp-upgrade']); +const BASE = 'http://localhost:3000'; +const OUT = 'dogfood-color-audit'; +mkdirSync(OUT, { recursive: true }); + +const browser = await chromium.launch({ args: ['--no-proxy-server'] }); + +/** 亮度:0(黑) ~ 255(白) */ +function luma(rgb) { + const m = rgb.match(/\d+/g); + if (!m) return null; + const [r, g, b] = m.map(Number); + return Math.round(0.2126 * r + 0.7152 * g + 0.0722 * b); +} + +async function probe(path, theme) { + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + await page.addInitScript((t) => { + try { localStorage.setItem('novalon-theme', t); } catch {} + }, theme); + + const errors = []; + page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); + page.on('pageerror', (e) => errors.push('PAGEERROR: ' + e.message)); + + await page.goto(BASE + path, { waitUntil: 'networkidle', timeout: 45000 }); + + const info = await page.evaluate(() => { + const pick = (el) => (el ? getComputedStyle(el).backgroundColor : null); + const body = pick(document.body); + // 抽样:主内容区的前若干 section / 大块容器 + const blocks = Array.from(document.querySelectorAll('section, main, [class*="min-h-screen"]')) + .slice(0, 12) + .map((el) => getComputedStyle(el).backgroundColor); + return { body, blocks, html: getComputedStyle(document.documentElement).backgroundColor }; + }); + + await ctx.close(); + + const bodyLuma = luma(info.body); + // 页面里仍然纯白的块(排除透明) + const whiteBlocks = info.blocks.filter((c) => luma(c) !== null && luma(c) >= 250); + + let ok; + let note; + if (theme === 'light') { + ok = bodyLuma !== null && bodyLuma >= 250; + note = `body=${info.body} (luma=${bodyLuma}),浅色应为纯白(与改动前一致)`; + } else { + ok = bodyLuma !== null && bodyLuma < 60 && whiteBlocks.length === 0; + note = `body=${info.body} (luma=${bodyLuma}),残留纯白块 ${whiteBlocks.length} 个`; + } + + return { path, theme, ok, bodyLuma, whiteBlocks: whiteBlocks.length, errors, note }; +} + +const results = []; +for (const r of ROUTES) { + results.push(await probe(r, 'light')); + results.push(await probe(r, 'dark')); +} +await browser.close(); + +const failed = results.filter((r) => !r.ok || r.errors.length > 0); +writeFileSync(`${OUT}/darkmode-bg-verify.json`, JSON.stringify({ generatedAt: new Date().toISOString(), routes: ROUTES, results, failedCount: failed.length }, null, 2)); + +for (const r of results) { + console.log(`${r.ok && r.errors.length === 0 ? 'PASS' : 'FAIL'} ${r.theme.padEnd(5)} ${r.path.padEnd(24)} ${r.note}`); + r.errors.slice(0, 2).forEach((e) => console.log(` └ ${e.slice(0, 140)}`)); +} +console.log(`\n${results.length - failed.length}/${results.length} PASS`); +process.exit(failed.length > 0 ? 1 : 0); diff --git a/scripts/audit/hardcoded-color-audit.mjs b/scripts/audit/hardcoded-color-audit.mjs new file mode 100644 index 0000000..2fe2364 --- /dev/null +++ b/scripts/audit/hardcoded-color-audit.mjs @@ -0,0 +1,168 @@ +// 硬编码颜色审计(暗黑模式 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: '纯白背景不翻转' }, + // 背景:深色背景在浅色主题下是黑块 —— 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'; +} + +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; + 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, + area: areaOf(f), + }); + } + } + }); +} + +// 聚合 +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][r.sev]++; + byFile[r.file].items.push(r); +} + +const counts = { P0: 0, P1: 0, P2: 0, INFO: 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][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 |'); +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(''); +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`); diff --git a/src/app/(marketing)/cases/cases-content-v3.tsx b/src/app/(marketing)/cases/cases-content-v3.tsx index 43ae4d1..21becce 100644 --- a/src/app/(marketing)/cases/cases-content-v3.tsx +++ b/src/app/(marketing)/cases/cases-content-v3.tsx @@ -26,7 +26,7 @@ function CaseCard({ caseItem, index }: { caseItem: CaseStudy; index: number }) { whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true, margin: '-60px' }} transition={{ duration: 0.3, delay: index * 0.06, ease: EASE_OUT }} - className="group relative block overflow-hidden border border-border-primary hover:border-border-secondary bg-white hover:bg-bg-secondary transition-all duration-150 flex flex-col h-full" + className="group relative block overflow-hidden border border-border-primary hover:border-border-secondary bg-bg-elevated hover:bg-bg-secondary transition-all duration-150 flex flex-col h-full" >
@@ -120,9 +120,9 @@ export default function CasesContentV3({ }, [selectedIndustry, casesProp]); return ( -
+
{/* Hero Section */} -
+
{/* Cases List Section */} -
+
+
{showToast && ( +
{data.contactTitle || ''}
-