新增可复用的全站 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
607 lines
26 KiB
JavaScript
607 lines
26 KiB
JavaScript
/**
|
||
* 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/<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); });
|