Files
aurora-admin/tools/build-icon-aliases.mjs
T
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

331 lines
16 KiB
JavaScript
Raw 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
/**
* 别名构建:把上游各库的历史图标名映射到本仓库的 canonical 名。
*
* 为什么需要别名层(而不是把名字改成上游的样子):
* Element UI 的 283 个图标名是**图标字体**时代的名字,画的是「线」不是「形」——
* 它们不携带形状数据,只携带一个码点。我们要的是可渲染的 SVG,
* 所以必须为每个历史名字**指定**它对应哪个真实图标。
* 这一步是显式映射,不做模糊匹配:猜错了用户看到的就是错的图标,
* 而"看起来差不多"在静默处最危险(历史教训:6 次"文档声称完成但代码缺失")。
*
* 三类映射:
* identity 名字本身就在 registry 里(同名直通)
* stripped 去掉 -solid/-outline/-round/-<n> 后缀后命中
* semantic 需要人工指定(历史名与新名不同词)——见 SEMANTIC 表
*
* 产物:.design_library/kole-ui/icons/aliases.json
* 用法:node tools/build-icon-aliases.mjs
*/
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 REGISTRY = join(ICON_DIR, 'registry.json');
const OUT = join(ICON_DIR, 'aliases.json');
/* Element UI 历史名清单:由 tools/fetch-icon-sources.mjs 的取证阶段产出,
这里作为输入读入(离线:文件已在仓库里)。 */
const EL_NAMES = join(ICON_DIR, 'element-ui-names.txt');
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);
}
if (!existsSync(REGISTRY)) die(`缺 ${REGISTRY} —— 先跑 node tools/build-icons.mjs`);
const registry = JSON.parse(readFileSync(REGISTRY, 'utf8')).icons;
const names = new Set(Object.keys(registry));
const strip = (n) => n.replace(/-(solid|outline|round|filled)$/, '').replace(/-[0-9]+$/, '');
/**
* 语义映射表:历史名 → canonical 名。逐条要能说清"为什么是它"。
*
* 未列入的名字由下面的自动规则处理(同名 / 去后缀)。
* 规则覆盖不到的会进 `unresolved` 并被 verify 门禁拦下 —— 不允许静默丢弃。
*/
const SEMANTIC = {
/* Element UI 的后台菜单系列(s-*):每个都是"某类管理台入口",
TDesign 用 view-* 与 setting-* 系列表达同一批语义。 */
's-check': 'check-circle',
's-claim': 'file-copy',
's-comment': 'chat',
's-cooperation': 'usergroup',
's-custom': 'setting',
's-data': 'chart-bar',
's-finance': 'money',
's-flag': 'flag',
's-fold': 'folder',
's-goods': 'goods',
's-grid': 'grid',
's-help': 'help-circle',
's-home': 'home',
's-management': 'view-organization',
's-marketing': 'present',
's-open': 'unlock',
's-operation': 'control-platform',
's-opportunity': 'chart-bubble',
's-order': 'order',
's-platform': 'app',
's-promotion': 'loudspeaker',
's-release': 'send',
's-shop': 'shop',
's-ticket': 'ticket',
's-tools': 'tools',
's-unfold': 'menu-unfold',
/* 单点语义 */
'attract': 'star',
'bangzhu': 'help-circle',
'bank-card': 'creditcard',
'c-scale-to-original': 'fullscreen',
'close-notification': 'mute-notification',
'cloudy-and-sunny': 'cloudy-day',
'date': 'calendar',
'discover': 'compass',
'heavy-rain': 'rain-heavy',
'light-rain': 'rain-light',
'mobile-phone': 'mobile',
'news': 'article',
'picture-outline-round': 'image',
'platform-eleme': 'app',
'potato-strips': 'food',
'receiving': 'download',
'scissors': 'cut',
'star-off': 'star',
'star-on': 'star-filled',
'success': 'check-circle',
'table-lamp': 'lightbulb',
'tableware': 'fork-spoon',
'thumb': 'thumb-up',
'truck': 'undertake-delivery',
'turn-off-microphone': 'sound-mute',
'upload2': 'upload',
'water-cup': 'coffee',
/* Element UI 的 -left/-right 是被解析器截下的碎片(原为 d-arrow-left 等),
不是真实图标名,显式标注为"忽略"而不是映射到某个猜的图标。 */
'-left': null,
'-right': null,
};
/* 移动端既有 9 名字形表 → 新图标集(保持既有 4 端行为不破,见 ICON-SPEC §六)。
注意:check / close / star / plus / minus / more **本身已是 canonical 名且语义一致**,
不需要别名条目 —— 直接命中真实图标即可(更精确,也不会遮蔽真图标)。
只有旧名与新集用词不同的三个才需要映射。 */
const MOBILE_LEGACY = {
warn: 'warning', /* 旧名 warn,新集是 warning */
info: 'info-circle', /* 旧名 info 画的是 ⓘ(圆圈 i),对应 info-circle */
arrow: 'chevron-right', /* 旧名 arrow 画的是 ›(右箭头) */
};
const aliases = {};
const ignored = [];
const problems = [];
/* ① Element UI 历史名 */
if (!existsSync(EL_NAMES)) die(`缺 ${EL_NAMES}`);
const elNames = readFileSync(EL_NAMES, 'utf8')
.split(/\r?\n/)
.map((s) => s.trim())
/* 该文件带 `#` 头注释(来源/许可/提取命令),跳过 */
.filter((s) => s && s.length > 1 && !s.startsWith('#'));
for (const n of elNames) {
if (Object.prototype.hasOwnProperty.call(SEMANTIC, n)) {
const target = SEMANTIC[n];
if (target === null) { ignored.push(n); continue; }
if (!names.has(target)) { problems.push(`SEMANTIC['${n}'] → '${target}' 不存在`); continue; }
if (target !== n) aliases[n] = target;
continue;
}
if (names.has(n)) continue; /* 同名直通,不需要别名条目 */
const s = strip(n);
if (names.has(s)) { aliases[n] = s; continue; }
const cands = [...names].filter((x) => strip(x) === s).sort((a, b) => a.length - b.length);
if (cands.length) { aliases[n] = cands[0]; continue; }
problems.push(`无法解析 ${n}`);
}
/* ② 移动端历史名。
同一条铁律:如果某个旧名本身就是真实图标名,就**不许**再给它挂别名 ——
否则 `plus` 这种真图标会被旧语义改写(门禁会当场拦下,见文件末尾自检)。 */
for (const [from, to] of Object.entries(MOBILE_LEGACY)) {
if (!names.has(to)) { problems.push(`MOBILE_LEGACY['${from}'] → '${to}' 不存在`); continue; }
if (from === to) continue;
if (names.has(from)) { problems.push(`MOBILE_LEGACY['${from}'] 与真实图标同名,不许遮蔽`); continue; }
aliases[from] = to;
}
/* ③ 常见同义词(开发者直觉命名 → canonical) */
const SYNONYMS = {
'chevron-left': 'chevron-left', 'angle-left': 'chevron-left', 'caret-left': 'caret-left',
'x': 'close', 'cross': 'close', 'cancel': 'close',
'trash': 'delete', 'bin': 'delete', 'garbage': 'delete',
'pencil': 'edit', 'write': 'edit',
'magnifier': 'search', 'magnify': 'search', 'find': 'search',
'cog': 'setting', 'gear': 'setting', 'settings': 'setting',
'warning': 'warning', 'alert': 'warning', 'caution': 'warning', 'danger': 'warning',
'info': 'info-circle', 'information': 'info-circle',
'ok': 'check', 'tick': 'check', 'done': 'check',
'plus-circle': 'add-circle', 'minus-circle': 'minus-circle',
'person': 'user', 'account': 'user', 'profile': 'user',
'picture': 'image', 'photo': 'image',
'email': 'mail', 'envelope': 'mail',
'home': 'home', 'house': 'home',
'refresh': 'refresh', 'reload': 'refresh', 'sync': 'refresh',
'eye': 'view', 'eye-off': 'hide',
'lock': 'lock', 'unlock': 'unlock',
'clock': 'time', 'time': 'time', 'schedule': 'calendar',
'map-pin': 'location', 'pin-location': 'location',
'loading': 'loading', 'spinner': 'loading',
'star-o': 'star', 'star-solid': 'star-filled',
'heart-o': 'heart', 'heart-solid': 'heart-filled',
'thumb-up': 'thumb-up', 'like': 'thumb-up',
'cart': 'cart', 'shopping-cart': 'cart',
'money': 'money', 'currency': 'money', 'yuan': 'money',
'list': 'list', 'menu': 'menu', 'hamburger': 'menu',
'grid': 'grid', 'apps': 'grid', 'dashboard': 'dashboard',
'chart': 'chart', 'graph': 'chart', 'statistics': 'chart',
'folder-open': 'folder-open', 'directory': 'folder',
'file-text': 'file', 'document': 'file',
'download': 'download', 'upload': 'upload',
'share': 'share', 'print': 'print',
'copy': 'copy', 'duplicate': 'copy',
'filter': 'filter', 'funnel': 'filter',
'sort': 'sort', 'order': 'sort',
'more': 'more', 'more-horizontal': 'more', 'ellipsis': 'more',
'external-link': 'link', 'link': 'link', 'chain': 'link',
'save': 'save', 'floppy': 'save',
'print': 'print', 'printer': 'printer',
'calendar': 'calendar', 'date': 'calendar',
'bell': 'bell', 'notification': 'notification',
'message': 'message', 'chat': 'chat', 'comment': 'chat',
'user-group': 'usergroup', 'users': 'usergroup', 'team': 'usergroup',
'shield': 'shield-error', 'security': 'shield-error',
'key': 'key', 'password': 'lock',
'play': 'play', 'pause': 'pause', 'stop': 'stop',
'video': 'video', 'music': 'music', 'audio': 'sound',
'camera': 'camera', 'image-plus': 'image-add',
'cloud': 'cloud', 'server': 'server', 'database': 'server',
'code': 'code', 'terminal': 'terminal', 'command': 'terminal',
'bug': 'bug', 'debug': 'bug',
'terminal': 'terminal', 'console': 'terminal',
'wifi': 'wifi', 'signal': 'signal',
'battery': 'battery', 'power': 'poweroff',
'mobile': 'mobile', 'phone': 'phone', 'tablet': 'tablet',
'desktop': 'desktop', 'monitor': 'monitor', 'laptop': 'laptop',
'keyboard': 'keyboard', 'mouse': 'mouse', 'printer': 'printer',
'folder-plus': 'folder-add', 'file-plus': 'file-add',
'cart-plus': 'cart-add', 'user-plus': 'user-add',
'arrow-up-circle': 'arrow-up-circle', 'arrow-down-circle': 'arrow-down-circle',
'chevron-up': 'chevron-up', 'chevron-down': 'chevron-down',
'chevron-right': 'chevron-right', 'chevron-left': 'chevron-left',
'caret-up': 'caret-up', 'caret-down': 'caret-down',
'expand-more': 'chevron-down', 'expand-less': 'chevron-up',
'arrow-back': 'back', 'arrow-forward': 'arrow-right',
'reply': 'back', 'undo': 'back', 'redo': 'forward',
'fullscreen': 'fullscreen', 'full-screen': 'fullscreen', 'maximize': 'fullscreen',
'minimize': 'fullscreen', 'zoom-in': 'zoom-in', 'zoom-out': 'zoom-out',
'help': 'help-circle', 'question-circle': 'help-circle', 'support': 'help-circle',
'exclamation': 'warning', 'exclamation-circle': 'error-circle',
'check-circle-o': 'check-circle', 'check-circle-filled': 'check-circle-filled',
'close-circle-o': 'close-circle',
'plus-square': 'add-rectangle', 'minus-square': 'minus-rectangle',
'check-square': 'check-rectangle',
'square': 'rectangle', 'circle': 'circle',
'triangle-up': 'arrow-up', 'triangle-down': 'arrow-down',
'sort-asc': 'arrow-up', 'sort-desc': 'arrow-down', 'sort-ascending': 'arrow-up',
'unsorted': 'sort', 'sortable': 'sort',
'loading-spinner': 'loading', 'spinner-loading': 'loading',
'drag': 'move', 'move': 'move', 'hand': 'move',
'crop': 'crop', 'scissors': 'cut', 'cut': 'cut',
'paste': 'file-copy', 'clipboard': 'file-copy',
'select-all': 'check-rectangle', 'select': 'check',
'indeterminate': 'minus-rectangle',
'star-half': 'star', 'rate': 'star',
'gift': 'present', 'present': 'present', 'coupon': 'coupon',
'ticket': 'ticket', 'price': 'price-tag', 'tag': 'tag',
'flag': 'flag', 'bookmark': 'bookmark', 'collection': 'collection',
'wallet': 'wallet', 'bank': 'bank', 'credit-card': 'creditcard',
'order': 'order', 'goods': 'goods', 'shop': 'shop', 'store': 'store',
'delivery': 'undertake-delivery', 'truck': 'undertake-delivery', 'shipping': 'undertake-delivery',
'box': 'box', 'package': 'box', 'archive': 'folder',
'receive': 'download', 'send': 'send', 'publish': 'send',
'announcement': 'loudspeaker', 'speaker': 'loudspeaker', 'broadcast': 'loudspeaker',
'megaphone': 'loudspeaker', 'promotion': 'loudspeaker',
'news': 'article', 'article': 'article', 'blog': 'article',
'book': 'book', 'notebook': 'notebook', 'note': 'file',
'reading': 'book-open', 'book-open': 'book-open',
'graduation': 'education', 'school': 'education', 'education': 'education',
'workspace': 'app', 'application': 'app', 'platform': 'app',
'component': 'component', 'widget': 'widget', 'module': 'widget',
'plugin': 'extension', 'extension': 'extension', 'puzzle': 'extension',
'integration': 'api', 'api': 'api', 'interface': 'api',
'webhook': 'link', 'endpoint': 'api',
'log': 'history', 'history': 'history', 'audit': 'history',
'version': 'history', 'release': 'send', 'deploy': 'cloud-upload',
'backup': 'cloud-download', 'restore': 'refresh',
'migration': 'transform', 'transfer': 'transform',
'import': 'upload', 'export': 'download',
'template': 'file-copy', 'snippet': 'code',
'form': 'file', 'survey': 'file',
'workflow': 'flowchart', 'flow': 'flowchart', 'process': 'flowchart',
'pipeline': 'flowchart', 'automation': 'control-platform',
'report': 'chart-bar', 'analytics': 'chart-bar', 'insight': 'chart-bar',
'metric': 'chart-line', 'kpi': 'chart-line', 'trend': 'chart-line',
'pie': 'chart-pie', 'donut': 'chart-ring',
'funnel-chart': 'chart-bubble', 'scatter': 'chart-scatter',
'radar-chart': 'chart-radar', 'gauge-chart': 'chart-radar',
'heatmap': 'chart-bar',
'table-chart': 'chart-bar', 'pivot': 'table',
'model': 'widget', 'dataset': 'data-base', 'etl': 'flowchart',
'ai': 'ai', 'robot': 'robot', 'assistant': 'chat-bubble',
'prompt': 'ai-edit', 'generate': 'ai', 'magic': 'magic',
};
for (const [from, to] of Object.entries(SYNONYMS)) {
if (from === to) continue; /* 同名不需要条目 */
if (!names.has(to)) continue; /* 目标不存在则跳过(由下面的统计报出) */
if (!names.has(from)) aliases[from] = to; /* 只有当它不是真实图标名时才做别名 */
}
const missingSyn = Object.entries(SYNONYMS).filter(([f, t]) => f !== t && !names.has(t)).map(([f, t]) => `${f}→${t}`);
if (missingSyn.length) console.log(` 注意:${missingSyn.length} 条同义词的目标不存在(已跳过):${missingSyn.slice(0, 8).join(', ')}`);
/* 被真图标挡下的同义词:记下来,不是错误但要知道有这么回事 */
const shadowedSyn = Object.entries(SYNONYMS).filter(([f, t]) => f !== t && names.has(f)).map(([f, t]) => `${f}(真图标)→${t}(未采用)`);
if (shadowedSyn.length) {
console.log(` 注意:${shadowedSyn.length} 条同义词因「键本身已是真实图标」而未采用(真图标优先):`);
console.log(` ${shadowedSyn.slice(0, 10).map((x) => x.split('(')[0]).join(', ')}${shadowedSyn.length > 10 ? ' …' : ''}`);
}
/* 自检 */
if (problems.length) {
console.error('无法解析的名字:');
problems.forEach((p) => console.error(' ' + p));
die(`${problems.length} 个名字没有落点 —— 别名层不许静默丢弃`);
}
for (const [a, t] of Object.entries(aliases)) {
if (!names.has(t)) die(`别名 ${a} → ${t} 指向不存在的图标`);
if (names.has(a) && a !== t) die(`别名 ${a} 与真实图标同名,会遮蔽真实图标`);
}
const payload = {
schemaVersion: 1,
generatedBy: 'tools/build-icon-aliases.mjs',
note: '别名只改查找路径,不产生新图标。identity(同名)不在此表中。',
ignored: ignored.sort(),
stats: { total: Object.keys(aliases).length, elementUI: elNames.length, mobileLegacy: Object.keys(MOBILE_LEGACY).length },
aliases,
};
atomicWrite(OUT, JSON.stringify(payload, null, 2));
console.log('图标别名构建');
console.log(` 别名总数 ${Object.keys(aliases).length}`);
console.log(` Element UI 历史名 ${elNames.length}(其中 ${ignored.length} 个标记为忽略:${ignored.join(', ')})`);
console.log(` 移动端历史名 ${Object.keys(MOBILE_LEGACY).length}`);
console.log(` 写入 ${OUT}(${(JSON.stringify(payload).length / 1024).toFixed(0)}KB)`);
console.log('OK');