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
+2
View File
@@ -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"
},
+77
View File
@@ -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 };
}
+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('提示:存在横向溢出或控制台错误的页面,详见报告。');
}
+225
View File
@@ -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);
}