Files
LoveChengke 9a38f86cd3 feat(server,web,desktop,shared,deploy,docs): Linux 一键安装与 classhelper 运维命令、更新检查、图表去 echarts,版本统一 1.1.0
Linux 交付链(新增)
- deploy/install.sh:一键安装器(CLASSHELPER 大字 TUI、native/docker 双形态、向导选安装目录/
  端口/数据库/初始管理员密码);识别 apt/yum/dnf 只补缺的依赖、建独立系统用户、下载内置 Node
  运行时(glibc < 2.28 的 CentOS 7 自动改用 unofficial-builds 的 glibc-217 构建)、幂等覆盖安装
  (保留 config.env 与 data/,不重置密钥)、全程写 /var/log/classhelper/install.log;
  `--check` 只体检(不需要 root)、`--yes` 无人值守、`--package` 支持内网离线分发。
- deploy/classhelper:运维命令 —— status / doctor / password / backup(list,restore,schedule) /
  upgrade / rollback / start|stop|restart|reload / logs / config / port / key rotate / uninstall /
  completion / help。特权操作一律校验 EUID,关键动作写 /var/log/classhelper/audit.log。
- deploy/tools/admin-cli.mjs:离线账号工具(改密、列账号、VACUUM INTO 一致性快照、哈希回滚),
  直连数据库 —— 服务停机也能救急,不需要先有一个能登录的账号。
- deploy/{classhelper.service,profile.d,logrotate.d,systemd}/ 模板 + Dockerfile.linux-package。
- scripts/dist-server.mjs 增加 --platform linux(根脚本 `pnpm dist:server:linux`),产物
  classhelper-server-linux-x64-<版本>.tar.gz + 同名 .sha256;新增 GitHub Actions workflow 在
  ubuntu runner 上构建并挂 Release(@libsql/linux-* 这类平台相关依赖只能在 Linux 上装)。
- docs/linux-deploy.md:安装 / 运维命令 / 升级回滚 / 备份恢复 / 排障 / 安全清单 /
  Ubuntu 22.04 与 Rocky 9 的验收清单。deploy/install-linux.sh 改为转发壳,不留两份实现。

三处必须知道的取舍(已写进 AGENTS.md §5 第 59 条)
- 配置真身在 /etc/classhelper/config.env,安装目录里的 .env 是指向它的软链:Web 端
  「数据库管理 → 一键切换」改写的是 <安装目录>/.env,而 systemd 注入的是同一份配置 ——
  两处不是同一个文件时,切换会「看起来成功、重启后又连回旧库」。classhelper doctor 有专门断言。
- 升级/回滚替换程序文件用「改名 + 就位」而不是 rm+cp:升级时正在运行的就是 bin/classhelper
  自己,直接删掉同名文件会让 bash 读到半个脚本。
- 交叉构建(在非 Linux 机器上打 Linux 包)会在包内写 .cross-built 标记、doctor 据此提示:
  已核对 @libsql/linux-x64-gnu 与 -musl 就位且无 win32 残留,但 prisma CLI 的原生 schema engine
  是宿主平台的,Web 端「数据库一键切换」那一步存疑;正式产物一律走 CI。

更新检查(此前只在工作区,本次一并提交)
- 服务端 modules/update:由服务端转发 GitHub Releases(Web 端 CSP 不允许直连),离线/限流/超时
  一律收敛成 ok:false + 一句人话,失败结果同样进缓存;?force=1 只对管理员生效(避免刷爆匿名限流)。
- 版本比较放 @classhelper/shared(compareVersions / isNewerVersion),三端共用;服务端版本号
  改由 lib/version.ts 读 package.json,不再在 app.ts 里写字面量。
- Web 端顶栏「检查更新」弹窗、客户端「关于」页的检查更新与诊断信息。

Web 端 / 客户端
- 成绩图表改用自包含的轻量组件(ScoreBarChart / ScoreLineChart),不再全量引入 echarts
  (原先 echarts 占桌面端渲染产物 1.12MB、Web 端 1.13MB);柱状图是纯 HTML/CSS,折线图是
  viewBox SVG,均无 resize 监听。
- Element Plus 图标改为按需注册(两端各一份 icons.ts),新增 scripts/check-icons.mjs 静态门禁
  并挂进两端 build —— 漏注册的后果是界面静默渲染空白,只在控制台一条 warning。

版本号统一 1.1.0
- 5 份 package.json、deploy/package.runtime.json、.env.example 的 APP_VERSION、
  docker-compose 与两个 Dockerfile 的镜像标签、Linux 安装包名示例、README 首页版本;
- ClassIsland 插件 manifest.yml 与 BridgeService.PluginVersion 同步(verify:classisland-plugin
  有 manifest ↔ code 的一致性断言);
- 刻意保持不变的是**历史性表述与举例**:docs/mysql.md 的「自 v1.0.0 起内置 MySQL 适配器」、
  shared/utils.ts 讲版本比较用的 1.0.0 / 1.0.0-fix、README 验收表里的实测记录。
2026-10-05 16:39:38 +08:00

144 lines
5.9 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
/**
* 图标注册表静态校验(构建前门禁)。
*
* 为什么需要:
* 本项目的 Element Plus 图标是按**字符串名**全局引用的 —— 菜单/路由配置里的 `icon: 'Odometer'`、
* 按钮上的 `:icon="'Search'"`、`<component :is="item.icon">`,以及模板里的 `<el-icon><School /></el-icon>`。
* 为了让主 chunk 不为 293 个图标里的 250 个死代码买单,两端改成**按需注册**
* (`packages/web-admin/src/icons.ts`、`packages/desktop-client/src/renderer/icons.ts`)。
*
* 代价是:新增视图时用了一个没注册的图标,Vue 只会打一条 warning、界面上**静默渲染成空白** ——
* 这类问题在 verify:web / verify:desktop 里都不容易暴露。因此在这里做一次静态校验,
* 有遗漏就 exit 1,把失败挪到构建阶段。
*
* 用法:node scripts/check-icons.mjs (已挂进两端 package.json 的 build 脚本)
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const TARGETS = [
{
name: 'web-admin',
registry: path.join(root, 'packages/web-admin/src/icons.ts'),
sources: [path.join(root, 'packages/web-admin/src')],
},
{
name: 'desktop-client',
registry: path.join(root, 'packages/desktop-client/src/renderer/icons.ts'),
sources: [
path.join(root, 'packages/desktop-client/src/renderer'),
path.join(root, 'packages/desktop-client/src/island'),
],
},
];
/** 递归收集 .vue / .ts 源文件 */
function collectFiles(dirs) {
const found = [];
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (/\.(vue|ts)$/.test(entry.name)) found.push(full);
}
};
for (const dir of dirs) {
if (fs.existsSync(dir)) walk(dir);
}
return found;
}
/**
* 从注册表文件里取出 `const APP_ICONS = { ... } as const;` 的键名。
* 这里刻意做文本解析而不是 import:注册表是 .ts,构建期没有可用的 TS 运行时。
* 对象字面量用的是简写属性(`{ Odometer, Bell }`),所以键名就是标识符本身。
*/
function readRegistry(file) {
const source = fs.readFileSync(file, 'utf8');
const match = source.match(/const APP_ICONS = \{([\s\S]*?)\} as const;/);
if (!match) {
throw new Error(`${path.relative(root, file)} 里找不到 \`const APP_ICONS = { ... } as const;\` 块`);
}
return new Set(match[1].match(/[A-Za-z][A-Za-z0-9]*/g) ?? []);
}
/**
* Element Plus 图标库的真实导出名(293 个)。
* 用运行时 import 取,而不是从 `dist/types/components/*.vue.d.ts` 猜文件名转 PascalCase ——
* 后者的来回转换(如 `circle-check-filled`)并不可靠。
*/
async function loadKnownIconNames() {
const candidates = [
path.join(root, 'packages/web-admin/node_modules/@element-plus/icons-vue'),
path.join(root, 'packages/desktop-client/node_modules/@element-plus/icons-vue'),
];
for (const base of candidates) {
const manifest = path.join(base, 'package.json');
if (!fs.existsSync(manifest)) continue;
const pkg = JSON.parse(fs.readFileSync(manifest, 'utf8'));
const entry = path.resolve(base, pkg.module ?? pkg.main);
const module = await import(pathToFileURL(entry).href);
return new Set(Object.keys(module).filter((key) => key !== 'default' && /^[A-Z][A-Za-z0-9]*$/.test(key)));
}
throw new Error('找不到 @element-plus/icons-vue,请先执行 pnpm install');
}
/** 扫描一个包,返回「用到的图标名 -> 首次出现位置」 */
function collectUsages(files, known) {
const usages = new Map();
const record = (name, file, index) => {
if (!known.has(name) || usages.has(name)) return;
const line = fs.readFileSync(file, 'utf8').slice(0, index).split('\n').length;
usages.set(name, `${path.relative(root, file)}:${line}`);
};
for (const file of files) {
const source = fs.readFileSync(file, 'utf8');
// 字符串形式:icon: 'Odometer' / :icon="'Search'" / :prefix-icon="'Lock'" / 菜单配置数组
for (const match of source.matchAll(/['"]([A-Z][A-Za-z0-9]*)['"]/g)) {
record(match[1], file, match.index ?? 0);
}
// 标签形式:<el-icon><School /></el-icon> / <Loading />
for (const match of source.matchAll(/<([A-Z][A-Za-z0-9]*)[\s/>]/g)) {
record(match[1], file, match.index ?? 0);
}
}
return usages;
}
const known = await loadKnownIconNames();
let failed = false;
for (const target of TARGETS) {
const registered = readRegistry(target.registry);
// 扫描时排除注册表自身:否则报出的「首次出现位置」会是注册表那一行,指不到真正的使用点
const files = collectFiles(target.sources).filter((file) => file !== target.registry);
const usages = collectUsages(files, known);
const missing = [...usages.keys()].filter((name) => !registered.has(name)).sort();
const unused = [...registered].filter((name) => !usages.has(name)).sort();
if (missing.length > 0) {
failed = true;
console.error(`\n[check-icons] ✗ ${target.name}:以下图标被使用但未注册(界面会渲染成空白)`);
for (const name of missing) {
console.error(` - ${name} (${usages.get(name)})`);
}
console.error(` 请把它们加进 ${path.relative(root, target.registry)} 的 APP_ICONS 对象与 import 列表。`);
} else {
console.log(`[check-icons] ✓ ${target.name}:用到 ${usages.size} 个图标,全部已注册`);
}
if (unused.length > 0) {
// 不是错误:多注册一个只多 1KB 左右。列出来是为了让名单别无限膨胀。
console.log(`[check-icons] 提示:注册了但当前未使用的图标 —— ${unused.join('、')}`);
}
}
if (failed) process.exit(1);
console.log('[check-icons] 图标注册表校验通过');