#!/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('提示:存在横向溢出或控制台错误的页面,详见报告。'); }