chore: add UI audits and reconcile release docs
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
"verify:app-assets": "node scripts/verify-app-assets.mjs",
|
||||
"verify:android-apk": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/verify-android-apk.ps1",
|
||||
"verify:app-release": "node scripts/verify-app-assets.mjs --require-legal-consent --check-legal-urls",
|
||||
"audit:ui-tokens": "node scripts/ui-token-audit.mjs",
|
||||
"audit:ui-rendered": "node scripts/ui-rendered-audit.mjs",
|
||||
"test:unit": "node --test tests/*.test.mjs",
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* ui-tokens.mjs — UI token 审计共享库
|
||||
*
|
||||
* 以 src/styles/base.css 为唯一 palette / 字阶 / 半径来源,
|
||||
* 同时服务静态审计(ui-token-audit.mjs)与渲染审计(ui-rendered-audit.mjs)。
|
||||
*/
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
export const mobileRoot = join(fileURLToPath(new URL('.', import.meta.url)), '..', '..');
|
||||
export const tokenFile = join(mobileRoot, 'src', 'styles', 'base.css');
|
||||
|
||||
export function normalizeHex(hex) {
|
||||
let h = hex.toLowerCase();
|
||||
if (h.length === 4 || h.length === 5) {
|
||||
h = '#' + [...h.slice(1)].map((c) => c + c).join('');
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
export function normalizeRgba(fn) {
|
||||
return fn.toLowerCase().replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
/** 渲染侧 computed style 给出的是 rgb()/rgba() 字符串,统一规整;不透明时折叠为 hex */
|
||||
export function normalizeComputedColor(value) {
|
||||
const v = value.toLowerCase().replace(/\s+/g, '');
|
||||
const m = v.match(/^rgba?\((\d+),(\d+),(\d+)(?:,([\d.]+))?\)$/);
|
||||
if (!m) return v;
|
||||
const alpha = m[4] === undefined ? 1 : Number(m[4]);
|
||||
if (alpha === 0) return 'transparent';
|
||||
if (alpha === 1) {
|
||||
return '#' + [m[1], m[2], m[3]].map((n) => Number(n).toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
return v;
|
||||
}
|
||||
|
||||
export function hexToRgb(h) {
|
||||
return [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
||||
}
|
||||
|
||||
export function colorDist(a, b) {
|
||||
const [r1, g1, b1] = hexToRgb(a);
|
||||
const [r2, g2, b2] = hexToRgb(b);
|
||||
return Math.round(Math.sqrt((r1 - r2) ** 2 + (g1 - g2) ** 2 + (b1 - b2) ** 2));
|
||||
}
|
||||
|
||||
export const NEAR_MISS_MAX_DIST = 24; // RGB 欧氏距离 ≤24 视为"近色漂移",疑似 token 笔误
|
||||
|
||||
export function loadTokens() {
|
||||
const baseCss = readFileSync(tokenFile, 'utf8');
|
||||
|
||||
const palette = new Set(['transparent', 'currentcolor', 'inherit']);
|
||||
for (const m of baseCss.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) palette.add(normalizeHex(m[0]));
|
||||
for (const m of baseCss.matchAll(/rgba?\([^)]*\)/g)) palette.add(normalizeRgba(m[0]));
|
||||
|
||||
const fontScale = new Set();
|
||||
for (const m of baseCss.matchAll(/font-size:\s*([\d.]+)px/g)) fontScale.add(Number(m[1]));
|
||||
|
||||
const radiusTokens = new Set();
|
||||
for (const m of baseCss.matchAll(/--[\w-]*radius[\w-]*:\s*([\d.]+)px/g)) radiusTokens.add(Number(m[1]));
|
||||
for (const m of baseCss.matchAll(/border-radius:\s*([\d.]+)px/g)) radiusTokens.add(Number(m[1]));
|
||||
|
||||
const paletteHexes = [...palette].filter((v) => v.startsWith('#') && v.length === 7);
|
||||
|
||||
function nearestPalette(hex) {
|
||||
let best = null;
|
||||
for (const p of paletteHexes) {
|
||||
const d = colorDist(hex, p);
|
||||
if (!best || d < best.dist) best = { value: p, dist: d };
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
return { palette, paletteHexes, fontScale, radiusTokens, nearestPalette };
|
||||
}
|
||||
@@ -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('提示:存在横向溢出或控制台错误的页面,详见报告。');
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* ui-token-audit.mjs — 视觉秩序(token 收敛度)审计
|
||||
*
|
||||
* 以 src/styles/base.css 的 :root 自定义属性与设计系统内置类为唯一 palette 来源,
|
||||
* 扫描 src 下全部 .vue/.css/.scss(base.css 自身除外),统计:
|
||||
* 1. 颜色:var() 引用 / palette 内字面值 / palette 外字面值(违规)
|
||||
* 2. 字号:分布 + 不在 base.css 派生字阶内的"孤儿"值
|
||||
* 3. 间距:margin/padding/gap 分布 + 非 2px 网格值
|
||||
* 4. 圆角:分布 + 不在 token 半径集合内的值(0/百分比/胶囊值除外)
|
||||
*
|
||||
* 输出控制台摘要 + output/ui-ux/token-audit-latest.{json,md}。
|
||||
* 默认只报告不失败;--strict 时存在 palette 外颜色则以 1 退出。
|
||||
*/
|
||||
import { readdirSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
||||
import { join, relative } from 'node:path';
|
||||
import {
|
||||
mobileRoot,
|
||||
tokenFile,
|
||||
normalizeHex,
|
||||
normalizeRgba,
|
||||
NEAR_MISS_MAX_DIST,
|
||||
loadTokens,
|
||||
} from './lib/ui-tokens.mjs';
|
||||
|
||||
const root = mobileRoot;
|
||||
const srcDir = join(root, 'src');
|
||||
const strict = process.argv.includes('--strict');
|
||||
|
||||
// ---------- 1. 从 base.css 提取 palette 与派生字阶(共享 lib,与渲染审计同源) ----------
|
||||
const { palette, fontScale, radiusTokens, nearestPalette } = loadTokens();
|
||||
|
||||
// ---------- 2. 收集源文件 ----------
|
||||
const exts = new Set(['.vue', '.css', '.scss']);
|
||||
function walk(dir) {
|
||||
const out = [];
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const p = join(dir, entry.name);
|
||||
if (entry.isDirectory()) out.push(...walk(p));
|
||||
else if (exts.has(entry.name.slice(entry.name.lastIndexOf('.')))) out.push(p);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const files = walk(srcDir).filter((f) => f !== tokenFile);
|
||||
|
||||
// ---------- 3. 扫描 ----------
|
||||
const colors = { varRefs: 0, paletteLiteral: new Map(), offPalette: new Map() };
|
||||
const fontSizes = new Map();
|
||||
const fontSizesOffScale = new Map();
|
||||
const spacings = new Map();
|
||||
const spacingsOffGrid = new Map();
|
||||
const radii = new Map();
|
||||
const radiiOffToken = new Map();
|
||||
|
||||
function bump(map, key, loc) {
|
||||
const cur = map.get(key) || { count: 0, locs: [] };
|
||||
cur.count += 1;
|
||||
if (cur.locs.length < 5) cur.locs.push(loc);
|
||||
map.set(key, cur);
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
const rel = relative(root, file).replaceAll('\\', '/');
|
||||
const text = readFileSync(file, 'utf8');
|
||||
const lineOf = (idx) => rel + ':' + (text.slice(0, idx).split('\n').length);
|
||||
|
||||
for (const m of text.matchAll(/var\(--[\w-]+\)/g)) colors.varRefs += 1;
|
||||
|
||||
for (const m of text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) {
|
||||
const v = normalizeHex(m[0]);
|
||||
if (palette.has(v)) bump(colors.paletteLiteral, v, lineOf(m.index));
|
||||
else bump(colors.offPalette, v, lineOf(m.index));
|
||||
}
|
||||
for (const m of text.matchAll(/rgba?\([^)]*\)/g)) {
|
||||
const v = normalizeRgba(m[0]);
|
||||
if (palette.has(v)) bump(colors.paletteLiteral, v, lineOf(m.index));
|
||||
else bump(colors.offPalette, v, lineOf(m.index));
|
||||
}
|
||||
|
||||
for (const m of text.matchAll(/font-size:\s*([\d.]+)px/g)) {
|
||||
const v = Number(m[1]);
|
||||
bump(fontSizes, v, lineOf(m.index));
|
||||
if (!fontScale.has(v)) bump(fontSizesOffScale, v, lineOf(m.index));
|
||||
}
|
||||
|
||||
for (const m of text.matchAll(/(?:margin|padding|gap)(?:-[\w]+)?:\s*([^;}]+)/g)) {
|
||||
for (const n of m[1].matchAll(/(-?[\d.]+)px/g)) {
|
||||
const v = Number(n[1]);
|
||||
if (v === 0) continue;
|
||||
bump(spacings, v, lineOf(m.index));
|
||||
if (v % 2 !== 0) bump(spacingsOffGrid, v, lineOf(m.index));
|
||||
}
|
||||
}
|
||||
|
||||
for (const m of text.matchAll(/border-radius:\s*([^;}]+)/g)) {
|
||||
const raw = m[1].trim();
|
||||
if (raw.includes('var(') || raw.includes('%')) continue;
|
||||
for (const n of raw.matchAll(/([\d.]+)px/g)) {
|
||||
const v = Number(n[1]);
|
||||
if (v === 0 || v >= 999) continue; // 直角与胶囊/圆形属合法手段
|
||||
bump(radii, v, lineOf(m.index));
|
||||
if (!radiusTokens.has(v)) bump(radiiOffToken, v, lineOf(m.index));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- 4. 汇总 ----------
|
||||
const sum = (map) => [...map.values()].reduce((a, b) => a + b.count, 0);
|
||||
const top = (map, n = 12) =>
|
||||
[...map.entries()].sort((a, b) => b[1].count - a[1].count).slice(0, n);
|
||||
|
||||
const offPaletteDetailed = {};
|
||||
let nearMissCount = 0;
|
||||
for (const [v, d] of top(colors.offPalette, colors.offPalette.size)) {
|
||||
const nearest = v.startsWith('#') && v.length === 7 ? nearestPalette(v) : null;
|
||||
if (nearest && nearest.dist > 0 && nearest.dist <= NEAR_MISS_MAX_DIST) nearMissCount += d.count;
|
||||
offPaletteDetailed[v] = { ...d, nearest };
|
||||
}
|
||||
|
||||
const result = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
scope: 'mobile-uni/src (excluding styles/base.css as token source)',
|
||||
filesScanned: files.length,
|
||||
tokenSource: {
|
||||
paletteSize: palette.size,
|
||||
fontScale: [...fontScale].sort((a, b) => a - b),
|
||||
radiusTokens: [...radiusTokens].sort((a, b) => a - b),
|
||||
},
|
||||
colors: {
|
||||
varRefs: colors.varRefs,
|
||||
paletteLiteralCount: sum(colors.paletteLiteral),
|
||||
offPaletteCount: sum(colors.offPalette),
|
||||
offPaletteDistinct: colors.offPalette.size,
|
||||
nearMissCount,
|
||||
offPalette: Object.fromEntries(Object.entries(offPaletteDetailed).slice(0, 30)),
|
||||
},
|
||||
fontSizes: {
|
||||
distinct: fontSizes.size,
|
||||
total: sum(fontSizes),
|
||||
offScaleDistinct: fontSizesOffScale.size,
|
||||
offScaleCount: sum(fontSizesOffScale),
|
||||
distribution: Object.fromEntries(top(fontSizes, 20)),
|
||||
offScale: Object.fromEntries(top(fontSizesOffScale, 20)),
|
||||
},
|
||||
spacings: {
|
||||
distinct: spacings.size,
|
||||
total: sum(spacings),
|
||||
offGridDistinct: spacingsOffGrid.size,
|
||||
offGridCount: sum(spacingsOffGrid),
|
||||
distribution: Object.fromEntries(top(spacings, 20)),
|
||||
offGrid: Object.fromEntries(top(spacingsOffGrid, 20)),
|
||||
},
|
||||
radii: {
|
||||
distribution: Object.fromEntries(top(radii, 20)),
|
||||
offToken: Object.fromEntries(top(radiiOffToken, 20)),
|
||||
},
|
||||
};
|
||||
|
||||
// ---------- 5. 输出 ----------
|
||||
const literalTotal = result.colors.paletteLiteralCount + result.colors.offPaletteCount;
|
||||
const colorTotal = colors.varRefs + literalTotal;
|
||||
const varRatio = colorTotal ? ((colors.varRefs / colorTotal) * 100).toFixed(1) : '0.0';
|
||||
|
||||
const lines = [];
|
||||
lines.push('# mobile-uni UI token 审计');
|
||||
lines.push('');
|
||||
lines.push(`- 生成时间:${result.generatedAt}`);
|
||||
lines.push(`- 扫描文件:${files.length} 个(token 源:src/styles/base.css)`);
|
||||
lines.push(`- palette:${palette.size} 个颜色;字阶:${result.tokenSource.fontScale.join('/')}px;半径 token:${result.tokenSource.radiusTokens.join('/')}px`);
|
||||
lines.push('');
|
||||
lines.push('## 颜色');
|
||||
lines.push(`- var() 引用 ${colors.varRefs} 次;字面值 ${literalTotal} 次(palette 内 ${result.colors.paletteLiteralCount},**palette 外 ${result.colors.offPaletteCount} 次 / ${colors.offPalette.size} 种**,其中近色漂移 ${nearMissCount} 次)`);
|
||||
lines.push(`- token 引用占比(var / 全部颜色引用):${varRatio}%`);
|
||||
if (colors.offPalette.size) {
|
||||
lines.push('');
|
||||
lines.push('| palette 外颜色 | 次数 | 最近 palette 色 (Δ) | 示例位置 |');
|
||||
lines.push('|---|---:|---|---|');
|
||||
for (const [v, d] of Object.entries(offPaletteDetailed).slice(0, 30)) {
|
||||
const hint = d.nearest ? `${d.nearest.value} (Δ${d.nearest.dist})` : '—';
|
||||
lines.push(`| ${v} | ${d.count} | ${hint} | ${d.locs.join('、')} |`);
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 字号');
|
||||
lines.push(`- 共 ${result.fontSizes.total} 处、${fontSizes.size} 种;**字阶外 ${result.fontSizes.offScaleCount} 处 / ${fontSizesOffScale.size} 种**`);
|
||||
lines.push(`- 分布:${[...top(fontSizes, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',')}`);
|
||||
if (fontSizesOffScale.size) {
|
||||
lines.push('');
|
||||
lines.push('| 字阶外字号 | 次数 | 示例位置 |');
|
||||
lines.push('|---|---:|---|');
|
||||
for (const [v, d] of top(fontSizesOffScale, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 间距(margin/padding/gap)');
|
||||
lines.push(`- 共 ${result.spacings.total} 处、${spacings.size} 种;**非 2px 网格 ${result.spacings.offGridCount} 处 / ${spacingsOffGrid.size} 种**`);
|
||||
lines.push(`- 分布:${[...top(spacings, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',')}`);
|
||||
if (spacingsOffGrid.size) {
|
||||
lines.push('');
|
||||
lines.push('| 非网格间距 | 次数 | 示例位置 |');
|
||||
lines.push('|---|---:|---|');
|
||||
for (const [v, d] of top(spacingsOffGrid, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`);
|
||||
}
|
||||
lines.push('');
|
||||
lines.push('## 圆角');
|
||||
lines.push(`- 分布:${[...top(radii, 20)].map(([v, d]) => `${v}px×${d.count}`).join(',') || '(无字面值)'}`);
|
||||
if (radiiOffToken.size) {
|
||||
lines.push('');
|
||||
lines.push('| token 外圆角 | 次数 | 示例位置 |');
|
||||
lines.push('|---|---:|---|');
|
||||
for (const [v, d] of top(radiiOffToken, 20)) lines.push(`| ${v}px | ${d.count} | ${d.locs.join('、')} |`);
|
||||
}
|
||||
|
||||
const outDir = join(root, '..', 'output', 'ui-ux');
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
writeFileSync(join(outDir, 'token-audit-latest.json'), JSON.stringify(result, null, 2));
|
||||
writeFileSync(join(outDir, 'token-audit-latest.md'), lines.join('\n') + '\n');
|
||||
|
||||
console.log(lines.join('\n'));
|
||||
console.log('');
|
||||
console.log(`报告已写入 output/ui-ux/token-audit-latest.{md,json}`);
|
||||
|
||||
if (strict && result.colors.offPaletteCount > 0) {
|
||||
console.error(`--strict:存在 ${result.colors.offPaletteCount} 处 palette 外颜色`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user