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
+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);
}