` 而非真 `
` 元素,首版 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