Files
aurora-admin/tools/verify-uniapp-build.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

359 lines
18 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-uniapp-build.mjs — uni-app 端**真实编译**验证(ROADMAP S7-P25)
*
* 与 tools/verify-uniapp.mjs 的分工(两者互补,都要跑):
* verify-uniapp.mjs 静态门禁:SFC 三段 / 语法 / 标签配平 / 禁 DOM API / 只用 uni 基础组件 / 前缀 / 单位
* verify-uniapp-build.mjs 本脚本:**真的调 uni-app 编译器**把 18(或 3)个 SFC 编译到 H5 与微信小程序
*
* 为什么必须做这件事(2026-09-20 首次执行):
* 静态门禁只能证明「源码长得合规」,证明不了「编译器接受它」。历史上本仓库有两次
* 「静态全绿但根本编译不过」的实例(`RangeQuickPicker` 的 const 重赋值、`CodeInput` 的 emit 遮蔽),
* 都是真编译才暴露的。uni-app 端的 18 个 SFC 此前**从未被编译器碰过** —— 本脚本补上这一环。
*
* 依赖处理(不污染主仓库):
* @dcloudio/* 装在**隔离目录**,仓库的 package.json 保持零运行时依赖。
* 依赖目录优先级:$KOLE_UNIAPP_DEPS_DIR > .tmp/uniapp-build/node_modules > 报错并打印安装命令。
*
* 用法:
* node tools/verify-uniapp-build.mjs # H5 + mp-weixin 两目标
* node tools/verify-uniapp-build.mjs --target=h5 # 只编译 H5(更快)
* node tools/verify-uniapp-build.mjs --keep # 保留临时工程(排查编译错误用)
*
* 首次准备(约 2 分钟,需联网;之后复用):
* mkdir -p .tmp/uniapp-build && cd .tmp/uniapp-build
* npm init -y && npm i -D @dcloudio/vite-plugin-uni@3.0.0-5020620260917001 \
* @dcloudio/uni-cli-shared@3.0.0-5020620260917001 vite@^5 vue@^3.4
* @dcloudio/uni-app@3.0.0-5020620260917001 @dcloudio/uni-h5@3.0.0-5020620260917001 \
* @dcloudio/uni-mp-weixin@3.0.0-5020620260917001 @dcloudio/uni-components@3.0.0-5020620260917001
*
* 退出码:0 全部通过;1 编译失败或产物断言失败;2 依赖缺失(环境问题,非代码问题)
*/
import {
readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, cpSync,
} from 'node:fs';
import { execFileSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const ROOT = join(__dirname, '..');
const read = (p) => readFileSync(p, 'utf8').replace(/^\uFEFF/, '');
/* ---------- 参数 ---------- */
const args = process.argv.slice(2);
const only = (args.find((a) => a.startsWith('--target=')) || '').split('=')[1]; // h5 | mp-weixin
const KEEP = args.includes('--keep');
const TARGETS = only ? [only] : ['h5', 'mp-weixin'];
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 : ''}`);
}
}
/* ---------- 依赖定位(隔离目录,不碰主仓库 node_modules) ---------- */
function resolveDepsDir() {
const candidates = [
process.env.KOLE_UNIAPP_DEPS_DIR,
join(ROOT, '.tmp', 'uniapp-build'),
].filter(Boolean);
for (const c of candidates) {
if (existsSync(join(c, 'node_modules', '@dcloudio', 'vite-plugin-uni'))) return c;
}
return null;
}
const DEPS = resolveDepsDir();
if (!DEPS) {
console.error('[FATAL] 找不到 uni-app 工具链(@dcloudio/vite-plugin-uni)。');
console.error(' 这是环境问题,不是代码问题 —— 请先准备隔离的依赖目录:');
console.error(' mkdir -p .tmp/uniapp-build && cd .tmp/uniapp-build');
console.error(' npm init -y');
console.error(' npm i -D @dcloudio/vite-plugin-uni@3.0.0-5020620260917001 \\');
console.error(' @dcloudio/uni-cli-shared@3.0.0-5020620260917001 vite@^5 vue@^3.4 \\');
console.error(' @dcloudio/uni-app@3.0.0-5020620260917001 \\');
console.error(' @dcloudio/uni-h5@3.0.0-5020620260917001 \\');
console.error(' @dcloudio/uni-mp-weixin@3.0.0-5020620260917001 \\');
console.error(' @dcloudio/uni-components@3.0.0-5020620260917001');
console.error(' 或用 KOLE_UNIAPP_DEPS_DIR 指向已有目录。');
process.exit(2);
}
/* ---------- 待编译的 SFC(从两端索引读取,与静态门禁同一真源) ---------- */
/* 依赖版本从**已装的那份**读,而不是硬编码 —— 硬编码会在依赖升级后静默失配
(package.json 里写旧版本号时 npm/vite 解析不到,产物又变回空包) */
const DEPS_VERSION = JSON.parse(
read(join(DEPS, 'node_modules', '@dcloudio', 'vite-plugin-uni', 'package.json'))
).version;
const mbIndex = JSON.parse(read(join(ROOT, '.design_library', 'kole-ui-mobile', 'components', 'index.json')));
const mobileSfcs = mbIndex.components.map((c) => ({
slug: c.slug,
name: c.frameworksPrefix,
file: c.files.uniapp,
src: join(ROOT, 'frameworks-mobile', c.files.uniapp),
}));
const pcIndexPath = join(ROOT, '.design_library', 'kole-ui-uniapp', 'index.json');
const pcSfcs = existsSync(pcIndexPath)
? JSON.parse(read(pcIndexPath)).components.map((c) => ({
slug: c.slug,
/* 字段名是 frameworksPrefix(与移动端索引同惯例),不是 prefix —— 写错会让绑定名成 undefined
(踩坑记录:首次写成 c.prefix,21 个里 3 个绑定名是 undefined,模板标签 <undefined /> 直接编译不过) */
name: c.frameworksPrefix,
file: c.file,
src: join(ROOT, 'frameworks-uniapp-pc', c.file),
}))
: [];
const all = [...mobileSfcs, ...pcSfcs];
/* 绑定名必须齐全且唯一 —— 缺名会生成 `<undefined />`(编译不过),重名会互相覆盖(静默丢组件)。
真实冲突(2026-09-20 实测):移动端有 `Button`(kole-m-btn-*),PC×uni-app 也有 `Button`
(PC 的 btn-*)—— 两者是**不同平台的同名组件**,直接都用 `Button` 作绑定名会互相覆盖,
产物里整批组件的样式全部消失(JS 只剩 App.vue,704 字节)。
消歧规则:PC×uni-app 侧加 `Pc` 前缀(移动端保持原名,因为它是本仓库的主要交付面)。 */
const NAME_CONFLICTS = new Set();
{
const seen = new Map();
for (const s of all) {
const base = s.name;
if (seen.has(base)) NAME_CONFLICTS.add(base);
seen.set(base, s);
}
for (const s of all) {
if (NAME_CONFLICTS.has(s.name) && s.src.includes('frameworks-uniapp-pc')) {
s.binding = 'Pc' + s.name;
} else {
s.binding = s.name;
}
}
}
const names = all.map((s) => s.name);
const bindings = all.map((s) => s.binding);
const badNames = all.filter((s) => !s.binding || !/^[A-Z][A-Za-z0-9]*$/.test(s.binding)).map((s) => s.slug);
const dupBindings = [...new Set(bindings.filter((n, i) => bindings.indexOf(n) !== i))];
check(badNames.length === 0 && dupBindings.length === 0, 'B0b 组件绑定名合法且唯一',
badNames.length || dupBindings.length
? `非法 ${badNames.join(', ') || '无'} / 重名 ${dupBindings.join(', ') || '无'}`
: `${bindings.length} 个${NAME_CONFLICTS.size ? '(同名消歧:' + [...NAME_CONFLICTS].join(', ') + ' → PC 侧加 Pc 前缀)' : ''}`);
console.log(`uni-app 真实编译验证 | 待编译 ${all.length} 个 SFC(移动端 ${mobileSfcs.length} · PC ${pcSfcs.length})`);
console.log(`依赖目录:${DEPS}`);
console.log(`目标:${TARGETS.join(', ')}\n`);
/* ---------- 搭一个最小 uni-app 工程,把 SFC 全量引入并编译 ---------- */
const PROJ = join(ROOT, '.tmp', 'uniapp-probe');
if (existsSync(PROJ)) rmSync(PROJ, { recursive: true, force: true });
mkdirSync(join(PROJ, 'src', 'pages', 'index'), { recursive: true });
mkdirSync(join(PROJ, 'src', 'components'), { recursive: true });
/* 依赖走软链/复制:把隔离目录的 node_modules 挂到工程里(Windows 下用 junction) */
try {
execFileSync('cmd', ['/c', 'mklink', '/J', join(PROJ, 'node_modules'), join(DEPS, 'node_modules')], { stdio: 'pipe' });
} catch {
/* 非 Windows 或已存在:退回复制(慢但可行) */
cpSync(join(DEPS, 'node_modules'), join(PROJ, 'node_modules'), { recursive: true });
}
/* package.json 必须把 @dcloudio/* 声明进 dependencies(不能只放 devDependencies)——
踩坑记录(2026-09-20,本脚本调通前最深的一个坑):uni-app 编译器靠 package.json 里声明的
@dcloudio 依赖来**识别这是一个 uni-app 工程**。只写 `{name, private, version}` 时,
编译器照样打印 "DONE Build complete."、退出码 0,但产物只有一个 704 字节的
modulepreload polyfill —— **页面与 21 个组件全部静默丢失**。
这类失败「静态门禁」与「编译成功」都发现不了,只有「断言产物里必须出现组件类名」能抓住,
所以下面的 B-* 类名断言不是锦上添花,而是本脚本的核心价值。 */
writeFileSync(join(PROJ, 'package.json'), JSON.stringify({
name: 'kole-uniapp-probe',
private: true,
version: '0.0.0',
scripts: { 'build:h5': 'uni build -p h5', 'build:mp-weixin': 'uni build -p mp-weixin' },
dependencies: {
'@dcloudio/uni-app': DEPS_VERSION,
'@dcloudio/uni-components': DEPS_VERSION,
'@dcloudio/uni-h5': DEPS_VERSION,
'@dcloudio/uni-mp-weixin': DEPS_VERSION,
vue: '^3.4.21',
},
devDependencies: {
'@dcloudio/uni-cli-shared': DEPS_VERSION,
'@dcloudio/vite-plugin-uni': DEPS_VERSION,
vite: '^5.2.8',
},
}, null, 2) + '\n');
writeFileSync(join(PROJ, 'vite.config.js'),
"import { defineConfig } from 'vite';\nimport uni from '@dcloudio/vite-plugin-uni';\nexport default defineConfig({ plugins: [uni()] });\n");
writeFileSync(join(PROJ, 'index.html'),
'<!DOCTYPE html><html lang="zh-CN"><head><meta charset="UTF-8"><title>Kole UI probe</title></head>' +
'<body><div id="app"></div><script type="module" src="/src/main.js"></script></body></html>\n');
writeFileSync(join(PROJ, 'src', 'main.js'),
"import { createSSRApp } from 'vue';\nimport App from './App.vue';\nexport function createApp() {\n" +
" const app = createSSRApp(App);\n return { app };\n}\n");
writeFileSync(join(PROJ, 'src', 'App.vue'),
'<template><view class="app-root"><text>Kole UI probe</text></view></template>\n' +
'<script setup></script>\n<style>.app-root { padding: 20rpx; }</style>\n');
writeFileSync(join(PROJ, 'src', 'pages.json'), JSON.stringify({
pages: [{ path: 'pages/index/index', style: { navigationBarTitleText: 'Kole UI probe' } }],
}, null, 2) + '\n');
writeFileSync(join(PROJ, 'src', 'manifest.json'), JSON.stringify({
name: 'kole-uniapp-probe', appid: '', description: 'Kole UI uni-app compile probe',
versionName: '1.0.0', versionCode: '100', vueVersion: '3',
}, null, 2) + '\n');
/* 拷 SFC + 令牌层(令牌是组件样式依赖的外部变量源) */
/* 落盘文件名用**绑定名**(含消歧前缀):移动端 Button.vue 与 PC 侧 PcButton.vue 各自独立,
否则后拷的会把先拷的覆盖掉(同名不同源,静默丢一个组件)。 */
for (const s of all) cpSync(s.src, join(PROJ, 'src', 'components', s.binding + '.vue'));
const tokens = join(ROOT, 'dist', 'mobile', 'tokens', 'tokens.css');
if (existsSync(tokens)) cpSync(tokens, join(PROJ, 'src', 'tokens.css'));
/* 页面把每个组件都真实挂载一次 —— 只有被 import 且**在模板里被用到**的 SFC 才会走完整编译链。
踩坑记录(2026-09-20 首次运行):初版用 `<C0 />` + `import C0 from …` 的索引式命名,
编译**通过但产物里 0 个组件**(JS 704 字节,只有 App.vue)——uni-app 的模板编译器要求
标签名与 import 绑定名可静态对应,`C0` 这类名字被当成未识别元素丢弃了。
改为「用组件自身的 Pascal 名作绑定名」后,21 个 SFC 全部进入产物(实测)。
这正是本脚本存在的意义:静态门禁与「编译成功」都发现不了这种整批静默丢弃。 */
const imports = all.map((s) => `import ${s.binding} from '../../components/${s.binding}.vue';`).join('\n');
const tags = all.map((s) => ` <${s.binding} />`).join('\n');
writeFileSync(join(PROJ, 'src', 'pages', 'index', 'index.vue'),
`<template>\n <view class="probe-page">\n${tags}\n </view>\n</template>\n\n` +
`<script setup>\n${imports}\n</script>\n\n` +
'<style>\n.probe-page { padding: 24rpx; background-color: var(--kole-color-page-bg); }\n</style>\n');
check(all.every((s) => existsSync(s.src)), 'B0 待编译 SFC 全部存在于源目录',
`${all.length} 个(${all.filter((s) => !existsSync(s.src)).map((s) => s.file).join(', ') || '无缺失'})`);
/* ---------- 逐目标编译 ---------- */
function compile(target) {
const env = { ...process.env, UNI_PLATFORM: target, NODE_ENV: 'production' };
try {
const out = execFileSync(
process.execPath,
[join(PROJ, 'node_modules', '@dcloudio', 'vite-plugin-uni', 'bin', 'uni.js'), 'build', '-p', target],
{ cwd: PROJ, env, stdio: 'pipe', timeout: 300000, encoding: 'utf8' }
);
return { ok: true, out };
} catch (e) {
return { ok: false, out: String(e.stdout || '') + String(e.stderr || '') + String(e.message || '') };
}
}
const results = {};
for (const target of TARGETS) {
const t0 = Date.now();
const r = compile(target);
const ms = Date.now() - t0;
results[target] = r;
check(r.ok, `B-${target} 编译通过`, r.ok ? `${ms} ms` : r.out.split('\n').filter(Boolean).slice(-6).join(' / ').slice(0, 300));
if (!r.ok) continue;
const dist = join(PROJ, 'dist', 'build', target);
check(existsSync(dist), `B-${target} 产物目录存在`, dist.replace(ROOT + '\\', '').replace(ROOT + '/', ''));
/* 产物里必须出现 kole-m- 类名(证明 18 个组件的样式真进了产物,而不是被 tree-shake 掉) */
let clsHit = 0;
const walk = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk(p);
else if (/\.(css|wxss|js|wxml)$/.test(e.name)) {
const m = read(p).match(/kole-m-[a-z0-9-]+/g);
if (m) clsHit += new Set(m).size;
}
}
};
if (existsSync(dist)) walk(dist);
check(clsHit > 0, `B-${target} 产物含 kole-m- 类名`, `${clsHit} 个唯一类名`);
/* 「每个组件都真的进了产物」怎么判才**不可自证**?
两次变异测试的教训(2026-09-20):
① 从 `frameworks-mobile/<X>.css` 取类名 —— uni-app 端样式是**内联**的,
改 uniapp 端不影响那份 `.css`,判据查的是另一份文件(假通过)。
② 从 uni-app 端源码取类名 —— 改名后产物与判据**同步变化**,两边一致所以照样 PASS
(实测:`kole-m-grid` 全量改名后产物里只有新名,判据取到的也是新名)。
最终判据:拿**契约**(`.design_library/kole-ui-mobile/components/<slug>.json` 的
`variantClasses`)里声明的类名去产物里找。契约与实现是两条独立的写入路径
(实现由人手写、契约由规格推导),所以「实现悄悄改名/丢组件」时契约不会跟着变 ——
这正是能让变异测试 FAIL 的原因。 */
const missing = [];
for (const s of mobileSfcs) {
if (!existsSync(s.src)) { missing.push(s.slug + '(缺 uni-app 端)'); continue; }
const ctPath = join(ROOT, '.design_library', 'kole-ui-mobile', 'components', s.slug + '.json');
if (!existsSync(ctPath)) { missing.push(s.slug + '(缺契约)'); continue; }
const vc = (JSON.parse(read(ctPath)).variantClasses) || {};
const declared = new Set();
for (const dim of Object.values(vc)) {
for (const arr of Object.values(dim || {})) {
for (const t of arr || []) if (typeof t === 'string' && t.startsWith('.')) declared.add(t.slice(1));
}
}
if (!declared.size) { missing.push(s.slug + '(契约无类名声明)'); continue; }
/* 只要**任一**契约声明的类名出现在产物里,就证明该组件的样式真的进了包 */
let hit = false;
const walk2 = (dir) => {
if (hit) return;
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk2(p);
else if (/\.(css|wxss|js|wxml)$/.test(e.name)) {
const t = read(p);
for (const cls of declared) if (t.includes(cls)) { hit = true; return; }
}
}
};
if (existsSync(dist)) walk2(dist);
if (!hit) missing.push(s.slug + '(契约类名 ' + [...declared][0] + ' 未进产物)');
}
check(missing.length === 0, `B-${target} 每个移动端组件(按契约声明的类名)都进了产物`,
missing.length ? missing.slice(0, 5).join(', ') : `${mobileSfcs.length}/${mobileSfcs.length}`);
/* 小程序端不得出现 DOM API(静态门禁查的是源码,这里查的是**编译产物**) */
if (target === 'mp-weixin') {
let domHit = 0;
const walk3 = (dir) => {
for (const e of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, e.name);
if (e.isDirectory()) walk3(p);
else if (/\.(js|wxml)$/.test(e.name) && read(p).match(/\bdocument\.(createElement|querySelector|body)/)) domHit++;
}
};
if (existsSync(dist)) walk3(dist);
check(domHit === 0, 'B-mp-weixin 产物无 DOM 操作', domHit ? `${domHit} 个文件命中` : '0');
}
}
/* ---------- 收尾 ---------- */
if (!KEEP) rmSync(PROJ, { recursive: true, force: true });
console.log('\n' + '─'.repeat(60));
if (failures.length === 0) {
console.log(`[OK] uni-app 真实编译验证通过(${pass} 条断言 · ${all.length} 个 SFC · ${TARGETS.join(' + ')})`);
console.log(' 注:App 端(app-plus)需 HBuilderX 的云端打包,无法在 CI 命令行完成;');
console.log(' 本脚本覆盖 H5 与微信小程序两个可命令行编译的目标。');
process.exit(0);
} else {
console.log(`[FAIL] ${failures.length} 条断言失败(通过 ${pass} 条):`);
failures.forEach((f) => console.log(' - ' + f));
console.log(' 排查提示:加 --keep 保留临时工程(.tmp/uniapp-probe),可进目录手工跑 uni build 看完整报错。');
process.exit(1);
}