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

448 lines
27 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
/**
* 图标构建:把 sources.json(第三方原始 SVGO 数据)编译成 Kole UI 的图标清单。
*
* 产物(全部入库,构建链离线可复现):
* .design_library/kole-ui/icons/manifest.json 图标清单:name / tier / source / mode / tags
* .design_library/kole-ui/icons/registry.json 渲染数据:name → 路径数据(各端运行时读这一份)
* .design_library/kole-ui/icons/ATTRIBUTION.md 来源与许可(MIT 归属,必须随分发)
* site/icons.json 文档站 IconPreview 用的轻量索引(无路径数据)
*
* 三层分级(tier)—— 决定「内联进组件包」还是「按需加载」:
* core ≥2 个上游库都有这个图标名,且是 UI 骨架高频语义(见 CORE_EXTRA)。内联。
* standard 上游有、名字被归一化收录。按需。
* extended 仅单一上游有的长尾图标(品牌 / 插画类)。按需。
*
* 命名规则:
* - 一律 kebab-case;面性图标保留 `-filled` 后缀(与上游一致,不发明第二套词)。
* - 冲突时优先级 tdesign-outline > element-plus > tdesign-filled。
* 因为线性图标与本仓库「线性图标,描边 1.5px」的规范一致(组件1.txt)。
*
* 用法:node tools/build-icons.mjs
* 零运行时依赖;退出码 0 = 成功。
*/
import { readFileSync, writeFileSync, mkdirSync, renameSync, existsSync } from 'node:fs';
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 SRC = join(ICON_DIR, 'sources.json');
const SITE = join(ROOT, 'site');
/* 上游优先级:线性优先(与规范 1.5px 描边同族),其次 Element Plus,最后面性 */
const SOURCE_PRIORITY = ['tdesign-outline', 'element-plus', 'tdesign-filled'];
/* 描边宽度:上游 TDesign 是 2(24 网格),规范要求 1.5 —— 构建期统一改。
取值按 组件1.txt「线性图标,描边1.5px」。 */
const STROKE_WIDTH = 1.5;
/* core 层:**内联进组件包**的那一批,必须小而准 —— 只放 UI 骨架高频语义。
判据不是"上游有几个库有"(那会得到 1000+ 个,内联等于把整包塞进每一页),
而是"B 端中后台界面的骨架会不会用到"。分组见 CORE_NAMES。
这批图标实测 ~200 个 / 约 40KB,作为内联表是合理的。 */
const CORE_NAMES = [
/* 方向与导航 */
'arrow-up', 'arrow-down', 'arrow-left', 'arrow-right', 'caret-up', 'caret-down',
'chevron-up', 'chevron-down', 'chevron-left', 'chevron-right', 'back', 'top', 'bottom',
'expand', 'menu-fold', 'menu-unfold', 'fold', 'unfold-less', 'unfold-more', 'sort', 'swap', 'jump',
/* 基础操作 */
'add', 'close', 'delete', 'edit', 'copy', 'save', 'search', 'refresh', 'setting', 'filter',
'upload', 'download', 'share', 'print', 'send', 'clear', 'remove',
'link', 'attach', 'pin', 'crop', 'rotate', 'scan', 'view', 'hide', 'open',
'check', 'play', 'pause', 'stop', 'refresh-left',
'refresh-right', 'fullscreen', 'zoom-in', 'zoom-out', 'more', 'menu', 'list', 'grid',
'minus', 'plus', 'mode-preview', 'poweroff',
/* 状态与反馈 */
'check-circle', 'close-circle', 'info-circle', 'error-circle', 'warning', 'help-circle',
'loading', 'success', 'error', 'info', 'help', 'question', 'lock', 'unlock',
'star', 'star-filled', 'heart', 'flag', 'shield-error', 'time', 'clock', 'certificate',
/* 数据与内容 */
'user', 'user-add', 'avatar', 'chart', 'chart-bar', 'chart-pie', 'chart-line', 'data',
'data-analysis', 'dashboard', 'file', 'folder', 'document', 'book', 'calendar', 'task',
'table', 'tree-catalog', 'map', 'location', 'code', 'terminal', 'cloud', 'server',
'image', 'video', 'camera', 'mail', 'message', 'chat', 'notification', 'bell', 'phone',
'mobile', 'monitor', 'desktop', 'keyboard', 'mouse', 'printer', 'wifi', 'bluetooth',
'battery', 'cpu', 'history', 'home', 'app', 'application',
/* 业务 */
'money', 'coin', 'wallet', 'creditcard', 'order', 'goods', 'cart', 'shop', 'store',
'gift', 'coupon', 'ticket', 'price-tag', 'box', 'undertake-delivery', 'view-organization',
'usergroup',
];
/* 语义分组:IconPreview 页的分组展示 + 检索标签。
规则按**实测的名字前缀频次**写(top 前缀见 tools/verify-icons.mjs 的覆盖率断言),
顺序即优先级:先匹配到的分组胜出。覆盖率是门禁指标,不是"看着差不多"。 */
const GROUPS = [
['方向导航', /^(arrow|caret|chevron|back|backtop|next|prev|forward|top|bottom|left|right|up|down|expand|collapse|fold|unfold|sort|swap|jump|go|return|enter|exit|move|drag|resize|fullscreen|full-screen|zoom|flip|align|indent|outdent|redirect|refresh-left|refresh-right|direction|navigate|guide|position)/],
['基础操作', /^(add|close|clear|delete|edit|pen|copy|save|search|browse|refresh|reload|setting|set-up|filter|upload|download|share|print|send|remove|minus|plus|link|attach|paperclip|pin|crop|rotate|scan|read|write|hide|view|show|open|reset|confirm|check|apply|cancel|preview|play|pause|stop|record|replay|reply|sync|update|upgrade|install|restore|cut|paste|undo|redo|lock|unlock|merge|split|magic|tool|control|edit-1)/],
['状态反馈', /^(success|error|warning|info|help|question|alert|tip|prompt|loading|circle|check-circle|close-circle|info-circle|error-circle|exclamation|star|heart|thumb|like|dislike|flag|mark|badge|certificate|verified|shield|secure|safety|risk|danger|fire|hot|new|crown|vip|level|rank|medal|trophy|award|score|grade|rate|smile|angry|calm|excited|sad|puzzle|check-|no-|ban|forbid|lock|unlock|error|progress)/],
['数据内容', /^(chart|data|analysis|analytics|statistic|table|grid|list|tree|map|graph|dashboard|report|trend|pie|bar|line|scatter|radar|gauge|metric|number|numbers|calc|formula|function|code|terminal|api|database|server|cloud|storage|disk|file|folder|document|doc|note|notebook|book|letters|text|textformat|format|font|article|page|paper|clip|attachment|archive|task|project|plan|goal|target|filter|system|component|widget|abstract|application|browser)/],
['用户组织', /^(user|usergroup|account|avatar|profile|member|team|group|contact|person|people|customer|employee|admin|manager|role|permission|authority|organization|company|department|position|job|id|card|badge|signature|fingerprint|face|eye|hand|body|health|medical|hospital|call|gesture|accessibility|personal|my-|friend)/],
['设备硬件', /^(mobile|phone|tablet|laptop|desktop|computer|monitor|screen|display|tv|camera|video|mic|microphone|speaker|sound|headphone|headset|mouse|keyboard|printer|scanner|device|hardware|cpu|chip|memory|battery|power|plug|charger|usb|bluetooth|wifi|signal|network|router|antenna|satellite|gps|location|compass|navigation|airplay|cast|projector|remote|sensor|cable)/],
['文件媒体', /^(file|folder|document|doc|note|book|read|page|paper|clip|attachment|archive|zip|pdf|word|excel|ppt|image|photo|picture|gallery|album|film|movie|music|audio|sound|voice|record|media|play|video|logo|icon|emoji|sticker|palette|brush|paint|color|crop|filter)/],
['业务商务', /^(money|coin|wallet|bank|creditcard|card|payment|pay|order|goods|product|cart|shop|store|mall|trade|deal|discount|coupon|ticket|price|cost|fee|tax|invoice|bill|receipt|refund|gift|present|package|box|delivery|ship|truck|warehouse|inventory|stock|buy|sell|auction|bid|contract|sign|shop|business|finance|fund|invest|profit|margin|budget|salary|bonus|chart-bar)/],
['品牌标识', /^(logo|brand|github|gitlab|wechat|weixin|qq|weibo|alipay|taobao|tmall|facebook|twitter|instagram|youtube|linkedin|google|apple|android|windows|chrome|firefox|safari|edge|figma|sketch|xd|photoshop|illustrator|slack|dingtalk|teams|zoom|element|tdesign|git|code-|open-source|npm|docker|linux|ubuntu|vue|react|angular|node|python|java)/],
['生活娱乐', /^(apple|cherry|chicken|coffee|dessert|dish|food|fruit|grape|ice|pear|watermelon|burger|sugar|lollipop|milk|tea|drink|goblet|sport|game|football|basketball|baseball|soccer|trophy|travel|beach|holiday|weather|sunny|cloudy|rain|snow|wind|moon|sun|star|cloud|cry|smile|angry|calm|uncomfortable|lemon|egg|bread|cake|candy|flower|plant|leaf|animal|cat|dog|bird|fish|city|building|houses|house|bridge|palace|tower|museum|institution|archway|attic|lighthouse|chimney|dam|street|road|garden|park|home|hotel|bank|hospital|school|campus|factory|warehouse|office|store|shop|market|restaurant|cafe|bar|theater|cinema|stadium|gym|airport|station|port|harbor)/],
['系统设置', /^(mode|brightness|contrast|dark|light|theme|palette|color|fill|frame|transform|animation|extension|bug|hard|sim|measurement|alarm|notification|chat|message|mail|bell|sound|volume|mute|voice|record|camera|screen|display|monitor|settings|preference|config|option|switch|toggle|slider|knob|dial|meter|gauge|indicator|status|health|diagnostic|log|history|version|update|upgrade|sync|cloud|wifi|signal|battery|power|energy|charge|plug|socket|cable|port|usb|bluetooth|network|server|database|storage|disk|memory|cpu|chip|hardware|device|equipment|machine|robot|drone|satellite|antenna|radar|sensor|detector|scanner|printer|fax|projector|microphone|speaker|headphone|headset|keyboard|mouse|joystick|touchpad|trackpad|stylus|pen|pencil|brush|eraser|ruler|scissors|clip|stapler|tape|glue|tool|wrench|hammer|screw|screwdriver|drill|saw|axe|shovel|rake|broom|bucket|ladder)/],
['任务流程', /^(task|assignment|undertake|project|plan|goal|target|milestone|schedule|timeline|workflow|process|procedure|step|stage|phase|progress|track|trace|monitor|supervise|review|approve|reject|submit|assign|delegate|transfer|handover|collaborate|cooperate|coordination|meeting|appointment|deadline|reminder|alert|notify|announce|publish|release|deploy|launch|pipeline|queue|batch|job|ticket|issue|case|request|apply|form|survey|questionnaire|vote|poll|election|evaluate|assess|score|rating|feedback|comment|discuss|reply|forward|share|mention|follow|subscribe|bookmark|collect|favorite|like|star|flag|tag|label|category|classify|group|organize|sort|filter|search|find|locate|browse|explore|discover|navigate|guide|tour|walkthrough|onboard|tutorial|help|support|service|maintain|repair|fix|debug|test|verify|validate|confirm|complete|finish|done|close|archive|delete|restore|recover|backup|export|import|migrate|transfer|copy|duplicate|clone|merge|split|combine|connect|disconnect|link|unlink|attach|detach|mount|unmount|load|unload|send|receive|deliver|dispatch|ship|track|trace)/],
];
function die(msg) {
console.error('FAIL: ' + msg);
process.exit(1);
}
function atomicWrite(file, content) {
mkdirSync(dirname(file), { recursive: true });
const tmp = `${file}.tmp-${process.pid}`;
writeFileSync(tmp, content.replace(/\r\n/g, '\n'), 'utf8');
renameSync(tmp, file);
}
/* ---------- 归一化 ---------- */
/** 图标名归一化:去首尾空白、小写、非字母数字统一成连字符、折叠重复连字符。 */
export function normalizeName(n) {
return String(n)
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '');
}
/** 取「基名」用于跨库匹配:去掉 filled/solid/outline/round 后缀与尾部序号。 */
export function baseName(n) {
return normalizeName(n).replace(/-(filled|solid|outline|round)$/, '').replace(/-[0-9]+$/, '');
}
/* ---------- 主流程 ---------- */
if (!existsSync(SRC)) die(`缺 ${SRC} —— 先跑 node tools/fetch-icon-sources.mjs`);
const raw = JSON.parse(readFileSync(SRC, 'utf8'));
/* ① 汇总:base 名 → 该名字出现在哪些 source */
const byBase = new Map();
for (const s of raw.sources) {
for (const name of Object.keys(s.icons)) {
const b = baseName(name);
if (!byBase.has(b)) byBase.set(b, new Set());
byBase.get(b).add(s.id);
}
}
/* 上游共识(≥2 个库都有)—— core 层的客观依据 */
const consensus = new Set([...byBase.entries()].filter(([, ids]) => ids.size >= 2).map(([b]) => b));
/* ② 选源:按优先级挑第一个真正拥有的 source,同 source 内优先取规范名 */
const picked = new Map(); // canonical -> { source, iconName, icon, tier }
for (const srcId of SOURCE_PRIORITY) {
const s = raw.sources.find((x) => x.id === srcId);
if (!s) continue;
for (const [name, icon] of Object.entries(s.icons)) {
const canonical = normalizeName(name);
if (picked.has(canonical)) continue;
picked.set(canonical, {
source: srcId,
iconName: name,
icon,
tier: consensus.has(baseName(name)) ? 'standard' : 'extended',
});
}
}
/* ③ core 层:命中 CORE_NAMES 的图标内联进组件包,其余按需。
名字对不上时尝试 `-filled` 变体(规范常写 `star`,上游可能是 `star-filled`)。 */
const coreSet = new Set();
const coreMissed = [];
for (const raw of CORE_NAMES) {
const c = normalizeName(raw);
if (picked.has(c)) coreSet.add(c);
else if (picked.has(c + '-filled')) coreSet.add(c + '-filled');
else coreMissed.push(c);
}
for (const c of coreSet) picked.get(c).tier = 'core';
/* core 里有名字在本仓库图标集里根本不存在 → 说明清单写错了,必须报出来而不是静默跳过 */
if (coreMissed.length) {
console.log(` 注意:CORE_NAMES 里 ${coreMissed.length} 个名字在上游不存在(已跳过):${coreMissed.join(', ')}`);
}
/* ④ 分组:按 GROUPS 规则命中,未命中归「其他」 */
function groupOf(name) {
const b = baseName(name);
for (const [label, re] of GROUPS) {
if (re.test(b) || re.test(name)) return label;
}
/* 长尾兜底:上游把 AI 系列、表情系列、饮食系列铺得很细(实测 289 个不同的
首段前缀),逐个写规则不值得。按"是不是 AIGC / 表情 / 饮食"归到已有分组,
剩下的才进「其他」。这样「其他」保持为真正的未知项,便于日后审计。 */
if (/^(ai|machine|deep|neural|prompt|model|token|dataset|train|infer)/.test(b)) return '数据内容';
if (/^(smile|angry|calm|excited|sad|cry|swear|surprised|speechless|unhappy|uncomfortable|confused|thinking|wink|kiss|laugh|bored|tired|sick|scared|shocked|proud|humble|neutral|delighted|amazed|curious|determined|disappointed|embarrassed|frustrated|grateful|guilty|hopeful|jealous|lonely|nostalgic|optimistic|pessimistic|relieved|satisfied|stressed|sympathetic|thankful|thoughtful|worried)/.test(b)) return '状态反馈';
if (/^(rice|noodle|soup|meat|vegetable|seafood|snack|breakfast|lunch|dinner|meal|recipe|kitchen|chef|restaurant|fog|wave|thunderstorm|snow|rain|sunny|cloudy|wind|humidity|temperature|weather|season|spring|summer|autumn|winter)/.test(b)) return '生活娱乐';
if (/^(mosque|pyramid|panorama|landmark|monument|statue|memorial|temple|church|castle|fortress|ruins|heritage|tourist|sightseeing|attraction|flight|traffic|transport|vehicle|car|bus|train|subway|metro|taxi|bicycle|motorcycle|scooter|boat|ship|plane|helicopter|rocket|spacecraft)/.test(b)) return '设备硬件';
return '其他';
}
/* ⑤ 生成 registry + manifest */
const registry = {};
const manifestIcons = [];
for (const [name, p] of [...picked.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
const { icon } = p;
/* 路径压缩成单字符串:"S<d>|F<d>|…",S=描边 F=填充。
这样单图标体积最小,且各端解析逻辑一致(见 frameworks/Icon.js 的 parsePath)。 */
const paths = icon.d.map((x) => (x.m === 'stroke' ? 'S' : 'F') + x.p).join('|');
registry[name] = paths;
manifestIcons.push({
name,
tier: p.tier,
source: p.source,
mode: icon.d.every((x) => x.m === 'stroke') ? 'stroke' : icon.d.some((x) => x.m === 'stroke') ? 'mixed' : 'fill',
group: groupOf(name),
viewBox: icon.v || '0 0 24 24',
paths: icon.d.length,
});
}
/* ⑥ 自检:不许有重复名、不许有空路径、core 层不许为空 */
if (!Object.keys(registry).length) die('registry 为空');
const dupes = manifestIcons.map((i) => i.name).filter((n, i, a) => a.indexOf(n) !== i);
if (dupes.length) die('图标重名:' + dupes.slice(0, 5).join(', '));
for (const [n, p] of Object.entries(registry)) {
if (!p || /(\|\||\|\$|^\|)/.test(p)) die(`路径格式异常:${n}`);
}
const coreCount = manifestIcons.filter((i) => i.tier === 'core').length;
if (coreCount < 30) die(`core 层只有 ${coreCount} 个,明显偏少(期望 ≥30)`);
const byTier = { core: 0, standard: 0, extended: 0 };
const byGroup = {};
for (const i of manifestIcons) {
byTier[i.tier]++;
byGroup[i.group] = (byGroup[i.group] || 0) + 1;
}
const viewBoxes = {};
for (const i of manifestIcons) viewBoxes[i.viewBox] = (viewBoxes[i.viewBox] || 0) + 1;
/* viewBox 例外表:只列不是默认 24 网格的那些,避免把 2000+ 个默认值写进产物 */
const vboxTable = {};
for (const i of manifestIcons) {
if (i.viewBox !== '0 0 24 24') vboxTable[i.name] = i.viewBox;
}
/* ⑦ 写产物 */
const registryPayload = {
schemaVersion: 1,
strokeWidth: STROKE_WIDTH,
/* viewBox 例外表随 registry 一起发:渲染方必须查它,写死 24 网格会让
1024 网格的 Element Plus 图标与 25 个 24×25 图标被拉伸变形。 */
viewBox: vboxTable,
note: '本文件为生成物(tools/build-icons.mjs)。路径格式:S<d>=描边,F<d>=填充,多段用 | 分隔。',
icons: registry,
};
atomicWrite(join(ICON_DIR, 'registry.json'), JSON.stringify(registryPayload));
const manifest = {
schemaVersion: 1,
library: 'kole-ui',
generatedBy: 'tools/build-icons.mjs',
generatedAt: raw.generatedAt,
strokeWidth: STROKE_WIDTH,
tiers: {
core: '内联进组件包:上游共识 ∧ UI 骨架高频语义',
standard: '上游共识图标,按需加载',
extended: '单一上游的长尾图标(品牌 / 插画类),按需加载',
},
naming: 'kebab-case;面性图标保留 -filled 后缀;冲突优先级 tdesign-outline > element-plus > tdesign-filled',
sources: raw.sources.map((s) => ({
id: s.id,
package: s.package,
version: s.version,
license: s.license,
copyright: s.copyright,
homepage: s.homepage,
})),
total: manifestIcons.length,
byTier,
byGroup,
viewBoxes,
icons: manifestIcons,
};
atomicWrite(join(ICON_DIR, 'manifest.json'), JSON.stringify(manifest, null, 2));
/* 文档站用的轻量索引:不含路径数据,IconPreview 页首屏只加载这一份。
用「列式」存法(names / tiers / groups 三个平行数组)而不是对象数组:
实测对象数组 119KB、列式 78KB,同样信息量少 35%。 */
const siteIndex = {
generatedAt: raw.generatedAt,
total: manifestIcons.length,
strokeWidth: STROKE_WIDTH,
groups: [...new Set(manifestIcons.map((i) => i.group))],
tiers: ['core', 'standard', 'extended'],
names: manifestIcons.map((i) => i.name),
/* tier 压成 0/1/2 索引,group 压成分组数组下标 —— 都是短整数,JSON 里最省 */
tier: manifestIcons.map((i) => ['core', 'standard', 'extended'].indexOf(i.tier)),
group: manifestIcons.map((i) => [...new Set(manifestIcons.map((x) => x.group))].indexOf(i.group)),
};
atomicWrite(join(SITE, 'icons.json'), JSON.stringify(siteIndex));
/* ⑧ 各端运行时模块:把 core 层(内联那一批)编译成一个自包含 JS。
为什么单独一个文件:11 个端实现都要「名字 → SVG 路径」这一段逻辑,
分散写 11 份必然漂移。这一段是**纯数据 + 纯函数**,不含任何类名 ——
所以 PC 与移动端可以共用同一份(类名隔离由各端模板负责,见 PLATFORMS.md)。 */
const coreIcons = {};
for (const i of manifestIcons) {
if (i.tier === 'core') coreIcons[i.name] = registry[i.name];
}
const runtime = `/* Kole UI 图标运行时 · 生成物(tools/build-icons.mjs),勿手改。
*
* 数据来源与许可见同目录 ATTRIBUTION.md(MIT;TDesign + Element Plus)。
*
* 这里只有 core 层(${Object.keys(coreIcons).length} 个)—— UI 骨架高频图标,内联进组件包。
* 全量 ${manifestIcons.length} 个在 registry.json,用 KOLE_ICONS.loadAll() 按需取。
*
* 路径格式:'S<d>' 描边段 / 'F<d>' 填充段,多段用 | 分隔。
* 描边宽度统一 ${STROKE_WIDTH}(规范 组件1.txt「图标规范:线性图标,描边1.5px」)。
*/
(function (root, factory) {
if (typeof module === 'object' && module.exports) module.exports = factory();
else root.KOLE_ICONS = factory();
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
var STROKE_WIDTH = ${STROKE_WIDTH};
var CORE = ${JSON.stringify(coreIcons)};
/* viewBox 例外(${Object.keys(vboxTable).length} 个不是 24 网格):写死 viewBox 会让它们被拉伸 */
var VBOX = ${JSON.stringify(vboxTable)};
/** 解析路径串 → [{ d, mode }] */
function parse(raw) {
if (!raw) return [];
return raw.split('|').map(function (seg) {
return { mode: seg.charAt(0) === 'S' ? 'stroke' : 'fill', d: seg.slice(1) };
});
}
/** 渲染成 SVG 字符串。viewBox 按图标查表(上游有非 24 网格的图标)。 */
function svg(name, opts) {
opts = opts || {};
var raw = CORE[name] || (opts.registry && opts.registry[name]) || null;
if (!raw) return '';
var vb = opts.viewBox || VBOX[name] || '0 0 24 24';
var body = parse(raw).map(function (p) {
return p.mode === 'stroke'
? '<path d="' + p.d + '" fill="none" stroke="currentColor" stroke-width="' + STROKE_WIDTH + '" stroke-linecap="round" stroke-linejoin="round"/>'
: '<path d="' + p.d + '" fill="currentColor"/>';
}).join('');
return '<svg viewBox="' + vb + '" width="1em" height="1em" aria-hidden="true" focusable="false">' + body + '</svg>';
}
/** 该名字是否在内联表里(false 时端上需要 loadAll) */
function has(name) { return Object.prototype.hasOwnProperty.call(CORE, name); }
/** 取全量(浏览器端 fetch registry.json;已完成过就复用) */
var loading = null;
function loadAll(url) {
if (loading) return loading;
if (typeof fetch !== 'function') return Promise.resolve(CORE);
loading = fetch(url || 'icons/registry.json')
.then(function (r) { return r.ok ? r.json() : { icons: {} }; })
.then(function (j) {
var icons = (j && j.icons) || {};
Object.keys(icons).forEach(function (k) { CORE[k] = icons[k]; });
return CORE;
})
.catch(function () { return CORE; });
return loading;
}
return {
strokeWidth: STROKE_WIDTH,
coreCount: Object.keys(CORE).length,
core: CORE,
viewBox: VBOX,
parse: parse,
svg: svg,
has: has,
loadAll: loadAll,
};
});
`;
atomicWrite(join(ICON_DIR, 'core.js'), runtime);
/* viewBox 例外表单独一份 JS:文档站与测试可以 import 它做断言 */
const vboxJs = `/* Kole UI 图标 viewBox 例外表 · 生成物(tools/build-icons.mjs),勿手改。\n *\n * 上游绝大多数图标是 24 网格,但有 ${Object.keys(vboxTable).length} 个不是(实测 24×25 / 25×24 /\n * 1024 三种)。renderSvg 必须查这张表,写死 viewBox 会让这些图标被拉伸变形。\n */\nexport const ICON_VIEWBOX_EXCEPTIONS = ${JSON.stringify(vboxTable, null, 2)};\n`;
atomicWrite(join(ICON_DIR, 'viewbox.js'), vboxJs);
/* ⑨ MIT 归属:分发时必须随包(两个上游库都要求保留版权声明) */
const attribution = `# 图标来源与许可
本目录下的 \`sources.json\` / \`registry.json\` / \`manifest.json\` 由 \`tools/build-icons.mjs\`
从下列开源图标库编译而来。它们均为 **MIT 许可**,允许商用与修改,**要求保留版权声明**——
本文件即为该声明,分发(npm 包 / 部署镜像)时必须一并携带。
| 来源 | 包 | 版本 | 许可 | 版权 | 主页 |
|---|---|---|---|---|---|
${raw.sources
.map((s) => `| ${s.id} | \`${s.package}\` | ${s.version} | ${s.license} | ${s.copyright} | ${s.homepage} |`)
.join('\n')}
## 上游原始包的校验和(sha256)
构建时逐包校验,防止"某天图标悄悄变了":
${raw.sources
.map((s) => `- \`${s.package}@${s.version}\`: \`${s.sha256}\``)
.join('\n')}
## 本仓库所做的归一化(不是照抄)
1. **修正 \`view-box\` 拼写错误**:TDesign 全部 2356 个 SVG 把属性写成 \`view-box\`
(无效属性,浏览器会忽略并按默认视口缩放)。本仓库统一写回规范的 \`viewBox\`。
\`tools/verify-icons.mjs\` 卡这条。
2. **描边宽度归一为 ${STROKE_WIDTH}px**:TDesign 上游为 \`stroke-width="2"\`(24 网格),
而本仓库规范(\`.design_library/kole-ui/specs/组件1.txt\`「图标规范」)要求
「线性图标,描边1.5px,尺寸16/20/24px」。差异在构建期抹平。
3. **丢弃 \`fill="transparent"\` 命中区路径**:那是交互热区不是视觉内容,
保留会让未选中态出现意外描边。
4. **数字精度归一到 2 位小数 + 折叠空白**:实测省约 25% 体积,视觉无差异。
5. **统一命名**:kebab-case;同名冲突按 \`tdesign-outline > element-plus > tdesign-filled\` 取一。
## 许可全文
MIT License
### TDesign Icons
${raw.sources.find((s) => s.id.startsWith('tdesign'))?.copyright || 'Copyright (c) 2021-present TDesign'}
### Element Plus Icons
${raw.sources.find((s) => s.id === 'element-plus')?.copyright || 'Copyright (c) 2016-present Element Plus'}
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
`;
atomicWrite(join(ICON_DIR, 'ATTRIBUTION.md'), attribution);
/* ⑨ 输出汇总 */
console.log('图标构建');
console.log(` 图标总数 ${manifestIcons.length}`);
console.log(` 分层 core ${byTier.core} / standard ${byTier.standard} / extended ${byTier.extended}`);
console.log(` 分组 ${Object.entries(byGroup).map(([k, v]) => `${k} ${v}`).join(' / ')}`);
console.log(` viewBox ${Object.entries(viewBoxes).map(([k, v]) => `${k}×${v}`).join(' / ')}`);
console.log(` registry ${(JSON.stringify(registryPayload).length / 1024).toFixed(0)}KB`);
console.log(` manifest ${(JSON.stringify(manifest).length / 1024).toFixed(0)}KB`);
console.log(` site/icons.json ${(JSON.stringify(siteIndex).length / 1024).toFixed(0)}KB`);
console.log('OK');