Files
novalon-website/scripts/audit/ui-audit.mjs
T
zhangxiang c22e9154c4 chore(audit): add reusable dogfood UI/UX/UE audit toolkit and 2026-09-01 report
新增可复用的全站 dogfood 审计工具链(Playwright,31 路由约 2 分钟跑完):

- scripts/audit/ui-audit.mjs —— 主审计器。9 维自动化探针:对比度(WCAG
  相对亮度 + alpha 合成 + 大文本 3:1 / 小文本 4.5:1)、横向溢出、触控目标
  (WCAG 2.2 AA 2.5.8 24x24)、img alt、标题层级、form label、文本截断、
  颜色频次(品牌红覆盖)、性能(LCP + CLS)。支持多视口采集与 P1/P2/P3 自动分级。
- scripts/audit/audit-fullpage.mjs —— 核心页 fullPage 长图(首屏看不全叙事结构)
- scripts/audit/verify-tabbar.mjs —— 移动端 Tab bar 遮挡修复验证
- scripts/audit/contact-diag.mjs —— 单页诊断(console/pageerror/requestfailed 捕获)

防刷绿机制:对比度探针命中 aria-hidden 祖先时跳过,但累计 decorativeSkipped
让豁免量可见;触控探针把 aria-hidden 内的可聚焦元素计入 ariaHiddenFocusable
单独上报,不静默跳过。

已知盲区(需在报告中人工补判):effectiveBg 遇到渐变或图片背景返回 unknown
并跳过整节点 —— 详情页 hero 的装饰数字因此从未被自动报出。

.gitignore 增加 dogfood-ui-audit/(采集产物单轮可达 39MB,只提交脚本与报告)。

Refs: UI_UX_UE_AUDIT_2026-09-01.md
2026-09-01 16:13:32 +08:00

607 lines
26 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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+mobiletablet 仅核心页
* 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/<timestamp>/{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); });