Files
aurora-admin/tools/gen-family-impl.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

218 lines
8.6 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
/**
* gen-family-impl.mjs - 族实现生成器(实现层)
*
* 把「同一族里各自手写一遍的 N 个组件」变成「一份参数化模板 + N 组参数取值」。
*
* 工作机制:
* tools/lib/family-impl/<family>/menu.{html,css,jsx,vue2,vue3}.tpl ← 族模板(唯一真源)
* tools/lib/family-model.mjs ← 成员与参数取值
* → frameworks/<PascalPrefix>.{html,css,jsx,vue2.vue,vue3.vue} ← 生成物(原地覆盖)
*
* 为什么是「生成」而不是「抽共享模块」:
* tools/pack-deploy.mjs 硬断言 frameworks/ 恰好 395 文件(79 x 5),
* tools/precompute.mjs 硬断言 395 个源文件,build-dist 按文件打包 dist/。
* 增删文件或引入跨文件 import 都会破坏这三者。生成器让 15 个文件保持自包含,
* 同时把「3 份实现」收敛成「1 份模板」—— 改模板 + 重跑即三端同步。
*
* 用法:
* node tools/gen-family-impl.mjs # 生成 IMPLEMENTED_FAMILIES 全部族
* node tools/gen-family-impl.mjs --only=nav-menu
* node tools/gen-family-impl.mjs --dry-run # 只列出将写入的文件
* node tools/gen-family-impl.mjs --check # 校验磁盘与模板一致(回归用)
* node tools/gen-family-impl.mjs --force # 允许覆盖有未提交改动的文件
*/
import fs from 'node:fs';
import path from 'node:path';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { FAMILIES, IMPLEMENTED_FAMILIES } from './lib/family-model.mjs';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TPL_ROOT = path.join(ROOT, 'tools', 'lib', 'family-impl');
const COMPONENTS = path.join(ROOT, '.design_library', 'kole-ui', 'components');
const argv = process.argv.slice(2);
const DRY = argv.includes('--dry-run');
const CHECK = argv.includes('--check');
const FORCE = argv.includes('--force');
const onlyArg = argv.find((a) => a.startsWith('--only='));
const targets = onlyArg ? [onlyArg.slice('--only='.length)] : IMPLEMENTED_FAMILIES;
/** 生成物标记:文件带此标记即视为生成器所有,可以安全重写 */
const GEN_MARKER = '@generated by tools/gen-family-impl.mjs';
/** 端 -> 文件后缀(与 build-site.ps1 / precompute.mjs 的 5 端约定一致) */
const KINDS = [
{ id: 'html', ext: '.html', tpl: 'menu.html.tpl' },
{ id: 'css', ext: '.css', tpl: 'menu.css.tpl' },
{ id: 'jsx', ext: '.jsx', tpl: 'menu.jsx.tpl' },
{ id: 'vue2', ext: '.vue2.vue', tpl: 'menu.vue2.tpl' },
{ id: 'vue3', ext: '.vue3.vue', tpl: 'menu.vue3.tpl' },
];
/** 演示页第三个用例:展示本 direction 的参数差异能力 */
const CASE3 = {
top: { title: '下拉展开态(订单 → 子项)', params: {}, initial: { active: 'order-all', open: 'order' } },
side: { title: '折叠态(collapsed=true)', params: { collapsed: true }, initial: { active: 'order-all', open: '' } },
mixed: { title: '二级联动(一级切到「商品」)', params: {}, initial: { active: 'goods-list', open: '' } },
};
const DIRECTION_ZH = { top: '顶部菜单', side: '侧边菜单', mixed: '混合导航' };
function readIndex() {
return JSON.parse(fs.readFileSync(path.join(COMPONENTS, 'index.json'), 'utf8'));
}
/** 目标文件是否有未提交改动(未跟踪文件不算) */
function gitDirty(relPath) {
try {
const out = execFileSync('git', ['status', '--porcelain', '--', relPath], {
cwd: ROOT,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore'],
});
return out.trim().length > 0;
} catch {
return false; // 没有 git 或不是仓库时不做保护,避免误伤
}
}
function render(tpl, vars) {
return tpl.replace(/\{\{([A-Z0-9_]+)\}\}/g, (m, key) => {
if (!(key in vars)) throw new Error('模板占位符未提供:' + m);
return String(vars[key]);
});
}
function main() {
const index = readIndex();
const bySlug = new Map(index.components.map((c) => [c.slug, c]));
const report = [];
const failures = [];
let written = 0;
let unchanged = 0;
let skipped = 0;
for (const famId of targets) {
const fam = FAMILIES.find((f) => f.id === famId);
if (!fam) {
failures.push(`未定义族 ${famId}`);
continue;
}
const dir = path.join(TPL_ROOT, famId);
if (!fs.existsSync(dir)) {
failures.push(`族模板目录缺失:${path.relative(ROOT, dir)}`);
continue;
}
const members = fam.members.map((m) => m.slug);
for (const kind of KINDS) {
const tplPath = path.join(dir, kind.tpl);
if (!fs.existsSync(tplPath)) failures.push(`模板缺失 ${path.relative(ROOT, tplPath)}`);
}
if (failures.length) continue;
for (const m of fam.members) {
const meta = bySlug.get(m.slug);
if (!meta) {
failures.push(`族 ${famId} 成员 ${m.slug} 不在 index.json`);
continue;
}
const prefix = meta.frameworksPrefix;
const direction = m.params.direction || fam.paramSurface.find((p) => p.name === 'direction').default;
const c3 = CASE3[direction] || { title: '替代形态', params: {}, initial: {} };
/* 代码里只放实现真正消费的参数(direction / collapsed);完整参数集以纯文本
写进头注释。两点原因:不把没用到的数据带进产物;参数名与枚举值若以
{"k":"v"} 字面量出现,会被跨端 class 提取器当成类记号,污染四端一致性比对。
direction 单独用常量承载:对象字面量里的字符串值在 Vue 端会被扫描器当作
变体记号展开(实测:写成 { direction: 'side' } 时 vue2/vue3 多出 side 记号,
拆成 FAMILY_DIRECTION = 'side' 后消失)。 */
function toCode(obj) {
return (
'{ ' +
Object.entries(obj)
.map(([k, v]) =>
k === 'direction'
? `direction: FAMILY_DIRECTION`
: `${k}: ${typeof v === 'string' ? `'${v}'` : String(v)}`
)
.join(', ') +
' }'
);
}
const consumed = {};
for (const key of ['direction', 'collapsed']) {
if (m.params[key] !== undefined) consumed[key] = m.params[key];
}
const paramsText = Object.entries(m.params)
.map(([k, v]) => `${k}=${v}`)
.join(', ');
const vars = {
PREFIX: prefix,
SLUG: m.slug,
DIRECTION: direction,
DIRECTION_ZH: DIRECTION_ZH[direction] || direction,
PARAMS_TEXT: paramsText,
PARAMS_DIRECTION: `'${direction}'`,
PARAMS_CODE: toCode(consumed),
MEMBERS: members.join(' / '),
CASE3_TITLE: c3.title,
CASE3_PARAMS_CODE: toCode(c3.params),
CASE3_INITIAL_CODE: toCode(c3.initial),
};
for (const kind of KINDS) {
const tpl = fs.readFileSync(path.join(dir, kind.tpl), 'utf8');
const out = render(tpl, vars);
const rel = path.join('frameworks', prefix + kind.ext);
const abs = path.join(ROOT, rel);
const prev = fs.existsSync(abs) ? fs.readFileSync(abs, 'utf8') : null;
/* 护栏:不覆盖「有未提交改动且不是生成物」的文件(除非 --force)。
这条来自实测教训 —— 工作区里可能躺着别人没提交的手工调整。
生成物自带 GEN_MARKER,重跑生成不会撞自己的护栏。 */
const isGenerated = prev !== null && prev.includes(GEN_MARKER);
if (prev !== null && prev !== out && !isGenerated && gitDirty(rel) && !FORCE) {
failures.push(`${rel} 有未提交改动且非生成物,拒绝覆盖(确认后可加 --force)`);
skipped++;
continue;
}
if (prev === out) {
unchanged++;
continue;
}
if (CHECK) {
failures.push(`${rel} 与族模板不一致(需重跑生成器)`);
continue;
}
if (DRY) {
report.push(`~ ${rel} (${prev === null ? '新建' : '覆盖'} ${out.length} bytes)`);
written++;
continue;
}
fs.writeFileSync(abs, out, 'utf8');
report.push(`+ ${rel} ${out.length} bytes`);
written++;
}
}
}
for (const line of report) console.log(line);
const scope = targets.join(', ');
console.log(
`\n${CHECK ? '[check]' : DRY ? '[dry-run]' : '[write]'} 族 ${scope}:` +
`写入 ${written} / 已最新 ${unchanged} / 跳过 ${skipped}`
);
if (failures.length) {
console.error('\nFAIL:');
for (const f of failures) console.error(' - ' + f);
process.exit(1);
}
console.log('OK: 族实现与模板一致');
}
main();