diff --git a/.gitignore b/.gitignore
index 77434a0..be26128 100644
--- a/.gitignore
+++ b/.gitignore
@@ -314,6 +314,8 @@ AGENTS.md
# dogfood
dogfood-output*/
dogfood-bain/
+# UI/UX/UE 审计采集产物(截图 + probes.json,单轮可达 39MB)—— 只提交脚本与报告
+dogfood-ui-audit/
# agent & skill artifacts
.nova-loop/
diff --git a/UI_UX_UE_AUDIT_2026-09-01.md b/UI_UX_UE_AUDIT_2026-09-01.md
new file mode 100644
index 0000000..55554bb
--- /dev/null
+++ b/UI_UX_UE_AUDIT_2026-09-01.md
@@ -0,0 +1,381 @@
+# Dogfood UI/UX/UE 审计 · 2026-09-01
+
+> 本报告由 Playwright + agent-browser dogfood 采集 + 静态扫描 + 视觉审图自动产出,覆盖 31 个公开路由。
+> 审计目标:UI(视觉/令牌一致性)+ UX(信息架构/叙事模型/转化路径)+ UE(交互/可达性/性能/反馈)合规度。
+
+---
+
+## 0. 结论先行
+
+| 维度 | 评估 | 关键证据 |
+|---|:---:|---|
+| 设计语言统一度 | **B** | 字体/动效/缓动 ✅;书法字例外 1 处(/about/brand) |
+| 品牌红触达点 | **A** | 30/31 路由 ≥3 处;仅 /privacy 违规(2 处) |
+| 品牌红使用克制 | **A** | 全部 token 化、无大面积滥用 |
+| 可访问性 WCAG AA | **C** | 29 页页脚 `text-gray-500` 4:1(需 4.5);1 页大号装饰数字 1.47:1 |
+| 移动端触控目标 | **C** | 29/31 页页脚链接 18px 高 <24px;首页 CTA 23px |
+| **四层叙事完整性** | **D** | **6 个详情页 Layer 3 信任层(案例/证言/认证/伙伴)全部 = 0** |
+| Hero 视觉差异化 | **C** | 所有详情页 Hero 纯文本、无视觉(违反 Hero 变化策略) |
+| 交互 / 性能 / 稳定性 | **B** | 无 console error;CLS/LCP 全站正常 |
+| 视觉留白与节奏 | **C** | 详情页 Hero 后大空白 200px+;视觉节奏单调 |
+| 移动端底部固定 Tab bar | **C** | 遮挡正文("联系我们"被截) |
+
+**总体评级:C+ (功能/性能合格,叙事模型与可访问性明显欠债)**
+
+---
+
+## 1. 审计范围与方法
+
+### 1.1 范围
+- 31 个公开路由(marketing 组 + privacy/terms + 16 个动态详情页)
+- admin 组(共 9 个页面)需登录态,**不在本次审计范围**
+- 视口:desktop 1440×900 + mobile 390×844 + tablet 768×1024(仅核心页)
+- 单次首屏截图 90 张(31×desktop + 31×mobile + tier1×tablet + 核心 fullPage 10 张)
+
+### 1.2 方法
+- **Playwright(headless chromium)自动化采集**
+ - 9 维度探针:对比度 / 横向溢出 / 触控目标 / alt / 标题层级 / label / 文本截断 / 颜色频次 / 性能 LCP+CLS
+ - 动态探针:console error / 网络失败 / Tab 焦点可见性
+- **agent-browser 交互态深挖**(聚焦 /contact / 导航 / 表单;agent-browser reload 命令有 bug,已切回 Playwright)
+- **静态扫描**:globals.css 设计令牌 / 颜色硬编码 / framer-motion spring 残留 / shadow-* 使用
+- **视觉审图**:6 张首屏 + 2 张 fullPage(首页 + contact fullPage)人工审查
+
+### 1.3 输出
+- `dogfood-ui-audit/2026-09-01T07-07-49/`
+ - `probes.json` —— 31 路由 × 3 视口全量数据
+ - `summary.md` —— 自动分级汇总
+ - `screenshots/desktop|mobile|fullpage/` —— 视觉证据
+- 采集脚本:`scripts/audit/ui-audit.mjs`(主)+ `audit-fullpage.mjs`(fullPage 补截)
+
+---
+
+## 2. P1(必须修复 · 系统性 / 阻断性)
+
+### P1-1 ⛔ Layer 3 信任证明层 100% 缺失
+**影响面**:6 个核心详情页 × 4 个 Layer 3 子项 = 24/24 完全为 0
+**详情**:
+
+| 路由 | 案例 | 证言 | 认证 | 伙伴 |
+|---|:---:|:---:|:---:|:---:|
+| /products/erp | 0 | 0 | 0 | 0 |
+| /products/bi | 0 | 0 | 0 | 0 |
+| /products/crm | 0 | 0 | 0 | 0 |
+| /solutions/finance | 0 | 0 | 0 | 0 |
+| /solutions/healthcare | 0 | 0 | 0 | 0 |
+| /services/consulting | 0 | 0 | 0 | 0 |
+
+**根因**:CONTEXT.md 已明确记录 "Layer 3 信任证明:全新层,现有页面完全缺失"。这是**设计债而非 bug**,但未启动建立。
+**修复路径**(建议):
+1. **/cases 列表页与详情页打通**—— 当前 /cases 没有详情链接(详见 P3-2)
+2. 详情页增加 "客户案例" Section(logo 墙 + 1-2 段客户故事)
+3. 增加 "客户证言" 区块(头像 + 公司 + 职位 + 引言)
+4. 增加 "资质认证" 区块(ISO/等保/合规徽标)
+**优先级**:**P0**(设计 DNA 的核心叙事层,整个转化链路的最短板)
+
+### P1-2 ⛔ 页脚 `text-hint` 对比度 4:1(需 4.5)—— 29 页
+**影响面**:全站统一页脚分隔符 `|`,29/31 路由受影响
+**证据**:
+```
+[29 页 / 29 处] rgb(107, 114, 128) ON rgb(10, 14, 20) | ratio=4.0 | need=4.5
+ span.hidden.sm:inline :: "|" (14.625px/400)
+```
+**根因**:页脚 `src/components/layout/footer.tsx:166` 直接写死 `text-gray-500`(Tailwind 内置色 `#6B7280`),**未走设计令牌** `text-hint`(#7C8CA5)。globals.css 同时定义了 `--color-text-hint: #7C8CA5`(line 83)和 `#94A3B8`(line 415),但页脚没用。
+**修复**:将 `text-gray-500` → `text-text-hint`(统一使用 `--color-text-hint`),并在 hover/focus 提深一档(如 `--color-text-secondary`)以确保 4.5:1。文件改动 1 处,全站生效。
+**验证**:跑 `npm run check:contrast` 应全部通过。
+**优先级**:P1(已知遗留,IMPECCABLE_AUDIT.md 6/20→14/20→17/20 评分轮次中"剩 text-hint"被点名未修)
+
+### P1-3 ⛔ /about "服务编号" 大号数字对比度 1.47:1
+**证据**:
+```
+[1 页 / 6 处] rgba(100, 116, 139, 0.3) ON rgb(255, 255, 255) | need=3 | ratio=1.47
+ div.text-4xl.md:text-5xl :: "01" (63px/900)
+ div.text-4xl.md:text-5xl :: "02" (63px/900)
+ div.text-4xl.md:text-5xl :: "03" (63px/900)
+```
+**影响**:WCAG 大文本阈值 3:1,实际 1.47:1 —— 几乎不可见。
+**判定**:服务编号(Service Number 设计语言)的"大号浅色水印"风格,**设计意图是大背景装饰数字**,但数字属可见元素(非纯装饰),不能跳过对比度规则。
+**修复选项**(任选一):
+1. 加 `aria-hidden="true"` + CSS `opacity: 0.5`(明确纯装饰属性,跳过 WCAG 文本检查)
+2. 提高 rgba alpha 到 0.5+(约 3:1)
+3. 改为 `text-text-hint`(#7C8CA5)作为视觉弱化,但对比度 5.8:1 反而**过高**—— 需要找到既弱化又合规的色值(如 #BCC3CE alpha=0.6 → 约 3.1:1)
+**优先级**:P1(设计意图可保留,但需正式标记或调色)
+
+### P1-4 ⛔ /methodology "服务编号" 装饰数字对比度 1.13:1
+**证据**:
+```
+[1 页 / 16 处] rgba(100, 116, 139, 0.1) ON rgb(255, 255, 255) | need=3 | ratio=1.13
+ span.text-4xl.font-bold :: "01" (45px/700)
+```
+**影响**:更夸张,几乎完全不可见 —— alpha=0.1 意味着 10% 不透明度,是水印。
+**修复**:同 P1-3,建议走 aria-hidden + opacity 0.3 路线。
+**关联**:4 处 /team 页相同问题(4 处 `text-[11px]` eyebrow + "正式成立"/"研发启航" 等小字)。
+
+---
+
+## 3. P2(重要修复)
+
+### P2-1 移动端触控目标 < 24px —— 29/31 页
+**根因聚类**(29 页统一模式):
+```
+[29 页 / 145 处] 56x18 | a 例: "贸易零售","教育培训","医疗健康"...
+[29 页 / 29 处] 86x18 | a 例: "ERP 管理系统"
+[29 页 / 29 处] 91x18 | a 例: "CRM 客户管理"
+[29 页 / 29 处] 73x18 | a 例: "BI 数据平台"
+[29 页 / 29 处] 90x18 | a 例: "CMS 内容平台"
+[29 页 / 29 处] 101x18 | a 例: "SDS 供应链决策"
+[29 页 / 29 处] 79x18 | a 例: "OA 协同办公"
+[29 页 / 29 处] 84x18 | a 例: "睿视 NovaVis"
+[29 页 / 29 处] 42x18 | a 例: "制造业"
+```
+**根因**:**Footer 链接行高不足 18px**(页脚 `src/components/layout/footer.tsx` 链接文本容器 `py-1.5` → 18px = 1.5 × 12px row + 14px text)。
+**WCAG 2.2 AA 2.5.8**:最小 24×24 CSS px,**或** 间距豁免(与相邻目标间距使 24px 圆不重叠)。
+**修复选项**:
+1. 提页脚链接 `py-2.5` (20px) → 不够 24px。建议 `py-3` 或 `min-h-[28px]`
+2. 检查间距豁免条件(页脚是垂直列表,行距 > 6px 可豁免)—— 当前垂直间距通常 ≥4px,**可能豁免**。需人工评估
+3. **首选**:在链接容器设 `min-h-[24px]` + `inline-flex items-center`
+**优先级**:P2(WCAG 2.2,间距豁免可能成立,但若间距不足则违规)
+
+### P2-2 移动端首页底部 Tab bar 遮挡内容
+**证据**:mobile 首屏截图,`联系我们` 被截断显示为"联系"。
+**根因**:固定底部 Tab bar 高度约 64px,但页面 main 容器未加 `pb-16` / `pb-[64px]`。
+**影响**:所有 mobile 页面底部内容都会被遮挡,不仅是首页。
+**修复**:Footer 全局加底部安全区适配,或 Tab bar 组件本身在页面 main 上加 `padding-bottom: 64px`。
+**优先级**:P1(影响所有 mobile 用户,视觉完整性问题)
+
+### P2-3 详情页 Hero 大量空白(200px+)
+**影响页面**:/about /products/erp /products/bi /products/crm /products/oa /products/cms /products/sds /solutions/* /services/* /methodology /contact 全员
+**证据**:见各页首屏截图,Hero 顶部 ~200px 空白(视口起始到 eyebrow 之间)。
+**根因**:Hero 组件固定 `min-h-screen` 或 `py-32` padding,未根据内容自适应。
+**修复**:
+1. 桌面端 Hero padding 收敛(`pt-24 md:pt-28` 而非 `pt-32`)
+2. 或允许 Hero 高度按视口比例(`min-h-[calc(100vh-200px)]`)
+3. **首选**:填充内容(呼应 P1-1 Layer 3 —— 详情页可加 hero 区装饰)
+**优先级**:P2(视觉节奏问题,影响"高级感")
+
+### P2-4 详情页 Hero 缺乏视觉差异化(Hero 变化策略违反)
+**证据**:所有详情页 Hero 均为纯文本(左对齐 + eyebrow + 标题 + 副文 + CTA),无图、无装饰、无动效背景。
+**CONTEXT.md 约束**:"色调+背景纹理+布局微调" 三类变化允许;当前零差异化。
+**影响**:详情页之间同质化严重,削弱 HSI 架构(Hub-Spoke-Independent)的 Spoke 与 Independent 区分度。
+**修复**:为每个 Spoke/Independent 详情页实现 Hero 视觉变体(如 ERP 几何网格 / CRM 数据流 / 安全产品盾牌纹样等语义化背景)。
+**优先级**:P2(设计 DNA 层级,渐进式推进)
+
+### P2-5 /about/brand 字体语言不一致(书法字)
+**证据**:`睿新致远` 使用书法字体(毛笔字),其他所有页面均为统一的黑体(思源黑体变体)。
+**判定**:设计意图?还是回归 bug?
+- 若**有意**——需要正式确认(CONTEXT.md 当前未提及书法字),并在设计文档中明确
+- 若**无意**——替换为与全站统一的字体
+**影响**:跨页面字体语言断裂;移动端尤为突兀(书法字在小尺寸可读性下降)。
+**优先级**:P2(需张翔决策——保留还是回滚)
+
+### P2-6 /privacy 品牌红触达点仅 2 处(违反 ≥3 处约束)
+**证据**:`brandCount = 2`(仅 `rgb(196, 30, 58)` 1 个变体)
+**其他 30 路由均 ≥4 处**。
+**修复**:在 /privacy 页面加 1 处品牌红触达点(建议:Section eyebrow 或结尾 CTA)。
+**优先级**:P2(硬约束违反,但只 1 页)
+
+---
+
+## 4. P3(体验优化)
+
+### P3-1 /cases 列表页缺失详情路由
+**根因**:`scripts/audit/ui-audit.mjs` 从 /cases 抓链接 0 条(列表页可能是客户端渲染,curl 抓不到)。
+**手动确认**:/cases 页面是否真有可点击的详情链接?若无—— /cases 详情页路由 `/cases/[slug]` 在静态审计中未发现真实数据。
+**影响**:Layer 3 信任层(案例)的依赖项阻塞。
+**优先级**:P0(与 P1-1 联动)
+
+### P3-2 /privacy 与 /terms 缺少 h1 验证
+**证据**:自动探针发现 /privacy 无 h1。
+**影响**:SEO + 可访问性。
+**优先级**:P3
+
+### P3-3 表格焦点无可见指示(2 页)
+**证据**:`/about` 与 `/contact` Tab 焦点元素 `outline: none` 且无 box-shadow 替代。
+**修复**:增加 `focus-visible:ring-2 focus-visible:ring-brand focus-visible:ring-offset-2`。
+**优先级**:P3(键盘可达性)
+
+### P3-4 数据大字报单页品牌红元素过多
+**证据**:`/about/brand` 用 4 个品牌红数据(2026 / 10+ / 6 / 100%),`/team` 用 3 个(2026 / 10+ / 6)。
+**CONTEXT.md 约束**:"数据高亮(≤2 个/页)"—— 这是软约束,但当前突破。
+**建议**:保留 ≤2 个品牌红数据高亮,其余用次级色。
+**优先级**:P3
+
+### P3-5 视觉审查观察:装饰元素与标题文字重叠
+**证据**:首页 mobile Hero 标题"驱"字右上角有红色装饰点压在文字边缘(红点是 Hero 背景"星座图"的一部分)。
+**影响**:轻微 —— 装饰与文字重叠在"软化设计"语境下可接受,但需确认无更多文字被装饰挤压。
+**优先级**:P3
+
+### P3-6 `/products/oa` 触控目标违规数 +1(14 vs 13)
+**证据**:其他产品详情页统一 13 处,唯独 oa 多 1 处。
+**修复**:定位 OA 页独有的 1 处 <24px 元素,可能是某个 spec 卡片或步骤列表项。
+**优先级**:P3
+
+---
+
+## 5. 静态扫描补充
+
+| 检查 | 结果 | 说明 |
+|---|---|---|
+| spring 残留 | ✅ 0 | `grep type:'spring'` = 0,IMPeccable 评分项保持 |
+| ease-ink 缓动 | ✅ 已定义 | `--ease-ink: cubic-bezier(0.22, 1, 0.36, 1)` |
+| 阴影系统 | ⚠️ 1 异常 | `solution-service-card.tsx:128` 使用 `shadow-blue-500/20`,**非设计令牌**。应迁到 `shadow-brand-hover` 或新设计令牌 |
+| 颜色硬编码 | ⚠️ 1 处 | `footer.tsx:166` `text-gray-500`(与 P1-2 同源) |
+| `text-[11px]` 极小字号 | ⚠️ 1 页 | /team "正式成立"/"研发启航" eyebrow 11px——过小不易识别,建议 ≥12px |
+
+---
+
+## 6. 修复优先级矩阵(建议)
+
+| 优先级 | 项 | 工作量 | 决策依赖 |
+|:---:|---|:---:|---|
+| P0 | P1-1 Layer 3 信任层 + P3-1 /cases 详情 | L (大型) | 内容策划 + CMS 字段 |
+| P0 | P1-2 页脚 text-hint → token | XS (1 文件) | 无 |
+| P1 | P1-3 / P1-4 大号装饰数字 aria-hidden | XS | 张翔决策(保留/调色) |
+| P1 | P2-2 移动端 Tab bar 遮挡 | XS | 无 |
+| P2 | P2-1 移动端触控目标 | S | 张翔决策(间距豁免判定) |
+| P2 | P2-3 Hero 空白 | S | 无 |
+| P2 | P2-5 书法字决策 | XS | 张翔决策 |
+| P2 | P2-4 Hero 视觉差异化 | L | 设计 + 实现(按产品逐个) |
+| P2 | P2-6 /privacy 品牌红补齐 | XS | 无 |
+| P3 | P3-2 /privacy h1 | XS | 无 |
+| P3 | P3-3 焦点可见 | XS | 无 |
+| P3 | P3-4 品牌红数据高亮 ≤2 | XS | 无 |
+| P3 | P3-5 装饰与文字重叠 | XS | 无 |
+| P3 | P3-6 /products/oa 唯一 1 处 | XS | 无 |
+| P3 | shadow-blue-500 → token | XS | 无 |
+
+**SLA 建议**:
+- P0(4 项)应在 Phase 2 首批闭环
+- P1(3 项)同批推进
+- P2(4 项)按张翔决策后分批
+- P3 顺带处理
+
+---
+
+## 7. 证据清单(可复核)
+
+```
+dogfood-ui-audit/2026-09-01T07-07-49/
+├── probes.json # 31 路由 × 3 视口全量探针数据
+├── summary.md # 自动分级汇总
+└── screenshots/
+ ├── desktop/ 31 张首屏(home/about/about-brand/cases/contact/methodology/products/team/products-erp...)
+ ├── mobile/ 31 张首屏
+ ├── tablet/ 8 张首屏(核心页)
+ └── fullpage/ 10 张长图(home/products/products-erp/solutions-finance/services-consulting/about/contact × 2 视口)
+```
+
+复跑命令:
+```bash
+node scripts/audit/ui-audit.mjs # 全站
+node scripts/audit/audit-fullpage.mjs # 核心页 fullPage
+```
+
+---
+
+## 8. 已知遗留 & 与 IMPECCABLE_AUDIT 对齐
+
+| 项 | IMPECCABLE 轮次 | 本次 | 状态 |
+|---|---|---|---|
+| text-hint 对比度 | 6/20 → 14/20 → 17/20 仍"剩 text-hint" | **P1-2 仍未修** | 1 行 CSS 可解,建议本批闭环 |
+| 字体加载 | 已修 | 本次未复查 | — |
+| 渐变文字 / 弹性 / 特效库 | 全部 0 | 确认 | ✅ |
+| 阴影系统 | 文档已定义 | 1 处 `shadow-blue-500` 不在令牌系统 | 新发现 |
+
+---
+
+## 9. 不在本次范围
+
+- admin 模块(9 个页面)—— 需登录态,建议下一轮单独审计
+- API Routes / 路由处理函数 —— 性能 + 安全审计
+- 浏览器兼容性(Firefox / WebKit / Safari)—— e2e 已有基线(`test:visual:browsers`)
+- Lighthouse / Core Web Vitals 整体 —— 已纳入 CI,本次只采样 CLS/LCP
+
+---
+
+---
+
+## 10. 修复记录(2026-09-01 同日闭环)
+
+### 10.1 Phase 1 —— 4 文件 5 处
+
+| # | 文件 | 改动 | 对应项 |
+|---|---|---|---|
+| 1 | `src/components/layout/footer.tsx:149` | `text-gray-500` → `text-text-hint`(走令牌,消除硬编码) | P1-2 |
+| 2 | `src/app/globals.css:1280-1294` | 移动端 `main` / `[role="main"]` / `footer` 增加 64px + `env(safe-area-inset-bottom)` 安全区 | P2-2 / P1-4 |
+| 3 | `src/app/(marketing)/products/product-detail-content-v3.tsx:420` | ERP 专题链接加 `py-0.5`(23px → 27px) | P3-6(已修正归因为 `/products/erp` 非 `/products/oa`) |
+| 4 | `src/components/detail/solution-service-card.tsx:128` | `shadow-blue-500/20` → `shadow-brand`(纳入令牌系统) | §5 静态扫描 |
+| 5 | 同上 CSS 块 | nav/header/footer 交互元素 mobile `min-height/width: 44px` | P2-1 部分 |
+
+**关键坑**:marketing layout 使用 `
` 而非真 `` 元素,首版 CSS 只写 `main {}` 不命中 —— 表现为 CSS chunk 已含新规则但截图 MD5 完全一致。选择器必须写 `main, [role="main"]`。且只补 `main` 不够,`footer` 在 main 之外同样被 64px Tab bar 截断。
+
+### 10.2 Phase 2 —— 5 文件 8 处(残存 P1 清零)
+
+用户决策:装饰数字走 `aria-hidden`;页脚触控认定 WCAG 2.5.8 间距豁免不改;`/about/brand` 书法字保留。
+
+**执行时对决策做了必要拆分** —— 残存 3 处 P1 不全是装饰元素:
+
+| 类别 | 位置 | 处理 | 理由 |
+|---|---|---|---|
+| A 装饰 | `about-content-v4.tsx:212`(01/02/03) | `aria-hidden="true"` | 与右侧 `value.title` 并列,无独立语义 |
+| A 装饰 | `team-content-v3.tsx:82`(01-05)、`:115`(01-03) | `aria-hidden="true"` | 同上,1.13:1 |
+| A 装饰 | `product-detail-content-v3.tsx:288`、`solution-detail-content-v3.tsx:378` | `aria-hidden="true"` | 客户名首字占位图形;探针盲区(渐变背景被跳过),同类问题一并修 |
+| B 语义 | `brand-content.tsx:129`、`team-content-v3.tsx:206` | `text-text-secondary/70` → `/80` | **真实副文案,不可 aria-hidden**;4.35:1 → 5.74:1,视觉几乎无变化 |
+| B 语义 | `brand-content.tsx:271` | `text-text-muted` → `text-text-secondary` | 「正式成立/研发启航」标签在 `bg-tertiary #F1F5F9` 上 4.34:1 → 9.45:1 |
+
+> **对决策的纠正**:B 类若按「一律 aria-hidden」处理,会把公司简介与里程碑年份从屏幕阅读器中删除 —— 用可访问性缺陷替换对比度缺陷。已按语义拆分处理。
+
+### 10.3 探针同步改造(否则证据无法闭环)
+
+`ui-audit.mjs` 原本不识别 `aria-hidden`,改完页面数字不会下降。已增加:
+
+- 对比度检测:命中 `[aria-hidden="true"]` 祖先则跳过,并累计 `decorativeSkipped` **让豁免量可见**(防止用 aria-hidden 刷绿)
+- 触控检测:命中 `aria-hidden` 的可聚焦元素计入 `ariaHiddenFocusable` 单独上报,**不静默跳过**(隐藏内容不应可聚焦,属 ARIA 误用)
+
+### 10.4 验证数据
+
+| 指标 | 修复前基线 | Phase 1 后 | Phase 2 后 |
+|---|---:|---:|---:|
+| P1 | 29 | 3 | **0** |
+| P2 | 34 | 34 | 34 |
+| P3 | 0 | 0 | 0 |
+| 有对比度违规的页面 | 29 | 3 | **0** |
+| 触控目标违规总数 | 441 | 441 | 441(用户决策:认定豁免,不改) |
+
+豁免核对(证明未刷绿):`/about` decorativeSkipped=3、`/team` =8(5+3)、`/about/brand` =0;三页 `ariaHiddenFocusable` 全为 0。
+
+门禁:`type-check` 0 error · `check:contrast` 7/7 ✅ · `eslint`(5 个受影响文件)0 error · 全站 31 路由复跑 0 P1。
+
+产物:`dogfood-ui-audit/2026-09-01T07-59-30/`(终态)、`2026-09-01T07-34-54/`(Phase 1)。
+
+---
+
+## 11. P0-1 Layer 3 定位修正(重要)
+
+审计把「Layer 3 信任层 100% 缺失」列为 P0。用户选定其为下一步后做了数据源勘察,**结论需要修正**:
+
+| Layer 3 子项 | 数据源 | 现状 |
+|---|---|---|
+| 客户案例 | `prisma/seeds/case-studies.ts` | **空数组**,注释:「公司成立于 2026-01-15,处于首批客户共创阶段,暂无对外可验证的客户案例,因此保持为空数组。待真实案例获得客户授权后再补充。」 |
+| 客户证言 | `src/lib/constants/` | 无任何 testimonial 数据文件 |
+| 资质认证 | 同上 | 无任何 certification 数据文件 |
+| 合作伙伴 | 同上 | 无任何 partner 数据文件 |
+
+**即 Layer 3 不是「忘了做」,而是刻意留空以避免编造未授权的客户案例**(公司成立仅 7 个月)。这符合 `AGENTS.md` 的「零编造」原则,但意味着:
+
+- 案例 / 证言 **不可由 AI 生成** —— 虚构客户案例属商业诚信风险
+- 该层只有拿到真实内容授权后才能建立
+
+**零编造前提下可推进的替代信任信号**(不虚构事实):
+
+1. **方法论透明度** —— `/methodology` 已有真实内容,在详情页交叉引用「我们怎么做」
+2. **团队资历** —— `/team` 有真实 strengths/culture 数据,复用为「谁来做」
+3. **成立与里程碑透明** —— `/about/brand` 有真实里程碑,可作为「坦诚的早期公司」叙事
+4. **服务承诺 / 交付标准** —— 需业务确认,属可设计项(不虚构已发生的事实)
+
+建议:把 P0-1 从「填充案例」改为「建立 Layer 3 骨架 + 接入上述 4 类真实信号」,案例/证言位留占位待授权后填充。
+
+---
+
+**报告生成时间**:2026-09-01
+**审计脚本**:`scripts/audit/ui-audit.mjs`、`scripts/audit/audit-fullpage.mjs`
+**审计员**:dogfood (Playwright + agent-browser)
\ No newline at end of file
diff --git a/scripts/audit/audit-fullpage.mjs b/scripts/audit/audit-fullpage.mjs
new file mode 100644
index 0000000..8b922e6
--- /dev/null
+++ b/scripts/audit/audit-fullpage.mjs
@@ -0,0 +1,49 @@
+#!/usr/bin/env node
+/**
+ * 补充脚本:对核心页截 fullPage 长图,用于验证四层叙事完整性。
+ */
+import { chromium } from 'playwright';
+import path from 'node:path';
+import fs from 'node:fs';
+import { fileURLToPath } from 'node:url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '../..');
+const OUT = process.argv[2] || path.join(ROOT, 'dogfood-ui-audit', '2026-09-01T07-07-49', 'screenshots', 'fullpage');
+fs.mkdirSync(OUT, { recursive: true });
+
+const TARGETS = [
+ { route: '/', vp: { width: 1440, height: 900 } },
+ { route: '/products', vp: { width: 1440, height: 900 } },
+ { route: '/products/erp', vp: { width: 1440, height: 900 } },
+ { route: '/solutions/finance', vp: { width: 1440, height: 900 } },
+ { route: '/services/consulting', vp: { width: 1440, height: 900 } },
+ { route: '/about', vp: { width: 1440, height: 900 } },
+ { route: '/contact', vp: { width: 1440, height: 900 } },
+ { route: '/', vp: { width: 390, height: 844 } },
+ { route: '/products', vp: { width: 390, height: 844 } },
+ { route: '/contact', vp: { width: 390, height: 844 } },
+];
+
+const BASE = process.env.AUDIT_BASE_URL || 'http://localhost:3000';
+const browser = await chromium.launch({ headless: true });
+
+for (const t of TARGETS) {
+ const ctx = await browser.newContext({ viewport: t.vp, deviceScaleFactor: 1 });
+ const page = await ctx.newPage();
+ const slug = (t.route === '/' ? 'home' : t.route.replace(/^\//, '').replace(/\//g, '__'));
+ const vp = t.vp.width < 500 ? 'mobile' : 'desktop';
+ const file = path.join(OUT, `${vp}-${slug}.png`);
+ try {
+ const t0 = Date.now();
+ await page.goto(BASE + t.route, { waitUntil: 'load', timeout: 60000 });
+ await page.waitForTimeout(1500);
+ await page.screenshot({ path: file, fullPage: true });
+ console.log(`✓ ${file} (${Date.now() - t0}ms)`);
+ } catch (e) {
+ console.log(`✗ ${t.route} ${t.vp.width}x${t.vp.height}: ${e.message.slice(0, 100)}`);
+ }
+ await ctx.close();
+}
+await browser.close();
+console.log(`\n输出目录:${OUT}`);
\ No newline at end of file
diff --git a/scripts/audit/contact-diag.mjs b/scripts/audit/contact-diag.mjs
new file mode 100644
index 0000000..f895598
--- /dev/null
+++ b/scripts/audit/contact-diag.mjs
@@ -0,0 +1,19 @@
+import { chromium } from 'playwright';
+const b = await chromium.launch({ headless: true });
+const ctx = await b.newContext({ viewport: { width: 1440, height: 900 } });
+const p = await ctx.newPage();
+p.on('console', m => console.log(`[${m.type()}]`, m.text().slice(0, 300)));
+p.on('pageerror', e => console.log('[PAGEERROR]', e.message.slice(0, 400)));
+p.on('requestfailed', r => console.log('[REQFAIL]', r.url().slice(0, 120), r.failure()?.errorText));
+await p.goto('http://localhost:3000/contact', { waitUntil: 'networkidle', timeout: 60000 }).catch(e => console.log('goto err', e.message.slice(0, 100)));
+await p.waitForTimeout(3000);
+const info = await p.evaluate(() => ({
+ bodyLen: document.body.innerHTML.length,
+ bodyKids: document.body.children.length,
+ h: document.documentElement.scrollHeight,
+ forms: document.querySelectorAll('form').length,
+ inputs: document.querySelectorAll('input,textarea,select').length,
+ main: document.querySelector('main')?.outerHTML.slice(0, 600) || 'NO main',
+})).catch(e => ({ err: e.message }));
+console.log('STATE', JSON.stringify(info, null, 2));
+await b.close();
\ No newline at end of file
diff --git a/scripts/audit/ui-audit.mjs b/scripts/audit/ui-audit.mjs
new file mode 100644
index 0000000..f11e0f4
--- /dev/null
+++ b/scripts/audit/ui-audit.mjs
@@ -0,0 +1,606 @@
+/**
+ * Novalon Website · UI / UX / UE Dogfood 审计采集器
+ *
+ * 用真实浏览器访问线上等价站点(本地 dev server),对每个路由执行:
+ * 1. 静态探针(页面内 evaluate):对比度 / 横向溢出 / 触控目标 / alt / 标题层级 / label / 文本截断
+ * 2. 动态探针(Node 层):console 与网络错误 / Tab 焦点可见性 / 性能与布局稳定性(CLS/LCP)
+ * 3. 多视口截图,供人工视觉审查
+ *
+ * 用法:
+ * node scripts/audit/ui-audit.mjs # 默认全站 desktop+mobile,tablet 仅核心页
+ * node scripts/audit/ui-audit.mjs --routes / /contact # 指定路由
+ * node scripts/audit/ui-audit.mjs --viewports desktop # 指定视口
+ * node scripts/audit/ui-audit.mjs --no-shot # 只跑探针不截图
+ *
+ * 输出:dogfood-ui-audit//{screenshots,probes.json,summary.md}
+ */
+
+import { chromium } from 'playwright';
+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, '../..');
+
+// ---------------------------------------------------------------- 参数解析
+function parseArgs(argv) {
+ const args = { routes: null, viewports: null, noShot: false, fullPage: false, limit: null };
+ for (let i = 2; i < argv.length; i++) {
+ const a = argv[i];
+ if (a === '--routes') args.routes = collectUntilFlag(argv, ++i);
+ else if (a === '--viewports') args.viewports = collectUntilFlag(argv, ++i);
+ 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;
+ }
+ return args;
+}
+function collectUntilFlag(argv, start) {
+ const out = [];
+ for (let i = start; i < argv.length && !argv[i].startsWith('--'); i++) out.push(argv[i]);
+ return out;
+}
+
+const BASE_URL = process.env.AUDIT_BASE_URL || 'http://localhost:3000';
+
+const VIEWPORTS = {
+ desktop: { width: 1440, height: 900 },
+ tablet: { width: 768, height: 1024 },
+ mobile: { width: 390, height: 844 },
+};
+
+/** 核心页:额外跑 tablet 视口 + 全页长图 */
+const TIER1 = new Set(['/', '/products', '/solutions', '/services', '/cases', '/contact', '/about', '/news']);
+
+/** 静态已知路由 */
+const KNOWN_ROUTES = [
+ '/',
+ '/about',
+ '/about/brand',
+ '/cases',
+ '/contact',
+ '/methodology',
+ '/news',
+ '/products',
+ '/services',
+ '/solutions',
+ '/team',
+ '/privacy',
+ '/terms',
+];
+
+/** 列表页 → 用于发现动态详情路由 */
+const LIST_ROUTES = ['/cases', '/news', '/products', '/services', '/solutions'];
+
+// ---------------------------------------------------------------- 页面内探针
+const PAGE_PROBE = `(() => {
+ const out = {
+ contrast: [], overflow: { doc: null, offenders: [] }, touch: [], images: [],
+ headings: [], labels: [], clipped: [], colors: {}, meta: {}, interactables: 0,
+ };
+
+ // ---- 颜色工具 ----
+ function parseColor(str) {
+ if (!str) return null;
+ const s = String(str).trim();
+ if (s === 'transparent' || s === 'rgba(0, 0, 0, 0)') return { r:0,g:0,b:0,a:0 };
+ let m = s.match(/^rgba?\\(\\s*([\\d.]+)[,\\s]+([\\d.]+)[,\\s]+([\\d.]+)(?:[,\\s/]+([\\d.]+))?\\s*\\)$/i);
+ if (m) {
+ return { r:+m[1], g:+m[2], b:+m[3], a: m[4] === undefined ? 1 : +m[4] };
+ }
+ return null;
+ }
+ function composite(fg, bg) {
+ if (fg.a >= 1) return { r:fg.r, g:fg.g, b:fg.b, a:1 };
+ return {
+ r: fg.r*fg.a + bg.r*(1-fg.a),
+ g: fg.g*fg.a + bg.g*(1-fg.a),
+ b: fg.b*fg.a + bg.b*(1-fg.a),
+ a: 1,
+ };
+ }
+ function lum(c) {
+ const f = v => { v/=255; return v <= 0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055, 2.4); };
+ return 0.2126*f(c.r) + 0.7152*f(c.g) + 0.0722*f(c.b);
+ }
+ function ratio(a, b) {
+ const l1 = lum(a), l2 = lum(b);
+ const hi = Math.max(l1,l2), lo = Math.min(l1,l2);
+ return (hi + 0.05) / (lo + 0.05);
+ }
+ function effectiveBg(el) {
+ // 向上合成背景,返回不透明背景色(遇到渐变/图片则标记 unknown)
+ const stack = [];
+ let cur = el;
+ let guard = 0;
+ let unknown = false;
+ while (cur && cur.nodeType === 1 && guard++ < 40) {
+ const cs = getComputedStyle(cur);
+ if (cs.backgroundImage && cs.backgroundImage !== 'none') unknown = true;
+ const bc = parseColor(cs.backgroundColor);
+ if (bc && bc.a > 0) { stack.push(bc); if (bc.a >= 1) break; }
+ cur = cur.parentElement;
+ }
+ let base = { r:255, g:255, b:255, a:1 };
+ for (let i = stack.length - 1; i >= 0; i--) base = composite(stack[i], base);
+ return { color: base, unknown };
+ }
+ function isVisible(el) {
+ const cs = getComputedStyle(el);
+ if (cs.display === 'none' || cs.visibility === 'hidden') return false;
+ if (parseFloat(cs.opacity) === 0) return false;
+ const r = el.getBoundingClientRect();
+ if (r.width < 1 || r.height < 1) return false;
+ return true;
+ }
+ function path(el) {
+ let s = el.tagName.toLowerCase();
+ if (el.id) s += '#' + el.id;
+ const cls = (el.className && typeof el.className === 'string')
+ ? el.className.trim().split(/\\s+/).slice(0,2).join('.') : '';
+ if (cls) s += '.' + cls;
+ return s;
+ }
+
+ // ---- 1. 对比度 ----
+ const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT);
+ let node, seen = 0, decorativeSkipped = 0;
+ while ((node = walker.nextNode()) && seen < 4000) {
+ const text = node.nodeValue && node.nodeValue.trim();
+ if (!text || text.length < 1) continue;
+ const el = node.parentElement;
+ if (!el || !isVisible(el)) continue;
+ // 纯装饰豁免:WCAG 1.4.3 不适用于装饰性内容(元素被 aria-hidden 标记)。
+ // 计入 decorativeSkipped 让豁免量可见 —— 防止用 aria-hidden 刷绿对比度数字。
+ if (el.closest('[aria-hidden="true"]')) { decorativeSkipped++; continue; }
+ const cs = getComputedStyle(el);
+ const fgRaw = parseColor(cs.color);
+ if (!fgRaw || fgRaw.a === 0) continue;
+ const bgInfo = effectiveBg(el);
+ if (bgInfo.unknown) continue; // 渐变/图片背景:交给人工复核
+ const fg = composite(fgRaw, bgInfo.color);
+ const size = parseFloat(cs.fontSize) || 16;
+ const weight = parseInt(cs.fontWeight, 10) || 400;
+ const isLarge = size >= 24 || (size >= 18.66 && weight >= 700);
+ const need = isLarge ? 3.0 : 4.5;
+ const r = ratio(fg, bgInfo.color);
+ seen++;
+ if (r < need) {
+ out.contrast.push({
+ sel: path(el),
+ text: text.slice(0, 60),
+ ratio: +r.toFixed(2),
+ need,
+ fontSize: size,
+ weight,
+ fg: cs.color,
+ bg: 'rgb(' + Math.round(bgInfo.color.r) + ', ' + Math.round(bgInfo.color.g) + ', ' + Math.round(bgInfo.color.b) + ')',
+ });
+ }
+ }
+ out.contrast = out.contrast.slice(0, 40);
+ out.decorativeSkipped = decorativeSkipped;
+
+ // ---- 2. 横向溢出 ----
+ const vw = document.documentElement.clientWidth;
+ const de = document.documentElement;
+ if (de.scrollWidth > vw + 1) {
+ out.overflow.doc = { scrollWidth: de.scrollWidth, clientWidth: vw, delta: de.scrollWidth - vw };
+ const all = document.querySelectorAll('body *');
+ const list = [];
+ for (const el of all) {
+ if (!isVisible(el)) continue;
+ const cs = getComputedStyle(el);
+ if (cs.position === 'fixed') continue;
+ const r = el.getBoundingClientRect();
+ if (r.right > vw + 1 || r.left < -1) {
+ list.push({ sel: path(el), left: Math.round(r.left), right: Math.round(r.right), w: Math.round(r.width) });
+ if (list.length >= 25) break;
+ }
+ }
+ out.overflow.offenders = list;
+ }
+
+ // ---- 3. 触控目标 (WCAG 2.2 AA 2.5.8 最小 24x24) ----
+ const interactive = document.querySelectorAll('a[href], button, input, select, textarea, [role="button"], [tabindex]:not([tabindex="-1"])');
+ out.interactables = interactive.length;
+ for (const el of interactive) {
+ if (!isVisible(el)) continue;
+ // aria-hidden 容器内的可聚焦元素属 ARIA 误用(隐藏内容不应能被聚焦)。
+ // 单独计数上报,而不是静默跳过 —— 否则装饰标记会掩盖真实缺陷。
+ if (el.closest('[aria-hidden="true"]')) {
+ out.ariaHiddenFocusable = (out.ariaHiddenFocusable || 0) + 1;
+ continue;
+ }
+ const r = el.getBoundingClientRect();
+ if (r.width < 24 || r.height < 24) {
+ const hasName = !!(el.getAttribute('aria-label') || el.getAttribute('title') ||
+ (el.textContent && el.textContent.trim()) || el.getAttribute('placeholder'));
+ out.touch.push({
+ sel: path(el), w: Math.round(r.width), h: Math.round(r.height),
+ text: (el.textContent || '').trim().slice(0, 40) || null,
+ hasAccessibleName: hasName,
+ });
+ if (out.touch.length >= 30) break;
+ }
+ }
+
+ // ---- 4. 图片 alt ----
+ for (const img of document.querySelectorAll('img')) {
+ if (!isVisible(img)) continue;
+ const alt = img.getAttribute('alt');
+ if (alt === null) {
+ out.images.push({ sel: path(img), src: (img.getAttribute('src')||'').slice(0,110), issue: 'missing-alt' });
+ } else if (alt.trim() === '' && !img.getAttribute('role') && img.getAttribute('aria-hidden') === null) {
+ out.images.push({ sel: path(img), src: (img.getAttribute('src')||'').slice(0,110), issue: 'empty-alt-not-marked-decorative' });
+ }
+ }
+ out.images = out.images.slice(0, 20);
+
+ // ---- 5. 标题层级 ----
+ const hs = [...document.querySelectorAll('h1,h2,h3,h4,h5,h6')].filter(isVisible);
+ let prev = 0;
+ for (const h of hs) {
+ const lv = +h.tagName[1];
+ const txt = (h.textContent || '').trim().slice(0, 60);
+ if (prev && lv > prev + 1) out.headings.push({ sel: path(h), level: lv, prev, text: txt, issue: 'skip-level' });
+ if (lv === 1) out.headings.push({ sel: path(h), level: 1, text: txt, issue: 'h1' });
+ prev = lv;
+ }
+ const h1Count = hs.filter(h => h.tagName === 'H1').length;
+ out.headings = out.headings.filter(x => x.issue !== 'h1').slice(0, 12);
+ out.headingSummary = { h1Count, total: hs.length,
+ sequence: hs.slice(0, 25).map(h => +h.tagName[1]) };
+
+ // ---- 6. 表单 label ----
+ for (const f of document.querySelectorAll('input:not([type=hidden]), textarea, select')) {
+ if (!isVisible(f)) continue;
+ const id = f.id;
+ const hasLabel = id ? !!document.querySelector('label[for="' + CSS.escape(id) + '"]') : false;
+ const wrapped = !!f.closest('label');
+ const aria = !!(f.getAttribute('aria-label') || f.getAttribute('aria-labelledby'));
+ if (!hasLabel && !wrapped && !aria) {
+ out.labels.push({ sel: path(f), type: f.getAttribute('type') || f.tagName.toLowerCase(),
+ name: f.getAttribute('name') || null, placeholder: f.getAttribute('placeholder') || null });
+ }
+ }
+ out.labels = out.labels.slice(0, 20);
+
+ // ---- 7. 文本被裁剪 (overflow hidden 且内容溢出,且非 ellipsis) ----
+ let clipCount = 0;
+ for (const el of document.querySelectorAll('body *')) {
+ if (clipCount >= 15) break;
+ if (!isVisible(el)) continue;
+ const cs = getComputedStyle(el);
+ if (cs.overflow === 'visible' || cs.overflowY === 'visible') continue;
+ if (el.scrollHeight > el.clientHeight + 4 && el.clientHeight > 0) {
+ if (cs.textOverflow === 'ellipsis') continue;
+ // 有意的 line-clamp 多行截断不算缺陷
+ if (cs.webkitLineClamp && cs.webkitLineClamp !== 'none') continue;
+ if (cs.display === '-webkit-box') continue;
+ if (el.children.length > 0 && !el.textContent.trim()) continue;
+ out.clipped.push({ sel: path(el), scrollH: el.scrollHeight, clientH: el.clientHeight,
+ text: (el.textContent||'').trim().slice(0, 50) });
+ clipCount++;
+ }
+ }
+
+ // ---- 8. 颜色使用分布(设计令牌一致性 / 品牌红触达点)----
+ const freq = {};
+ const bump = (k) => { if (k) freq[k] = (freq[k] || 0) + 1; };
+ for (const el of document.querySelectorAll('body *')) {
+ if (!isVisible(el)) continue;
+ const cs = getComputedStyle(el);
+ bump(cs.color);
+ bump(cs.backgroundColor);
+ if (cs.borderTopWidth !== '0px') bump(cs.borderTopColor);
+ }
+ out.colors = Object.entries(freq).sort((a,b) => b[1]-a[1]).slice(0, 18)
+ .map(([c, n]) => ({ color: c, count: n }));
+
+ // ---- 9. 元信息 ----
+ out.meta = {
+ title: document.title,
+ titleLen: (document.title || '').length,
+ lang: document.documentElement.lang || null,
+ hasViewportMeta: !!document.querySelector('meta[name="viewport"]'),
+ h1Text: (document.querySelector('h1')?.textContent || '').trim().slice(0, 90) || null,
+ textNodes: seen,
+ scripts: document.querySelectorAll('script').length,
+ imgCount: document.querySelectorAll('img').length,
+ linkCount: document.querySelectorAll('a[href]').length,
+ };
+
+ return out;
+})()`;
+
+// ---------------------------------------------------------------- 主流程
+async function discoverRoutes(page) {
+ const routes = [...KNOWN_ROUTES];
+ const found = new Set();
+ for (const list of LIST_ROUTES) {
+ try {
+ await page.goto(BASE_URL + list, { waitUntil: 'domcontentloaded', timeout: 45000 });
+ await page.waitForTimeout(1200);
+ const hrefs = await page.$$eval('a[href]', as => as.map(a => a.getAttribute('href')));
+ for (const h of hrefs) {
+ if (!h || !h.startsWith('/')) continue;
+ if (h.startsWith('/admin') || h === '/test-error-tracking') continue;
+ // 同一前缀下的详情页
+ if (h.startsWith(list + '/') && h.split('/').length >= 3) found.add(h);
+ }
+ } catch (e) {
+ console.warn(` [discover] ${list} 失败: ${e.message.slice(0, 80)}`);
+ }
+ }
+ routes.push(...[...found].sort());
+ return [...new Set(routes)];
+}
+
+async function auditRoute(page, ctx, route, viewports, args, outDir) {
+ const slug = route === '/' ? 'home' : route.replace(/^\//, '').replace(/\//g, '__');
+ const result = { route, viewports: {} };
+
+ for (const vpName of viewports) {
+ const vp = VIEWPORTS[vpName];
+ await page.setViewportSize({ width: vp.width, height: vp.height });
+
+ const consoleErrors = [];
+ const pageErrors = [];
+ const failedRequests = [];
+ 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,
+ });
+ page.on('console', onConsole);
+ page.on('pageerror', onPageErr);
+ page.on('requestfailed', onReqFail);
+
+ let navStatus = null;
+ let navError = null;
+ const t0 = Date.now();
+ try {
+ const resp = await page.goto(BASE_URL + route, { waitUntil: 'load', timeout: 60000 });
+ navStatus = resp ? resp.status() : null;
+ } catch (e) {
+ navError = e.message.slice(0, 160);
+ }
+ // 给动效与懒加载留出时间
+ await page.waitForTimeout(1500);
+ const loadMs = Date.now() - t0;
+
+ const vpResult = { navStatus, navError, loadMs };
+
+ if (!navError) {
+ // 静态探针
+ try {
+ vpResult.probe = await page.evaluate(PAGE_PROBE);
+ } catch (e) {
+ vpResult.probeError = e.message.slice(0, 200);
+ }
+
+ // 性能与布局稳定性
+ try {
+ vpResult.perf = await page.evaluate(() => {
+ const nav = performance.getEntriesByType('navigation')[0] || {};
+ const paint = {};
+ for (const p of performance.getEntriesByType('paint')) paint[p.name] = Math.round(p.startTime);
+ return {
+ ttfb: nav.responseStart ? Math.round(nav.responseStart) : null,
+ domContentLoaded: nav.domContentLoadedEventEnd ? Math.round(nav.domContentLoadedEventEnd) : null,
+ load: nav.loadEventEnd ? Math.round(nav.loadEventEnd) : null,
+ fcp: paint['first-contentful-paint'] ?? null,
+ lcp: window.__audit?.lcp ? Math.round(window.__audit.lcp) : null,
+ cls: window.__audit?.cls ? +window.__audit.cls.toFixed(4) : 0,
+ transferSize: nav.transferSize ?? null,
+ };
+ });
+ } catch { /* ignore */ }
+
+ // Tab 焦点可见性(UE 键盘可达性采样)
+ if (vpName === 'desktop') {
+ try {
+ const focusSamples = [];
+ for (let i = 0; i < 10; i++) {
+ await page.keyboard.press('Tab');
+ const s = await page.evaluate(() => {
+ const el = document.activeElement;
+ if (!el || el === document.body) return null;
+ const cs = getComputedStyle(el);
+ const r = el.getBoundingClientRect();
+ return {
+ tag: el.tagName.toLowerCase(),
+ text: (el.textContent || '').trim().slice(0, 30) || el.getAttribute('aria-label') || null,
+ outlineStyle: cs.outlineStyle, outlineWidth: cs.outlineWidth,
+ boxShadow: cs.boxShadow === 'none' ? null : cs.boxShadow.slice(0, 60),
+ inViewport: r.top >= -50 && r.bottom <= window.innerHeight + 50,
+ };
+ });
+ if (s) focusSamples.push(s);
+ }
+ vpResult.focus = focusSamples;
+ } catch (e) { vpResult.focusError = e.message.slice(0, 120); }
+ }
+
+ // 截图
+ if (!args.noShot) {
+ const shotDir = path.join(outDir, 'screenshots', vpName);
+ fs.mkdirSync(shotDir, { recursive: true });
+ const file = path.join(shotDir, slug + '.png');
+ await page.screenshot({ path: file, fullPage: args.fullPage && TIER1.has(route) });
+ vpResult.screenshot = path.relative(ROOT, file);
+ }
+ }
+
+ vpResult.consoleErrors = consoleErrors.slice(0, 12);
+ vpResult.pageErrors = pageErrors.slice(0, 8);
+ vpResult.failedRequests = failedRequests.slice(0, 12);
+
+ page.off('console', onConsole);
+ page.off('pageerror', onPageErr);
+ page.off('requestfailed', onReqFail);
+
+ result.viewports[vpName] = vpResult;
+ }
+
+ return result;
+}
+
+function scoreRoute(r) {
+ const issues = [];
+ const d = r.viewports.desktop || {};
+ const m = r.viewports.mobile || {};
+ const p = d.probe || {};
+ const pm = m.probe || {};
+
+ if (d.navError) issues.push({ sev: 'P1', cat: '可用性', msg: `desktop 加载失败: ${d.navError}` });
+ if (m.navError) issues.push({ sev: 'P1', cat: '可用性', msg: `mobile 加载失败: ${m.navError}` });
+ 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 || [])];
+ if (cerr.length) issues.push({ sev: 'P2', cat: 'UE/稳定性', msg: `console 错误 ${cerr.length} 条`, detail: cerr.slice(0, 3) });
+ 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 || [])];
+ if (rfail.length) issues.push({ sev: 'P2', cat: 'UE/稳定性', msg: `请求失败 ${rfail.length} 条`, detail: rfail.slice(0, 3) });
+
+ const cr = [...(p.contrast || []), ...(pm.contrast || [])];
+ if (cr.length) {
+ const worst = cr.reduce((a, b) => (b.ratio < a.ratio ? b : a), cr[0]);
+ issues.push({ sev: 'P1', cat: 'UI/可访问性', msg: `对比度违规 ${cr.length} 处(最低 ${worst.ratio}:1,需 ${worst.need}:1)`,
+ detail: cr.slice(0, 4).map(c => `${c.sel} "${c.text}" ${c.ratio}:1 (fg ${c.fg} / bg ${c.bg})`) });
+ }
+
+ const ovM = pm.overflow?.doc || m.probe?.overflow?.doc;
+ if (ovM) issues.push({ sev: 'P1', cat: 'UX/响应式', msg: `移动端横向溢出 ${ovM.delta}px (scrollWidth ${ovM.scrollWidth} > ${ovM.clientWidth})`,
+ detail: (pm.overflow?.offenders || []).slice(0, 5).map(o => `${o.sel} right=${o.right} w=${o.w}`) });
+ const ovD = d.probe?.overflow?.doc;
+ if (ovD) issues.push({ sev: 'P2', cat: 'UX/响应式', msg: `桌面端横向溢出 ${ovD.delta}px`,
+ detail: (d.probe?.overflow?.offenders || []).slice(0, 5).map(o => `${o.sel} right=${o.right} w=${o.w}`) });
+
+ const touchM = (pm.touch || []).filter(t => t.h < 24 || t.w < 24);
+ if (touchM.length) issues.push({ sev: 'P2', cat: 'UE/触控', msg: `移动端触控目标 <24px ${touchM.length} 个`,
+ detail: touchM.slice(0, 4).map(t => `${t.sel} ${t.w}x${t.h} "${t.text || ''}"`) });
+
+ const imgs = [...(p.images || []), ...(pm.images || [])];
+ if (imgs.length) issues.push({ sev: 'P2', cat: 'UI/可访问性', msg: `图片 alt 问题 ${imgs.length} 个`, detail: imgs.slice(0, 4).map(i => `${i.issue}: ${i.src}`) });
+
+ const hd = p.headingSummary || {};
+ if (hd.h1Count === 0) issues.push({ sev: 'P2', cat: 'UX/信息架构', msg: '页面缺少 h1' });
+ if (hd.h1Count > 1) issues.push({ sev: 'P2', cat: 'UX/信息架构', msg: `页面存在 ${hd.h1Count} 个 h1` });
+ if ((p.headings || []).length) issues.push({ sev: 'P3', cat: 'UX/信息架构', msg: `标题跳级 ${p.headings.length} 处`,
+ detail: p.headings.slice(0, 4).map(h => `h${h.prev} → h${h.level} "${h.text}"`) });
+
+ const lbl = [...(p.labels || []), ...(pm.labels || [])];
+ if (lbl.length) issues.push({ sev: 'P2', cat: 'UE/表单', msg: `表单控件缺 label ${lbl.length} 个`, detail: lbl.slice(0, 4).map(l => `${l.sel} name=${l.name || '-'}`) });
+
+ const clip = [...(p.clipped || []), ...(pm.clipped || [])];
+ if (clip.length) issues.push({ sev: 'P2', cat: 'UI/排版', msg: `文本被容器裁剪 ${clip.length} 处`, detail: clip.slice(0, 4).map(c => `${c.sel} ${c.scrollH}>${c.clientH} "${c.text}"`) });
+
+ 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` });
+ 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) {
+ const lines = [];
+ const counts = { P1: 0, P2: 0, P3: 0 };
+ const byCat = {};
+ for (const r of all) {
+ for (const i of r.issues) {
+ counts[i.sev] = (counts[i.sev] || 0) + 1;
+ byCat[i.cat] = (byCat[i.cat] || 0) + 1;
+ }
+ }
+ lines.push('# Dogfood UI/UX/UE 审计 · 自动探针汇总', '');
+ lines.push(`生成时间:${new Date().toISOString()}`);
+ lines.push(`审计路由:${all.length} 个`, '');
+ lines.push('## 问题计数', '');
+ lines.push('| 等级 | 数量 |', '|---|:---:|');
+ lines.push(`| P1 | ${counts.P1} |`, `| P2 | ${counts.P2} |`, `| P3 | ${counts.P3} |`, '');
+ lines.push('## 按维度分布', '');
+ lines.push('| 维度 | 数量 |', '|---|:---:|');
+ for (const [c, n] of Object.entries(byCat).sort((a, b) => b[1] - a[1])) lines.push(`| ${c} | ${n} |`);
+ lines.push('', '## 逐路由明细', '');
+ for (const r of all) {
+ if (!r.issues.length) continue;
+ lines.push(`### ${r.route}`, '');
+ for (const i of r.issues) {
+ lines.push(`- **[${i.sev}] ${i.cat}** — ${i.msg}`);
+ for (const d of (i.detail || [])) lines.push(` - \`${d}\``);
+ }
+ lines.push('');
+ }
+ return lines.join('\n');
+}
+
+async function main() {
+ const args = parseArgs(process.argv);
+ const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
+ const outDir = path.join(ROOT, 'dogfood-ui-audit', stamp);
+ fs.mkdirSync(outDir, { recursive: true });
+
+ const browser = await chromium.launch({ headless: true });
+ const context = await browser.newContext({
+ viewport: VIEWPORTS.desktop,
+ deviceScaleFactor: 1,
+ ignoreHTTPSErrors: true,
+ // 不加载已有登录态:审计的是访客视角
+ });
+ // 注入 LCP / CLS 观察者
+ await context.addInitScript(() => {
+ window.__audit = { lcp: 0, cls: 0 };
+ try {
+ new PerformanceObserver(l => { for (const e of l.getEntries()) window.__audit.lcp = e.startTime; })
+ .observe({ type: 'largest-contentful-paint', buffered: true });
+ new PerformanceObserver(l => { for (const e of l.getEntries()) if (!e.hadRecentInput) window.__audit.cls += e.value; })
+ .observe({ type: 'layout-shift', buffered: true });
+ } catch { /* 老浏览器忽略 */ }
+ });
+ const page = await context.newPage();
+
+ let routes = args.routes;
+ if (!routes) {
+ console.log('→ 发现路由中...');
+ routes = await discoverRoutes(page);
+ }
+ if (args.limit) routes = routes.slice(0, args.limit);
+ console.log(`→ 共 ${routes.length} 个路由:\n ${routes.join('\n ')}\n`);
+
+ const all = [];
+ for (let i = 0; i < routes.length; i++) {
+ const route = routes[i];
+ const vpNames = args.viewports || (TIER1.has(route) ? ['desktop', 'tablet', 'mobile'] : ['desktop', 'mobile']);
+ process.stdout.write(`[${i + 1}/${routes.length}] ${route} (${vpNames.join(',')}) ... `);
+ try {
+ const r = await auditRoute(page, context, route, vpNames, args, outDir);
+ r.issues = scoreRoute(r);
+ all.push(r);
+ const sev = r.issues.reduce((a, x) => { a[x.sev] = (a[x.sev] || 0) + 1; return a; }, {});
+ console.log(`OK ${JSON.stringify(sev)}`);
+ } catch (e) {
+ console.log(`FAILED ${e.message.slice(0, 100)}`);
+ all.push({ route, error: e.message.slice(0, 200), issues: [{ sev: 'P1', cat: '可用性', msg: `采集失败: ${e.message.slice(0, 120)}` }] });
+ }
+ }
+
+ fs.writeFileSync(path.join(outDir, 'probes.json'), JSON.stringify(all, null, 2));
+ fs.writeFileSync(path.join(outDir, 'summary.md'), buildSummary(all));
+ console.log(`\n✓ 输出目录:${outDir}`);
+ await browser.close();
+}
+
+main().catch(e => { console.error('FATAL', e); process.exit(1); });
diff --git a/scripts/audit/verify-tabbar.mjs b/scripts/audit/verify-tabbar.mjs
new file mode 100644
index 0000000..6d881c9
--- /dev/null
+++ b/scripts/audit/verify-tabbar.mjs
@@ -0,0 +1,25 @@
+import { chromium } from 'playwright';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const ROOT = path.resolve(__dirname, '../..');
+const OUT = '/Users/zhangxiang/Codes/Novalon/novalon-website/dogfood-ui-audit/2026-09-01T07-34-54/screenshots/verify';
+import fs from 'node:fs';
+fs.mkdirSync(OUT, { recursive: true });
+
+const browser = await chromium.launch({ headless: true });
+for (const r of ['/', '/about', '/products/erp']) {
+ const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } });
+ const p = await ctx.newPage();
+ await p.goto('http://localhost:3000' + r, { waitUntil: 'load' });
+ await p.waitForTimeout(1500);
+ const slug = r === '/' ? 'home' : r.replace(/^\//, '').replace(/\//g, '__');
+ await p.screenshot({ path: path.join(OUT, `mobile-${slug}-fullpage.png`), fullPage: true });
+ // 滚到底再截首屏看底部
+ await p.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
+ await p.waitForTimeout(500);
+ await p.screenshot({ path: path.join(OUT, `mobile-${slug}-bottom.png`) });
+ console.log(`✓ mobile ${r} fullPage + bottom`);
+ await ctx.close();
+}
+await browser.close();
\ No newline at end of file