chore: add UI audits and reconcile release docs

This commit is contained in:
key
2026-07-29 16:10:46 +08:00
parent d08d3fb921
commit 6ba7df863f
16 changed files with 770 additions and 36 deletions
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env node
/**
* ui-rendered-audit.mjs — 渲染态视觉秩序审计(与静态审计互补)
*
* 静态脚本证明"源码写了什么",本脚本证明"用户实际看到什么":
* 在 390×844 移动视口的真实 Chromium 中逐页渲染 H5 构建产物,
* 用 getComputedStyle 收集每个可见元素的生效颜色/背景/字号/圆角,
* 与 base.css token palette(共享 lib/ui-tokens.mjs)比对;
* 同时检查横向溢出、控制台错误,并留存截图证据。
*
* 用法:
* node scripts/ui-rendered-audit.mjs # 静态服务 dist/build/h5(需先 build:h5)
* node scripts/ui-rendered-audit.mjs --url http://127.0.0.1:5175/h5/ # 审计 dev server
* node scripts/ui-rendered-audit.mjs --routes pages/auth/login/index,pages/user/today/index
*
* 输出:output/ui-ux/rendered-audit-latest.{md,json} + output/ui-ux/rendered-shots/*.png
* 注意:默认静态服务不带后端代理,页面以未登录/错误/空态渲染——这正是三态覆盖的一部分;
* 认证态渲染需 --url 指向带后端的入口并另行登录(见报告说明)。
*/
import http from 'node:http';
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync, rmSync, mkdtempSync } from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import {
mobileRoot,
normalizeComputedColor,
NEAR_MISS_MAX_DIST,
loadTokens,
} from './lib/ui-tokens.mjs';
const { palette, fontScale, radiusTokens, nearestPalette } = loadTokens();
// 已知的框架默认样式来源,照常统计但单独标注,不计入近色漂移
const KNOWN_FRAMEWORK_COLORS = {
'#000000': 'uni-app 内置组件默认色(uni-button/uni-picker 等,业务未定制时生效)',
};
// ---------- 参数 ----------
const args = process.argv.slice(2);
const argValue = (name) => {
const i = args.indexOf(name);
return i >= 0 ? args[i + 1] : undefined;
};
const VIEW_W = Number(argValue('--width') || 390);
const VIEW_H = Number(argValue('--height') || 844);
const outDir = path.resolve(argValue('--out') || path.join(mobileRoot, '..', 'output', 'ui-ux'));
const DEFAULT_ROUTES = [
'pages/auth/login/index',
'pages/user/today/index',
'pages/user/sop/index',
'pages/user/practice/index',
'pages/candidate/index/index',
'pages/supervisor/index/index',
];
const routes = (argValue('--routes') || DEFAULT_ROUTES.join(',')).split(',').map((s) => s.trim()).filter(Boolean);
// ---------- H5 静态服务(SPA 回退) ----------
const MIME = {
'.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp',
'.svg': 'image/svg+xml', '.woff': 'font/woff', '.woff2': 'font/woff2', '.ttf': 'font/ttf',
'.mp3': 'audio/mpeg', '.ico': 'image/x-icon',
};
function serveH5() {
const h5Dir = path.join(mobileRoot, 'dist', 'build', 'h5');
if (!existsSync(path.join(h5Dir, 'index.html'))) {
throw new Error(`未找到 H5 构建产物 ${h5Dir},请先 npm run build:h5,或用 --url 指向已有服务`);
}
const server = http.createServer((req, res) => {
let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
if (p.startsWith('/h5/')) p = p.slice(3);
if (p === '/' || p === '') p = '/index.html';
let file = path.join(h5Dir, p);
if (!file.startsWith(h5Dir) || !existsSync(file) || !file.includes('.')) {
file = path.join(h5Dir, 'index.html'); // SPA fallback
}
res.writeHead(200, { 'Content-Type': MIME[path.extname(file)] || 'application/octet-stream' });
res.end(readFileSync(file));
});
return new Promise((resolve) => {
server.listen(0, '127.0.0.1', () => resolve({ server, url: `http://127.0.0.1:${server.address().port}/h5/` }));
});
}
// ---------- Chrome 定位与启动 ----------
function findChrome() {
if (process.env.CHROME_BIN) return process.env.CHROME_BIN;
const candidates = [
'C:/Program Files/Google/Chrome/Application/chrome.exe',
'C:/Program Files (x86)/Google/Chrome/Application/chrome.exe',
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/usr/bin/google-chrome',
];
for (const c of candidates) if (existsSync(c)) return c;
const pwDir = path.join(process.env.LOCALAPPDATA || '', 'ms-playwright');
if (existsSync(pwDir)) {
for (const entry of readdirSync(pwDir).filter((d) => d.startsWith('chromium_headless_shell')).sort().reverse()) {
const bin = path.join(pwDir, entry, 'chrome-win', 'headless_shell.exe');
if (existsSync(bin)) return bin;
}
for (const entry of readdirSync(pwDir).filter((d) => d.startsWith('chromium-')).sort().reverse()) {
const bin = path.join(pwDir, entry, 'chrome-win', 'chrome.exe');
if (existsSync(bin)) return bin;
}
}
throw new Error('未找到 Chrome/Chromium,请设置 CHROME_BIN');
}
async function launchChrome() {
const chromeBin = findChrome();
const userDataDir = mkdtempSync(path.join(os.tmpdir(), 'ui-rendered-audit-'));
const proc = spawn(chromeBin, [
'--headless=new', '--disable-gpu', '--no-first-run', '--ignore-certificate-errors',
'--remote-debugging-port=0', `--user-data-dir=${userDataDir}`, 'about:blank',
], { stdio: ['ignore', 'ignore', 'pipe'] });
const port = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('Chrome DevTools 启动超时')), 10000);
proc.stderr.on('data', (chunk) => {
const m = String(chunk).match(/DevTools listening on ws:\/\/127\.0\.0\.1:(\d+)\//);
if (m) { clearTimeout(timer); resolve(Number(m[1])); }
});
proc.on('exit', (code) => reject(new Error(`Chrome 退出: ${code}`)));
});
return { proc, port, userDataDir, chromeBin };
}
async function connectPage(port) {
const targets = await (await fetch(`http://127.0.0.1:${port}/json`)).json();
const wsUrl = targets.find((t) => t.type === 'page')?.webSocketDebuggerUrl;
const ws = new WebSocket(wsUrl);
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
let id = 0;
const pending = new Map();
const listeners = [];
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.id && pending.has(msg.id)) {
const { resolve, reject } = pending.get(msg.id);
pending.delete(msg.id);
msg.error ? reject(new Error(msg.error.message)) : resolve(msg.result || {});
return;
}
listeners.forEach((fn) => fn(msg));
};
return {
send(method, params = {}) {
const callId = ++id;
ws.send(JSON.stringify({ id: callId, method, params }));
return new Promise((resolve, reject) => pending.set(callId, { resolve, reject }));
},
onEvent(fn) {
listeners.push(fn);
return () => {
const index = listeners.indexOf(fn);
if (index >= 0) listeners.splice(index, 1);
};
},
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
close() { try { ws.close(); } catch {} },
};
}
// ---------- 页面内采集器 ----------
const COLLECTOR = `(() => {
const acc = { colors: {}, backgrounds: {}, fontSizes: {}, fontWeights: {}, radii: {}, elements: 0 };
const bump = (o, k) => { o[k] = (o[k] || 0) + 1; };
const walk = (root) => {
for (const el of root.querySelectorAll('*')) {
const cs = getComputedStyle(el);
if (cs.display === 'none' || cs.visibility === 'hidden') continue;
const r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) continue;
acc.elements++;
bump(acc.colors, cs.color);
bump(acc.backgrounds, cs.backgroundColor);
bump(acc.fontSizes, cs.fontSize);
bump(acc.fontWeights, cs.fontWeight);
for (const p of ['borderTopLeftRadius','borderTopRightRadius','borderBottomRightRadius','borderBottomLeftRadius']) {
bump(acc.radii, cs[p]);
}
if (el.shadowRoot) walk(el.shadowRoot);
}
};
walk(document);
return {
...acc,
title: document.title,
scrollWidth: document.documentElement.scrollWidth,
innerWidth: window.innerWidth,
bodyTextLength: document.body ? document.body.innerText.trim().length : 0,
};
})()`;
// ---------- 主流程 ----------
const served = argValue('--url') ? null : await serveH5();
const baseUrl = argValue('--url') || served.url;
console.log(`[setup] baseUrl=${baseUrl}`);
const shotsDir = path.join(outDir, 'rendered-shots');
mkdirSync(shotsDir, { recursive: true });
const chrome = await launchChrome();
console.log(`[setup] chrome pid=${chrome.proc.pid} port=${chrome.port} bin=${chrome.chromeBin}`);
const page = await connectPage(chrome.port);
console.log('[setup] page connected');
const pages = [];
try {
await page.send('Page.enable');
await page.send('Runtime.enable');
await page.send('Emulation.setDeviceMetricsOverride', {
width: VIEW_W, height: VIEW_H, deviceScaleFactor: 2, mobile: true,
});
await page.send('Emulation.setTouchEmulationEnabled', { enabled: true });
for (const route of routes) {
const consoleErrors = [];
const off = (msg) => {
if (msg.method === 'Runtime.exceptionThrown') {
consoleErrors.push(msg.params.exceptionDetails?.exception?.description || msg.params.exceptionDetails?.text || 'exception');
}
if (msg.method === 'Runtime.consoleAPICalled' && msg.params.type === 'error') {
consoleErrors.push(msg.params.args?.map((a) => a.value ?? a.description ?? '').join(' ').slice(0, 200));
}
};
const stopListening = page.onEvent(off);
// 该 H5 构建为 hash 路由(见 index.html 兼容重定向脚本),路由必须走 #/ 前缀
const url = `${baseUrl.replace(/\/$/, '')}/#/${route}`;
console.log(`[nav] ${url}`);
await page.send('Page.navigate', { url });
// 等 uni-page-body 出现再静置 1.5s 让异步渲染完成
const started = Date.now();
for (;;) {
const r = await page.send('Runtime.evaluate', {
expression: `!!document.querySelector('uni-page-body')`, returnByValue: true,
});
if (r.result?.value || Date.now() - started > 10000) break;
await page.sleep(300);
}
await page.sleep(1500);
const r = await page.send('Runtime.evaluate', { expression: COLLECTOR, returnByValue: true });
const stats = r.result?.value;
const shot = await page.send('Page.captureScreenshot', { format: 'png' });
const slug = route.replaceAll('/', '-');
writeFileSync(path.join(shotsDir, `${slug}.png`), Buffer.from(shot.data, 'base64'));
stopListening();
pages.push({ route, url, stats, consoleErrors: consoleErrors.filter(Boolean).slice(0, 10) });
console.log(`[rendered] ${route} elements=${stats?.elements ?? '?'} errors=${consoleErrors.filter(Boolean).length}`);
}
} finally {
page.close();
chrome.proc.kill('SIGTERM');
try { rmSync(chrome.userDataDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 }); } catch {}
if (served) await new Promise((r) => served.server.close(r));
}
// ---------- 分析 ----------
function classifyColors(rawMap, { skipTransparent }) {
const agg = new Map();
for (const [raw, count] of Object.entries(rawMap)) {
const v = normalizeComputedColor(raw);
if (skipTransparent && v === 'transparent') continue;
agg.set(v, (agg.get(v) || 0) + count);
}
const onPalette = new Map();
const offPalette = new Map();
for (const [v, count] of agg) {
if (palette.has(v)) onPalette.set(v, count);
else offPalette.set(v, count);
}
const offDetailed = [...offPalette.entries()]
.sort((a, b) => b[1] - a[1])
.map(([v, count]) => {
const framework = KNOWN_FRAMEWORK_COLORS[v] || null;
const nearest = !framework && v.startsWith('#') && v.length === 7 ? nearestPalette(v) : null;
return { value: v, count, nearest, framework, nearMiss: !!nearest && nearest.dist > 0 && nearest.dist <= NEAR_MISS_MAX_DIST };
});
return {
onPaletteCount: [...onPalette.values()].reduce((a, b) => a + b, 0),
offPaletteCount: offDetailed.reduce((a, b) => a + b.count, 0),
nearMissCount: offDetailed.filter((d) => d.nearMiss).reduce((a, b) => a + b.count, 0),
offPalette: offDetailed.slice(0, 20),
};
}
function classifyFontSizes(rawMap) {
const dist = {};
const off = {};
for (const [raw, count] of Object.entries(rawMap)) {
const px = Number(raw.replace('px', ''));
if (!px) continue; // font-size:0 是消间隙/图标字体等合法手段,不计入字阶
dist[px] = (dist[px] || 0) + count;
if (!fontScale.has(px)) off[px] = (off[px] || 0) + count;
}
return { distribution: dist, offScale: off };
}
function classifyRadii(rawMap) {
const dist = {};
const off = {};
for (const [raw, count] of Object.entries(rawMap)) {
if (raw.includes('%')) { dist[raw] = (dist[raw] || 0) + count; continue; }
const px = Number(raw.replace('px', ''));
if (Number.isNaN(px) || px === 0 || px >= 999) continue;
dist[px] = (dist[px] || 0) + count;
if (!radiusTokens.has(px)) off[px] = (off[px] || 0) + count;
}
return { distribution: dist, offToken: off };
}
const analyzed = pages.map((p) => ({
route: p.route,
url: p.url,
elements: p.stats?.elements ?? 0,
bodyTextLength: p.stats?.bodyTextLength ?? 0,
horizontalOverflow: p.stats ? p.stats.scrollWidth > p.stats.innerWidth : null,
scrollWidth: p.stats?.scrollWidth,
innerWidth: p.stats?.innerWidth,
consoleErrors: p.consoleErrors,
colors: classifyColors(p.stats?.colors || {}, { skipTransparent: true }),
backgrounds: classifyColors(p.stats?.backgrounds || {}, { skipTransparent: true }),
fontSizes: classifyFontSizes(p.stats?.fontSizes || {}),
radii: classifyRadii(p.stats?.radii || {}),
}));
const fmtDist = (obj, n = 12) =>
Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, n).map(([v, c]) => `${v}×${c}`).join(',');
const lines = [];
lines.push('# mobile-uni 渲染态 UI 审计');
lines.push('');
lines.push(`- 生成时间:${new Date().toISOString()}`);
lines.push(`- 入口:${baseUrl}(${argValue('--url') ? '外部服务' : '本地静态服务 dist/build/h5'}),视口 ${VIEW_W}×${VIEW_H} @2x`);
lines.push(`- Chrome:${chrome.chromeBin}`);
lines.push(`- palette:${palette.size} 色;字阶:${[...fontScale].sort((a, b) => a - b).join('/')}px;半径 token:${[...radiusTokens].sort((a, b) => a - b).join('/')}px`);
lines.push(`- 口径:按可见元素的 getComputedStyle 计次(一个元素一次),非源码声明次数;默认未登录态渲染,异步接口失败的页面呈现错误/空态`);
lines.push('');
for (const p of analyzed) {
lines.push(`## ${p.route}`);
lines.push(`- 可见元素 ${p.elements};横向溢出:${p.horizontalOverflow ? `**是(scrollWidth ${p.scrollWidth} > ${p.innerWidth})**` : '否'};正文文本 ${p.bodyTextLength} 字符`);
lines.push(`- 控制台错误:${p.consoleErrors.length ? '**' + p.consoleErrors.length + ' 条**' : '0'}`);
for (const e of p.consoleErrors.slice(0, 5)) lines.push(` - ${e}`);
lines.push(`- 文字颜色:palette 内 ${p.colors.onPaletteCount} / 外 **${p.colors.offPaletteCount}**(近色 ${p.colors.nearMissCount})`);
if (p.colors.offPalette.length) {
lines.push('');
lines.push(' | 生效文字色(palette 外) | 元素数 | 最近 palette 色 (Δ) |');
lines.push(' |---|---:|---|');
for (const d of p.colors.offPalette.slice(0, 8)) {
const hint = d.framework ? `框架默认(${d.framework})` : d.nearest ? `${d.nearest.value} (Δ${d.nearest.dist})` : '—';
lines.push(` | ${d.value} | ${d.count} | ${hint} |`);
}
lines.push('');
}
lines.push(`- 背景色:palette 内 ${p.backgrounds.onPaletteCount} / 外 **${p.backgrounds.offPaletteCount}**(近色 ${p.backgrounds.nearMissCount})`);
const offBg = p.backgrounds.offPalette.slice(0, 5).map((d) => `${d.value}×${d.count}${d.nearest ? `(→${d.nearest.value} Δ${d.nearest.dist})` : ''}`).join(',');
if (offBg) lines.push(` - 头部:${offBg}`);
const offFs = Object.entries(p.fontSizes.offScale).sort((a, b) => b[1] - a[1]);
lines.push(`- 字号:分布 ${fmtDist(p.fontSizes.distribution)}`);
lines.push(`- 字阶外:${offFs.length ? offFs.map(([v, c]) => `${v}px×${c}`).join(',') : '无'}`);
const offR = Object.entries(p.radii.offToken).sort((a, b) => b[1] - a[1]);
lines.push(`- 圆角 token 外:${offR.length ? offR.map(([v, c]) => `${v}px×${c}`).join(',') : '无'}`);
lines.push('');
}
lines.push('## 截图证据');
for (const p of analyzed) lines.push(`- output/ui-ux/rendered-shots/${p.route.replaceAll('/', '-')}.png`);
mkdirSync(outDir, { recursive: true });
writeFileSync(path.join(outDir, 'rendered-audit-latest.json'), JSON.stringify({ generatedAt: new Date().toISOString(), baseUrl, viewport: [VIEW_W, VIEW_H], pages: analyzed }, null, 2));
writeFileSync(path.join(outDir, 'rendered-audit-latest.md'), lines.join('\n') + '\n');
console.log(`\n报告已写入 ${path.join(outDir, 'rendered-audit-latest.md')}`);
if (analyzed.some((p) => p.horizontalOverflow || p.consoleErrors.length)) {
console.log('提示:存在横向溢出或控制台错误的页面,详见报告。');
}