Files
aurora-admin/tools/fetch-icon-sources.mjs
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

322 lines
14 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
/**
* 图标源固化:把第三方图标库的 SVG 取下来,归一化成仓库内的单一 JSON 真源。
*
* 为什么要有这一步:
* 1. 图标来自 TDesign 与 Element Plus(均 MIT),但它们的 SVG 文件有真实缺陷 ——
* TDesign 全部 2356 个文件把 `viewBox` 写成 `view-box`(无效属性),
* 照抄会让图标在部分浏览器里按错误尺寸缩放。必须归一化后再进仓库。
* 2. 构建链不能联网:CI 与 `npm run build:icons` 都必须离线可复现,
* 所以「下载」只发生在这里,产物 sources.json 入库,之后全链路离线。
*
* 产物:.design_library/kole-ui/icons/sources.json
* { schemaVersion, generatedAt, sources: [{ id, package, version, license, sha256, icons }] }
* icons: { "<name>": { d: [ {p, mode} ], v?: "0 0 24 24" } }
* mode: "stroke" 线性(描边)| "fill" 面性(填充)
*
* 用法:
* node tools/fetch-icon-sources.mjs # 联网下载(校验 sha256)
* node tools/fetch-icon-sources.mjs --from-dir <dir> # 用已解压的目录(离线)
*
* 零运行时依赖;退出码 0 = 成功,非 0 = 失败。
*/
import { writeFileSync, readFileSync, readdirSync, existsSync, mkdirSync, renameSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { createHash } from 'node:crypto';
import { pathToFileURL } from 'node:url';
import { execFileSync } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const OUT_DIR = join(ROOT, '.design_library', 'kole-ui', 'icons');
const OUT = join(OUT_DIR, 'sources.json');
/* 钉死版本与校验和:源换了必须显式改这里,避免"某天图标悄悄变了"。 */
const SOURCES = [
{
id: 'tdesign-outline',
package: 'tdesign-icons-svg',
version: '0.4.7',
tarball: 'https://registry.npmjs.org/tdesign-icons-svg/-/tdesign-icons-svg-0.4.7.tgz',
sha256: '4918267445549dfa0729c4f4cfe3ff54aa96251c01656cbce175d03e8cc175af',
license: 'MIT',
copyright: 'Copyright (c) 2021-present TDesign',
homepage: 'https://github.com/Tencent/tdesign-icons',
srcDir: 'src',
/* 只取线性(描边)集:面性集另起一个 source,避免同名覆盖 */
filter: (n) => !n.endsWith('-filled'),
nameStrip: null,
},
{
id: 'tdesign-filled',
package: 'tdesign-icons-svg',
version: '0.4.7',
tarball: 'https://registry.npmjs.org/tdesign-icons-svg/-/tdesign-icons-svg-0.4.7.tgz',
sha256: '4918267445549dfa0729c4f4cfe3ff54aa96251c01656cbce175d03e8cc175af',
license: 'MIT',
copyright: 'Copyright (c) 2021-present TDesign',
homepage: 'https://github.com/Tencent/tdesign-icons',
srcDir: 'src',
filter: (n) => n.endsWith('-filled'),
/* filled 集的文件名是 <base>-filled,归一到 <base>-filled 保序(不撞线性集) */
nameStrip: null,
},
{
id: 'element-plus',
package: '@element-plus/icons-svg',
version: '2.3.2',
tarball: 'https://registry.npmjs.org/@element-plus/icons-svg/-/icons-svg-2.3.2.tgz',
sha256: 'b525dc9d0f1150ac28fc6246df07288d6809dcb417927b7af894feb3fd1bbb4e',
license: 'MIT',
copyright: 'Copyright (c) 2016-present Element Plus',
homepage: 'https://github.com/element-plus/element-plus-icons',
srcDir: '',
filter: () => true,
nameStrip: null,
},
];
/* ---------- SVG 解析:抽图形 + 判断线性/面性 ---------- */
/**
* 从一份 SVG 文本里提取描边与填充路径。
*
* 判定规则(按 TDesign/ElementPlus 的实际写法,不做启发式猜测):
* - `stroke="currentColor"` → 线性路径(mode: stroke)
* - `fill="currentColor"` → 面性路径(mode: fill)
* - `fill="transparent"` → 命中区(hit area),不是视觉内容,丢弃
* - 同一 d 同时出现在 stroke 与 fill 两条里 → 合并为一条 stroke(TDesign 的
* outline 集就是这么写的:先画透明命中区,再叠描边)
*
* ⚠️ 除 `<path>` 外还必须处理 `<circle>` / `<ellipse>`:
* 实测上游有 36 个文件(TDesign 的 circle / round / brightness / image 等)
* 把图形写成 `<circle cx cy r>` 而不是 `<path d>`。只认 `<path>` 会**静默丢掉**
* 这些图形 —— 图标看起来"渲染成功"但实际是空的(TDesign 的 `circle` 与 `round`
* 就因此整个消失,是覆盖率审计才发现的)。这里把圆/椭圆等价换算成 path 的 arc 命令。
*/
export function parseSvgPaths(text) {
const nodes = [...text.matchAll(/<(path|circle|ellipse)\b([^>]*?)\/?>/g)].map((m) => ({
tag: m[1],
attrs: m[2],
}));
const byD = new Map();
for (const { tag, attrs } of nodes) {
const d = tag === 'path' ? (attrs.match(/\sd="([^"]+)"/) || [])[1] : shapeToPath(tag, attrs);
if (!d) continue;
const isStroke = /stroke="currentColor"/.test(attrs);
const isFill = /fill="currentColor"/.test(attrs);
const isTransparent = /fill="transparent"/.test(attrs);
if (isTransparent) continue;
if (!isStroke && !isFill) continue;
const cur = byD.get(d) || { mode: 'fill' };
/* 描边优先级高于填充:同名 d 两者都有时按描边渲染(TDesign outline 的写法) */
if (isStroke) cur.mode = 'stroke';
byD.set(d, cur);
}
return [...byD.entries()].map(([d, meta]) => ({ d, mode: meta.mode }));
}
/** `<circle>` / `<ellipse>` → 等价 path(用两段 arc 画整圆/整椭圆) */
export function shapeToPath(tag, attrs) {
const num = (name, dflt) => {
const m = attrs.match(new RegExp(`\\s${name}="([^"]+)"`));
if (!m) return dflt;
const v = parseFloat(m[1]);
return Number.isFinite(v) ? v : dflt;
};
if (tag === 'circle') {
const cx = num('cx', 0), cy = num('cy', 0), r = num('r', 0);
if (!(r > 0)) return null;
return `M${cx} ${cy - r}A${r} ${r} 0 1 1 ${cx} ${cy + r}A${r} ${r} 0 1 1 ${cx} ${cy - r}Z`;
}
if (tag === 'ellipse') {
const cx = num('cx', 0), cy = num('cy', 0), rx = num('rx', 0), ry = num('ry', 0);
if (!(rx > 0 && ry > 0)) return null;
return `M${cx} ${cy - ry}A${rx} ${ry} 0 1 1 ${cx} ${cy + ry}A${rx} ${ry} 0 1 1 ${cx} ${cy - ry}Z`;
}
return null;
}
/**
* 归一化数字精度:2 位小数 + 压缩空白。
*
* ⚠️ 这里有一个真实踩过的坑(Element Plus 的 quartz-watch 图标):
* SVG path 允许「隐式分隔」—— `q-13.005.48-22.5` 是三个数:`-13.005`、`.48`、`-22.5`,
* 靠 `-` 和 `.` 自身当分隔符。如果简单地把 `-13.005` 四舍五入成 `-13`(丢掉小数),
* 后面的 `.48` 就会**粘成** `-13.48` —— 那是另一个坐标,图形会画错,而且不报错。
* 两条保险:
* ① 原本有小数位的数字,输出**至少保留 1 位小数**(`-13.005` → `-13.0`),
* 这样后面的 `.48` 不可能再被吸收;
* ② 归一化后**复核数字序列**,与原串逐值比对,不一致就原样返回(宁可不省体积也不许画错)。
*
* 不额外插空格:SVG 路径语法里 `5-3` 与 `5 .3` 都合法(`-`/`.` 本身就是分隔符),
* 插空格实测让 registry 涨 185KB 而无一利。
*/
export function optimizePath(d) {
const optimized = d
/* 有小数位的数:四舍五入到 2 位,去掉多余尾零但**至少留 1 位小数**。
留 1 位是关键:`-13.005` → `-13.0`(而不是 `-13`),这样后面隐式相连的
`.48` 不会被吸收成 `-13.48`。 */
.replace(/-?\d+\.\d+/g, (m) => {
const v = Math.round(parseFloat(m) * 100) / 100;
const s = v.toFixed(2).replace(/0+$/, '');
return s.endsWith('.') ? s + '0' : s;
})
.replace(/\s+/g, ' ')
.trim();
/* 复核:优化前后的数字序列必须逐个对得上,否则原样返回(宁可不省体积也不许画错) */
if (!sameNumbers(d, optimized)) return d;
return optimized;
}
/** 抽出路径串里的数字序列(用于优化前后比对) */
export function numberSequence(d) {
return (d.match(/-?\d*\.?\d+(?:[eE][-+]?\d+)?/g) || []).map((x) => {
const v = parseFloat(x);
return Number.isFinite(v) ? v : x;
});
}
/** 两个路径串是否表示同一组数字(顺序与数值都一致) */
function sameNumbers(a, b) {
const x = numberSequence(a);
const y = numberSequence(b);
if (x.length !== y.length) return false;
for (let i = 0; i < x.length; i++) {
if (typeof x[i] === 'number' && typeof y[i] === 'number') {
/* 四舍五入会改值,所以只比较「结构」:位数一致 + 值差在 0.005 容差内 */
if (Math.abs(x[i] - y[i]) > 0.005) return false;
} else if (x[i] !== y[i]) return false;
}
return true;
}
/**
* 读 viewBox。
*
* ⚠️ TDesign 全部 2356 个 SVG 把属性写成 `view-box`(连字符、小写 b)——
* 那是**无效属性**,浏览器会忽略它并把图标按默认 300×150 缩放。
* 这里两种拼写都认,并在产物里统一写回规范的 `viewBox`,
* 拼写错误不许流进仓库(tools/verify-icons.mjs 会卡这条)。
*/
export function parseViewBox(text) {
const m = text.match(/\sview-?Box="([^"]+)"/i);
return m ? m[1].trim() : null;
}
function die(msg) {
console.error('FAIL: ' + msg);
process.exit(1);
}
function sha256File(p) {
return createHash('sha256').update(readFileSync(p)).digest('hex');
}
/* ---------- 取源:联网下载 或 复用已解压目录 ---------- */
const fromDirArg = process.argv.indexOf('--from-dir');
const FROM_DIR = fromDirArg >= 0 ? process.argv[fromDirArg + 1] : null;
if (fromDirArg >= 0 && !FROM_DIR) die('--from-dir 需要跟一个目录参数');
const CACHE = join(ROOT, '.cache-icons');
if (!FROM_DIR && !existsSync(CACHE)) mkdirSync(CACHE, { recursive: true });
/** 返回每个 source 的「图标目录绝对路径」 */
function resolveDir(src) {
if (FROM_DIR) {
/* 离线模式:约定 --from-dir 下按 source.id 分子目录 */
const d = join(FROM_DIR, src.id, src.srcDir);
if (!existsSync(d)) die(`离线目录不存在:${d}`);
return d;
}
const tgz = join(CACHE, `${src.id}-${src.version}.tgz`);
if (!existsSync(tgz)) {
console.log(` 下载 ${src.package}@${src.version} …`);
execFileSync('curl', ['-sL', '--fail', '-m', '120', src.tarball, '-o', tgz], { stdio: 'inherit' });
}
const got = sha256File(tgz);
if (got !== src.sha256) die(`${src.id} sha256 不符\n 期望 ${src.sha256}\n 实际 ${got}`);
const extract = join(CACHE, `${src.id}-${src.version}`);
/* 先解到 temp 再改名换入:避免半解压的目录被下次运行当成"已解压"复用。
两个 Windows 实测坑:
① tar 的 `-C <绝对路径>` 在含 `C:` 时会被当成远程主机("Cannot connect to C:");
② `--one-top-level` 与 `--strip-components` 同用会解错层级。
所以改成 cwd=目标目录 + 只用 --strip-components。 */
if (!existsSync(extract)) {
const tmpName = `.tmp-${src.id}-${Date.now()}`;
const tmp = join(CACHE, tmpName);
mkdirSync(tmp, { recursive: true });
try {
/* cwd=CACHE + 相对路径:tar 把含 `C:` 的绝对路径当成远程主机
("Cannot connect to C:" 实测),所以 -C 只能用相对目录名。 */
execFileSync('tar', ['xzf', `${src.id}-${src.version}.tgz`, '-C', tmpName, '--strip-components=1'],
{ cwd: CACHE, stdio: ['ignore', 'ignore', 'pipe'] });
} catch (e) {
die(`${src.id} 解压失败:${e.stderr ? e.stderr.toString() : e.message}`);
}
renameSync(tmp, extract);
}
return join(extract, src.srcDir);
}
/* ---------- 主流程 ----------
只在「作为脚本直接运行」时执行;被 import 时(测试要复用 optimizePath /
parseSvgPaths 这些纯函数)不产生任何副作用 —— 否则 import 一次就会去联网拉包。 */
const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMain) main();
function main() {
console.log('图标源固化');
console.log(`仓库:${ROOT}`);
const out = { schemaVersion: 1, generatedAt: null, sources: [] };
for (const src of SOURCES) {
const dir = resolveDir(src);
const files = readdirSync(dir).filter((f) => f.endsWith('.svg'));
const icons = {};
let paths = 0;
for (const f of files) {
const name = src.nameStrip ? src.nameStrip(f.replace(/\.svg$/, '')) : f.replace(/\.svg$/, '');
if (!src.filter(name)) continue;
const text = readFileSync(join(dir, f), 'utf8');
const ps = parseSvgPaths(text);
if (!ps.length) continue;
const vb = parseViewBox(text);
icons[name] = { v: vb, d: ps.map((p) => ({ p: optimizePath(p.d), m: p.mode })) };
paths += ps.length;
}
const names = Object.keys(icons);
const bytes = JSON.stringify(icons).length;
console.log(
` ${src.id.padEnd(16)} ${names.length} 个图标 / ${paths} 条路径 / ${(bytes / 1024).toFixed(1)}KB`
);
if (!names.length) die(`${src.id} 没有解析出任何图标`);
out.sources.push({
id: src.id,
package: src.package,
version: src.version,
license: src.license,
copyright: src.copyright,
homepage: src.homepage,
sha256: src.sha256,
iconCount: names.length,
icons,
});
}
out.generatedAt = new Date().toISOString().slice(0, 10);
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
writeFileSync(OUT, JSON.stringify(out), 'utf8');
const total = out.sources.reduce((a, s) => a + s.iconCount, 0);
console.log('');
console.log(`写入 ${OUT}`);
console.log(` 来源 ${out.sources.length} 个 | 图标合计 ${total} 个 | 文件 ${(readFileSync(OUT).length / 1024).toFixed(0)}KB`);
console.log('OK');
}