Files
aurora-admin f1fbfc2ddb
Regression / regression (push) Canceled after 0s
feat(品牌标识): 几何 K 图标(favicon/顶栏标记/theme-color) + 并行会话成果入库
## 品牌标识(本次会话)

起因:品牌此前没有任何图形标识 —— 唯一 favicon 是内联 data-URI 里的字母「A」,
那是 v2.0.0「Aurora Admin → Kole UI」改名漏掉的一处(PC 顶栏也是「A」,
移动端站已是「K」;移动端文档站则完全没有 favicon)。

- 几何:24 网格三个互不接触的笔画(竖 + 两斜),圆头描边;
  描边 2.25 → 16px 标签页尺寸下正好 1.5px = 规范原文「描边1.5px」
- 取色分两套(刻意):favicon 硬编码品牌蓝/白(渲染在浏览器标签栏,不继承 kole-dark);
  顶栏标记走 currentColor(实测暗色下自动转 rgb(20,22,28))
- 新增 theme-color 双条(light #FFFFFF / dark #1C1F26,取 --kole-color-card-bg)
- 修 site/app.js hero 标语 KOLE ADMIN → KOLE UI(改名变形残留)
- 移动端 7 个模板补 favicon(此前计数 0)

验收:门禁 9 条全 OK(site-routing/site-routes/mobile-docs/mobile-site/isolation/
theme/nav/i18n/icons);PC 回归 1464/1464 · 移动端 807/807,各连跑 8 次一致;
两端 favicon 405 字节逐字节一致;PC 站控制台错误 1→0。

## 并行会话成果(本次一并入库)

- 图标系统:2576 图标(TDesign/Element Plus,MIT)+ 11 端注入 + 5 个构建门禁工具
  + IconPreview 预览页 + ICON-SPEC.md 冻结规格
- 移动端平台:47 组件 × 6 端 + 文档站 53 页 + 隔离门禁
- PC 组件:103 个大后台组件 / 组件11 批次
- uni-app:PC 端试点 + 移动端端实现 + 真实编译验证

## 工程

- .gitignore 补 .scratch/ 与 .zcode-preexisting-*.txt(会话中间产物,实测 9.1MB,不入库)
- CHANGELOG 补品牌标识条目
- ROADMAP 登 S8-P4(品牌标识任务包 + og:image/apple-touch-icon 未做部分)
2026-09-21 10:05:48 +08:00

323 lines
15 KiB
JavaScript
Raw Permalink 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
/**
* verify-icons.mjs — 图标系统的静态断言门禁(ICON-SPEC.md §八)
*
* 为什么要有它(本文件此前只被引用、从未存在):
* ICON-SPEC.md §八与 build-icons.mjs / fetch-icon-sources.mjs / inject-icon-table.mjs
* 的注释里共 **6 处**引用 `tools/verify-icons.mjs`,但仓库里没有这个文件 ——
* 于是「规格声明的验收门」实际不可执行,§二 的 5 条归一化修正与 §三 的别名约束
* 全靠构建期自觉,没有回归保护。本脚本把它补齐。
*
* 判据来源(关键:不能自证):
* 数字真源是**上游产物** registry.json(2576 个 icon 的路径数据)与 manifest.json
* (名字 / 层 / 分组 / viewBox / 模式)。端实现只被**检查**,不被当成判据 ——
* 否则「实现悄悄改错」会让判据跟着一起错(本仓库 B-<target> 门禁的实测教训)。
*
* 覆盖 ICON-SPEC.md §八 的 7 条:
* V1 产物齐全且互相一致(manifest ↔ registry 双向)
* V2 无 `view-box` 拼写残留(§二 修正 1:上游把它写成无效属性)
* V3 描边宽度全部 1.5(§二 修正 2)
* V4 core 层图标真的能从 core.js 渲染出非空 SVG
* V5 别名全部指向真实图标(§三 3.2)
* V6 每个端实现的 name 查找路径都能落到 registry(不出现「端上有 API 但取不到图标」)
* V7 分组覆盖率 ≥ 75%(低于则说明分组规则退化)
*
* 零依赖;退出码 0 = 全部通过。
*/
import { readFileSync, existsSync } from 'node:fs';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const ICON_DIR = join(ROOT, '.design_library', 'kole-ui', 'icons');
const read = (p) => readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
const readIf = (p) => (existsSync(p) ? read(p) : null);
let pass = 0;
const failures = [];
function check(ok, id, detail) {
if (ok) {
pass++;
console.log(` PASS ${id}${detail ? ' — ' + detail : ''}`);
} else {
failures.push(id + (detail ? ' — ' + detail : ''));
console.log(` FAIL ${id}${detail ? ' — ' + detail : ''}`);
}
}
console.log('图标系统静态门禁(ICON-SPEC.md §八)');
/* ---------- 载入真源 ---------- */
const REGISTRY = join(ICON_DIR, 'registry.json');
const MANIFEST = join(ICON_DIR, 'manifest.json');
const ALIASES = join(ICON_DIR, 'aliases.json');
const CORE_JS = join(ICON_DIR, 'core.js');
const VIEWBOX_JS = join(ICON_DIR, 'viewbox.js');
const SRC = join(ICON_DIR, 'sources.json');
for (const f of [REGISTRY, MANIFEST, ALIASES, CORE_JS, VIEWBOX_JS]) {
if (!existsSync(f)) {
console.error(`[FATAL] 缺产物 ${f} —— 先跑 node tools/build-icons.mjs`);
process.exit(2);
}
}
const registry = JSON.parse(read(REGISTRY));
const manifest = JSON.parse(read(MANIFEST));
const aliases = JSON.parse(read(ALIASES)).aliases;
const REG = registry.icons;
const REG_NAMES = Object.keys(REG);
const require2 = createRequire(import.meta.url);
const runtime = require2(CORE_JS);
console.log(` 真源:registry ${REG_NAMES.length} · manifest ${manifest.icons.length} · 别名 ${Object.keys(aliases).length} · core ${runtime.coreCount}`);
/* ---------- V1 产物齐全且互相一致(manifest ↔ registry 双向) ---------- */
const MAN_NAMES = manifest.icons.map((i) => i.name);
const manSet = new Set(MAN_NAMES);
const regSet = new Set(REG_NAMES);
const onlyManifest = [...manSet].filter((n) => !regSet.has(n));
const onlyRegistry = [...regSet].filter((n) => !manSet.has(n));
check(MAN_NAMES.length === new Set(MAN_NAMES).size, 'V1a manifest 内名字无重复', `${MAN_NAMES.length} 个`);
check(
onlyManifest.length === 0 && onlyRegistry.length === 0,
'V1b manifest ↔ registry 双向一致',
onlyManifest.length || onlyRegistry.length
? `只在 manifest:${onlyManifest.slice(0, 4).join(',') || '无'};只在 registry:${onlyRegistry.slice(0, 4).join(',') || '无'}`
: `${REG_NAMES.length} 个双向对齐`
);
/* viewBox 表:manifest 里非 24 网格的才是例外,必须与 viewbox.js 逐条对齐 */
const VBOX_EX = JSON.parse(
read(VIEWBOX_JS).replace(/^[\s\S]*?=\s*/, '').replace(/;\s*$/, '')
);
const manVb = new Map(manifest.icons.map((i) => [i.name, i.viewBox]));
const exNames = Object.keys(VBOX_EX).sort();
const exMismatch = exNames.filter((n) => manVb.get(n) !== VBOX_EX[n]);
const missingEx = [...manVb].filter(([n, v]) => v !== '0 0 24 24' && !(n in VBOX_EX)).map(([n]) => n);
check(
exMismatch.length === 0 && missingEx.length === 0,
'V1c viewBox 例外表与 manifest 逐条一致',
exMismatch.length || missingEx.length
? `值不符:${exMismatch.slice(0, 4).join(',') || '无'};漏登记:${missingEx.slice(0, 4).join(',') || '无'}`
: `${exNames.length} 条例外`
);
/* ---------- V2 无 view-box 拼写残留 ---------- */
const viewBoxBad = [];
for (const f of ['registry.json', 'manifest.json', 'sources.json', 'core.js', 'viewbox.js']) {
const t = readIf(join(ICON_DIR, f));
if (t && /view-box/.test(t)) viewBoxBad.push(f);
}
check(viewBoxBad.length === 0, 'V2 无 view-box 无效拼写残留(§二 修正 1)', viewBoxBad.join(', ') || `扫描 5 个产物文件`);
/* ---------- V3 描边宽度全部 1.5 ---------- */
const swProblems = [];
if (registry.strokeWidth !== 1.5) swProblems.push(`registry.strokeWidth=${registry.strokeWidth}`);
if (manifest.strokeWidth !== 1.5) swProblems.push(`manifest.strokeWidth=${manifest.strokeWidth}`);
if (runtime.strokeWidth !== 1.5) swProblems.push(`core.js strokeWidth=${runtime.strokeWidth}`);
/* 产物里不许再出现旧宽度 2 的渲染字面量 */
const coreSrc = read(CORE_JS);
if (/stroke-width="2"/.test(coreSrc)) swProblems.push('core.js 含 stroke-width="2"');
check(swProblems.length === 0, 'V3 描边宽度全部 1.5(§二 修正 2)', swProblems.join('; ') || null);
/* ---------- V4 core 层图标真的能渲染出非空 SVG ---------- */
const coreNames = Object.keys(runtime.core).sort();
const renderBad = [];
for (const n of coreNames) {
const svg = runtime.svg(n);
if (!svg) { renderBad.push(`${n}:空串`); continue; }
if (!/<path d="[^"]/.test(svg)) renderBad.push(`${n}:无 path`);
if (!/viewBox="0 0 [0-9.]+ [0-9.]+"/.test(svg)) renderBad.push(`${n}:无 viewBox`);
/* 非 24 网格必须命中例外表,否则会被拉伸变形 */
const want = VBOX_EX[n] || '0 0 24 24';
if (!svg.includes(`viewBox="${want}"`)) renderBad.push(`${n}:viewBox 应为 ${want}`);
}
check(renderBad.length === 0, 'V4 core 层图标可渲染出非空 SVG', renderBad.slice(0, 5).join('; ') || `${coreNames.length} 个`);
check(
coreNames.length === runtime.coreCount,
'V4b core 计数自洽',
`coreCount=${runtime.coreCount} / 实际 ${coreNames.length}`
);
/* ---------- V5 别名全部指向真实图标 ---------- */
const badAlias = [];
for (const [a, target] of Object.entries(aliases)) {
if (!regSet.has(target)) badAlias.push(`${a}→${target}(不存在)`);
if (regSet.has(a)) badAlias.push(`${a}(别名遮蔽了真图标)`);
}
check(badAlias.length === 0, 'V5 别名全部指向真实图标且不遮蔽(§三 3.2)', badAlias.slice(0, 5).join('; ') || `${Object.keys(aliases).length} 条`);
/* ---------- V6 每个端实现的 name 查找路径都能落到 registry ---------- */
/* 端文件清单:与 tools/inject-icon-table.mjs 的 FILES 同源(那里是唯一注入入口) */
const injector = read(join(ROOT, 'tools', 'inject-icon-table.mjs'));
const filesBlock = injector.match(/const FILES = \[([\s\S]*?)\];/);
const END_FILES = filesBlock
? [...filesBlock[1].matchAll(/\['([^']+)',\s*'(html|js)'\]/g)].map((m) => ({ rel: m[1], kind: m[2] }))
: [];
if (!END_FILES.length) {
check(false, 'V6 端实现清单可解析', '未能从 tools/inject-icon-table.mjs 解析出 FILES');
} else {
const endProblems = [];
const endPending = [];
let checkedEnds = 0;
for (const { rel, kind } of END_FILES) {
const abs = join(ROOT, rel);
if (!existsSync(abs)) { endProblems.push(`${rel}(文件缺失)`); continue; }
const t = read(abs);
const [b, e] = kind === 'html'
? ['<!-- === KOLE_ICON_TABLE:BEGIN === -->', '<!-- === KOLE_ICON_TABLE:END === -->']
: ['/* === KOLE_ICON_TABLE:BEGIN === */', '/* === KOLE_ICON_TABLE:END === */'];
if (!t.includes(b) || !t.includes(e)) { endProblems.push(`${rel}(缺标记对)`); continue; }
const bi = t.indexOf(b);
const ei = t.indexOf(e);
const block = t.slice(bi + b.length, ei);
/* 标记块内部必须已有数据(未注入 = 端上取不到图标,正是 §八 第 6 条要拦的) */
const jsonText = kind === 'html'
? (block.match(/<script type="application\/json" id="kole-icon-data">([\s\S]*?)<\/script>/) || [])[1]
: (block.match(/const KOLE_ICON_DATA = ([\s\S]*?);\s*$/) || [])[1];
if (!jsonText) { endPending.push(rel); continue; }
let data;
try {
data = JSON.parse(jsonText.replace(/\\u003c/g, '<'));
} catch (err) {
endProblems.push(`${rel}(数据块 JSON 解析失败: ${String(err.message).slice(0, 40)})`);
continue;
}
checkedEnds++;
/* ① 端上表必须是「终态表」:别名已折叠 —— 每个名字都能直接查到路径 */
const table = data.i || {};
const names = Object.keys(table);
/* ② 每个端名字必须能落到 registry(canonical 直接命中;别名落到其 target) */
const unlanded = [];
for (const n of names) {
const target = regSet.has(n) ? n : aliases[n];
if (!target || !regSet.has(target)) { unlanded.push(n); continue; }
/* ③ 路径数据必须与真源逐字节一致(防端上手抄漂移) */
if (table[n] !== REG[target]) unlanded.push(`${n}(数据漂移)`);
}
if (unlanded.length) endProblems.push(`${rel}: ${unlanded.slice(0, 3).join(',')}${unlanded.length > 3 ? ` 等 ${unlanded.length} 个` : ''}`);
/* ④ 描边宽度与 viewBox 例外表必须与真源一致 */
if (data.sw !== 1.5) endProblems.push(`${rel}: sw=${data.sw}(应 1.5)`);
if (data.vb && JSON.stringify(Object.keys(data.vb).sort()) !== JSON.stringify(exNames)) {
const got = new Set(Object.keys(data.vb));
const miss = exNames.filter((n) => !got.has(n));
const extra = [...got].filter((n) => !exNames.includes(n));
endProblems.push(`${rel}: vb 例外表不符(漏 ${miss.length} / 多 ${extra.length})`);
}
/* ⑤ 端上名字覆盖率:必须含 core 全部 + 「能折叠的别名」。
注意**不能**要求 241 条别名全在端上 —— ICON-SPEC §五 明确分层:
core 151 内联,standard/extended 按需。别名若指向非 core 的 canonical
(如 circle-plus-outline → circle-plus),注入器按设计不折叠它
(注入器的条件是 `merged[target]` 存在)。所以这里只要求
「core 名 + 目标在 core 内的别名」齐全。 */
const foldableAliases = Object.keys(aliases).filter(
(a) => regSet.has(aliases[a]) && coreNames.includes(aliases[a]) && !regSet.has(a)
);
const expect = new Set([...coreNames, ...foldableAliases]);
const missingNames = [...expect].filter((n) => !(n in table));
if (missingNames.length) endProblems.push(`${rel}: 缺 ${missingNames.length} 个名字(如 ${missingNames.slice(0, 3).join(',')})`);
}
check(
endProblems.length === 0,
`V6 各端 name 查找均能落到 registry(${checkedEnds} 个端已注入)`,
endProblems.slice(0, 6).join('; ') || null
);
if (endPending.length) {
console.log(` SKIP ${endPending.length} 个端尚未注入图标表(先跑 node tools/inject-icon-table.mjs):`);
endPending.forEach((p) => console.log(' - ' + p));
}
}
/* ---------- V7 分组覆盖率 ≥ 75% ---------- */
const GROUPED_OTHER = new Set(['其他', '未分组', '']);
const groups = new Set();
let grouped = 0;
for (const i of manifest.icons) {
if (!GROUPED_OTHER.has(i.group)) { grouped++; groups.add(i.group); }
}
const cov = grouped / manifest.icons.length;
check(
cov >= 0.75,
'V7 分组覆盖率 ≥ 75%(§八 第 7 条)',
`${(cov * 100).toFixed(1)}% · ${groups.size} 个分组 · 未分组 ${manifest.icons.length - grouped}`
);
/* ---------- V8 路径数字完整性(防「归一化把坐标改错」) ----------
*
* 为什么单列这一条:构建期把路径数字四舍五入到 2 位小数看似无害,但 SVG path 允许
* 「隐式分隔」—— `q-13.005.48-22.5` 是三个数,靠 `-` 和 `.` 自身当分隔符。
* 2026-09-20 实测踩到(Element Plus 的 quartz-watch):`-13.005` 被舍成 `-13` 后,
* 后面的 `.48` 粘成 `-13.48` —— 图形画错,**且不报任何错**,是靠浏览器控制台才发现的。
*
* 判据:把 registry 每个路径的**数字序列**抽出来,与 sources.json 的原串逐值比对。
* 允许 0.005 的四舍五入容差,但**不允许数字个数变化**(个数变了就是粘连/吞并)。
*/
{
const srcRaw = readIf(SRC) ? JSON.parse(read(SRC)) : null;
const problems = [];
let compared = 0;
if (!srcRaw) {
console.log(' SKIP V8 缺 sources.json(先跑 node tools/fetch-icon-sources.mjs)');
} else {
const nums = (d) => (d.match(/-?\d*\.?\d+(?:[eE][-+]?\d+)?/g) || []).map(Number);
/* 按 **source id + 名字** 建索引:同一个名字可能同时存在于多个上游
(实测 brush-filled / home-filled 在 tdesign-filled 与 element-plus 里都有),
而 manifest 记录了最终采用哪个 source —— 必须比同源的那一份,否则是假阳性。 */
const srcById = new Map(srcRaw.sources.map((s) => [s.id, s.icons]));
const manifestSource = new Map(manifest.icons.map((i) => [i.name, i.source]));
for (const [name, packed] of Object.entries(REG)) {
const srcId = manifestSource.get(name);
if (!srcId) continue; /* 别名:V5 已保证它指向真实图标 */
const bag = srcById.get(srcId);
const icon = bag && bag[name];
if (!icon) continue;
compared++;
const a = nums(packed.split('|').map((s) => s.slice(1)).join(' '));
const b = nums(icon.d.map((x) => x.p).join(' '));
if (a.length !== b.length) {
problems.push(`${name}: 数字个数 ${a.length} ≠ 源 ${b.length}`);
continue;
}
for (let i = 0; i < a.length; i++) {
if (Math.abs(a[i] - b[i]) > 0.005) {
problems.push(`${name}: 第 ${i} 个数 ${a[i]} ≠ 源 ${b[i]}`);
break;
}
}
}
check(problems.length === 0, 'V8 路径数字与源逐值一致(防隐式分隔粘连)',
problems.slice(0, 5).join('; ') || `比对 ${compared} 个同源同名图标`);
}
}
/* ---------- 汇总 ---------- */
console.log('');
console.log('未执行:把 core.js / aliases.json 重新从上游拉取核对(需联网,见 tools/fetch-icon-sources.mjs)');
console.log('\n────────────────────────────');
if (failures.length) {
console.error(`[FAIL] ${failures.length} 条图标系统断言失败(通过 ${pass} 条):`);
failures.forEach((f) => console.error(' - ' + f));
process.exit(1);
}
console.log(`[OK] 图标系统门禁全部通过(${pass} 条断言 · registry ${REG_NAMES.length} 图标)`);