Files
prop-ai-hr/mobile-uni/scripts/ui-rendered-audit.mjs
T

453 lines
21 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.
#!/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 AUTH_DEFAULT_ROUTES = [
'pages/user/today/index',
'pages/user/sop/index',
'pages/user/practice/index',
'pages/user/direct/index',
'pages/user/work-results/index',
'pages/candidate/index/index',
'pages/supervisor/index/index',
];
const apiBase = (argValue('--api') || 'http://127.0.0.1:8081').replace(/\/$/, '');
const loginPhone = argValue('--phone') || process.env.AIHR_UI_AUDIT_PHONE || '';
const loginCode = argValue('--code') || process.env.AIHR_UI_AUDIT_CODE || '';
if (loginPhone && !loginCode) {
throw new Error('认证态审计需要通过 --code 或 AIHR_UI_AUDIT_CODE 显式提供验证码');
}
const routes = (argValue('--routes') || (loginPhone ? AUTH_DEFAULT_ROUTES : DEFAULT_ROUTES).join(','))
.split(',').map((s) => s.trim()).filter(Boolean);
// ---------- 登录态(凭据只留在进程内,不写入日志、截图或报告) ----------
async function loginForToken() {
const smsResp = await fetch(`${apiBase}/resource/sms/code?phonenumber=${encodeURIComponent(loginPhone)}`);
const smsPayload = await smsResp.json().catch(() => ({}));
if (!smsResp.ok || (smsPayload.code !== undefined && smsPayload.code !== 200)) {
// 验证码按手机号粒度限频;已下发且在有效期内的固定码仍可登录,输错不作废
console.log(`[auth] 验证码请求被限频,沿用已下发验证码重试登录(${smsPayload.msg || smsResp.status})`);
}
const loginResp = await fetch(`${apiBase}/auth/mobile/sms-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ phonenumber: loginPhone, smsCode: loginCode }),
});
const loginPayload = await loginResp.json().catch(() => ({}));
const token = loginPayload?.data?.access_token;
const clientId = loginPayload?.data?.client_id;
if (!loginResp.ok || !token || !clientId) {
throw new Error(`登录失败:HTTP ${loginResp.status} ${loginPayload.msg || ''}`);
}
console.log('[auth] 已完成认证(手机号、验证码、token 与 clientId 均不写入报告)');
return { token, clientId };
}
// ---------- H5 静态服务(SPA 回退 + /dev-api 代理) ----------
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(async (req, res) => {
let p = decodeURIComponent(new URL(req.url, 'http://x').pathname);
// 与 vite dev 一致的代理行为:/dev-api/* → apiBase/*(去前缀)
if (p.startsWith('/dev-api/')) {
try {
const body = ['GET', 'HEAD'].includes(req.method) ? undefined : await new Promise((resolve) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => resolve(Buffer.concat(chunks)));
});
const headers = {};
for (const h of ['content-type', 'authorization', 'clientid', 'content-language']) {
if (req.headers[h]) headers[h] = req.headers[h];
}
const upstream = await fetch(`${apiBase}${p.slice(8)}${new URL(req.url, 'http://x').search}`, {
method: req.method, headers, body,
});
res.writeHead(upstream.status, { 'Content-Type': upstream.headers.get('content-type') || 'application/json' });
res.end(Buffer.from(await upstream.arrayBuffer()));
} catch (error) {
res.writeHead(502, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ code: 502, msg: `dev-api 代理失败:${error.message}` }));
}
return;
}
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 session = loginPhone ? await loginForToken() : null;
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');
if (session) {
// 与应用 saveAuth 相同的 storage 键,注入已登录会话(不经过 UI,不触发额外短信)
await page.send('Page.addScriptToEvaluateOnNewDocument', {
source: [
`localStorage.setItem('aihr_mobile_access_token', ${JSON.stringify(session.token)});`,
`localStorage.setItem('aihr_mobile_client_id', ${JSON.stringify(session.clientId)});`,
`localStorage.setItem('aihr_mobile_phone', ${JSON.stringify(loginPhone)});`,
`localStorage.setItem('aihr_mobile_tenant_id', '000000');`,
].join('\n'),
});
}
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(`- 会话:${session ? `已认证(凭据不入报告),API 代理 ${apiBase}` : '未登录(页面呈现登录门禁/空态/错误态)'}`);
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('提示:存在横向溢出或控制台错误的页面,详见报告。');
}