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 验收表里的实测记录。
This commit is contained in:
1 parent
aad22df4fd
commit
9a38f86cd3
77 files changed
+8021
-899
No files matched your search
@@ -0,0 +1,143 @@
|
||||
#!/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] 图标注册表校验通过');
|
||||
+383
-26
@@ -1,16 +1,25 @@
|
||||
/**
|
||||
* 服务端 + Web 管理端 生产打包脚本:pnpm dist:server
|
||||
*
|
||||
* 产出:
|
||||
* 产出(Windows):
|
||||
* release-server/classhelper-server/ 可直接运行的免安装目录
|
||||
* release-server/班级小助手服务端-<版本>-x64-setup.exe Windows 安装程序(NSIS)
|
||||
*
|
||||
* 打包策略(面向"双击即可用、目标机无需安装 Node"):
|
||||
* 1. 内置 Node 运行时(复制本机 node.exe)—— 目标机无需安装 Node.js
|
||||
* 产出(Linux,加 --platform linux,即 pnpm dist:server:linux):
|
||||
* release-server/classhelper-server-linux-x64-<版本>.tar.gz Linux 一键安装器的输入
|
||||
* release-server/classhelper-server-linux-x64-<版本>.tar.gz.sha256
|
||||
*
|
||||
* 为什么 Linux 包要在 Linux 上构建:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 都是
|
||||
* 平台相关依赖,在 Windows 上 npm install 出来的 node_modules 装到 Linux 跑不起来。
|
||||
* 仓库里的 .github/workflows/release-linux-server.yml 用 ubuntu runner 出这个包。
|
||||
*
|
||||
* 打包策略(面向"双击即可用、目标机无需安装 Node",Linux 侧同理):
|
||||
* 1. 内置 Node 运行时(Windows 复制本机 node.exe;Linux 由安装器下载官方/glibc-217 版 Node)
|
||||
* 2. 依赖用 npm 安装为真实目录(非 pnpm 软链),保证可整体拷贝到别的机器
|
||||
* 3. 后端使用 tsc 构建产物(ESM),Web 管理端使用 Vite 构建产物
|
||||
* 4. 首次启动自动执行迁移(AUTO_MIGRATE=true)+ 自动生成 JWT 密钥 + PID 文件
|
||||
* 5. 用 electron-builder 自带的 NSIS(makensis)生成安装程序
|
||||
* 5. Windows 用 electron-builder 自带的 NSIS(makensis)生成安装程序;
|
||||
* Linux 打 tar.gz,交给 deploy/install.sh 安装
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import crypto from 'node:crypto';
|
||||
@@ -22,12 +31,25 @@ import { resolveNodeRuntime } from './lib/node-runtime.mjs';
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const serverDir = path.join(root, 'packages', 'server');
|
||||
const webDir = path.join(root, 'packages', 'web-admin');
|
||||
const deployDir = path.join(root, 'deploy');
|
||||
const outRoot = path.join(root, 'release-server');
|
||||
const staging = path.join(outRoot, 'classhelper-server');
|
||||
|
||||
/** win = 免安装目录 + NSIS 安装程序;linux = 带运维工具的 tar.gz(给 deploy/install.sh 用) */
|
||||
const PLATFORM = (() => {
|
||||
const index = process.argv.indexOf('--platform');
|
||||
const value = index >= 0 ? process.argv[index + 1] : process.platform === 'win32' ? 'win' : 'linux';
|
||||
if (!['win', 'linux'].includes(value)) throw new Error(`--platform 只支持 win / linux(收到 ${value})`);
|
||||
return value;
|
||||
})();
|
||||
const isLinux = PLATFORM === 'linux';
|
||||
|
||||
const staging = path.join(outRoot, isLinux ? 'classhelper-server-linux-x64' : 'classhelper-server');
|
||||
|
||||
const VERSION = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
|
||||
const PRODUCT = '班级小助手服务端';
|
||||
const PORT = '4000';
|
||||
const LINUX_ARCH = 'x64';
|
||||
const LINUX_TARBALL = path.join(outRoot, `classhelper-server-linux-${LINUX_ARCH}-${VERSION}.tar.gz`);
|
||||
|
||||
const log = (message) => console.log(`[dist:server] ${message}`);
|
||||
|
||||
@@ -43,6 +65,20 @@ const run = (command, args, options = {}) => {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 交叉构建:在非 Linux 机器上出 Linux 包。
|
||||
*
|
||||
* npm ≥9.6 支持 `--os` / `--cpu`,会按**目标平台**解析可选依赖,因此
|
||||
* `@libsql/linux-x64-gnu` 这类"按平台分发"的包能装进来(默认只会装宿主平台的 win32-x64-msvc)。
|
||||
*
|
||||
* 什么时候能用:本地想快速打一个包出来测安装/运维流程时。
|
||||
* **正式发布仍应在 Linux 上构建**(CI 的 workflow 就是干这个的):交叉构建时
|
||||
* prisma CLI 的原生引擎(`db push` / `generate` 用,即 Web 端「数据库一键切换」那条链路)
|
||||
* 是宿主平台下载的,Linux 上不一定能跑;`dist:server:linux` 在非 Linux 上会明确告警。
|
||||
*/
|
||||
const CROSS_BUILD = isLinux && process.platform !== 'linux';
|
||||
const CROSS_TARGET = { os: 'linux', cpu: 'x64' };
|
||||
|
||||
/* ------------------------------------------------------------ 1. 前置构建 */
|
||||
|
||||
function ensureBuilds() {
|
||||
@@ -130,8 +166,13 @@ function installDependencies() {
|
||||
writeRuntimeManifest();
|
||||
|
||||
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
|
||||
const args = ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'];
|
||||
if (CROSS_BUILD) {
|
||||
args.push(`--os=${CROSS_TARGET.os}`, `--cpu=${CROSS_TARGET.cpu}`);
|
||||
log(`交叉构建:按目标平台 ${CROSS_TARGET.os}-${CROSS_TARGET.cpu} 解析依赖(正式发布请在 Linux 上构建)`);
|
||||
}
|
||||
log('安装生产依赖(npm install --omit=dev,真实目录,便于整体拷贝)...');
|
||||
run(npmCmd, ['install', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'], {
|
||||
run(npmCmd, args, {
|
||||
cwd: staging,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -150,6 +191,8 @@ function copyRecursive(from, to) {
|
||||
|
||||
function buildRuntime() {
|
||||
// 后端代码:保留 dist 层级,保证代码里 serverRoot 的计算(../..)与开发环境一致
|
||||
// 注:dist 里没有 .d.ts / *.map —— packages/server/tsconfig.build.json 显式关掉了它们
|
||||
//(那三样占了原 dist 的 82%,运行时一个都用不到,见 AGENTS.md §5 第 54 条)。
|
||||
copyRecursive(path.join(serverDir, 'dist'), path.join(staging, 'server', 'dist'));
|
||||
// 迁移文件(首次启动 AUTO_MIGRATE 使用)
|
||||
copyRecursive(
|
||||
@@ -160,6 +203,9 @@ function buildRuntime() {
|
||||
// schema.prisma —— 切换时改写 provider 后供 prisma generate / db push 使用
|
||||
// prisma.config.ts —— Prisma 7 的 CLI 配置(连接串来自子进程环境变量)
|
||||
// tsconfig.generate.json —— generate 产出的是 .ts,切换后用它编译进 dist/generated
|
||||
// 注意:tsconfig.generate.json **故意是自包含的**(不 extends tsconfig.json/tsconfig.base.json),
|
||||
// 因为安装目录里只有 server/ 一层,那两级基配置不在包里 —— 一旦 extends 就会在打包形态下
|
||||
// 报 TS5083「Cannot read file .../server/tsconfig.json」,数据库「一键切换」直接走不到底。
|
||||
for (const file of ['schema.prisma']) {
|
||||
fs.copyFileSync(path.join(serverDir, 'prisma', file), path.join(staging, 'server', 'prisma', file));
|
||||
}
|
||||
@@ -191,10 +237,17 @@ function buildRuntime() {
|
||||
// Web 管理端
|
||||
copyRecursive(path.join(webDir, 'dist'), path.join(staging, 'web'));
|
||||
|
||||
// Node 运行时(必须是真正的 node.exe,不能用宿主 Electron 可执行文件)
|
||||
const nodeRuntime = resolveNodeRuntime();
|
||||
copyRecursive(nodeRuntime.path, path.join(staging, 'node.exe'));
|
||||
log(`内置 Node 运行时:${nodeRuntime.path}(${nodeRuntime.version})`);
|
||||
if (isLinux) {
|
||||
// Linux 包不带 Node 二进制:官方 linux-x64 构建绑 glibc 版本(老系统要 glibc-217 变体),
|
||||
// 由 deploy/install.sh 在目标机上按 glibc 自动挑一个下载。这里只留占位目录。
|
||||
fs.mkdirSync(path.join(staging, 'runtime'), { recursive: true });
|
||||
fs.writeFileSync(path.join(staging, 'VERSION'), `${VERSION}\n`, 'utf8');
|
||||
} else {
|
||||
// Node 运行时(必须是真正的 node.exe,不能用宿主 Electron 可执行文件)
|
||||
const nodeRuntime = resolveNodeRuntime();
|
||||
copyRecursive(nodeRuntime.path, path.join(staging, 'node.exe'));
|
||||
log(`内置 Node 运行时:${nodeRuntime.path}(${nodeRuntime.version})`);
|
||||
}
|
||||
|
||||
// 数据目录:强制清空,避免把打包机上测试用的数据库带进安装包
|
||||
fs.rmSync(path.join(staging, 'data'), { recursive: true, force: true });
|
||||
@@ -202,6 +255,176 @@ function buildRuntime() {
|
||||
fs.mkdirSync(path.join(staging, 'logs'), { recursive: true });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 3.5 运行时裁剪 */
|
||||
|
||||
/** 递归统计文件大小(字节);目标既可以是目录也可以是单个文件 */
|
||||
function dirSize(target) {
|
||||
if (!fs.existsSync(target)) return 0;
|
||||
const stat = fs.statSync(target);
|
||||
if (stat.isFile()) return stat.size;
|
||||
let total = 0;
|
||||
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 total += fs.statSync(full).size;
|
||||
}
|
||||
};
|
||||
walk(target);
|
||||
return total;
|
||||
}
|
||||
|
||||
const sizeMb = (target) => Math.round(dirSize(target) / 1024 / 1024);
|
||||
|
||||
/**
|
||||
* 运行时裁剪:删掉**运行时永远加载不到**的静态载荷。
|
||||
*
|
||||
* 为什么值得单独立一步:安装包的 node_modules 实测 380MB,而其中绝大部分是
|
||||
* Prisma 为「它支持的所有数据库方言」准备的查询编译器副本 —— 本项目的 schema
|
||||
* 只在 sqlite / mysql 之间切换(见 docs/mysql.md、`lib/db.ts` 的 DatabaseProvider),
|
||||
* 其余方言(postgresql / cockroachdb / sqlserver)的 wasm 一个字节都用不上。
|
||||
*
|
||||
* 原则:只删数据文件与文档,**不碰任何会被 require 的入口**;每一条都写明依据。
|
||||
* 改这里之前先看「为什么安全」,改完务必按 AGENTS.md §8 的清单复验一次
|
||||
* (尤其是数据库管理模块的「生成客户端 / 一键切换」—— 它是唯一会调用 Prisma CLI 的地方)。
|
||||
*/
|
||||
function pruneRuntime() {
|
||||
const nodeModules = path.join(staging, 'node_modules');
|
||||
if (!fs.existsSync(nodeModules)) return;
|
||||
|
||||
const before = sizeMb(staging);
|
||||
const removed = [];
|
||||
const drop = (target, note) => {
|
||||
if (!fs.existsSync(target)) return;
|
||||
const size = dirSize(target);
|
||||
fs.rmSync(target, { recursive: true, force: true });
|
||||
removed.push({ path: path.relative(staging, target), size, note });
|
||||
};
|
||||
|
||||
/* (a) @prisma/client/runtime —— 只留 sqlite / mysql 的 ESM 方言包
|
||||
*
|
||||
* 依据:`src/generated/prisma/internal/class.ts` 里的 queryCompiler 是**生成期按
|
||||
* provider 写死**的动态 import(当前是 `query_compiler_fast_bg.sqlite.mjs` +
|
||||
* `query_compiler_fast_bg.sqlite.wasm-base64.mjs`);切到 MySQL 时重新 generate 只会
|
||||
* 指向 `*.mysql.*`。而:
|
||||
* - postgresql / cockroachdb / sqlserver 与全部 `_small_bg` 变体:本产品不支持,永不加载;
|
||||
* - `.wasm-base64.js`(CJS 版):服务端是 ESM(`type: module`),只走 `.mjs`;
|
||||
* - `.map`:Node 没开 `--enable-source-maps`,不会读。
|
||||
* 实测这一步省下约 60MB。 */
|
||||
const clientRuntime = path.join(nodeModules, '@prisma', 'client', 'runtime');
|
||||
if (fs.existsSync(clientRuntime)) {
|
||||
const keep = new Set([
|
||||
'client.js',
|
||||
'client.mjs',
|
||||
'client.d.ts',
|
||||
'client.d.mts',
|
||||
'wasm-compiler-edge.js',
|
||||
'wasm-compiler-edge.mjs',
|
||||
'wasm-compiler-edge.d.ts',
|
||||
'wasm-compiler-edge.d.mts',
|
||||
'query_compiler_fast_bg.sqlite.js',
|
||||
'query_compiler_fast_bg.sqlite.mjs',
|
||||
'query_compiler_fast_bg.mysql.js',
|
||||
'query_compiler_fast_bg.mysql.mjs',
|
||||
'query_compiler_fast_bg.sqlite.wasm-base64.mjs',
|
||||
'query_compiler_fast_bg.mysql.wasm-base64.mjs',
|
||||
]);
|
||||
for (const entry of fs.readdirSync(clientRuntime)) {
|
||||
if (keep.has(entry)) continue;
|
||||
drop(path.join(clientRuntime, entry), '@prisma/client/runtime:只保留 sqlite/mysql 的 ESM 方言包');
|
||||
}
|
||||
}
|
||||
|
||||
/* (b) prisma CLI 的 build/ —— 同样只留 sqlite / mysql
|
||||
*
|
||||
* 依据:部署形态里 Prisma CLI 只被数据库管理模块用于 `generate` 与 `db push`,
|
||||
* 两者都按当前 provider 选用对应的查询编译器 wasm。 */
|
||||
const prismaBuild = path.join(nodeModules, 'prisma', 'build');
|
||||
if (fs.existsSync(prismaBuild)) {
|
||||
const unusedDialect =
|
||||
/^query_compiler_(?:fast|small)_bg\.(?:postgresql|cockroachdb|sqlserver)\.(?:js|mjs|wasm)$/;
|
||||
for (const entry of fs.readdirSync(prismaBuild)) {
|
||||
if (!unusedDialect.test(entry)) continue;
|
||||
drop(path.join(prismaBuild, entry), 'Prisma CLI:只保留 sqlite/mysql 的查询编译器');
|
||||
}
|
||||
}
|
||||
|
||||
/* (c) 通用静态载荷:sourcemap / 文档 / 测试与示例目录
|
||||
*
|
||||
* 依据:Node 不读 sourcemap;文档与测试夹具不会被 require。
|
||||
* **不要**顺手删 `*.d.ts`:数据库「一键切换」要跑
|
||||
* `tsc -p tsconfig.generate.json` 编译重新生成的 Prisma 客户端,
|
||||
* 那一步需要 `@prisma/client` 与 `@types/*` 的类型声明。
|
||||
*
|
||||
* 两条硬护栏(都踩过):
|
||||
* 1) **整棵 `@types/**` 不参与裁剪** —— 类型包里的 `test/`、`docs/` 是真实的声明文件
|
||||
* (如 `@types/node/test/reporters.d.ts` 被 `test.d.ts` 引用),按"测试目录"删掉会让
|
||||
* tsc 报 TS6053;
|
||||
* 2) 名字命中 DROP_DIRS 的目录,只要里面含 `*.d.ts` 就整个跳过 —— 同名目录在不同包里
|
||||
* 含义完全不同,不能只看名字。 */
|
||||
const DROP_DIRS = new Set([
|
||||
'test',
|
||||
'tests',
|
||||
'__tests__',
|
||||
'__mocks__',
|
||||
'example',
|
||||
'examples',
|
||||
'docs',
|
||||
'benchmark',
|
||||
]);
|
||||
const isDroppableDoc = (name) =>
|
||||
/\.(?:md|markdown)$/i.test(name) && !/^(?:license|licence|notice|copying|third[-_]?party)/i.test(name);
|
||||
/** 目录下(递归)是否存在类型声明文件 */
|
||||
const containsDts = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
if (entry.isDirectory()) {
|
||||
if (containsDts(path.join(dir, entry.name))) return true;
|
||||
} else if (entry.name.endsWith('.d.ts')) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
// 工作区内部包(@classhelper/shared)是手工放的;@types 整棵保留(理由见上)
|
||||
const protectedRoots = new Set([path.join(nodeModules, '@classhelper'), path.join(nodeModules, '@types')]);
|
||||
const walkPrune = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (protectedRoots.has(full)) continue;
|
||||
if (entry.isDirectory()) {
|
||||
if (DROP_DIRS.has(entry.name) && !containsDts(full)) {
|
||||
drop(full, 'node_modules 里的测试/示例/文档目录');
|
||||
continue;
|
||||
}
|
||||
walkPrune(full);
|
||||
continue;
|
||||
}
|
||||
if (/\.map$/i.test(entry.name)) {
|
||||
drop(full, 'node_modules 里的 sourcemap');
|
||||
} else if (isDroppableDoc(entry.name)) {
|
||||
drop(full, 'node_modules 里的说明文档');
|
||||
}
|
||||
}
|
||||
};
|
||||
walkPrune(nodeModules);
|
||||
|
||||
const after = sizeMb(staging);
|
||||
const saved = before - after;
|
||||
if (removed.length === 0) {
|
||||
log('运行时裁剪:没有可裁剪的内容(跳过)');
|
||||
return;
|
||||
}
|
||||
// 逐类汇总,便于回归时对照有没有误删
|
||||
const byNote = new Map();
|
||||
for (const item of removed) {
|
||||
byNote.set(item.note, (byNote.get(item.note) ?? 0) + item.size);
|
||||
}
|
||||
log(`运行时裁剪:删除 ${removed.length} 项,节省约 ${Math.round(saved)} MB(${before}MB -> ${after}MB)`);
|
||||
for (const [note, bytes] of [...byNote.entries()].sort((a, b) => b[1] - a[1])) {
|
||||
log(` · ${(bytes / 1024 / 1024).toFixed(1)}MB ${note}`);
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 4. 配置与脚本 */
|
||||
|
||||
function writeEnvironmentFile() {
|
||||
@@ -236,7 +459,8 @@ LOG_LEVEL=info
|
||||
|
||||
# 可选项
|
||||
BCRYPT_ROUNDS=10
|
||||
DEFAULT_STUDENT_PASSWORD=123456
|
||||
# 新建班级的初始密码(学生端用「班级码 + 班级密码」登录,没有个人学生账号)
|
||||
DEFAULT_CLASS_PASSWORD=123456
|
||||
TERM_START_DATE=
|
||||
`;
|
||||
fs.writeFileSync(path.join(staging, '.env'), content, 'utf8');
|
||||
@@ -386,7 +610,8 @@ function writeReadme() {
|
||||
管理员 admin / admin123
|
||||
教师 teacher1 / teacher123、teacher2 / teacher123
|
||||
班级 班级码 G101/G102/G203 + 班级密码 123456(学生端桌面客户端使用)
|
||||
个人 student01 ~ student15 / student123(个人学生账号,接口向后兼容)
|
||||
说明 学生没有个人账号:学生只是名单记录(成绩/未交/叫人/已读都按名单走),
|
||||
学生端统一用「班级码 + 班级密码」登录。
|
||||
|
||||
三、目录说明
|
||||
server\\ 后端程序(server\\dist\\index.js 为入口)
|
||||
@@ -443,6 +668,117 @@ function writeReadme() {
|
||||
|
||||
/* ------------------------------------------------------------ 5. NSIS 安装程序 */
|
||||
|
||||
/* ------------------------------------------------------------ 5. Linux 专属内容 */
|
||||
|
||||
/**
|
||||
* Linux 包额外要带的东西:运维命令本体、安装器模板、systemd / logrotate / profile.d、
|
||||
* Docker 形态的 Dockerfile、Nginx 样例。
|
||||
*
|
||||
* 布局(deploy/install.sh 依赖它,改动要同步改那里):
|
||||
* bin/classhelper 运维命令(安装到 /usr/local/bin 的软链指向它)
|
||||
* tools/admin-cli.mjs 离线改密 / 备份快照工具
|
||||
* classhelper.service.template systemd 单元模板(__NODE__/__DIR__/… 占位符)
|
||||
* systemd/classhelper-backup.* classhelper backup schedule 用的定时器
|
||||
* logrotate.d/、profile.d/ 安装时拷到 /etc 下
|
||||
* Dockerfile --mode docker 时用它构建镜像
|
||||
* nginx.conf 反代样例(--with-nginx)
|
||||
*/
|
||||
function copyLinuxExtras() {
|
||||
const copyFile = (from, to, mode) => {
|
||||
fs.mkdirSync(path.dirname(to), { recursive: true });
|
||||
fs.copyFileSync(from, to);
|
||||
if (mode) fs.chmodSync(to, mode);
|
||||
};
|
||||
const copyDir = (from, to) => copyRecursive(from, to);
|
||||
|
||||
// 目标路径就是安装目录里的相对路径 —— install.sh 的 place_program_files 按这几个名字铺开,
|
||||
// 名字对不上就会在安装时才暴露(而且症状是"命令找不到",不是构建报错)。
|
||||
const required = [
|
||||
['bin/classhelper', path.join(deployDir, 'classhelper'), 0o755],
|
||||
['tools/admin-cli.mjs', path.join(deployDir, 'tools', 'admin-cli.mjs')],
|
||||
['classhelper.service.template', path.join(deployDir, 'classhelper.service')],
|
||||
['Dockerfile', path.join(deployDir, 'Dockerfile.linux-package')],
|
||||
['nginx.conf', path.join(deployDir, 'nginx.conf')],
|
||||
];
|
||||
for (const [target, source, mode] of required) {
|
||||
if (!fs.existsSync(source)) throw new Error(`缺少打包所需文件:${path.relative(root, source)}`);
|
||||
copyFile(source, path.join(staging, target), mode);
|
||||
}
|
||||
// Windows 上 chmod 只影响只读位,打出来的 tar 里/bin/classhelper 的可执行位靠 install.sh 兜底
|
||||
// (place_program_files 里有 chmod +x),这里只是让在 Linux 上构建时也带上正确的权限位。
|
||||
if (process.platform !== 'win32') fs.chmodSync(path.join(staging, 'bin', 'classhelper'), 0o755);
|
||||
|
||||
for (const [target, source] of [
|
||||
['systemd', path.join(deployDir, 'systemd')],
|
||||
['logrotate.d', path.join(deployDir, 'logrotate.d')],
|
||||
['profile.d', path.join(deployDir, 'profile.d')],
|
||||
]) {
|
||||
if (fs.existsSync(source)) copyDir(source, path.join(staging, target));
|
||||
}
|
||||
log('已注入运维工具与模板(bin/classhelper、tools/、systemd/、Dockerfile 等)');
|
||||
}
|
||||
|
||||
function writeLinuxReadme() {
|
||||
const content = `${PRODUCT} v${VERSION}(Linux ${LINUX_ARCH})
|
||||
|
||||
一、这是什么
|
||||
这是给 deploy/install.sh 用的安装包,不能直接双击运行。安装方式:
|
||||
|
||||
curl -fsSL -O https://github.com/.../classhelper-server-linux-${LINUX_ARCH}-${VERSION}.tar.gz
|
||||
sudo bash install.sh --package ./classhelper-server-linux-${LINUX_ARCH}-${VERSION}.tar.gz
|
||||
|
||||
或者在有本安装包的机器上:
|
||||
sudo bash install.sh --package /path/to/classhelper-server-linux-${LINUX_ARCH}-${VERSION}.tar.gz
|
||||
|
||||
二、包内目录
|
||||
server/ 后端程序(server/dist/index.js 为入口)
|
||||
server/prisma/ 迁移 SQL(首启动 AUTO_MIGRATE=true 时自动执行)
|
||||
web/ Web 管理端(由后端直接托管)
|
||||
node_modules/ 生产依赖(linux 真实目录,可直接整体拷贝)
|
||||
tools/ 运维工具(admin-cli.mjs:离线改密、数据库一致性快照)
|
||||
bin/classhelper 运维命令(installation 时会软链到 /usr/local/bin/classhelper)
|
||||
systemd/ classhelper-backup.{service,timer}(定时备份用)
|
||||
logrotate.d/ profile.d/ 安装时拷到 /etc 下
|
||||
Dockerfile --mode docker 时用它构建镜像
|
||||
VERSION 版本号
|
||||
|
||||
三、安装后的默认账号(首次启动自动创建)
|
||||
管理员 admin / admin123(安装器里设置初始密码则用它)
|
||||
学生端 班级码 + 班级密码(学生没有个人账号)
|
||||
|
||||
四、常用运维命令(安装后)
|
||||
classhelper status / doctor / password admin / backup / upgrade / rollback / logs -f
|
||||
|
||||
五、默认路径
|
||||
安装目录 /opt/classhelper(安装时可改)
|
||||
配置 /etc/classhelper/config.env(权限 600;安装目录里的 .env 是它的软链)
|
||||
数据 <安装目录>/data
|
||||
日志 /var/log/classhelper/(安装日志 + 审计日志)
|
||||
备份 /var/backups/classhelper/
|
||||
|
||||
六、安全建议
|
||||
- 立刻修改默认密码:classhelper password admin
|
||||
- 公网访问请在前面加 Nginx/Caddy 并启用 HTTPS,并把配置里的 CORS_ORIGIN 收敛到你的域名
|
||||
- 定期备份:classhelper backup schedule daily
|
||||
- 更换 JWT_SECRET 会让所有用户重新登录(classhelper key rotate)
|
||||
`;
|
||||
fs.writeFileSync(path.join(staging, 'README.txt'), content, 'utf8');
|
||||
}
|
||||
|
||||
/** 打 tar.gz 并输出 sha256(classhelper upgrade 会校验这个哈希) */
|
||||
function packLinuxTarball() {
|
||||
fs.rmSync(LINUX_TARBALL, { force: true });
|
||||
const topDir = path.basename(staging); // tar 里套一层目录,安装器用 --strip-components=1 展开
|
||||
log(`打包 ${path.relative(root, LINUX_TARBALL)} ...`);
|
||||
run('tar', ['-czf', LINUX_TARBALL, '-C', path.dirname(staging), topDir]);
|
||||
|
||||
const digest = crypto.createHash('sha256').update(fs.readFileSync(LINUX_TARBALL)).digest('hex');
|
||||
const shaFile = `${LINUX_TARBALL}.sha256`;
|
||||
fs.writeFileSync(shaFile, `${digest} ${path.basename(LINUX_TARBALL)}\n`, 'utf8');
|
||||
log(`sha256:${digest}`);
|
||||
return { file: LINUX_TARBALL, shaFile, digest };
|
||||
}
|
||||
|
||||
function findMakensis() {
|
||||
const cacheRoot = path.join(root, '.cache', 'electron-builder');
|
||||
const candidates = [];
|
||||
@@ -500,7 +836,7 @@ function buildInstaller() {
|
||||
/* ------------------------------------------------------------ 主流程 */
|
||||
|
||||
function main() {
|
||||
log(`版本 ${VERSION}`);
|
||||
log(`版本 ${VERSION}|平台 ${PLATFORM}`);
|
||||
ensureBuilds();
|
||||
|
||||
// 迭代打包时可加 --reuse-deps 复用已安装的 node_modules,跳过 2 分钟的 npm install
|
||||
@@ -526,25 +862,46 @@ function main() {
|
||||
installDependencies();
|
||||
}
|
||||
buildRuntime();
|
||||
pruneRuntime();
|
||||
|
||||
if (isLinux) {
|
||||
copyLinuxExtras();
|
||||
writeLinuxReadme();
|
||||
// 交叉构建的包留个标记:`classhelper doctor` 会据此提醒"这个包不是在 Linux 上打的"。
|
||||
// 不静默 —— 这个包能装能跑,但 Web 端「数据库一键切换」依赖 prisma CLI 的**原生 schema engine**,
|
||||
// 而 npm 的 postinstall 只下宿主平台那一个(交叉包里就是 schema-engine-windows.exe)。
|
||||
if (CROSS_BUILD) {
|
||||
fs.writeFileSync(
|
||||
path.join(staging, '.cross-built'),
|
||||
`cross-built on ${process.platform}-${process.arch} at ${new Date().toISOString()}\n`,
|
||||
'utf8',
|
||||
);
|
||||
}
|
||||
const { file, shaFile } = packLinuxTarball();
|
||||
if (CROSS_BUILD) {
|
||||
log('');
|
||||
log('⚠ 这是**交叉构建**出来的包(在非 Linux 机器上打的),没有在 Linux 上实机验证过:');
|
||||
log(' · 已确认没问题:@libsql/linux-x64-gnu 与 linux-x64-musl 都在包内、没有任何 win32 依赖,');
|
||||
log(' 服务端本体(Node + libsql + express + socket.io)在 Linux 上可正常跑;');
|
||||
log(' · 存疑的一处:Web 端「数据库管理 → 一键切换数据库」会调 prisma CLI 的 schema engine,');
|
||||
log(' CLI 在运行期按平台扫 @prisma/engines/schema-engine-debian-openssl-3.0.x 这类路径,');
|
||||
log(' 而交叉包里只有宿主平台的 schema-engine-windows.exe → 这一步可能失败(其余功能不受影响)。');
|
||||
log(' · 包内已写入 .cross-built 标记,classhelper doctor 会提示这一点。');
|
||||
log(' 正式发布请用 .github/workflows/release-linux-server.yml(ubuntu runner)出包。');
|
||||
log('');
|
||||
}
|
||||
log(`Linux 安装包:${path.relative(root, file)}(${sizeMb(LINUX_TARBALL)} MB)`);
|
||||
log(`校验文件:${path.relative(root, shaFile)}`);
|
||||
log('完成。部署方式:sudo bash deploy/install.sh --package <上面的 tar.gz>');
|
||||
return;
|
||||
}
|
||||
|
||||
writeEnvironmentFile();
|
||||
writeRuntimeScripts();
|
||||
writeReadme();
|
||||
|
||||
const installer = buildInstaller();
|
||||
|
||||
const sizeMb = (target) => {
|
||||
let total = 0;
|
||||
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 total += fs.statSync(full).size;
|
||||
}
|
||||
};
|
||||
walk(target);
|
||||
return Math.round(total / 1024 / 1024);
|
||||
};
|
||||
|
||||
log(`免安装目录:${path.relative(root, staging)}(${sizeMb(staging)} MB)`);
|
||||
if (installer)
|
||||
log(
|
||||
|
||||
@@ -265,6 +265,72 @@ async function runAdminTeacherChecks(win) {
|
||||
Boolean(removed?.ok),
|
||||
`${removed?.ok ? `已删除 ${username}` : `原因=${removed?.reason ?? '-'}`}`,
|
||||
);
|
||||
|
||||
// 4) 更新检查:顶栏用户菜单 →「检查更新」→ 弹窗渲染出结果
|
||||
//
|
||||
// 只断言"入口可用 + 弹窗给出了结论",**刻意不断言一定有新版本** ——
|
||||
// 那会把用例绑死在外网可达上(机房服务器查不到 GitHub 是常态,"暂时无法检查更新"是设计内的结果)。
|
||||
// 判据用弹窗内的文案(与本脚本其余用例一致):data-test 打在 el-dialog / el-alert 上,
|
||||
// 是否透传到根元素取决于组件实现,不如文案直接。
|
||||
const updateCheck = await win.webContents.executeJavaScript(`(async () => {
|
||||
const chip = document.querySelector('[data-tour="user-chip"]');
|
||||
if (!chip) return { ok: false, reason: '顶栏找不到用户菜单' };
|
||||
chip.click();
|
||||
|
||||
const menuDeadline = Date.now() + 5000;
|
||||
let entry = null;
|
||||
while (Date.now() < menuDeadline && !entry) {
|
||||
entry = Array.from(document.querySelectorAll('.el-dropdown-menu__item')).find((node) =>
|
||||
(node.textContent ?? '').includes('检查更新'),
|
||||
);
|
||||
if (!entry) await new Promise((resolve) => setTimeout(resolve, 80));
|
||||
}
|
||||
if (!entry) return { ok: false, reason: '用户下拉菜单里没有「检查更新」' };
|
||||
entry.click();
|
||||
|
||||
const findDialog = () =>
|
||||
Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
|
||||
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('检查更新'),
|
||||
);
|
||||
|
||||
const deadline = Date.now() + 25000;
|
||||
let state = '';
|
||||
while (Date.now() < deadline && !state) {
|
||||
const dialog = findDialog();
|
||||
const text = (dialog?.innerText ?? '').replace(/\\s+/g, ' ');
|
||||
if (text.includes('发现新版本')) state = '发现新版本';
|
||||
else if (text.includes('已是最新版本')) state = '已是最新版本';
|
||||
else if (text.includes('暂时无法检查更新')) state = '暂时无法检查更新';
|
||||
if (!state) await new Promise((resolve) => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
// 收尾关闭,避免影响后续用例。Element Plus 关闭后 DOM 仍在(只把 .el-overlay 置成 display:none),
|
||||
// 而且淡出有过渡 —— 所以要轮询等遮罩真的隐藏,不能点完 sleep 一下就看(会误报"没关上")。
|
||||
const dialog = findDialog();
|
||||
const closeButton = Array.from(dialog?.querySelectorAll('.el-dialog__footer button') ?? []).find(
|
||||
(node) => (node.textContent ?? '').trim() === '关闭',
|
||||
);
|
||||
closeButton?.click();
|
||||
|
||||
const closeDeadline = Date.now() + 5000;
|
||||
let closed = false;
|
||||
while (Date.now() < closeDeadline && !closed) {
|
||||
const overlay = findDialog()?.closest('.el-overlay');
|
||||
closed = !overlay || getComputedStyle(overlay).display === 'none';
|
||||
if (!closed) await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
return state
|
||||
? { ok: true, state, closed }
|
||||
: { ok: false, reason: '弹窗 25 秒内没有渲染出结论' };
|
||||
})()`);
|
||||
record(
|
||||
'更新检查(管理员入口):顶栏菜单 → 弹窗给出结论并可关闭',
|
||||
Boolean(updateCheck?.ok) && updateCheck?.closed !== false,
|
||||
`结论=${updateCheck?.state ?? '-'} 已关闭=${updateCheck?.closed ?? '-'}${
|
||||
updateCheck?.reason ? ` 原因=${updateCheck.reason}` : ''
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
@@ -743,6 +809,53 @@ async function main() {
|
||||
})()`);
|
||||
await sleep(300);
|
||||
|
||||
// 7.66 成绩图表:内置 SVG/CSS 图表(替换 echarts 后的回归)
|
||||
// echarts 全量引入曾让 GradesView 分块有 1.1MB;换成两个自包含组件后
|
||||
// 分块降到十几 KB。这里断言的是"真的画出来了":柱子有实际高度、
|
||||
// 折线有路径、数据点落在不同的纵坐标上(否则说明比例换算写错了)。
|
||||
const gradeCharts = await win.webContents.executeJavaScript(`(() => {
|
||||
const bar = document.querySelector('.bar-chart');
|
||||
const line = document.querySelector('.line-chart');
|
||||
if (!bar || !line) return { ok: false, reason: '成绩页没有渲染图表容器' };
|
||||
const empty = Boolean(bar.querySelector('.bar-chart-empty'));
|
||||
const heights = Array.from(bar.querySelectorAll('.bar')).map(
|
||||
(node) => node.getBoundingClientRect().height,
|
||||
);
|
||||
const points = Array.from(line.querySelectorAll('.point'));
|
||||
const tops = new Set(points.map((node) => Math.round(node.getBoundingClientRect().top)));
|
||||
// 柱高比例要跟数据对上:最高的柱应接近绘图区高度,而不是全都贴地或全都顶格
|
||||
const track = bar.querySelector('.bar-track')?.getBoundingClientRect().height ?? 0;
|
||||
return {
|
||||
ok: true,
|
||||
empty,
|
||||
barCount: heights.length,
|
||||
drawnBars: heights.filter((h) => h > 0).length,
|
||||
distinctHeights: new Set(heights.map((h) => Math.round(h))).size,
|
||||
tallestRatio: track > 0 ? Number((Math.max(0, ...heights) / track).toFixed(2)) : 0,
|
||||
linePaths: line.querySelectorAll('svg path').length,
|
||||
pointCount: points.length,
|
||||
distinctTops: tops.size,
|
||||
};
|
||||
})()`);
|
||||
// 注意:某个等级人数为 0 时它的柱高本来就是 0(echarts 也一样),
|
||||
// 所以判据是「至少画出一根、且柱高有区分度」,不是「每根都有高度」。
|
||||
record(
|
||||
'成绩图表为内置 SVG/CSS(柱子按数据出高度、折线有路径与数据点)',
|
||||
Boolean(gradeCharts?.ok) &&
|
||||
gradeCharts?.empty === false &&
|
||||
gradeCharts?.barCount > 0 &&
|
||||
gradeCharts?.drawnBars >= 1 &&
|
||||
gradeCharts?.distinctHeights > 1 &&
|
||||
gradeCharts?.tallestRatio > 0.3 &&
|
||||
gradeCharts?.tallestRatio <= 1.01 &&
|
||||
gradeCharts?.linePaths >= 1 &&
|
||||
gradeCharts?.pointCount > 0 &&
|
||||
gradeCharts?.distinctTops > 1,
|
||||
`柱=${gradeCharts?.drawnBars}/${gradeCharts?.barCount}(高度种类=${gradeCharts?.distinctHeights} 最高占绘图区=${gradeCharts?.tallestRatio})` +
|
||||
` 折线路径=${gradeCharts?.linePaths} 数据点=${gradeCharts?.pointCount} 纵坐标种类=${gradeCharts?.distinctTops}` +
|
||||
`${gradeCharts?.reason ? ` 原因=${gradeCharts.reason}` : ''}`,
|
||||
);
|
||||
|
||||
// 7.7 ClassIsland 时间配置导入弹窗(班主任可见:粘贴 JSON → 解析预览 → 确认导入)
|
||||
// 7.7 ClassIsland 课程表导入弹窗(班主任可见:粘贴 JSON → 解析预览 → 单双周识别)
|
||||
// 注:需求调整后课表页只保留「导入 ClassIsland 课程表」入口("导入时间配置"按钮已下线,
|
||||
|
||||
Reference in new issue
Block a user