17 Commits
Author SHA1 Message Date
LoveChengke 580f5bed16 fix(dist): 交叉构建 Linux 包时 tar 的盘符冒号坑(转正斜杠 + --force-local)
在 Windows 上跑 pnpm dist:server:linux 时,packLinuxTarball 传给 tar 的归档路径带盘符,GNU tar 会把 F: 当成主机名去解析远程归档,报 resolve failed 并以退出码 2 失败。现象很误导:包已经整套铺好,只差最后一步打 tar。反斜杠还会被当转义符。

改成路径统一转正斜杠并加 --force-local。该选项在 Linux 上无害(本地路径本来就该走本地分支),因此无条件加上、不做平台分支 —— Linux 上路径没有盘符,这个坑在 CI 与真机上永远复现不了,只在交叉构建时现形。已记入 AGENTS.md 第 5 节第 59 条与 README 的交付验收表。
2026-10-06 15:52:05 +08:00
LoveChengke ce9bed73f4 feat(shared,web-admin,desktop-client,build): beUI 动效层与两端深色主题,全项目图标改用设计导出的新图标
动效层:把 beUI 的动效语言移植到两端,令牌放在 packages/shared/src/motion.ts(纯常量,后端也引用得到)。弹簧用数值积分生成 CSS linear(),纯 CSS 即可驱动,不必每组件一个 rAF 循环;两端各一份 composables/motion.ts 负责 DOM 部分,在 app.mount() 之前注入 :root。含 reduced-motion 全局降级与 hover 能力探测;灵动岛只移植单调曲线。

深色主题:Web 管理端补齐,机制与桌面客户端一致;语义色令牌只覆盖颜色,不动圆角与动效令牌。默认跟随系统,用户手动点过之后以用户为准。

图标:build/classhelper.{png,ico} 成为全项目唯一图形源,pnpm icons 从它派生各端图标与界面内品牌标。版本号统一到 1.1.2。
2026-10-06 15:32:01 +08:00
LoveChengke 82c85b59c1 feat(dist,deploy,website): 交付产物统一归集到 releases/<组件>/<版本>,补齐官网与 Linux 验收
三个打包脚本原先各写各的输出目录,现在一律直接落在仓库根 releases/<组件>/<版本>/,分层固定为安装包/免安装/构建中间;dist-win 用 --config.directories.output 覆盖 electron-builder 的目录后再整理,显式传入时不覆盖。插件侧顺手修掉一个既有 bug:原先 rmSync 整个组件目录,重打一次就把上一版的留档抹掉,现在只清当前版本目录。verify-packaged 的 --exe 默认值跟着改到新落点。

deploy/verify-linux.sh 修掉管道在 pipefail 下的 SIGPIPE 误判(命中被算成失败);CI workflow 的出包与上传路径同步;electron-builder.yml 显式指定安装包图标。

website/ 是产品官网(纯静态、无构建步骤,不进 pnpm workspace),含 beUI 动效 token 与实机留档图;客户端冒烟相应加了采集留档图用的外观开关。
2026-10-06 15:31:38 +08:00
LoveChengke 7b1ae29774 feat(desktop-client,build): 换用设计导出的新图标(客户端 / 服务端安装程序 / 托盘)
build/classhelper.{ico,png} 是新的图标来源(设计导出、手工放入),不再走 `pnpm icons` 那条
SVG 渲染流水线(那条只会重写 icon.*,两套并存容易搞混):

- packages/desktop-client/build/classhelper.{ico,png}:客户端安装包与应用图标;
- packages/desktop-client/public/tray.png:托盘图标(由 classhelper.png 覆盖);
- 服务端安装程序与客户端的"接线"分别在 scripts/dist-server.mjs 的 @ICON@ 与
  packages/desktop-client/electron-builder.yml 的 win.icon —— 这两个文件当前另有未提交改动
  (另一处工作在进行),那两行会随那批改动一起入库。

说明:Web 管理端(PWA)的 favicon / icon-192 / icon-512 未动,仍是原图标。
2026-10-05 21:13:52 +08:00
LoveChengke 9e52cfad4d fix(deploy): 改密前把密码规则说清楚,并让"不合规"提示与输入提示走同一个终端
实机反馈:`classhelper password admin` 连问三次"新密码:"却不说明为什么——用户得失败一两次
才知道规则(而且 read -s 不回显,屏幕上连星号都没有,更容易以为卡住了)。

- 开问之前先打印规则(≥8 位、大小写/数字/符号至少 3 类、不能与用户名相同、别用
  admin/123456 这类开头,并给一个合规示例),并说明"输入不回显是正常的";
- 提示语带上次数:`新密码(第 1/3 次):`,三次都没过时明确告知密码未改动,
  并给出非交互改密的完整命令;
- 新增 warn_user():这类"正对着交互输入"的提示直接写 /dev/tty(与提示语同一个设备),
  不再走 stdout/stderr 分流,避免提示与它对应的警告错位或看不见;没有终端时回退 stderr。
  用 `{ …; } 2>/dev/null` 这个形状而不是 `… >/dev/tty 2>/dev/null`,否则 /dev/tty 打不开时
  bash 自己那句 "No such device or address" 会漏在用户眼前(重定向从左往右处理,压不住)。
2026-10-05 21:01:10 +08:00
LoveChengke 79d73c5889 chore(release): 版本号统一到 1.1.1(服务端 CSP 白屏修复)
对齐 1.1.0 的做法把版本号统一:5 份 package.json、deploy/package.runtime.json、
两份 .env.example 的 APP_VERSION、两个 docker-compose 的镜像标签、ClassIsland 插件的
manifest.yml 与 BridgeService.PluginVersion(verify:classisland-plugin 有 manifest↔代码
一致性断言,已跑 68/68)、README 首页版本、以及文档与脚本里的安装包名示例,共 28 处。

刻意不动:docs/linux-deploy.md 排障条目里"1.1.0 的服务端在 HTTP 直连访问时会下发 CSP
upgrade-insecure-requests…"——那句说的是**出问题的那个版本**,改成 1.1.1 反而是错的。
2026-10-05 20:05:39 +08:00
LoveChengke 48e0c89394 fix(server): 纯 HTTP 部署下 CSP 的 upgrade-insecure-requests 会让管理端白屏
helmet 的**默认** CSP 自带 upgrade-insecure-requests,它让浏览器把页面**子资源**(JS/CSS)
请求全部改写成 https。而安装器的默认形态是 http://IP:端口 直连(没有 TLS),改写后的请求
必然连接失败 —— 表现是「HTML 外壳加载出来了(标签页标题正常)、脚本没加载(页面一片空白)」。
2026-10-05 用一行安装脚本在 Ubuntu 24.04 上装完后实测踩到。

改法:securityHeaders() 按请求挑策略 —— req.secure(直连 TLS,或反代下由
x-forwarded-proto 决定)走 buildHelmet(true),否则 buildHelmet(false):

- 明文请求不再下发 upgrade-insecure-requests(置 null 去掉 helmet 的默认指令);
- 明文请求也不再下发 HSTS(按规范浏览器本就忽略,没必要发);
- HTTPS 请求的行为与改动前完全一致,安全性没有被削弱。

本地实测(另起一份服务在 4100、TRUST_PROXY=1,避免扰动开发机上已有的 4000 实例):
明文请求的 CSP 里 upgrade-insecure-requests 出现 0 次、带 X-Forwarded-Proto: https 的
请求里出现 1 次;healthz 与首页均 200;tsc 与 ESLint 均无问题。
2026-10-05 20:02:30 +08:00
LoveChengke 5d2fa2d6b4 fix(deploy): 修两处安装器 bug——下载地址多了一段 /releases、菜单选 4(只体检)不生效
Ubuntu 24.04 实机跑出来的两个问题(用户贴的输出各暴露一个):

1. **下载地址变成 .../releases/releases/download/...**(必然 404):GITHUB_RELEASES_PAGE 本身
   已经以 /releases 结尾,拼接时又写了一遍。install.sh 与 classhelper 各有一份,都改用新增的
   GITHUB_REPO_URL(不含 /releases);GITHUB_RELEASES_PAGE 只留给"发布页"这类展示用途。
2. **菜单里选 4)只体检,却直接开始安装**:CHECK_ONLY 的分流写在菜单**之前**,菜单里再把它置 1
   已经没人看。现在把 root 检查与 tee 都挪到菜单之后,菜单选 4 与 --check 走同一条路
   —— 顺带让非 root 用户也能看到菜单并选 4,不必非得记住 --check。
2026-10-05 19:52:57 +08:00
LoveChengke 2be8d30376 fix(deploy): 交互提示写不进终端导致静默退出——fd 3 必须用 <> 打开,不能只读
Ubuntu 24.04 实机定位:菜单打印完就退回 shell、退出码 1、没有任何提示(用户三次复现)。
根因是 `exec 3</dev/tty` 以**只读**方式打开 fd 3,而 read_prompt / read_secret 要往它**写**提示语:

  $ exec 3<file; printf x >&3
  bash: printf: write error: Bad file descriptor      # 退出码 1

在 set -e 下这一下就把脚本打死;错误信息还因为走 tee 管道、脚本退出时进程替换的子进程来不及
flush 而被吞掉 —— 于是表现成"菜单打完就默默消失"。

- install.sh 的 open_prompt_fd、classhelper 的 open_tty 都改成 `exec 3<>/dev/tty`(读写打开);
- 提示语写失败改为显式降级(该项用默认值 + 打印一条提示),不再让整条命令消失;
- classhelper 里是同一份实现,不修的话 Linux 上 `classhelper password` 会同样毫无提示地退出。

本地复现验证过三种情形:只读 fd 写 → Bad file descriptor / 退出码 1;`<>` 写 → 成功;
`if printf … >&3; then` 里的失败不会被 set -e 打死(新加的护栏确实兜住了)。
2026-10-05 19:50:24 +08:00
LoveChengke f6edfa5ea1 fix(deploy): 修正一行安装的写法——bash -c 会吞掉参数,改用「先落盘再执行」
实测(Ubuntu 24.04):`bash -c "$(curl …)" --check` 并没有进体检分支,而是走进了交互式安装。
原因是 `bash -c '代码' 第一个参数` 里那个参数会被赋给 **$0** 而不是 $1,脚本根本收不到它 ——
我之前在 install.sh 头部、内嵌 --help 与 README 里推荐的"末尾追加 --version / --yes / --check"
全都因此无效(用户按文档加了参数,实际一个都没生效)。

改成先落盘再执行:
  curl -fsSL -o /tmp/classhelper-install.sh <url> && sudo bash /tmp/classhelper-install.sh
参数直接接在最后(`sudo bash /tmp/classhelper-install.sh --check`)。这个形式顺带避免了
"下载中断却执行到半个脚本"的风险,stdin 也仍是终端。

同步位置:install.sh 头部注释、内嵌 usage(远端模式 --help)、README、docs/linux-deploy.md;
另在脚本里写明这个 $0 坑,免得以后又改回去。
2026-10-05 19:46:31 +08:00
LoveChengke 985e602012 fix(deploy): 修一行安装"菜单打印完就静默退出"——read_prompt 的返回值被 set -e 当成致命错误,且报错被 tee 吞掉
Ubuntu 24.04 实机跑一行安装时的现象:菜单打印出来后**没有任何提示**就退回 shell,
用户敲的 1 被 shell 当成命令(1: command not found)。两个原因叠在一起:

- read_prompt 里写的是 `open_prompt_fd || return 1`,而调用处(`read_prompt "…" choice`)是独立命令,
  在 `set -e` 下返回非 0 会当场终止整个脚本;改成永远返回 0(读不到终端就把变量留空、由调用方用默认值)。
- `exec > >(tee -a 安装日志)` 之后所有输出都走进程替换,而脚本退出时那个子进程可能来不及把最后几行
  写出去 —— 于是**致命错误被彻底吞掉**,只剩"默默退出"。现在脚本开头先把真正的 stderr 存到 fd 9,
  die / on_error 绕开 tee 直接写 fd 9(终端立刻可见)并直接追加日志文件。

随之而来的两处改进:
- 交互式安装读不到终端时**明确报错**并给出三条出路(--yes / 存成文件再执行 / --check),
  不再默默按默认值装下去 —— 那会让用户以为自己在选,其实一个都没问到;
- 开始安装前打印最终生效的选择(形态|目录|端口|数据库|Node 来源):非交互或问答被跳过时,
  这一行是唯一能看出"到底按什么装的"地方。

"请用 root" 的提示也按本地/远端两种执行方式分别给出可照抄的命令。
2026-10-05 19:44:15 +08:00
LoveChengke d820926491 feat(deploy): install.sh 支持一行远程安装(从 GitHub 取脚本与安装包,并自动校验哈希)
用户侧只要一行:
  sudo bash -c "$(curl -fsSL https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh)"

为让它成立改了四处(原来只支持"本地有脚本 + 本地有安装包"):

- usage():读不到脚本文件时(bash -c "$(curl …)" / curl|bash)打印内置参数表,
  不再去 awk 一个不存在的文件;
- SCRIPT_PATH / SCRIPT_DIR:容忍非文件执行;与脚本同目录的模板本来也由安装包提供;
- 交互问答统一走 /dev/tty(新增 read_prompt / can_prompt):原来判断 [ ! -t 0 ] 会让
  curl|bash 下的所有提问被**静默跳过、直接按默认值装完**;并且刻意不退回 stdin ——
  管道模式里 stdin 就是脚本本身,从 stdin 读会把脚本的下一行当成用户输入吃掉;
- fetch_package:没给 --sha256 时自动取发布页上同名的 .sha256 来校验。一行安装的用户
  不会自己去核哈希,不补这一步"校验"就只是把哈希记进日志;取不到时明确告警,而不是
  假装校验过了。

文档:README 增加一行安装入口;docs/linux-deploy.md 的开头同样补上(该文件另有一处
改动不属于本次提交,故未纳入)。
2026-10-05 19:27:20 +08:00
LoveChengke f1edb42665 ci(workflow): Linux 包一并上传 .sha256,避免发布后哈希对不上
首次跑通后发现:workflow 只上传了 tar.gz 与 SHA256SUMS,而那份独立发布的 .sha256 还是上一版的哈希 —— 拿它校验会 mismatch(classhelper upgrade 用的正是这个文件,不是 SHA256SUMS)。另补一道兜底:文件缺失时现场生成。
2026-10-05 18:19:02 +08:00
LoveChengke 67e3961e55 ci(workflow): 修 Linux 出包 —— 补 Prisma Client 生成,SHA256SUMS 不再排序打乱
首次在 tag push 上跑就挂了(run 37294579347,失败在"构建 Linux 安装包")。两处都是我的问题:

1. 漏了 `pnpm --filter @classhelper/server db:generate`。`packages/server/src/generated/prisma`
   是 gitignored 的,CI 全新检出来没有它,于是 Prisma 类型解析不到、回调参数全被推成 any,
   `tsc` 报出一大片 TS7006 / TS2339 —— 看着像代码写错了,其实只是缺生成物。
   本机因为早就生成过,永远踩不到这个坑;deploy/Dockerfile 的构建阶段本来就有这一步,
   workflow 里漏抄了(同一条注释在那边写着)。
2. 更新 SHA256SUMS 时用了 `sort -u -k2` 去重,会把那份人读的清单(表头 + 体积 + 说明行)打散。
   改成只替换/追加本包那一行,其余原样保留。
2026-10-05 18:10:29 +08:00
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
LoveChengke aad22df4fd docs: 重写 README —— 按 1.0.0 现状重排为「产品说明书 + 验收对照表」
结构重排:组成部分 → 交付产物与部署形态 → 快速开始 → 功能说明(Web 端 / 客户端 / 灵动岛 / 作业 /
叫人 / 上课时段 / 导入 / ClassIsland 联动 / 数据库管理)→ 技术参考(目录结构 / 技术栈 / REST API /
WebSocket / RBAC / 模块化 / 数据库与迁移)→ 验收 → 打包与交付 → 常见问题 → 后续增强。
1594 行 → 1236 行,去掉的是与 AGENTS.md 重复的踩坑叙述与「按阶段的交付清单」文件流水(后者改由目录结构
与 git 历史承载)。

修正过期内容:
- 迁移数 9 → 10(补回 add_schedule_source),迁移清单同步
- verify:e2e 173 项 → 186 项;verify:web 30/31、verify:desktop 99/102、插件静态契约 68/68 按最近一次实测重写
- 删掉两条与现状相反的验收行(「客户端设置页提供修改密码」),改为「客户端有意不提供改密入口」,
  与 AGENTS.md §5 第 31 条、冒烟断言「改密入口存在=false」一致
- 服务端安装包体积口径统一为 ~71MB(切库依赖内置后);插件版本 1.0.0.0;正文标注当前版本 1.0.0

新增与加强:
- 补 1.0.0 的内容:数据库管理(仅管理员)、新手引导(Web 端 el-tour + 客户端六步向导)、
  设置拆 6 个子路由、深色模式、客户端主窗口与 ClassIsland 对齐(1242x582)、
  课表 source 字段与「幽灵行清理」口径
- 6.3「已知的时序 / 环境相关用例」:写明 verify:desktop 那 3 项在「当前正在上课」时必然失败的原因,
  并给出各验证脚本覆盖凭据的方式(ELECTRON_SMOKE_* / UI_SMOKE_* 支持环境变量,verify:e2e 不支持)

验证(2026-10-02 本机):verify:e2e 186/186;verify:web 30/31(1 项为既有脆弱用例);
verify:desktop 99/102(3 项时序相关);verify:classisland-plugin 68/68;typecheck 与 lint 通过。
2026-10-02 11:24:31 +08:00
LoveChengke 572295c297 feat(server,web,desktop,shared,deploy,plugin): 数据库管理模块 + 客户端深色模式/设置拆子路由/首启引导,版本统一 1.0.0
- 服务端新增 database 模块(/api/database/*,仅 ADMIN):全库 JSON 快照(备份 / 恢复 / 导入导出 / 定时备份
  共用同一格式,14 张表按拓扑序导出、恢复时关外键检查)、连接测试与目标库校验、一键 SQLite ⇄ MySQL 切换
  (备份 → 导出 → 测试连接 → 校验空库 → 改 schema → 子进程 generate / tsc / db push → 写数据,全部成功才改写
  .env,中途失败回滚 schema);XML 快照与 SQLite 数据文件均可下载;打包形态随包带上 prisma CLI 与 typescript
  (prisma-client 生成物是 .ts,需再编进 dist/generated,安装包因之变大)
- Web 管理端新增「数据库管理」页(仅管理员),并加首次登录新手引导(el-tour,可跳过、可随时重看,版本号写
  localStorage,改版后老用户会再收到一次)
- 客户端设置改版:侧边栏改为分组折叠菜单,设置拆成 6 个子路由
  (/settings/{general,appearance,island,reminder,account,about}),新增深色模式(主题持久化到主进程配置,
  主窗口底色跟主题走,避免"先白后黑"闪一下),新增「关于」页(应用信息 / 诊断信息)
- 客户端新增首启六步引导(OnboardingWelcome,状态存主进程配置 onboardingDone)
- 客户端主窗口初始尺寸与 ClassIsland 对齐(1242x582 / minHeight 540 / 居中)
- 版本号统一提升到 1.0.0:5 个 package.json、部署配置与镜像标签(.env.example / docker-compose / Dockerfile)、
  服务端 /health 与首页、插件 manifest.yml 与 BridgeService.PluginVersion、文档里的安装包名
- 文档与验收同步:AGENTS.md 补第 37~51 条约定(深色主题实现边界、设置子路由、数据库管理三件事、侧栏与窗口
  尺寸、引导在自动化里的判据等),README 补「数据库管理 / 新手引导 / 设置改版」三节;verify:e2e 增补 13 条
  数据库管理用例,客户端冒烟与 Web UI 冒烟跟进子路由、深色主题、侧栏与引导

验证:pnpm lint 0 error;pnpm typecheck 全过;verify:e2e 186/186;verify:classisland-plugin 68/68;
build:desktop 通过(含产物凭据门禁);verify:desktop 99/102(3 项为上课时段下的"真实通知链路"用例,
客户端按产品规则暂存普通通知所致,见下);插件 dotnet 编译 0 错误。

注:verify:desktop 的「真实通知链路 / 标记已读」三项要求当前不在上课时段——本次运行时种子课表恰有
「周五 10:55-11:40 生物」正在进行,客户端正确地暂存了普通通知(AGENTS.md 第 18 条),属时间相关用例,
与本次改动无关。
2026-10-02 11:18:47 +08:00
174 changed files with 21634 additions and 2958 deletions

No files matched your search

+132
View File
@@ -0,0 +1,132 @@
name: 发布 Linux 服务端安装包
# 为什么单独有这个 workflow:
# Linux 包的依赖(@libsql/linux-x64-gnu、@prisma/adapter-libsql 等)是平台相关的,
# 在 Windows 开发机上 npm install 出来的 node_modules 装到 Linux 跑不起来,
# 所以 Linux 包必须在 Linux 上构建。这个 workflow 用 ubuntu runner 产出
# classhelper-server-linux-x64-<版本>.tar.gz 并挂到 GitHub Release 上,
# 服务器上的 `classhelper upgrade` 就是从这里下载 + 校验 sha256 的。
#
# 用法:
# 1. 先在本地按惯例打出 Windows 包并发 Release(vX.Y.Z);
# 2. 到 Actions 页面手动运行本 workflow,填 tag(例如 v1.1.2);
# 3. 跑完 Release 里会多出 Linux 包与更新后的 SHA256SUMS-<版本>.txt。
#
# 也可以只跑构建不挂 Release(attach=false),产物在 workflow 的 Artifacts 里。
on:
workflow_dispatch:
inputs:
tag:
description: '要构建并挂载的 Release tag(例如 v1.1.2)'
required: true
type: string
attach:
description: '构建后上传到该 Release'
required: false
default: true
type: boolean
push:
tags:
- 'v*'
permissions:
contents: write
jobs:
build-linux-package:
runs-on: ubuntu-22.04
timeout-minutes: 40
steps:
- name: 确定 tag
id: ref
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "tag=${{ inputs.tag }}" >> "$GITHUB_OUTPUT"
echo "attach=${{ inputs.attach }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
echo "attach=true" >> "$GITHUB_OUTPUT"
fi
- name: 检出代码
uses: actions/checkout@v4
with:
ref: ${{ steps.ref.outputs.tag }}
- name: 安装 Node 22
uses: actions/setup-node@v4
with:
node-version: '22'
- name: 安装 pnpm
uses: pnpm/action-setup@v4
with:
version: 11.8.0
run_install: false
- name: 安装依赖
run: pnpm install --frozen-lockfile
- name: 生成 Prisma Client
# 必须显式生成:packages/server/src/generated/prisma 是 gitignored 的,CI 上全新检出没有它。
# 少了这一步,`tsc` 会因为 Prisma 类型解析不到而把回调参数全推成 any,报出一大片
# "TS7006 Parameter 'item' implicitly has an 'any' type" / "TS2339 Property 'id' does not exist
# on type '{}'" —— 看着像代码写错了,实际是缺生成物(本机因为早就生成过,永远踩不到)。
# deploy/Dockerfile 的构建阶段有同样的步骤与注释。
run: pnpm --filter @classhelper/server db:generate
- name: 构建 Linux 安装包
run: pnpm dist:server:linux
- name: 展示产物
run: ls -la releases/server/*/linux-x64/
- name: 上传构建产物(Artifacts)
uses: actions/upload-artifact@v4
with:
name: classhelper-server-linux-x64
path: |
releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz
releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz.sha256
if-no-files-found: error
retention-days: 30
- name: 挂到 Release 并更新 SHA256SUMS
if: steps.ref.outputs.attach == 'true'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG: ${{ steps.ref.outputs.tag }}
run: |
set -euo pipefail
VERSION="${TAG#v}"
ARTIFACT_DIR="releases/server/${VERSION}/linux-x64"
TARBALL="${ARTIFACT_DIR}/classhelper-server-linux-x64-${VERSION}.tar.gz"
SHAFILE="${TARBALL}.sha256"
SUMS="releases/SHA256SUMS-${VERSION}.txt"
if [ ! -f "$TARBALL" ]; then
echo "::error::没有找到 $TARBALL(tag 与 package.json 的版本号对得上吗?)"
exit 1
fi
# .sha256 必须跟着一起传:它是独立发布的资产,只更新 tar.gz 会让这个文件指向旧哈希,
# 拿它校验的人会得到 mismatch(classhelper upgrade 用的是 --sha256/这个文件,不是 SHA256SUMS)。
if [ ! -f "$SHAFILE" ]; then
sha256sum "$TARBALL" | sed "s#${ARTIFACT_DIR}/##" > "$SHAFILE"
fi
# 取回已有的校验清单(Windows 包那几行),只替换/追加本包这一行,其余内容原样保留。
# 注意**不要**用 `sort -u` 去重:那份文件是人读的(表头 + 体积 + 说明),排序会把它们打散。
gh release download "$TAG" --pattern "SHA256SUMS-*.txt" --dir releases --clobber || true
DOWNLOADED="$(ls releases/SHA256SUMS-*.txt 2>/dev/null | head -n 1 || true)"
if [ -n "$DOWNLOADED" ] && [ "$DOWNLOADED" != "$SUMS" ]; then mv "$DOWNLOADED" "$SUMS"; fi
touch "$SUMS"
grep -v "^[0-9a-f]\{64\} classhelper-server-linux-" "$SUMS" > "${SUMS}.new" || true
printf '%s %s\n' "$(sha256sum "$TARBALL" | awk '{print $1}')" "$(basename "$TARBALL")" >> "${SUMS}.new"
mv "${SUMS}.new" "$SUMS"
gh release upload "$TAG" "$TARBALL" "$SHAFILE" "$SUMS" --clobber
echo "已上传:"
cat "$SHAFILE"
cat "$SUMS"
+2
View File
@@ -46,3 +46,5 @@ releases/
# AI 浼氳瘽浜х墿锛圸Code 璁″垝/閰嶇疆锛屼笉灞炰簬浜у搧浠g爜锛�
.zcode/
.zcodeignore
packages/server/prisma/backups/
packages/server/prisma/database-settings.json
+554 -47
View File
@@ -47,20 +47,36 @@ pnpm-workspace.yaml workspace + allowBuilds(pnpm 11 依赖构建白
tsconfig.base.json 共享 TS 基础配置(ESM + NodeNext + strict)
eslint.config.mjs ESLint 扁平配置(TS + Vue);ignores 含 releases/ / release-server/ / src/generated/
.prettierrc.json 单引号 / printWidth 110 / trailingComma all / LF / 2 空格
build/icon.{ico,png} 应用图标(由 pnpm icons 生成)
build/classhelper.{png,ico} 图标源(**设计导出、手工放入**;`pnpm icons` 从它派生其余全部图标,见 §5 第 62 条)
scripts/
use-database.mjs SQLite ⇄ MySQL 切换(**改写 schema.prisma 里的 provider**,没有第二份 schema)
generate-icons.mjs Electron 渲染 SVG → PNG/ICO
icons/render.cjs 图标渲染(CommonJS)
dist-server.mjs 服务端 + Web 管理端打包(产物落在 release-server/)
generate-icons.mjs 启动 Electron 跑 icons/render.cjs(= `pnpm icons`)
icons/render.cjs 把 classhelper.png 重采样成各端图标(CommonJS,见 §5 第 62 条)
dist-server.mjs 服务端 + Web 管理端打包(产物落在 releases/server/<版本>/)
nsis/server-installer.nsi 服务端 NSIS 安装脚本
verify-packaged.mjs 对**打包后/已安装**的客户端 EXE 复跑冒烟
check-icons.mjs 图标注册表静态门禁(漏注册会静默渲染空白,见 §5 第 53 条)
ui-smoke/{run,main.cjs,live-probe.mjs} Web 管理端真实点击回归(Electron 驱动)
lib/{electron-env,node-runtime}.mjs 启动 Electron / 定位真实 node.exe
deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf / install-linux.sh
classhelper.service / package.runtime.json / .env{,.sqlite}.example
docs/ production.md(生产部署)/ mysql.md(MySQL 切换)/ winisland-design-tokens.md
deploy/ Dockerfile / docker-compose{,.sqlite}.yml / nginx.conf / package.runtime.json
install.sh(Linux 一键安装器,TUI)/ install-linux.sh(转发壳)
classhelper(运维命令本体)/ verify-linux.sh(Linux 验收,自统计 N/N)
tools/admin-cli.mjs(离线改密 / SQLite 一致性快照)
profile.d/ logrotate.d/ systemd/(装到 /etc 的模板)
Dockerfile.linux-package(安装包内那份,--mode docker 用)
classhelper.service(systemd 模板,5 个占位符)/ .env{,.sqlite}.example
docs/ production.md(生产部署)/ linux-deploy.md(Linux 部署与运维)
mysql.md(MySQL 切换)/ winisland-design-tokens.md
screenshots/{island,client,web-mobile,classisland}/
website/ 产品官网(**纯静态、无构建步骤**,不进 pnpm workspace)
index.html / assets/{styles.css,motion.js,main.js,icon.png,shots/}
serve.mjs(本地预览)/ tools/capture-shots.mjs(实机图采集)
⚠ assets/motion.js 是动效层:曲线与弹簧 token 逐条取自 beUI
(beui.dev/components/motion,见 README §7「官网」)。
改官网动效请改 token 表,不要在组件里另写 duration / cubic-bezier。
⚠ assets/shots/ 里全是**跑起来截的**实机图,重采集步骤见 README
§7「采集实机图」;客户端那两张要配合冒烟的
ELECTRON_CLIENT_SHOTS_DIR / ELECTRON_SMOKE_ISLAND_* 开关。
packages/shared/src/ types.ts / constants.ts / permissions.ts / utils.ts / index.ts
packages/server/
prisma/schema.prisma 数据模型(14 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
@@ -69,27 +85,27 @@ packages/server/
homework_assign_date / push_kind / schedule_source)
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,实测 173 项)
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-05 实测 190 项)
scripts/verify-classisland.mjs 联动链路验收(实测 42 项)
scripts/apply-column-migrations.cjs 旧库补列(手工覆盖 dist 部署时用)
src/app.ts Express 装配(探针 → 限流 → 模块挂载 → 静态托管 → 兜底)
src/index.ts 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
src/config/env.ts zod 环境变量校验 + 生产自检
src/lib/ access(RBAC) class-account db db-bootstrap http jwt logger mappers
password schemas session term web-static
password schemas session term version(读 package.json) web-static
src/middleware/ auth validate error security
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
src/modules/ 13 个功能模块 + registry.ts + module.types.ts
packages/web-admin/src/ api stores router layouts views(11) components(4) composables styles config.ts
src/modules/ 15 个功能模块 + registry.ts + module.types.ts
packages/web-admin/src/ api stores router layouts views(12) components(5) composables styles config.ts
packages/desktop-client/
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / logger / smoke
src/main/ 主进程:index / config(含作业短语与看板偏好)/ ipc / island / tray / update / logger / smoke
src/preload/ contextBridge 白名单桥(index.ts + island.ts,**不暴露 ipcRenderer 本体**)
src/island/ 灵动岛渲染进程:IslandApp.vue / spring.ts / squircle.ts / main.ts
src/renderer/ 学生端渲染进程:api / cache(IndexedDB) / stores / views / island / router
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
scripts/ build-main / dev / smoke / dist-win
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=0.1.0.0
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.1.2.0
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
src/Models/PluginSettings.cs
@@ -171,9 +187,10 @@ pnpm build:classisland-plugin
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 `.cache/`) |
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` |
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint |
| `pnpm check:icons` | 图标注册表门禁(两端 build 已内联,这里可单独跑;见 §5 第 53 条) |
| `pnpm format` / `pnpm format:check` | Prettier(`format:check` 在 master 基线上本就失败,见 §7 第 21 条) |
| `pnpm db:generate` | 生成 Prisma Client |
| `pnpm db:migrate` | `prisma migrate dev`(本机不可靠,别用;见 §7) |
@@ -181,14 +198,15 @@ pnpm build:classisland-plugin
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
| `pnpm db:studio` | Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/mysql.md`) |
| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-09-30 实测 173 项全过) |
| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-10-05 实测 190 项全过) |
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) |
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) |
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 42 项) |
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 68 项) |
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 |
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) |
| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 界面内品牌标) |
### 端口
@@ -200,32 +218,52 @@ pnpm build:classisland-plugin
### 交付产物归集
打包脚本各自输出到固定目录,**归集到仓库根 `releases/` 是本仓库的人工约定**(该目录 gitignore + eslint ignore):
**三个打包脚本都直接输出到仓库根 `releases/<组件>/<版本>/`,没有中间输出目录**
(该目录 gitignore + eslint ignore),人工归集这一步已经取消:
```
release-server/ pnpm dist:server 的输出(免安装目录 + NSIS 安装程序)
packages/desktop-client/release/ pnpm dist:win / dist:dir 的输出(win-unpacked + setup.exe + portable.exe)
releases/ 本仓库约定的归集处(把上面的产物拷进 client/ server/ classisland-plugin/)
releases/
client/<版本>/
安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
免安装/ electron-builder 的 win-unpacked 内容
构建中间/ builder-*.yml、*.nsis.7z
server/<版本>/
安装包/ 班级小助手服务端-<版本>-x64-setup.exe
免安装/ classhelper-server 免安装目录(含 .env、node.exe、data/、logs/)
linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256 + 同内容解包目录
构建中间/ server-installer.generated.nsi
classisland-plugin/<版本>/
安装包/ ClassHelper.ClassIslandPlugin.cipx
免安装/ ClassHelper.ClassIslandPlugin/(解压即用,丢进 Plugins 目录即可加载)
checksums.md
SHA256SUMS-<版本>.txt 跨组件哈希清单(人读,由发布流程维护)
```
版本号取自根 `package.json`(插件用 `manifest.yml` 的前三段,与产品版本对齐);
重新打包只清**当前版本目录**里的产物,历史版本原样保留 —— 所以同版本多次构建留下的
不同产物要自己改名归档(例如 `1.0.0-重构建20261005/`),别把上一次的覆盖掉。
```bash
# 客户端:electron-builder 支持指定输出目录(dist-win.mjs 会把额外参数透传)
node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对路径>\releases\client
# 服务端:输出路径写死在 release-server/,打完自行移进 releases/server/
pnpm dist:server # 首次会跑一次 npm install;重试可加 --reuse-deps 跳过
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过)
pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④)
pnpm dist:win # 客户端:安装包 + 免安装目录;dist:dir 只出免安装目录
pnpm dist:classisland-plugin
```
`dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后
再把原始布局整理成上面的分层(`win-unpacked/` → `免安装/`、`*.exe` → `安装包/`、`*.yml` → `构建中间/`);
显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。
**交付物的完整性标签修正(§7 第 7 条)也挂在这条收尾链路上,别把 `tidyArtifacts` 之后的
`normalizeIntegrityLabel(destDir)` 拆掉。**
---
## 5. 架构硬约定(改代码前必须知道)
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 13 个:`auth / classes / courses / schedules /
homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard`。
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 15 个:`auth / classes / courses / schedules /
homeworks / notifications / calls / imports / integrations / grades / students / teachers / dashboard /
database / update`。
新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
@@ -360,7 +398,10 @@ homeworks / notifications / calls / imports / integrations / grades / students /
主进程把窗口**贴合岛体**(`windowBox` = 当前形态 + 2×阴影留白)并**始终接收输入**
(`setInteractive` 在触摸模式下把入参强制为 true)。三条护栏别动:
- **变小要等岛真的收小**(`cardFitsFormSize` + 渲染进程上报的岛体矩形)—— 立刻缩小会把形变中的卡片裁掉;
- **窗口可见期间只增不减**:`syncTouchLayout()` 里 `if (shrinking && this.win.isVisible()) return;`
—— 需要变小一律等窗口隐藏之后再做(`syncWindow()` 的"不可见时对齐包围盒"那条)。立刻缩小既会裁掉
形变中的卡片,又会因为 DWM 还没重画桌面而留下上一帧(用户拍照反馈过"收回一瞬间闪一下");
(注:`cardFitsFormSize()` 这个"按岛体矩形判断能不能缩"的旧判据**已无调用**,别再照它理解现状。)
- `expectedHitRect` 必须按**窗口实际尺寸**算偏移(触摸模式下窗口不再是固定包围盒);
- 失焦宽限期用 `BLUR_GRACE_TOUCH_MS`(1200ms):触摸没有光标,`isCursorOnIsland()` 恒为 false,
而实测假失焦出现在 70~520ms(跨过 500ms 边界 ⇒ 会把刚展开的岛缩回胶囊)。
@@ -395,9 +436,9 @@ homeworks / notifications / calls / imports / integrations / grades / students /
长高发生在卡片形变**之前**(`setState` 里先 `relayout()` 再 `emit()`),用户看不到这一步;
- 列表态 CSS 的每个尺寸(行高/行距/内边距/标题行/提示行/按钮行)都要与那份度量**一一对应**,
改 CSS 必须改常量,否则"最后一行被切掉"这类问题会以"看着有内容、按钮点不到"的形式出现。
回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。
写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**,
否则量到的是列表高度。
回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。
写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**,
否则量到的是列表高度。
34. **"知道了 / 标为已读"之后是「状态同步落地 + 窗口淡出」,没有跨进程的收回握手。**
收起一律是**主进程单向**的:`handleAction()` 收到 `dismiss` / `dismiss-all` / `mark-read` /
@@ -427,18 +468,19 @@ homeworks / notifications / calls / imports / integrations / grades / students /
回执拿到 id 后再补记一次,投递前用 id / 指纹双重判定。改作业录入入口时别忘了在请求前调
`markHomeworkCreatedLocally()`。回归用例:「本机录入的作业不再上灵动岛」。
36. **触摸模式下收回动画期间,窗口必须一动不动**(`syncTouchLayout()` 开头 `if (this.closing) return;`)。
36. **触摸模式下收回动画期间,窗口必须一动不动**(窗口可见期间一律不缩窗口,见 `syncTouchLayout()`)。
触摸模式窗口是"贴合岛体"的,而收回时卡片正在缩小:若跟着缩窗口,**中心停靠下窗口 x 还要右移**,
"让出去"的那块区域会留着上一帧(DWM 还没重画桌面)——同一帧里能看到"旧展开卡的左半截 + 新胶囊"
两份画面(用户拍照反馈的"收回一瞬间闪一下")。窗口尺寸一律等**隐藏之后**再对齐
(`syncWindow` 里"不可见时对齐包围盒"那条,不可见时改尺寸不会有残影)。
回归用例:「触摸模式收回:动画期间窗口不动」逐帧采样窗口矩形(只在"窗口可见 / 正在收回"时追究,
收完隐藏后对齐尺寸是正常的);「收回动画期间卡片不被窗口裁切」。
另:`cardFitsFormSize()` 的判据里**不能**再塞 `closing` —— 那条早退已经把收回整个排除了,
早先漏掉这一条时窗口会在卡片还很大时就缩成胶囊尺寸,卡片底部被平切("上半剩圆角、下半截断");
而"隐藏态改边距不生效"那条修法仍要保留:`this.win.isVisible() && (mode !== 'hidden' || idleSliver)`。
另:**别再去代码里找 `closing` 或 `cardFitsFormSize()`** —— 自研的那层"收回快照"(§5 第 34 条已说明
整体删除)与"按岛体矩形判断能否缩小"这个旧判据都已不在调用链上,最终判据只有上面那条
`shrinking && isVisible` 早退。"隐藏态改边距不生效"则是由 `setAppearance()` 无条件 `relayout()`
加 `applyWindowLayout()` 的"矩形没变就不调 setBounds"守卫保证的,同样不需要额外的可见性判断。
40. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动;
37. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动;
胶囊内容若按卡片宽度布局,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
(用户反馈的"新消息那行字往右跳一下再缩回")。所以 `IslandApp.vue` 的胶囊层里套了一层
`.pill-inner`,**固定成胶囊自己的几何**(`width: var(--island-w)`),并按停靠方式对齐
@@ -446,7 +488,7 @@ homeworks / notifications / calls / imports / integrations / grades / students /
左/右停靠贴对应边,居中则居中 —— 静止态外观与之前完全一致,动画全程文字位置与省略号都不变。
回归用例:「灵动岛开合时胶囊内容不横移」,逐帧采样 `.pill-title` 左右边缘,波动必须 ≤ 2px。
41. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。**
38. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。**
`verify:desktop` / `verify:packaged` 会真的在屏幕上放一个灵动岛窗口(顶部居中),而用户自己的客户端
默认也在同一位置(他的设置常是 `top-center` + 较大 `marginY`)—— 两个岛一高一低叠在一起,用户会以为
**"屏幕上出现了第二个灵动岛"**,而且点它"反应不对"(它归验证脚本控制,会自己展开/收起/消失)。
@@ -457,35 +499,427 @@ homeworks / notifications / calls / imports / integrations / grades / students /
**只改颜色、不要改停靠位置**:命中/几何断言都是按默认位置写的,把岛挪到角落会把
「空闲细缝态命中跟随光标」这类用例弄红(实测踩过)。
另外:冒烟会**真发通知**给当前班级(实时链路用例必须打到客户端所在班),所以验证期间用户的通知列表/
岛上会短暂出现几条「张老师 · *自检*」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`),
岛上会短暂出现几条「张老师 · _自检_」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`),
若验证被中途 kill 会留下残条,收尾时顺手清一下(标题里带「自检 / 红点位置校验」的那几条)。
42. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟,
39. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟,
而用户很可能正在同一块屏幕上 —— 他的客户端那个岛和冒烟这个窗口都在屏幕顶部居中、互相叠着,
他点到的是**冒烟这个窗口**:实测冒烟收到了人发出的 `collapse` / `mark-read`,把「点胶囊展开」
「点空白处收起」等用例整片弄红。开关只忽略**真实**输入:`cursorOverride`(`setHitTestCursor`)
一旦注入就照常按注入位置判定,所以「光标轮询校正命中」「岛外穿透」「触摸模式始终接收输入」
这些断言不受影响(触摸模式那条会临时关掉开关)。跑完在收尾处恢复。
37. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读
40. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读
一次的话,同路由再推 query(冒烟切到"最近有作业的日期"、用户从别处深链回来)会被复用的组件
无视,看板停在原日期 —— 表现为"看板明明有历史作业却显示空"。修法是 watch `route.query.date`
(`HomeworkView.vue`,与 `selectDate` 的 `router.replace` 形成环时靠"值相同就跳过"防死循环)。
同理,冒烟里"切到有作业的日期"的判据是**看板里没有 `.board-card`**,不是"没有 `.board-host`"
(空看板照样渲染容器,拿容器当判据兜底永远不会触发)。
38. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true`
41. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true`
(上课时段普通通知会被**正确地**暂存到下课、紧急通知不带确认会被 409 `URGENT_DURING_CLASS` 拒掉 ——
两个都是产品功能,别为了测试去改产品行为)。凡是断言"窗口已隐藏"的用例一律**轮询**等待,
不要写死 sleep:`dismiss` 现在带 ~360ms 收回动画再淡出,机器忙时固定 sleep 会间歇性踩空
(「关闭空闲细缝后空闲再次完全隐藏」曾因此 4 连红)。
39. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境):
42. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境):
① **后端起没起** —— 客户端配置指向 `127.0.0.1:4000`,后端不在就进离线模式、数据全空,
观感就是"软件坏了";② **托盘单实例锁** —— 关窗是隐藏到托盘、进程还在,再双击 EXE 会被
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。
43. **Web 端新手引导(`el-tour`)在自动化里的判据有三条坑**(2026-10-02 实测,`verify:web`
「首次登录展示新手引导且可跳过」踩过):
① **外壳先出现、内容晚一拍** —— `document.querySelector('.el-tour')` 先于步骤卡片渲染成立,
急着找 `.el-tour__closebtn` 会拿到 null;出现判据要等 **× 按钮渲染出来**(等 10s 不为过)。
② **关闭后内容元素残留** —— 和 el-dialog 一样(本表第 22 条),点 × 生效后 `.el-tour` 仍在 DOM
(父级 `.el-popper` 被隐藏,computed display 仍是 block);**关闭判据是 `.el-tour__mask` 卸载**,
不是 `.el-tour` 消失。跳过 / 走完都会写 `classhelper.onboarding`(版本号),可顺带断言。
③ **全高元素(侧边栏)作锚点必须显式 `placement: 'right'`** —— 默认 bottom 会把卡片推到
视口外(表现为"遮罩挖了洞、卡片不见了")。
另外 `ui-smoke` 管理员用例里的 `localStorage.clear()` 会把「已看过」标记一并清掉,管理员登录后
引导会再次弹出 —— 该用例里必须先跳过它再继续,别只处理首次登录那一次。
44. **数据库管理模块(服务端)** 见 §5 第 36 条;做"切库/备份/迁移"类改动前先读那一条,
那里写清了快照格式、外键陷阱、切换为什么必须走子进程、以及打包为何要带上 prisma CLI + tsc。
45. **客户端设置页已拆成子路由**(2026-10-02):`/settings/{general,appearance,island,reminder,account,about}`,
`/settings` 重定向到 `/settings/general`;旧的单一 `views/SettingsView.vue` **已删除**。
改设置相关冒烟用例时必须改到对应子路由(`smoke.ts` 里 `location.hash = '#/settings'` 已全部改掉),
否则会停在通用页、找不到灵动岛滑块或「ClassIsland 联动」卡片而误报失败。
46. **深色主题的实现边界**:颜色一律走 `--ch-*` / `--el-*` 变量(`styles/index.css` 的 `:root`
是浅色值、`html.dark` 是深色值),**组件里不要写死颜色**。已知的固定深色区域(灵动岛、
登录页、App 启动过渡页、全屏作业看板)刻意不随主题 —— 改它们会被当成回归。
主题状态在 `stores/ui.ts`(持久化到主进程配置 `theme`),主窗口 `backgroundColor` 也按主题设置
(深色下若仍用浅色底会"先白后黑"闪一下)。
**两端都有深色主题,机制一致**(Web 管理端 2026-10-06 补齐):`html.dark` + element-plus 的
`dark/css-vars.css` + 两端各自的 `stores/ui.ts`。语义色令牌是
`--ch-surface` / `--ch-surface-alt` / `--ch-surface-sunken` / `--ch-surface-hover` 与
`--ch-text` / `-secondary` / `-muted` / `-placeholder`;**`html.dark` 只覆盖颜色,
绝不覆盖圆角与动效令牌** —— 两套主题的几何必须完全一致,否则切换时会看到布局跳一下。
Web 端还要一并同步 `<meta name="theme-color">` 与 `color-scheme`(后者管原生控件:
滚动条、日期选择器弹层 —— 只改 CSS 变量的话它们仍是浅色)。
默认**跟随系统**(`prefers-color-scheme`,系统改了就跟着变);用户手动点过之后以用户为准。
切换入口两端都在顶栏,都走 `useThemeReveal()`(见第 61 条)。
改令牌时只改值 —— **不要对样式表跑批量 sed**,理由见第 61 条末尾。
47. **数据库管理模块(`/api/database/*`,仅 ADMIN)——改它之前必须知道的四件事**:
① **一切都建立在 `lib/snapshot.ts` 的 JSON 快照上**(备份 / 导入导出 / 跨库迁移共用一种格式,
14 张表按拓扑序导出、恢复时临时关外键检查、分批 `createMany`)。恢复时**必须**先关外键检查:
schema 里 `User.classId → Class` 与 `Class.teacherId → User` **互相引用**,任何排序都会被一侧卡死。
② **一键切换必须走子进程**:运行中的服务进程缓存的是旧方言的 Prisma Client,改完 provider 后
只有**新进程**才能加载重新生成的客户端 —— 因此 `generate` / `db push` / 写数据分别用
`spawn(process.execPath, ...)` 拉起(数据写入走 `dist/tools/apply-snapshot.js`)。
**顺序不能换**:备份 → 导出 → 测试连接 → 校验目标库为空 → 改 schema → generate → 编译 → db push → 写数据 → 改 .env;
`.env` 只在**全部成功后**才改写,中途失败回滚 `schema.prisma`(现有库不受影响)。
③ **`prisma generate` 的产物是 `.ts`**(`prisma-client` 生成器),所以:dev 由 tsx 直接跑 src;
**打包形态必须再跑一次 `tsc -p tsconfig.generate.json`** 把新客户端编进 `dist/generated`,
生产包因此要随包带上 `prisma` CLI、`typescript` 与 `schema.prisma`/`prisma.config.ts`
(`scripts/dist-server.mjs` 的 `buildRuntime` 已拷;安装包 ~34MB → ~71MB)。
④ **主库只支持 SQLite / MySQL**:Redis 等键值库 Prisma 不支持(无法建表/迁移),
schema 层直接 422 拒绝;这是产品口径,别"顺手"加上。
e2e 只覆盖校验分支(同库 400 / 非法 provider 422 / 目标库非空),**不真跑切换**(会改写 .env);
真实切换用子进程链路单独实测(快照 728 行逐表一致)。切换后**必须重启服务端**才生效。
48. **客户端主窗口初始尺寸与 ClassIsland 对齐(1242×582)**。这个数不是拍的:用
`GetWindowRect` + `DwmGetWindowAttribute(DWMWA_EXTENDED_FRAME_BOUNDS)` 量运行中的
`ClassIsland.Desktop` 主窗口(实测可视区 1242×582,屏幕工作区 1600×852、DPI 100%)。
`main/index.ts` 的 `createWindow` 按它设 width/height + `center: true`,
**`minHeight` 必须 ≤ 582**(否则窗口达不到目标高度,用户会看到"设置不了那个大小")。
冒烟断言:「初始窗口尺寸与 ClassIsland 对齐(1242x582)」(`win.getSize()`),改尺寸即红。
49. **侧栏是"一级项 + 一个设置分组",折叠后是图标导轨 —— 五条硬要求**,都是用户报过的问题固化的:
① **菜单结构**:课表 / 作业 / 通知 / 成绩是**一级 `el-menu-item`**(2026-10-05 按用户要求把原来的
「学习」分组拆掉:分组标题白占一行、还得多点一次展开,折叠成导轨时尤其明显);只有「设置」
保留 `el-sub-menu`(6 个子项平铺太长)。`layoutNavigationSelfTest` 里有断言盯着
"一级项不能还挂在任何 `.el-sub-menu` 里"。
② **折叠是 CSS 收拢动画,不用 `el-menu` 的 `:collapse`** —— 那个 prop 会把每个分组的子项
卸载进浮层(一次结构切换)并让整条菜单重排一遍:既做不出连续形变,也是"点汉堡卡一下"的渲染侧来源。
现在折叠只做三件事:栏宽(`.aside` 的 `width` 过渡)、文字(`min-width:0` 让 flex 压得动 +
`overflow:hidden` 裁切 + `opacity` 淡出)、图标位置(`padding-left` 过渡)。**全程没有结构变化、
没有 `v-if` 增删**,导轨里分组标题与子项都以图标列出(**不再有悬停浮层**)。
时长/缓动/图标-文字间距统一在 `styles/index.css` 的 `--nav-dur` / `--nav-ease` / `--nav-gap`。
③ **折叠态图标必须与顶部汉堡按钮同一竖列**(冒烟断言中心差 ≤ 2px,实测 11 个图标全 0.0px)。
几何:64px 栏、菜单项左右各 8px margin、折叠后 padding 左右各 12px ⇒ 图标中心 = 8+12+12 = 32px
= 64/2;汉堡靠 `padding-left: 18px` 位移到位(**不能用 `justify-content: center`**,它不可过渡)。
④ **导轨必须"一眼看全",不许出现滚动**(用户要求"不要用滚轮、删掉滚动条"):
- 折叠态页脚要**把高度也收掉**(`.is-collapsed .aside-footer` 的 `max-height: 0` +
**`min-height: 0`** —— flex 子项默认 `min-height: auto`,而 CSS 里 min-height 优先于 max-height,
不显式归零就压不下去)。留着那 ~70px 的话,11 行图标(462px)在 540px 的最小窗口里放不下。
- 导轨里用 `.is-collapsed .menu { scrollbar-width: none }` 隐藏滚动条;展开态**不隐藏** ——
那一栏确实会超(设置组展开约 606px > 582px),滚动条是"下面还有内容"的唯一提示。
- 冒烟断言「导轨无溢出」(先展开设置组造出 11 行的最坏情况,再断言 `scrollHeight ≤ clientHeight`)。
⑤ **折叠态文字不能"看得见"** —— 判据已从"没被布局"(老的 `display:none`)改成"看得见"
(有宽度且未透明):现在靠 `overflow:hidden` 裁切,文字仍参与布局。冒烟断言「文字残影=无」。
(另:菜单区仍要 `min-height: 0` + `overflow-y: auto`,footer 是 aside 的兄弟 flex 子项,
菜单区滚动后它恒定可见 —— 冒烟断言「侧栏底部可见」。)
改折叠相关样式时的两个坑:
- **压 Element Plus 的层级缩进必须照抄它的选择器形状**:EP 用**三个不同选择器**分别算
「一级项 20px / 分组标题 20px / 分组内子项 40px」(特异度 0-4-0 / 0-4-0 / 0-5-0):
`.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item`
(把 `…__title`、以及多加一层 `.el-sub-menu` 的变体都算上)。写成
`.ch-nav.is-collapsed .el-sub-menu__title` 这种 0-3-0 的一条都压不住 —— **踩过两次**:
第一版漏了分组标题(偏 8.0px),把「学习」拆成一级项后又漏了一级项(同样偏 8.0px)。
现在 `index.css` 里那两条(0-5-0 / 0-6-0)把三种形状全覆盖。
- 图标与文字的间距放在**文字的 `margin-left`** 上,别用 EP 给图标挂的 `margin-right: 5px`
(那会让图标在 24px 的槽里偏左 2.5px)。
另:分组**默认只展开当前路由所在分组**(落在四个一级项上时一个都不展开);
`el-sub-menu__title` 的点击是 **toggle**,自动化里要先判"子项是否可见"再决定点不点标题
(盲点会把已展开的组收起来,后续点不到子项)。
50. **深色主题:渲染进程里除固定深色区域外,颜色一律走 `--ch-*` 变量。**
§5 第 46 条讲了机制,这里补具体踩坑:课表页时间轴卡片曾写死 `rgba(255, 255, 255, 0.6)`,
深色主题下变成"浅灰卡 + 近白字"几乎不可读(用户截图)。自查命令:
`grep -rn "rgba(255, 255, 255\|rgba(0, 0, 0\|#fff" packages/desktop-client/src/renderer`,
除**明确固定深色**的区域(全屏作业看板、登录页渐变、App 启动过渡页、灵动岛自身、
以及 accent 渐变底上的白字)外,都应该是变量。
51. **跑 `verify:desktop` / `verify:packaged` 若大面积报"教师登录失败",先核对本机 teacher1 的密码**:
本机实测是 **`123456`**,不是种子默认的 `teacher123` —— 因为「修改密码」弹窗留空会落到
`DEFAULT_TEACHER_PASSWORD`(=123456),某次人工/脚本操作就会把演示账号改成它。
处理方式是用环境变量传实际凭据(脚本原生支持,**不要去改数据**):
`set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop`。
**注意两个演示账号的口令不一定一起漂**(2026-10-04 实测):本机 `teacher1 = 123456`、
`teacher2` **仍是 `teacher123`**。因此 `verify:e2e`(口令硬编码、不支持环境变量)不能简单地
全局替换 `teacher123` —— 那样 teacher2 会 401,直接挂掉 9 条科任老师用例。
正确做法是**只替换 teacher1 那一行**的临时副本,跑完即删:
```bash
sed "/username: 'teacher1'/s/'teacher123'/'123456'/" packages/server/scripts/verify-e2e.mjs \
> packages/server/scripts/.verify-e2e-local.tmp.mjs
node packages/server/scripts/.verify-e2e-local.tmp.mjs # 副本必须放在 scripts/ 内
rm packages/server/scripts/.verify-e2e-local.tmp.mjs
```
副本**必须放在 `packages/server/scripts/` 里**(不能放 `.cache/`):脚本要 `import 'socket.io-client'`,
换个目录就 `ERR_MODULE_NOT_FOUND`。
52. **更新检查:三端共用一套版本口径,但检查路径与"失败即正常"是硬约束。**
① **版本号单一来源**:服务端版本由 `lib/version.ts` 读 package.json 得到
(打包后读的是运行时清单,即 `dist-server.mjs` 用根 package.json 写的那个),
`app.ts` 里**不要再写字面量版本号**(原先有三处 `'1.0.0'`,而仓库里有 5 份 package.json,必漂)。
比较逻辑放 `@classhelper/shared` 的 `compareVersions` / `isNewerVersion`,三端共用。
② **检查路径不对称是 CSP 逼的,不是随手选的**:Web 端**不能**直连 GitHub
(`securityHeaders()` 的 `connect-src` 只有 `'self'` / ws / CORS_ORIGIN),必须走
`GET /api/update/check`;客户端在主进程直连 GitHub(渲染进程的 CSP 与导航防线不要为这个开洞)。
③ **`ok:false` 是设计内的结果**:机房与教室机器常常只有内网,离线/超时/限流一律收敛成
`ok:false` + 一句人话,**失败结果同样进缓存**(否则断网点一次按钮就白等一个超时)。
④ **自动化里绝不查外网**:冒烟跳过启动自动检查,`verify:*` 的断言只验"结构完整 + 能降级",
断言"一定有新版本"会把用例绑死在外网可达上。`?force=1` 绕过缓存**只对管理员生效**,
否则任意登录用户都能刷爆 GitHub 匿名限流(60 次/小时/IP)。
53. **Element Plus 图标按需注册,漏注册有构建门禁。** 两端都不再 `import * as ElementPlusIconsVue`
全量注册 293 个图标(实测全量打包 minified 203KB,实际只用 22 / 33 个),而是各有一份
`packages/web-admin/src/icons.ts` 与 `packages/desktop-client/src/renderer/icons.ts` 显式列名单。
**不能改成"模板里逐个 import"**:本项目的图标是按**字符串名**引用的(菜单/路由配置里的
`icon: 'Odometer'`、`:icon="'Search'"`、`<component :is="item.icon">`),Element Plus 会用
`<component :is="'Odometer'">` 去解析**全局组件名**,图标必须以全局组件存在。
漏注册的后果是**界面静默渲染成空白**(只在控制台一条 warning),所以在构建前加了静态门禁
`scripts/check-icons.mjs`(已挂进两端 `build` 脚本,根上也可 `pnpm check:icons`):
它对比"源码里用到的图标 ∪"与"注册表里的名单",有遗漏即 exit 1 并指出出现位置。
新增视图时直接用图标名即可,忘了加进注册表会在构建阶段被拦下。
54. **服务端 dist 是运行产物,不带声明与 sourcemap。** `packages/server/tsconfig.build.json` 与
`tsconfig.generate.json` 都显式关掉 `declaration / declarationMap / sourceMap` 并开 `removeComments`
—— 根 `tsconfig.base.json` 默认是给"库"用的(三个都开),照搬到服务端会让 dist 从约 0.7MB
膨胀到 3.8MB(其中 `.d.ts` 1.5MB + `.d.ts.map` 1.1MB + `.js.map` 0.5MB,全是运行时用不到的)。
需要 sourcemap 调试时用 `tsconfig.json`(`noEmit`)即可。
**`tsconfig.generate.json` 还必须是自包含的(不 extends 任何配置)**:它随包发布到安装目录的
`server/` 一层,而 `tsconfig.json` / `tsconfig.base.json` 两级基配置都不在包里 —— 一旦 extends
就会在打包形态下报 `TS5083 Cannot read file .../server/tsconfig.json`,数据库「一键切换」
走到"编译新客户端"这一步直接失败(2026-10-05 实测踩到并修掉)。
55. **服务端安装包的体积靠"运行时裁剪"压,规则写在 `scripts/dist-server.mjs` 的 `pruneRuntime()`。**
打包顺序是 `installDependencies() → buildRuntime() → pruneRuntime() → buildInstaller()`,
裁剪在最后一份文件就位、打包之前做。当前四条规则与收益(实测删 2329 项 / 省 101MB):
| 规则 | 省 | 依据(改之前先看这里) |
| ---------------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- |
| `@prisma/client/runtime` 白名单,只留 sqlite/mysql 的 ESM 方言包 | 61.4MB | 生成的客户端按 **provider 写死**动态 import(`internal/class.ts`),换库重新 generate 只会指向对应方言;`_small_bg` 与 CJS 版永不加载 |
| `node_modules/**/*.map` | 19.0MB | Node 没开 `--enable-source-maps` |
| `prisma/build/` 只留 sqlite/mysql 查询编译器 | 15.0MB | 部署形态里 CLI 只被用于 `generate` 与 `db push` |
| 文档(保留 LICENSE/NOTICE)与测试/示例目录 | 5.2MB | 不会被 require |
**两条硬护栏(都踩过)**:① 整棵 `@types/**` 不参与裁剪 —— 类型包里的 `test/`、`docs/` 是真实声明
文件(`@types/node/test/reporters.d.ts` 被 `test.d.ts` 引用),按"测试目录"删掉会让数据库切换的
`tsc` 报 TS6053;② 名字命中 `DROP_DIRS` 的目录,只要里面含 `*.d.ts` 就跳过。
改完必须按下面的清单复验(尤其数据库管理模块,它是唯一会调 Prisma CLI 的地方)。
56. **`verify:classisland` 开头会先"抽干"本班的历史积压提醒。** `/integrations/classisland/pending`
是「本班全部未确认提醒,按时间升序取**前 20 条**」,而 `verify:desktop` / `verify:web` 等冒烟会
**真的往演示班发通知**(见 §5 第 38 条)——那些条没被确认就会一直堆着,攒够 20 条之后
本脚本刚发的提醒会被挤出窗口,表现为「待提醒里找不到刚发的那条」,跟代码对不对毫无关系
(2026-10-05 实测:库里积了 45 条历次冒烟残条,本脚本从 42/42 掉到 35/40)。
现在脚本用 `drainPending()` 按**真实插件重连后的行为**(补弹并逐条确认)先清空,再开始断言,
可见范围与插件一致(只看 `deviceId` 为空的本班广播 + 本设备自己的推送)。
**别把这个 drain 删掉**,否则这个门禁会重新变成"跑几次就红"的随机失败。
57. **桌面端配置(`config.json`)是"常驻内存 + 异步合并落盘",别再当成同步写。**
`main/config.ts` 里配置**只读一次盘**(懒初始化),`getConfig()` / `saveConfig()` 之后一律走内存缓存
(连已解密的 token 一起缓存,不再每次过一遍 DPAPI);落盘改成"记下待写 → 120ms 合并窗口 →
`fs.promises.writeFile`",同一拍内的多次改动只写最后一次,多次写入之间串行、不会互相覆盖;
退出前由 `flushConfigSync()`(挂在 `main/index.ts` 的 `shutdownResources()` 里)同步兜底写一次。
**为什么必须这样**:侧栏折叠、主题切换、通知渠道、作业看板偏好、登录/登出**全都走 `saveConfig`**,
而它原先一次调用要在**主进程主线程**上做「`readFileSync` + `JSON.parse` + 8 个 normalize」**两遍**
(开头读一遍、结尾 `getConfig()` 又读一遍)+ `writeFileSync` + DPAPI 解密一遍 —— 主进程是 Electron 的
消息泵进程,被它摁住的那一拍渲染进程也跟着掉帧。用户反馈的「点侧栏汉堡展开/收回时卡一下」
就是这一拍(2026-10-05 定位并修掉;当时先误查到灵动岛,那块与它无关)。
三条硬约束:
① **缓存必须懒初始化**:`main/index.ts` 会先 `app.setPath('userData', smokeProfile)` 再读配置,
在模块顶层立即读盘会读错文件;
② **`saveConfig()` 返回时数据可能还没落盘**(最多晚 120ms)。要"读回刚存的值"一律用 `getConfig()`
(读的就是缓存里的最新值),别去读文件;正常退出(托盘 → 退出)会 flush,只有进程被强杀才会丢
最后 120ms 内的改动;
③ 别把落盘写成"每次保存挂一个 promise 链"—— 拖滑块/连点会排队几十次写。`clearConfig()`(退出登录)
走 `immediate` 分支,不参与合并窗口。
58. **顶栏右侧(主题切换按钮 + 用户名下拉)必须是 flex 容器。** Element Plus 给 `.el-button` 的是
`vertical-align: middle`、给 `.el-dropdown` 的是 `vertical-align: top` —— 两个 inline-level
子元素放在普通 block 容器里会各按各的垂直对齐规则落位,错开几像素(用户反馈的"**深色模式没对齐**",
其实两种主题下都偏,只是深色下更显眼)。`.header-right` 必须写
`display:flex; align-items:center; gap`,与 Web 管理端 `AdminLayout` 的同名容器同一套写法
(那边一开始就是对的,桌面端当初漏了这条规则)。回归断言在 `layoutChromeSelfTest` 里:
量主题按钮与 `.user-chip` 的**中线差**,要求 ≤ 1.5px(实测 0.0px)。
59. **Linux 交付链有六条硬约定**(`deploy/install.sh`、`deploy/classhelper`、`scripts/dist-server.mjs`
的 `--platform linux`、`deploy/verify-linux.sh`)。改这条链路前先读这一节,每条都对应一个踩过的坑:
① **配置真身在 `/etc/classhelper/config.env`,安装目录里的 `.env` 必须是指向它的软链。**
Web 端「数据库管理 → 一键切换」在运行中改写的是 **`<安装目录>/.env`**(`database.service.ts`),
而 systemd 是通过 `EnvironmentFile=` 注入同一份配置的;dotenv 不覆盖已存在的环境变量,
所以两处一旦不是同一个文件,切换就会**看起来成功、重启后又连回旧库**(极难排查)。
权限同时满足"配置文件 600 / 配置目录 700"与服务进程可读写(属主给 `classhelper`)。
`classhelper doctor` 里有专门一条断言盯着这条软链。
② **systemd 单元的 `ReadWritePaths` 必须同时包含安装目录与配置目录。**
服务端不是只写 `data/`:一键切库会改写 `server/prisma/schema.prisma`、把新客户端编译进
`server/dist/generated`,并改写 `config.env`。`ProtectSystem=full` 下不显式放行就会失败。
占位符有 5 个:`__NODE__ __DIR__ __USER__ __CONFIG__ __CONFIGDIR__`(少替换一个,
systemd 会拿字面量当路径)。
③ **升级/回滚替换程序文件必须用"改名 + 就位",不能 `rm -rf` 再 `cp`。**
升级时正在运行的就是 `bin/classhelper` 自己:先 `mv bin bin.old-…` 再放新目录,
运行中的进程继续读旧 inode;直接删掉同名文件会让 bash 读到半个脚本(报莫名其妙的语法错误)。
④ **Linux 包只能在 Linux 上构建,产物名固定。** `@libsql/linux-x64-gnu`、`@prisma/adapter-libsql`
是平台相关依赖,Windows 上装的 `node_modules` 拷过去跑不起来 ⇒ 由
`.github/workflows/release-linux-server.yml`(ubuntu runner)出包。命名约定
`classhelper-server-linux-<arch>-<版本>.tar.gz` + 同名 `.sha256`(旁边那份、以及 Release 里的
`SHA256SUMS-<版本>.txt`),`classhelper upgrade` 按这个名字拼下载地址,**改名即断升级链路**。
非 Linux 上 `--platform linux` 也能跑(会加 npm 的 `--os=linux --cpu=x64` 按目标平台解析依赖),
但那是**应急**,已在 2026-10-05 实测核对过包内容:`@libsql/linux-x64-gnu`/`-musl` 就位、无 win32 残留、
服务端与全部运维命令可用;**只有 Web 端「一键切换数据库」存疑** —— 那条链路要 prisma CLI 的原生
schema engine,CLI 运行期按平台扫 `@prisma/engines/schema-engine-debian-openssl-3.0.x` 这类路径,
而交叉包里只有 `schema-engine-windows.exe`。交叉构建会写入 `.cross-built` 标记,
`classhelper doctor` 据此提示(别改成静默),`upgrade` 替换程序文件时会按新包有无该标记来增删。
正式产物仍走 CI。**交叉构建时 `tar` 还有两个 Windows 专有坑**(`packLinuxTarball()`):
`-f F:\...tar.gz` 里的**盘符冒号**会被 GNU tar 当成 `主机:路径` 的远程归档写法,报
`tar (child): Cannot connect to F: resolve failed`、退出码 2 —— 现象很误导:包已经整套铺好、
就差最后打 tar 那一步失败。反斜杠还会被当成转义符。所以那一步把路径统一转成**正斜杠**
并加 `--force-local`。该选项在 Linux 上无害(本地路径本来就该走本地分支),因此无条件加上、
不做平台分支 —— **Linux 上路径没有盘符,这个坑在 CI 与真机上永远复现不了**,只在交叉构建时现形。
⑤ **密码永远走 stdin,命令行一律不接受密码;调用服务端模块的工具必须压掉它的 stdout 噪声。**
`classhelper password` 用 `read -s` 优先读 `/dev/tty`、无 tty 回退 stdin(这样既能交互,
也能被验收脚本用管道喂 —— 都不进 history/ps)。输入循环**必须有界**:stdin 结束时 `read`
返回非 0,不看这个标记就会在 EOF 上死循环(`verify-linux.sh` 里有一条"不合规时不卡死"的回归)。
写配置(`config set`)同理:值经临时文件传给 `awk`,不进 argv;敏感键强制交互输入,
`key rotate` 自己生成的密钥走内部写函数。另外 `tools/admin-cli.mjs` 复用了服务端的
`config/env.ts` 与 `lib/logger.ts`,**这两个模块会往 stdout 打日志**(dotenv 的
`◇ injected env …` 提示、Prisma 初始化一行)—— 调它时必须带上
`LOG_LEVEL=error DOTENV_CONFIG_QUIET=true`,否则 `dump-hash` 写出的"原哈希备份"里会多一行日志,
变成非法 JSON,**改密失败自动回滚整条链路静默失效**(dotenv 不覆盖已有环境变量,所以这两个
变量能生效,已实测)。
⑥ **内置 Node 要按 glibc 选构建。** Node 官方 linux-x64 二进制要求 glibc ≥ 2.28,
CentOS 7 是 2.17 —— 装上去的表现为"服务起来就退出、日志什么都没有"。安装器检测 glibc,
低于 2.28 时自动改用 unofficial-builds 的 `-glibc-217` 变体。CentOS 7 仍属尽力而为。
两条写这类脚本时的通用坑(本仓库踩过):**`set -e` 下 `[ cond ] && 可能失败的命令` 会
在条件为假时……其实不会退出**(bash 对 `&&` 列表里非最后一条失败是豁免的),但
`A && B` 里 B 失败、以及**用 `$(函数)` 捕获输出时把日志一起捕获**(所以安装器用全局
`STAGE_DIR` 而不是 `echo` 返回路径)这两类是实打实的 bug;`die` 要先 `trap - ERR` 再退出,
免得 ERR 钩子再补一句误导的"第 N 行执行失败"。
60. **安全响应头里的 `upgrade-insecure-requests` 与 HSTS 只能在 HTTPS 请求下下发**(`middleware/security.ts`
的 `buildHelmet(httpsRequest)`,按 `req.secure` 二选一;TRUST_PROXY 开启时 `req.secure` 已含
`x-forwarded-proto` 判断)。helmet 的默认 CSP **自带 `upgrade-insecure-requests`**,而安装器的
默认形态是 `http://IP:端口` 直连(无 TLS):这条指令会把页面**子资源**请求全部改写成 https 发出去,
服务器没有 TLS 监听,全部连接失败 —— 表现为「管理端一片空白、标签页标题正常」
(2026-10-05 用户在 98.142.241.144 实测踩到;`/healthz`、登录 API 都正常,纯浏览器侧的资产全灭)。
HSTS 在明文响应里按规范会被浏览器忽略,但 CSP 指令没有这层豁免,所以两者都要跟着 https 走。
开发时一直用 localhost(浏览器视为安全上下文、不做改写),所以这个坑在本地永远不复现,
只有真机 http 直连部署才炸 —— 别因为"本地好好的"就把条件判断摘掉。
61. **动效层:令牌在 `packages/shared/src/motion.ts`,两端各一份组合式函数。**
移植自 [beUI](https://beui.dev) 的 `lib/ease.ts`(它的组件是 React,用不了;动效语言与框架无关)。
三组缓动 + 六组具名弹簧(`SPRING_PRESS/SWAP/PANEL/LAYOUT/MOUSE/GLIDE`)。
- **令牌必须是纯常量**:`packages/shared` 被后端一起消费,它的 tsconfig 是 `lib: ["ES2023"]`
(不含 DOM),所以 `motion.ts` 里不碰 `document` / `window` / vue。DOM 注入与 Vue 组合式函数
在两端各自的 `composables/motion.ts`(两端各一份,与 `ScoreBarChart.vue` 同一做法)。
- **CSS 变量在 `app.mount()` 之前由 JS 注入 `:root`**(两端 `main.ts` 调 `applyMotionTokens()`)。
CSS 里只引用 `var(--ch-spring-*)`,**不要抄数值** —— 抄了必漂。
- **弹簧靠数值积分生成 CSS `linear()`**,于是弹簧可以纯 CSS 驱动,不需要每组件一个 rAF 循环。
算落定时间**不能用包络公式 `6/(ζ·ω0)`**:它只对欠阻尼成立,过阻尼要用较慢的实极点
`ω0(ζ−√(ζ²−1))`,否则曲线会被截断在 0.87/0.95 上、元素永远差一截没到位(实测踩过)。
- **`--ch-spring-X` 与 `--ch-spring-X-dur` 必须成对用**:`linear()` 是把 [0,1] 进度重映射,
时长给短了等于把整条曲线等比压缩 —— 形状还在,但已经不是那组参数的手感了。
- `linear()` 的进度值可以 >1(过冲):只能用在不越界无害的属性上。**别用在 `opacity`**。
**四条硬约束(都对应实测过的坑)**:
- **路由层不要包 `<transition>`,尤其不要 `mode="out-in"`。** 它把"旧组件卸载、新组件挂载"推迟到
退场动画结束(90ms 起),后果有两个:① 点菜单后有一小段时间页面上还是上一页,`ui-smoke` 的多条
用例("pathname 一变就找新页面的元素")会整片判红 —— 实测一次掉 9 项;② 退场判定依赖
`animationend`,**窗口被遮挡时 Chromium 冻结 CSS 动画**(实测 `document.timeline` 1.2 秒推进 0ms),
事件永不触发 ⇒ 新页面永远不挂载,整个应用卡死在第一页。页面切换的动效改用 `.page > *` 错峰入场,
那是随组件挂载**同步**触发的。
- **Vue 的 `<transition>` 一律显式给 `:duration`。** 同上,靠 `animationend` 判断的写法在窗口不可见时
会永远停住(`UrgentClassWarning` 那块铺满全屏的遮罩尤其致命:不卸载就再也点不动)。
- **`position: fixed` 的整屏层、以及"自己量几何再据此设尺寸"的元素,不要叠位移类动画。**
`.page > *` 错峰入场排除了 `.el-overlay`(Element Plus 的 `el-dialog` 默认 `append-to-body=false`,
声明在视图里的弹窗其遮罩就是 `.page` 的直接子元素)、`.el-loading-mask`、`.board-host`
(客户端的作业看板按"刚好铺满"算 `transform: scale()`,下移 6px 就溢出屏幕)。
判定方法是 `getBoundingClientRect()` **包含** transform,而 `clientHeight` / `offsetHeight` 不含。
- **弹窗面板的动画要挂在 `.el-dialog` 上,不要挂在 `.el-overlay-dialog`(居中容器)上。**
Element Plus 的 `@opened` 按**根元素**(`.el-overlay`,只有 200ms 淡入)的动画结束触发,
而外层容器上的弹簧要跑 463ms —— 任何在 `@opened` 里量容器尺寸做自适应的弹窗都会量到
"还带着 `scale(0.97)` 的缩小版",被 `minScale` 兜住后反而超出屏幕(作业看板实测 +7px)。
挂 `.el-dialog` 还有个好处:全屏弹窗能用 `is-fullscreen` 精确豁免(全屏表面本来也不该缩放入场)。
**两条容易踩的实现细节**:
- 客户端的 `--nav-ease` **刻意保留** Material 的 `cubic-bezier(.4, 0, .2, 1)`,没有统一成 EASE_OUT。
EASE_OUT 起步极快,宽度掉到 80px 以下时动画才走了约 35%,而 `layoutChromeSelfTest` 一到读宽度
判据就往下走、立刻量图标中心,此刻内边距还在动 ⇒ 6 个分组子项偏 2.3px 判红。想换曲线得连那条断言
一起重新商量。(该断言的宽度判据已从 `≤80` 收紧到 `≤65`,即"真正收拢到稳态"再量 —— 折叠目标宽度是 64px。)
- **Vue 的 scoped `<style>` 可以引用全局 `@keyframes`**(只有同一个 scoped 块里**定义**的才会被改名)。
实测:`.login-card` 的 `animation: ch-panel-in …` 原样输出且生效。但 `mode="out-in"` 之类的
transition 类名仍在 scoped 块里写、元素也带 `data-v`,能正常命中。
- `el-aside` / `el-menu` 这些是**组件**不是原生标签:模板 ref 拿到的是组件实例,
`.querySelector` 不是函数(会被 `app.config.errorHandler` 吞成一行中文异常,界面看着毫无异样)。
用 `$el`,或直接 `document.querySelector`(客户端的主侧栏只有一个,后者更直白)。
- 降低动态效果:两端 `styles/index.css` 各有一个 `@media (prefers-reduced-motion: reduce)` 全局块,
把动画压到 1ms(不是删掉,`transition: none` 会让依赖 transitionend 的逻辑失灵)。
**加载指示旋要豁免**(改为放慢而非停)—— 停掉会让界面看起来像卡死。
**灵动岛:只移植曲线,不移植弹簧。**
岛的形变由主进程缓动窗口尺寸(`src/main/island.ts`),而冒烟断言「展开过程不震动」
「收回过程不抖动」**逐帧采样岛体几何**并要求单调 —— 任何带过冲的弹簧都会直接判红。
所以岛用 `--ch-ease-out` 这类单调曲线,壳的物理仍归它自己的 `island/spring.ts`(WinIsland 移植),
不要拿 beUI 的 `SPRING_*` 去换。
`IslandApp.vue` 是**另一个渲染进程**,不共享主窗口的 `:root`:令牌要在
`src/island/main.ts` 里**再注入一次**(`applyMotionTokens()`)。漏了的话 `var(--ch-ease-out)`
解析为空、**整条 `transition` 简写作废**,反而比改造前更差(完全没有过渡)。
另外岛的 `:hover` 规则必须关在 `@media (hover: hover) and (pointer: fine)` 里 ——
教室触摸屏的"幽灵 hover"会让点过「知道了」的按钮永远高亮。
**深色主题的语义色令牌:只改值,不要对样式表跑批量 sed。**
`--ch-text: #303133` 被"把 #303133 换成 var(--ch-text)"的批量替换一过,就变成
`--ch-text: var(--ch-text)` —— **自引用**会让整条自定义属性失效,`getPropertyValue` 返回空串,
全站文字颜色静默回落成继承色(浅色下看着"还行",深色下就露馅)。实测踩过。
62. **图标:全项目只有一个图形源 `build/classhelper.{png,ico}`,其余一律由 `pnpm icons` 派生。**
这两个文件是**设计导出、手工放入**的,`pnpm icons` 只读不写 —— 要换图标就换这两个文件,
别去改脚本。派生清单写在 `scripts/icons/render.cjs` 顶部的注释里,覆盖:Web 端 PWA 三件套
(`icon-192` / `icon-512` / `apple-touch-icon`)、`favicon`(png + svg 各一份)、
ClassIsland 插件 `icon.png`、客户端 `build/{classhelper,icon}.{png,ico}`、
托盘 `public/tray.png`、官网 `assets/icon.png`,以及**两端界面内的品牌标** `public/logo.png`。
**四条硬约束(都对应实测踩过的坑)**:
- **ICO 原样透传,不要重新生成。** `classhelper.ico` 里 9 个尺寸
(16/24/32/48/64/72/96/128/256)是设计工具**逐尺寸栅格化**的,比把那张 345×339 的位图
缩下去干净得多;脚本只解析它的目录项做日志与校验,然后把整份复制给客户端与 electron-builder。
- **`packages/desktop-client/build/icon.{png,ico}` 别删。** 它们是与 `classhelper.*` 同步的副本,
而名字是 **electron-builder 的兜底约定**:`convertIcon()` 的候选表是
`['<win.icon>', 'icon.ico', 'icons', 'icon', 'icon.png', 'icon.svg', 'icon.icns']`,
依次在 `[buildResources, projectDir]` 两个根下 stat,**第一个命中的就赢**。
`win.icon` 现在显式写着 `build/classhelper.ico`(实测 `isFallback = false`,
解析到 `packages/desktop-client/build/classhelper.ico`),但那条配置一旦被拿掉,
就会**静默**回落到 `icon.ico` —— 副本同步着,回落也还是新图标。
- **界面内的品牌标用 `<img>`,且两端 URL 的写法必须不同。** Web 端写根绝对路径 `/logo.png`
(永远从源站根提供服务;前端是 history 路由,相对路径在 `/classes/1` 这类嵌套路由下会解析错)。
客户端**必须经 `BRAND_LOGO_URL`(`src/renderer/config.ts`)绑定给 `:src`** ——
字面量的相对 `src` 会被 Vite 当成模块导入去解析
(`Rolldown failed to resolve import "logo.png" from "LoginView.vue"`,构建直接失败),
而根绝对路径在 `file://` 加载的打包形态下会解析到**盘符根目录**。
- **`School` 字形在两端 `icons.ts` 里的注册不要删**:它仍是「班级管理」菜单项与
「班级码」输入框前缀的字段图标,只是不再是品牌标。品牌标换成真图标后,
`.brand-logo` 的 `background` / `color` / `border-radius` / `overflow` 都要撤掉 ——
圆角与透明边角本来就在图里,再叠一层会出现双重圆角。
跑完 `pnpm icons` 记得把**生成出来的那些图**一起提交(脚本会比对内容,没变动的不会重写)。
源图 345×339 不是正方形,脚本按等比缩放居中补透明(补边约 1.7%,肉眼不可见);
512 那两档是**放大**,会略软 —— 那是源图分辨率的上限,要更清晰只能换更大的设计导出。
---
## 6. 代码风格
@@ -501,6 +935,14 @@ homeworks / notifications / calls / imports / integrations / grades / students /
- **提交信息**:Conventional Commits + 模块 scope,描述用中文,可多 scope,例如
`fix(island): 胶囊"点不动"的根因——窗口必须始终可激活`、
`feat(teachers,classes,schedules,web): 教师录入(仅管理员)`。
- **图表不引第三方库。** 全项目只有三张图(桌面端成绩页 1 张柱状图;Web 端成绩页柱状 + 折线各 1 张),
历史上用 echarts 全量引入,占了桌面端渲染产物 1.12MB(35%)与 Web 端 1.13MB。现在换成
自包含的轻量组件:`packages/desktop-client/src/renderer/components/ScoreBarChart.vue`、
`packages/web-admin/src/components/{ScoreBarChart,ScoreLineChart}.vue` ——
柱状图是纯 HTML/CSS(柱高走百分比、刻度与网格线用**零高度 flex 行 + space-between** 对齐),
折线图是 `viewBox="0 0 100 100" + preserveAspectRatio="none"` 的 SVG(数据点用 HTML 绝对定位,
免得非等比拉伸把圆点压成椭圆)。两者都是响应式的,**不要给它们加重算尺寸的 resize 监听**。
新增图表请沿用这个做法,不要重新引入图表库。
- **改动交付形态 / 新增功能后**,同步更新 `README.md`(它兼作产品说明书与验收对照表)。
---
@@ -597,10 +1039,12 @@ homeworks / notifications / calls / imports / integrations / grades / students /
(`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore。)
11. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后,
这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉;`release-server/` 同理)。
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉;旧的 `release-server/` 条目留作兜底)。
**它一度只在 ESLint 里被忽略、`.gitignore` 里漏了**(后者只有单数的 `release/` 和 `release-server/`):
归集一次客户端产物就有 225MB 的 EXE 变成未跟踪文件,`git add -A` 会直接把安装包提交入库。
2026-09-30 已在 `.gitignore` 末尾补上 `releases/`。
(2026-10-05 起 `release-server/`、`packages/desktop-client/release/` 这两个中间输出目录已取消,
三个打包脚本都直接输出到 `releases/`,见 §4;那两条 ignore 保留作兜底。)
12. **ClassIsland 的数据根是 `D:\Classisland\data`**(**不是**旧文档写的 `F:\data`,F 盘已不存在):
- 插件 DLL 覆盖到 `D:\Classisland\data\Plugins\classhelper.classisland.bridge\`(**ClassIsland 运行时会锁住
DLL,必须先退出它再覆盖**:`Get-Process ClassIsland.Desktop | Stop-Process -Force`);
@@ -625,7 +1069,8 @@ homeworks / notifications / calls / imports / integrations / grades / students /
- `pnpm dist:win` 报 `!tempfile: Unable to create temporary file!` / `Error in macro _Switch`:**不是空间不足**,
而是 Git Bash 里 `TEMP=TMP=/tmp` —— makensis 是原生程序,把 `/tmp` 当成「当前盘根下的 tmp」解析成
`D:\tmp`,该目录不存在。`/c` 有 79G、`/d` 有 124G 也会照样失败(2026-09-30 实测)。
`win-unpacked` 与 `release/*.nsis.7z` 此时都已生成,**只需重跑 electron-builder,不必重建前端**:
`win-unpacked` 与 `*.nsis.7z` 此时都已生成(整理进 `安装包/`、`免安装/` 的那一步在打包
成功之后才跑,失败时还没执行),**只需重跑,不必重建前端**:
```bash
mkdir -p .cache/tmp
@@ -653,6 +1098,24 @@ homeworks / notifications / calls / imports / integrations / grades / students /
同理,断言岛体尺寸别写死"胶囊尺寸 + 留白":那一刻是胶囊还是卡片取决于前面用例留下的状态,
应改成**与形态无关的不变量**(如"窗口 = 卡片实测尺寸 + 2×阴影留白")。
23. **客户端安装包有硬底:`班级小助手.exe` 单独压缩后就有 85~90MB,"压到 60MB"做不到 —— 别再为这个反复折腾。**
实测(2026-10-05,Electron 44.3.0):`win-unpacked` 372MB,其中 `班级小助手.exe`(就是 electron.exe,
rcedit 只加了 74KB)**234.7MB**;brotli-9 抽样压缩后估算它单独就有 85~90MB。也就是说把渲染产物
从 3.2MB 压到 2.1MB、把图标从 293 个减到 42 个,对安装包只影响约 1MB。
剩下能动的只有这些(合计约 12MB,风险不成比例,**当前一律不做**):
`dxcompiler.dll + dxil.dll` 26MB(D3D12 着色器编译)、`vk_swiftshader + vulkan-1` 6.2MB、
`d3dcompiler_47.dll` 4.5MB(D3D11 回退)、`chrome_200_percent.pak` 1.2MB(高 DPI)、`ffmpeg.dll` 3MB。
教室机器多是老显卡 / 希沃一体机 / 虚拟机,删掉这些就是拿渲染可靠性换体积。
**唯一做的一条**是语言包:`electron-builder.yml` 的 `electronLanguages: [zh-CN]`
(官方选项,`app-builder-lib` 的 `removeUnusedLanguagesIfNeeded()` 会精确删掉其余 54 个
`locales/*.pak`)。实测 `win-unpacked` 372MB → **323MB**、`setup.exe`/`portable.exe`
107.2MB → **98.7MB / 98.4MB**(表中口径均为 MiB,与本机 `ls -la` / `du` 一致)。
要真正做到 60MB 只能换渲染运行时(WebView2 / Tauri,安装包可到 5~15MB),代价是把
`src/main/island.ts`(1840 行)里全部原生窗口行为重写一遍 —— 逐像素透明、鼠标穿透、
窗口贴合岛体、触摸模式、多屏/DPI,全是 §5 第 10/16/21/22/30/32/36/37 条踩过坑的地方,
且 `verify:desktop` / `verify:packaged` 的整套回归要重做。属于独立立项,不是顺手优化。
### 已知脆弱用例(不是环境问题,别去"修环境")
| 用例 | 触发条件 |
@@ -660,6 +1123,7 @@ homeworks / notifications / calls / imports / integrations / grades / students /
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把「周一第 1 节」限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`TERM_START_DATE` 对齐到 `2026-09-07` 即全绿(本机 `.env` 已这样配;`.env.example` 仍是 `2026-02-23`,重置 `.env` 时记得改回来) |
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 |
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
| `verify:desktop`「作业看板全屏自适应」 | 演示数据里某天有 6 条长作业、而冒烟窗口只有 ~543px 高时,`computeScale` 算出的缩放低于下限 0.45 被 `Math.max(minScale, …)` **钳住**,于是必然溢出(实测 565 > 543)。判据用的是 `host.clientHeight` / `inner.offsetHeight`,**这两个是布局属性、transform 不影响**,所以与动效层无关;数据在冒烟里建了又删,每次跑出来的高度还会差几十像素 |
| 岛交互 / 岛动画 / 岛截图留档这几个用例 | **AI 会话里会间歇性失败**:同一份产物连跑两次,失败项会在「点击展开 / 紧急展开动画 / 各状态截图留档」之间跳(2026-09-30 实测三轮结果各不相同)。根因是置顶透明小窗的 rAF 被限流、形变采样抓不到帧,属时序敏感;**先原样重跑一次再动手改代码**,别把偶发当成回归 |
---
@@ -671,7 +1135,7 @@ pnpm lint # ESLint(0 error 为底线)
pnpm typecheck # 全仓库类型检查
pnpm dev:server # 另开终端
pnpm verify:e2e # 后端端到端(2026-09-30 实测 173 项全过)
pnpm verify:e2e # 后端端到端(2026-10-05 实测 190 项全过)
pnpm verify:classisland # ClassIsland 联动链路(实测 42 项)
pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 68 项)
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
@@ -681,6 +1145,25 @@ pnpm build && pnpm verify:web # Web 端真实点击回归(同上
条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`、`ClassIsland 联动插件静态校验:N/N 项通过`),
文档不要写死容易过期的总数。
改动到 **Linux 交付链**(`deploy/install.sh`、`deploy/classhelper`、`deploy/tools/`、
`scripts/dist-server.mjs` 的 linux 分支、systemd/profile.d/logrotate 模板)时:
```bash
bash -n deploy/install.sh deploy/classhelper deploy/verify-linux.sh deploy/install-linux.sh # 语法(Windows 上就能跑)
pnpm dist:server:linux # 只能在 Linux 上跑;开发机是 Windows 时到服务器/CI 上验证
# 真机(任意一台能 SSH 的 Linux,容器也行但 systemd 用例会跳过)
scp releases/server/<版本>/linux-x64/classhelper-server-linux-x64-*.tar.gz deploy/{install.sh,verify-linux.sh} root@<主机>:/tmp/
ssh root@<主机> 'bash /tmp/install.sh --check'
ssh root@<主机> "printf '%s\n' '<密码>' | bash /tmp/install.sh --yes --admin-password-stdin --package /tmp/classhelper-server-linux-x64-*.tar.gz"
ssh root@<主机> "bash /tmp/verify-linux.sh --admin-password-stdin" < <(printf '%s\n' '<密码>')
```
`verify-linux.sh` 会**真的**改一次管理员密码再回滚、真的发一条备份,跑完机器仍是可用状态;
升级/回滚链路要加 `--full --package <新版本包>`(会真的替换一次程序文件)。
**改过这条链路就要在真机上跑一遍**——脚本写在 Windows 上跑不出问题,`set -e` 的坑、
systemd 的 `EnvironmentFile` 解析、glibc 差异都只有在 Linux 上才暴露。
改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`;
本机已装 ClassIsland 2.1.0.1(`D:\Classisland`)与 .NET 8 SDK(8.0.425),因此**真机验证是可行的**
(直接 `pnpm build:classisland-plugin` 编译,再按 §7 第 12 条部署):
@@ -700,4 +1183,28 @@ Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
开发产物通过不等于用户机器上的副本通过。
**改过 `pruneRuntime()`(§5 第 55 条)时,还要对免安装目录本身跑一遍**(开发形态跑通不代表裁剪后跑通)。
从 `releases/server/<版本>/免安装/` 起,用**自带的 node.exe**:
```bash
# ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口)
cd releases/server/<版本>/免安装 && PORT=4100 ./node.exe server/dist/index.js
curl.exe -s --noproxy '*' http://127.0.0.1:4100/healthz
curl.exe -s --noproxy '*' -X POST http://127.0.0.1:4100/api/auth/login \
-H "content-type: application/json" -d '{"username":"admin","password":"admin123"}'
# ② 数据库「一键切换」的三步子进程链路(唯一会调用 Prisma CLI 的地方)
cd server
DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \
../node.exe ../node_modules/prisma/build/index.js generate
../node.exe ../node_modules/typescript/lib/tsc.js -p tsconfig.generate.json
DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \
../node.exe ../node_modules/prisma/build/index.js db push --accept-data-loss
```
跑完把这几个测试残留删掉,别带进交付目录:`server/src`、`server/prune-test.db`,
并把 `data/`、`logs/` 清空。
(`prisma db push` 是破坏性命令,Prisma 7.10 的 AI 代理护栏会要求显式同意;
这里的目标是脚本刚建出来的空临时库,不是真实数据。)
自测本地接口记得绕过代理(见 §7 第 1 条)。
+1054 -1085
View File
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

+2 -2
View File
@@ -7,7 +7,7 @@
SERVER_PORT=4000
# 应用版本(镜像标签)
APP_VERSION=0.1.0
APP_VERSION=1.1.2
# MySQL 配置
MYSQL_ROOT_PASSWORD=请改成强密码_root
@@ -31,7 +31,7 @@ INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=admin123
# 各类账号的默认初始密码(新增/重置时使用)
DEFAULT_STUDENT_PASSWORD=123456
DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
+2 -2
View File
@@ -8,7 +8,7 @@
SERVER_PORT=4000
# 应用版本(镜像标签)
APP_VERSION=0.1.0
APP_VERSION=1.1.2
# JWT 签名密钥(必填,必须 ≥32 位;不能包含 secret / change-me 等示例词)
# 生成:node -e "console.log(require('crypto').randomBytes(48).toString('hex'))"
@@ -25,7 +25,7 @@ INITIAL_ADMIN_USERNAME=admin
INITIAL_ADMIN_PASSWORD=admin123
# 各类账号的默认初始密码(新增/重置时使用)
DEFAULT_STUDENT_PASSWORD=123456
DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
+2 -2
View File
@@ -5,13 +5,13 @@
# runtime —— 仅保留运行时所需内容(生产依赖 + 构建产物),镜像更小
#
# 构建(在仓库根目录执行):
# docker build -f deploy/Dockerfile -t classhelper-server:0.1.0 .
# docker build -f deploy/Dockerfile -t classhelper-server:1.1.0 .
#
# 运行(SQLite + 数据卷):
# docker run -d --name classhelper -p 4000:4000 \
# -e JWT_SECRET=<48字节随机串> \
# -v classhelper-data:/app/data \
# classhelper-server:0.1.0
# classhelper-server:1.1.0
#
# 生产推荐用 docker compose(见 deploy/docker-compose.yml,含 MySQL 与 HTTPS 反代)
+45
View File
@@ -0,0 +1,45 @@
# ---------------------------------------------------------------------------
# 班级小助手服务端 · Docker 形态镜像(由 install.sh --mode docker 使用)
#
# 与仓库根 deploy/Dockerfile 的区别:
# - 那份是"从源码构建"(两阶段、需要 pnpm 与全量源码),适合开发与自己打包镜像;
# - 这份是"从安装包构建":安装包里的 server/dist、web、node_modules 已经是**linux-x64 的真实目录**,
# 所以这里只做 COPY,不需要联网装依赖、不需要构建工具,几十秒就能出镜像。
#
# 构建(install.sh 自动执行,也可手工):
# docker build -t classhelper-server:1.1.0 -f Dockerfile .
# 运行:
# docker run -d --name classhelper-server -p 4000:4000 \
# --env-file /etc/classhelper/config.env -v classhelper-data:/app/data \
# classhelper-server:1.1.0
# ---------------------------------------------------------------------------
FROM node:22-slim
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=4000
WORKDIR /app
# 运行时会用到的全部内容(安装包里就是这套布局)
COPY package.json ./
COPY node_modules ./node_modules
COPY server ./server
COPY web ./web
COPY tools ./tools
# 数据目录(compose 里挂成卷)
RUN mkdir -p /app/data /app/logs
# 说明:不用 `USER node`
# - 镜像只在本机跑,容器本身就是隔离边界;
# - 数据卷里的文件需要与宿主机上的 classhelper backup / restore 互通,
# 用 root 可以避免 uid 不一致导致的权限问题。
EXPOSE 4000
# 健康检查用 Node 自己发请求:slim 镜像里没有 curl/wget
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||4000)+'/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "server/dist/index.js"]
+1618
View File
File diff suppressed because it is too large. Load diff
+41 -15
View File
@@ -1,24 +1,33 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 服务端 systemd 单元(Linux)
#
# 这是**模板**:deploy/install-linux.sh 会把 __NODE__ / __DIR__ / __USER__ 替换成真实值后
# 安装到 /etc/systemd/system/classhelper.service。手动部署时直接改这三处即可:
# 这是**模板**:`deploy/install.sh` 会把占位符替换成真实值后安装到
# /etc/systemd/system/classhelper.service。手动部署时照着替换这四处即可:
#
# sudo sed -e 's#__NODE__#/usr/bin/node#' \
# __NODE__ Node 可执行文件的绝对路径(内置运行时:<安装目录>/runtime/node/bin/node)
# __DIR__ 安装目录(默认 /opt/classhelper)
# __USER__ 运行服务的系统用户(默认 classhelper)
# __CONFIG__ 环境变量文件(默认 /etc/classhelper/config.env)
# __CONFIGDIR__ __CONFIG__ 所在目录(需要写权限,见下面的 ReadWritePaths)
#
# sudo sed -e 's#__NODE__#/opt/classhelper/runtime/node/bin/node#' \
# -e 's#__DIR__#/opt/classhelper#' \
# -e 's#__USER__#classhelper#' \
# deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service
# -e 's#__CONFIGDIR__#/etc/classhelper#' \
# -e 's#__CONFIG__#/etc/classhelper/config.env#' \
# deploy/classhelper.service > /tmp/classhelper.service
# sudo install -m 644 /tmp/classhelper.service /etc/systemd/system/classhelper.service
# sudo systemctl daemon-reload && sudo systemctl enable --now classhelper
#
# 常用命令:
# 常用命令(也有对应的 classhelper 封装):
# classhelper status / start / stop / restart / logs -f
# systemctl status classhelper
# journalctl -u classhelper -f # 实时日志
# systemctl restart classhelper # 重启(改完 .env 需要)
# journalctl -u classhelper -f
# ---------------------------------------------------------------------------
[Unit]
Description=ClassHelper Server (班级小助手服务端)
Documentation=file://__DIR__/README.txt
Documentation=https://github.com/LoveChengke/ClassHelper
After=network-online.target
Wants=network-online.target
@@ -27,15 +36,18 @@ Type=simple
User=__USER__
Group=__USER__
WorkingDirectory=__DIR__
# .env 由服务端自己从 WorkingDirectory 加载(工作目录即安装根目录)
# 配置从这里注入(唯一的配置真身;安装目录里的 .env 是指向它的软链,见 docs/linux-deploy.md)。
# systemd 会剥掉值两侧的引号,所以同一份文件给 dotenv / shell source / systemd 三种读法都能用。
# 前面不加 "-":文件被删掉时直接启动失败,比拿着一份假配置跑起来更容易发现。
EnvironmentFile=__CONFIG__
ExecStart=__NODE__ server/dist/index.js
Environment=NODE_ENV=production
Restart=always
RestartSec=3
# Socket.IO 长连接较多:提高文件描述符上限
LimitNOFILE=65535
# 优雅退出:给在途请求与 websocket 收尾时间
# 优雅退出:给在途请求与 websocket 收尾时间(index.ts 处理 SIGTERM/SIGINT)
KillSignal=SIGTERM
TimeoutStopSec=20
@@ -44,12 +56,26 @@ StandardOutput=journal
StandardError=journal
SyslogIdentifier=classhelper
# 基础加固(服务只需要读程序、写 data/logs)
NoNewPrivileges=true
PrivateTmp=true
# ---------------------------------------------------------------- 加固
# 为什么 ReadWritePaths 要给整个 __DIR__ 与配置目录,而不是只给 data/logs:
# 1) Web 端「数据库管理 → 一键切换」会在运行中改写 server/prisma/schema.prisma,
# 并把重新生成的 Prisma Client 编译进 server/dist/generated —— 那两处都在 __DIR__ 里;
# 2) 同一功能切换成功后要改写 __CONFIG__(DATABASE_PROVIDER / DATABASE_URL 两行)。
# ProtectSystem=full 默认把 /etc 与 /usr 设成只读,ReadWritePaths 是官方的"打洞"方式。
# 服务本来就以 __USER__ 身份运行,这里给的写权限没有超出它的既有边界。
ProtectSystem=full
ProtectHome=true
ReadWritePaths=__DIR__/data __DIR__/logs
PrivateTmp=true
NoNewPrivileges=true
ReadWritePaths=__DIR__ __CONFIGDIR__
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictSUIDSGID=true
LockPersonality=true
# 需要 AF_NETLINK:glibc 解析主机名(MySQL 主机 / GitHub API)时会用到
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX AF_NETLINK
[Install]
WantedBy=multi-user.target
+2 -2
View File
@@ -23,7 +23,7 @@ services:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
image: classhelper-server:${APP_VERSION:-0.1.0}
image: classhelper-server:${APP_VERSION:-1.1.2}
restart: unless-stopped
environment:
NODE_ENV: production
@@ -44,7 +44,7 @@ services:
TERM_START_DATE: ${TERM_START_DATE:-}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123}
DEFAULT_STUDENT_PASSWORD: ${DEFAULT_STUDENT_PASSWORD:-123456}
DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456}
DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456}
ports:
+2 -2
View File
@@ -60,7 +60,7 @@ services:
context: ..
dockerfile: deploy/Dockerfile
target: runtime
image: classhelper-server:${APP_VERSION:-0.1.0}
image: classhelper-server:${APP_VERSION:-1.1.2}
restart: unless-stopped
environment:
NODE_ENV: production
@@ -78,7 +78,7 @@ services:
TERM_START_DATE: ${TERM_START_DATE:-}
INITIAL_ADMIN_USERNAME: ${INITIAL_ADMIN_USERNAME:-admin}
INITIAL_ADMIN_PASSWORD: ${INITIAL_ADMIN_PASSWORD:-admin123}
DEFAULT_STUDENT_PASSWORD: ${DEFAULT_STUDENT_PASSWORD:-123456}
DEFAULT_CLASS_PASSWORD: ${DEFAULT_CLASS_PASSWORD:-123456}
DEFAULT_TEACHER_PASSWORD: ${DEFAULT_TEACHER_PASSWORD:-123456}
ports:
+9 -274
View File
@@ -1,279 +1,14 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# 班级小助手 · 服务端 Linux 一键部署(systemd + SQLite,单机单实例)
# 兼容壳:早期文档、脚本与 README 里写的是 deploy/install-linux.sh,
# 现在安装器统一在 deploy/install.sh(避免两份实现漂移)。
#
# 干什么:
# 在 Linux 服务器上把服务端装成 systemd 服务,布局与已验证的 Windows 免安装目录完全一致:
# /opt/classhelper/
# ├── .env 全部配置(首次安装随机生成 JWT_SECRET)
# ├── server/dist 后端程序(入口 index.js)
# ├── server/prisma 迁移 SQL(AUTO_MIGRATE=true 时首启动自动执行)
# ├── web/ Web 管理端(由后端托管,单端口 4000)
# ├── node_modules/ 仅生产依赖(npm install --omit=dev)
# ├── data/ classhelper.db + server.pid
# └── logs/ 预留(日志默认进 journald)
#
# 前置条件:
# - 在**仓库根目录**已执行过构建:pnpm build:shared && pnpm --filter @classhelper/server build \
# && pnpm --filter @classhelper/web-admin build
# - 目标机装好 Node.js ≥ 20.19(推荐 22 LTS);本脚本不代装 Node
#
# 用法(在仓库根目录执行,需要 root):
# sudo bash deploy/install-linux.sh # 安装/升级,默认端口 4000、目录 /opt/classhelper
# sudo bash deploy/install-linux.sh --port 8080 # 换端口
# sudo bash deploy/install-linux.sh --dir /srv/classhelper
# sudo bash deploy/install-linux.sh --no-service # 只铺文件,不装 systemd(自己接管进程)
# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node 版本/端口探针
# sudo bash deploy/install-linux.sh --uninstall # 停止并禁用服务(保留 data 与 .env)
#
# 升级:把新代码构建好后**再跑一次同样的命令**即可。脚本不会覆盖已有 .env(JWT 密钥与端口保持
# 不变,用户不用重新登录),只会补齐缺失项并覆盖程序文件。
#
# 注意:本脚本未在 Linux 实机验证过(开发机是 Windows,无 Docker/WSL 发行版)。
# 它复用的是**已验证**的两套布局:Windows 免安装目录(install/ 结构)与 deploy/Dockerfile
# 的 runtime 阶段(package.runtime.json + prisma/migrations + web/)。
# 两者参数完全一致:
# sudo bash deploy/install-linux.sh --port 8080
# 等价于
# sudo bash deploy/install.sh --port 8080
# ---------------------------------------------------------------------------
set -euo pipefail
TARGET_DIR="/opt/classhelper"
SERVICE_USER="classhelper"
PORT="4000"
WITH_SERVICE=1
UNINSTALL=0
CHECK_ONLY=0
SERVICE_NAME="classhelper"
while [ $# -gt 0 ]; do
case "$1" in
--dir) TARGET_DIR="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--user) SERVICE_USER="$2"; shift 2 ;;
--no-service) WITH_SERVICE=0; shift ;;
--check) CHECK_ONLY=1; shift ;;
--uninstall) UNINSTALL=1; shift ;;
-h|--help) sed -n '2,40p' "$0"; exit 0 ;;
*) echo "未知参数:$1(用 --help 看用法)" >&2; exit 2 ;;
esac
done
log() { printf '\033[32m[classhelper]\033[0m %s\n' "$*"; }
warn() { printf '\033[33m[classhelper]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[31m[classhelper]\033[0m %s\n' "$*" >&2; exit 1; }
# 就绪探针:优先 curl(几乎每台 Linux 都有),否则退回 node。
# 注意不要用 `node -e "...process.exit()"` —— 在部分 Node 版本上于 promise 回调里硬退会触发
# libuv 断言崩溃(本机 Windows 实测退出码 -1073740791),改成设置 process.exitCode 让事件循环自然结束。
probe_ready() {
if command -v curl >/dev/null 2>&1; then
curl -fsS -o /dev/null --max-time 3 "http://127.0.0.1:${PORT}/healthz" >/dev/null 2>&1
return $?
fi
"$NODE_BIN" -e "fetch('http://127.0.0.1:${PORT}/healthz').then(r=>{process.exitCode=r.ok?0:1}).catch(()=>{process.exitCode=1})" >/dev/null 2>&1
}
[ "$(id -u)" = "0" ] || { [ "$CHECK_ONLY" = "1" ] || die "请用 root 执行:sudo bash deploy/install-linux.sh"; }
# ---------------------------------------------------------------- 卸载分支
if [ "$UNINSTALL" = "1" ]; then
if systemctl list-unit-files | grep -q "^${SERVICE_NAME}.service"; then
systemctl disable --now "$SERVICE_NAME" || true
rm -f "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
log "已停止并移除 systemd 服务 ${SERVICE_NAME}"
else
warn "没有找到 ${SERVICE_NAME}.service(可能没装成服务)"
fi
log "程序文件与数据保留在 ${TARGET_DIR};确认不需要后再手动删除"
exit 0
fi
# ---------------------------------------------------------------- 定位仓库与产物
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
cd "$REPO_ROOT"
SERVER_DIST="packages/server/dist/index.js"
SERVER_PRISMA="packages/server/prisma"
WEB_DIST="packages/web-admin/dist/index.html"
SHARED_DIST="packages/shared/dist/index.js"
RUNTIME_PKG="deploy/package.runtime.json"
for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG"; do
[ -e "$f" ] || die "缺少构建产物:$f
请先在仓库根目录执行:
pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build"
done
[ -d "$SERVER_PRISMA/migrations" ] || die "缺少迁移文件目录:$SERVER_PRISMA/migrations"
# ---------------------------------------------------------------- Node 检查
NODE_BIN="$(command -v node || true)"
[ -n "$NODE_BIN" ] || die "未找到 node,请先安装 Node.js ≥ 20.19(推荐 22 LTS)"
NODE_MAJOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[0]')"
NODE_MINOR="$("$NODE_BIN" -p 'process.versions.node.split(".")[1]')"
if [ "$NODE_MAJOR" -lt 20 ] || { [ "$NODE_MAJOR" -eq 20 ] && [ "$NODE_MINOR" -lt 19 ]; }; then
die "Node 版本过低:$("$NODE_BIN" -v)(要求 ≥ 20.19)"
fi
NPM_BIN="$(command -v npm || true)"
[ -n "$NPM_BIN" ] || die "未找到 npm(随 Node 一起安装)"
log "Node $("$NODE_BIN" -v) → $NODE_BIN"
# ---------------------------------------------------------------- 只体检
if [ "$CHECK_ONLY" = "1" ]; then
echo
echo "构建产物:"
for f in "$SERVER_DIST" "$WEB_DIST" "$SHARED_DIST" "$RUNTIME_PKG" "$SERVER_PRISMA/migrations"; do
printf ' %-42s %s\n' "$f" "OK"
done
printf ' %-42s %s\n' "迁移文件数量" "$(find "$SERVER_PRISMA/migrations" -mindepth 1 -maxdepth 1 -type d | wc -l | tr -d ' ')"
echo
echo "运行环境:"
printf ' %-42s %s\n' "node" "$("$NODE_BIN" -v)(要求 ≥ 20.19)"
printf ' %-42s %s\n' "npm" "$("$NPM_BIN" -v)"
printf ' %-42s %s\n' "systemd" "$(command -v systemctl >/dev/null 2>&1 && echo 可用 || echo '不可用(用 --no-service)')"
printf ' %-42s %s\n' "目标目录 ${TARGET_DIR}" "$([ -e "$TARGET_DIR" ] && echo '已存在(会就地升级,保留 .env)' || echo 尚未创建)"
printf ' %-42s %s\n' "端口 ${PORT} 上的既有服务" "$(probe_ready && echo '健康(就绪)' || echo '无响应')"
echo
log "--check 通过:构建产物与运行环境都满足,可以执行安装(sudo bash deploy/install-linux.sh)"
exit 0
fi
# ---------------------------------------------------------------- 用户与目录
if ! id -u "$SERVICE_USER" >/dev/null 2>&1; then
useradd --system --create-home --shell /usr/sbin/nologin "$SERVICE_USER" 2>/dev/null \
|| useradd --system --shell /sbin/nologin "$SERVICE_USER"
log "已创建系统用户 ${SERVICE_USER}"
fi
mkdir -p "${TARGET_DIR}/server" "${TARGET_DIR}/web" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
log "安装目录:${TARGET_DIR}"
# ---------------------------------------------------------------- 拷贝产物
rm -rf "${TARGET_DIR}/server/dist"
cp -R "packages/server/dist" "${TARGET_DIR}/server/dist"
rm -rf "${TARGET_DIR}/server/prisma"
cp -R "$SERVER_PRISMA" "${TARGET_DIR}/server/prisma"
# 运行时不读 schema,但保留一份便于排查
[ -f packages/server/prisma/schema.prisma ] && cp packages/server/prisma/schema.prisma "${TARGET_DIR}/server/prisma/schema.prisma"
rm -rf "${TARGET_DIR}/web"
cp -R "packages/web-admin/dist" "${TARGET_DIR}/web"
cp "$RUNTIME_PKG" "${TARGET_DIR}/package.json"
cp -R deploy/classhelper.service "${TARGET_DIR}/classhelper.service.template"
# 生产依赖(与 Dockerfile 的 runtime 阶段同一份清单)
if [ ! -d "${TARGET_DIR}/node_modules" ] || [ "${FORCE_NPM_INSTALL:-0}" = "1" ]; then
log "安装生产依赖(npm install --omit=dev,约 1~3 分钟)..."
( cd "$TARGET_DIR" && "$NPM_BIN" install --omit=dev --no-audit --no-fund )
else
log "复用已有 node_modules(如需强制重装:FORCE_NPM_INSTALL=1 再跑一次)"
fi
# workspace 共享包(后端 import '@classhelper/shared'):拷进 node_modules 里
mkdir -p "${TARGET_DIR}/node_modules/@classhelper/shared"
rm -rf "${TARGET_DIR}/node_modules/@classhelper/shared/dist"
cp -R "packages/shared/dist" "${TARGET_DIR}/node_modules/@classhelper/shared/dist"
cp packages/shared/package.json "${TARGET_DIR}/node_modules/@classhelper/shared/package.json"
# ---------------------------------------------------------------- .env(已存在则不覆盖)
ENV_FILE="${TARGET_DIR}/.env"
if [ -f "$ENV_FILE" ]; then
log "检测到已有 .env:保留现有配置(含 JWT_SECRET),只补齐缺失项"
else
JWT_SECRET="$("$NODE_BIN" -e 'console.log(require("crypto").randomBytes(48).toString("hex"))')"
cat > "$ENV_FILE" <<EOF
# 班级小助手服务端(install-linux.sh 生成,请妥善保管)
# 修改后重启服务生效:systemctl restart ${SERVICE_NAME}
NODE_ENV=production
HOST=0.0.0.0
PORT=${PORT}
# 数据库:SQLite 文件放在安装根目录的 data 下(备份只需复制该目录)
DATABASE_PROVIDER=sqlite
DATABASE_URL="file:../data/classhelper.db"
# 空库首启动自动执行随包迁移并创建管理员
AUTO_MIGRATE=true
# 令牌签名密钥(随机生成;更换后所有用户需重新登录)
JWT_SECRET=${JWT_SECRET}
JWT_EXPIRES_IN=7d
# 允许的跨域来源:走域名/反代时建议改成具体来源,例如 https://class.example.com
CORS_ORIGIN=*
# 前面有 Nginx 等反向代理时设为 1,限流才会按真实客户端 IP 统计
TRUST_PROXY=1
RATE_LIMIT_ENABLED=true
STARTUP_DB_CHECK=true
PID_FILE=../data/server.pid
LOG_LEVEL=info
BCRYPT_ROUNDS=10
DEFAULT_STUDENT_PASSWORD=123456
DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
TERM_START_DATE=
EOF
log "已生成 ${ENV_FILE}(含随机 JWT_SECRET)"
fi
# data/logs 必须可写;程序文件只读即可
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/data" "${TARGET_DIR}/logs"
chown -R "${SERVICE_USER}:${SERVICE_USER}" "${TARGET_DIR}/node_modules"
chown "${SERVICE_USER}:${SERVICE_USER}" "$ENV_FILE"
chown -R root:root "${TARGET_DIR}/server" "${TARGET_DIR}/web"
chmod 600 "$ENV_FILE"
# ---------------------------------------------------------------- systemd
if [ "$WITH_SERVICE" = "1" ]; then
command -v systemctl >/dev/null 2>&1 || die "本机没有 systemd;请加 --no-service 自行托管进程"
sed -e "s#__NODE__#${NODE_BIN}#g" \
-e "s#__DIR__#${TARGET_DIR}#g" \
-e "s#__USER__#${SERVICE_USER}#g" \
deploy/classhelper.service > "/etc/systemd/system/${SERVICE_NAME}.service"
systemctl daemon-reload
systemctl enable "$SERVICE_NAME" >/dev/null
systemctl restart "$SERVICE_NAME"
log "等待服务就绪..."
ready=0
for _ in $(seq 1 60); do
if probe_ready; then
ready=1
break
fi
sleep 1
done
if [ "$ready" != "1" ]; then
warn "60 秒内没通过 /healthz,最近日志:"
journalctl -u "$SERVICE_NAME" -n 50 --no-pager || true
die "启动失败,请按上面的日志排查(常见:端口被占用、数据目录权限、JWT_SECRET 未生成)"
fi
log "服务已就绪 ✅"
else
log "已跳过 systemd(--no-service)。手动启动:"
echo " sudo -u ${SERVICE_USER} sh -c 'cd ${TARGET_DIR} && ${NODE_BIN} server/dist/index.js'"
fi
# ---------------------------------------------------------------- 结果
cat <<EOF
------------------------------------------------------------------
安装完成
访问管理端:http://<服务器IP>:${PORT}/
健康探针: http://<服务器IP>:${PORT}/healthz · /readyz
配置文件: ${ENV_FILE}
数据目录: ${TARGET_DIR}/data(备份只需复制这里)
查看日志: journalctl -u ${SERVICE_NAME} -f
重启服务: systemctl restart ${SERVICE_NAME}
首次启动会自动建表并创建管理员:admin / admin123(登录后请立即在右上角「修改密码」改掉)。
公网部署请在前面加 Nginx + HTTPS(deploy/nginx.conf 可直接用),并把 .env 里的
TRUST_PROXY 设为 1、CORS_ORIGIN 收敛到你的域名。
学生端客户端登录时填的「服务器地址」就是 https://你的域名(或 http://IP:${PORT})。
------------------------------------------------------------------
EOF
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
printf '\033[33m[classhelper]\033[0m install-linux.sh 已更名为 install.sh,本次自动转发(后续请直接用 install.sh)\n' >&2
exec bash "${DIR}/install.sh" "$@"
+1293
View File
File diff suppressed because it is too large. Load diff
+19
View File
@@ -0,0 +1,19 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 日志轮转
# 由 deploy/install.sh 安装到 /etc/logrotate.d/classhelper
#
# copytruncate 而不是 create+信号:安装器与 classhelper 都是"追加写完就关"
# (`>>` 打开、写完立即关闭),没有常驻 fd 可通知,copytruncate 是这里唯一正确的做法。
# ---------------------------------------------------------------------------
/var/log/classhelper/*.log {
weekly
rotate 8
missingok
notifempty
compress
delaycompress
copytruncate
create 0600 root root
su root root
}
+4 -1
View File
@@ -1,12 +1,13 @@
{
"name": "classhelper-server-runtime",
"version": "0.1.0",
"version": "1.1.2",
"private": true,
"type": "module",
"description": "班级小助手服务端运行时(安装包 / 容器内置)",
"dependencies": {
"@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "7.10.0",
"@prisma/adapter-mariadb": "7.10.0",
"@prisma/client": "7.10.0",
"bcryptjs": "^3.0.3",
"compression": "^1.8.2",
@@ -16,7 +17,9 @@
"express-rate-limit": "^8.7.0",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"prisma": "7.10.0",
"socket.io": "^4.8.3",
"typescript": "~5.9.3",
"xlsx": "0.18.5",
"zod": "^4.6.2"
}
+28
View File
@@ -0,0 +1,28 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 登录 shell 环境变量
#
# 由 deploy/install.sh 安装到 /etc/profile.d/classhelper.sh(占位符替换成真实路径)。
# 生效方式:重新登录,或 `source /etc/profile.d/classhelper.sh`。
#
# 说明:`classhelper` 命令本身装在 /usr/local/bin(软链到 $CLASSHELPER_HOME/bin/classhelper),
# 不依赖这个文件也能用;这里提供的是路径类变量,方便写脚本时引用。
# ---------------------------------------------------------------------------
# 只在目录真实存在时导出:卸载后残留的 profile.d 不应该污染别人的环境
if [ -d '__DIR__' ]; then
export CLASSHELPER_HOME='__DIR__'
export CLASSHELPER_CONFIG='__CONFIG__'
export CLASSHELPER_LOG_DIR='__LOGDIR__'
export CLASSHELPER_BACKUP_DIR='__BACKUPDIR__'
export CLASSHELPER_SERVICE='classhelper'
case ":${PATH}:" in
*":${CLASSHELPER_HOME}/bin:"*) ;;
*) export PATH="${PATH}:${CLASSHELPER_HOME}/bin" ;;
esac
fi
# 让 `classhelper` 在非 root 登录时也能补全(bash 补全由 `classhelper completion bash` 输出)
if [ -n "${BASH_VERSION:-}" ] && [ -r /usr/share/bash-completion/completions/classhelper ]; then
. /usr/share/bash-completion/completions/classhelper
fi
+22
View File
@@ -0,0 +1,22 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 定时备份(systemd timer)
# 由 `classhelper backup schedule daily|weekly` 安装(占位符 __BIN__ / __KEEP__ 由命令替换)
#
# 手工安装:
# sudo sed -e 's#__BIN__#/usr/local/bin/classhelper#' -e 's#__KEEP__#7#' \
# deploy/systemd/classhelper-backup.service > /tmp/x.service ...
# 然后 systemctl daemon-reload && systemctl enable --now classhelper-backup.timer
# ---------------------------------------------------------------------------
[Unit]
Description=ClassHelper 定时备份
Documentation=https://github.com/LoveChengke/ClassHelper
After=classhelper.service
[Service]
Type=oneshot
ExecStart=__BIN__ backup --quiet --keep __KEEP__
# 备份失败时不要静默:journalctl -u classhelper-backup 里能看到退出码与原因
StandardOutput=journal
StandardError=journal
SyslogIdentifier=classhelper-backup
+19
View File
@@ -0,0 +1,19 @@
# ---------------------------------------------------------------------------
# 班级小助手 · 定时备份(触发器)
# 由 `classhelper backup schedule daily|weekly` 安装;OnCalendar 行由命令按选择替换
# ---------------------------------------------------------------------------
[Unit]
Description=ClassHelper 定时备份触发器
Documentation=https://github.com/LoveChengke/ClassHelper
[Timer]
OnCalendar=__ONCALENDAR__
# 错开整点:避免所有装了本服务的机器在同一秒打盘
RandomizedDelaySec=30m
# 机器关机期间错过的备份在开机后补一次
Persistent=true
Unit=classhelper-backup.service
[Install]
WantedBy=timers.target
+335
View File
@@ -0,0 +1,335 @@
#!/usr/bin/env node
/**
* 班级小助手 · 离线账号工具(随 Linux 包发布,安装在 ${INSTALL_DIR}/tools/admin-cli.mjs)
*
* 为什么不用 HTTP API 改密码:`classhelper password` 要能在**服务停机**时救急,
* 而调 API 必须先有一个能登录的账号 —— 管理员忘了密码时就是死锁。
* 因此这里直连数据库,复用服务端自己的 Prisma Client 与 bcrypt 参数(`server/dist/lib/*`),
* 保证写入的哈希与登录校验用的是同一套实现。
*
* 安全约定(务必保持):
* - **密码只从 stdin 读**,绝不接受 argv / 环境变量形式的密码(否则会进 shell history 与 ps 输出);
* - 本工具自己**不打印任何密码**,也不写任何日志;
* - `dump-hash` 会输出 bcrypt 哈希 —— 仅由 root 调用的 `classhelper password` 用于改前备份,
* 调用方负责把结果落到 600 权限的文件里。
*
* 用法(全部由 classhelper 封装,一般不需要手工调):
* node tools/admin-cli.mjs info
* node tools/admin-cli.mjs list [--json]
* node tools/admin-cli.mjs dump-hash <用户名|class:班级码>
* node tools/admin-cli.mjs set-password <用户名> # 新密码从 stdin 读
* node tools/admin-cli.mjs set-class-password <班级码> # 新密码从 stdin 读
* node tools/admin-cli.mjs verify <用户名> # 待校验密码从 stdin 读
* node tools/admin-cli.mjs restore-hash <备份json路径>
* node tools/admin-cli.mjs snapshot <目标db文件> # SQLite 一致性快照(VACUUM INTO)
*
* 退出码:0 成功 / 1 通用失败 / 2 用法错误 / 3 目标不存在 / 4 密码不合规 / 5 数据库不可用
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
/** 安装根目录(tools/ 的上一级) */
const installDir = path.resolve(scriptDir, '..');
const serverDist = path.join(installDir, 'server', 'dist');
const EXIT = { OK: 0, FAIL: 1, USAGE: 2, NOT_FOUND: 3, WEAK: 4, NO_DB: 5 };
function fail(message, code = EXIT.FAIL) {
process.stderr.write(`admin-cli: ${message}\n`);
process.exitCode = code;
return null;
}
/** 一次性读完 stdin(去掉行尾换行;保留内部空白,密码里可以有空格) */
async function readSecretFromStdin() {
const chunks = [];
for await (const chunk of process.stdin) chunks.push(chunk);
return Buffer.concat(chunks).toString('utf8').replace(/\r?\n$/, '');
}
/** 与服务端一致的密码强度规则(classhelper 里还有一份交互式版本,两边保持同样口径) */
function checkStrength(password, username) {
const problems = [];
if (password.length < 8) problems.push('长度至少 8 位');
if (password.length > 128) problems.push('长度不要超过 128 位');
const kinds = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^A-Za-z0-9]/].filter((re) => re.test(password)).length;
if (kinds < 3) problems.push('需包含小写字母 / 大写字母 / 数字 / 符号 中至少 3 类');
if (username && password.toLowerCase() === username.toLowerCase()) problems.push('不能与用户名相同');
if (/^(123456|password|admin|classhelper|qwerty|111111|abc123)/i.test(password) && password.length < 12) {
problems.push('过于常见,请换一个');
}
return problems;
}
/** 懒加载服务端模块:先做"库在不在"的检查,避免 libSQL 在连接时凭空建出一个空库 */
async function loadServerModules() {
const envFile = path.join(serverDist, 'config', 'env.js');
if (!fs.existsSync(envFile)) {
fail(`找不到服务端产物:${envFile}`, EXIT.NO_DB);
return null;
}
const env = await import(pathToFileURL(envFile).href);
if (env.env.databaseProvider === 'sqlite') {
// classhelper 做备份时会用 CLASSHELPER_SNAPSHOT_DB 显式指定数据文件(跳过硬编码路径假设)
const dbFile = process.env.CLASSHELPER_SNAPSHOT_DB || env.env.sqliteFilePath;
if (!dbFile || !fs.existsSync(dbFile)) {
fail(
`数据库文件不存在:${dbFile || '(未配置)'}\n` +
' 提示:全新安装请先 systemctl start classhelper 完成建表(AUTO_MIGRATE);',
EXIT.NO_DB,
);
return null;
}
}
const db = await import(pathToFileURL(path.join(serverDist, 'lib', 'db.js')).href);
const password = await import(pathToFileURL(path.join(serverDist, 'lib', 'password.js')).href);
return { env: env.env, prisma: db.prisma, disconnectPrisma: db.disconnectPrisma, ...password };
}
/** 目标账号:普通用户按 username,班级账号按 Class.code(学生端登录用的是班级码) */
async function resolveTarget(prisma, key) {
if (key.startsWith('class:')) {
const code = key.slice('class:'.length).toUpperCase();
const klass = await prisma.class.findUnique({ where: { code } });
if (!klass) return { error: `班级码不存在:${code}`, code: EXIT.NOT_FOUND };
return { kind: 'class', id: klass.id, label: `班级 ${klass.name}(码 ${klass.code})`, hash: klass.passwordHash };
}
const user = await prisma.user.findUnique({ where: { username: key } });
if (!user) return { error: `用户名不存在:${key}`, code: EXIT.NOT_FOUND };
if (user.role === 'STUDENT') {
return {
error: `学生没有个人账号(${key} 只是名单记录),学生端用「班级码 + 班级密码」登录,请用 --class 改班级密码`,
code: EXIT.NOT_FOUND,
};
}
return { kind: 'user', id: user.id, label: `${user.name}(${user.username} / ${user.role})`, hash: user.passwordHash };
}
/* ------------------------------------------------------------------ 子命令 */
/**
* SQLite 一致性快照:`VACUUM INTO` 在服务运行中也能拿到自洽的副本
* (直接 cp 数据文件可能抓到写入中的中间状态)。
* 目标文件必须不存在 —— 这是 SQLite 的硬要求,先删掉。
*/
async function cmdSnapshot(modules, dest) {
const { env, prisma } = modules;
if (env.databaseProvider !== 'sqlite') {
return fail('snapshot 只支持 SQLite;MySQL 请用 mysqldump', EXIT.FAIL);
}
const target = path.resolve(dest);
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.rmSync(target, { force: true });
// VACUUM INTO 不接受参数占位符,只能拼字面量 —— 单引号按 SQL 规则翻倍转义
await prisma.$executeRawUnsafe(`VACUUM INTO '${target.replace(/'/g, "''")}'`);
if (!fs.existsSync(target)) return fail('快照未生成(VACUUM INTO 未报错但没有产出文件)', EXIT.FAIL);
process.stdout.write(`${target}\n`);
}
async function cmdInfo(modules) {
const { env, prisma } = modules;
const counts = {};
for (const model of ['user', 'class', 'course', 'schedule', 'homework', 'notification', 'grade']) {
counts[model] = await prisma[model].count();
}
const payload = {
provider: env.databaseProvider,
sqliteFile: env.sqliteFilePath || null,
serverRoot: env.serverRoot,
termStartDate: env.termStartDate || null,
counts,
};
process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
}
async function cmdList(modules, asJson) {
const { prisma } = modules;
const users = await prisma.user.findMany({
where: { role: { in: ['ADMIN', 'TEACHER'] } },
select: { username: true, name: true, role: true, classId: true, updatedAt: true },
orderBy: [{ role: 'asc' }, { username: 'asc' }],
});
const classes = await prisma.class.findMany({
select: { code: true, name: true, passwordHash: true },
orderBy: { code: 'asc' },
});
if (asJson) {
process.stdout.write(
`${JSON.stringify(
{
users,
classes: classes.map((item) => ({ code: item.code, name: item.name, hasPassword: !!item.passwordHash })),
},
null,
2,
)}\n`,
);
return;
}
process.stdout.write('可登录账号(教师 / 管理员):\n');
for (const user of users) {
process.stdout.write(` ${user.role.padEnd(8)} ${user.username.padEnd(16)} ${user.name}\n`);
}
process.stdout.write('\n班级账号(学生端用「班级码 + 班级密码」登录):\n');
for (const item of classes) {
const state = item.passwordHash ? '已设置' : '⚠ 未设置密码(该班当前无法登录)';
process.stdout.write(` ${item.code.padEnd(8)} ${item.name.padEnd(16)} ${state}\n`);
}
}
async function cmdDumpHash(modules, key) {
const { prisma } = modules;
const target = await resolveTarget(prisma, key);
if (target.error) return fail(target.error, target.code);
if (!target.hash) return fail(`${target.label} 当前没有密码(passwordHash 为空)`, EXIT.NOT_FOUND);
// 只给调用方(root)看的原始数据,不落到本工具的日志里
process.stdout.write(`${JSON.stringify({ target: key, kind: target.kind, id: target.id, hash: target.hash })}\n`);
}
async function cmdSetPassword(modules, key, readSecret) {
const { prisma, hashPassword } = modules;
const target = await resolveTarget(prisma, key);
if (target.error) return fail(target.error, target.code);
const secret = await readSecret();
const problems = checkStrength(secret, key);
if (problems.length > 0) return fail(`密码不合规:${problems.join(';')}`, EXIT.WEAK);
const hash = await hashPassword(secret);
if (target.kind === 'class') {
await prisma.class.update({ where: { id: target.id }, data: { passwordHash: hash } });
} else {
await prisma.user.update({ where: { id: target.id }, data: { passwordHash: hash } });
}
// 写完立刻复核一次:曾经出现过"bcrypt 参数不一致导致写进去的哈希验证不过"的排查成本
const ok = await modules.verifyPassword(secret, hash);
if (!ok) return fail('写入后复核失败:哈希无法通过校验,请用 restore-hash 回滚', EXIT.FAIL);
process.stdout.write(`已更新密码:${target.label}\n`);
}
async function cmdVerify(modules, key, readSecret) {
const { prisma, verifyPassword } = modules;
const target = await resolveTarget(prisma, key);
if (target.error) return fail(target.error, target.code);
const secret = await readSecret();
const ok = !!target.hash && (await verifyPassword(secret, target.hash));
process.stdout.write(`${ok ? 'match' : 'mismatch'}\n`);
if (!ok) process.exitCode = EXIT.FAIL;
}
async function cmdRestoreHash(modules, backupFile) {
const { prisma } = modules;
if (!fs.existsSync(backupFile)) return fail(`备份文件不存在:${backupFile}`, EXIT.NOT_FOUND);
let payload;
try {
payload = JSON.parse(fs.readFileSync(backupFile, 'utf8'));
} catch (error) {
return fail(`备份文件不是合法 JSON:${error.message}`);
}
const { target, hash } = payload;
if (typeof target !== 'string' || typeof hash !== 'string') {
return fail('备份文件缺少 target / hash 字段,拒绝回滚');
}
if (target.startsWith('class:')) {
await prisma.class.update({ where: { code: target.slice('class:'.length).toUpperCase() }, data: { passwordHash: hash } });
} else {
await prisma.user.update({ where: { username: target }, data: { passwordHash: hash } });
}
process.stdout.write(`已回滚密码哈希:${target}\n`);
}
/* ------------------------------------------------------------------ 入口 */
const argv = process.argv.slice(2);
const command = argv[0] ?? 'help';
const args = argv.slice(1);
const wantsJson = args.includes('--json');
const positional = args.filter((item) => !item.startsWith('--'));
function usage() {
process.stdout.write(
[
'classhelper 离线账号工具(一般由 classhelper 命令调用)',
'',
' info 输出数据库与表行数(JSON)',
' list [--json] 列出可登录账号与班级账号',
' dump-hash <用户名|class:班级码> 输出当前密码哈希(供改密前备份)',
' set-password <用户名> 从 stdin 读新密码并写入',
' set-class-password <班级码> 从 stdin 读新密码并写入班级账号',
' verify <用户名> 从 stdin 读密码,输出 match / mismatch',
' restore-hash <备份json路径> 用备份文件里的哈希回滚',
' snapshot <目标db文件> 把 SQLite 库一致性快照到指定路径(VACUUM INTO)',
'',
].join('\n'),
);
}
if (command === 'help' || command === '--help' || command === '-h') {
usage();
} else if (
![
'info',
'list',
'dump-hash',
'set-password',
'set-class-password',
'verify',
'restore-hash',
'snapshot',
].includes(command)
) {
fail(`未知子命令:${command}\n`, EXIT.USAGE);
usage();
} else {
const modules = await loadServerModules();
if (!modules) {
// 已经在 loadServerModules 里设了 exitCode
} else {
try {
switch (command) {
case 'info':
await cmdInfo(modules);
break;
case 'list':
await cmdList(modules, wantsJson);
break;
case 'dump-hash':
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
else await cmdDumpHash(modules, positional[0]);
break;
case 'set-password':
case 'set-class-password': {
const key = command === 'set-class-password' ? `class:${positional[0] ?? ''}` : (positional[0] ?? '');
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
else await cmdSetPassword(modules, key, readSecretFromStdin);
break;
}
case 'verify':
if (!positional[0]) fail('缺少目标账号', EXIT.USAGE);
else await cmdVerify(modules, positional[0], readSecretFromStdin);
break;
case 'restore-hash':
if (!positional[0]) fail('缺少备份文件路径', EXIT.USAGE);
else await cmdRestoreHash(modules, positional[0]);
break;
case 'snapshot':
if (!positional[0]) fail('缺少目标文件路径', EXIT.USAGE);
else await cmdSnapshot(modules, positional[0]);
break;
default:
usage();
}
} catch (error) {
fail(error instanceof Error ? error.message : String(error));
} finally {
// 不用 process.exit():在顶层 await 里硬退会踩 libuv 断言(见 AGENTS.md §7)
await modules.disconnectPrisma();
}
}
}
+307
View File
@@ -0,0 +1,307 @@
#!/usr/bin/env bash
# ---------------------------------------------------------------------------
# 班级小助手 · Linux 安装与运维链路验收(在**已安装的服务器上**运行)
#
# 覆盖:安装结果 → 服务可用性 → API 登录 → 改密(含回滚与审计)→ 备份 →
# 配置读写 → key rotate →(可选)升级 / 回滚 / 卸载
#
# 用法(在服务器上,需要 root):
# sudo bash verify-linux.sh # 基础链路
# sudo bash verify-linux.sh --full --package /tmp/xxx.tar.gz # 追加升级/回滚
# sudo bash verify-linux.sh --admin-password-stdin # 密码从 stdin 读一行(不进 argv/history)
#
# 说明:
# - 本脚本会**真的**改一次管理员密码(换成随机强密码,再改回去),并真的发一条备份;
# - 除了 --purge 的卸载测试,不会删除任何数据;
# - 全程不需要外网(升级测试用 --package 指向本地包)。
# ---------------------------------------------------------------------------
set -uo pipefail
INSTALL_DIR="/opt/classhelper"
CONFIG_FILE="/etc/classhelper/config.env"
LOG_DIR="/var/log/classhelper"
BACKUP_DIR="/var/backups/classhelper"
PORT=""
ADMIN_USER="admin"
ADMIN_PASSWORD=""
ADMIN_PASSWORD_FROM_STDIN=0
DO_FULL=0
PACKAGE=""
TOTAL=0; PASS=0; FAIL=0
FAILED=()
while [ $# -gt 0 ]; do
case "$1" in
--dir) INSTALL_DIR="$2"; shift 2 ;;
--config) CONFIG_FILE="$2"; shift 2 ;;
--port) PORT="$2"; shift 2 ;;
--user) ADMIN_USER="$2"; shift 2 ;;
--admin-password) ADMIN_PASSWORD="$2"; shift 2 ;;
--admin-password-stdin) ADMIN_PASSWORD_FROM_STDIN=1; shift ;;
--package) PACKAGE="$2"; shift 2 ;;
--full) DO_FULL=1; shift ;;
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
*) echo "未知参数:$1" >&2; exit 2 ;;
esac
done
if [ -t 1 ]; then G=$'\033[32m'; R=$'\033[31m'; Y=$'\033[33m'; D=$'\033[2m'; B=$'\033[1m'; N=$'\033[0m'; else G=; R=; Y=; D=; B=; N=; fi
section() { printf '\n%s▶ %s%s\n' "$B" "$1" "$N"; }
check() { # $1=描述 $2=0 通过
TOTAL=$((TOTAL + 1))
if [ "$2" = "0" ]; then
PASS=$((PASS + 1)); printf ' %s✔%s %s\n' "$G" "$N" "$1"
else
FAIL=$((FAIL + 1)); FAILED+=("$1"); printf ' %s✘%s %s\n' "$R" "$N" "$1"
fi
}
note() { printf ' %s· %s%s\n' "$D" "$1" "$N"; }
[ "$(id -u)" = "0" ] || { echo "请用 root 运行:sudo bash verify-linux.sh" >&2; exit 1; }
if [ ! -f "$CONFIG_FILE" ]; then
echo "找不到安装:${CONFIG_FILE} 不存在(用 --config 指定,或先跑 install.sh)" >&2
exit 1
fi
read_cfg() { sed -n "s/^[[:space:]]*$1=//p" "$CONFIG_FILE" | tail -n 1 | tr -d '"'; }
[ -n "$PORT" ] || PORT="$(read_cfg PORT)"
[ -n "$PORT" ] || PORT=4000
CLASSHELPER="${INSTALL_DIR}/bin/classhelper"
API="http://127.0.0.1:${PORT}"
CURL=(curl -fsS --noproxy '*')
if [ "$ADMIN_PASSWORD_FROM_STDIN" = "1" ]; then IFS= read -r ADMIN_PASSWORD || true; fi
printf '%s班级小助手 · Linux 安装验收%s\n' "$B" "$N"
note "安装目录 ${INSTALL_DIR}|配置 ${CONFIG_FILE}|端口 ${PORT}"
# ---------------------------------------------------------------- 1. 安装结果
section "安装结果"
check "安装目录存在且含 server/dist/index.js" "$([ -f "${INSTALL_DIR}/server/dist/index.js" ] && echo 0 || echo 1)"
check "版本文件 VERSION 可读" "$([ -s "${INSTALL_DIR}/VERSION" ] && echo 0 || echo 1)"
check "安装元数据 .installed.json 存在" "$([ -f "${INSTALL_DIR}/.installed.json" ] && echo 0 || echo 1)"
cfg_mode="$(stat -c '%a' "$CONFIG_FILE" 2>/dev/null || echo '?')"
check "配置文件权限 600(实际 ${cfg_mode})" "$([ "$cfg_mode" = "600" ] && echo 0 || echo 1)"
dir_mode="$(stat -c '%a' "$(dirname "$CONFIG_FILE")" 2>/dev/null || echo '?')"
check "配置目录权限 700(实际 ${dir_mode})" "$([ "$dir_mode" = "700" ] && echo 0 || echo 1)"
env_link="${INSTALL_DIR}/.env"
check "安装目录 .env 是指向配置真身的软链(Web 端切库依赖它)" \
"$([ -L "$env_link" ] && [ "$(readlink "$env_link")" = "$CONFIG_FILE" ] && echo 0 || echo 1)"
node_bin="${INSTALL_DIR}/runtime/node/bin/node"
node_ver="$([ -x "$node_bin" ] && "$node_bin" -v 2>/dev/null || echo '')"
check "内置 Node 可用(${node_ver:-未找到})" "$([ -n "$node_ver" ] && echo 0 || echo 1)"
check "classhelper 命令已装到 /usr/local/bin" "$([ -x /usr/local/bin/classhelper ] && echo 0 || echo 1)"
check "安装日志 ${LOG_DIR}/install.log 存在" "$([ -s "${LOG_DIR}/install.log" ] && echo 0 || echo 1)"
check "审计日志 ${LOG_DIR}/audit.log 存在" "$([ -f "${LOG_DIR}/audit.log" ] && echo 0 || echo 1)"
check "审计/安装日志权限不放行他人(≤640)" \
"$([ "$(stat -c '%a' "${LOG_DIR}/audit.log" 2>/dev/null || echo 644)" -le 640 ] && echo 0 || echo 1)"
check "/etc/profile.d/classhelper.sh 已写入" "$([ -f /etc/profile.d/classhelper.sh ] && echo 0 || echo 1)"
profile_home="$(bash -lc 'echo "${CLASSHELPER_HOME:-}"' 2>/dev/null)"
check "新登录 shell 里 CLASSHELPER_HOME 生效(${profile_home:-空})" "$([ -n "$profile_home" ] && echo 0 || echo 1)"
# ---------------------------------------------------------------- 2. 服务
section "服务与探针"
if command -v systemctl >/dev/null 2>&1 && [ -f /etc/systemd/system/classhelper.service ]; then
check "systemd 单元已安装" 0
check "服务处于 active" "$(systemctl is-active --quiet classhelper && echo 0 || echo 1)"
check "服务开机自启(enabled)" "$(systemctl is-enabled --quiet classhelper 2>/dev/null && echo 0 || echo 1)"
else
check "systemd 单元已安装" 1
fi
check "/healthz 返回 200" "$("${CURL[@]}" -o /dev/null "${API}/healthz" 2>/dev/null && echo 0 || echo 1)"
status_json="$("$CLASSHELPER" status --json 2>/dev/null || true)"
check "classhelper status --json 是合法 JSON" "$(printf '%s' "$status_json" | grep -q '"healthy"' && echo 0 || echo 1)"
check "status 报告 healthy=yes" "$(printf '%s' "$status_json" | grep -q '"healthy": "yes"' && echo 0 || echo 1)"
check "status 报告的端口与实际一致" "$(printf '%s' "$status_json" | grep -q "\"port\": ${PORT}" && echo 0 || echo 1)"
check "classhelper help 可执行(非 root 也能看)" "$("$CLASSHELPER" help >/dev/null 2>&1 && echo 0 || echo 1)"
check "classhelper completion bash 有输出" "$("$CLASSHELPER" completion bash 2>/dev/null | grep -q 'classhelper' && echo 0 || echo 1)"
doctor_out="$("$CLASSHELPER" doctor 2>&1 || true)"
check "classhelper doctor 无待处理项" "$(printf '%s' "$doctor_out" | grep -q '体检通过' && echo 0 || echo 1)"
if ! printf '%s' "$doctor_out" | grep -q '体检通过'; then
printf '%s' "$doctor_out" | sed 's/^/ /'
fi
# ---------------------------------------------------------------- 3. API
section "API 与登录"
TOKEN=""
login() { # $1=密码 → 输出 token(失败则空)
"${CURL[@]}" -X POST "${API}/api/auth/login" -H 'content-type: application/json' \
-d "{\"username\":\"${ADMIN_USER}\",\"password\":\"$1\"}" 2>/dev/null \
| sed -n 's/.*"token"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p'
}
if [ -z "$ADMIN_PASSWORD" ]; then
note "未提供 --admin-password(-stdin),跳过登录与改密用例"
else
TOKEN="$(login "$ADMIN_PASSWORD")"
check "管理员用提供的密码能登录并拿到 token" "$([ -n "$TOKEN" ] && echo 0 || echo 1)"
if [ -n "$TOKEN" ]; then
classes="$("${CURL[@]}" -H "Authorization: Bearer ${TOKEN}" "${API}/api/classes" 2>/dev/null || true)"
check "带 token 能读业务接口 /api/classes" "$(printf '%s' "$classes" | grep -q '"data"' && echo 0 || echo 1)"
me="$("${CURL[@]}" -H "Authorization: Bearer ${TOKEN}" "${API}/api/auth/me" 2>/dev/null || true)"
check "/api/auth/me 返回的是 ADMIN" "$(printf '%s' "$me" | grep -q '"ADMIN"' && echo 0 || echo 1)"
fi
fi
# ---------------------------------------------------------------- 4. 改密
if [ -n "$TOKEN" ]; then
section "password 子命令(改密 → 回滚 → 审计)"
# 生成一个一定满足强度要求的密码(大小写+数字+符号,且不落在常见口令里)
GENERATED="Ch$(head -c 12 /dev/urandom | od -An -tx1 | tr -d ' \n')!a9Z"
before_count="$(grep -c 'action=password' "${LOG_DIR}/audit.log" 2>/dev/null || echo 0)"
# 密码经管道进 stdin,不出现在 argv / 环境变量 / shell history
out="$(printf '%s\n%s\n' "$GENERATED" "$GENERATED" | "$CLASSHELPER" password "$ADMIN_USER" 2>&1)"
check "classhelper password 执行成功" "$(printf '%s' "$out" | grep -q '密码已更新' && echo 0 || echo 1)"
new_token="$(login "$GENERATED")"
check "新密码能登录" "$([ -n "$new_token" ] && echo 0 || echo 1)"
old_token="$(login "$ADMIN_PASSWORD")"
check "旧密码已失效(登录被拒)" "$([ -z "$old_token" ] && echo 0 || echo 1)"
hash_backup="$(ls -1t "${BACKUP_DIR}"/password-*.json 2>/dev/null | head -n 1 || true)"
check "改密前备份了原哈希(${hash_backup:-无})" "$([ -n "$hash_backup" ] && echo 0 || echo 1)"
check "哈希备份权限 600" \
"$([ -n "$hash_backup" ] && [ "$(stat -c '%a' "$hash_backup")" = "600" ] && echo 0 || echo 1)"
after_count="$(grep -c 'action=password' "${LOG_DIR}/audit.log" 2>/dev/null || echo 0)"
check "审计日志新增了 password 记录" "$([ "${after_count:-0}" -gt "${before_count:-0}" ] && echo 0 || echo 1)"
check "审计日志里没有新密码明文" "$(grep -q -- "$GENERATED" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
check "审计日志里没有原密码明文" "$(grep -q -- "$ADMIN_PASSWORD" "${LOG_DIR}/audit.log" 2>/dev/null && echo 1 || echo 0)"
# 先把输出收进变量再 grep:`外部命令 | grep -q` 在 `set -o pipefail` 下,grep 命中即退出会让
# 写端收到 SIGPIPE(退出码 141),于是"匹配到了"被算成失败
weak_out="$(printf 'abc\nabc\n' | "$CLASSHELPER" password "$ADMIN_USER" 2>&1 || true)"
check "弱密码被拒绝(长度/种数不足)" "$(printf '%s' "$weak_out" | grep -q '不合规' && echo 0 || echo 1)"
check "密码不合规时不会卡死(stdin 结束即退出)" \
"$(printf 'abc\n' | timeout 20 "$CLASSHELPER" password "$ADMIN_USER" >/dev/null 2>&1; [ $? -ne 124 ] && echo 0 || echo 1)"
list_out="$("$CLASSHELPER" password --list 2>&1 || true)"
check "password --list 能列出账号" "$(printf '%s' "$list_out" | grep -q "$ADMIN_USER" && echo 0 || echo 1)"
# 用改密前保存的哈希把账号恢复原样:比再改一次密码更可靠
# (原密码若是 admin123 这类弱口令,改回去会被强度校验挡下 —— 那是产品的正确行为)
if [ -n "$hash_backup" ]; then
restore_hash_out="$(LOG_LEVEL=error DOTENV_CONFIG_QUIET=true "$node_bin" "${INSTALL_DIR}/tools/admin-cli.mjs" restore-hash "$hash_backup" 2>&1 || true)"
check "restore-hash 能把原哈希回滚回来" "$(printf '%s' "$restore_hash_out" | grep -q '已回滚密码哈希' && echo 0 || echo 1)"
check "原密码重新可用" "$([ -n "$(login "$ADMIN_PASSWORD")" ] && echo 0 || echo 1)"
fi
else
note "跳过改密用例(没有可用的登录会话)"
fi
# ---------------------------------------------------------------- 5. 备份
section "backup 子命令"
backup_out="$("$CLASSHELPER" backup --keep 5 2>&1)"
check "classhelper backup 执行成功" "$(printf '%s' "$backup_out" | grep -q '备份完成' && echo 0 || echo 1)"
latest="$(ls -1dt "${BACKUP_DIR}"/*/ 2>/dev/null | head -n 1)"
check "生成了新的备份目录" "$([ -n "$latest" ] && echo 0 || echo 1)"
if [ -n "$latest" ]; then
check "备份含 config.env" "$([ -f "${latest}config.env" ] && echo 0 || echo 1)"
check "备份 meta.json 带版本号" "$(grep -q '"version"' "${latest}meta.json" 2>/dev/null && echo 0 || echo 1)"
db_kind="$(read_cfg DATABASE_PROVIDER)"
if [ "$db_kind" = "sqlite" ]; then
snap="${latest}classhelper.db"
check "备份含 SQLite 快照" "$([ -s "$snap" ] && echo 0 || echo 1)"
# 一致性快照必须是完整的 SQLite 文件(VACUUM INTO 产物)
check "快照是合法 SQLite 文件(文件头正确)" \
"$([ -n "$snap" ] && [ "$(head -c 15 "$snap" 2>/dev/null)" = "SQLite format 3" ] && echo 0 || echo 1)"
check "快照权限 600" "$([ -n "$snap" ] && [ "$(stat -c '%a' "$snap")" = "600" ] && echo 0 || echo 1)"
else
note "数据库是 MySQL,跳过 SQLite 快照用例"
fi
check "备份目录权限 700" "$([ "$(stat -c '%a' "${latest%/}" 2>/dev/null)" = "700" ] && echo 0 || echo 1)"
fi
backup_list_out="$("$CLASSHELPER" backup list 2>&1 || true)"
check "backup list 能列出备份" "$(printf '%s' "$backup_list_out" | grep -qE '^ [0-9]{8}-' && echo 0 || echo 1)"
# ---------------------------------------------------------------- 6. 配置
section "config 子命令"
check "config get PORT 返回当前端口" "$([ "$("$CLASSHELPER" config get PORT)" = "$PORT" ] && echo 0 || echo 1)"
masked="$("$CLASSHELPER" config show 2>/dev/null | grep '^JWT_SECRET=')"
check "config show 对 JWT_SECRET 打码" "$(printf '%s' "$masked" | grep -q '\*\*\*\*' && echo 0 || echo 1)"
check "config show 没打印出真实密钥" \
"$(secret="$(read_cfg JWT_SECRET)"; [ -n "$secret" ] && printf '%s' "$masked" | grep -q -- "$secret" && echo 1 || echo 0)"
# 用一个无害的键做读写往返(不重启服务)
printf 'CH_VERIFY_PROBE=1\n' >>"$CONFIG_FILE"
probe="$("$CLASSHELPER" config get CH_VERIFY_PROBE 2>/dev/null)"
check "config get 能读到新增键" "$([ "$probe" = "1" ] && echo 0 || echo 1)"
sed -i '/^CH_VERIFY_PROBE=/d' "$CONFIG_FILE"
# ---------------------------------------------------------------- 7. key rotate
if [ -n "$TOKEN" ]; then
section "key rotate"
if [ "${CLASSHELPER_VERIFY_SKIP_KEY_ROTATE:-0}" = "1" ]; then
note "按环境变量跳过"
else
old_secret="$(read_cfg JWT_SECRET)"
# key rotate 无 tty 时会问两次(确认轮换、是否现在重启);只喂一个 yes 的话第二次读到 EOF,
# 重启被跳过 —— 新密钥不生效,下面「旧 token 已失效」必然失败。
# 用 CLASSHELPER_ASSUME_YES=1 跳过问答并强制重启,语义才是"轮换后旧会话立即失效"。
CLASSHELPER_NO_TTY=1 CLASSHELPER_ASSUME_YES=1 "$CLASSHELPER" key rotate >/dev/null 2>&1 || true
new_secret="$(read_cfg JWT_SECRET)"
check "key rotate 改变了 JWT_SECRET" "$([ -n "$new_secret" ] && [ "$new_secret" != "$old_secret" ] && echo 0 || echo 1)"
check "新密钥长度 ≥32" "$([ "${#new_secret}" -ge 32 ] && echo 0 || echo 1)"
check "轮换后服务仍可用" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
check "轮换前的 token 已失效(/api/auth/me 返回 401)" \
"$(code="$(curl -s --noproxy '*' -o /dev/null -w '%{http_code}' -H "Authorization: Bearer ${TOKEN}" "${API}/api/auth/me")"; [ "$code" = "401" ] && echo 0 || echo 1)"
# 轮换后要重新登录,否则后面的用例会带着失效 token 跑
TOKEN="$(login "$ADMIN_PASSWORD")"
check "轮换后重新登录成功" "$([ -n "$TOKEN" ] && echo 0 || echo 1)"
fi
fi
# ---------------------------------------------------------------- 8. 升级 / 回滚
if [ "$DO_FULL" = "1" ]; then
section "upgrade / rollback(--full)"
if [ -z "$PACKAGE" ]; then
note "未提供 --package,跳过升级用例"
else
before_version="$("$CLASSHELPER" version 2>/dev/null | awk '{print $2}')"
sha="$(sha256sum "$PACKAGE" | awk '{print $1}')"
up_out="$("$CLASSHELPER" upgrade --url "$PACKAGE" --sha256 "$sha" --force 2>&1)"
check "upgrade 执行成功" "$(printf '%s' "$up_out" | grep -q '升级完成' && echo 0 || echo 1)"
check "升级后服务仍就绪" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
check "升级后配置未被改动(JWT_SECRET 保持)" \
"$([ "$(read_cfg JWT_SECRET)" = "${new_secret:-$(read_cfg JWT_SECRET)}" ] && echo 0 || echo 1)"
check "产生了程序快照(可回滚)" "$(ls -1 "${BACKUP_DIR}/rollback" 2>/dev/null | grep -q . && echo 0 || echo 1)"
check "坏包被拒绝(sha256 不匹配时不改动任何文件)" \
"$("$CLASSHELPER" upgrade --url "$PACKAGE" --sha256 0000000000000000000000000000000000000000000000000000000000000000 2>&1 | grep -q '校验失败' && echo 0 || echo 1)"
check "校验失败后服务仍在运行" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
note "(回滚需要交互确认,这里用 yes 管道)"
rb_out="$(printf 'yes\n' | "$CLASSHELPER" rollback 2>&1)"
check "rollback 执行成功" "$(printf '%s' "$rb_out" | grep -qE '已回滚到|回滚' && echo 0 || echo 1)"
check "回滚后服务仍就绪" "$("${CURL[@]}" -o /dev/null "${API}/healthz" && echo 0 || echo 1)"
note "升级前版本 ${before_version}|升级后 $("$CLASSHELPER" version 2>/dev/null | awk '{print $2}')"
fi
fi
# ---------------------------------------------------------------- 结果
printf '\n'
if [ "$FAIL" = "0" ]; then
printf '%s=== 结果:%s/%s 项通过 ===%s\n' "$G" "$PASS" "$TOTAL" "$N"
else
printf '%s=== 结果:%s/%s 项通过,%s 项失败 ===%s\n' "$R" "$PASS" "$TOTAL" "$FAIL" "$N"
printf '%s失败项:%s\n' "$Y" "$N"
for item in "${FAILED[@]}"; do printf ' · %s\n' "$item"; done
fi
[ "$FAIL" = "0" ] || exit 1
+560
View File
@@ -0,0 +1,560 @@
# Linux 部署与运维指南
本文覆盖服务端在 Linux 上的**一键安装**与日常运维(`classhelper` 命令)。
适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky Linux / AlmaLinux 8+(自动识别 apt / yum / dnf)。
不想看长文的话,最短路径就是这**一行**(在服务器上执行):
```bash
curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh && sudo bash /tmp/classhelper-install.sh
```
它自己会把整套流程走完:识别发行版 → 补依赖 → 建系统用户 → **从 GitHub Release 取最新版服务端包
并校验发布页上的 `.sha256`** → 下载内置 Node 运行时 → 建库建号 → 装成 systemd 服务。装完直接:
```bash
sudo classhelper status
```
参数接在最后即可,例如先只体检(不需要 root、不改动系统):
```bash
curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/LoveChengke/ClassHelper/master/deploy/install.sh && bash /tmp/classhelper-install.sh --check
```
> **别写成 `bash -c "$(curl …)" --check`**:`bash -c '代码' 第一个参数` 里那个参数会变成 `$0`,
> 不是 `$1` —— 脚本收不到它(实测踩到:本意是体检,结果走进了交互式安装)。
> 先落盘再执行没这个坑,也不会因为下载中断而执行到半个脚本。
以下是"已有安装包 / 内网离线"的用法:
```bash
sudo bash install.sh --package /path/to/classhelper-server-linux-x64-1.1.2.tar.gz
sudo classhelper status
```
---
## 1. 三种形态怎么选
| 形态 | 适合 | 命令 | 说明 |
| --- | --- | --- | --- |
| **直接安装(native)** | 有一台 Linux 服务器/Mini 主机,单机跑 | `install.sh` 选 1 | systemd 管理,自带 Node 运行时,SQLite 或 MySQL |
| **直接安装 + MySQL** | 多终端共享数据、已有 MySQL | `install.sh` 选 2 | 连接串写在配置里;库要先建好 |
| **Docker** | 机器上已有 Docker、想与其他服务隔离 | `install.sh` 选 3 | 用安装包里随带的 Dockerfile 构建镜像 + compose 起容器 |
> 只想先看看这台机器能不能装:`bash install.sh --check`(**不需要 root**,不改动任何东西)。
> Windows 机器用服务端安装包(`pnpm dist:server` 的产物),不需要本文。
---
## 2. 安装包从哪来
Linux 安装包命名固定为:
```
classhelper-server-linux-x64-<版本>.tar.gz ← install.sh / classhelper upgrade 都认这个名字
classhelper-server-linux-x64-<版本>.tar.gz.sha256 ← 旁边那份校验值
```
它发布在 [GitHub Releases](https://github.com/LoveChengke/ClassHelper/releases) 上。
**为什么 Linux 包要单独出一个**:`@libsql/linux-x64-gnu`、`@prisma/adapter-libsql` 这些依赖是平台相关的,
Windows 开发机上 `npm install` 出来的 `node_modules` 拷到 Linux 跑不起来,因此 Linux 包必须在 Linux 上构建。
仓库里的 [`.github/workflows/release-linux-server.yml`](../.github/workflows/release-linux-server.yml)
就是干这个的(拿 ubuntu runner 跑 `pnpm dist:server:linux`,再把产物挂到对应 Release 上):
1. 本地照惯例发 Windows 包的 Release(tag 形如 `v1.1.2`);
2. GitHub → Actions → 「发布 Linux 服务端安装包」→ Run workflow → 填 tag → 运行;
3. 跑完 Release 里会多出 Linux 包与更新后的 `SHA256SUMS-<版本>.txt`。
内网离线分发也可以:在能上网的机器上 `pnpm dist:server:linux`,把 tar.gz 拷进去,
用 `install.sh --package /path/to/xxx.tar.gz --sha256 <哈希>` 安装。
### 没有 Linux 机器时(交叉构建)
`pnpm dist:server:linux` 在非 Linux 上也**能跑**:脚本会用 npm 的 `--os=linux --cpu=x64`
按目标平台解析可选依赖,所以 `@libsql/linux-x64-gnu` 这些"按平台分发"的包会被正确装进来。
适合本地快速打一个包去验证安装与运维流程。已核对过的结论:
| 项 | 结论 |
| --- | --- |
| SQLite 驱动 | ✅ 包内是 `@libsql/linux-x64-gnu` + `linux-x64-musl`,**没有任何 win32 残留** |
| 服务端本体 | ✅ Node + libsql + express + socket.io 都是纯 JS 或已就位的 Linux 原生包 |
| 运维命令 | ✅ `status` / `doctor` / `password` / `backup` / `upgrade` / `rollback` 都不依赖平台差异 |
| Web 端「一键切换数据库」 | ⚠ **可能失败**:这条链路要调 prisma CLI 的 schema engine,CLI 在运行期按平台扫 `@prisma/engines/schema-engine-debian-openssl-3.0.x` 这类路径,而交叉包里只有宿主平台的 `schema-engine-windows.exe` |
所以交叉包**能装、能跑、能验收**,只是那一个 Web 端功能存疑 —— 它会在包内写入 `.cross-built` 标记,
`classhelper doctor` 会据此提示你(不会静默)。**正式发布一律用 GitHub Actions 那条路。**
```bash
# 开发机(Windows 也可以)
pnpm dist:server:linux
# → releases/server/<版本>/linux-x64/classhelper-server-linux-x64-<版本>.tar.gz(+ .sha256)
# 重跑很快:node scripts/dist-server.mjs --platform linux --reuse-deps(复用已装的 node_modules)
```
---
## 3. 一键安装
### 3.1 交互式(推荐第一次用)
```bash
sudo bash install.sh
```
TUI 会依次问:形态 → 安装目录 → 端口 → 数据库 → 初始管理员密码 → 是否要 Nginx 配置 / 放行防火墙。
每一问都有默认值,直接回车即可。
### 3.2 非交互(自动化 / 批量部署)
```bash
# 全部默认:/opt/classhelper、4000 端口、SQLite、内置 Node
sudo bash install.sh --yes --package ./classhelper-server-linux-x64-1.1.2.tar.gz
# 指定目录与端口,从 stdin 传初始管理员密码(不进 argv / shell history)
printf '%s\n' 'MyStrongPass!2026' | sudo bash install.sh --yes \
--dir /srv/classhelper --port 8080 --admin-password-stdin \
--package ./classhelper-server-linux-x64-1.1.2.tar.gz
# MySQL 形态
sudo bash install.sh --yes --database mysql --mysql-host 10.0.0.5 --mysql-db classhelper \
--mysql-user classhelper --mysql-password-stdin \
--package ./classhelper-server-linux-x64-1.1.2.tar.gz <<< 'MySQL密码'
```
完整参数见 `bash install.sh --help`。要点:
| 参数 | 作用 |
| --- | --- |
| `--package <url|file://路径>` | 安装包地址(默认从 GitHub Release 取最新版) |
| `--sha256 <哈希>` | 强制校验安装包哈希 |
| `--admin-password-stdin` | 从 stdin 读一行作为初始管理员密码 |
| `--node-source bundled|system|tarball` | 内置 Node / 系统 Node / 指定压缩包 |
| `--node-mirror <url>` | Node 下载镜像(默认 `https://npmmirror.com/mirrors/node`) |
| `--node-tarball <url|路径>` | 内网离线时直接用本地 Node 包 |
| `--with-nginx` / `--open-firewall` | 顺带生成反代配置 / 放行端口 |
| `--yes` | 全默认、不再交互 |
| `--check` | 只体检(无需 root) |
| `--force` | 覆盖已有配置;在无 systemd 的机器上只铺文件不装服务 |
**幂等性**:重复执行就是覆盖安装。已有的 `config.env`(含 `JWT_SECRET`)与 `data/` 一律保留,
用户不会被踢下线;端口与数据库形态也沿用旧配置,不会被改回默认值。
### 3.3 安装器做了什么
1. 识别发行版与包管理器,补齐 `curl / tar / gzip / ca-certificates`(只装缺的);
2. 创建系统用户与组 `classhelper`(`--system`,nologin);
3. 建目录、收权限(见下一节);
4. 下载安装包 → 校验 sha256 → 解压铺文件;
5. 下载 Node(默认走 npmmirror 镜像,从 `SHASUMS256.txt` 同时取版本号与哈希);
6. 生成 `/etc/classhelper/config.env`(随机 `JWT_SECRET`,权限 600);
7. 装 systemd 单元、`/usr/local/bin/classhelper`、`/etc/profile.d/classhelper.sh`、logrotate;
8. 启动服务并轮询 `/healthz`,通过后才算安装成功;
9. 全过程写 `/var/log/classhelper/install.log`。
### 3.4 Docker 形态
`install.sh` 选 3(或 `--mode docker`):用安装包里随带的 `Dockerfile` 直接构建镜像
(包的 `node_modules` 已经是 linux 的真实目录,**构建时不需要联网装依赖**,几十秒出镜像),
然后写一份 `/etc/classhelper/compose.yml` 拉起容器。
```bash
classhelper status # 走 docker compose ps / logs
classhelper restart # 重建容器
classhelper password admin # 通过 docker exec 在容器里执行同一份 admin-cli
classhelper backup # 容器内做一致性快照再取出来
```
Docker 形态与直接安装的差异:
| | 直接安装 | Docker |
| --- | --- | --- |
| 数据位置 | `<安装目录>/data` | Docker 卷 `classhelper-data` |
| 配置 | `/etc/classhelper/config.env`(systemd 注入) | 同一份文件(compose `env_file`) |
| 升级 / 回滚 | 替换程序文件后重启服务 | 同一套流程:替换构建上下文 → 重建镜像(`classhelper-server:local`)→ `compose up -d --force-recreate` |
| 恢复数据 | `classhelper backup restore <目录>` | 需手工 `docker cp` 回容器(命令里有提示) |
| `backup schedule` | 支持(systemd timer) | 用宿主机 cron 调 `classhelper backup --quiet` |
> Docker 形态的镜像用**稳定标签** `classhelper-server:local`(另打一个 `classhelper-server:<版本>` 便于追溯)。
> 版本号写在 compose 里的话,升级替换完程序文件后那个标签就过期了,`compose up` 会一直沿用旧镜像 ——
> 表现为"升级成功但行为没变"。`classhelper` 自己按 `VERSION` 决定要不要重建镜像。
---
## 4. 目录、权限与配置
```
/opt/classhelper/ 安装目录(--dir 可改)
├── server/ 后端程序(入口 server/dist/index.js)
│ ├── dist/ prisma/ 产物 + 迁移 SQL(首启动 AUTO_MIGRATE 自动执行)
│ ├── prisma.config.ts tsconfig.generate.json 数据库「一键切换」用
├── web/ Web 管理端(由后端托管)
├── node_modules/ 生产依赖(真实目录,非软链)
├── tools/admin-cli.mjs 离线改密 / 数据库快照工具
├── bin/classhelper 运维命令本体(/usr/local/bin/classhelper 软链到它)
├── runtime/node/ 内置 Node 运行时
├── systemd/ logrotate.d/ profile.d/ 模板(安装时拷到 /etc 下)
├── Dockerfile Docker 形态构建用
├── data/ 数据库 + Web 端自建快照(备份就是拷这里)
├── logs/ 预留
├── VERSION 当前版本
├── .installed.json 安装元数据(形态/版本/端口,classhelper 靠它决策)
└── .env → /etc/classhelper/config.env (软链,见下)
/etc/classhelper/ 目录 700,属主 classhelper
└── config.env 配置文件,**权限 600**,唯一真身
/var/log/classhelper/ 安装日志 install.log + 审计日志 audit.log
/var/backups/classhelper/ 备份 + 程序快照(回滚用)
```
### 4.1 为什么安装目录里的 `.env` 是软链
Web 端「数据库管理 → 一键切换数据库」在运行中改写的是 **`<安装目录>/.env`**
(`packages/server/src/modules/database/database.service.ts`),而 systemd 是通过
`EnvironmentFile=/etc/classhelper/config.env` 注入环境变量的。dotenv 不覆盖已存在的环境变量 →
如果这两处不是同一份文件,**切换看起来成功、重启后又连回旧库**。
所以安装器把 `<安装目录>/.env` 做成指向 `config.env` 的软链:两种读法落到同一个真身。
`classhelper doctor` 会专门检查这条软链,别手工删掉。
### 4.2 配置项
配置就是一份 `KEY=VALUE` 的 env 文件,systemd / dotenv / `source` 三种读法都兼容
(含空白或 `#` 的值请加双引号)。最常见的几项:
| KEY | 默认 | 说明 |
| --- | --- | --- |
| `PORT` / `HOST` | `4000` / `0.0.0.0` | 监听端口与地址 |
| `DATABASE_PROVIDER` | `sqlite` | `sqlite` 或 `mysql` |
| `DATABASE_URL` | `file:../data/classhelper.db` | SQLite 相对 `<安装目录>/server` 解析;MySQL 是连接串 |
| `JWT_SECRET` | 随机 | 登录令牌签名密钥。**换掉=所有用户重新登录**(`classhelper key rotate`) |
| `JWT_EXPIRES_IN` | `7d` | 令牌有效期 |
| `CORS_ORIGIN` | `*` | 走域名/反代时建议收敛成 `https://你的域名` |
| `TRUST_PROXY` | `1` | 前面有 Nginx 时保持 1,限流才按真实 IP 统计 |
| `AUTO_MIGRATE` | `true` | 空库首启动自动建表 + 建管理员 |
| `INITIAL_ADMIN_USERNAME` / `INITIAL_ADMIN_PASSWORD` | `admin` / `admin123` | **只在空库首次初始化时生效**,之后改密码用 `classhelper password` |
| `DEFAULT_CLASS_PASSWORD` / `DEFAULT_TEACHER_PASSWORD` | `123456` | 新建班级 / 重置教师时的初始密码 |
| `TERM_START_DATE` | 空 | 第 1 教学周的周一(`YYYY-MM-DD`),影响"当前周次" |
改配置的三种方式(都会先自动备份、改完问你是否重启):
```bash
sudo classhelper config show # 敏感值打码查看
sudo classhelper config get PORT
sudo classhelper config set CORS_ORIGIN https://class.example.com # 敏感项会改成交互输入
sudo classhelper config edit # 用 $EDITOR 直接编辑
sudo classhelper port 8080 # 改端口的快捷方式
```
> 手工改也可以(`vim /etc/classhelper/config.env`),但记得改完 `classhelper restart`,
> 并确认权限仍是 600。
---
## 5. `classhelper` 运维命令
安装后可直接使用(`/usr/local/bin/classhelper`)。除 `help` / `version` / `completion` 外都需要 root。
### 5.1 查看与诊断
```bash
classhelper status # 服务状态、版本、端口、数据库、各类路径
classhelper status --json # 同上,机器可读(接监控用)
classhelper doctor # 完整体检:权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份
classhelper logs -f # 实时日志(= journalctl -u classhelper -f)
classhelper logs -n 200 --since "1 hour ago"
classhelper logs --unit classhelper-backup # 看定时备份的日志
```
`doctor` 会把每一项标成 `✔` / `✘`,失败项后面直接跟修复建议,例如:
```
✘ 安装目录 .env 是指向配置真身的软链
✔ Node 运行时可用且 ≥20.19(v22.20.0)
✘ 最近 7 天内有备份(无)
建议依次处理:
· ln -sfn /etc/classhelper/config.env /opt/classhelper/.env(Web 端「一键切换数据库」依赖它)
· classhelper backup;长期可用 classhelper backup schedule daily
```
### 5.2 改密码
```bash
sudo classhelper password # 改 admin
sudo classhelper password teacher1 # 改指定教师
sudo classhelper password --list # 列出可登录账号与班级账号(含是否已设密码)
sudo classhelper password --class G101 # 改某个班的班级密码(学生端登录用)
```
安全约定(照要求逐条落实):
- 密码**交互输入、不回显**;不接受命令行传参,因此不会进 shell history、也不出现在 `ps` 里;
- 强度校验:≥8 位,小写/大写/数字/符号至少 3 类,不得与用户名相同,常见弱口令直接拒绝;
需二次确认,连续 3 次不合规即退出(密码不变);
- 改前把**原密码哈希**备份到 `/var/backups/classhelper/password-<时间戳>.json`(权限 600),
写入失败或写入后复核不通过会**自动回滚**原哈希;
- 全过程写审计日志,**审计里只有"谁对谁做了什么、结果如何",没有密码**;
- 自动化可以用管道传入(仍不进 history):
```bash
printf '%s\n%s\n' "$NEW_PW" "$NEW_PW" | sudo classhelper password admin
```
> 顺带说明:改密码不需要重启服务(登录时读库校验);已登录的会话仍有效,
> 要让所有会话立刻失效用 `classhelper key rotate`。
### 5.3 备份与恢复
```bash
sudo classhelper backup # 备份配置 + 数据库
sudo classhelper backup --note "升级前" --keep 7
sudo classhelper backup list
sudo classhelper backup restore 20261005-033000 # 用某个备份覆盖当前配置与数据(会先停服务)
sudo classhelper backup schedule daily # 每天 03:30 自动备份(systemd timer,保留 7 份)
sudo classhelper backup schedule off
```
- 备份落在 `/var/backups/classhelper/<时间戳>[-说明]/`:`config.env` + `classhelper.db` + `meta.json`,
目录 700、文件 600;
- SQLite 用 `VACUUM INTO` 做**一致性快照**(服务在跑也安全,比直接 `cp` 可靠);
- MySQL 用 `mysqldump --single-transaction`(密码经 `MYSQL_PWD` 传给子进程,不进 argv);
- `restore` 前会先自动备份现状,恢复对象是配置与数据(不是程序);
- 程序文件快照(回滚用)在 `/var/backups/classhelper/rollback/`,是另一类东西,`backup list` 会分开列。
### 5.4 升级与回滚
```bash
sudo classhelper upgrade --check # 只检查有没有新版本(查 GitHub Release)
sudo classhelper upgrade # 升到最新版
sudo classhelper upgrade --version 1.2.0 # 升到指定版本
sudo classhelper upgrade --url ./classhelper-server-linux-x64-1.0.1.tar.gz --sha256 <哈希> # 内网离线
sudo classhelper upgrade --url <包> --force # 强制重装同一版本
sudo classhelper rollback --list # 看有哪些程序快照
sudo classhelper rollback # 回滚到最近一份(会问一次确认)
```
`upgrade` 的完整链路(任一步失败都不改动程序文件):
```
解析目标版本 → 下载 → 校验 sha256 → 备份数据与配置 → 快照当前程序文件 →
停服 → 替换程序文件(mv 原子替换,data/ 配置/ 运行时原地不动)→ 起服 → 轮询 /healthz
↘ 失败:自动用刚才的快照回滚并重新起服,再告诉你失败原因
```
两点必须知道:
1. **升级不会回滚数据库结构**。如果新版执行过迁移,回滚程序前要确认旧版还能读新库,
必要时用 `classhelper backup restore` 连数据一起回退;
2. 替换程序文件用的是"改名 + 就位"而不是删除重写 —— 升级时正在运行的就是 `bin/classhelper` 自己,
必须先改名再放新文件,运行中的进程继续读旧 inode,否则会读到半个脚本。
### 5.5 服务与配置
```bash
sudo classhelper start | stop | restart | reload # reload 与 restart 同义(服务端不支持热重载)
sudo classhelper port # 看当前端口
sudo classhelper port 8080 # 改端口(顺带提示放行防火墙)
sudo classhelper config show [--reveal]
sudo classhelper config set KEY VALUE
sudo classhelper key rotate # 轮换 JWT_SECRET(所有用户需重新登录)
```
### 5.6 其它
```bash
classhelper version
classhelper help
source <(classhelper completion bash) # 补全(安装时已装到 /usr/share/bash-completion)
```
### 5.7 卸载
```bash
sudo classhelper uninstall
```
会依次问:保留配置?保留数据?保留备份?默认全部保留 —— 数据与配置会被**搬到**
`/var/backups/classhelper/uninstall-<时间戳>/`,然后才删除程序目录,不会悄悄丢数据。
```bash
sudo classhelper uninstall --yes # 不问,用默认(保留配置/数据/备份)
sudo classhelper uninstall --purge # 全删(含备份与数据),不可恢复
```
---
## 6. 环境变量文件
安装器会写 `/etc/profile.d/classhelper.sh`(内容见
[`deploy/profile.d/classhelper.sh`](../deploy/profile.d/classhelper.sh)):
```sh
export CLASSHELPER_HOME=/opt/classhelper
export CLASSHELPER_CONFIG=/etc/classhelper/config.env
export CLASSHELPER_LOG_DIR=/var/log/classhelper
export CLASSHELPER_BACKUP_DIR=/var/backups/classhelper
export PATH=$PATH:$CLASSHELPER_HOME/bin
```
新登录的 shell 自动生效;当前会话要手工 `source /etc/profile.d/classhelper.sh`。
`classhelper` 命令本身装在 `/usr/local/bin`(软链),所以**不登录也照样能用**。
---
## 7. 反向代理与 HTTPS(公网部署必做)
```bash
sudo bash install.sh --with-nginx # 或者安装后手工:
sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
```
要点:
- `client_max_body_size 12m`(表格导入最大 8MB,base64 后约 11MB);
- Socket.IO 需要 `Upgrade` / `Connection` 头与较长的 `proxy_read_timeout`(模板里已配);
- 配置里保持 `TRUST_PROXY=1`,并把 `CORS_ORIGIN` 收敛成你的域名;
- 证书用 certbot 或你自己的证书,装好后 `nginx -t && nginx -s reload`。
---
## 8. 故障排查
| 现象 | 先查什么 |
| --- | --- |
| 服务起不来 | `classhelper doctor` → `classhelper logs -n 100`。常见:端口被占用、MySQL 连接串错、`data/` 权限 |
| 装完打不开页面 | `classhelper status` 看探针与监听地址;云服务器还要在**安全组**放行端口 |
| 页面能打开但是**空白** | 1.1.0 的服务端在 HTTP 直连访问时会下发 CSP `upgrade-insecure-requests`,浏览器把所有子资源改写成 https 加载而服务器没有 TLS,于是整页空白(HTML/接口都正常)。已修复的版本按请求协议条件下发;若暂不能升级,唯一正解是上 HTTPS 反代 |
| 改了配置没生效 | 是否重启过(`classhelper config set` 会问);`EnvironmentFile` 的值优先于文件里的同项 |
| Web 端切库后还是连旧库 | `classhelper doctor` 看 `.env` 软链是否被破坏(`ln -sfn /etc/classhelper/config.env <安装目录>/.env`) |
| 升级卡住/失败 | 失败会自动回滚;看 `classhelper logs -n 100`,确认磁盘余量 ≥1GB |
| `classhelper` 命令找不到 | `ls -l /usr/local/bin/classhelper`;被删了就重装或 `ln -sfn <安装目录>/bin/classhelper /usr/local/bin/classhelper` |
| 忘记管理员密码 | `sudo classhelper password admin`(直连数据库,不需要登录) |
| 学生端连不上 | 客户端「服务器地址」填 `http://IP:端口` 或域名;确认没有多余路径与斜杠 |
| CentOS 7 装完立刻退出 | glibc < 2.28,见下面的"已知限制" |
---
## 9. 已知限制
### CentOS 7 / glibc < 2.28
Node 官方的 linux-x64 二进制要求 **glibc ≥ 2.28**,而 CentOS 7 是 2.17 —— 直接装官方包会表现成
"装完服务立刻退出、日志里什么也没有"。安装器会自动检测 glibc,低于 2.28 时改用
[unofficial-builds](https://unofficial-builds.nodejs.org/) 的 **glibc-217** 构建(功能一致,只是编译基线更低)。
但即便这样,个别原生依赖仍可能不兼容,CentOS 7 支持属于**尽力而为**:
- CentOS 7 已于 2024-06 EOL,**新部署请用 Rocky Linux 9 / AlmaLinux 9 / Ubuntu 22.04+**;
- 若坚持用 CentOS 7,建议装完立刻跑一遍 `classhelper doctor` 与本文第 10 节的验收清单。
### 其它
- 直接安装形态要求 **systemd**(容器里没有 `/run/systemd/system` 时会明确拒绝;
容器场景请用 Docker 形态)。
- Docker 形态的数据恢复需要手工 `docker cp`(数据在容器卷里,宿主机上看不到那个文件);
`classhelper backup restore` 会给出具体命令。
- `classhelper reload` 等价于 restart(服务端没有 SIGHUP 处理)。
- 升级包只发 x64;arm64 需要自行构建(`node scripts/dist-server.mjs --platform linux` 跑在 arm64 机器上)。
---
## 10. 验收清单
下面这套在干净的 **Ubuntu 22.04** 与 **Rocky Linux 9** 上各跑一遍。
`deploy/verify-linux.sh` 会把第 3~8 步自动跑完并打印 `=== 结果:N/N 项通过 ===`。
### 10.0 准备
```bash
# 在开发机:做出 Linux 包(或在 Actions 里下载)
pnpm dist:server:linux
scp releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz root@<服务器>:/tmp/
scp deploy/install.sh deploy/verify-linux.sh root@<服务器>:/tmp/
```
### 10.1 体检(不需要 root)
```bash
bash /tmp/install.sh --check
```
期望:系统/包管理器/glibc/docker/端口/磁盘逐项列出,末尾「体检通过」。
端口被占用、缺 curl、没有 systemd 都会在这里被点出来。
### 10.2 安装
```bash
sudo bash /tmp/install.sh --yes \
--package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz \
--admin-password-stdin <<< 'Verify-Pass-2026!'
```
期望:末尾出现「安装完成」与「服务已就绪 ✅」,并打印管理端地址。
### 10.3 自动验收
```bash
sudo bash /tmp/verify-linux.sh --admin-password-stdin <<< 'Verify-Pass-2026!'
```
覆盖项:
| 分组 | 检查内容 |
| --- | --- |
| 安装结果 | 程序文件、`VERSION`、`.installed.json`、`config.env` 600、配置目录 700、`.env` 软链、内置 Node、`/usr/local/bin/classhelper`、安装/审计日志、profile.d 生效 |
| 服务 | systemd 单元、active、enabled、`/healthz` 200、`status --json` 合法且 healthy |
| 体检 | `classhelper doctor` 无待处理项、`help` / `completion` 可用 |
| API | 用初始密码登录拿 token、带 token 读 `/api/classes` 与 `/api/auth/me` |
| 改密 | 改密成功 → 新密码可登录 → **旧密码被拒** → 原哈希已备份且 600 → 审计有记录且**不含密码明文** → 弱密码被拒 → stdin 结束不卡死 → `restore-hash` 把原密码回滚回来 |
| 备份 | 备份目录/文件/权限、SQLite 快照是合法库文件、`backup list` |
| 配置 | `config get/show`、敏感值打码、读写往返 |
| key rotate | 密钥确实变了且 ≥32 位、服务仍可用、**旧 token 变 401**、重新登录成功 |
追加升级/回滚链路(会真的替换一次程序文件):
```bash
sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x64-1.0.1.tar.gz \
--admin-password-stdin <<< 'Verify-Pass-2026!'
```
覆盖:升级成功 → 服务恢复 → 配置未被改动 → 产生程序快照 → **坏哈希被拒绝且服务不受影响** →
`rollback` 回到旧版且服务可用。
### 10.4 手工补验(自动化覆盖不到的)
```bash
# ① 浏览器打开 http://<IP>:4000/ ,用 admin 登录,改一次密码再登录
# ② 学生端客户端填 http://<IP>:4000 用班级码 G101/123456 登录,收到一条老师发的通知
# ③ 重跑安装器(幂等性):sudo bash /tmp/install.sh --yes --package <同一个包>
# 期望:配置与数据保留、JWT_SECRET 不变(已登录用户不被踢下线)
# ④ 卸载:sudo classhelper uninstall --yes
# 期望:服务停止、单元删除、/usr/local/bin/classhelper 消失、
# 数据与配置被搬到 /var/backups/classhelper/uninstall-*/
# ⑤ 重启机器后服务自动起来(systemctl is-enabled classhelper)
```
### 10.5 Rocky 9 的差异点
- 包管理器走 `dnf`,依赖包名与 apt 不同(`install.sh` 已按 `PKG` 分支处理);
- 默认没有 `curl` 之外的工具时会被补装,注意首次安装需要能访问 dnf 源;
- 防火墙默认是 `firewalld`:用 `--open-firewall` 或手工
`firewall-cmd --add-port=4000/tcp --permanent && firewall-cmd --reload`;
- SELinux(Enforcing):服务读写 `/opt/classhelper` 与 `/etc/classhelper` 一般不受影响,
若日志里出现 `Permission denied` 且路径正常,先 `ausearch -m avc -ts recent` 看是不是 SELinux,
可以用 `semanage fcontext` 加规则,或临时 `setenforce 0` 验证判断。
---
## 11. 安全清单
- [ ] 改掉默认管理员密码:`classhelper password admin`(**公网部署第一件事**)
- [ ] 公网服务前面加 Nginx + HTTPS,`CORS_ORIGIN` 收敛到具体域名
- [ ] 定期备份:`classhelper backup schedule daily`,并确认备份能恢复(`backup restore` 演练一次)
- [ ] 配置与备份权限:`config.env` 600、`/etc/classhelper` 700、备份目录 700
- [ ] `classhelper doctor` 无待处理项;关注"最近 7 天内有备份"
- [ ] 审计日志(`/var/log/classhelper/audit.log`)随日志轮转保留,必要时接集中日志
- [ ] 只在需要时使用 `--purge`;卸载默认保留数据与备份
- [ ] 升级包一律校验 sha256(`upgrade` 内网分发时也要带 `--sha256`)
+53 -9
View File
@@ -19,28 +19,62 @@ MySQL 兼容方式设计,可平滑切换到云端 MySQL / MariaDB 统一存储
| 唯一约束仅使用 `@@unique` / `@unique` | SQLite 与 MySQL 语义一致 |
| 级联删除使用 Prisma 的 `onDelete` | 由 Prisma 生成对应外键动作 |
## 切换步骤
## 切换到 MySQL
### 1. 修改 provider
### 方式一:Web 管理端「数据库管理 → 一键切换」(推荐,v1.0.0 新增)
管理员在 Web 管理端打开 **数据库管理** 页(`/database`,仅 ADMIN 可见),在「一键切换数据库」卡片里:
1. 选目标类型(SQLite ⇄ MySQL)并填**空库**连接串(有表的库会被拒绝,防误覆盖);
2. 输入「确认迁移」→ 点「开始迁移」。
界面会自动依次执行(带步骤进度条):
1. **备份当前数据库**(gzip JSON 快照存 `<数据目录>/backups/`,失败可回滚);
2. 导出当前数据为快照;
3. 测试目标库连接;
4. 检查目标库为空;
5. 改写 `schema.prisma` 的 provider;
6. 子进程 `prisma generate` 生成新方言的 Prisma 客户端;
7. (打包形态)把生成的 `.ts` 客户端编译进 `dist/generated`;
8. 子进程 `prisma db push` 在目标库建表;
9. 子进程 `dist/tools/apply-snapshot.js` 把快照写入目标库;
10. 全部成功后改写 `.env` 的 `DATABASE_PROVIDER` / `DATABASE_URL`。
**完成后必须重启服务端**(安装版运行 `restart.cmd`,开发模式重跑 `pnpm dev:server`)才会连接新库。
中途任一步失败会**回滚 `schema.prisma`**,`.env` 不被改写 —— 现有数据库与配置保持可用。
> 同一页还提供:连接状态检测(版本 / 体积 / 各表行数)、连接测试、手动与**定时备份**、
> 备份恢复、快照导出导入、SQLite 数据库文件下载。备份与导入导出共用同一种 JSON 快照格式,
> 因此**任意备份都能恢复回任意一种受支持的数据库**。
>
> 只支持 SQLite 与 MySQL 两种主库:Redis 等键值库不是 Prisma 支持的主数据库(无法建表/迁移),
> 接口层直接拒绝。
### 方式二:命令行手工切换
#### 1. 修改 provider
```bash
# 自动把 prisma/schema.prisma 的 provider 改成 mysql
node scripts/use-database.mjs mysql
```
### 2. 安装 MySQL 驱动适配器
#### 2. MySQL 驱动适配器(自 v1.0.0 起已随包内置)
Prisma 7 要求所有数据库都通过 driver adapter 接入,MySQL 对应 `@prisma/adapter-mariadb`
(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。
(该适配器基于 `mariadb` 驱动,兼容 MySQL 8)。**它已在 `packages/server/package.json` 的
依赖里**(数据库管理模块的连接测试/迁移需要),因此无需再手动安装:
```bash
# 仅当从旧版本升级、依赖里还没有时才需要
pnpm --filter @classhelper/server add @prisma/adapter-mariadb
```
`packages/server/src/lib/db.ts` 已经内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器,
因此只需要安装依赖 + 配置环境变量,服务端代码无需改动。
`packages/server/src/lib/db.ts` 内置分支:当 `DATABASE_PROVIDER=mysql` 时会动态加载该适配器,
服务端代码无需改动。
### 3. 配置环境变量
#### 3. 配置环境变量
编辑 `packages/server/.env`:
@@ -52,7 +86,7 @@ DATABASE_URL="mysql://classhelper:your-password@127.0.0.1:3306/classhelper"
> 云端部署时把 `127.0.0.1` 换成云数据库内网/公网地址;
> 生产环境请同时修改 `JWT_SECRET` 与 `CORS_ORIGIN`。
### 4. 重建迁移并生成客户端
#### 4. 重建迁移并生成客户端
SQLite 与 MySQL 的 DDL 方言不同,**必须重新生成迁移文件**:
@@ -71,7 +105,11 @@ pnpm db:seed
pnpm --filter @classhelper/server exec prisma migrate dev --create-only --name init_mysql
```
### 5. 回到 SQLite
> 只搬数据、不要演示数据时,用界面的「下载快照 / 导入快照」更省事:
> 旧库导出 JSON → 切库建表 → 新库导入,一份快照也可用 CLI 迁移:
> `node server/dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json`
#### 5. 回到 SQLite
```bash
node scripts/use-database.mjs sqlite
@@ -80,6 +118,8 @@ rm -rf packages/server/prisma/migrations # 若之前已切换成 MySQL 迁移
pnpm db:migrate
```
> 用界面「一键切换」回 SQLite 时,目标库同样必须是空文件:删除旧 `.db` 或指定一个新路径。
## 生产部署注意事项
1. **连接池**:Prisma 7 的 driver adapter 使用底层驱动(`mariadb`)的连接池设置,
@@ -88,3 +128,7 @@ pnpm db:migrate
三端展示时再按本地时区格式化(前端使用 `@classhelper/shared` 的 `formatDate`)。
3. **迁移部署**:CI/CD 中使用 `prisma migrate deploy`(对应 `pnpm db:deploy`)而不是 `migrate dev`。
4. **备份**:切换云数据库后,`packages/server/prisma/dev.db` 不再被使用,可从 `.gitignore` 之外移除。
界面「定时备份」把快照写进 `<数据目录>/backups/`(MySQL 模式下即安装根 `data/backups/`),
**服务端进程不运行时不会补跑**;关键环境建议同时用数据库自带的备份策略。
5. **安装包体积**:切换链路需要随包内置 `prisma` CLI 与 `typescript` 编译器(Prisma 7 生成器产出
`.ts`,切换后需在目标机编译进 `dist/generated`),服务端安装包因此约 34MB → 71MB。
+90 -114
View File
@@ -4,9 +4,9 @@
| 形态 | 适用场景 | 产物 | 本机验证情况 |
| --------------------- | -------------------------------------------- | -------------------------------------------------------- | -------------------------------------- |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `release-server/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd + SQLite) | `deploy/install-linux.sh` + `deploy/classhelper.service` | ⚠️ 脚本已提供,本机无 Linux 未实测 |
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](../deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 |
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) |
---
@@ -22,10 +22,10 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
产物:
| 路径 | 说明 |
| ----------------------------------------------------- | ----------------------------------------------- |
| `release-server/classhelper-server/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
| `release-server/班级小助手服务端-0.1.0-x64-setup.exe` | 安装程序(约 34 MB,NSIS) |
| 路径 | 说明 |
| ----------------------------------------------------------------------- | ----------------------------------------------- |
| `releases/server/<版本>/免安装/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
| `releases/server/<版本>/安装包/班级小助手服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
@@ -35,7 +35,7 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
双击安装程序即可,**无需管理员权限**(当前用户级安装):
- 安装目录:`%LOCALAPPDATA%\Programs\ClassHelperServer`
- 静默安装:`"班级小助手服务端-0.1.0-x64-setup.exe" /S`
- 静默安装:`"班级小助手服务端-1.1.2-x64-setup.exe" /S`
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
- 桌面:`班级小助手服务端.lnk`(打开管理端)
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
@@ -149,108 +149,97 @@ docker compose -f deploy/docker-compose.sqlite.yml --env-file deploy/.env.sqlite
---
## 三、Linux 原生部署(形态 C,systemd + SQLite)
## 三、Linux 部署(形态 C,推荐一键安装器)
适用:Ubuntu / Debian / CentOS / Rocky 等有 systemd 的服务器,单机跑一套(SQLite),
用 Nginx 挂 HTTPS 给校内网/公网访问。
适用:Ubuntu 20.04+ / Debian 11+ / CentOS 7+ / Rocky / AlmaLinux,任何有 systemd 的服务器
(容器环境用下面的 Docker 形态)。**完整指南见 [linux-deploy.md](linux-deploy.md)**,这里是速查。
### 3.1 一键脚本(推荐)
### 3.1 一键安装(推荐)
在**仓库根目录**先构建,然后执行脚本(需要 root):
Linux 包由 GitHub Actions 在 ubuntu runner 上构建(平台相关的依赖只能在 Linux 上装),
产物挂在 Release 上;也可以本地 `pnpm dist:server:linux` 后把 tar.gz 拷到服务器。
安装器脚本两个文件都在仓库的 `deploy/` 下:
```bash
# 1) 构建(Node ≥ 20.19,推荐 22 LTS;目标机没有 pnpm 时可在别的机器上构建后拷仓库过来)
corepack enable && corepack prepare pnpm@11.8.0 --activate
pnpm install
pnpm build:shared
pnpm --filter @classhelper/server build
pnpm --filter @classhelper/web-admin build
# 开发机:做出 Linux 包并连同脚本一起拷到服务器
pnpm dist:server:linux
scp releases/server/*/linux-x64/classhelper-server-linux-x64-*.tar.gz deploy/install.sh deploy/verify-linux.sh \
root@<服务器>:/tmp/
# 2) 安装成 systemd 服务
sudo bash deploy/install-linux.sh # 默认端口 4000、目录 /opt/classhelper
# 常用变体:
# bash deploy/install-linux.sh --check # 只体检(不需要 root):校验构建产物/Node/端口探针
# sudo bash deploy/install-linux.sh --port 8080
# sudo bash deploy/install-linux.sh --dir /srv/classhelper
# sudo bash deploy/install-linux.sh --no-service # 只铺文件,自己托管进程
# sudo bash deploy/install-linux.sh --uninstall # 停服务并移除 unit(保留 data 与 .env)
# 服务器上
bash /tmp/install.sh --check # 只体检(不需要 root,不改动系统)
sudo bash /tmp/install.sh # 交互式:选形态 → 选目录 → 选库 → 设初始管理员密码
```
> 建议先跑一次 `--check`:它只读地校验"构建产物齐不齐、Node 版本够不够、目标端口上有没有旧实例",
> 不需要 root,也不会改任何东西。输出示例见 `deploy/install-linux.sh` 头部注释。
脚本做的事:建系统用户 `classhelper` → 把后端产物、`prisma/migrations`、Web 管理端拷到
`/opt/classhelper` → `npm install --omit=dev`(清单就是 `deploy/package.runtime.json`,与
容器运行时同一份)→ 生成 `.env`(随机 `JWT_SECRET`、`AUTO_MIGRATE=true`)→ 装 systemd unit →
`enable --now` → 轮询 `/healthz` 就绪后打印地址与初始管理员。
安装后的目录(与已验证的 Windows 免安装目录同构,因此 `.env` 里的相对路径语义一致):
```
/opt/classhelper/
├── .env 全部配置(权限 600)
├── server/dist 后端程序(入口 index.js)
├── server/prisma 迁移 SQL(首启动自动执行)
├── web/ Web 管理端(后端托管,单端口)
├── node_modules/ 仅生产依赖
├── data/ classhelper.db + server.pid(备份只复制这里)
└── logs/
```
首次启动会自动建表并创建管理员 `admin / admin123`(**登录后立刻改密码**)。
### 3.2 手动部署(不想用脚本 / 没有 systemd)
等价的手工步骤,便于排查:
非交互(批量部署 / 验收用):
```bash
sudo useradd --system --shell /usr/sbin/nologin classhelper
sudo mkdir -p /opt/classhelper/{server,web,data,logs}
printf '%s\n' 'StrongPass!2026' | sudo bash /tmp/install.sh --yes \
--dir /srv/classhelper --port 8080 --admin-password-stdin \
--package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz
```
# 程序产物
sudo cp -R packages/server/dist /opt/classhelper/server/dist
sudo cp -R packages/server/prisma /opt/classhelper/server/prisma
sudo cp -R packages/web-admin/dist /opt/classhelper/web
sudo cp deploy/package.runtime.json /opt/classhelper/package.json
安装器做的事:识别发行版与包管理器补依赖 → 建系统用户 `classhelper` → 下载包并校验 sha256 →
铺文件(`server/` `web/` `node_modules/` `tools/` `bin/`)→ 下载内置 Node 运行时 →
写 `/etc/classhelper/config.env`(随机 `JWT_SECRET`,权限 600)→ 装 systemd 单元与
`/usr/local/bin/classhelper` → 启动并轮询 `/healthz` 通过为止。全过程写
`/var/log/classhelper/install.log`。
# 生产依赖 + 工作区共享包
cd /opt/classhelper && sudo npm install --omit=dev --no-audit --no-fund
sudo mkdir -p /opt/classhelper/node_modules/@classhelper/shared
sudo cp -R packages/shared/dist /opt/classhelper/node_modules/@classhelper/shared/dist
sudo cp packages/shared/package.json /opt/classhelper/node_modules/@classhelper/shared/package.json
重跑就是**覆盖安装**:`config.env`(含密钥)与 `data/` 保留,已登录用户不会被踢下线。
# 配置(参考脚本生成的 .env,务必自己生成 JWT_SECRET)
sudo tee /opt/classhelper/.env >/dev/null <<'EOF'
NODE_ENV=production
HOST=0.0.0.0
PORT=4000
DATABASE_PROVIDER=sqlite
DATABASE_URL="file:../data/classhelper.db"
AUTO_MIGRATE=true
JWT_SECRET=换成 openssl rand -hex 48 的输出
JWT_EXPIRES_IN=7d
CORS_ORIGIN=https://class.example.com
TRUST_PROXY=1
RATE_LIMIT_ENABLED=true
STARTUP_DB_CHECK=true
PID_FILE=../data/server.pid
LOG_LEVEL=info
EOF
### 3.2 安装后的布局与运维
sudo chown -R classhelper:classhelper /opt/classhelper/{data,logs,node_modules} /opt/classhelper/.env
sudo chmod 600 /opt/classhelper/.env
```
/opt/classhelper/ 程序、node_modules、内置 Node、data/、tools/、bin/
/etc/classhelper/config.env 唯一配置真身(600);安装目录里的 .env 是指向它的软链
/var/log/classhelper/ install.log + audit.log
/var/backups/classhelper/ 备份与程序快照(回滚用)
```
# systemd
sudo sed -e 's#__NODE__#/usr/bin/node#' -e 's#__DIR__#/opt/classhelper#' -e 's#__USER__#classhelper#' \
deploy/classhelper.service | sudo tee /etc/systemd/system/classhelper.service
> 为什么安装目录的 `.env` 是软链:Web 端「数据库管理 → 一键切换」改写的是 `<安装目录>/.env`,
> 而 systemd 用 `EnvironmentFile=` 注入同一份配置;两处不是同一个文件时,切换会"看起来成功、
> 重启后又连回旧库"。软链把两者收成一份真身(`classhelper doctor` 会检查它)。
日常运维全部走 `classhelper`(`classhelper help` 看全部):
| 事项 | 命令 |
| ------ | --------------------------------------------------------------------------------------- |
| 状态 | `classhelper status [--json]` |
| 体检 | `classhelper doctor`(权限 / Node / 服务单元 / 探针 / 数据库 / 磁盘 / 备份) |
| 改密码 | `classhelper password [用户名]`(交互输入、不回显、不进 shell history) |
| 备份 | `classhelper backup`;每日自动:`classhelper backup schedule daily` |
| 升级 | `classhelper upgrade [--check]`(下载 + 校验 sha256 + 备份 + 替换 + 探针,失败自动回滚) |
| 回滚 | `classhelper rollback [--list]` |
| 日志 | `classhelper logs -f` |
| 改配置 | `classhelper config set KEY VALUE` / `config show` / `port 8080` |
| 换密钥 | `classhelper key rotate`(所有用户需重新登录) |
| 卸载 | `classhelper uninstall`(默认保留配置、数据与备份) |
| 服务 | `classhelper start|stop|restart|reload` |
### 3.3 手动部署(不想用安装器 / 没有 systemd)
没有 systemd 的容器里可以用安装器的"只铺文件"模式,再自己托管进程:
```bash
sudo bash /tmp/install.sh --yes --force --package /tmp/classhelper-server-linux-x64-1.1.2.tar.gz
sudo -u classhelper /opt/classhelper/runtime/node/bin/node /opt/classhelper/server/dist/index.js
```
完全手工也可以(等价步骤,便于排查):解包后把 `server/` `web/` `node_modules/` `tools/` 铺到安装目录,
配置写到 `/etc/classhelper/config.env`(`JWT_SECRET` 用 `openssl rand -hex 48`),再用
`deploy/classhelper.service` 模板生成单元 —— 注意模板有 5 个占位符
(`__NODE__` `__DIR__` `__USER__` `__CONFIG__` `__CONFIGDIR__`):
```bash
sudo sed -e "s#__NODE__#/opt/classhelper/runtime/node/bin/node#" \
-e "s#__DIR__#/opt/classhelper#" -e "s#__USER__#classhelper#" \
-e "s#__CONFIG__#/etc/classhelper/config.env#" -e "s#__CONFIGDIR__#/etc/classhelper#" \
deploy/classhelper.service > /tmp/classhelper.service
sudo install -m 644 /tmp/classhelper.service /etc/systemd/system/classhelper.service
sudo systemctl daemon-reload && sudo systemctl enable --now classhelper
systemctl status classhelper --no-pager
```
也可以用 PM2 之类托管(`pm2 start /opt/classhelper/server/dist/index.js --name classhelper`),
但 systemd 更省心:`Restart=always`、日志进 journald、开机自启一次配好。
### 3.3 Nginx + HTTPS
### 3.4 Nginx + HTTPS
```bash
sudo cp deploy/nginx.conf /etc/nginx/conf.d/classhelper.conf
@@ -259,7 +248,7 @@ sudo nginx -t && sudo systemctl reload nginx
```
`deploy/nginx.conf` 已经处理了最容易踩的两点:`/socket.io/` 的 WebSocket 升级头,
以及 `X-Forwarded-For/X-Forwarded-Proto` 透传(配合 `.env` 里 `TRUST_PROXY=1`,限流才会按真实 IP 统计)。
以及 `X-Forwarded-For/X-Forwarded-Proto` 透传(配合配置里的 `TRUST_PROXY=1`,限流才会按真实 IP 统计)。
用 certbot 签证书:
```bash
@@ -267,31 +256,19 @@ sudo apt install -y certbot python3-certbot-nginx
sudo certbot --nginx -d class.example.com
```
### 3.4 升级 / 备份 / 排障
升级(不会动 `.env`,JWT 密钥不变,学生端不用重新登录):
### 3.5 验收
```bash
git pull && pnpm install
pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build
sudo bash deploy/install-linux.sh # 覆盖程序文件 + 自动补迁移(AUTO_MIGRATE)
journalctl -u classhelper -n 30 --no-pager
sudo bash /tmp/verify-linux.sh --admin-password-stdin <<< 'StrongPass!2026'
# 追加升级 / 回滚链路(会真的替换一次程序文件):
sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x64-1.0.1.tar.gz \
--admin-password-stdin <<< 'StrongPass!2026'
```
| 事项 | 命令 |
| -------- | ---------------------------------------------------------------------------------------------------------- |
| 备份 | `systemctl stop classhelper && cp -a /opt/classhelper/data /root/classhelper-data-$(date +%F)`(然后启动) |
| 恢复 | 停服务 → 用备份覆盖 `data/` → 启动 |
| 看日志 | `journalctl -u classhelper -f` |
| 重启 | `systemctl restart classhelper`(改 `.env` 后必须重启) |
| 探针 | `curl -s localhost:4000/healthz` / `/readyz`(就绪 200,数据库不可用 503) |
| 换端口 | 改 `.env` 的 `PORT` → 重启;同时改 Nginx 的 `upstream` |
| 换数据库 | 见 `docs/mysql.md`:切 provider + 装 `@prisma/adapter-mariadb` + `npx prisma migrate deploy` |
覆盖安装结果、服务探针、API 登录、改密(含审计与回滚)、备份、配置读写、key rotate、
升级与回滚整条链路,末尾打印 `=== 结果:N/N 项通过 ===`。逐条清单见
[linux-deploy.md](linux-deploy.md) 第 10 节。
> 注意:本机(开发环境)是 Windows 且无 Docker/WSL 发行版,因此 **3.1 的脚本没有在 Linux 实机跑过**。
> 它复用的两套东西都已验证:Windows 免安装目录(同样的目录结构、`.env` 相对路径与
> `AUTO_MIGRATE` 首启动行为)和 `deploy/Dockerfile` 的 runtime 阶段(同样的依赖清单与产物布局)。
> 首次在服务器上执行后请按 3.4 的探针 + Web 登录 + `pnpm verify:e2e`(把 `VERIFY_BASE_URL` 指向服务器)确认。
---
@@ -302,7 +279,7 @@ pnpm install
pnpm build:shared
pnpm --filter @classhelper/server build
pnpm --filter @classhelper/web-admin build
pnpm dist:server -- --reuse-deps # 得到 release-server/classhelper-server 免安装目录
pnpm dist:server -- --reuse-deps # 得到 releases/server/<版本>/免安装/ 免安装目录
```
把该目录拷贝到目标机,编辑 `.env`(端口、数据库、密钥、跨域),然后:
@@ -337,7 +314,6 @@ start.cmd :: Windows
| `PID_FILE` | — | PID 文件路径(安装包启停脚本依赖),相对 `server` 根 |
| `WEB_DIST_DIR` | 自动探测 | Web 管理端产物目录(`../web-admin/dist` 或 `./web`) |
| `LOG_LEVEL` | `info` | `debug` / `info` / `warn` / `error` |
| `DEFAULT_STUDENT_PASSWORD` | `123456` | 新增/重置学生账号的默认密码 |
| `DEFAULT_CLASS_PASSWORD` | `123456` | 新建班级时班级账号(学生端登录)的默认密码 |
| `DEFAULT_TEACHER_PASSWORD` | `123456` | 新增/重置教师账号的默认密码(管理员录入教师用) |
| `TERM_START_DATE` | — | 第 1 教学周的周一,用于"当前周次"与课表默认视图 |
+3 -1
View File
@@ -1,6 +1,6 @@
{
"name": "class-helper",
"version": "0.1.0",
"version": "1.1.2",
"private": true,
"description": "班级小助手 - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0",
@@ -16,6 +16,7 @@
"build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/desktop-client build",
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
"build:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs",
"check:icons": "node scripts/check-icons.mjs",
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
@@ -38,6 +39,7 @@
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
"dist:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux",
"dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx",
"dist:all": "pnpm dist:server && pnpm dist:win && pnpm dist:classisland-plugin"
},
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 83 KiB

+1 -1
View File
@@ -14,6 +14,6 @@ description: |-
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper
version: 0.1.0.0
version: 1.1.2.0
apiVersion: 2.0.0.0
author: 班级小助手
+19 -13
View File
@@ -3,7 +3,7 @@
*
* 用法:
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
* node scripts/build.mjs --cipx # 追加打包 .cipx(ClassIsland 插件包)并归集到 releases/classisland-plugin/
* node scripts/build.mjs --cipx # 追加打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
*
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
@@ -21,7 +21,7 @@ import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..');
const cacheDir = path.join(repoRoot, '.cache');
const releasesDir = path.join(repoRoot, 'releases', 'classisland-plugin');
const releasesRoot = path.join(repoRoot, 'releases', 'classisland-plugin');
const argv = process.argv.slice(2);
const withCipx = argv.includes('--cipx');
@@ -146,14 +146,19 @@ if (!fs.existsSync(cipxFile)) {
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
}
// 归集到 releases/classisland-plugin/(与客户端、服务端交付产物同级;releases/ 不入库)
fs.rmSync(releasesDir, { recursive: true, force: true });
fs.mkdirSync(releasesDir, { recursive: true });
fs.copyFileSync(cipxFile, path.join(releasesDir, path.basename(cipxFile)));
// 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
// 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
// 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
const pluginVersion = manifestVersion.split('.').slice(0, 3).join('.');
const versionDir = path.join(releasesRoot, pluginVersion);
const installerDir = path.join(versionDir, '安装包');
const portableDir = path.join(versionDir, '免安装', 'ClassHelper.ClassIslandPlugin');
fs.rmSync(installerDir, { recursive: true, force: true });
fs.rmSync(path.dirname(portableDir), { recursive: true, force: true });
fs.mkdirSync(installerDir, { recursive: true });
fs.mkdirSync(portableDir, { recursive: true });
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
const portableDir = path.join(releasesDir, 'ClassHelper.ClassIslandPlugin');
fs.mkdirSync(portableDir, { recursive: true });
for (const name of fs.readdirSync(outputDir)) {
const from = path.join(outputDir, name);
if (!fs.statSync(from).isFile()) continue;
@@ -165,10 +170,11 @@ for (const name of fs.readdirSync(outputDir)) {
}
for (const file of fs.readdirSync(cipxDir)) {
fs.copyFileSync(path.join(cipxDir, file), path.join(releasesDir, file));
// .cipx 是插件包本体,校验清单跟着版本目录走
const to = file.toLowerCase().endsWith('.cipx') ? installerDir : versionDir;
fs.copyFileSync(path.join(cipxDir, file), path.join(to, file));
}
log(`插件包已归集到 ${path.relative(repoRoot, releasesDir)}`);
for (const file of fs.readdirSync(releasesDir)) {
log(` ${file}`);
}
log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
for (const file of fs.readdirSync(portableDir)) log(` 免安装/ClassHelper.ClassIslandPlugin/${file}`);
@@ -495,7 +495,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
/* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "0.1.0.0";
private const string PluginVersion = "1.1.2.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion()
Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 109 KiB

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 250 KiB

+18 -1
View File
@@ -2,7 +2,21 @@ appId: com.classhelper.desktop
productName: 班级小助手
copyright: Copyright © 2026 Class Helper
# 语言包:Electron 发行版自带 55 个 `locales/*.pak`(实测 48.3MB 未压缩 / 约 10MB 压缩后),
# 而本应用界面文案只有中文,其余 54 个语言包纯属白占体积(`win-unpacked` 372MB → 约 324MB)。
# 这是 electron-builder 的官方选项:`app-builder-lib` 的 removeUnusedLanguagesIfNeeded()
# 会对 `locales/*.pak` 做精确匹配删除,只留 `zh-CN.pak`(0.6MB)。
#
# 注意:**不要**为了继续压体积去删 dxcompiler/vk_swiftshader/d3dcompiler_47 这些 GPU 回退组件,
# 或 LICENSES.chromium.html —— 前者是教室老显卡/虚拟机上的 D3D12/D3D11/Vulkan 渲染回退,
# 后者是 Chromium 的许可证声明,合计只能再省约 12MB,风险不成比例。
# 另外安装包有硬底:`班级小助手.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。
electronLanguages:
- zh-CN
directories:
# 这里的 output 只是 electron-builder 的兜底值:scripts/dist-win.mjs 会把它覆盖成
# releases/client/<版本>/(与服务端、插件同一约定)。直接手敲 electron-builder 才会落到这里。
output: release
buildResources: build
@@ -31,7 +45,10 @@ win:
arch: [x64]
- target: portable
arch: [x64]
# 未提供 build/icon.ico 时使用 Electron 默认图标
# 应用/安装包图标。**显式指定**而不是靠 electron-builder 的默认约定(默认找 build/icon.ico):
# 现在生效的是仓库根 build/classhelper.{ico,png}(设计导出、手工放入),
# 而 `pnpm icons` 那条 SVG 流水线只会重写 icon.*,两者不打架。
icon: build/classhelper.ico
artifactName: ${productName}-${version}-${arch}.${ext}
nsis:
+2 -3
View File
@@ -1,13 +1,13 @@
{
"name": "@classhelper/desktop-client",
"version": "0.1.0",
"version": "1.1.2",
"private": true,
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
"main": "dist/main/index.js",
"scripts": {
"dev": "node scripts/dev.mjs",
"build": "npm run build:main && npm run build:renderer && npm run check:secrets",
"build": "node ../../scripts/check-icons.mjs && npm run build:main && npm run build:renderer && npm run check:secrets",
"build:main": "node scripts/build-main.mjs",
"build:renderer": "vite build",
"check:secrets": "node scripts/check-bundle-secrets.mjs",
@@ -21,7 +21,6 @@
"@classhelper/shared": "workspace:*",
"@element-plus/icons-vue": "^2.3.2",
"axios": "^1.20.0",
"echarts": "^6.1.0",
"element-plus": "^2.14.5",
"pinia": "^4.0.3",
"socket.io-client": "^4.8.3",
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

+70 -18
View File
@@ -10,7 +10,14 @@
*
* 用法:
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
* node scripts/dist-win.mjs --dir # 仅生成免安装目录 release/win-unpacked
* node scripts/dist-win.mjs --dir # 仅生成免安装目录
*
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
* 安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
* 免安装/ electron-builder 的 win-unpacked 内容
* 构建中间/ builder-*.yml、*.nsis.7z
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
*/
import { execFileSync, spawn } from 'node:child_process';
import fs from 'node:fs';
@@ -59,6 +66,14 @@ if (!fs.existsSync(cli)) {
process.exit(2);
}
// 交付产物落到 releases/client/<版本>/(与服务端、插件同一约定;releases/ 不入库)
const clientVersion = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')).version;
const explicitOutput = passthrough.find((item) => item.includes('directories.output'));
const destDir = explicitOutput
? path.resolve(root, explicitOutput.slice(explicitOutput.indexOf('=') + 1))
: path.join(repoRoot, 'releases', 'client', clientVersion);
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
@@ -72,18 +87,18 @@ console.log(`[dist] Node 运行时:${runtime.path}(${runtime.version})`);
// 清理上一轮产物:本仓库目录树带沙箱 ACL(含 Everyone 的 DENY(delete child)),
// 已存在的产物会被工具(makensis / 7za)判为"Can't open output file / 拒绝访问"而失败,
// 手动删掉再打包即可 —— 顺手也保证产物集合确定,不会残留上一轮的旧安装包。
const outputDir = path.join(root, 'release');
if (fs.existsSync(outputDir)) {
for (const name of fs.readdirSync(outputDir)) {
if (!/\.(exe|7z|blockmap|yml)$/i.test(name)) continue;
try {
fs.rmSync(path.join(outputDir, name), { force: true });
} catch (error) {
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
}
let cleaned = false;
for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
const target = path.join(destDir, name);
if (!fs.existsSync(target)) continue;
try {
fs.rmSync(target, { recursive: true, force: true });
cleaned = true;
} catch (error) {
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
}
console.log('[dist] 已清理上一轮的安装包 / 归档');
}
if (cleaned) console.log('[dist] 已清理上一轮的免安装目录 / 安装包 / 构建中间');
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
@@ -105,21 +120,58 @@ const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env })
function normalizeIntegrityLabel(dir) {
if (process.platform !== 'win32') return;
const run = (args) => execFileSync('icacls', args, { stdio: 'pipe', windowsHide: true });
// 这一行才是"交付物能不能用"的关键,失败必须喊出来
try {
run([dir, '/setintegritylevel', 'Medium', '/T', '/C']);
// 目录自身恢复为"继承父目录的 Low",保证下次构建时 Low 的 7za 仍能写入归档
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
console.log('[dist] 已修正交付物完整性标签为 Medium,目录恢复继承:', dir);
console.log('[dist] 已把交付物完整性标签修正为 Medium:', dir);
} catch (error) {
console.warn('[dist] 完整性标签修正跳过:', error.message);
console.warn('[dist] 完整性标签修正失败(交付物可能装不上,见 AGENTS.md §7 第 7 条):', error.message);
}
// 目录自身恢复为"继承父目录的 Low",保证下次构建时带 Low 标签的 7za 仍能写进这个目录。
// 宿主目录树本来就没有 Low 标签体系时,这一步会被系统拒绝(保持继承即可),属正常,不提示。
try {
run([dir, '/setintegritylevel', '(OI)(CI)Low']);
} catch {
/* 忽略 */
}
}
/**
* 把 electron-builder 的原始输出整理成 releases 的固定分层:
* win-unpacked/ → 免安装/
* *.exe / *.blockmap → 安装包/
* *.yml / *.nsis.7z → 构建中间/
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
*/
function tidyArtifacts(dir) {
if (!fs.existsSync(dir)) return;
const moveInto = (from, sub) => {
const to = path.join(dir, sub);
fs.mkdirSync(to, { recursive: true });
fs.renameSync(from, path.join(to, path.basename(from)));
};
const unpacked = path.join(dir, 'win-unpacked');
const portableDir = path.join(dir, '免安装');
if (fs.existsSync(unpacked)) {
fs.rmSync(portableDir, { recursive: true, force: true });
fs.renameSync(unpacked, portableDir);
}
for (const name of fs.readdirSync(dir)) {
const from = path.join(dir, name);
if (!fs.statSync(from).isFile()) continue;
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
}
}
child.on('exit', (code) => {
if (code === 0) {
const output = path.join(root, 'release');
normalizeIntegrityLabel(output);
console.log('\n[dist] 打包完成,产物目录:', output);
tidyArtifacts(destDir);
normalizeIntegrityLabel(destDir);
console.log('\n[dist] 打包完成,产物目录:', destDir);
}
process.exit(code ?? 1);
});
@@ -31,7 +31,9 @@ import { squirclePath } from './squircle.js';
* - 阴影:只在展开态,`0 2px 3px rgba(0,0,0,.11)`(WinIsland `draw_expanded_shadow`);
* - 排版:白字 + alpha 分级(.92/.72/.58),**所有文本 = 基础字号 × 排版系数**,
* 因此「字号」设置一改,岛内文字整体等比缩放(WinIsland `font_size` 模型);
* - 动效:单调缓动(无过冲、无回弹),只有淡入淡出与窗口形变,符合"开合不震动"。
* - 动效:**单调缓动(无过冲、无回弹)**,只有淡入淡出与窗口形变,符合"开合不震动"。
* 曲线取自共享令牌(`--ch-ease-out`,见 packages/shared/src/motion.ts);
* 刻意**不用** beUI 那几组带过冲的弹簧 —— 理由写在文件末尾"悬停微交互"那段。
*
* 窗口尺寸仍由主进程按状态缓动(见 src/main/island.ts),渲染进程负责形状、排版与交互回传。
*/
@@ -1201,11 +1203,7 @@ body {
color: var(--wn-text-1);
text-align: left;
cursor: pointer;
transition: background 0.15s ease;
}
.list-row:hover {
background: var(--wn-surface);
transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.row-icon {
@@ -1292,11 +1290,7 @@ body {
font-size: calc(var(--island-font, 13px) * 0.76);
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease;
}
.more-btn:hover {
background: var(--wn-surface-hover);
transition: background var(--ch-dur-fast) var(--ch-ease-out);
}
.more-app {
@@ -1430,13 +1424,8 @@ body {
cursor: pointer;
flex: 0 0 auto;
transition:
background 0.15s ease,
color 0.15s ease;
}
.icon-btn:hover {
background: var(--wn-surface-hover);
color: #fff;
background var(--ch-dur-fast) var(--ch-ease-out),
color var(--ch-dur-fast) var(--ch-ease-out);
}
.icon-btn svg {
@@ -1521,8 +1510,8 @@ body {
cursor: pointer;
white-space: nowrap;
transition:
background 0.15s ease,
transform 0.12s ease;
background var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-dur-instant) var(--ch-ease-out);
}
.ghost-btn {
@@ -1530,10 +1519,6 @@ body {
color: var(--wn-text-1);
}
.ghost-btn:hover {
background: var(--wn-surface-hover);
}
.solid-btn {
background: #ffffff;
color: #0b0b0f;
@@ -1554,35 +1539,40 @@ body {
transform: scale(0.97);
}
/* ---------------------------------------------------------------- 过渡(无缩放、无回弹) */
/* --------------------------------------------------------------- 悬停微交互
*
* **必须关在 `@media (hover: hover) and (pointer: fine)` 里**:教室机器上的希沃触摸屏
* 点一下会产生"幽灵 hover"并**一直粘着**,直到点别处才消失 —— 不关的话,点过「知道了」
* 的按钮会永远保持高亮(同 AGENTS.md §5 第 30 条那类问题)。
* 这是 beUI `useHoverCapable` 的 CSS 版:纯样式效果用媒体查询就够,不必挂 composable。
*/
@media (hover: hover) and (pointer: fine) {
.list-row:hover {
background: var(--wn-surface);
}
.island-instant-enter-active {
transition: none;
.more-btn:hover,
.ghost-btn:hover {
background: var(--wn-surface-hover);
}
.icon-btn:hover {
background: var(--wn-surface-hover);
color: #ffffff;
}
}
.island-instant-enter-from {
opacity: 1;
}
.island-instant-leave-active {
transition: opacity calc(var(--island-dur, 320ms) * 0.28) ease-out;
}
.island-instant-leave-to {
opacity: 0;
}
/* 展开 / 收回:只做淡入淡出,形变交给窗口尺寸(避免任何缩放回弹"震动") */
.island-fade-enter-active {
transition: opacity calc(var(--island-dur, 320ms) * 0.45) ease-out;
}
.island-fade-leave-active {
transition: opacity calc(var(--island-dur, 320ms) * 0.3) ease-out;
}
.island-fade-enter-from,
.island-fade-leave-to {
opacity: 0;
}
/*
* 关于本岛的动效:**只有单调缓动(无过冲、无回弹)**,曲线取自共享令牌
* (`--ch-ease-out`,见 packages/shared/src/motion.ts)。
*
* 这里刻意**不用** beUI 那几组弹簧:弹簧带过冲,而本岛的形变由主进程按状态缓动窗口尺寸
* (`src/main/island.ts`),任何回弹都会让"开合"看起来在震动 ——
* 冒烟断言「展开过程不震动」「收回过程不抖动」逐帧采样岛体几何,正是为这条立的规矩。
* 岛的物理是自己的一套(`./spring.ts`,从 WinIsland 逐行移植),不该被换掉。
*
* (这里原本还有 `.island-instant-*` / `.island-fade-*` 两组过渡类 —— 它们**是死规则**:
* 全包只有 `<style>` 里出现,没有任何地方挂类名,是 §5 第 34 条删掉"收回快照"动画后的残留。
* 已删除,免得后人以为岛上存在一层 Vue `<Transition>`。)
*/
</style>
@@ -1,5 +1,6 @@
import { createApp } from 'vue';
import IslandApp from './IslandApp.vue';
import { applyMotionTokens } from '../renderer/composables/motion.js';
/**
* 灵动岛渲染进程入口。
@@ -8,6 +9,19 @@ import IslandApp from './IslandApp.vue';
*/
const app = createApp(IslandApp);
/**
* 动效令牌(beUI 的缓动曲线,见 packages/shared/src/motion.ts)写进 `:root`。
*
* **必须在这里也注入一次**:灵动岛是**另一个渲染进程**,不共享主窗口的 `:root`。
* 而 `IslandApp.vue` 的悬停微交互引用了 `var(--ch-dur-fast)` / `var(--ch-ease-out)`——
* 变量缺席会让整条 `transition` 简写失效(自定义属性解析不出来 → 声明作废),
* 结果是**完全没有过渡**,比改造前还差。
*
* 成本可控:多出来的是共享模块里的令牌常量与弹簧积分(约 2KB),
* 相比岛内已经打包的 Vue 可以忽略;换来的是"两端一岛同一套曲线、不各抄一份"。
*/
applyMotionTokens();
app.config.errorHandler = (error) => {
console.error('[island] 渲染异常', error);
};
+173 -15
View File
@@ -36,6 +36,14 @@ interface PersistedConfig {
notificationChannel?: ClassIslandNotificationChannel;
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
homeworkPhrases?: string[];
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
onboardingDone?: boolean;
/** 界面主题(向后兼容:旧配置没有该字段用浅色) */
theme?: 'light' | 'dark';
/** 主侧边栏是否折叠(向后兼容:旧配置没有该字段视为展开) */
sidebarCollapsed?: boolean;
/** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */
ignoredUpdateVersion?: string;
}
const DEFAULT_CONFIG: PersistedConfig = {
@@ -47,8 +55,17 @@ const DEFAULT_CONFIG: PersistedConfig = {
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
onboardingDone: false,
theme: 'light',
sidebarCollapsed: false,
ignoredUpdateVersion: '',
};
/** 主题白名单:非法值一律回落浅色 */
function normalizeTheme(input?: 'light' | 'dark'): 'light' | 'dark' {
return input === 'dark' ? 'dark' : 'light';
}
/**
* 作业快捷短语的合法化:去空白、去重、限长限量。
* 空数组是**合法值**(老师把短语全删了 = 不想用快捷短语),因此不能拿默认值顶回去。
@@ -165,7 +182,7 @@ function decryptToken(raw: PersistedConfig): string | null {
}
}
function readPersisted(): PersistedConfig {
function readPersistedFromDisk(): PersistedConfig {
try {
const content = fs.readFileSync(configFilePath(), 'utf8');
const parsed = JSON.parse(content) as Partial<PersistedConfig>;
@@ -181,16 +198,128 @@ function readPersisted(): PersistedConfig {
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
onboardingDone: parsed.onboardingDone === true,
theme: normalizeTheme(parsed.theme),
sidebarCollapsed: parsed.sidebarCollapsed === true,
ignoredUpdateVersion:
typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '',
};
} catch {
return { ...DEFAULT_CONFIG };
}
}
function writePersisted(config: PersistedConfig): void {
const target = configFilePath();
fs.mkdirSync(path.dirname(target), { recursive: true });
fs.writeFileSync(target, JSON.stringify(config, null, 2), 'utf8');
/* ------------------------------------------------------------ 内存缓存 + 异步合并写
*
* 为什么不能每次 saveConfig 都同步读写盘(原来的写法):
* 侧栏折叠(`stores/ui.ts` 的 toggleSidebar)、主题切换、通知渠道、作业看板偏好、登录/登出
* 全都走 `saveConfig`,而它一次调用要做「readFileSync + JSON.parse + 8 个 normalize」**两遍**
* (开头读一遍、结尾 `getConfig()` 又读一遍)+ `writeFileSync` 一遍 + DPAPI 解密一遍,
* **全部同步跑在主进程主线程上**。主进程是 Electron 的消息泵进程,被它摁住的那一拍
* 渲染进程也跟着掉帧 —— 用户反馈的"点汉堡展开/收回侧栏时卡一下"就是这一拍。
*
* 现在:配置常驻内存(连已解密的 token 一起缓存),写盘改成**异步 + 合并** ——
* 同一拍内的多次改动只落最后一次,写入串行不会互相覆盖;退出前由 `flushConfigSync()` 兜底。
*
* **语义变化(有意为之)**:`saveConfig()` 返回时数据可能还没落盘(延迟 CONFIG_WRITE_DEBOUNCE_MS)。
* 正常退出(托盘 → 退出)会走 `flushConfigSync()`,不会丢最后一次改动;只有进程被强杀
* 才可能丢这最后 120ms 内的改动。
*/
/** 写入合并窗口:拖动同类控件(滑块/连续点击)时的连续改动只落最后一次 */
const CONFIG_WRITE_DEBOUNCE_MS = 120;
interface ConfigCache {
config: PersistedConfig;
/** 已解密的登录令牌(null = 未登录 / 解密失败)。缓存它是为了不再每次都走一遍 DPAPI */
token: string | null;
}
/**
* 内存配置。**必须懒初始化**:`main/index.ts` 会先 `app.setPath('userData', smokeProfile)`
* 把配置目录指到冒烟专用 profile,之后才第一次读配置;在模块顶层立即读盘会读错文件。
*/
let cache: ConfigCache | null = null;
function loadCache(): ConfigCache {
if (cache) return cache;
const config = readPersistedFromDisk();
cache = { config, token: decryptToken(config) };
return cache;
}
/** 待写入的配置快照(合并窗口内只保留最后一次) */
let pendingWrite: PersistedConfig | null = null;
let writeTimer: ReturnType<typeof setTimeout> | null = null;
/** 是否有一次异步写入正在进行(用它把写入串行化,避免两次写互相覆盖) */
let writing = false;
/** 排一次落盘。合并窗口内的多次调用只会写最后一次;`immediate` 用于不该被延迟的场景 */
function scheduleWrite(config: PersistedConfig, immediate = false): void {
pendingWrite = config;
if (immediate) {
if (writeTimer !== null) {
clearTimeout(writeTimer);
writeTimer = null;
}
void flushConfigAsync();
return;
}
// 已有待写任务(定时器在跑或正在写):内容已经记在 pendingWrite 里,等它自己收尾即可。
// 正在写的那次结束后会再检查一次 pendingWrite(见 flushConfigAsync 的 finally)。
if (writeTimer !== null || writing) return;
writeTimer = setTimeout(() => {
writeTimer = null;
void flushConfigAsync();
}, CONFIG_WRITE_DEBOUNCE_MS);
}
/** 异步落盘(不阻塞主进程消息泵)。失败只记日志 —— 与 readPersistedFromDisk 的容错口径一致 */
async function flushConfigAsync(): Promise<void> {
if (writing) return;
const target = pendingWrite;
if (!target) return;
pendingWrite = null;
writing = true;
// 先把内容序列化下来:写入期间的后续改动由 pendingWrite 负责补一次,
// 不会出现"这一次写的是半新半旧的对象"
const payload = JSON.stringify(target, null, 2);
try {
const file = configFilePath();
await fs.promises.mkdir(path.dirname(file), { recursive: true });
await fs.promises.writeFile(file, payload, 'utf8');
} catch (error) {
logger.warn(`写入配置失败(${configFilePath()}):${error instanceof Error ? error.message : error}`);
} finally {
writing = false;
// 写入期间又有改动:再写一次(内容已被 pendingWrite 合并成最后一次)
if (pendingWrite) void flushConfigAsync();
}
}
/**
* 退出前同步落地一次(`main/index.ts` 的 shutdownResources 调用)。
*
* 即使没有待写内容也把内存里的配置重写一遍:那一时刻可能正有一次异步写在进行中,
* 它未必来得及完成;退出时多写一次的代价可以忽略。
*/
export function flushConfigSync(): void {
if (writeTimer !== null) {
clearTimeout(writeTimer);
writeTimer = null;
}
const target = pendingWrite ?? cache?.config ?? null;
if (!target) return;
pendingWrite = null;
try {
const file = configFilePath();
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, JSON.stringify(target, null, 2), 'utf8');
} catch (error) {
logger.warn(
`退出前写入配置失败(${configFilePath()}):${error instanceof Error ? error.message : error}`,
);
}
}
/**
@@ -212,23 +341,33 @@ function normalizeServerUrl(input: string): string | null {
}
}
/** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig {
const persisted = readPersisted();
/** 由内存缓存组装对渲染进程暴露的配置(不再读盘、不再解密) */
function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
const persisted = state.config;
return {
serverUrl: persisted.serverUrl,
username: persisted.username,
token: decryptToken(persisted),
token: state.token,
island: normalizeIslandAppearance(persisted.island),
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
onboardingDone: persisted.onboardingDone === true,
theme: normalizeTheme(persisted.theme),
sidebarCollapsed: persisted.sidebarCollapsed === true,
ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '',
};
}
/** 读取配置(token 已解密),供渲染进程使用 */
export function getConfig(): DesktopStoredConfig {
return toDesktopConfig(loadCache());
}
/** 局部更新配置:只覆盖传入的字段 */
export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredConfig {
const persisted = readPersisted();
const state = loadCache();
const persisted = state.config;
if (typeof patch.serverUrl === 'string' && patch.serverUrl.trim()) {
const normalized = normalizeServerUrl(patch.serverUrl);
@@ -253,20 +392,39 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.notificationChannel !== undefined) {
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
}
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
if (patch.onboardingDone === true) {
persisted.onboardingDone = true;
}
if (patch.theme !== undefined) {
persisted.theme = normalizeTheme(patch.theme);
}
if (patch.sidebarCollapsed !== undefined) {
persisted.sidebarCollapsed = patch.sidebarCollapsed === true;
}
// 空串是合法值(= 没有忽略任何版本),因此这里只判类型不判空
if (typeof patch.ignoredUpdateVersion === 'string') {
persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion;
}
if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token;
persisted.tokenEncrypted = encrypted.tokenEncrypted;
// 明文一并进缓存:既省掉一次 DPAPI 解密,也保证紧接着的 getConfig() 读到的就是刚存的令牌
state.token = patch.token;
}
writePersisted(persisted);
return getConfig();
scheduleWrite(persisted);
return toDesktopConfig(state);
}
/** 清空为默认配置(退出登录 / 重置) */
/** 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟 */
export function clearConfig(): DesktopStoredConfig {
writePersisted({ ...DEFAULT_CONFIG });
return getConfig();
const state: ConfigCache = { config: { ...DEFAULT_CONFIG }, token: null };
cache = state;
scheduleWrite(state.config, true);
return toDesktopConfig(state);
}
export function getConfigPath(): string {
+19 -5
View File
@@ -1,11 +1,13 @@
import path from 'node:path';
import { pathToFileURL } from 'node:url';
import { BrowserWindow, app, shell } from 'electron';
import { flushConfigSync, getConfig } from './config.js';
import { registerIpcHandlers } from './ipc.js';
import { island, registerIslandIpc } from './island.js';
import { logger } from './logger.js';
import { runSmokeTest } from './smoke.js';
import { createTray, destroyTray, isTrayReady } from './tray.js';
import { scheduleStartupUpdateCheck } from './update.js';
// 主进程由 esbuild 打包为 CommonJS,因此这里可以直接使用 __dirname
// (dist/main/index.js -> dist/preload/index.js 与 dist/renderer/index.html)
@@ -49,16 +51,20 @@ function isAllowedNavigation(url: string): boolean {
}
function createWindow(): BrowserWindow {
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
const theme = getConfig().theme;
const win = new BrowserWindow({
width: 1200,
height: 800,
minWidth: 980,
minHeight: 640,
// 初始尺寸与 ClassIsland 主窗口对齐(实测其可视区 1242x582),保持同一观感
width: 1242,
height: 582,
minWidth: 1000,
minHeight: 540,
center: true,
title: '班级小助手',
// 冒烟验证时不弹窗,避免打扰使用者
show: !isSmokeTest,
autoHideMenuBar: true,
backgroundColor: '#f5f7fa',
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
webPreferences: {
preload: path.join(currentDir, '../preload/index.js'),
contextIsolation: true,
@@ -158,6 +164,9 @@ if (!gotLock) {
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
}
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
@@ -185,6 +194,11 @@ if (!gotLock) {
*/
function shutdownResources(): string {
const done: string[] = [];
// 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次,
// 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。
// 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。
flushConfigSync();
done.push('配置已落盘');
if (isTrayReady()) {
destroyTray();
done.push('托盘已销毁');
+20
View File
@@ -1,5 +1,7 @@
import { app, ipcMain, shell } from 'electron';
import type { UpdateInfo } from '@classhelper/shared';
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
import { checkForUpdates, ignoreUpdateVersion } from './update.js';
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */
@@ -9,6 +11,8 @@ export const IPC_CHANNELS = {
clearConfig: 'classhelper:config:clear',
appInfo: 'classhelper:app:info',
openExternal: 'classhelper:shell:open-external',
updateCheck: 'classhelper:update:check',
updateIgnore: 'classhelper:update:ignore',
} as const;
export function registerIpcHandlers(): void {
@@ -30,6 +34,7 @@ export function registerIpcHandlers(): void {
node: process.versions.node ?? '',
platform: `${process.platform} ${process.arch}`,
userDataPath: app.getPath('userData'),
configPath: getConfigPath(),
smokeTest: process.env.ELECTRON_SMOKE_TEST === '1',
}));
@@ -38,6 +43,21 @@ export function registerIpcHandlers(): void {
await shell.openExternal(url);
return true;
});
/**
* 检查更新(走主进程直连 GitHub,见 main/update.ts 的说明)。
*
* `force` 为 true 时绕过主进程缓存 —— 对应「关于」页里用户主动点的那次检查;
* 启动自动检查一律用缓存(避免同一分钟内重复请求打满匿名限流)。
*/
ipcMain.handle(IPC_CHANNELS.updateCheck, (_event, force?: boolean): Promise<UpdateInfo> => {
return checkForUpdates(force === true);
});
/** 忽略某个版本的更新提示(记入 config.json,启动自动检查不再提示它) */
ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => {
if (typeof version === 'string') ignoreUpdateVersion(version);
});
}
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
+34 -12
View File
@@ -958,9 +958,9 @@ class IslandController {
}
/** 当前应该把岛放在哪块屏上:跟随鼠标时用光标所在屏,否则用主屏 */
private targetDisplay(): Electron.Display {
private targetDisplay(cursor?: { x: number; y: number } | null): Electron.Display {
return this.appearance.followCursorDisplay
? screen.getDisplayNearestPoint(this.cursorOverride ?? screen.getCursorScreenPoint())
? screen.getDisplayNearestPoint(cursor ?? this.cursorOverride ?? screen.getCursorScreenPoint())
: screen.getPrimaryDisplay();
}
@@ -970,10 +970,13 @@ class IslandController {
* 原先锚点只在 `setAppearance()` 里算一次,于是"跟随鼠标屏幕"实际上**只在改设置的那一瞬间**
* 生效:讲台从投影切回来之后,岛还留在原来那块屏上,直到用户再动一次外观设置。
* 这里挂在已有的 60ms 命中轮询上(不新增定时器),并且只在目标屏幕真的变了、且窗口可见时才动窗口。
*
* 不需要额外监听 `screen` 的 display-* 事件:目标屏是由**当前光标位置**或主屏推出来的,
* 插拔/切主屏之后下一轮 tick 自然会算出不同的 id,一样能触发重排。
*/
private syncAnchorDisplay(): void {
private syncAnchorDisplay(cursor?: { x: number; y: number } | null): void {
if (!this.win || this.win.isDestroyed()) return;
const displayId = this.targetDisplay().id;
const displayId = this.targetDisplay(cursor).id;
if (this.anchorDisplayId === displayId) return;
this.anchorDisplayId = displayId;
// 换屏了:可用高度(工作区)也跟着换,列表"能放几行"要重算
@@ -1409,11 +1412,14 @@ class IslandController {
/**
* 光标相对窗口左上角的本地坐标(CSS px / DIP,与渲染进程上报的岛体矩形同一坐标系)。
* 冒烟里用 `setHitTestCursor` 注入,真实场景读系统光标。
*
* `cursor`:调用方在本轮 tick 里已经读过的光标位置(见 `startHitPoll`)。
* 传进来就复用它,避免同一个 tick 里重复调 `screen.getCursorScreenPoint()`。
*/
private cursorLocal(): { x: number; y: number } | null {
private cursorLocal(cursor?: { x: number; y: number } | null): { x: number; y: number } | null {
if (!this.win || this.win.isDestroyed()) return null;
const bounds = this.win.getBounds();
const point = this.cursorOverride ?? screen.getCursorScreenPoint();
const point = cursor ?? this.cursorOverride ?? screen.getCursorScreenPoint();
return { x: point.x - bounds.x, y: point.y - bounds.y };
}
@@ -1494,7 +1500,7 @@ class IslandController {
}
/** 按光标位置校正命中(不依赖渲染进程是否收到 mousemove) */
private syncHitFromCursor(): void {
private syncHitFromCursor(cursor?: { x: number; y: number } | null): void {
if (!this.win || this.win.isDestroyed() || !this.win.isVisible()) {
this.setInteractive(false, '窗口不可见');
return;
@@ -1505,18 +1511,34 @@ class IslandController {
this.setInteractive(false, '隐藏态');
return;
}
this.setInteractive(this.isInsideHitRect(this.cursorLocal()), '光标轮询');
this.setInteractive(this.isInsideHitRect(this.cursorLocal(cursor)), '光标轮询');
}
private startHitPoll(): void {
if (this.hitTimer) return;
// 60ms:渲染进程的 mousemove 转发在 Windows 上并不可靠,这里是命中的**权威来源**,
// 间隔必须足够小,否则"指针移上胶囊后立刻点击"会因窗口还没接收鼠标而点空(用户反馈过)。
//
// 每一跳的成本要压到最低(这个定时器从岛初始化一直跑到进程退出):
// 1) 光标位置**每跳只读一次**,命中判定与"跟随鼠标屏幕"共用 —— 原先 cursorLocal() 与
// targetDisplay() 各读一次,等于每跳两次 `screen.getCursorScreenPoint()`;
// 2) 窗口不可见时直接返回:此时命中判定与心跳检查本来就各自早退(窗口不可见 ⇒ 无命中可言),
// 留着那两次系统调用纯属白跑。
this.hitTimer = setInterval(() => {
if (!this.win || this.win.isDestroyed()) return;
const visible = this.win.isVisible();
// 不可见、且不跟随鼠标屏幕时,连读光标都可省掉(targetDisplay 会走主屏分支)
const cursor =
visible || this.appearance.followCursorDisplay
? (this.cursorOverride ?? screen.getCursorScreenPoint())
: null;
// 先按目标屏幕校正窗口位置(换屏/插拔显示器),再算命中与心跳:
// 顺序不能反,否则命中判定会用上一块屏的窗口坐标
this.syncAnchorDisplay();
this.syncHitFromCursor();
this.syncAnchorDisplay(cursor);
if (!visible) return;
this.syncHitFromCursor(cursor);
this.checkRendererAlive();
}, 60);
// 定时器不应拖住进程退出
@@ -1748,12 +1770,12 @@ const MIN_CARD_HEIGHT = 120;
/**
* 渲染进程心跳(渲染进程每 5s 上报一次):超过这个时间没上报就认为它已经卡死/不响应。
* 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`):
* 超过这个时间没上报就认为它已经卡死/不响应。
* 岛是"无边框透明置顶窗口",渲染进程一旦挂掉,Windows 会把**最后一帧留在屏幕上**
* 形成"幽灵胶囊":窗口还在、看着正常,但怎么点都没反应(实测复现:崩掉渲染进程后
* 点击完全不产生任何动作)。所以必须有心跳 + 重建来兜底。
*/
const RENDERER_ALIVE_INTERVAL_MS = 5_000;
const RENDERER_ALIVE_TIMEOUT_MS = 20_000;
/** 窗口重建限流:一段时间内最多重建几次,避免持续崩坏时无限重建 */
const RECOVER_MAX_TIMES = 3;
+229 -26
View File
@@ -15,6 +15,7 @@ import {
ISLAND_URGENT_SIZE,
} from './island.js';
import { destroyTray, getTrayState, isTrayReady } from './tray.js';
import { checkForUpdates, wasStartupCheckSkipped } from './update.js';
interface SmokeResult {
name: string;
@@ -555,11 +556,26 @@ async function diagnoseClosingFlash(label: string, withTouch: boolean): Promise<
);
if (buttonPoint) {
const bounds = win.getBounds();
island.setHitTestCursor({ x: Math.round(bounds.x + buttonPoint.x), y: Math.round(bounds.y + buttonPoint.y) });
island.setHitTestCursor({
x: Math.round(bounds.x + buttonPoint.x),
y: Math.round(bounds.y + buttonPoint.y),
});
await sleep(160);
win.webContents.sendInputEvent({ type: 'mouseDown', x: buttonPoint.x, y: buttonPoint.y, button: 'left', clickCount: 1 });
win.webContents.sendInputEvent({
type: 'mouseDown',
x: buttonPoint.x,
y: buttonPoint.y,
button: 'left',
clickCount: 1,
});
await sleep(40);
win.webContents.sendInputEvent({ type: 'mouseUp', x: buttonPoint.x, y: buttonPoint.y, button: 'left', clickCount: 1 });
win.webContents.sendInputEvent({
type: 'mouseUp',
x: buttonPoint.x,
y: buttonPoint.y,
button: 'left',
clickCount: 1,
});
}
const deadline = Date.now() + 1600;
let index = 0;
@@ -581,14 +597,8 @@ async function diagnoseClosingFlash(label: string, withTouch: boolean): Promise<
const crop = image.crop({
x: Math.max(0, Math.round((geometry.window.x - pad) * shotScale)),
y: Math.max(0, Math.round((geometry.window.y - pad) * shotScale)),
width: Math.min(
image.getSize().width,
Math.round((geometry.window.width + pad * 2) * shotScale),
),
height: Math.min(
image.getSize().height,
Math.round((geometry.window.height + pad * 2) * shotScale),
),
width: Math.min(image.getSize().width, Math.round((geometry.window.width + pad * 2) * shotScale)),
height: Math.min(image.getSize().height, Math.round((geometry.window.height + pad * 2) * shotScale)),
});
fs.writeFileSync(path.join(dir, `${label}-${String(index).padStart(2, '0')}.png`), crop.toPNG());
lines.push(
@@ -1132,8 +1142,20 @@ async function runIslandChecks(
* (他常是 top-center + 较大 marginY,验证实例是 top-center + marginY=8)—— 两个岛一高一低叠在一起,
* 用户会以为"屏幕上出现了第二个灵动岛",点它又"反应不对"(它归验证脚本控制,会自己展开/收起)。
* 只改**颜色**(不动停靠位置):命中/几何断言全部按默认位置写过,挪位置会把它们弄红(实测踩过)。
*
* 例外:采集产品留档图时这两个值可以临时换成产品默认外观 ——
* `ELECTRON_SMOKE_ISLAND_ACCENT` / `ELECTRON_SMOKE_ISLAND_STYLE`(见 website/tools/capture-shots.mjs)。
* 留档图要的是"产品长什么样",不是"验证实例长什么样"。**日常跑验证不要设它们。**
*/
island.setAppearance({ accent: '#e91e8c', style: 'tinted' });
const shotAppearance: Parameters<typeof island.setAppearance>[0] = {
accent: process.env.ELECTRON_SMOKE_ISLAND_ACCENT ?? '#e91e8c',
};
if (process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'black' || process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'glass') {
shotAppearance.style = process.env.ELECTRON_SMOKE_ISLAND_STYLE;
} else if (!process.env.ELECTRON_SMOKE_ISLAND_STYLE) {
shotAppearance.style = 'tinted';
}
island.setAppearance(shotAppearance);
/**
* 强制关掉触摸模式:本节的命中/穿透/窗口几何断言全部是按"鼠标模式"写的(用
* `setHitTestCursor` 注入光标位置代替挪动真实鼠标)。若跑在带触摸屏的机器上,
@@ -2087,7 +2109,8 @@ async function runIslandChecks(
let count = 0;
for (let y = Math.max(0, fromY); y < Math.min(collapseShotSize.height, toY); y += 2) {
for (let x = Math.max(0, fromX); x < Math.min(collapseShotSize.width, toX); x += 2) {
if (x >= cardBox.x && x <= cardBox.x + cardBox.w && y >= cardBox.y && y <= cardBox.y + cardBox.h) continue;
if (x >= cardBox.x && x <= cardBox.x + cardBox.w && y >= cardBox.y && y <= cardBox.y + cardBox.h)
continue;
const index = (y * collapseShotSize.width + x) * 4;
if (index + 3 >= bitmap.length) continue;
const b = bitmap[index];
@@ -3515,6 +3538,94 @@ async function runIslandChecks(
* "HTTP 发布 → Socket.IO 广播 → 渲染进程 realtime store → bridge → IPC → 灵动岛"全链路,
* 并在灵动岛里真实点击"标为已读",确认通知中心的未读状态同步更新。
*/
/**
* 客户端逐页留档(官网素材用)。只有设了 `ELECTRON_CLIENT_SHOTS_DIR` 才会调用。
*
* 页面顺序就是侧边栏的顺序,方便照着做图注;走的是 hash 路由,与真实点击菜单等价。
* 作业页带 `?date=` 深链 —— 当天通常没有作业,空看板当素材没意义(日期取自
* `GET /homeworks/days`,种子数据里只有 2026-09-27 有作业)。
*/
const CLIENT_PAGE_SHOTS: Array<{ name: string; hash: string }> = [
{ name: 'client-schedule', hash: '#/schedule' },
{ name: 'client-homeworks', hash: '#/homeworks?date=2026-09-27' },
{ name: 'client-notifications', hash: '#/notifications' },
{ name: 'client-grades', hash: '#/grades' },
{ name: 'client-settings-island', hash: '#/settings/island' },
{ name: 'client-settings-appearance', hash: '#/settings/appearance' },
];
/** 页面里飘着的 ElMessage 提示会挡内容,截之前先收掉 */
const STRIP_TOASTS = `document.querySelectorAll('.el-message, .el-notification').forEach((node) => node.remove()); true;`;
/** 主内容区的纯文本长度(侧栏不算):用来判断页面到底渲染出来没有 */
const MAIN_TEXT_LENGTH = `((document.querySelector('.el-main.main') ?? document.body).innerText ?? '').trim().length`;
/**
* 截图前把 CSS 动画与过渡停掉。
*
* 为什么必须:客户端每个页面有「错峰入场」动画(styles/index.css 的 `.page > *`),
* 起始帧是 `opacity: 0`。窗口被别的窗口盖住时 Chromium 会冻结 CSS 动画,
* 于是 DOM 里文本齐全(实测 3000 多字)、截出来却是**整片空白** —— 排查了半天。
* `animation: none` 之后元素回落到自己的基础样式(可见),截图也顺带变成确定性的一帧。
*/
const FREEZE_ANIMATIONS = `(() => {
const id = 'smoke-shot-freeze';
if (!document.getElementById(id)) {
const style = document.createElement('style');
style.id = id;
style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; }';
document.head.append(style);
}
return true;
})()`;
const UNFREEZE_ANIMATIONS = `(() => {
document.getElementById('smoke-shot-freeze')?.remove();
return true;
})()`;
async function captureClientPages(win: BrowserWindow, dir: string): Promise<void> {
fs.mkdirSync(dir, { recursive: true });
await win.webContents.executeJavaScript(FREEZE_ANIMATIONS);
for (const page of CLIENT_PAGE_SHOTS) {
try {
for (let attempt = 1; attempt <= 3; attempt += 1) {
// 连点两次同样的 hash 不会触发路由,先回课表再过去,保证每次都真的重新渲染
await win.webContents.executeJavaScript(`location.hash = '#/schedule'; true;`);
await sleep(350);
await win.webContents.executeJavaScript(`location.hash = ${JSON.stringify(page.hash)}; true;`);
await sleep(1200);
await win.webContents.executeJavaScript(STRIP_TOASTS);
await sleep(200);
// 懒加载的视图偶尔会来不及挂上,量到空内容就重来一次
const length = await win.webContents.executeJavaScript(MAIN_TEXT_LENGTH);
if (typeof length === 'number' && length >= 60) break;
if (attempt === 3) {
console.warn(`[SHOTS] ${page.name} 内容区一直是空的(最后一次 ${length} 字),跳过`);
break;
}
await sleep(900);
}
const length = await win.webContents.executeJavaScript(MAIN_TEXT_LENGTH);
if (typeof length !== 'number' || length < 60) continue;
const image = await win.webContents.capturePage();
if (image.isEmpty()) {
console.warn(`[SHOTS] ${page.name} 截到空图,跳过`);
continue;
}
const file = path.join(dir, `${page.name}.png`);
fs.writeFileSync(file, image.toPNG());
console.log(`[SHOTS] ${page.name} → ${file}(内容 ${length} 字)`);
} catch (error) {
console.warn(`[SHOTS] ${page.name} 截图失败`, error);
}
}
await win.webContents.executeJavaScript(UNFREEZE_ANIMATIONS);
}
async function runIslandRealtimeCheck(
win: BrowserWindow,
): Promise<{ delivered: boolean; deliveryDetail: string; markRead: boolean; markReadDetail: string }> {
@@ -3712,7 +3823,7 @@ async function runSettingsPagePasswordEntryCheck(
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
location.hash = '#/schedule';
await wait(250);
location.hash = '#/settings';
location.hash = '#/settings/account';
let card = null;
for (let i = 0; i < 40 && !card; i += 1) {
await wait(100);
@@ -3756,7 +3867,7 @@ async function runSettingsPageAppearanceCheck(win: BrowserWindow): Promise<{ ok:
window.desktop.islandSetAppearance({ ...before, height: 44 });
await wait(300);
location.hash = '#/settings';
location.hash = '#/settings/island';
let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100);
@@ -3838,7 +3949,7 @@ async function runSettingsPageMarginCheck(
// 页面里会留着过期的位置(这正是"改了设置没反应"的一个真实来源)
location.hash = '#/schedule';
await wait(250);
location.hash = '#/settings';
location.hash = '#/settings/island';
let item = null;
for (let i = 0; i < 40 && !item; i += 1) {
await wait(100);
@@ -3880,15 +3991,15 @@ async function runSettingsPageMarginCheck(
.executeJavaScript(
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
location.hash = '#/schedule';
await wait(200);
location.hash = '#/settings';
let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100);
const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
);
location.hash = '#/schedule';
await wait(200);
location.hash = '#/settings/island';
let runway = null;
for (let i = 0; i < 40 && !runway; i += 1) {
await wait(100);
const item = Array.from(document.querySelectorAll('.el-form-item')).find((node) =>
(node.querySelector('.el-form-item__label')?.textContent ?? '').startsWith('左右边距'),
);
runway = item?.querySelector('.el-slider__runway') ?? null;
}
if (!runway) return { ok: false, detail: '设置页里找不到「左右边距」滑块' };
@@ -3986,7 +4097,7 @@ async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: bo
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const before = (await window.desktop.getConfig()).notificationChannel;
location.hash = '#/settings';
location.hash = '#/settings/reminder';
// 找到「通知显示位置」那张卡片里的单选组
let group = null;
@@ -4151,6 +4262,28 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
`text=${dom?.text ?? ''}`,
);
// 初始窗口尺寸:与 ClassIsland 主窗口对齐(实测其可视区 1242x582,见 main/index.ts)
const [initialWidth, initialHeight] = win.getSize();
record(
'初始窗口尺寸与 ClassIsland 对齐(1242x582)',
initialWidth === 1242 && initialHeight === 582,
`实际 ${initialWidth}x${initialHeight}`,
);
// 初次启动引导(新装客户端首启自动弹出):真实点击走完一遍,断言完成后写入配置。
// 必须放在"登录页不含示例内容"的全文扫描**之前**——那时引导还开着,先把它的文案从屏幕上撤走。
const onboarding = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.onboardingSelfTest) return { ok: false, detail: '缺少引导自检钩子' };
return await window.__classhelperSmoke__.onboardingSelfTest();
})()`,
);
record(
'初次启动引导(首启自动弹出、可走完、状态写入配置)',
Boolean(onboarding?.ok),
String(onboarding?.detail ?? ''),
);
// 需求:"删掉登录页所有示例内容" —— 占位符/提示里不允许出现示例班级码或示例账号。
// 判据不写死具体示例码:用"演示/示例"字样 + 旧版占位符的**形状**(例如 G101)+ 环境变量传入的凭据,
// 这样断言更严(任何"例如 XXX123"都会被抓到),安装包里也不留示例账号字面量。
@@ -4308,6 +4441,65 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
);
record('侧边栏点击导航(逐一点击 5 个菜单)', Boolean(navigation?.ok), String(navigation?.detail ?? ''));
// 主界面外观件(新功能回归):真实点击主题日月按钮与侧边栏汉堡按钮,
// 断言 html.dark 翻转、collapse 态生效、两者都写入主进程配置;结束时还原主题
const chrome = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.layoutChromeSelfTest) return { ok: false, detail: '缺少外观件自检钩子' };
return await window.__classhelperSmoke__.layoutChromeSelfTest();
})()`,
);
record('主题切换与侧边栏折叠(真实点击、写回配置)', Boolean(chrome?.ok), String(chrome?.detail ?? ''));
// 关于页(新功能回归):/settings/about 渲染 + 诊断信息(getAppInfo 含 configPath)
const aboutPage = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__?.aboutPageSelfTest) return { ok: false, detail: '缺少关于页自检钩子' };
return await window.__classhelperSmoke__.aboutPageSelfTest();
})()`,
);
record('关于页渲染(应用信息/诊断信息/鸣谢)', Boolean(aboutPage?.ok), String(aboutPage?.detail ?? ''));
// 更新检查(新功能回归):主进程直连 GitHub 取最新 Release。
//
// **刻意不断言"一定有新版本"**:那会把冒烟绑死在外网可达上,而教室机器常常没有外网
// (`ok=false` 是设计内的降级结果,不是失败)。因此两种结果都算通过,验的是:
// 结构完整、失败时有可读原因、以及缓存真的生效。
const updateFirst = await checkForUpdates(true);
const isVersionLike = (value: unknown): boolean => typeof value === 'string' && /^\d/.test(value);
const updateShapeOk =
isVersionLike(updateFirst.currentVersion) &&
typeof updateFirst.ok === 'boolean' &&
typeof updateFirst.hasUpdate === 'boolean' &&
(updateFirst.ok
? isVersionLike(updateFirst.latestVersion) &&
Array.isArray(updateFirst.assets) &&
/^https:\/\//.test(updateFirst.releaseUrl) &&
updateFirst.error === null
: typeof updateFirst.error === 'string' && updateFirst.error.length > 0);
record(
'更新检查返回结构正确(无外网时降级为 ok=false 且带原因)',
updateShapeOk,
`ok=${updateFirst.ok} 本机=${updateFirst.currentVersion} 最新=${updateFirst.latestVersion ?? '-'} ` +
`有新版本=${updateFirst.hasUpdate} 附件=${updateFirst.assets.length}` +
`${updateFirst.error ? ` 原因=${updateFirst.error}` : ''}`,
);
// 缓存:紧接着再查一次(不传 force)应命中缓存 —— checkedAt 不变即说明没有二次请求
const updateSecond = await checkForUpdates();
record(
'更新检查结果带缓存(不重复请求 GitHub)',
updateSecond.checkedAt === updateFirst.checkedAt,
`两次 checkedAt 相同=${updateSecond.checkedAt === updateFirst.checkedAt}`,
);
// 启动自动检查在冒烟模式下必须被跳过,否则每次验证都会产生一次真实网络请求与提示条
record(
'冒烟模式下跳过启动自动检查(验证不依赖外网)',
wasStartupCheckSkipped(),
`skipped=${wasStartupCheckSkipped()}`,
);
// 未读红点必须挂在"通知"图标右上方(用户反馈:原来挂在文字后面,位置不对)
const readBadgeGeometry = async (): Promise<{
ok: boolean;
@@ -4362,6 +4554,17 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
);
if (injectedId) await deleteSmokeNotification(injectedId);
/**
* 官网素材:逐页留档。只有设了 ELECTRON_CLIENT_SHOTS_DIR 才跑 —— 日常验证不受影响。
*
* 位置两头的约束把这里夹死了:
* - 必须在**收尾清理登录态之前**,否则截出来的是登录页 + 缓存数据;
* - 必须在**所有 UI 断言之后**:翻页会改变侧栏分组展开态,紧接着测「折叠后图标与汉堡同列」
* 就会量到过渡中间值(实测:宽度 200→77、中心差 3.2px,把那条用例弄红了)。
*/
const clientShotsDir = process.env.ELECTRON_CLIENT_SHOTS_DIR ?? '';
if (clientShotsDir) await captureClientPages(win, clientShotsDir);
const cleanup = await win.webContents.executeJavaScript(
`(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
+215
View File
@@ -0,0 +1,215 @@
import { app, type BrowserWindow } from 'electron';
import {
GITHUB_RELEASES_LATEST_API,
GITHUB_RELEASES_PAGE,
UPDATE_CHECK_CACHE_TTL_MS,
UPDATE_CHECK_TIMEOUT_MS,
UPDATE_CHECK_USER_AGENT,
isNewerVersion,
normalizeVersion,
type UpdateAssetDto,
type UpdateInfo,
} from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js';
import { logger } from './logger.js';
/**
* 更新检查(桌面客户端)。
*
* **为什么在主进程做**:渲染进程通过 preload 能拿到 `getConfig()` 的明文 token(代表全班身份),
* 它的 CSP 与导航防线是整个安全模型里最要紧的一层;让它额外去连一个外部域名、
* 解析外部 JSON,等于把"外部数据"请进最有价值的那一侧。主进程本来就在做网络与文件,
* 放这里也顺带复用 Node 的 fetch(无需额外依赖)。
*
* 与 Web 管理端的差别:Web 端受服务端 CSP 的 `connect-src` 限制,只能走 `/api/update/check` 转发;
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
*/
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示) */
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
const STARTUP_DELAY_MS = 5_000;
interface GithubReleasePayload {
tag_name?: unknown;
name?: unknown;
published_at?: unknown;
body?: unknown;
html_url?: unknown;
assets?: unknown;
}
let cache: { at: number; info: UpdateInfo } | null = null;
let startupTimer: ReturnType<typeof setTimeout> | null = null;
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下必须是 true) */
let startupCheckSkipped = false;
/** 失败的统一出口:结构完整,界面不必做 null 判断 */
function failure(error: string): UpdateInfo {
return {
ok: false,
error,
currentVersion: app.getVersion(),
latestVersion: null,
hasUpdate: false,
releaseUrl: GITHUB_RELEASES_PAGE,
releaseName: null,
publishedAt: null,
notes: null,
assets: [],
checkedAt: new Date().toISOString(),
};
}
function toAssets(raw: unknown): UpdateAssetDto[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown })
.filter(
(item): item is { name: string; browser_download_url: string; size?: unknown } =>
typeof item.name === 'string' && typeof item.browser_download_url === 'string',
)
.map((item) => ({
name: item.name,
downloadUrl: item.browser_download_url,
size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0,
}));
}
function toInfo(payload: GithubReleasePayload, currentVersion: string): UpdateInfo {
const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : '');
if (!latestVersion) {
logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name');
return failure('GitHub 返回的版本号无法识别');
}
return {
ok: true,
error: null,
currentVersion,
latestVersion,
hasUpdate: isNewerVersion(currentVersion, latestVersion),
releaseUrl:
typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE,
releaseName: typeof payload.name === 'string' ? payload.name : null,
publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null,
notes: typeof payload.body === 'string' ? payload.body : null,
assets: toAssets(payload.assets),
checkedAt: new Date().toISOString(),
};
}
/** 把各种失败翻译成一句用户能看懂的话(教室里最常见的就是"这台机器没有外网") */
function describeFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof Error && error.name === 'TimeoutError') {
return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`;
}
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate|net::/i.test(message)) {
return '无法连接 GitHub,本机可能没有外网';
}
return `检查更新失败:${message}`;
}
/**
* 检查 GitHub 上有没有比本机新的版本(结果带缓存)。
*
* 缓存的意义:匿名调用 GitHub API 的限流是 60 次/小时/IP,而「关于」页的按钮可以随便点。
* 失败结果同样进缓存 —— 否则离线时每点一次都要白等一个超时。
*/
export async function checkForUpdates(force = false): Promise<UpdateInfo> {
const currentVersion = app.getVersion();
const now = Date.now();
if (!force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) {
return cache.info;
}
let info: UpdateInfo;
try {
const response = await fetch(GITHUB_RELEASES_LATEST_API, {
headers: {
// GitHub API 强制要求 User-Agent,不带会直接 403
'User-Agent': UPDATE_CHECK_USER_AGENT,
Accept: 'application/vnd.github+json',
},
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
});
if (!response.ok) {
info =
response.status === 403 || response.status === 429
? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试')
: failure(`GitHub 返回 HTTP ${response.status}`);
logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${currentVersion})`);
} else {
info = toInfo((await response.json()) as GithubReleasePayload, currentVersion);
logger.info(
`更新检查:本机 ${currentVersion} / 最新 ${info.latestVersion ?? '-'}` +
`${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`,
);
}
} catch (error) {
info = failure(describeFailure(error));
logger.warn(`更新检查失败:${info.error}(本机版本 ${currentVersion})`);
}
cache = { at: now, info };
return info;
}
/** 「知道了 / 忽略此版本」:记到配置里,同一个版本不再重复弹提示 */
export function ignoreUpdateVersion(version: string): void {
const normalized = normalizeVersion(version);
if (!normalized) return;
saveConfig({ ignoredUpdateVersion: normalized });
logger.info(`已忽略版本 ${normalized} 的更新提示`);
}
/** 该版本是否已被用户忽略 */
export function isUpdateIgnored(version: string): boolean {
return normalizeVersion(getConfig().ignoredUpdateVersion) === normalizeVersion(version);
}
/**
* 启动后的自动检查:延迟 `STARTUP_DELAY_MS` 查一次,有新版本(且没被忽略)就通知渲染进程弹提示。
*
* **冒烟模式直接跳过**:自动化验证不该每次都发真实网络请求(既抖动又依赖外网),
* 而且凭空多出来的提示条会撞坏既有 UI 断言。冒烟用例改为直接调 IPC 验证返回结构。
*/
export function scheduleStartupUpdateCheck(getWindow: () => BrowserWindow | null): void {
if (process.env.ELECTRON_SMOKE_TEST === '1') {
startupCheckSkipped = true;
logger.info('更新检查:冒烟模式下跳过启动自动检查');
return;
}
if (startupTimer) clearTimeout(startupTimer);
startupTimer = setTimeout(() => {
startupTimer = null;
void (async () => {
try {
const info = await checkForUpdates();
if (!info.ok || !info.hasUpdate || !info.latestVersion) return;
if (isUpdateIgnored(info.latestVersion)) {
logger.info(`更新检查:${info.latestVersion} 已被用户忽略,本次不提示`);
return;
}
const win = getWindow();
if (!win || win.isDestroyed()) return;
win.webContents.send(UPDATE_AVAILABLE_CHANNEL, info);
logger.info(`更新检查:已提示新版本 ${info.latestVersion}`);
} catch (error) {
// 自动检查失败不该打扰用户(手动检查才需要看到原因)
logger.warn(`启动自动检查更新失败:${error instanceof Error ? error.message : error}`);
}
})();
}, STARTUP_DELAY_MS);
// 不阻止进程退出:这个定时器只是"顺便查一下"
startupTimer.unref?.();
}
/** 冒烟断言用:启动自动检查是否被跳过(冒烟模式下应为 true —— 否则每次验证都会发真实网络请求) */
export function wasStartupCheckSkipped(): boolean {
return startupCheckSkipped;
}
+11 -1
View File
@@ -1,5 +1,6 @@
import { contextBridge, ipcRenderer } from 'electron';
import { IPC_CHANNELS } from '../main/ipc.js';
import { UPDATE_AVAILABLE_CHANNEL } from '../main/update.js';
import type {
DesktopAppInfo,
DesktopBridge,
@@ -7,7 +8,7 @@ import type {
IslandClassStatePayload,
IslandPushContext,
} from '../types/desktop.js';
import type { IslandNotification } from '@classhelper/shared';
import type { IslandNotification, UpdateInfo } from '@classhelper/shared';
/**
* 预加载脚本:在开启了 contextIsolation + sandbox 的前提下,
@@ -37,6 +38,15 @@ const bridge: DesktopBridge = {
getAppInfo: (): Promise<DesktopAppInfo> => ipcRenderer.invoke(IPC_CHANNELS.appInfo),
openExternal: (url: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openExternal, url),
/* 更新检查(主进程直连 GitHub,见 main/update.ts) */
checkForUpdates: (force = false): Promise<UpdateInfo> =>
ipcRenderer.invoke(IPC_CHANNELS.updateCheck, force),
ignoreUpdateVersion: (version: string): Promise<void> =>
ipcRenderer.invoke(IPC_CHANNELS.updateIgnore, version),
onUpdateAvailable: (handler: (info: UpdateInfo) => void): void => {
ipcRenderer.on(UPDATE_AVAILABLE_CHANNEL, (_event, info: UpdateInfo) => handler(info));
},
islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => {
ipcRenderer.send('island:push', payload);
},
+128 -2
View File
@@ -1,24 +1,77 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue';
import { h, onMounted, onUnmounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElButton, ElNotification } from 'element-plus';
import type { UpdateInfo } from '@classhelper/shared';
import {
startIslandBridge,
stopIslandBridge,
subscribeIslandMarkAllRead,
subscribeIslandMarkRead,
} from './island/bridge.js';
import OnboardingWelcome from './components/OnboardingWelcome.vue';
import { useAppStore } from './stores/app.js';
import { useAuthStore } from './stores/auth.js';
import { useOnboardingStore } from './stores/onboarding.js';
import { useRealtimeStore } from './stores/realtime.js';
import { useUiStore } from './stores/ui.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const onboarding = useOnboardingStore();
const ui = useUiStore();
const realtime = useRealtimeStore();
const booting = ref(true);
const bootText = ref('正在读取本地配置...');
/**
* 启动自动检查发现新版本时的提示(主进程查出后推过来,见 main/update.ts)。
*
* **为什么订阅放在 App.vue**:`onUpdateAvailable` 底层是 `ipcRenderer.on`,
* 桥接层没有对应的 off —— 挂在会随"登出 → 登录"反复挂载的 ClientLayout 里,
* 处理器会越堆越多(与灵动岛订阅同一个坑)。App.vue 整个应用生命周期只挂载一次。
*
* 提示是**一次性**且可永久消音:点「忽略此版本」写进 config.json,同一版本不再提示;
* 「关于」页里仍可随时手动检查。
*/
function showUpdateAvailable(info: UpdateInfo): void {
if (!info.latestVersion) return;
ElNotification({
title: `发现新版本 v${info.latestVersion}`,
type: 'info',
// 不自动消失:这是需要用户决定的事(去下载 / 忽略),滑过去就只能靠「关于」页再想起来
duration: 0,
message: h('div', { style: 'line-height: 1.7' }, [
h('p', { style: 'margin: 0 0 10px' }, `当前版本 ${info.currentVersion},可前往下载更新。`),
h('div', { style: 'display: flex; gap: 8px' }, [
h(
ElButton,
{
size: 'small',
type: 'primary',
onClick: () => {
void window.desktop?.openExternal(info.releaseUrl);
},
},
() => '前往下载',
),
h(
ElButton,
{
size: 'small',
onClick: () => {
void window.desktop?.ignoreUpdateVersion(info.latestVersion ?? '');
},
},
() => '忽略此版本',
),
]),
]),
});
}
/**
* 启动流程:
* 1) 从主进程配置恢复服务器地址与登录令牌
@@ -27,6 +80,10 @@ const bootText = ref('正在读取本地配置...');
*/
onMounted(async () => {
try {
bootText.value = '正在读取本地配置...';
// 主题与侧栏折叠最先恢复:在首屏渲染前挂好 html.dark,避免"先白后黑"闪一下
await ui.init();
bootText.value = '正在恢复登录状态...';
const session = await auth.restore();
@@ -50,8 +107,22 @@ onMounted(async () => {
subscribeIslandMarkRead();
subscribeIslandMarkAllRead();
// 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable)
window.desktop?.onUpdateAvailable?.(showUpdateAvailable);
if (auth.isAuthenticated) await router.replace('/schedule');
else await router.replace('/login');
// 初次启动引导:配置里没记「已看过」就自动弹一次(完成/跳过后由引导组件写入配置)。
// 稍等一拍再弹:让登录页/主界面先渲染完,避免和启动过渡的收尾抢帧。
window.setTimeout(() => {
void window.desktop
?.getConfig()
.then((config) => {
if (config.onboardingDone !== true) onboarding.open();
})
.catch(() => undefined);
}, 400);
} finally {
booting.value = false;
}
@@ -66,9 +137,21 @@ onUnmounted(() => {
<div v-if="booting" class="boot-screen">
<el-icon :size="30" class="is-loading" color="#409eff"><Loading /></el-icon>
<p class="boot-title">班级小助手</p>
<p class="boot-text">{{ bootText }}</p>
<!--
启动文案会依次经过「读取本地配置 → 恢复登录状态」,直接换字是一下"啪"的跳变。
交叉淡入(旧的上移淡出、新的从下方淡入)把两次状态读成**同一行字在更新**,
而不是"屏幕闪了一下"。
`:duration` 是必需的(理由同 ClientLayout 的页面过渡):靠 `animationend` 判断的话,
窗口不可见时动画被冻结、事件不触发,文案会一直停在第一句。
-->
<transition name="boot-text" mode="out-in" :duration="{ enter: 200, leave: 90 }">
<p class="boot-text" :key="bootText">{{ bootText }}</p>
</transition>
</div>
<router-view v-else />
<!-- 初次启动引导:常驻挂载,由 onboarding store 控制显隐(登录页/主界面/设置页都能唤起) -->
<OnboardingWelcome v-if="!booting" />
</template>
<style scoped>
@@ -87,6 +170,19 @@ onUnmounted(() => {
margin: 6px 0 0;
font-size: 20px;
font-weight: 600;
/* 启动页是"整个应用的第一帧",给标题一点入场分量是值得的 */
animation: boot-title-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
}
@keyframes boot-title-in {
from {
opacity: 0;
transform: translateY(6px) scale(0.98);
}
to {
opacity: 1;
transform: none;
}
}
.boot-text {
@@ -94,4 +190,34 @@ onUnmounted(() => {
font-size: 13px;
color: rgba(255, 255, 255, 0.75);
}
@keyframes boot-text-in {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes boot-text-out {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: translateY(-4px);
}
}
.boot-text-enter-active {
animation: boot-text-in var(--ch-dur-base) var(--ch-ease-out) both;
}
.boot-text-leave-active {
animation: boot-text-out var(--ch-dur-instant) var(--ch-ease-out) both;
}
</style>
+340 -12
View File
@@ -93,19 +93,31 @@ async function waitUntil(predicate: () => boolean, timeoutMs = 3000, intervalMs
return predicate();
}
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配) */
/** 侧边栏菜单与期望路由的对应关系(按显示文案匹配;侧边栏是「分组 + 子菜单」结构) */
const EXPECTED_MENU: Array<{ label: string; path: string }> = [
{ label: '课表', path: '/schedule' },
{ label: '作业', path: '/homeworks' },
{ label: '通知', path: '/notifications' },
{ label: '成绩', path: '/grades' },
{ label: '设置', path: '/settings' },
/** 设置组的代表子项:设置入口改成分组后,用「通用」验证设置子页导航可用 */
{ label: '通用', path: '/settings/general' },
];
/**
* 冒烟自检 3:侧边栏点击导航。
* 直接对真实 DOM 触发 click,验证每次点击后路由与视图都发生切换 ——
* 这是"点击左侧边栏没反应"这类问题的回归测试。
* 一级菜单项:2026-10-05 起「学习」分组已拆掉,课表/作业/通知/成绩**直接铺在侧栏一级**。
* 下面的结构自检会确认它们真的不在任何 `el-sub-menu` 里。
*/
const PRIMARY_MENU_ITEMS = ['课表', '作业', '通知', '成绩'];
/** 分组标题(el-sub-menu)与应包含的子项,点击分组展开后再点子项。设置组自成一类,保留分组 */
const MENU_GROUPS: Array<{ title: string; labels: string[] }> = [
{ title: '设置', labels: ['通用', '外观', '灵动岛', '提醒', '账号', '关于'] },
];
/**
* 冒烟自检 3:侧边栏点击导航(分组结构版)。
* 先点分组标题展开,再逐个点击子项,验证每次点击后路由与视图都发生切换 ——
* 这是"点击左侧边栏没反应"这类问题的回归测试(侧边栏改为「学习/设置」分组后重写)。
*/
export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
const { router } = await import('../router/index.js');
@@ -113,20 +125,51 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
// 按**文案那一层 span 的完整文本**匹配,而不是整项 textContent:
// "通知"项里还有一个未读红点徽标(sup),整项 textContent 会变成 "2通知",
// 用 startsWith 匹配就会误报"菜单项缺失"(历史失败原因)。
const findMenuItem = (label: string): HTMLElement | undefined =>
(Array.from(document.querySelectorAll('.el-menu-item')) as HTMLElement[]).find((element) =>
Array.from(element.querySelectorAll('span')).some(
(span) => (span.textContent ?? '').trim() === label,
),
const findByLabel = (selector: string, label: string): HTMLElement | undefined =>
(Array.from(document.querySelectorAll(selector)) as HTMLElement[]).find((element) =>
Array.from(element.querySelectorAll('span')).some((span) => (span.textContent ?? '').trim() === label),
);
const findMenuItem = (label: string): HTMLElement | undefined => findByLabel('.el-menu-item', label);
const findGroupTitle = (title: string): HTMLElement | undefined =>
findByLabel('.el-sub-menu__title', title);
if (!findMenuItem('课表')) {
return { ok: false, detail: '侧边栏未渲染(当前不在主布局或未登录)' };
if (!findGroupTitle('设置')) {
return { ok: false, detail: '侧边栏「设置」分组未渲染(当前不在主布局或未登录)' };
}
const missingPrimary = PRIMARY_MENU_ITEMS.filter((label) => !findMenuItem(label));
if (missingPrimary.length > 0) {
return { ok: false, detail: `侧边栏缺少一级项:${missingPrimary.join('、')}` };
}
const visited: string[] = [];
const failures: string[] = [];
// 展开分组:默认只展开"当前路由所在分组",其它组需要先点开。
// 判据用"子项是否可见"而不是"盲点一次标题" —— 标题点击是 toggle,
// 对已经展开的组再点一下会把它**收起**,后续点不到子项。
const isVisible = (element: HTMLElement | undefined): boolean =>
Boolean(element && element.offsetParent !== null);
for (const group of MENU_GROUPS) {
const firstItem = findMenuItem(group.labels[0] ?? '');
if (!isVisible(firstItem)) {
findGroupTitle(group.title)?.click();
await new Promise((resolve) => setTimeout(resolve, 250));
}
}
// 结构自检:
// ① 一级项必须**真的在一级** —— 不能还挂在某个 el-sub-menu 里(用户要求把"学习"拆掉);
// ② 分组各自应包含全部子项
for (const label of PRIMARY_MENU_ITEMS) {
const element = findMenuItem(label);
if (element?.closest('.el-sub-menu')) failures.push(`${label}(仍挂在分组里,应在一级)`);
}
for (const group of MENU_GROUPS) {
for (const label of group.labels) {
if (!findMenuItem(label)) failures.push(`${group.title}组缺少子项 ${label}`);
}
}
for (const { label, path } of EXPECTED_MENU) {
const element = findMenuItem(label);
if (!element) {
@@ -818,6 +861,62 @@ export async function islandHomeworkSuppressionCheck(): Promise<SmokeCheckResult
}
}
/**
* 冒烟自检(不需要后端):**初次启动引导**。
*
* 冒烟每次都用全新配置目录(等于"首次启动"),因此引导必须自动弹出;
* 这里真实点击把它一步步走完,断言:遮罩关闭 + `onboardingDone` 已写入配置。
* 关闭判据用「元素从 DOM 消失」——引导是 v-if 自绘遮罩(特意不用 el-dialog,
* 避免"关闭后 DOM 残留在文档里"的误报,见 AGENTS §7 第 22 条)。
*/
export async function onboardingSelfTest(): Promise<SmokeCheckResult> {
try {
// 引导在启动后延迟 ~400ms 弹出,这里轮询等它出现
const appearDeadline = Date.now() + 5000;
while (Date.now() < appearDeadline && !document.querySelector('.onboarding-welcome')) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (!document.querySelector('.onboarding-welcome')) {
const config = await window.desktop?.getConfig?.();
return {
ok: false,
detail: `初次启动引导未自动出现(onboardingDone=${config?.onboardingDone ?? '-'})`,
};
}
// 逐页「下一步」到最后一页再点「开始使用」(按钮由 data-action 标识,不依赖文案)
const clicks: string[] = [];
for (let attempt = 0; attempt < 12; attempt += 1) {
const action = document.querySelector('.onboarding-welcome [data-action="finish"]') ? 'finish' : 'next';
const button = document.querySelector<HTMLElement>(`.onboarding-welcome [data-action="${action}"]`);
if (!button) break;
clicks.push(action);
button.click();
if (action === 'finish') break;
await new Promise((resolve) => setTimeout(resolve, 150));
}
let closed = !document.querySelector('.onboarding-welcome');
const closeDeadline = Date.now() + 2000;
while (Date.now() < closeDeadline && !closed) {
await new Promise((resolve) => setTimeout(resolve, 100));
closed = !document.querySelector('.onboarding-welcome');
}
const config = await window.desktop?.getConfig?.();
const done = config?.onboardingDone === true;
return {
ok: closed && done,
detail: `步骤点击=[${clicks.join(',')}] 已关闭=${closed} onboardingDone=${config?.onboardingDone ?? '-'}`,
};
} catch (error) {
return {
ok: false,
detail: `引导自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
@@ -834,6 +933,232 @@ export async function sessionCleanup(): Promise<SmokeCheckResult> {
}
}
/**
* 冒烟自检(不需要后端):主界面外观件 —— 主题切换与侧边栏折叠。
* 走**真实 DOM 点击**(顶栏日月按钮 / 汉堡按钮),验证:
* 1) html.dark 类与 store 同步翻转,且写入主进程配置;
* 2) 侧边栏折叠后 el-menu 进入 collapse 态、宽度收窄,配置同步;
* 结束时把主题还原为初始值(后续截图/几何用例依赖固定主题)。
*/
export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
try {
const { router } = await import('../router/index.js');
await router.push('/schedule');
if (!(await waitUntil(() => Boolean(document.querySelector('[data-test="theme-toggle"]')), 6000))) {
return { ok: false, detail: '主布局未渲染(找不到主题切换按钮)' };
}
const clickToggle = (selector: string): boolean => {
const node = document.querySelector<HTMLElement>(selector);
if (!node) return false;
node.click();
return true;
};
// 1) 主题:真实点击日月按钮 → html.dark 翻转 → 配置落盘
const initialDark = document.documentElement.classList.contains('dark');
if (!clickToggle('[data-test="theme-toggle"]')) return { ok: false, detail: '主题按钮点击失败' };
const flipped = await waitUntil(
() => document.documentElement.classList.contains('dark') !== initialDark,
2500,
);
const flippedDark = document.documentElement.classList.contains('dark');
const configAfterFlip = await window.desktop?.getConfig?.();
const themePersisted = configAfterFlip?.theme === (flippedDark ? 'dark' : 'light');
// 1.5) 顶栏右侧必须水平对齐(用户反馈"深色模式没对齐",两种主题其实都偏)。
// Element Plus 给按钮的是 `vertical-align:middle`、给下拉容器的是 `vertical-align:top`,
// 容器不是 flex 的话两个 inline-level 子元素会各按各的规则落位,错开几像素。
const centerYOf = (element: Element | null | undefined): number | null => {
if (!element) return null;
const rect = element.getBoundingClientRect();
return rect.height > 0 ? rect.top + rect.height / 2 : null;
};
const themeToggleY = centerYOf(document.querySelector('[data-test="theme-toggle"]'));
const userChipY = centerYOf(document.querySelector('.user-chip'));
const headerCenterDelta =
themeToggleY !== null && userChipY !== null ? Math.abs(themeToggleY - userChipY) : null;
const headerAligned = headerCenterDelta !== null && headerCenterDelta <= 1.5;
// 2) 侧边栏:点汉堡折叠 → 图标导轨态 + 宽度收窄(是动画,要等它走完)→ 配置落盘 → 再展开还原
const asideBefore = document.querySelector<HTMLElement>('.aside.ch-nav');
const widthBefore = asideBefore?.offsetWidth ?? 0;
// 2.0) 折叠必须是**动画**而不是瞬切(用户要求"重写菜单的动画")。
// 不去抓中间帧(时序敏感),直接断言侧栏上挂着 width 的过渡 —— 确定性的。
const asideComputed = asideBefore ? getComputedStyle(asideBefore) : null;
const asideTransitionProperty = asideComputed?.transitionProperty ?? '';
const asideTransitionSeconds = asideComputed ? Number.parseFloat(asideComputed.transitionDuration) : 0;
const widthAnimated =
asideTransitionProperty.includes('width') &&
Number.isFinite(asideTransitionSeconds) &&
asideTransitionSeconds > 0.05;
if (!clickToggle('[data-test="sidebar-toggle"]')) return { ok: false, detail: '汉堡按钮点击失败' };
/*
* 判据是「宽度收拢到**稳态**」而不是「宽度小于 80」。
*
* 折叠的目标宽度是 64px(ClientLayout 的 `:width="ui.sidebarCollapsed ? '64px' : '200px'"`),
* 而宽度与菜单项内边距是同一条过渡在驱动 —— 只等「≤80」会在动画还剩几十毫秒时就往下走,
* 下面量图标中心时内边距仍在变化,量到的是中间帧而非稳态(实测出现过 0.5px 与 2.3px 两个值,
* 全看当时机器有多忙)。等它真正到位之后再量,这条断言才是它想表达的"折叠后同列"。
*/
const collapsed = await waitUntil(() => {
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
return Boolean(aside?.classList.contains('is-collapsed')) && (aside?.offsetWidth ?? 999) <= 65;
}, 4000);
const asideAfter = document.querySelector<HTMLElement>('.aside.ch-nav');
const widthAfter = asideAfter?.offsetWidth ?? 0;
const configAfterCollapse = await window.desktop?.getConfig?.();
const collapsePersisted = configAfterCollapse?.sidebarCollapsed === true;
// 2.1) 折叠态的两条观感硬要求(用户反馈回归):
// a) 菜单里不允许残留任何**可见**的文字。折叠实现是"文字宽度被 flex 压到 0 + overflow:hidden
// 裁掉 + opacity 归零"(`display:none` 无法过渡,用不了),所以判据是"看得见"
// —— 有宽度且未透明 —— 而不是"根本没参与布局";
// b) 折叠后的图标必须与顶部汉堡按钮在同一竖列(中心 x 相差 ≤ 2px)。
// 分组标题里还有个绝对定位的折叠箭头,它同样带 el-icon 类但已淡出,量它没有意义,排除。
const centerXOf = (element: Element | null | undefined): number | null => {
if (!element) return null;
const rect = element.getBoundingClientRect();
return rect.width > 0 ? rect.left + rect.width / 2 : null;
};
const leftoverText: string[] = [];
for (const node of Array.from(
document.querySelectorAll('.aside .el-menu-item > span, .aside .el-sub-menu__title > span'),
)) {
const element = node as HTMLElement;
if (element.classList.contains('menu-icon-slot')) continue;
const text = (element.textContent ?? '').trim();
if (!text || element.getClientRects().length === 0) continue;
const rect = element.getBoundingClientRect();
const opacity = Number(getComputedStyle(element).opacity || '1');
if (rect.width >= 1 && opacity > 0.05) leftoverText.push(`可见:${text.slice(0, 6)}`);
}
const toggleCenter = centerXOf(document.querySelector('[data-test="sidebar-toggle"]'));
const iconCenters = Array.from(
document.querySelectorAll(
'.aside.is-collapsed .menu-icon-slot .el-icon, ' +
'.aside.is-collapsed .el-sub-menu__title > .el-icon:not([class*="icon-arrow"])',
),
).map((node) => centerXOf(node));
const iconDeltas = iconCenters
.filter((value): value is number => value !== null && toggleCenter !== null)
.map((value) => Math.abs(value - (toggleCenter as number)));
const iconsAligned = iconDeltas.length > 0 && Math.max(...iconDeltas) <= 2;
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
const settingsItem = Array.from(
document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'),
).find((element) =>
Array.from(element.querySelectorAll('span')).some((span) => (span.textContent ?? '').trim() === '通用'),
);
const submenuOpen = (): boolean => Boolean(settingsItem && settingsItem.offsetParent !== null);
if (!submenuOpen()) {
// el-sub-menu__title 的点击是 **toggle**:先判"子项是否可见"再点,
// 盲点一次会把已展开的组收起来(AGENTS §5 第 49 条)
document.querySelector<HTMLElement>('.aside .el-sub-menu__title')?.click();
await waitUntil(() => submenuOpen(), 2000);
}
const railMenu = document.querySelector<HTMLElement>('.aside .menu');
const railOverflowPx = railMenu ? railMenu.scrollHeight - railMenu.clientHeight : Number.NaN;
const railFits = Number.isFinite(railOverflowPx) && railOverflowPx <= 1;
clickToggle('[data-test="sidebar-toggle"]');
// 展开同样是动画:等宽度回到 200 再继续,否则下面量页脚时侧栏还停在中间宽度
const expandedBack = await waitUntil(() => {
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
return !aside?.classList.contains('is-collapsed') && (aside?.offsetWidth ?? 0) >= 190;
}, 4000);
// 3) 侧栏不溢出:菜单区自己滚动(.menu 有 min-height:0 + overflow-y:auto),
// 底部「第 N 周 / 最近同步」始终留在窗口内 —— 窗口默认高 582px,
// 在加这条结构性保证之前,两个分组全展开会把 footer 挤出屏幕(用户反馈)。
const footer = document.querySelector<HTMLElement>('.aside-footer');
const footerRect = footer?.getBoundingClientRect();
const footerVisible =
Boolean(footer && footer.offsetParent !== null) &&
Boolean(footerRect && footerRect.bottom <= window.innerHeight + 1);
// 3) 还原主题,避免影响后续用例
clickToggle('[data-test="theme-toggle"]');
await waitUntil(() => document.documentElement.classList.contains('dark') === initialDark, 2500);
const ok =
flipped &&
themePersisted &&
headerAligned &&
widthAnimated &&
collapsed &&
collapsePersisted &&
widthAfter < widthBefore &&
widthAfter <= 80 &&
expandedBack &&
leftoverText.length === 0 &&
iconsAligned &&
railFits &&
footerVisible;
return {
ok,
detail:
`主题翻转=${flipped} 落盘=${themePersisted}(${configAfterFlip?.theme ?? '-'}) ` +
`顶栏对齐=${headerAligned}(中线差=${headerCenterDelta === null ? '-' : headerCenterDelta.toFixed(1)}px,要求 ≤1.5px) ` +
`宽度过渡=${widthAnimated}(${asideTransitionProperty} ${asideTransitionSeconds}s) ` +
`折叠=${collapsed} 宽度=${widthBefore}→${widthAfter} 展开还原=${expandedBack} 落盘=${collapsePersisted} ` +
`文字残影=${leftoverText.length === 0 ? '无' : leftoverText.join(',')} ` +
`图标与汉堡同列=${iconsAligned}(中心差=${iconDeltas.map((d) => d.toFixed(1)).join('/') || '-'},要求 ≤2px) ` +
`导轨无溢出=${railFits}(溢出=${Number.isFinite(railOverflowPx) ? `${railOverflowPx}px` : '-'}) ` +
`侧栏底部可见=${footerVisible}`,
};
} catch (error) {
return {
ok: false,
detail: `外观件自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/**
* 冒烟自检(不需要后端):关于页(/settings/about)。
* 断言页面渲染出标题与「鸣谢 / 诊断信息」区块,且 getAppInfo 返回了新增的 configPath
* (诊断信息依赖它)。
*/
export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
try {
const { router } = await import('../router/index.js');
await router.push('/settings/about');
const rendered = await waitUntil(
() => (document.querySelector('.page-title')?.textContent ?? '').includes('关于'),
6000,
);
if (!rendered) return { ok: false, detail: '关于页未挂载' };
const text = document.body.innerText.replace(/\s+/g, ' ');
const hasThanks = text.includes('鸣谢');
const hasDiagnostics = text.includes('诊断信息');
const info = await window.desktop?.getAppInfo?.();
const hasConfigPath = Boolean(info?.configPath);
// 检查更新入口(新功能回归):关于页要有这一块,且按钮可点。
// 这里只断言"入口存在"——是否真有新版本取决于 GitHub,不该绑进冒烟(见 main/update.ts)
const updateBlock = document.querySelector('[data-test="update-check"]');
const updateButton = document.querySelector('[data-test="update-check-button"]');
await router.push('/schedule');
return {
ok: hasThanks && hasDiagnostics && hasConfigPath && Boolean(updateBlock) && Boolean(updateButton),
detail:
`鸣谢=${hasThanks} 诊断=${hasDiagnostics} configPath=${hasConfigPath ? '有' : '无'} ` +
`检查更新块=${updateBlock ? '有' : '无'} 按钮=${updateButton ? '有' : '无'}`,
};
} catch (error) {
return {
ok: false,
detail: `关于页自检异常:${error instanceof Error ? error.message : String(error)}`,
};
}
}
/** 注册到 window,供主进程冒烟脚本调用 */
export function registerSmokeHooks(): void {
window.__classhelperSmoke__ = {
@@ -845,6 +1170,9 @@ export function registerSmokeHooks(): void {
islandRealtimeCleanup,
islandReadState,
islandHomeworkSuppressionCheck,
onboardingSelfTest,
layoutChromeSelfTest,
aboutPageSelfTest,
scheduleTimelineSelfTest,
homeworkBoardSelfTest,
sessionCleanup,
@@ -0,0 +1,311 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue';
import { useOnboardingStore } from '../stores/onboarding.js';
/**
* 初次启动引导(六步向导)。
*
* - 首次启动自动弹出(App.vue 按 `onboardingDone` 判定);完成 / 跳过(右上角按钮 / Esc)
* 都算看过,由这里写入主进程配置,之后不再自动弹出。
* - 登录页与设置页的「使用引导」入口可随时重看(重看关闭时再写一次也无副作用)。
* - **用 v-if 自绘遮罩而不是 el-dialog**:关闭即从 DOM 卸载。冒烟断言"引导已关闭"时
* 不会踩 el-dialog「DOM 残留在文档里」的坑(AGENTS §7 第 22 条)。
* - 操作按钮用 `data-action` 标识(prev/next/finish/skip),冒烟点击不依赖按钮文案。
*/
const onboarding = useOnboardingStore();
const steps = [
{
icon: 'School',
title: '欢迎使用班级小助手',
lines: [
'这台电脑将以「班级」的身份使用班级小助手:实时接收课表、作业、通知与成绩。',
'第一次使用,先花半分钟看看怎么开始。',
],
},
{
icon: 'Link',
title: '第一步 · 连接服务器',
lines: [
'在登录页填写老师或管理员提供的服务器地址(通常形如 http://192.168.x.x:4000)。',
'点「测试连接」确认可达;以后也能在「设置」里随时修改。',
],
},
{
icon: 'Key',
title: '第二步 · 登录班级',
lines: [
'使用班级码 + 班级密码登录(向老师或管理员索取)。',
'登录后这台设备就代表全班:作业完成、通知已读都会按全班记录,不需要每个学生单独登录。',
],
},
{
icon: 'Calendar',
title: '登录之后',
lines: [
'左侧菜单切换「课表 / 作业 / 通知 / 成绩」四个页面,作业支持看板全屏展示。',
'断网时自动显示最近同步的缓存数据,联网后自动更新。',
],
},
{
icon: 'Bell',
title: '灵动岛提醒',
lines: [
'屏幕上方会有一条「灵动岛」浮窗:新通知、叫人、作业都会在那里弹出。',
'上课时段自动隐藏、下课自动弹出;外观与位置可在「设置 → 灵动岛」调整。',
],
},
{
icon: 'CircleCheckFilled',
title: '准备好了',
lines: [
'按上面的步骤登录就可以开始使用了。',
'侧边栏「设置」分组里有通用、外观(深色模式)、灵动岛等页面;这份引导可以在登录页或「设置 → 通用 → 使用引导」随时重看。',
],
},
];
const index = ref(0);
const current = computed(() => steps[index.value] ?? steps[0]);
const isFirst = computed(() => index.value === 0);
const isLast = computed(() => index.value === steps.length - 1);
/** 每次打开都从第一步开始(重看入口也走完整流程) */
watch(
() => onboarding.visible,
(visible) => {
if (visible) index.value = 0;
},
);
function goNext(): void {
if (isLast.value) finish();
else index.value += 1;
}
function goPrev(): void {
if (!isFirst.value) index.value -= 1;
}
/** 完成 / 跳过统一走这里:记「已看过」并关闭(重看时重复写一次无副作用) */
function finish(): void {
const bridge = window.desktop;
// 桥接不可用(浏览器调试)也要能正常关掉引导
if (!bridge) {
onboarding.close();
return;
}
void bridge
.saveConfig({ onboardingDone: true })
.then(() => onboarding.close())
.catch(() => onboarding.close());
}
function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') finish();
}
if (typeof window !== 'undefined') {
window.addEventListener('keydown', onKeydown);
}
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown);
});
</script>
<template>
<Transition name="onboarding-fade">
<div
v-if="onboarding.visible"
class="onboarding-mask"
role="dialog"
aria-modal="true"
aria-label="初次启动引导"
>
<div class="onboarding-card onboarding-welcome">
<div class="onboarding-head">
<span class="onboarding-logo">
<el-icon :size="18"><School /></el-icon>
</span>
<div class="onboarding-heading">
<h2 class="onboarding-title">班级小助手</h2>
<p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p>
</div>
<el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button>
</div>
<div class="onboarding-body">
<span class="onboarding-icon">
<el-icon :size="30"><component :is="current.icon" /></el-icon>
</span>
<h3 class="onboarding-step-title">{{ current.title }}</h3>
<p v-for="(line, lineIndex) in current.lines" :key="lineIndex" class="onboarding-line">
{{ line }}
</p>
</div>
<div class="onboarding-foot">
<div class="onboarding-dots">
<span
v-for="(step, dotIndex) in steps"
:key="dotIndex"
class="dot"
:class="{ active: dotIndex === index }"
/>
</div>
<div class="onboarding-actions">
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">
下一步
</el-button>
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
</div>
</div>
</div>
</div>
</Transition>
</template>
<style scoped>
.onboarding-mask {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
background: rgba(15, 30, 50, 0.55);
}
.onboarding-card {
width: 520px;
max-width: 92vw;
max-height: 86vh;
overflow-y: auto;
background: var(--ch-layer-solid);
border-radius: 16px;
padding: 22px 24px 18px;
box-shadow: 0 24px 64px rgba(15, 30, 50, 0.35);
}
.onboarding-head {
display: flex;
align-items: center;
gap: 10px;
}
.onboarding-logo {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 9px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
}
.onboarding-heading {
flex: 1;
min-width: 0;
}
.onboarding-title {
margin: 0;
font-size: 15px;
line-height: 1.3;
}
.onboarding-sub {
margin: 2px 0 0;
font-size: 12px;
color: var(--ch-text-tertiary);
}
.onboarding-body {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 18px 4px 6px;
}
.onboarding-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 58px;
height: 58px;
border-radius: 16px;
margin-bottom: 14px;
color: var(--ch-accent);
background: var(--ch-accent-soft);
}
.onboarding-step-title {
margin: 0 0 10px;
font-size: 18px;
}
.onboarding-line {
margin: 0 0 8px;
font-size: 13px;
line-height: 1.8;
color: var(--ch-text-regular, #606266);
}
.onboarding-foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--ch-divider);
}
.onboarding-dots {
display: flex;
align-items: center;
gap: 6px;
}
.dot {
width: 7px;
height: 7px;
border-radius: 999px;
background: var(--ch-border-strong);
transition: all 0.2s ease;
}
.dot.active {
width: 20px;
background: var(--ch-accent);
}
.onboarding-actions {
display: flex;
align-items: center;
gap: 8px;
}
/* 进出场:淡入 + 轻微上浮,跟随系统"动画"开关无强依赖 */
.onboarding-fade-enter-active,
.onboarding-fade-leave-active {
transition: opacity 0.22s ease;
}
.onboarding-fade-enter-active .onboarding-card,
.onboarding-fade-leave-active .onboarding-card {
transition: transform 0.22s ease;
}
.onboarding-fade-enter-from,
.onboarding-fade-leave-to {
opacity: 0;
}
.onboarding-fade-enter-from .onboarding-card,
.onboarding-fade-leave-to .onboarding-card {
transform: translateY(10px) scale(0.98);
}
</style>
@@ -0,0 +1,193 @@
<script setup lang="ts">
import { computed } from 'vue';
/**
* 轻量柱状图(纯 HTML/CSS,不依赖任何图表库)。
*
* 为什么不用 echarts:成绩页只需要「分组柱 + 数值标签 + 坐标轴」这点东西,
* 而 `import * as echarts from 'echarts'` 会给渲染产物加 1.1MB(客户端里最大的一块),
* 按需引入 echarts/core 也仍有约 500KB。
*
* 实现要点(决定了这里为什么不需要 resize 监听):
* - 柱高用**百分比**:`.bar-track` 是行方向 flex 里 `flex:1` 的子项,高度确定,
* 柱子的 `height: X%` 直接按它解析;
* - 刻度与网格线用**零高度 flex 行 + `justify-content: space-between`**:
* 零高度元素的中心正好落在 i/(N-1) 处,与柱状区上下边界精确对齐;
* - 纵轴刻度列与绘图区共用同一套上下留白(数值行 18px / 标签行 30px),因此永远对齐。
*/
interface BarItem {
label: string;
value: number;
}
const props = withDefaults(
defineProps<{
/** 每根柱:分类名 + 数值(0 ~ max) */
items: BarItem[];
/** 柱色 */
color?: string;
/** 纵轴上限 */
max?: number;
/** 数值单位(显示在刻度与柱顶数值后面) */
unit?: string;
/** 图表高度(px) */
height?: number;
}>(),
{ color: '#409eff', max: 100, unit: '%', height: 300 },
);
/** 纵轴刻度(自上而下):100 / 75 / 50 / 25 / 0(按 max 换算) */
const ticks = computed(() => [1, 0.75, 0.5, 0.25, 0].map((ratio) => Math.round(props.max * ratio)));
function barHeight(value: number): string {
const ratio = props.max > 0 ? value / props.max : 0;
return `${Math.min(1, Math.max(0, ratio)) * 100}%`;
}
function formatValue(value: number): string {
return `${Number.isInteger(value) ? value : value.toFixed(1)}${props.unit}`;
}
</script>
<template>
<div class="bar-chart" :style="{ height: `${height}px` }">
<div v-if="items.length === 0" class="bar-chart-empty">暂无数据</div>
<template v-else>
<div class="axis-y" aria-hidden="true">
<span v-for="tick in ticks" :key="tick" class="tick">{{ tick }}{{ unit }}</span>
</div>
<div class="plot">
<div class="gridlines" aria-hidden="true">
<span v-for="tick in ticks" :key="tick" class="gridline"></span>
</div>
<div class="bars">
<div v-for="item in items" :key="item.label" class="bar-col">
<div class="bar-value">{{ formatValue(item.value) }}</div>
<div class="bar-track">
<div
class="bar"
:style="{ height: barHeight(item.value), background: color }"
:title="`${item.label}:${formatValue(item.value)}`"
></div>
</div>
<div class="bar-label" :title="item.label">{{ item.label }}</div>
</div>
</div>
</div>
</template>
</div>
</template>
<style scoped>
.bar-chart {
display: grid;
grid-template-columns: 46px 1fr;
width: 100%;
}
.bar-chart-empty {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
font-size: 13px;
color: var(--ch-text-tertiary);
}
/* 纵轴刻度:上下留白与 .bars 里的数值行 / 标签行一一对应 */
.axis-y {
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 18px 0 30px;
}
.tick {
height: 0;
display: flex;
align-items: center;
justify-content: flex-end;
padding-right: 8px;
font-size: 11px;
line-height: 1;
white-space: nowrap;
color: var(--ch-text-tertiary);
}
.plot {
position: relative;
min-width: 0;
}
.gridlines {
position: absolute;
inset: 18px 0 30px;
display: flex;
flex-direction: column;
justify-content: space-between;
pointer-events: none;
}
.gridline {
height: 0;
border-top: 1px solid var(--ch-border);
}
.bars {
display: flex;
align-items: stretch;
gap: 10px;
height: 100%;
padding: 0 6px;
}
.bar-col {
flex: 1 1 0;
min-width: 0;
display: flex;
flex-direction: column;
height: 100%;
}
.bar-value {
flex: none;
height: 18px;
font-size: 11px;
line-height: 1;
text-align: center;
white-space: nowrap;
overflow: hidden;
color: var(--ch-text-secondary);
}
.bar-track {
flex: 1 1 auto;
min-height: 0;
display: flex;
align-items: flex-end;
justify-content: center;
}
.bar {
width: 100%;
max-width: 46px;
border-radius: 4px 4px 0 0;
transition: height 0.2s ease;
}
.bar-label {
flex: none;
height: 30px;
padding-top: 6px;
font-size: 11px;
line-height: 1.3;
text-align: center;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--ch-text-secondary);
}
</style>
@@ -0,0 +1,239 @@
/**
* 动效组合式函数 —— beUI 那套动效语言在客户端的落地层。
*
* 令牌的**数值**在 `@classhelper/shared` 的 `motion.ts`(单一来源);
* 这里只放需要 DOM / Vue 的部分。内容与 Web 管理端的
* `packages/web-admin/src/composables/motion.ts` 一致 —— 与 `ScoreBarChart.vue`
* 一样,两端各一份是仓库既有做法。
*
* 客户端相较 Web 端多一层现实约束:教室机器多为**希沃触摸屏**,
* 触摸点按会产生粘住的幽灵 hover,因此 `useHoverCapable()` 在客户端是**必需**的,
* 不是锦上添花(见 AGENTS.md §5 第 30 条)。
*/
import { computed, onUnmounted, ref, watch, type CSSProperties, type Ref } from 'vue';
import {
EASE_OUT,
HOVER_CAPABLE_QUERY,
MOTION_DURATION,
REDUCED_MOTION_QUERY,
cubicBezierCss,
motionCssVars,
} from '@classhelper/shared';
/**
* 把动效令牌写进 `:root` 的自定义属性。
*
* **必须在 `app.mount()` 之前调用**:那样 CSS 里可以放心写 `var(--ch-spring-panel)`,
* 首帧之前就已就位,既没有 FOUC,也不需要到处写兜底值。
*
* ⚠️ 只给**主渲染进程**注入。灵动岛是另一个渲染进程(`src/island/main.ts`),
* 它有自己那套从 WinIsland 移植的弹簧物理,刻意不参与这里的动效体系。
*
* 弹簧用 CSS `linear()` 表达;Electron 44(Chromium 13x)原生支持,
* 仍保留 `cubic-bezier` 回落分支以防将来降版本。
*/
export function applyMotionTokens(): void {
if (typeof document === 'undefined') return;
const linear =
typeof CSS !== 'undefined' &&
typeof CSS.supports === 'function' &&
CSS.supports('transition-timing-function', 'linear(0, 1)');
for (const [name, value] of Object.entries(motionCssVars({ linear }))) {
document.documentElement.style.setProperty(name, value);
}
}
/**
* 监听 `prefers-reduced-motion`。
*
* 三元表达式而非直接返回 `matchMedia(...)` 的结果:这条偏好可能在运行中被改动,
* 必须跟着变;JS 驱动的动画(数字滚动、位置测量)拿不到 CSS 那层自动降级,得自己看这个值。
*/
export function useReducedMotion(): Ref<boolean> {
const matches = ref(false);
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return matches;
const query = window.matchMedia(REDUCED_MOTION_QUERY);
matches.value = query.matches;
const update = (): void => {
matches.value = query.matches;
};
// Electron 的 Chromium 支持 addEventListener;保留旧接口兜底
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', update);
onUnmounted(() => query.removeEventListener('change', update));
} else if (typeof query.addListener === 'function') {
query.addListener(update);
onUnmounted(() => query.removeListener(update));
}
return matches;
}
/**
* 是否有真正的悬停能力(鼠标 / 触控板)。
*
* 触摸设备在点按时会触发**幽灵 hover** 并一直粘着,直到点别处才消失 ——
* 教室里的希沃触摸屏正属于这种,所有 hover-only 效果(抬升、磁吸、光标跟随)
* 都必须先过这一关,否则触摸屏上会出现"点一下,卡片就永远浮着"。
*/
export function useHoverCapable(): Ref<boolean> {
const capable = ref(false);
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return capable;
const query = window.matchMedia(HOVER_CAPABLE_QUERY);
capable.value = query.matches;
const update = (): void => {
capable.value = query.matches;
};
if (typeof query.addEventListener === 'function') {
query.addEventListener('change', update);
onUnmounted(() => query.removeEventListener('change', update));
} else if (typeof query.addListener === 'function') {
query.addListener(update);
onUnmounted(() => query.removeListener(update));
}
return capable;
}
/** 缓动函数:给定进度 t∈[0,1],返回缓动后的进度。即 EASE_OUT 那条贝塞尔的求值。 */
function easeOutProgress(t: number): number {
const [x1, y1, x2, y2] = EASE_OUT;
// 牛顿迭代反解 x(u) = t,再用同一个 u 求 y —— 三次贝塞尔求值的标准做法
let u = t;
for (let i = 0; i < 6; i += 1) {
const x = 3 * (1 - u) ** 2 * u * x1 + 3 * (1 - u) * u ** 2 * x2 + u ** 3;
const dx = 3 * (1 - u) ** 2 * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u ** 2 * (1 - x2);
if (Math.abs(dx) < 1e-6) break;
u = Math.min(1, Math.max(0, u - (x - t) / dx));
}
return 3 * (1 - u) ** 2 * u * y1 + 3 * (1 - u) * u ** 2 * y2 + u ** 3;
}
export interface CountUpOptions {
/** 时长(毫秒)。默认 `MOTION_DURATION.slow`。 */
duration?: number;
}
/**
* 数字滚动:目标值变化时从当前显示值滚过去,而不是直接跳。
*
* `reduced-motion` 下**直接落位**(不启动 rAF)。
*
* 刻意**不**给"首次变化"开豁免:数据是异步加载的,挂载那一刻目标值还是 0,
* 真正想看到滚动的恰恰是"0 → 实际值"这第一跳;而如果数据在挂载时就已就位
* (离线缓存命中),`displayed` 初始化即等于目标值、根本不会触发 watch。
*/
export function useCountUp(source: () => number, options: CountUpOptions = {}): Ref<number> {
const reduce = useReducedMotion();
const duration = options.duration ?? MOTION_DURATION.slow;
const displayed = ref(source());
let frame = 0;
function stop(): void {
if (frame) {
cancelAnimationFrame(frame);
frame = 0;
}
}
watch(source, (target) => {
stop();
if (reduce.value || duration <= 0) {
displayed.value = target;
return;
}
const from = displayed.value;
const delta = target - from;
if (delta === 0) return;
const start = performance.now();
const tick = (now: number): void => {
const progress = Math.min(1, (now - start) / duration);
displayed.value = from + delta * easeOutProgress(progress);
if (progress < 1) frame = requestAnimationFrame(tick);
else {
displayed.value = target;
frame = 0;
}
};
frame = requestAnimationFrame(tick);
});
onUnmounted(stop);
return displayed;
}
/**
* 列表错峰入场:返回一个"给第 index 项算内联样式"的函数,
* 配合 CSS 类 `.ch-reveal`(见 `styles/index.css`)使用。
*
* 延迟**封顶**(默认第 8 项之后不再累加):一屏几十行时,最后一行不该等一秒才出现。
*/
export function useStaggerIn(step = 35, maxIndex = 8): (index: number) => CSSProperties {
const reduce = useReducedMotion();
return (index: number): CSSProperties =>
reduce.value ? {} : ({ '--ch-reveal-delay': `${Math.min(index, maxIndex) * step}ms` } as CSSProperties);
}
/** 把 reduced-motion 折成一个布尔,供模板里判断"要不要挂动画类"。 */
export function useMotionEnabled(): Ref<boolean> {
const reduce = useReducedMotion();
return computed(() => !reduce.value);
}
/**
* 整页换肤的揭示动画(beUI 的 `theme-toggle`):新旧两帧各存一张快照,
* 让新帧从一个**从点击位置扩散的圆**里长出来 —— 这是"整个界面换了张皮"这件事
* 唯一能让人**看见**的表达方式,比整页硬切一下好懂得多。
*
* 返回的函数直接挂 `@click`:`@click="reveal($event, () => ui.setTheme(next))"`。
*
* 三条守卫缺一不可:
* ① 不认识 View Transition API 的浏览器直接走同步切换;
* ② 系统开了「减少动态效果」时不放全屏动画;
* ③ **文档不可见时不放**(最小化、被遮挡、无头浏览器)—— 那种情况下浏览器不产生帧,
* 而 View Transition 的更新回调要等到下一个渲染时机才执行,**主题会一直不切**。
* 教室机器上窗口被别的窗口盖住是常态,这条尤其要守。
*/
export function useThemeReveal(): (event: MouseEvent, apply: () => void) => void {
const reduce = useReducedMotion();
return (event, apply) => {
const doc = document as Document & {
startViewTransition?: (callback: () => void) => { ready: Promise<void> };
};
if (
typeof doc.startViewTransition !== 'function' ||
reduce.value ||
document.visibilityState !== 'visible'
) {
apply();
return;
}
const { clientX: x, clientY: y } = event;
void doc
.startViewTransition(apply)
.ready.then(() => {
// 半径取到最远的那个角,圆才能把整屏吃完
const radius = Math.hypot(
Math.max(x, window.innerWidth - x),
Math.max(y, window.innerHeight - y),
);
document.documentElement.animate(
{ clipPath: [`circle(0px at ${x}px ${y}px)`, `circle(${radius}px at ${x}px ${y}px)`] },
{
// 比常规过渡长一点 —— 它是一次**整屏**的变化,太快会读成"闪了一下"而不是"换了一层皮"
duration: MOTION_DURATION.slow * 1.5,
// 缓动取自共享令牌,别在这里手写曲线字面量(那是漂移的起点)
easing: cubicBezierCss(EASE_OUT),
pseudoElement: '::view-transition-new(root)',
},
);
})
.catch(() => undefined);
};
}
@@ -3,6 +3,19 @@ import { DEFAULT_SERVER_URL } from '@classhelper/shared';
/** 默认后端地址(首次启动的预填值) */
export const DEFAULT_SERVER = DEFAULT_SERVER_URL;
/**
* 品牌标地址(侧栏与登录页的 `<img>` 用它)。
*
* 模板里既不能写 `src="logo.png"`、也不能写 `src="/logo.png"`:
* ① 字面量的**相对** `src` 会被 Vite 当成模块导入去解析 —— 构建直接失败
* (`Rolldown failed to resolve import "logo.png" from "LoginView.vue"`);
* ② 根绝对路径在打包形态下是坏的:Electron 用 `file://` 加载
* `dist/renderer/index.html`,`/logo.png` 会解析到**盘符根目录**。
* 所以只能「绑定属性 + 跟着 vite base 走」:base 为 `'./'`,结果是 `./logo.png`,
* 开发态(5174 静态服务)与打包态(`dist/renderer/logo.png`)都指向 public/ 拷过去的那份。
*/
export const BRAND_LOGO_URL = `${import.meta.env.BASE_URL}logo.png`;
/** 由服务器地址推导 API 基地址,例如 http://127.0.0.1:4000 -> http://127.0.0.1:4000/api */
export function apiBaseOf(serverUrl: string): string {
return `${serverUrl.trim().replace(/\/+$/, '')}/api`;
@@ -0,0 +1,94 @@
import type { App } from 'vue';
import {
AlarmClock,
ArrowDown,
ArrowLeft,
ArrowRight,
Bell,
Brush,
Calendar,
CircleCheckFilled,
Connection,
Expand,
Fold,
FullScreen,
Guide,
InfoFilled,
Key,
Link,
Loading,
Lock,
MagicStick,
Moon,
Notebook,
Plus,
Pointer,
Refresh,
School,
Setting,
Sunny,
SwitchButton,
Tools,
Trophy,
UserFilled,
Warning,
} from '@element-plus/icons-vue';
/**
* Element Plus 图标注册表(桌面客户端渲染进程)。
*
* **为什么不 `import * as ElementPlusIconsVue` 全量注册**:
* 图标库有 293 个组件(实测全量打包 minified 203KB,实际用到的只有 33 个),
* 全量注册既把死代码打进主 chunk,又要在启动时执行 293 次 `app.component()`。
*
* **为什么不改成模板里逐个 import**:本项目的图标是按**字符串名**引用的
* (`ClientLayout.vue` 的菜单配置 `icon: 'Setting'`、`OnboardingWelcome.vue` 的引导步骤、
* 按钮上的 `:icon="'FullScreen'"`、`<component :is="item.icon">`),Element Plus 会用
* `<component :is="'Setting'">` 去解析**全局组件名**,因此图标必须以全局组件存在。
*
* 新增视图时若用了新图标,`scripts/check-icons.mjs` 会在构建前报错,
* 不会静默渲染成空白。
*/
const APP_ICONS = {
AlarmClock,
ArrowDown,
ArrowLeft,
ArrowRight,
Bell,
Brush,
Calendar,
CircleCheckFilled,
Connection,
Expand,
Fold,
FullScreen,
Guide,
InfoFilled,
Key,
Link,
Loading,
Lock,
MagicStick,
Moon,
Notebook,
Plus,
Pointer,
Refresh,
School,
Setting,
Sunny,
SwitchButton,
Tools,
Trophy,
UserFilled,
Warning,
} as const;
/** 供 `scripts/check-icons.mjs` 读取的名单(构建期静态校验用) */
export const APP_ICON_NAMES: readonly string[] = Object.keys(APP_ICONS).sort();
export function registerAppIcons(app: App): void {
for (const [name, component] of Object.entries(APP_ICONS)) {
app.component(name, component);
}
}
@@ -1,13 +1,16 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted } from 'vue';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
import { useThemeReveal } from '../composables/motion.js';
import { BRAND_LOGO_URL } from '../config.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
import { useUiStore } from '../stores/ui.js';
const route = useRoute();
const router = useRouter();
@@ -15,18 +18,186 @@ const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const ui = useUiStore();
const menuItems = [
/**
* 主侧边栏。
*
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。
* 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨,
* 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
*
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
*/
const primaryItems = [
{ path: '/schedule', title: '课表', icon: 'Calendar' },
{ path: '/homeworks', title: '作业', icon: 'Notebook' },
{ path: '/notifications', title: '通知', icon: 'Bell' },
{ path: '/grades', title: '成绩', icon: 'Trophy' },
{ path: '/settings', title: '设置', icon: 'Setting' },
];
const menuGroups = [
{
title: '设置',
icon: 'Setting',
items: [
{ path: '/settings/general', title: '通用', icon: 'Tools' },
{ path: '/settings/appearance', title: '外观', icon: 'Brush' },
{ path: '/settings/island', title: '灵动岛', icon: 'MagicStick' },
{ path: '/settings/reminder', title: '提醒', icon: 'AlarmClock' },
{ path: '/settings/account', title: '账号', icon: 'UserFilled' },
{ path: '/settings/about', title: '关于', icon: 'InfoFilled' },
],
},
];
/* -------------------------------------------------------------- 导航指示器 */
/**
* 侧栏激活项的**滑动指示器**(beUI 的 `shared-layout-bg` / tabs 的 layoutId 手法)。
*
* 做法是**一个**绝对定位的底片跟着激活项平移,而不是让每个菜单项各自画一块底色 ——
* 后者在切换时是"旧的瞬间消失、新的瞬间出现",看不出两者之间的关系。
*
* 位置靠**量 DOM**:菜单项的位置由 Element Plus 的 padding 与行高决定,
* 写死会在组件库升级或文案变长时错位;折叠成图标导轨时宽度还会变,量出来才跟得上。
*
* ⚠️ 这里用 `document.querySelector` 而不是模板 ref:`.aside` 是 `el-aside` **组件**,
* 模板 ref 拿到的是组件实例(`.querySelector` 不是函数,实测踩过);
* 客户端只有一个主侧栏,直接查文档反而更直白。
*/
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
function syncIndicator(): void {
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
if (!aside || !active) {
indicator.value = { ...indicator.value, visible: false };
return;
}
const asideRect = aside.getBoundingClientRect();
const rect = active.getBoundingClientRect();
indicator.value = {
x: rect.left - asideRect.left,
y: rect.top - asideRect.top,
width: rect.width,
height: rect.height,
visible: rect.width > 0 && rect.height > 0,
};
}
const indicatorStyle = computed(() => ({
width: `${indicator.value.width}px`,
height: `${indicator.value.height}px`,
transform: `translate(${indicator.value.x}px, ${indicator.value.y}px)`,
opacity: indicator.value.visible ? '1' : '0',
}));
let asideObserver: ResizeObserver | null = null;
let indicatorFrame = 0;
/**
* 把测量推迟到下一帧再跑。
*
* 侧栏折叠时 `.aside` 的宽度有一整段过渡,`ResizeObserver` 会逐帧回调;若在回调里**同步**
* 读 `getBoundingClientRect()` 再写样式,就是在每一帧中间强行插入一次同步布局
* (Chromium 还会因此报 "ResizeObserver loop completed with undelivered notifications")。
* 放进 rAF 就变成"本帧布局完成后统一量一次",代价是跟随慢一帧,肉眼无感。
*/
function scheduleIndicatorSync(): void {
if (indicatorFrame) return;
indicatorFrame = requestAnimationFrame(() => {
indicatorFrame = 0;
syncIndicator();
});
}
/**
* 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active`
* 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。
*
* `ResizeObserver` 不是为了窗口缩放,而是为了**侧栏折叠**:折叠时 `.aside` 的宽度
* 有一整段过渡(`--nav-dur`),观察它会逐帧回调,指示器于是跟着栏宽一起收 ——
* 否则图标导轨形成后,底片还停在展开态的宽度上。
*/
onMounted(() => {
syncIndicator();
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
if (aside && typeof ResizeObserver !== 'undefined') {
asideObserver = new ResizeObserver(scheduleIndicatorSync);
asideObserver.observe(aside);
}
});
onUnmounted(() => {
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
indicatorFrame = 0;
asideObserver?.disconnect();
asideObserver = null;
});
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量
watch(
() => route.path,
() => {
void nextTick(syncIndicator);
},
);
/* ---------------------------------------------------------- 主题切换动画 */
/**
* 顶栏的日夜快捷切换。
*
* "从点击位置扩散的圆"那套整页揭示由 `useThemeReveal()` 提供 ——
* **与 Web 管理端共用同一份实现**(两端 `composables/motion.ts` 里的同名函数),
* 这里只负责把"点击事件"和"切换主题"接起来。那三条守卫(不支持 View Transition /
* 减少动态效果 / 文档不可见)都在组合式函数里,别在这里再判一遍。
*/
const revealThemeChange = useThemeReveal();
function handleThemeToggle(event: MouseEvent): void {
revealThemeChange(event, () => ui.toggleTheme());
}
/** 未读红点挂在「通知」项的图标右上角 */
function showBadge(path: string): boolean {
return path === '/notifications' && notifications.unreadCount > 0;
}
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
const activeMenu = computed(() => route.path);
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
const activeGroupTitle = computed(() => {
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
return group?.title ?? null;
});
/**
* 分组展开控制:默认只展开**当前路由所在的分组**(一级项不属于任何分组,此时一个都不展开)。
*
* 窗口默认高度与 ClassIsland 对齐(582px),设置组 6 个子项展开后会超出可视高度、
* 把底部的「账号 / 关于」挤出屏幕;只展开当前组既符合 ClassIsland 的观感(它也只展开当前组),
* 又让小窗口下的侧栏始终完整可见。用户手动展开其它分组不受限制(可多个同时展开)。
*/
const menuRef = ref<MenuInstance>();
const openedGroups = ref<string[]>(activeGroupTitle.value ? [activeGroupTitle.value] : []);
watch(
() => route.path,
async (path) => {
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
if (!group) return;
// 展开当前组(不主动关闭用户已展开的其它组)
await nextTick();
menuRef.value?.open(group.title);
},
);
/**
* 菜单点击回调。
* 注意:el-menu 的 @select 抛出的是 index(这里即路由路径),
@@ -44,9 +215,11 @@ function go(name: string): void {
/** 开发期自检:菜单路径必须存在于路由表中 */
if (import.meta.env.DEV) {
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
for (const item of menuItems) {
if (!knownPaths.has(item.path)) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
for (const group of menuGroups) {
for (const item of group.items) {
if (!knownPaths.has(item.path)) {
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
}
}
}
}
@@ -106,20 +279,45 @@ onUnmounted(() => {
<template>
<el-container class="layout">
<el-aside width="200px" class="aside ch-nav">
<el-aside
:width="ui.sidebarCollapsed ? '64px' : '200px'"
class="aside ch-nav"
:class="{ 'is-collapsed': ui.sidebarCollapsed }"
>
<div class="brand">
<span class="brand-logo">
<el-icon :size="16"><School /></el-icon>
</span>
<button
type="button"
class="nav-toggle"
aria-label="折叠或展开侧边栏"
:aria-expanded="ui.sidebarCollapsed ? 'false' : 'true'"
data-test="sidebar-toggle"
@click="ui.toggleSidebar()"
>
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon>
</button>
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
<img class="brand-logo" :src="BRAND_LOGO_URL" alt="" width="26" height="26" />
<span class="brand-text">班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="menu" @select="handleMenuSelect">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
<el-menu
ref="menuRef"
:default-active="activeMenu"
:default-openeds="openedGroups"
class="menu"
@select="handleMenuSelect"
>
<!-- 一级项:直接铺在侧栏上(不再套「学习」分组)。
下面 el-sub-menu 里的菜单项与本段是同一套标记(图标槽 + 未读红点 + 标题),
改动时两处要一起改。 -->
<el-menu-item v-for="item in primaryItems" :key="item.path" :index="item.path">
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge
v-if="item.path === '/notifications' && notifications.unreadCount > 0"
v-if="showBadge(item.path)"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
@@ -127,7 +325,27 @@ onUnmounted(() => {
</span>
<span>{{ item.title }}</span>
</el-menu-item>
<el-sub-menu v-for="group in menuGroups" :key="group.title" :index="group.title">
<template #title>
<el-icon><component :is="group.icon" /></el-icon>
<span>{{ group.title }}</span>
</template>
<el-menu-item v-for="item in group.items" :key="item.path" :index="item.path">
<!-- 未读红点挂在图标右上角(之前挂在文字后面,位置不对) -->
<span class="menu-icon-slot">
<el-icon><component :is="item.icon" /></el-icon>
<el-badge
v-if="showBadge(item.path)"
:value="notifications.unreadCount"
:max="99"
class="menu-badge"
/>
</span>
<span>{{ item.title }}</span>
</el-menu-item>
</el-sub-menu>
</el-menu>
<!-- 同上:折叠时不删节点,只淡出并收掉内边距 -->
<div class="aside-footer">
<div class="aside-meta">第 {{ appStore.currentWeek }} 周</div>
<div class="aside-meta">最近同步:{{ appStore.lastSyncText }}</div>
@@ -142,6 +360,19 @@ onUnmounted(() => {
<span class="server-url">{{ appStore.serverUrl }}</span>
</div>
<div class="header-right">
<!-- 黑夜/白天快捷切换:外观页里也有同样的设置,两处写同一份配置 -->
<el-tooltip :content="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" placement="bottom">
<el-button
class="theme-toggle"
text
circle
data-test="theme-toggle"
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
@click="handleThemeToggle"
>
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon>
</el-button>
</el-tooltip>
<el-dropdown trigger="click">
<span class="user-chip">
<el-icon><UserFilled /></el-icon>
@@ -150,7 +381,7 @@ onUnmounted(() => {
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="go('settings')">
<el-dropdown-item @click="go('settings-general')">
<el-icon><Setting /></el-icon>
设置
</el-dropdown-item>
@@ -174,6 +405,19 @@ onUnmounted(() => {
title="当前处于离线状态"
:description="`服务器不可达,正在显示本地缓存数据(最近同步:${appStore.lastSyncText})。恢复网络后会自动同步。`"
/>
<!--
这里**刻意不用** `<transition>` 包 router-view。
页面切换的动效由 `.page > *` 的错峰入场承担(styles/index.css)——
那是随组件挂载同步触发的,不推迟任何东西。
包一层过渡(尤其 `mode="out-in"`)会把"旧组件卸载、新组件挂载"推迟到退场动画结束
(90ms 起)。代价有两个,都不值得:
① 点菜单后有一小段时间页面上还是**上一页**,用户和自动化看到的都是旧内容;
② 退场判定依赖 `animationend`,窗口被遮挡时 Chromium 冻结 CSS 动画、
事件永不触发,新页面就永远不挂载 —— 整个客户端卡死在当前页。
教室机器上窗口被别的东西盖住是常态,这条尤其要守。
真要加回来,必须先确认这两条都有人兜住。
-->
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
@@ -191,6 +435,54 @@ onUnmounted(() => {
.aside {
display: flex;
flex-direction: column;
/* 展开/收回动画:宽度来自 el-aside 的内联 --el-aside-width,这里只负责过渡。
overflow:hidden 是必需的 —— 收窄过程中文字要被栏边裁掉,不能溢出到主区域;
菜单自己的滚动由内部的 .menu(flex:1 + min-height:0 + overflow-y:auto)负责。 */
overflow: hidden;
transition: width var(--nav-dur) var(--nav-ease);
/* 滑动指示器(.nav-indicator)的定位父级 */
position: relative;
}
/*
* 导航指示器:一个跟着激活项平移的底片,承载了原先"激活项底色 + 左侧强调条"两件事。
*
* 位置与尺寸都由 syncIndicator() 量出来后内联下发,这里只负责外观与过渡。
* 缓动用 LAYOUT 弹簧(397ms)—— 它是"共享布局滑动"的专用参数:比 SWAP(263ms)更有分量,
* 因为这一跳可能跨越好几个菜单项,太快会显得"闪"而不是"滑"。
*
* 折叠成图标导轨时,栏宽有一整段 260ms 的过渡,ResizeObserver 会逐帧把新宽度送进来,
* 底片于是跟着栏宽一起收窄 —— 两个时长不一致是**故意的**:底片比栏慢一点,
* 读起来像"被栏宽带着走",同步反而显得僵硬。
*/
.nav-indicator {
position: absolute;
top: 0;
left: 0;
z-index: 0;
border-radius: var(--ch-radius-control);
background: var(--ch-accent-soft);
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
pointer-events: none;
will-change: transform;
transition:
transform var(--ch-spring-layout-dur) var(--ch-spring-layout),
width var(--ch-spring-layout-dur) var(--ch-spring-layout),
height var(--ch-spring-layout-dur) var(--ch-spring-layout),
opacity var(--ch-dur-fast) var(--ch-ease-out);
}
/* 左侧强调条(原有观感,现在跟着底片一起滑) */
.nav-indicator::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: var(--ch-accent);
}
.brand {
@@ -198,34 +490,119 @@ onUnmounted(() => {
align-items: center;
gap: 10px;
height: 52px;
padding: 0 16px;
padding: 0 10px 0 12px;
font-weight: 600;
font-size: 14px;
color: var(--ch-text);
transition:
padding-left var(--nav-dur) var(--nav-ease),
gap var(--nav-dur) var(--nav-ease);
}
/* Fluent 品牌标:强调色圆角方块 + 白色图标 */
.brand-logo {
/* 汉堡按钮:折叠/展开整个侧边栏(状态持久化) */
.nav-toggle {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 6px;
color: #fff;
background: linear-gradient(135deg, var(--ch-accent) 0%, #4d94d1 100%);
width: 28px;
height: 28px;
flex: 0 0 auto;
border: none;
border-radius: var(--ch-radius-control);
background: transparent;
color: var(--ch-text-secondary);
cursor: pointer;
transition: background 0.15s ease;
}
.nav-toggle:hover {
background: var(--ch-hover-soft);
}
/* 折叠态:侧栏只剩 64px,汉堡按钮水平居中。
用**位移 padding-left** 而不是 `justify-content: center` —— 后者不可过渡,会在动画中途"跳"一下。
汉堡宽 28px,padding-left 18px ⇒ 中心 = 18 + 14 = 32px,正好是 64/2,与折叠后的图标导轨同列
(冒烟断言中心差 ≤ 2px)。
这里刻意用**自己的类名**(`.is-collapsed`)而不是 `:has(.el-menu--collapse)`:
`:has()` 的失效会让 Chromium 标记整棵文档树重新计算样式。 */
.aside.ch-nav.is-collapsed .brand {
padding-left: 18px;
}
/*
* 品牌标:与应用图标同一份图(`public/logo.png`,由 `pnpm icons` 从 `build/classhelper.png` 生成)。
* 上一版这里是"强调色渐变方块 + 白色 School 字形"手搓的占位标,换成真图标之后
* `background` / `color` / `border-radius` / `overflow` 全部撤掉 —— 圆角与透明边角本来就在图里,
* 再叠一层只会出现双重圆角。
*
* `src` 走 `BRAND_LOGO_URL`(见 `config.ts`):模板里既不能写字面量的相对 `src`(Vite 会把它
* 当模块导入去解析、构建失败),也不能写根绝对路径(Electron 生产环境走 file://,会解析到盘符根目录)。
*
* 折叠态的 `.is-collapsed .brand-logo { width: 0 }` 照旧生效:img 的 width 一样可过渡,
* 而且它本来就带 opacity 淡出,收放观感与改造前完全一致。
*/
.brand-logo {
display: block;
width: 26px;
height: 26px;
flex: 0 0 auto;
transition:
width var(--nav-dur) var(--nav-ease),
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/*
* 品牌文字 / 菜单文字在折叠时**不靠 max-width 归零**(那要么写死一个魔法数字,要么
* `none → 0` 根本不可过渡),而是:
* - `min-width: 0` 让这个 flex 子项能被压缩(默认的 `min-width: auto` 会撑住内容宽度);
* - 宽度自然跟着栏宽收缩,多出来的字被 `overflow: hidden` 裁掉;
* - 再叠一层 opacity 淡出,避免在中间帧里留下"半截字"。
* 于是文字宽度是**跟着侧栏宽度连续变化**的,不需要给它单独设动画终点。
*/
.brand-text {
min-width: 0;
overflow: hidden;
white-space: nowrap;
letter-spacing: 0.2px;
transition: opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/* 折叠:品牌区收成 0 宽并淡出(不是删节点,理由见模板上的注释) */
.aside.ch-nav.is-collapsed .brand {
gap: 0;
}
/*
* 图标导轨里**不显示滚动条**:所有图标应当一眼看全(用户要求"不要用滚轮、删掉滚动条")。
* 导轨之所以放得下,靠的是页脚在折叠态把高度也收成 0(见 .is-collapsed .aside-footer)——
* 不再有那 ~70px 的留白,11 行图标(462px)在 540px 的最小窗口里也够。
* 这里只隐藏滚动条本身,`overflow-y: auto` 保留:窗口被拉到极矮时还能滚,不至于把图标直接裁掉。
* 展开态**不隐藏** —— 那一栏确实会超(设置组展开时约 606px > 582px),
* 滚动条是"下面还有内容"的唯一提示,不能拿掉。
*/
.aside.ch-nav.is-collapsed .menu {
scrollbar-width: none;
}
.aside.ch-nav.is-collapsed .brand-logo {
width: 0;
opacity: 0;
}
.aside.ch-nav.is-collapsed .brand-text {
opacity: 0;
}
.menu {
flex: 1;
/* min-height:0 是 flex 子项能滚动的关键:否则内容撑高、底部 footer 被挤出窗口 */
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
border-right: none;
background: transparent;
--el-menu-bg-color: transparent;
--el-menu-hover-bg-color: rgba(0, 0, 0, 0.04);
--el-menu-hover-bg-color: var(--ch-hover-soft);
--el-menu-text-color: var(--ch-text);
--el-menu-active-color: var(--ch-text);
}
@@ -269,6 +646,30 @@ onUnmounted(() => {
.aside-footer {
padding: 10px 16px 14px;
border-top: 1px solid var(--ch-divider);
overflow: hidden;
/* max-height 是给折叠动画用的终点:`* { box-sizing: border-box }`,展开态给一个略高于
内容(约 70px)的值,折叠时归零 —— 导轨里**一个像素都不给页脚留**。
不这么做的话,文字淡出后仍占着 ~70px,10 个图标在 540px 的最小窗口里就放不下、被迫滚动
(用户反馈:"图标无法完全显示、需要上下滑动、滚动条碍眼")。 */
max-height: 96px;
transition:
opacity calc(var(--nav-dur) * 0.7) var(--nav-ease),
padding var(--nav-dur) var(--nav-ease),
max-height var(--nav-dur) var(--nav-ease),
border-top-color var(--nav-dur) var(--nav-ease);
}
/* 折叠:页脚淡出、内边距与高度一起收到 0,分隔线也透明掉。
`min-height: 0` 是必需的:flex 子项默认 `min-height: auto`(= 内容高度),
而 CSS 里 min-height 优先于 max-height —— 不显式归零的话 max-height:0 根本压不下去。 */
.aside.ch-nav.is-collapsed .aside-footer {
opacity: 0;
padding-top: 0;
padding-bottom: 0;
max-height: 0;
min-height: 0;
border-top-color: transparent;
pointer-events: none;
}
.aside-meta {
@@ -279,7 +680,7 @@ onUnmounted(() => {
.header {
height: 52px;
background: rgba(255, 255, 255, 0.6);
background: var(--ch-header-bg);
backdrop-filter: blur(20px);
border-bottom: 1px solid var(--ch-border);
display: flex;
@@ -301,6 +702,17 @@ onUnmounted(() => {
color: var(--ch-text-tertiary);
}
.header-right {
/* 必须是 flex:Element Plus 的按钮是 `vertical-align: middle`、下拉容器是 `vertical-align: top`,
放在普通 block 容器里两个 inline-level 子元素会按各自的垂直对齐规则落位,主题切换按钮与
用户名牌就会错开几像素(用户反馈的"深色模式没对齐",其实两种主题下都偏,只是深色下更显眼)。
与 Web 管理端 AdminLayout 的同名容器保持同一套写法。 */
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.user-chip {
display: flex;
align-items: center;
@@ -313,7 +725,12 @@ onUnmounted(() => {
}
.user-chip:hover {
background: rgba(0, 0, 0, 0.04);
background: var(--ch-hover-soft);
}
.theme-toggle {
/* 与用户名牌的间距由 .header-right 的 gap 负责,这里不再自带 margin */
color: var(--ch-text-secondary);
}
.main {
+25 -5
View File
@@ -2,27 +2,35 @@ import { createApp } from 'vue';
import { createPinia } from 'pinia';
import ElementPlus from 'element-plus';
import zhCn from 'element-plus/es/locale/lang/zh-cn';
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import 'element-plus/dist/index.css';
// 深色主题:html.dark 下的 --el-* 变量(品牌覆盖在 styles/index.css 的 html.dark 块)
import 'element-plus/theme-chalk/dark/css-vars.css';
import App from './App.vue';
import router from './router/index.js';
import { registerSmokeHooks, setSmokeTeacherCredentials } from './cache/self-test.js';
import { registerAppIcons } from './icons.js';
import { applyMotionTokens } from './composables/motion.js';
import './styles/index.css';
const app = createApp(App);
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
app.component(name, component);
}
// 按需注册图标(见 icons.ts:只注册用到的那些,而不是全量 293 个)
registerAppIcons(app);
app.use(createPinia());
app.use(router);
app.use(ElementPlus, { locale: zhCn });
// 冒烟验证模式:把缓存自检钩子暴露给主进程调用(正常运行时不存在)。
//
// **动态 import 是刻意的**:`cache/self-test.ts` 有 1000 多行自检代码,
// 静态 import 会把它打进渲染进程入口 chunk,每次启动都要解析一遍。改成动态 import 后
// Vite/Rollup 会把它切成独立 chunk,只在冒烟模式下才加载(`verify:desktop` /
// `verify:packaged` 的那条路径)。
//
// 教师凭据由 preload 从环境变量透传(**不写在代码里**:渲染产物会进 app.asar 发给学生机)。
if (window.desktop?.smokeTest) {
const { registerSmokeHooks, setSmokeTeacherCredentials } = await import('./cache/self-test.js');
setSmokeTeacherCredentials({
username: window.desktop.smokeCredentials?.username ?? '',
password: window.desktop.smokeCredentials?.password ?? '',
@@ -30,5 +38,17 @@ if (window.desktop?.smokeTest) {
registerSmokeHooks();
}
/**
* 动效令牌(beUI 那套缓动与弹簧,见 packages/shared/src/motion.ts)写进 :root。
*
* 必须在 mount 之前:CSS 里引用的 `var(--ch-spring-*)` 要在首帧之前就已就位,
* 否则第一帧会按"变量不存在"渲染一遍(表现为入场动画闪一下)。
* 也刻意排在主题恢复(stores/ui.ts 的 init)之前 —— 令牌与主题互不相干,
* 但先落令牌能让首屏的入场动画从第一帧起就是对的节奏。
*
* ⚠️ 灵动岛是**另一个渲染进程**(src/island/main.ts),不经过这里。
*/
applyMotionTokens();
await router.isReady();
app.mount('#app');
@@ -41,11 +41,46 @@ const routes: RouteRecordRaw[] = [
component: () => import('../views/GradeView.vue'),
meta: { title: '成绩' },
},
/* 设置:主侧边栏「设置」分组下的六个子页(ClassIsland 式 主菜单+子菜单) */
{
path: 'settings',
name: 'settings',
component: () => import('../views/SettingsView.vue'),
meta: { title: '设置' },
redirect: '/settings/general',
},
{
path: 'settings/general',
name: 'settings-general',
component: () => import('../views/settings/GeneralSettingsView.vue'),
meta: { title: '通用设置' },
},
{
path: 'settings/appearance',
name: 'settings-appearance',
component: () => import('../views/settings/AppearanceView.vue'),
meta: { title: '外观' },
},
{
path: 'settings/island',
name: 'settings-island',
component: () => import('../views/settings/IslandSettingsView.vue'),
meta: { title: '灵动岛' },
},
{
path: 'settings/reminder',
name: 'settings-reminder',
component: () => import('../views/settings/ReminderSettingsView.vue'),
meta: { title: '提醒' },
},
{
path: 'settings/account',
name: 'settings-account',
component: () => import('../views/settings/AccountSettingsView.vue'),
meta: { title: '账号' },
},
{
path: 'settings/about',
name: 'settings-about',
component: () => import('../views/settings/AboutView.vue'),
meta: { title: '关于' },
},
],
},
@@ -0,0 +1,24 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
/**
* 初次启动引导的显示状态。
*
* 首次启动由 App.vue 按「配置里是否记过 onboardingDone」自动弹出;
* 登录页与设置页的「使用引导」入口调用 open() 随时重看。
* 单独一个 store 而不是组件内部 ref:触发入口(登录页 / 设置页)与
* 渲染点(App.vue 顶层)分处两棵互不相干的子树。
*/
export const useOnboardingStore = defineStore('onboarding', () => {
const visible = ref(false);
function open(): void {
visible.value = true;
}
function close(): void {
visible.value = false;
}
return { visible, open, close };
});
@@ -0,0 +1,73 @@
import { ref } from 'vue';
import { defineStore } from 'pinia';
import type { DesktopStoredConfig } from '../../types/desktop.js';
/**
* 界面偏好(主题 / 侧边栏折叠)。
*
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
*/
const THEME_KEY = 'classhelper.theme';
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
function applyThemeClass(theme: 'light' | 'dark'): void {
document.documentElement.classList.toggle('dark', theme === 'dark');
}
export const useUiStore = defineStore('ui', () => {
const theme = ref<'light' | 'dark'>('light');
const sidebarCollapsed = ref(false);
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
async function init(): Promise<void> {
if (window.desktop) {
const config = await window.desktop.getConfig().catch(() => null);
if (config) {
theme.value = config.theme === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = config.sidebarCollapsed === true;
}
} else {
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
}
applyThemeClass(theme.value);
}
/** 切换主题(外观页单选 / 顶栏快捷切换共用),写回配置 */
async function setTheme(next: 'light' | 'dark'): Promise<void> {
theme.value = next;
applyThemeClass(next);
if (window.desktop) {
await window.desktop.saveConfig({ theme: next }).catch(() => undefined);
} else {
localStorage.setItem(THEME_KEY, next);
}
}
function toggleTheme(): void {
void setTheme(theme.value === 'dark' ? 'light' : 'dark');
}
/** 主侧边栏折叠/展开(汉堡按钮),写回配置 */
async function setSidebarCollapsed(collapsed: boolean): Promise<void> {
sidebarCollapsed.value = collapsed;
if (window.desktop) {
await window.desktop.saveConfig({ sidebarCollapsed: collapsed }).catch(() => undefined);
} else {
localStorage.setItem(SIDEBAR_KEY, collapsed ? '1' : '0');
}
}
function toggleSidebar(): void {
void setSidebarCollapsed(!sidebarCollapsed.value);
}
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
});
/** 供类型检查使用(仅引用类型) */
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
@@ -1,12 +1,19 @@
/* ============================================================================
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
*
* 参考 ClassIsland(Windows 课件表应用)的观感:
* - WinUI / Fluent 设计语言:浅色 Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
* - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
* - 强调色 #0F6CBD(悬停 #115EA3),选中项用强调色淡底 + 左侧强调条
* - 圆角:卡片 8px、控件 4~6px、胶囊 999px
* - 字体:Segoe UI Variable / 微软雅黑 UI,字号偏小、行距偏松
* - 阴影极轻(0 2px 4px rgba(0,0,0,.04)),层次主要靠描边与底色区分
*
* 主题机制:
* - 全部颜色走 --ch-* / --el-* 变量,:root 是浅色值;
* - 深色主题 = html 根元素挂 .dark 类(由 stores/ui.ts 按配置切换),
* Element Plus 的 dark/css-vars.css(main.ts 引入)提供 --el-* 深色默认,
* 下面的 html.dark 块覆盖品牌变量(特异性高于 :root,必定生效);
* - 组件里**不要写死颜色**,一律用变量——深色回归靠这条纪律守着。
* ========================================================================== */
:root {
@@ -33,12 +40,40 @@
--ch-text-secondary: #5c5c5c;
--ch-text-tertiary: #8a8a8a;
/* 交互态(悬停底色 / 侧栏与顶栏的亚克力底 / 滚动条) */
--ch-hover-soft: rgba(0, 0, 0, 0.04);
--ch-nav-bg: rgba(255, 255, 255, 0.65);
--ch-header-bg: rgba(255, 255, 255, 0.6);
--ch-scrollbar: rgba(0, 0, 0, 0.18);
--ch-scrollbar-hover: rgba(0, 0, 0, 0.3);
/* 圆角与阴影 */
--ch-radius-card: 8px;
--ch-radius-control: 4px;
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.08);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.04);
/*
* 侧栏收放动画。组件(ClientLayout 的 .aside/.brand/.brand-logo/.aside-footer)
* 与全局的菜单导轨规则(下面的 .ch-nav.is-collapsed …)共用这一份参数,
* 改时长/缓动只改这里,两边不会漂。
*
* ⚠️ 缓动**刻意保留 Material 的 cubic-bezier(.4, 0, .2, 1)**,没有换成全站统一的
* beUI EASE_OUT。试过的后果:EASE_OUT 起步极快,宽度掉到 80px 以下时动画才走了约 35%,
* 而冒烟自检 layoutChromeSelfTest 一旦读到"宽度 ≤ 80"就立刻量图标中心 ——
* 此刻内边距还在动,量出来 6 个分组子项偏 2.3px(要求 ≤2px),直接判红。
* 原曲线的 0.882 进度点落在 74% 处,剩下的时间足够动画跑完,量到的是稳态。
* 换句话说:**这条断言是按"收完之后的几何"写的,而它采样的时机取决于缓动的形状**,
* 想换曲线就得连那条断言一起重新商量。
*
* ⚠️ 时长**不要**改到 0.05s 以下:同一条自检断言"折叠必须真的是动画"
* (读 getComputedStyle 的 transitionDuration,要求 > 0.05)。
*/
--nav-dur: 260ms;
--nav-ease: cubic-bezier(0.4, 0, 0.2, 1);
/* 图标与文字的间距(折叠时收到 0) */
--nav-gap: 8px;
/* 兼容旧变量名 */
--ch-sidebar: #ffffff;
@@ -62,6 +97,55 @@
--el-font-size-base: 13px;
}
/* ---------------------------------------------------------------- 深色主题 */
html.dark {
--ch-accent: #5aabec;
--ch-accent-hover: #7cc0f4;
--ch-accent-soft: rgba(90, 171, 236, 0.16);
--ch-accent-soft-strong: rgba(90, 171, 236, 0.26);
--ch-bg: #161719;
--ch-layer: rgba(32, 33, 36, 0.72);
--ch-layer-solid: #1e1f23;
--ch-layer-alt: rgba(28, 29, 32, 0.5);
--ch-card-subtle: rgba(255, 255, 255, 0.04);
--ch-border: rgba(255, 255, 255, 0.08);
--ch-border-strong: rgba(255, 255, 255, 0.14);
--ch-divider: rgba(255, 255, 255, 0.06);
--ch-text: #e8e8ea;
--ch-text-secondary: #b0b1b5;
--ch-text-tertiary: #7e7f85;
--ch-hover-soft: rgba(255, 255, 255, 0.07);
--ch-nav-bg: rgba(28, 29, 32, 0.65);
--ch-header-bg: rgba(30, 31, 34, 0.6);
--ch-scrollbar: rgba(255, 255, 255, 0.22);
--ch-scrollbar-hover: rgba(255, 255, 255, 0.36);
--ch-shadow-flyout: 0 8px 16px rgba(0, 0, 0, 0.45);
--ch-shadow-card: 0 2px 4px rgba(0, 0, 0, 0.3);
--ch-sidebar: #1e1f23;
/* Element Plus 深色下的品牌色对齐(dark/css-vars.css 的默认蓝偏亮,这里对齐品牌) */
--el-color-primary: #4a9eff;
--el-color-primary-light-3: #337fd6;
--el-color-primary-light-5: #2563ab;
--el-color-primary-light-7: #1b4a82;
--el-color-primary-light-8: #163c6b;
--el-color-primary-light-9: #122f55;
--el-color-primary-dark-2: #6cb4ff;
--el-text-color-primary: #e8e8ea;
--el-text-color-regular: #c9cacd;
--el-text-color-secondary: #9a9b9f;
--el-border-color: rgba(255, 255, 255, 0.12);
--el-border-color-light: rgba(255, 255, 255, 0.08);
--el-border-color-lighter: rgba(255, 255, 255, 0.06);
}
* {
box-sizing: border-box;
}
@@ -179,12 +263,12 @@ textarea {
}
.el-button--default {
background: rgba(255, 255, 255, 0.7) !important;
background: var(--ch-layer) !important;
border-color: var(--ch-border-strong) !important;
}
.el-button--default:hover {
background: rgba(0, 0, 0, 0.04) !important;
background: var(--ch-hover-soft) !important;
}
.el-button--primary {
@@ -195,7 +279,7 @@ textarea {
.el-textarea__inner,
.el-select__wrapper {
border-radius: var(--ch-radius-control) !important;
background: rgba(255, 255, 255, 0.7) !important;
background: var(--ch-layer) !important;
box-shadow: 0 0 0 1px var(--ch-border-strong) inset !important;
}
@@ -224,12 +308,38 @@ textarea {
box-shadow: var(--ch-shadow-flyout) !important;
}
/* 关于页的折叠卡片:与 el-card 同一套观感(分割线代替卡片描边) */
.el-collapse {
--el-collapse-border-color: var(--ch-divider);
border: none !important;
}
.el-collapse-item__header {
background: var(--ch-layer) !important;
border: 1px solid var(--ch-border) !important;
border-radius: var(--ch-radius-card) !important;
padding: 0 16px;
margin-top: 8px;
font-weight: 600;
font-size: 13.5px;
}
.el-collapse-item__wrap {
background: transparent !important;
border-bottom: none !important;
}
.el-collapse-item__content {
padding: 12px 6px 4px !important;
color: var(--ch-text);
}
/* ---------------------------------------------------------------- 表格 */
.el-table {
--el-table-border-color: var(--ch-divider);
--el-table-header-bg-color: transparent;
--el-table-row-hover-bg-color: rgba(0, 0, 0, 0.03);
--el-table-row-hover-bg-color: var(--ch-hover-soft);
background: transparent !important;
}
@@ -251,12 +361,13 @@ textarea {
/* ---------------------------------------------------------------- 侧边导航(Fluent NavigationView 观感) */
.ch-nav {
background: rgba(255, 255, 255, 0.65);
background: var(--ch-nav-bg);
backdrop-filter: blur(24px) saturate(1.2);
border-right: 1px solid var(--ch-border);
}
.ch-nav .el-menu-item {
.ch-nav .el-menu-item,
.ch-nav .el-sub-menu__title {
height: 40px;
line-height: 40px;
margin: 2px 8px;
@@ -266,26 +377,122 @@ textarea {
position: relative;
}
.ch-nav .el-menu-item:hover {
background: rgba(0, 0, 0, 0.04);
.ch-nav .el-menu-item:hover,
.ch-nav .el-sub-menu__title:hover {
background: var(--ch-hover-soft);
}
/* 分组标题:比普通菜单项轻一档(分组是"容器",不是目的地) */
.ch-nav .el-sub-menu__title {
font-weight: 600;
color: var(--ch-text-secondary);
}
.ch-nav .el-sub-menu .el-menu {
background: transparent;
}
/*
* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式)。
*
* 底色与强调条**都改由滑动指示器承担**(见 ClientLayout.vue 的 `.nav-indicator`)——
* 原先每个激活项各画一块,切换时是"旧的瞬间消失、新的瞬间出现",看不出两者之间的关系;
* 现在由同一个底片滑过去。指示器是 z-index:0 的绝对定位元素,菜单项抬到 1 层,
* 文字才不会被它盖住(`.ch-nav .el-menu-item` 上面已有 `position: relative`)。
*/
.ch-nav .el-menu-item {
z-index: 1;
}
/* 选中项:强调色淡底 + 左侧强调条(WinUI 经典样式) */
.ch-nav .el-menu-item.is-active {
background: var(--ch-accent-soft);
color: var(--ch-text);
}
.ch-nav .el-menu-item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 16px;
border-radius: 2px;
background: var(--ch-accent);
/* ---------------------------------------------------------------- 折叠态:图标导轨
*
* 折叠**不再用** el-menu 的 `:collapse`:那会把每个分组的子项卸载进浮层(一次结构切换),
* 并让整条菜单重排一遍 —— 既做不出连续动画,也是"点汉堡卡一下"的渲染侧来源。
* 现在改成纯粹的 CSS 收拢:栏宽、文字宽度、图标位置全是可过渡属性,全程没有结构变化,
* 折叠后就是一条图标导轨(分组标题与子项都以图标列出)。
*
* 几何(64px 栏、菜单项左右各 8px margin):
* 展开 一级项 padding-left 20px、二级项 40px(Element Plus 自带)→ 图标中心 40 / 60px
* 折叠 所有项 padding-left / padding-right 都是 12px → 图标中心 8 + 12 + 12 = 32px,
* 正好与顶部汉堡按钮的居中位置同列(冒烟断言中心差 ≤ 2px)。
* 图标槽(.menu-icon-slot)与分组标题里的裸图标都是 Element Plus 给的 24px 宽。
*/
/* 图标与文字的间距改由文字的 margin-left 负责:Element Plus 给图标挂了 margin-right:5px,
那会让图标在 24px 的槽里偏左 2.5px(折叠时肉眼可见地不居中)。 */
.ch-nav .el-menu-item [class^='el-icon'],
.ch-nav .el-sub-menu__title > [class^='el-icon'] {
margin-right: 0;
}
.ch-nav .el-menu-item,
.ch-nav .el-sub-menu__title {
transition:
padding-left var(--nav-dur) var(--nav-ease),
padding-right var(--nav-dur) var(--nav-ease),
border-color var(--el-transition-duration),
background-color var(--el-transition-duration),
color var(--el-transition-duration);
}
/*
* 文字:**不给它单独设动画终点**,宽度靠 flex 被压缩 —— `min-width:0` 让这个 flex 子项
* 能被压到 0(默认的 `min-width:auto` 会撑住内容宽度),多出来的字由 `overflow:hidden` 裁掉。
* 于是文字宽度天然跟着栏宽连续变化,不需要写什么魔法 max-width;再叠一层 opacity 淡出,
* 避免中间帧留下"半截字"(`display:none` 没法过渡,用不了)。
*/
.ch-nav .el-menu-item > span:not(.menu-icon-slot),
.ch-nav .el-sub-menu__title > span:not(.menu-icon-slot) {
min-width: 0;
margin-left: var(--nav-gap);
overflow: hidden;
white-space: nowrap;
transition:
margin-left var(--nav-dur) var(--nav-ease),
opacity calc(var(--nav-dur) * 0.6) var(--nav-ease);
}
/* 分组标题右侧的折叠箭头:导轨里没有它的位置 */
.ch-nav .el-sub-menu__icon-arrow {
transition: opacity calc(var(--nav-dur) * 0.5) var(--nav-ease);
}
.ch-nav.is-collapsed .el-menu-item > span:not(.menu-icon-slot),
.ch-nav.is-collapsed .el-sub-menu__title > span:not(.menu-icon-slot) {
margin-left: 0;
opacity: 0;
pointer-events: none;
}
.ch-nav.is-collapsed .el-sub-menu__icon-arrow {
opacity: 0;
}
/*
* 折叠后的内边距。
*
* 这三条选择器形状都是**照抄 Element Plus 的**,不是冗余 —— 它按层级用三个不同的选择器算缩进
* (一级项 20px、分组标题 20px、分组内子项 40px),特异度分别是 0-4-0 / 0-4-0 / 0-5-0:
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-menu-item`
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu__title`
* `.el-menu--vertical:not(.el-menu--collapse):not(.el-menu--popup-container) .el-sub-menu .el-menu-item`
* 写成 `.ch-nav.is-collapsed .el-sub-menu__title` 这种(0-3-0)压不住任何一条 —— 实测踩过两次:
* 第一版漏了分组标题(偏 8.0px),把「学习」拆成一级项后又漏了一级项(同样偏 8.0px)。
* 下面两条(0-5-0 / 0-6-0)把它们全覆盖,且不用 !important。
*/
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-menu-item,
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu__title {
padding-left: 12px;
padding-right: 12px;
}
.ch-nav.is-collapsed .el-menu--vertical:not(.el-menu--collapse) .el-sub-menu .el-menu-item {
padding-left: 12px;
padding-right: 12px;
}
/* ---------------------------------------------------------------- 课表时间轴 */
@@ -320,17 +527,422 @@ textarea {
}
::-webkit-scrollbar-thumb {
background: rgba(0, 0, 0, 0.18);
background: var(--ch-scrollbar);
border: 3px solid transparent;
background-clip: content-box;
border-radius: 8px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(0, 0, 0, 0.3);
background: var(--ch-scrollbar-hover);
background-clip: content-box;
}
::-webkit-scrollbar-track {
background: transparent;
}
/* ============================================================================
* 动效层 —— beUI 的动效语言
*
* 令牌(缓动曲线、六组弹簧、时长)在 `packages/shared/src/motion.ts`,
* 由 `main.ts` 在 mount 之前写进 `:root`;这里只负责**用**它们。
* 与 Web 管理端(packages/web-admin/src/styles/index.css)是同一套词汇表。
*
* ⚠️ 灵动岛**不在**这套体系里:它是另一个渲染进程,用自研的 WinIsland 弹簧物理
* (src/island/spring.ts),刻意保持独立,不要往那边接这里的变量。
*
* 分三层,越往下越"重":
* ① 变量调音 —— 改写 Element Plus 自己的过渡变量,一次性改掉全站组件库过渡;
* ② 弹簧覆盖 —— 弹窗 / 抽屉 / 浮层换成真正的物理曲线;
* ③ 自有动效 —— 页面过渡、列表错峰入场、按钮按压、卡片抬升。
* ========================================================================== */
/* ------------------------------------------------- ① 变量调音(覆盖组件库)
*
* Element Plus 的每个过渡都由这几个变量拼出来(`--el-transition-all` / `-fade` /
* `-md-fade`),调这里等于一次性调完全站,比逐个覆盖类名安全。
*
* Fluent/ClassIsland 观感本来就偏"轻快",原来 300/200ms 的默认值在这套界面里更显拖沓,
* 收到 200/140ms;缓动换成 beUI 的 EASE_OUT。
*
* 只改时长与缓动、不改颜色,所以不需要在 html.dark 里再写一份。
* 侧栏的 `--nav-ease` 也在文件开头的 :root 里改成了同一条曲线(与折叠几何放在一起,
* 免得"改侧栏动画要翻两个地方")。
*/
:root {
--el-transition-duration: 200ms;
--el-transition-duration-fast: 140ms;
--el-transition-function-ease-in-out-bezier: cubic-bezier(0.16, 1, 0.3, 1);
--el-transition-function-fast-bezier: cubic-bezier(0.16, 1, 0.3, 1);
}
/* ------------------------------------------------------------ ② 弹簧覆盖 */
/*
* 弹窗(el-dialog)。Element Plus 的入场做在 `.el-overlay-dialog`(居中容器)上,
* 遮罩淡入在 `.dialog-fade-enter-active` 自己身上,两条都要换 ——
* 否则遮罩先到位、弹窗还在动,看起来是"背景已经变暗了但框还没出来"。
*
* 弹簧 PANEL 总时长 463ms 看着偏长,但它 95% 的行程在 262ms 就走完了,
* 剩下的是极慢的收尾 —— 观感上就是"200ms 到位、再轻轻落定一下"。
*/
@keyframes ch-panel-in {
from {
opacity: 0;
transform: translateY(-12px) scale(0.97);
}
to {
opacity: 1;
transform: none;
}
}
@keyframes ch-panel-out {
from {
opacity: 1;
transform: none;
}
to {
opacity: 0;
transform: translateY(-6px) scale(0.985);
}
}
/*
* 位移与缩放挂在 `.el-dialog` **自己**身上,不挂在 `.el-overlay-dialog`(居中容器)上。
*
* 挂外层容器有个隐蔽的坑:Element Plus 的「打开完成」回调(@opened)按**根元素**
* (`.el-overlay`,只有 200ms 的淡入)的动画结束触发,而外层容器上的弹簧要跑 463ms ——
* 于是任何在 @opened 里量容器尺寸做自适应的弹窗,都会量到"还带着 scale(0.97) 的缩小版"。
* **本客户端的作业看板正是这么算 scale 的**:测小了会被最小缩放兜住,反而超出屏幕 7px
* (冒烟断言「作业看板全屏自适应(铺满屏幕、内容不溢出)」实测逮到,两次跑出来都是 550 > 543)。
* 挂到 `.el-dialog` 上还有个好处:全屏弹窗能靠 `is-fullscreen` 精确豁免(见下)。
*/
.dialog-fade-enter-active .el-overlay-dialog,
.dialog-fade-leave-active .el-overlay-dialog {
/* 抵消 Element Plus 自带的那两条(它们同样带 translateY,会与下面这两条叠起来) */
animation: none;
}
.dialog-fade-enter-active .el-dialog {
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
}
/*
* 退出**刻意比入场快得多**:用户已经决定关掉它了,这时候再让他等一段"有分量"的动画
* 只会显得卡。而且冒烟里多处断言"窗口/弹窗已消失",退出越快越稳。
*/
.dialog-fade-leave-active .el-dialog {
animation: ch-panel-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
/*
* 全屏弹窗(`is-fullscreen`,就是作业看板)**不做位移缩放**。
* 它本来就是铺满整屏的表面,"从略小处落下来"既看不出效果,又会让内部的
* fitBoard / fitFullscreen 在 @opened 那一刻量到被 scale 过的容器 —— 测小了会被
* 最小缩放兜住,结果反而超出屏幕。只留淡入。
*/
.dialog-fade-enter-active .el-dialog.is-fullscreen {
animation: ch-fade-in var(--ch-dur-base) var(--ch-ease-out) both;
}
.dialog-fade-leave-active .el-dialog.is-fullscreen {
animation: ch-fade-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
/*
* 确认框(ElMessageBox)。这条沿用了"动画挂在遮罩层上"的做法:
* 它内部没有任何按容器尺寸做自适应的内容,不存在上面那个 @opened 测量的坑。
*/
.el-overlay-message-box {
animation: ch-panel-in var(--ch-spring-panel-dur) var(--ch-spring-panel) both;
}
.dialog-fade-enter-active {
animation: ch-fade-in var(--ch-dur-base) var(--ch-ease-out) both;
}
.dialog-fade-leave-active {
animation: ch-fade-out var(--ch-dur-fast) var(--ch-ease-out) both;
}
@keyframes ch-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes ch-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
/*
* 抽屉。位移由 `.el-drawer` **自身**的 `transition: all` 承载
* (`.el-drawer-fade-*` 那组类只管遮罩与 overflow),所以弹簧挂在 `.el-drawer` 上,
* 且显式列出属性 —— 不想让背景色的变化也走弹簧曲线。
*/
.el-drawer {
transition:
transform var(--ch-spring-panel-dur) var(--ch-spring-panel),
opacity var(--ch-dur-base) var(--ch-ease-out);
}
/*
* 浮层(下拉菜单 / 选择器 / 气泡)。Element Plus 用 `el-zoom-in-*` 系列,
* transform-origin 已按 placement 设好。换成 SWAP 弹簧(263ms,比 PANEL 短):
* 这类浮层是高频出现的,每次点下拉都等半秒会烦。
*/
.el-zoom-in-top-enter-active,
.el-zoom-in-top-leave-active,
.el-zoom-in-bottom-enter-active,
.el-zoom-in-bottom-leave-active,
.el-zoom-in-center-enter-active,
.el-zoom-in-center-leave-active,
.el-zoom-in-left-enter-active,
.el-zoom-in-left-leave-active {
transition:
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
opacity var(--ch-dur-fast) var(--ch-ease-out);
}
.el-zoom-in-top-enter-from,
.el-zoom-in-bottom-enter-from,
.el-zoom-in-center-enter-from,
.el-zoom-in-left-enter-from {
opacity: 0;
transform: scale(0.95);
}
.el-zoom-in-top-leave-active,
.el-zoom-in-bottom-leave-active,
.el-zoom-in-center-leave-active,
.el-zoom-in-left-leave-active {
opacity: 0;
transform: scale(0.97);
}
/* 消息条与通知:原生的位移是 0.4s,连弹三条时明显拖沓 */
.el-message {
transition:
opacity var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
top var(--ch-spring-swap-dur) var(--ch-spring-swap),
bottom var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
.el-notification {
transition:
opacity var(--ch-dur-fast) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap),
left var(--ch-spring-swap-dur) var(--ch-spring-swap),
right var(--ch-spring-swap-dur) var(--ch-spring-swap),
top var(--ch-spring-swap-dur) var(--ch-spring-swap),
bottom var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
/* ------------------------------------------------------------ ③ 自有动效 */
/*
* 按钮按压。Fluent 的按钮只有颜色过渡,没有"按下去"的物理反馈。
*
* 上行/下行用**不同的缓动**,这是这里唯一需要技巧的地方:
* - 按下(`:active` 生效时):90ms 直落,要"跟手",慢了就是延迟;
* - 抬起(`:active` 撤销后回到基础规则):走 PRESS 弹簧,轻轻弹回原位。
* CSS 的规则是"进入某状态用该状态的 transition、离开用目标状态的 transition",
* 所以把弹簧写在基础规则、把快落写在 `:active` 里,正好得到想要的一对。
*/
.el-button {
transition:
transform var(--ch-spring-press-dur) var(--ch-spring-press),
color var(--el-transition-duration-fast) var(--ch-ease-out),
border-color var(--el-transition-duration-fast) var(--ch-ease-out),
background-color var(--el-transition-duration-fast) var(--ch-ease-out),
box-shadow var(--el-transition-duration-fast) var(--ch-ease-out);
}
.el-button:not(.is-disabled):active {
transform: scale(var(--ch-press-scale));
transition-duration: var(--ch-dur-instant);
transition-timing-function: var(--ch-ease-out);
}
/*
* 卡片抬升。**必须关在 `@media (hover: hover) and (pointer: fine)` 里**:
* 教室机器的希沃触摸屏点一下会产生"幽灵 hover"并一直粘着,
* 不加这层的话卡片被点过之后就永远浮着(见 AGENTS.md §5 第 30 条)。
* 这也正是 `composables/motion.ts` 里 `useHoverCapable()` 的 CSS 版本 ——
* 纯 CSS 效果用媒体查询就够了,不必为它挂一个 composable。
*/
@media (hover: hover) and (pointer: fine) {
.el-card {
transition:
border-color var(--el-transition-duration-fast) var(--ch-ease-out),
box-shadow var(--el-transition-duration) var(--ch-ease-out),
transform var(--ch-spring-swap-dur) var(--ch-spring-swap);
}
.el-card:hover {
transform: translateY(-1px);
}
}
/* 表格行:hover 底色快速跟上,别用 Element Plus 的 0.3s */
.el-table td.el-table__cell {
transition: background-color var(--el-transition-duration-fast) var(--ch-ease-out);
}
/*
* 列表错峰入场。配合 `composables/motion.ts` 的 `useStaggerIn()` 使用 ——
* 那个函数按索引下发 `--ch-reveal-delay`,延迟在 CSS 里生效。
* `both` 是必需的:否则带延迟的元素会在延迟期间先按终态渲染一下(闪一帧)。
*/
@keyframes ch-reveal-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.ch-reveal {
animation: ch-reveal-in var(--ch-dur-slow) var(--ch-ease-out) both;
animation-delay: var(--ch-reveal-delay, 0ms);
}
/*
* 页面切换**没有**容器级的过渡 —— 这里曾经有一对 `.ch-page-enter-active` /
* `-leave-active`(配 `<transition mode="out-in">`),已经去掉,原因写在
* ClientLayout.vue 的 router-view 注释里(延期换页 + 窗口被遮挡时卡死)。
* 页面切换的动效全部由下面的 `.page > *` 错峰入场承担。
*/
/*
* 页面内容的错峰入场。
*
* 挂在 `.page > *` 上而不是逐个视图去写类名:所有视图(含六个设置子页)的根容器
* 都叫 `.page`,一条规则覆盖全部,新增视图自动享有,也不会有人忘记加。
*
* 延迟按**块**递进(30ms 一档、封顶 180ms):一屏通常是"页头 + 两三块内容",
* 这个节奏读起来是依次浮现;给更多块继续累加就变成排队了,所以第 7 块起不再递增。
*/
@keyframes ch-page-block-in {
from {
opacity: 0;
transform: translateY(6px);
}
to {
opacity: 1;
transform: none;
}
}
.page > * {
animation: ch-page-block-in var(--ch-dur-slow) var(--ch-ease-out) both;
}
.page > *:nth-child(2) {
animation-delay: 30ms;
}
.page > *:nth-child(3) {
animation-delay: 60ms;
}
.page > *:nth-child(4) {
animation-delay: 90ms;
}
.page > *:nth-child(5) {
animation-delay: 120ms;
}
.page > *:nth-child(6) {
animation-delay: 150ms;
}
.page > *:nth-child(n + 7) {
animation-delay: 180ms;
}
/*
* 三个例外,都是"动了就会破坏别人的测量":
* ① `v-loading` 的遮罩是插在 `.page` 里的直接子元素,让它跟着淡入会闪一下;
* ② **弹窗遮罩**(`.el-overlay`)。Element Plus 的 `el-dialog` 默认 `append-to-body=false`,
* 所以声明在视图里的弹窗,它的遮罩是 `.page` 的**直接子元素**。而 `.el-overlay` 是
* `position: fixed; inset: 0` 的全屏层,整体下移 6px 就会把"刚好铺满"的内容顶出屏幕 ——
* 作业看板正是这样被顶出去的(冒烟断言「作业看板全屏自适应」读到可视底 550 > 视口高 543)。
* 全屏层本来也不该参与"内容块依次浮现",它一出现就该盖住整屏。
* ③ **看板容器**(`.board-host`):它的尺寸是按"刚好铺满屏幕"用 `transform: scale()`
* 精确算出来的(见 HomeworkView 的 computeScale),算完就是贴边的,同样经不起位移。
* 一句话:**凡是 `position: fixed` 的整屏层、或自己量几何再据此设尺寸的元素,
* 都不要叠位移类动画。**
*/
.page > .el-loading-mask,
.page > .el-overlay,
.page > .board-host {
animation: none;
}
/* ------------------------------------------------------- 降低动态效果偏好
*
* 系统开了「减少动态效果」时,把全站的动画压到 1ms —— 不是删掉,
* 而是让它在同一帧内完成(`transition: none` 会让依赖 transitionend 的逻辑失灵)。
*/
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
transition-duration: 1ms !important;
scroll-behavior: auto !important;
}
/*
* 例外:加载指示旋的是"状态"不是"装饰"。把它一起停掉会让界面看起来像卡死了
* (用户会去重复点击),所以这里改为**放慢**而不是停。
*/
.el-icon.is-loading,
.el-loading-spinner .circular,
.el-loading-spinner svg,
.is-loading {
animation-duration: 1.6s !important;
animation-iteration-count: infinite !important;
}
}
/* ------------------------------------------------- 整页换肤(View Transition)
*
* `handleThemeToggle`(ClientLayout.vue)用 View Transition API 做"从点击位置扩散的圆"。
* 浏览器默认会给新旧两帧做一次交叉淡入 —— 这里把它关掉,改由 JS 用 WAAPI 驱动
* `::view-transition-new(root)` 的 clip-path。不关掉的话两套动画会叠在一起,
* 观感是"圆在扩散的同时整页又在闪"。
*
* 层级:老帧在下(z-index 1)、新帧在上(z-index 2),新帧的圆从无到有地盖住老帧。
* 不支持这个 API 的浏览器不会渲染这些伪元素,规则自然失效(JS 侧也有守卫)。
*/
::view-transition-old(root),
::view-transition-new(root) {
animation: none;
mix-blend-mode: normal;
}
::view-transition-old(root) {
z-index: 1;
}
::view-transition-new(root) {
z-index: 2;
}
@@ -1,6 +1,5 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue';
import * as echarts from 'echarts';
import {
SOCKET_EVENTS,
averageOf,
@@ -11,6 +10,7 @@ import {
} from '@classhelper/shared';
import { gradeApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import ScoreBarChart from '../components/ScoreBarChart.vue';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js';
@@ -24,8 +24,6 @@ const loading = ref(false);
const grades = ref<GradeDto[]>([]);
const fromCache = ref(false);
const updatedAt = ref<number | null>(null);
const chartRef = ref<HTMLDivElement>();
let chart: ReturnType<typeof echarts.init> | null = null;
const averagePercent = computed(() =>
grades.value.length === 0
@@ -53,6 +51,21 @@ const levelCounts = computed(() => {
return levels;
});
/**
* 按考试聚合平均得分率(最近 8 次考试),交给 ScoreBarChart 渲染。
* 这里原先调的是 `echarts.setOption(...)`;改用纯 CSS 柱状图是为了去掉
* echarts(渲染产物里最大的一块,1.1MB)。聚合口径与之前完全一致。
*/
const examAverages = computed(() => {
const byExam = new Map<string, number[]>();
for (const item of grades.value) {
const bucket = byExam.get(item.examName) ?? [];
bucket.push(gradePercent(item.score, item.totalScore));
byExam.set(item.examName, bucket);
}
return [...byExam.entries()].slice(-8).map(([label, percents]) => ({ label, value: averageOf(percents) }));
});
async function loadGrades(): Promise<void> {
loading.value = true;
try {
@@ -61,50 +74,11 @@ async function loadGrades(): Promise<void> {
fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt;
if (!result.fromCache) appStore.markSynced();
renderChart();
} finally {
loading.value = false;
}
}
function renderChart(): void {
if (!chartRef.value) return;
chart ??= echarts.init(chartRef.value);
// 按考试聚合平均得分率(最近 8 次考试)
const byExam = new Map<string, number[]>();
for (const item of grades.value) {
const bucket = byExam.get(item.examName) ?? [];
bucket.push(gradePercent(item.score, item.totalScore));
byExam.set(item.examName, bucket);
}
const exams = [...byExam.entries()].slice(-8);
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 44, right: 18, top: 30, bottom: 34 },
xAxis: {
type: 'category',
data: exams.map(([name]) => name),
axisLabel: { interval: 0, rotate: exams.length > 4 ? 20 : 0, fontSize: 11 },
},
yAxis: { type: 'value', max: 100, name: '得分率%' },
series: [
{
type: 'bar',
barMaxWidth: 46,
itemStyle: { color: '#409eff', borderRadius: [4, 4, 0, 0] },
label: { show: true, position: 'top', formatter: '{c}%', fontSize: 11 },
data: exams.map(([, percents]) => averageOf(percents)),
},
],
});
}
function handleResize(): void {
chart?.resize();
}
function onGradeEvent(): void {
void loadGrades();
}
@@ -115,15 +89,11 @@ function onRecovered(): void {
onMounted(async () => {
await loadGrades();
window.addEventListener('resize', handleResize);
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
appStore.onServerRecovered(onRecovered);
});
onUnmounted(() => {
window.removeEventListener('resize', handleResize);
chart?.dispose();
chart = null;
realtime.off(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
appStore.offServerRecovered(onRecovered);
});
@@ -172,7 +142,7 @@ onUnmounted(() => {
<el-col :xs="24" :md="16">
<el-card v-loading="loading" shadow="never">
<template #header><span>各次考试平均得分率</span></template>
<div ref="chartRef" class="chart"></div>
<ScoreBarChart :items="examAverages" />
</el-card>
</el-col>
</el-row>
@@ -226,7 +196,7 @@ onUnmounted(() => {
<style scoped>
.stat-card {
background: #fff;
background: var(--ch-layer-solid);
border: 1px solid var(--ch-border);
border-radius: 10px;
padding: 16px;
@@ -234,7 +204,7 @@ onUnmounted(() => {
.stat-label {
font-size: 13px;
color: #909399;
color: var(--ch-text-secondary);
}
.stat-value {
@@ -246,7 +216,7 @@ onUnmounted(() => {
.stat-hint {
margin-top: 4px;
font-size: 12px;
color: #c0c4cc;
color: var(--ch-text-tertiary);
}
.level-list {
@@ -255,9 +225,4 @@ onUnmounted(() => {
gap: 6px;
margin-top: 8px;
}
.chart {
width: 100%;
height: 300px;
}
</style>
@@ -916,7 +916,7 @@ onUnmounted(() => {
.content-block {
white-space: pre-wrap;
line-height: 1.75;
background: #fafafa;
background: var(--ch-layer-alt);
border-radius: 12px;
padding: 12px;
margin: 0;
@@ -1148,7 +1148,7 @@ onUnmounted(() => {
transform: translateX(-50%);
font-size: 14px;
font-weight: 500;
color: #6b7280;
color: var(--ch-text-tertiary);
white-space: nowrap;
}
@@ -1176,7 +1176,7 @@ onUnmounted(() => {
.submission-list {
max-height: 46vh;
overflow-y: auto;
border: 1px solid #ebeef5;
border: 1px solid var(--ch-divider);
border-radius: 12px;
padding: 12px;
}
@@ -5,12 +5,14 @@ import { ElMessage, type FormInstance, type FormRules } from 'element-plus';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useOnboardingStore } from '../stores/onboarding.js';
import { useRealtimeStore } from '../stores/realtime.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
import { DEFAULT_SERVER, BRAND_LOGO_URL, normalizeServerUrl } from '../config.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const onboarding = useOnboardingStore();
const realtime = useRealtimeStore();
const formRef = ref<FormInstance>();
@@ -91,7 +93,7 @@ async function enterOffline(): Promise<void> {
<div class="login-page">
<div class="login-card">
<div class="login-head">
<el-icon :size="30" color="#409eff"><School /></el-icon>
<img class="login-logo" :src="BRAND_LOGO_URL" alt="" width="44" height="44" />
<div>
<h1 class="login-title">班级小助手</h1>
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
@@ -134,6 +136,14 @@ async function enterOffline(): Promise<void> {
<el-button type="primary" :loading="auth.loading" @click="submit">登录</el-button>
</div>
<!-- 重看初次启动引导:首次启动会自动弹出过一次,这里供随时回看 -->
<div class="login-guide">
<el-button link type="primary" @click="onboarding.open()">
<el-icon><Guide /></el-icon>
查看使用引导
</el-button>
</div>
<el-alert
v-if="testResult === 'ok'"
class="mt-12"
@@ -177,7 +187,7 @@ async function enterOffline(): Promise<void> {
.login-card {
width: 420px;
background: #fff;
background: var(--ch-layer-solid);
border-radius: 14px;
padding: 28px 26px 20px;
box-shadow: 0 18px 40px rgba(15, 30, 50, 0.28);
@@ -190,6 +200,16 @@ async function enterOffline(): Promise<void> {
margin-bottom: 18px;
}
/*
* 品牌标:与应用图标同一份图(`public/logo.png`)。
* 图自带圆角与透明边角,不要再加 border-radius;`src` 走 `BRAND_LOGO_URL` 的理由同 ClientLayout。
* (表单里「班级码」输入框的前缀仍用 School 字形 —— 那是字段语义图标,不是品牌标。)
*/
.login-logo {
flex: none;
display: block;
}
.login-title {
margin: 0;
font-size: 20px;
@@ -198,7 +218,7 @@ async function enterOffline(): Promise<void> {
.login-sub {
margin: 4px 0 0;
font-size: 12px;
color: #909399;
color: var(--ch-text-secondary);
}
.login-actions {
@@ -210,6 +230,12 @@ async function enterOffline(): Promise<void> {
flex: 1;
}
.login-guide {
display: flex;
justify-content: center;
margin-top: 10px;
}
.offline-button {
width: 100%;
}
@@ -218,6 +244,6 @@ async function enterOffline(): Promise<void> {
margin: 16px 0 0;
font-size: 12px;
line-height: 1.7;
color: #c0c4cc;
color: var(--ch-text-tertiary);
}
</style>
@@ -165,11 +165,11 @@ onUnmounted(() => {
}
.notice-item:hover {
background: #f7f9fc;
background: var(--ch-hover-soft);
}
.notice-item.unread {
background: #f0f7ff;
background: var(--ch-accent-soft);
}
.notice-item:last-child {
@@ -194,7 +194,7 @@ onUnmounted(() => {
.notice-content {
margin-top: 6px;
font-size: 13px;
color: #606266;
color: var(--ch-text-secondary);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
@@ -220,7 +220,7 @@ onUnmounted(() => {
.content-block {
white-space: pre-wrap;
line-height: 1.8;
background: #fafafa;
background: var(--ch-layer-alt);
border-radius: 8px;
padding: 14px;
margin: 0 0 16px;
@@ -460,7 +460,7 @@ onUnmounted(() => {
}
.hero-status.status-idle {
background: rgba(0, 0, 0, 0.03);
background: var(--ch-hover-soft);
}
.status-title {
@@ -553,7 +553,7 @@ onUnmounted(() => {
width: 10px;
height: 10px;
border-radius: 50%;
background: #fff;
background: var(--ch-layer-solid);
border: 2px solid var(--ch-border-strong);
}
@@ -574,7 +574,8 @@ onUnmounted(() => {
padding: 10px 14px;
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
background: rgba(255, 255, 255, 0.6);
/* 走主题变量:浅色是白玻璃、深色是深玻璃(写死白色会在深色主题下变成"浅灰卡 + 浅字"看不清) */
background: var(--ch-layer);
transition:
border-color 0.15s ease,
background 0.15s ease,
@@ -595,7 +596,7 @@ onUnmounted(() => {
}
.timeline-item.is-past .timeline-card-inner {
background: rgba(255, 255, 255, 0.35);
background: var(--ch-layer-alt);
color: var(--ch-text-tertiary);
}
@@ -1,708 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_CHANNELS,
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES,
ISLAND_POSITION_LABELS,
ISLAND_STYLE_LABELS,
ISLAND_STYLES,
ISLAND_POSITIONS,
formatDate,
type ClassIslandNotificationChannel,
type IslandAppearance,
type IslandPosition,
type IslandStyle,
} from '@classhelper/shared';
import type { DesktopAppInfo } from '../../types/desktop.js';
import { classIslandStatusApi } from '../api/index.js';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { normalizeServerUrl } from '../config.js';
import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js';
import { useRealtimeStore } from '../stores/realtime.js';
const router = useRouter();
const appStore = useAppStore();
const auth = useAuthStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const serverInput = ref(appStore.serverUrl);
const testing = ref(false);
const saving = ref(false);
const appInfo = ref<DesktopAppInfo | null>(null);
const storeLabels: Record<string, string> = {
profile: '登录资料',
classes: '班级 / 学期',
schedules: '课表',
homeworks: '作业',
notifications: '通知',
grades: '成绩',
};
const cacheRows = computed(() =>
appStore.cacheStoreStats.map((item) => ({
label: storeLabels[item.store] ?? item.store,
store: item.store,
count: item.count,
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
})),
);
async function loadAppInfo(): Promise<void> {
if (!window.desktop) return;
appInfo.value = await window.desktop.getAppInfo();
}
async function saveServer(): Promise<void> {
const normalized = normalizeServerUrl(serverInput.value);
if (!normalized) {
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
return;
}
saving.value = true;
try {
await appStore.applyServerUrl(normalized);
serverInput.value = normalized;
ElMessage.success('服务器地址已保存');
await testConnection();
if (auth.token) realtime.connect(normalized, auth.token);
} finally {
saving.value = false;
}
}
async function testConnection(): Promise<void> {
testing.value = true;
try {
const result = await pingHealth();
if (result.ok) ElMessage.success('服务器连接正常');
else ElMessage.error('无法连接服务器,请检查地址与网络');
} finally {
testing.value = false;
}
}
async function manualSync(): Promise<void> {
setApiBaseUrl(appStore.serverUrl);
await appStore.ping();
if (appStore.offline) {
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
return;
}
await notifications.load();
appStore.markSynced();
ElMessage.success('已同步最新数据');
}
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
async function logout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
await router.replace('/login');
}
/* ------------------------------------------------------------ 通知显示位置 */
/**
* 通知显示位置:这台教室机器把提醒弹在哪个端。
*
* - both:ClassHelper 客户端(弹窗 + 灵动岛)与 ClassIsland 都弹(默认)
* - client:只弹 ClassHelper 客户端
* - classisland:只弹 ClassIsland(客户端只进通知中心,不弹窗、不上岛)
*
* 值会落本地配置(本机立刻生效)并写回班级记录(服务端据此决定要不要推 ClassIsland)。
*/
const channelSaving = ref(false);
/**
* 本班有没有接入 ClassIsland 设备。
*
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
*/
const classIslandConnected = ref(false);
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
const classIslandStatus = ref<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
} | null>(null);
const classIslandStatusText = computed(() => {
const status = classIslandStatus.value;
if (!status || !status.connected) return '未接入';
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
});
const channelOptions = computed(() =>
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
value,
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
disabled: value === 'classisland' && !classIslandConnected.value,
})),
);
async function loadClassChannel(): Promise<void> {
const classId = auth.classId;
if (!classId) return;
await appStore.loadNotificationChannel(classId);
}
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
channelSaving.value = true;
try {
const saved = await appStore.setNotificationChannel(auth.classId, value);
if (saved) ElMessage.success('提醒显示位置已更新');
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
} finally {
channelSaving.value = false;
}
}
/* ------------------------------------------------------------ 作业录入快捷短语 */
/**
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
*
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
*/
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
const phraseInput = ref('');
const savingPhrases = ref(false);
async function loadPhrases(): Promise<void> {
const config = await window.desktop?.getConfig?.();
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
}
async function savePhrases(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingPhrases.value = true;
try {
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
phrases.value = [...saved.homeworkPhrases];
} finally {
savingPhrases.value = false;
}
}
async function addPhrase(): Promise<void> {
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
if (!value) return;
if (phrases.value.includes(value)) {
ElMessage.warning('这条短语已经有了');
return;
}
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
return;
}
phrases.value = [...phrases.value, value];
phraseInput.value = '';
await savePhrases();
ElMessage.success('已添加');
}
async function removePhrase(value: string): Promise<void> {
phrases.value = phrases.value.filter((item) => item !== value);
await savePhrases();
}
async function resetPhrases(): Promise<void> {
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
await savePhrases();
ElMessage.success('已恢复默认');
}
/* ------------------------------------------------------------ 灵动岛个性化 */
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
/**
* 左右边距是否有效:只在停靠左 / 右时生效。
*
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
*/
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
const savingIsland = ref(false);
const positionOptions = ISLAND_POSITIONS.map((value) => ({
value,
label: ISLAND_POSITION_LABELS[value],
}));
const styleOptions = ISLAND_STYLES.map((value) => ({
value,
label: ISLAND_STYLE_LABELS[value],
}));
/** 读取当前生效的外观(主进程为单一事实来源) */
async function loadIslandAppearance(): Promise<void> {
if (!window.desktop?.islandGetAppearance) return;
island.value = await window.desktop.islandGetAppearance();
}
/**
* 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘。
* 高度/宽度只影响窗口与卡片尺寸变量,内容用 flex + 溢出滚动承载,不会溢出或错乱。
*
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
*/
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
island.value = { ...island.value, ...patch };
window.desktop?.islandSetAppearance({ ...island.value });
}
async function saveIslandAppearance(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingIsland.value = true;
try {
await window.desktop.saveConfig({ island: { ...island.value } });
ElMessage.success('个性化设置已保存');
} finally {
savingIsland.value = false;
}
}
async function resetIslandAppearance(): Promise<void> {
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
await saveIslandAppearance();
}
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
function testIsland(): void {
window.desktop?.islandPush?.({
notification: {
id: `appearance-test-${Date.now()}`,
title: '灵动岛外观预览',
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
priority: 'NORMAL',
createdAt: new Date().toISOString(),
courseName: null,
teacherName: '本地预览',
},
context: { inClass: false, currentPeriodEnd: null, preview: true },
});
}
onMounted(async () => {
await loadAppInfo();
await appStore.refreshCacheStats();
await loadIslandAppearance();
await loadClassChannel();
await loadPhrases();
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
if (auth.classId) {
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
classIslandStatus.value = status;
classIslandConnected.value = status?.connected === true;
}
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">设置</h2>
<p class="page-subtitle">服务器连接、账号信息与离线缓存管理</p>
</div>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>服务器连接</span></template>
<el-form label-position="top">
<el-form-item label="服务器地址">
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
<el-button @click="manualSync">立即同步</el-button>
</div>
<el-descriptions class="mt-16" :column="1" border size="small">
<el-descriptions-item label="当前状态">
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
</el-tag>
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
{{ realtime.statusText }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header>
<span>灵动岛 · 个性化</span>
</template>
<el-form label-width="96px" label-position="left">
<el-form-item :label="`高度 ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
:max="RANGES.height.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ height: value })"
/>
</el-form-item>
<el-form-item :label="`宽度 ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
:max="RANGES.width.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ width: value })"
/>
</el-form-item>
<el-form-item :label="`圆角 ${island.radius}px`">
<el-slider
:model-value="island.radius"
:min="RANGES.radius.min"
:max="RANGES.radius.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ radius: value })"
/>
</el-form-item>
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
<el-slider
:model-value="island.opacity"
:min="RANGES.opacity.min"
:max="RANGES.opacity.max"
:step="0.05"
@input="(value: number) => applyIslandAppearance({ opacity: value })"
/>
</el-form-item>
<el-form-item :label="`字号 ${island.fontSize}px`">
<el-slider
:model-value="island.fontSize"
:min="RANGES.fontSize.min"
:max="RANGES.fontSize.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
/>
</el-form-item>
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
<el-slider
:model-value="island.speed"
:min="RANGES.speed.min"
:max="RANGES.speed.max"
:step="0.1"
@input="(value: number) => applyIslandAppearance({ speed: value })"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker
:model-value="island.accent"
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
/>
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="视觉风格">
<el-select
:model-value="island.style"
style="width: 180px"
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
>
<el-option
v-for="item in styleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item>
<el-form-item label="显示位置">
<el-select
:model-value="island.position"
style="width: 180px"
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
>
<el-option
v-for="item in positionOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item :label="`左右边距 ${island.marginX}px`">
<el-slider
:model-value="island.marginX"
:min="RANGES.marginX.min"
:max="RANGES.marginX.max"
:step="2"
:disabled="!marginXApplies"
@input="(value: number) => applyIslandAppearance({ marginX: value })"
/>
<span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
<span v-else class="text-muted ml-8">
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
</span>
</el-form-item>
<el-form-item :label="`上下边距 ${island.marginY}px`">
<el-slider
:model-value="island.marginY"
:min="RANGES.marginY.min"
:max="RANGES.marginY.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ marginY: value })"
/>
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
</el-form-item>
<el-form-item label="跟随鼠标屏幕">
<el-switch
:model-value="island.followCursorDisplay"
@change="
(value: boolean | string | number) =>
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
"
/>
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
</el-form-item>
<el-form-item label="动画">
<el-switch
:model-value="island.animations"
@change="
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
"
/>
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
</el-form-item>
<el-form-item label="始终置顶">
<el-switch
:model-value="island.alwaysOnTop"
@change="
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
"
/>
</el-form-item>
<el-form-item label="空闲细缝">
<el-switch
:model-value="island.idleSliver"
@change="
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
"
/>
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">
保存设置
</el-button>
<el-button @click="testIsland">预览效果</el-button>
<el-button @click="resetIslandAppearance">恢复默认</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
/>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>ClassIsland 联动</span></template>
<el-descriptions :column="1" border size="small" class="mb-12">
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
<el-descriptions-item label="插件 / ClassIsland">
{{ classIslandStatus?.pluginVersion ?? '—' }} /
{{ classIslandStatus?.classIslandVersion ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<p class="text-muted">
<strong>通知模式</strong>
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
</p>
<el-radio-group
:model-value="appStore.notificationChannel"
:disabled="channelSaving"
class="channel-group"
@change="
(value: string | number | boolean | undefined) =>
applyChannel(value as ClassIslandNotificationChannel)
"
>
<el-radio
v-for="item in channelOptions"
:key="item.value"
:value="item.value"
:disabled="item.disabled"
>
{{ item.label }}
</el-radio>
</el-radio-group>
<p class="text-muted channel-hint">
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
<template v-if="!classIslandConnected">
<br />
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
ClassIsland 上弹")。
</template>
</p>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>作业录入</span></template>
<p class="text-muted">
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
{{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
</p>
<div class="phrase-row">
<el-tag
v-for="item in phrases"
:key="item"
closable
type="info"
effect="plain"
@close="removePhrase(item)"
>
{{ item }}
</el-tag>
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
</div>
<div class="toolbar mt-12">
<el-input
v-model="phraseInput"
placeholder="新增短语,如 大本"
style="width: 200px"
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
@keyup.enter="addPhrase"
/>
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
<el-button @click="resetPhrases">恢复默认</el-button>
</div>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item :label="auth.isClassSession ? '班级码' : '用户名'">
{{ auth.user?.username ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="登录方式">
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
</el-descriptions-item>
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-16">
<el-button type="danger" plain @click="logout">退出登录</el-button>
</div>
<div class="text-muted mt-12">
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
班级密码重新登录。
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>离线缓存(IndexedDB)</span></template>
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
<el-table-column prop="label" label="数据类型" width="130" />
<el-table-column label="条目数" width="90">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="最近更新" min-width="150">
<template #default="{ row }">{{ row.updatedAt }}</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
show-icon
title="离线可用"
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>客户端信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Electron">{{ appInfo?.electron ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Chromium">{{ appInfo?.chrome ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="Node.js">{{ appInfo?.node ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
</el-descriptions>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.channel-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-top: 10px;
}
.channel-hint {
margin-top: 10px;
}
.phrase-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.ml-8 {
margin-left: 8px;
}
</style>
@@ -0,0 +1,301 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import type { UpdateInfo } from '@classhelper/shared';
import type { DesktopAppInfo } from '../../../types/desktop.js';
import { useAppStore } from '../../stores/app.js';
/**
* 关于(ClassIsland 同款折叠卡片式):
* 应用信息(版本/运行时/常用链接/检查更新)→ 诊断信息(排查问题用)→ 鸣谢。
* 所有外链走主进程 openExternal(只放行 http/https,见 main/ipc.ts 的白名单)。
*/
const appStore = useAppStore();
const appInfo = ref<DesktopAppInfo | null>(null);
/** 折叠面板默认展开第一项(应用信息),诊断信息默认收起 */
const expandedNames = ref(['app']);
/** 检查更新:结果由主进程直连 GitHub 得到(见 main/update.ts) */
const checking = ref(false);
const updateInfo = ref<UpdateInfo | null>(null);
const PROJECT_URL = 'https://github.com/LoveChengke/ClassHelper';
const ISSUES_URL = 'https://github.com/LoveChengke/ClassHelper/issues';
const collapseItems = [
{ name: 'app', title: '应用信息', icon: 'School' },
{ name: 'diagnostics', title: '查看诊断信息', icon: 'Warning' },
{ name: 'thanks', title: '鸣谢', icon: 'Pointer' },
];
async function openExternal(url: string): Promise<void> {
if (window.desktop?.openExternal) {
const handled = await window.desktop.openExternal(url);
if (handled) return;
}
window.open(url, '_blank');
}
/**
* 检查更新。
*
* @param force true = 用户主动点击(绕过主进程缓存);false = 进页面时带出启动自动检查的结果
*/
async function checkUpdate(force: boolean): Promise<void> {
if (!window.desktop?.checkForUpdates) return;
checking.value = true;
try {
updateInfo.value = await window.desktop.checkForUpdates(force);
} finally {
checking.value = false;
}
}
/** 忽略这个版本:写进配置,启动自动检查不再提示它 */
async function ignoreVersion(): Promise<void> {
const version = updateInfo.value?.latestVersion;
if (!version) return;
await window.desktop?.ignoreUpdateVersion(version);
ElMessage.success(`已忽略 v${version} 的更新提示`);
}
onMounted(async () => {
if (!window.desktop) return;
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
// 带出启动自动检查的结果(命中主进程缓存,不会再发一次请求)
void checkUpdate(false);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">关于</h2>
<p class="page-subtitle">版本、诊断信息与鸣谢</p>
</div>
</div>
<el-collapse v-model="expandedNames" class="about-collapse">
<!-- 应用信息 -->
<el-collapse-item name="app">
<template #title>
<span class="about-title">
<el-icon><School /></el-icon>
{{ collapseItems[0].title }}
</span>
<span class="about-extra">班级小助手 v{{ appInfo?.appVersion ?? '-' }}</span>
</template>
<div class="about-section">
<p class="about-copy">
Copyright © 2025-2026 班级小助手
<br />
面向班级的课表 · 作业 · 通知 · 成绩信息工具(Web 管理端 + 桌面客户端 + 灵动岛)
</p>
<div class="about-links">
<span class="about-links-label">常用链接</span>
<el-button link type="primary" @click="openExternal(PROJECT_URL)">项目主页</el-button>
<el-button link type="primary" @click="openExternal(ISSUES_URL)">问题反馈</el-button>
</div>
<el-descriptions :column="1" border size="small" class="mt-12">
<el-descriptions-item label="应用版本">{{ appInfo?.appVersion ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="运行时">
Electron {{ appInfo?.electron ?? '-' }} · Chromium {{ appInfo?.chrome ?? '-' }} · Node
{{ appInfo?.node ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '-' }}</el-descriptions-item>
</el-descriptions>
<!-- 检查更新:直连 GitHub 看最新的 Release 是不是比本机新 -->
<div class="about-update" data-test="update-check">
<div class="about-update-head">
<span class="about-update-label">检查更新</span>
<el-button
size="small"
:loading="checking"
data-test="update-check-button"
@click="checkUpdate(true)"
>
检查更新
</el-button>
</div>
<div class="about-update-body">
<template v-if="checking">正在检查…</template>
<template v-else-if="!updateInfo">尚未检查</template>
<template v-else-if="!updateInfo.ok">
<!-- 教室机器没有外网是常态,这里只是"查不到",不是错误 -->
<span class="about-update-muted">{{ updateInfo.error ?? '暂时无法检查更新' }}</span>
</template>
<template v-else-if="updateInfo.hasUpdate">
<el-tag size="small" type="success" effect="light">
发现新版本 v{{ updateInfo.latestVersion }}
</el-tag>
<span class="about-update-muted">当前 v{{ updateInfo.currentVersion }}</span>
<el-button
link
type="primary"
size="small"
data-test="update-download"
@click="openExternal(updateInfo.releaseUrl)"
>
前往下载
</el-button>
<el-button link size="small" @click="ignoreVersion">忽略此版本</el-button>
</template>
<template v-else>
<el-tag size="small" type="info" effect="plain">已是最新版本</el-tag>
<span class="about-update-muted">
v{{ updateInfo.currentVersion }} · {{ updateInfo.latestVersion }}
</span>
</template>
</div>
</div>
</div>
</el-collapse-item>
<!-- 诊断信息 -->
<el-collapse-item name="diagnostics">
<template #title>
<span class="about-title">
<el-icon><Warning /></el-icon>
{{ collapseItems[1].title }}
</span>
<span class="about-extra">报障时请附带本页信息</span>
</template>
<div class="about-section">
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="服务器地址">{{ appStore.serverUrl }}</el-descriptions-item>
<el-descriptions-item label="连接状态">
<el-tag size="small" :type="appStore.serverReachable ? 'success' : 'danger'">
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
<el-descriptions-item label="缓存条目">{{ appStore.cacheItemCount }} 条</el-descriptions-item>
<el-descriptions-item label="数据目录">{{ appInfo?.userDataPath ?? '-' }}</el-descriptions-item>
<el-descriptions-item label="配置文件">{{ appInfo?.configPath ?? '-' }}</el-descriptions-item>
</el-descriptions>
</div>
</el-collapse-item>
<!-- 鸣谢 -->
<el-collapse-item name="thanks">
<template #title>
<span class="about-title">
<el-icon><Pointer /></el-icon>
{{ collapseItems[2].title }}
</span>
</template>
<div class="about-section">
<ul class="about-thanks">
<li>
<strong>ClassIsland</strong>
—— 界面风格、设置页与「今天」时间轴的交互参考
</li>
<li>
<strong>WinIsland</strong>
—— 灵动岛的窗口形态与弹簧动画参考
</li>
<li>
<strong>Vue / Element Plus / Prisma / Express / Socket.IO</strong>
—— 本项目赖以构建的开源基石
</li>
</ul>
</div>
</el-collapse-item>
</el-collapse>
<p class="about-quote">谨以此应用,献给朝夕相伴的班级与同窗;愿时光流转,情谊常在。</p>
</div>
</template>
<style scoped>
.about-title {
display: inline-flex;
align-items: center;
gap: 8px;
}
.about-extra {
margin-left: auto;
margin-right: 12px;
font-size: 12px;
font-weight: 400;
color: var(--ch-text-tertiary);
}
.about-section {
padding: 0 4px;
}
.about-copy {
margin: 0 0 10px;
line-height: 1.8;
color: var(--ch-text-secondary);
font-size: 12.5px;
}
.about-links {
display: flex;
align-items: center;
gap: 4px;
}
.about-links-label {
font-size: 12.5px;
color: var(--ch-text-secondary);
margin-right: 4px;
}
/* 检查更新:颜色一律走 --ch-* 变量(深色主题下不能写死浅色底/深色字) */
.about-update {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid var(--ch-border);
border-radius: var(--ch-radius-card);
background: var(--ch-layer-alt);
}
.about-update-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.about-update-label {
font-size: 12.5px;
font-weight: 600;
color: var(--ch-text-secondary);
}
.about-update-body {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
font-size: 12.5px;
color: var(--ch-text);
}
.about-update-muted {
color: var(--ch-text-tertiary);
}
.about-thanks {
margin: 0;
padding-left: 18px;
line-height: 2;
font-size: 12.5px;
color: var(--ch-text-secondary);
}
.about-quote {
margin: 18px 0 4px;
text-align: center;
font-size: 12px;
color: var(--ch-text-tertiary);
}
</style>
@@ -0,0 +1,55 @@
<script setup lang="ts">
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import { useAuthStore } from '../../stores/auth.js';
import { useRealtimeStore } from '../../stores/realtime.js';
/**
* 账号设置:本机登录的班级账号信息 + 退出登录。
* 班级密码由管理员在 Web 管理端维护(本机按用户要求不提供改密入口)。
*/
const router = useRouter();
const auth = useAuthStore();
const realtime = useRealtimeStore();
async function logout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' });
realtime.disconnect();
await auth.logout();
ElMessage.success('已退出登录');
await router.replace('/login');
}
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">账号</h2>
<p class="page-subtitle">本机登录的班级账号</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small">
<el-descriptions-item label="姓名">{{ auth.user?.name ?? '-' }}</el-descriptions-item>
<el-descriptions-item :label="auth.isClassSession ? '班级码' : '用户名'">
{{ auth.user?.username ?? '-' }}
</el-descriptions-item>
<el-descriptions-item label="登录方式">
{{ auth.isClassSession ? '班级账号(本机代表全班)' : '班级账号' }}
</el-descriptions-item>
<el-descriptions-item label="班级">{{ auth.user?.className ?? '未分班' }}</el-descriptions-item>
<el-descriptions-item label="年级">{{ auth.user?.grade ?? '-' }}</el-descriptions-item>
</el-descriptions>
<div class="toolbar mt-16">
<el-button type="danger" plain @click="logout">退出登录</el-button>
</div>
<div class="text-muted mt-12">
班级密码由管理员在 Web 管理端「班级管理 → 修改班级账号」中设置;本机退出后用班级码 +
班级密码重新登录。
</div>
</el-card>
</div>
</template>
@@ -0,0 +1,162 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus';
import { useUiStore } from '../../stores/ui.js';
/**
* 外观:黑夜 / 白天模式。
* 状态与持久化都在 stores/ui.ts(主进程配置 theme 字段),顶栏右上角的
* 日月按钮与本页写同一份配置,切换立即生效(html.dark + Element Plus dark css-vars)。
*/
const ui = useUiStore();
const options = [
{ value: 'light', label: '浅色', desc: '明亮界面,适合白天与投影环境' },
{ value: 'dark', label: '深色', desc: '黑夜模式,适合晚自习与暗教室' },
] as const;
async function selectTheme(value: 'light' | 'dark'): Promise<void> {
if (value === ui.theme) return;
await ui.setTheme(value);
ElMessage.success(value === 'dark' ? '已切换到深色模式' : '已切换到浅色模式');
}
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">外观</h2>
<p class="page-subtitle">黑夜与白天模式,切换立即生效并自动保存</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>界面主题</span></template>
<div class="theme-options">
<button
v-for="option in options"
:key="option.value"
type="button"
class="theme-option"
:class="[`theme-${option.value}`, { active: ui.theme === option.value }]"
:data-theme-option="option.value"
@click="selectTheme(option.value)"
>
<span class="theme-preview">
<span class="preview-side" />
<span class="preview-body">
<span class="preview-line" />
<span class="preview-line short" />
</span>
</span>
<span class="theme-name">
<el-icon><Sunny v-if="option.value === 'light'" /><Moon v-else /></el-icon>
{{ option.label }}
</span>
<span class="theme-desc">{{ option.desc }}</span>
</button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
title="顶栏右上角也有主题快捷切换按钮"
description="灵动岛始终为深色浮窗,不随主题变化;登录页与全屏作业看板为固定配色,亦不受影响。"
/>
</el-card>
</div>
</template>
<style scoped>
.theme-options {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.theme-option {
width: 200px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 12px;
border: 1px solid var(--ch-border-strong);
border-radius: var(--ch-radius-card);
background: var(--ch-layer);
cursor: pointer;
color: var(--ch-text);
text-align: left;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
}
.theme-option:hover {
border-color: var(--ch-accent);
}
.theme-option.active {
border-color: var(--ch-accent);
box-shadow: 0 0 0 1px var(--ch-accent) inset;
}
/* 主题缩略预览:两块小色板直接表现各自配色 */
.theme-preview {
display: flex;
width: 100%;
height: 64px;
border-radius: 6px;
overflow: hidden;
border: 1px solid var(--ch-border-strong);
}
.theme-light .theme-preview {
background: #f3f3f3;
}
.theme-dark .theme-preview {
background: #161719;
}
.preview-side {
width: 28px;
background: rgba(255, 255, 255, 0.65);
}
.theme-dark .preview-side {
background: rgba(32, 33, 36, 0.9);
}
.preview-body {
flex: 1;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.preview-line {
height: 8px;
border-radius: 4px;
width: 80%;
background: rgba(15, 108, 189, 0.55);
}
.preview-line.short {
width: 50%;
background: rgba(128, 128, 128, 0.35);
}
.theme-name {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
}
.theme-desc {
font-size: 12px;
color: var(--ch-text-tertiary);
}
</style>
@@ -0,0 +1,285 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH,
formatDate,
} from '@classhelper/shared';
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
import { normalizeServerUrl } from '../../config.js';
import { useAppStore } from '../../stores/app.js';
import { useAuthStore } from '../../stores/auth.js';
import { useNotificationStore } from '../../stores/notifications.js';
import { useOnboardingStore } from '../../stores/onboarding.js';
import { useRealtimeStore } from '../../stores/realtime.js';
/**
* 通用设置:服务器连接、离线缓存与作业录入短语。
* (外观 / 灵动岛 / 提醒 / 账号 / 关于 各自是独立子页,见侧边栏「设置」分组。)
*/
const appStore = useAppStore();
const auth = useAuthStore();
const onboarding = useOnboardingStore();
const realtime = useRealtimeStore();
const notifications = useNotificationStore();
const serverInput = ref(appStore.serverUrl);
const testing = ref(false);
const saving = ref(false);
const storeLabels: Record<string, string> = {
profile: '登录资料',
classes: '班级 / 学期',
schedules: '课表',
homeworks: '作业',
notifications: '通知',
grades: '成绩',
};
const cacheRows = computed(() =>
appStore.cacheStoreStats.map((item) => ({
label: storeLabels[item.store] ?? item.store,
store: item.store,
count: item.count,
updatedAt: item.updatedAt ? formatDate(item.updatedAt, true) : '—',
})),
);
async function saveServer(): Promise<void> {
const normalized = normalizeServerUrl(serverInput.value);
if (!normalized) {
ElMessage.error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
return;
}
saving.value = true;
try {
await appStore.applyServerUrl(normalized);
serverInput.value = normalized;
ElMessage.success('服务器地址已保存');
await testConnection();
if (auth.token) realtime.connect(normalized, auth.token);
} finally {
saving.value = false;
}
}
async function testConnection(): Promise<void> {
testing.value = true;
try {
const result = await pingHealth();
if (result.ok) ElMessage.success('服务器连接正常');
else ElMessage.error('无法连接服务器,请检查地址与网络');
} finally {
testing.value = false;
}
}
async function manualSync(): Promise<void> {
setApiBaseUrl(appStore.serverUrl);
await appStore.ping();
if (appStore.offline) {
ElMessage.warning('当前无法连接服务器,已保留缓存数据');
return;
}
await notifications.load();
appStore.markSynced();
ElMessage.success('已同步最新数据');
}
async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
type: 'warning',
});
await appStore.clearCache();
ElMessage.success('离线缓存已清空');
}
/* ------------------------------------------------------------ 作业录入快捷短语 */
/**
* 作业快捷短语:录作业时点一下就把词追加到标题/内容里(P、大本、背诵…)。
*
* 存在客户端本地配置(这台教室机器自己的习惯),不占用服务端字段;
* 空数组是合法值 —— 老师把短语全删了就是"不想用快捷短语"。
*/
const phrases = ref<string[]>([...HOMEWORK_PHRASE_DEFAULTS]);
const phraseInput = ref('');
const savingPhrases = ref(false);
async function loadPhrases(): Promise<void> {
const config = await window.desktop?.getConfig?.();
if (config?.homeworkPhrases) phrases.value = [...config.homeworkPhrases];
}
async function savePhrases(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingPhrases.value = true;
try {
const saved = await window.desktop.saveConfig({ homeworkPhrases: [...phrases.value] });
phrases.value = [...saved.homeworkPhrases];
} finally {
savingPhrases.value = false;
}
}
async function addPhrase(): Promise<void> {
const value = phraseInput.value.trim().slice(0, HOMEWORK_PHRASE_MAX_LENGTH);
if (!value) return;
if (phrases.value.includes(value)) {
ElMessage.warning('这条短语已经有了');
return;
}
if (phrases.value.length >= HOMEWORK_PHRASE_MAX_COUNT) {
ElMessage.warning(`最多 ${HOMEWORK_PHRASE_MAX_COUNT} 条快捷短语`);
return;
}
phrases.value = [...phrases.value, value];
phraseInput.value = '';
await savePhrases();
ElMessage.success('已添加');
}
async function removePhrase(value: string): Promise<void> {
phrases.value = phrases.value.filter((item) => item !== value);
await savePhrases();
}
async function resetPhrases(): Promise<void> {
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' });
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
await savePhrases();
ElMessage.success('已恢复默认');
}
onMounted(async () => {
await appStore.refreshCacheStats();
await loadPhrases();
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">通用</h2>
<p class="page-subtitle">服务器连接、离线缓存与作业录入</p>
</div>
<!-- 重看初次启动引导(首启时由 App.vue 自动弹出过一次) -->
<el-button @click="onboarding.open()">
<el-icon><Guide /></el-icon>
使用引导
</el-button>
</div>
<el-row :gutter="12">
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>服务器连接</span></template>
<el-form label-position="top">
<el-form-item label="服务器地址">
<el-input v-model="serverInput" placeholder="http://127.0.0.1:4000">
<template #prefix>
<el-icon><Link /></el-icon>
</template>
</el-input>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="saving" @click="saveServer">保存并测试</el-button>
<el-button :loading="testing" @click="testConnection">测试连接</el-button>
<el-button @click="manualSync">立即同步</el-button>
</div>
<el-descriptions class="mt-16" :column="1" border size="small">
<el-descriptions-item label="当前状态">
<el-tag :type="appStore.serverReachable ? 'success' : 'danger'" size="small">
{{ appStore.serverReachable ? '服务器可达' : '离线(显示缓存数据)' }}
</el-tag>
<el-tag class="ml-8" :type="realtime.connected ? 'success' : 'info'" size="small">
{{ realtime.statusText }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="最近同步">{{ appStore.lastSyncText }}</el-descriptions-item>
<el-descriptions-item label="实时事件数">{{ realtime.eventCount }} 条</el-descriptions-item>
</el-descriptions>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>作业录入</span></template>
<p class="text-muted">
在教室电脑上录作业时,点一下短语就会追加到标题 / 内容里(例如 P、大本、背诵)。 最多
{{ HOMEWORK_PHRASE_MAX_COUNT }} 条、每条
{{ HOMEWORK_PHRASE_MAX_LENGTH }} 字;全部删掉即不在录入页显示。
</p>
<div class="phrase-row">
<el-tag
v-for="item in phrases"
:key="item"
closable
type="info"
effect="plain"
@close="removePhrase(item)"
>
{{ item }}
</el-tag>
<span v-if="phrases.length === 0" class="text-muted">(已清空)</span>
</div>
<div class="toolbar mt-12">
<el-input
v-model="phraseInput"
placeholder="新增短语,如 大本"
style="width: 200px"
:maxlength="HOMEWORK_PHRASE_MAX_LENGTH"
@keyup.enter="addPhrase"
/>
<el-button type="primary" :loading="savingPhrases" @click="addPhrase">添加</el-button>
<el-button @click="resetPhrases">恢复默认</el-button>
</div>
</el-card>
</el-col>
<el-col :xs="24" :md="12">
<el-card shadow="never">
<template #header><span>离线缓存(IndexedDB)</span></template>
<el-table :data="cacheRows" size="small" empty-text="暂无缓存数据">
<el-table-column prop="label" label="数据类型" width="130" />
<el-table-column label="条目数" width="90">
<template #default="{ row }">{{ row.count }}</template>
</el-table-column>
<el-table-column label="最近更新" min-width="150">
<template #default="{ row }">{{ row.updatedAt }}</template>
</el-table-column>
</el-table>
<div class="toolbar mt-12">
<el-button :icon="'Refresh'" @click="appStore.refreshCacheStats()">刷新统计</el-button>
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
show-icon
title="离线可用"
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
/>
</el-card>
</el-col>
</el-row>
</div>
</template>
<style scoped>
.phrase-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-top: 12px;
}
.ml-8 {
margin-left: 8px;
}
</style>
@@ -0,0 +1,276 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES,
ISLAND_POSITION_LABELS,
ISLAND_POSITIONS,
ISLAND_STYLE_LABELS,
ISLAND_STYLES,
type IslandAppearance,
type IslandPosition,
type IslandStyle,
} from '@classhelper/shared';
/**
* 灵动岛个性化(原设置页「灵动岛 · 个性化」卡整块迁移)。
*
* 注意:必须传**展开后的纯对象**。`island` 是 `ref`,它的 `.value` 在 Vue 里是响应式
* Proxy,Proxy 过不了 IPC 的结构化克隆(`An object could not be cloned.`)——
* 那正是"拖了滑块、数字在变、灵动岛却没反应"的原因。
*/
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
const savingIsland = ref(false);
/**
* 左右边距是否有效:只在停靠左 / 右时生效。
*
* 岛是"居中停靠"时(默认就是顶部居中),水平方向的锚点就是屏幕中线 ——
* 这时调左右边距不会有任何变化。之前没把这点说清楚,用户会以为"调了不生效"。
*/
const marginXApplies = computed(() => !island.value.position.endsWith('center'));
const positionOptions = ISLAND_POSITIONS.map((value) => ({
value,
label: ISLAND_POSITION_LABELS[value],
}));
const styleOptions = ISLAND_STYLES.map((value) => ({
value,
label: ISLAND_STYLE_LABELS[value],
}));
/** 读取当前生效的外观(主进程为单一事实来源) */
async function loadIslandAppearance(): Promise<void> {
if (!window.desktop?.islandGetAppearance) return;
island.value = await window.desktop.islandGetAppearance();
}
/** 改动即生效:属性变化时先应用(实时预览),点"保存"再落盘 */
function applyIslandAppearance(patch: Partial<IslandAppearance> = {}): void {
island.value = { ...island.value, ...patch };
window.desktop?.islandSetAppearance({ ...island.value });
}
async function saveIslandAppearance(): Promise<void> {
if (!window.desktop?.saveConfig) return;
savingIsland.value = true;
try {
await window.desktop.saveConfig({ island: { ...island.value } });
ElMessage.success('个性化设置已保存');
} finally {
savingIsland.value = false;
}
}
async function resetIslandAppearance(): Promise<void> {
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' });
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
await saveIslandAppearance();
}
/** 本地推一条测试通知,立刻确认外观效果(预览模式:直接展开、失焦不收起、30 秒后自动消失) */
function testIsland(): void {
window.desktop?.islandPush?.({
notification: {
id: `appearance-test-${Date.now()}`,
title: '灵动岛外观预览',
content: '拖动滑块即可实时预览:高度、宽度、圆角、透明度、字号、主题色、位置与动画。',
priority: 'NORMAL',
createdAt: new Date().toISOString(),
courseName: null,
teacherName: '本地预览',
},
context: { inClass: false, currentPeriodEnd: null, preview: true },
});
}
onMounted(async () => {
await loadIslandAppearance();
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">灵动岛</h2>
<p class="page-subtitle">屏幕上方的消息浮窗:外观、停靠位置与动画</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>灵动岛 · 个性化</span></template>
<el-form label-width="96px" label-position="left">
<el-form-item :label="`高度 ${island.height}px`">
<el-slider
:model-value="island.height"
:min="RANGES.height.min"
:max="RANGES.height.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ height: value })"
/>
</el-form-item>
<el-form-item :label="`宽度 ${island.width}px`">
<el-slider
:model-value="island.width"
:min="RANGES.width.min"
:max="RANGES.width.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ width: value })"
/>
</el-form-item>
<el-form-item :label="`圆角 ${island.radius}px`">
<el-slider
:model-value="island.radius"
:min="RANGES.radius.min"
:max="RANGES.radius.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ radius: value })"
/>
</el-form-item>
<el-form-item :label="`不透明度 ${Math.round(island.opacity * 100)}%`">
<el-slider
:model-value="island.opacity"
:min="RANGES.opacity.min"
:max="RANGES.opacity.max"
:step="0.05"
@input="(value: number) => applyIslandAppearance({ opacity: value })"
/>
</el-form-item>
<el-form-item :label="`字号 ${island.fontSize}px`">
<el-slider
:model-value="island.fontSize"
:min="RANGES.fontSize.min"
:max="RANGES.fontSize.max"
:step="1"
@input="(value: number) => applyIslandAppearance({ fontSize: value })"
/>
</el-form-item>
<el-form-item :label="`动画速度 ${island.speed.toFixed(1)}x`">
<el-slider
:model-value="island.speed"
:min="RANGES.speed.min"
:max="RANGES.speed.max"
:step="0.1"
@input="(value: number) => applyIslandAppearance({ speed: value })"
/>
</el-form-item>
<el-form-item label="主题色">
<el-color-picker
:model-value="island.accent"
@change="(value: string | null) => applyIslandAppearance({ accent: value ?? '#0a84ff' })"
/>
<span class="text-muted ml-8">用于高亮、按钮与进度条</span>
</el-form-item>
<el-form-item label="视觉风格">
<el-select
:model-value="island.style"
style="width: 180px"
@change="(value: IslandStyle) => applyIslandAppearance({ style: value })"
>
<el-option
v-for="item in styleOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
<span class="text-muted ml-8">纯黑 / 毛玻璃(半透明)/ 主题色渐变</span>
</el-form-item>
<el-form-item label="显示位置">
<el-select
:model-value="island.position"
style="width: 180px"
@change="(value: IslandPosition) => applyIslandAppearance({ position: value })"
>
<el-option
v-for="item in positionOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item :label="`左右边距 ${island.marginX}px`">
<el-slider
:model-value="island.marginX"
:min="RANGES.marginX.min"
:max="RANGES.marginX.max"
:step="2"
:disabled="!marginXApplies"
@input="(value: number) => applyIslandAppearance({ marginX: value })"
/>
<span v-if="marginXApplies" class="text-muted ml-8">停靠左/右时距屏幕边缘的距离</span>
<span v-else class="text-muted ml-8">
当前是「居中」停靠,左右边距不生效 —— 把上面的「显示位置」改成左 / 右停靠即可看到效果
</span>
</el-form-item>
<el-form-item :label="`上下边距 ${island.marginY}px`">
<el-slider
:model-value="island.marginY"
:min="RANGES.marginY.min"
:max="RANGES.marginY.max"
:step="2"
@input="(value: number) => applyIslandAppearance({ marginY: value })"
/>
<span class="text-muted ml-8">停靠顶/底时距屏幕边缘的距离(任务栏在侧面时调大)</span>
</el-form-item>
<el-form-item label="跟随鼠标屏幕">
<el-switch
:model-value="island.followCursorDisplay"
@change="
(value: boolean | string | number) =>
applyIslandAppearance({ followCursorDisplay: Boolean(value) })
"
/>
<span class="text-muted ml-8">多显示器教室电脑:岛出现在鼠标所在的那块屏幕</span>
</el-form-item>
<el-form-item label="动画">
<el-switch
:model-value="island.animations"
@change="
(value: boolean | string | number) => applyIslandAppearance({ animations: Boolean(value) })
"
/>
<span class="text-muted ml-8">关闭后展开/收起为瞬时生效</span>
</el-form-item>
<el-form-item label="始终置顶">
<el-switch
:model-value="island.alwaysOnTop"
@change="
(value: boolean | string | number) => applyIslandAppearance({ alwaysOnTop: Boolean(value) })
"
/>
</el-form-item>
<el-form-item label="空闲细缝">
<el-switch
:model-value="island.idleSliver"
@change="
(value: boolean | string | number) => applyIslandAppearance({ idleSliver: Boolean(value) })
"
/>
<span class="text-muted ml-8">参考 WinIsland:没有消息时保留一条细缝(关闭则完全隐藏)</span>
</el-form-item>
</el-form>
<div class="toolbar">
<el-button type="primary" :loading="savingIsland" @click="saveIslandAppearance">保存设置</el-button>
<el-button @click="testIsland">预览效果</el-button>
<el-button @click="resetIslandAppearance">恢复默认</el-button>
</div>
<el-alert
class="mt-12"
type="info"
:closable="false"
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
/>
</el-card>
</div>
</template>
<style scoped>
.ml-8 {
margin-left: 8px;
}
</style>
@@ -0,0 +1,141 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus';
import {
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_CHANNELS,
formatDate,
type ClassIslandNotificationChannel,
} from '@classhelper/shared';
import { classIslandStatusApi } from '../../api/index.js';
import { useAppStore } from '../../stores/app.js';
import { useAuthStore } from '../../stores/auth.js';
/**
* 提醒设置:这台教室机器把提醒弹在哪个端(客户端 / ClassIsland / 两端都弹)。
* 值同时落本地配置与班级记录(服务端据此决定是否推送 ClassIsland)。
*/
const appStore = useAppStore();
const auth = useAuthStore();
const channelSaving = ref(false);
/**
* 本班有没有接入 ClassIsland 设备。
*
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
*/
const classIslandConnected = ref(false);
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
const classIslandStatus = ref<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
} | null>(null);
const classIslandStatusText = computed(() => {
const status = classIslandStatus.value;
if (!status || !status.connected) return '未接入';
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
});
const channelOptions = computed(() =>
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
value,
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
disabled: value === 'classisland' && !classIslandConnected.value,
})),
);
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
channelSaving.value = true;
try {
const saved = await appStore.setNotificationChannel(auth.classId, value);
if (saved) ElMessage.success('提醒显示位置已更新');
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
} finally {
channelSaving.value = false;
}
}
onMounted(async () => {
await appStore.loadNotificationChannel(auth.classId);
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
if (auth.classId) {
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
classIslandStatus.value = status;
classIslandConnected.value = status?.connected === true;
}
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">提醒</h2>
<p class="page-subtitle">老师发通知 / 叫人时,提醒弹在哪个端</p>
</div>
</div>
<el-card shadow="never">
<template #header><span>ClassIsland 联动</span></template>
<el-descriptions :column="1" border size="small" class="mb-12">
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
<el-descriptions-item label="插件 / ClassIsland">
{{ classIslandStatus?.pluginVersion ?? '—' }} /
{{ classIslandStatus?.classIslandVersion ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<p class="text-muted">
<strong>通知模式</strong>
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
</p>
<el-radio-group
:model-value="appStore.notificationChannel"
:disabled="channelSaving"
class="channel-group"
@change="
(value: string | number | boolean | undefined) =>
applyChannel(value as ClassIslandNotificationChannel)
"
>
<el-radio v-for="item in channelOptions" :key="item.value" :value="item.value" :disabled="item.disabled">
{{ item.label }}
</el-radio>
</el-radio-group>
<p class="text-muted channel-hint">
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
<template v-if="!classIslandConnected">
<br />
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
ClassIsland 上弹")。
</template>
</p>
</el-card>
</div>
</template>
<style scoped>
.channel-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-top: 10px;
}
.channel-hint {
margin-top: 10px;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
+42
View File
@@ -7,6 +7,7 @@ import type {
IslandAppearance,
IslandNotification,
IslandState,
UpdateInfo,
} from '@classhelper/shared';
/** 作业页展示偏好(看板 / 列表,看板外观) */
@@ -47,6 +48,24 @@ export interface DesktopStoredConfig {
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
*/
notificationChannel: ClassIslandNotificationChannel;
/**
* 是否已看过「初次启动引导」。
*
* 首次启动自动弹出一次(完成或跳过即记为看过并写入配置,之后不再自动弹出);
* 登录页与设置页的「使用引导」入口可随时重看(重看不改变该值)。
*/
onboardingDone: boolean;
/** 界面主题:light=浅色(默认)/ dark=深色(黑夜模式) */
theme: 'light' | 'dark';
/** 主侧边栏是否折叠成图标栏(可由顶栏汉堡按钮切换) */
sidebarCollapsed: boolean;
/**
* 用户点过「忽略此版本」的版本号(空串 = 没忽略过)。
*
* 启动时的自动检查会跳过这个版本,避免每次开客户端都弹一次同样的提示;
* 在「关于」页手动检查时仍会正常显示结果。
*/
ignoredUpdateVersion: string;
}
export interface DesktopAppInfo {
@@ -56,6 +75,8 @@ export interface DesktopAppInfo {
node: string;
platform: string;
userDataPath: string;
/** 主进程配置文件(config.json)的绝对路径(关于页「诊断信息」展示) */
configPath: string;
/** 是否处于冒烟验证模式(由 ELECTRON_SMOKE_TEST=1 触发) */
smokeTest: boolean;
}
@@ -108,6 +129,21 @@ export interface DesktopBridge {
getAppInfo(): Promise<DesktopAppInfo>;
openExternal(url: string): Promise<boolean>;
/* 更新检查 */
/**
* 检查 GitHub 上有没有新版本(主进程直连,不经后端)。
*
* @param force true = 绕过主进程缓存(对应「关于」页里用户主动点的那次检查)
*
* 返回的 `ok:false` 是**正常结果**(教室机器没有外网 / GitHub 限流 / 超时),不是异常,
* 界面按"暂时查不到更新"呈现即可。
*/
checkForUpdates(force?: boolean): Promise<UpdateInfo>;
/** 忽略某个版本的更新提示(写入 config.json,启动自动检查不再提示它) */
ignoreUpdateVersion(version: string): Promise<void>;
/** 订阅"启动自动检查发现新版本"(仅新版本且未被忽略时触发一次) */
onUpdateAvailable(handler: (info: UpdateInfo) => void): void;
/* 灵动岛 */
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
@@ -209,6 +245,12 @@ declare global {
homeworkBoardSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 本机录入的作业不上灵动岛自检(内容指纹 + id 双重判定) */
islandHomeworkSuppressionCheck(): Promise<{ ok: boolean; detail: string }>;
/** 初次启动引导自检(首启自动出现 → 真实点击走完 → 完成状态写入配置) */
onboardingSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 主界面外观件自检(真实点击:主题切换 html.dark + 落盘、侧边栏折叠 + 落盘) */
layoutChromeSelfTest(): Promise<{ ok: boolean; detail: string }>;
/** 关于页自检(/settings/about 渲染 + getAppInfo 含 configPath) */
aboutPageSelfTest(): Promise<{ ok: boolean; detail: string }>;
sessionCleanup(): Promise<{ ok: boolean; detail: string }>;
};
}
+4 -2
View File
@@ -29,8 +29,10 @@ CORS_ORIGIN=*
# bcrypt 加密强度
BCRYPT_ROUNDS=10
# 教师新增学生账号时未指定密码所使用的初始密码
# 新建班级 / 新增教师未指定密码时使用的初始密码
# (学生没有个人账号,因此没有 DEFAULT_STUDENT_PASSWORD —— 见 AGENTS.md §5 第 31 条)
DEFAULT_CLASS_PASSWORD=123456
DEFAULT_TEACHER_PASSWORD=123456
# 学期配置:第 1 教学周的周一日期(YYYY-MM-DD),用于“当前周次”换算
TERM_START_DATE=2026-02-23
+4 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/server",
"version": "0.1.0",
"version": "1.1.2",
"private": true,
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module",
@@ -25,6 +25,7 @@
"@classhelper/shared": "workspace:*",
"@libsql/client": "^0.17.0",
"@prisma/adapter-libsql": "7.10.0",
"@prisma/adapter-mariadb": "7.10.0",
"@prisma/client": "7.10.0",
"bcryptjs": "^3.0.3",
"compression": "^1.8.2",
@@ -34,7 +35,9 @@
"express-rate-limit": "^8.7.0",
"helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3",
"prisma": "7.10.0",
"socket.io": "^4.8.3",
"typescript": "~5.9.3",
"xlsx": "0.18.5",
"zod": "^4.6.2"
},
+2 -1
View File
@@ -4,7 +4,8 @@
// 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。
// 字段全部使用 String / Int / Float / Boolean / DateTime 等跨库通用类型,
// 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。
// MySQL 版本见同目录 schema.mysql.prisma,切换步骤见 docs/mysql.md。
// MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`),
// 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、
// 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验,
// 这样 SQLite / MySQL 行为完全一致。
+36 -1
View File
@@ -105,6 +105,34 @@ async function loginWithCandidates(username, passwords) {
return null;
}
/**
* 反复「拉取待提醒 → 逐条确认」,直到该设备视角下没有积压。
*
* 为什么验证脚本要做这件事:`GET /integrations/classisland/pending` 是
* 「本班全部未确认且未过期的提醒,按时间升序取前 20 条」。而 `verify:desktop` /
* `verify:web` 等冒烟会**真的往演示班发通知**(AGENTS.md §5 第 38 条),
* 那些条如果没被确认就会一直堆着 —— 攒够 20 条之后,本脚本刚发的提醒会被挤到窗口之外,
* 表现为「待提醒里找不到刚发的那条」,与代码对不对完全无关(2026-10-05 实测踩过:
* 库里积了 45 条历次冒烟残条,本脚本从 42/42 掉到 35/40)。
*
* 这里做的事情与真实插件重连后的行为完全一致(补弹积压并逐条确认),
* 因此不会掩盖任何产品侧问题。可见范围也一致:只处理 `deviceId` 为空的班级广播
* 与本设备自己的推送,别的设备的目标推送取不到、也确认不了。
*/
async function drainPending(deviceToken) {
let total = 0;
for (let round = 0; round < 20; round += 1) {
const pending = await api('/integrations/classisland/pending', { deviceToken });
const items = pending.payload?.data?.notifications ?? [];
if (items.length === 0) break;
for (const item of items) {
await api('/integrations/classisland/ack', { method: 'POST', deviceToken, body: { id: item.id } });
total += 1;
}
}
return total;
}
async function main() {
console.log(`\n=== ClassIsland 联动验证(${BASE_URL})===\n`);
@@ -182,7 +210,7 @@ async function main() {
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = {
pluginVersion: '0.1.0.0',
pluginVersion: '1.1.2.0',
classIslandVersion: '2.1.0.0',
state: {
inClass: true,
@@ -237,6 +265,13 @@ async function main() {
);
// ---------------------------------------------------------------- 4. 下发提醒
// 先确认掉本班的历史积压(历次冒烟留下的未确认提醒),否则 /pending 的前 20 条窗口
// 会被它们占满,下面「插件拉取到待提醒」等用例会拿不到刚发的这条(理由见 drainPending)
const drained = await drainPending(deviceToken);
if (drained > 0) {
console.log(`(已确认本班 ${drained} 条历史积压提醒,避免挤占待提醒窗口)\n`);
}
const notify = await api('/integrations/classisland/notify', {
method: 'POST',
token: teacherToken,
+277
View File
@@ -67,6 +67,17 @@ async function main() {
}
console.log(` 已挂载模块:${(health.payload?.data?.modules ?? []).join(', ')}\n`);
// 版本号不再硬编码(见 lib/version.ts):健康检查必须暴露一个像样的版本,
// 更新检查要靠它判断"本机是不是旧版";同时确认 update 模块已进注册表。
const healthData = health.payload?.data ?? {};
record(
'健康检查暴露版本号与模块清单(含 update 模块)',
typeof healthData.version === 'string' &&
/^\d+\.\d+\.\d+/.test(healthData.version) &&
(healthData.modules ?? []).includes('update'),
`版本=${healthData.version} 模块数=${(healthData.modules ?? []).length}`,
);
// ---------------------------------------------------------------- 1. 登录
const teacherLogin = await api('/auth/login', {
method: 'POST',
@@ -2339,6 +2350,236 @@ async function main() {
`教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`,
);
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
// ---------------------------------------------------------------- 数据库管理(仅管理员)
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
const dbStatus = await api('/database/status', { token: adminToken });
const dbStatusData = dbStatus.payload?.data ?? {};
record(
'数据库状态(管理员):已连接且返回 14 张表行数',
dbStatus.status === 200 &&
dbStatusData.connected === true &&
Array.isArray(dbStatusData.tables) &&
dbStatusData.tables.length === 14 &&
dbStatusData.tables.every((item) => item.count >= 0),
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
(dbStatusData.tables ?? []).length
}`,
);
const dbStatusTeacher = await api('/database/status', { token: teacherToken });
const dbBackupTeacher = await api('/database/backups', { method: 'POST', token: teacherToken });
const dbScheduleTeacher = await api('/database/backup-schedule', {
method: 'PUT',
token: teacherToken,
body: { enabled: true, intervalHours: 24, keepCount: 7 },
});
const dbSwitchTeacher = await api('/database/switch', {
method: 'POST',
token: teacherToken,
body: { provider: 'sqlite', url: 'file:./data/teacher-forbidden.db' },
});
record(
'数据库管理越权(教师全部 403)',
dbStatusTeacher.status === 403 &&
dbBackupTeacher.status === 403 &&
dbScheduleTeacher.status === 403 &&
dbSwitchTeacher.status === 403,
`status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`,
);
// 连接测试:当前库必须可达(SQLite 用相对路径,MySQL 用一个不可达地址测失败分支)
let testOkResult;
let testOkName;
if (dbStatusData.provider === 'sqlite') {
testOkResult = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'sqlite', url: 'file:./prisma/dev.db' },
});
testOkName = '连接测试(当前 SQLite 库可达)';
} else {
testOkResult = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'mysql', url: 'mysql://root:wrong-password@127.0.0.1:1/none' },
});
testOkName = '连接测试(不可达 MySQL 返回 ok=false)';
}
record(
testOkName,
testOkResult.status === 200 &&
(dbStatusData.provider === 'sqlite'
? testOkResult.payload?.data?.ok === true
: testOkResult.payload?.data?.ok === false),
`ok=${testOkResult.payload?.data?.ok} 版本=${testOkResult.payload?.data?.version} 详情=${testOkResult.payload?.data?.error ?? testOkResult.payload?.data?.note ?? '-'}`,
);
const dbTestRedis = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
});
record(
'Redis 作为主库被拒绝(schema 层面 422,产品口径:主库仅 SQLite/MySQL)',
dbTestRedis.status === 422,
`status=${dbTestRedis.status}`,
);
const badConnection = await api('/database/test-connection', {
method: 'POST',
token: adminToken,
body: { provider: 'mysql', url: 'mysql://root:wrong@127.0.0.1:1/none' },
});
record(
'连接测试(不可达 MySQL → ok=false 且不抛 500)',
badConnection.status === 200 && badConnection.payload?.data?.ok === false,
`ok=${badConnection.payload?.data?.ok} error=${(badConnection.payload?.data?.error ?? '').slice(0, 60)}`,
);
// 切换校验分支:同库拒绝(真实切换不在此跑,避免改写 .env)
const switchSame =
dbStatusData.provider === 'sqlite'
? await api('/database/switch', {
method: 'POST',
token: adminToken,
body: { provider: 'sqlite', url: dbStatusData.databaseUrlMasked },
})
: { status: 0, payload: null };
const switchInvalid = await api('/database/switch', {
method: 'POST',
token: adminToken,
body: { provider: 'redis', url: 'redis://127.0.0.1:6379' },
});
record(
'一键切换校验分支(同库 400 / 非法 provider 422,不真跑切换)',
(dbStatusData.provider === 'sqlite' ? switchSame.status === 400 : true) && switchInvalid.status === 422,
`同库=${switchSame.status}(${dbStatusData.provider === 'sqlite' ? 'sqlite 当前库' : 'mysql 跳过'}) redis=${switchInvalid.status}`,
);
// 备份 → 导出快照 → 造数据 → 快照导入 → 造数据 → 从备份恢复(三个恢复路径全验)
const dbBackup = await api('/database/backups', { method: 'POST', token: adminToken });
const backupName = dbBackup.payload?.data?.name ?? '';
record(
'手动备份(201,文件名符合约定格式)',
dbBackup.status === 201 && /^backup-manual-\d{8}-\d{6}\.json\.gz$/.test(backupName),
`name=${backupName} size=${dbBackup.payload?.data?.sizeBytes ?? '-'}`,
);
const dbExport = await fetch(`${BASE_URL}/api/database/export`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const snapshotJson = dbExport.status === 200 ? await dbExport.text() : '';
let snapshotParsed = null;
try {
snapshotParsed = JSON.parse(snapshotJson);
} catch {
snapshotParsed = null;
}
record(
'导出快照(JSON 格式正确、14 张表、用户数 > 0)',
dbExport.status === 200 &&
snapshotParsed?.format === 'classhelper-snapshot' &&
Object.keys(snapshotParsed?.data ?? {}).length === 14 &&
(snapshotParsed?.counts?.User ?? 0) > 0,
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
);
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)
const dbRoundtripNotice = await api('/notifications', {
method: 'POST',
token: adminToken,
body: { classId, title: '数据库roundtrip自检', content: '导入/恢复后应消失' },
});
const dbRoundtripNoticeId = dbRoundtripNotice.payload?.data?.id;
const dbImport = await api('/database/import', {
method: 'POST',
token: adminToken,
body: { data: Buffer.from(snapshotJson, 'utf8').toString('base64') },
});
const afterImport = await api(`/notifications?classId=${classId}`, { token: adminToken });
const roundtripNoticeGone = !(afterImport.payload?.data ?? []).some(
(item) => item.id === dbRoundtripNoticeId,
);
record(
'快照导入(整库覆盖:导入后临时通知消失)',
dbImport.status === 200 && roundtripNoticeGone,
`导入=${dbImport.status} 通知已清除=${roundtripNoticeGone} 行数=${JSON.stringify(dbImport.payload?.data?.counts ?? {}).slice(0, 80)}`,
);
const dbRoundtripNotice2 = await api('/notifications', {
method: 'POST',
token: adminToken,
body: { classId, title: '数据库roundtrip自检2', content: '备份恢复后应消失' },
});
const dbRoundtripNotice2Id = dbRoundtripNotice2.payload?.data?.id;
const dbRestore = await api(`/database/backups/${backupName}/restore`, {
method: 'POST',
token: adminToken,
});
const afterRestore = await api(`/notifications?classId=${classId}`, { token: adminToken });
const restoreNoticeGone = !(afterRestore.payload?.data ?? []).some(
(item) => item.id === dbRoundtripNotice2Id,
);
record(
'从备份恢复(整库覆盖:恢复后临时通知消失)',
dbRestore.status === 200 && restoreNoticeGone,
`恢复=${dbRestore.status} 通知已清除=${restoreNoticeGone}`,
);
if (dbRoundtripNoticeId) {
await api(`/notifications/${dbRoundtripNoticeId}`, { method: 'DELETE', token: adminToken });
}
if (dbRoundtripNotice2Id) {
await api(`/notifications/${dbRoundtripNotice2Id}`, { method: 'DELETE', token: adminToken });
}
const dbBackupList = await api('/database/backups', { token: adminToken });
const dbBackupDelete = await api(`/database/backups/${backupName}`, {
method: 'DELETE',
token: adminToken,
});
const dbBackupListAfter = await api('/database/backups', { token: adminToken });
record(
'备份列表与删除(创建后可见,删除后消失)',
dbBackupList.status === 200 &&
(dbBackupList.payload?.data ?? []).some((item) => item.name === backupName) &&
dbBackupDelete.status === 200 &&
!(dbBackupListAfter.payload?.data ?? []).some((item) => item.name === backupName),
`删除前=${(dbBackupList.payload?.data ?? []).length} 删除后=${(dbBackupListAfter.payload?.data ?? []).length}`,
);
const dbScheduleInvalid = await api('/database/backup-schedule', {
method: 'PUT',
token: adminToken,
body: { enabled: true, intervalHours: 0, keepCount: 7 },
});
const dbScheduleSave = await api('/database/backup-schedule', {
method: 'PUT',
token: adminToken,
body: { enabled: false, intervalHours: 24, keepCount: 7 },
});
record(
'定时备份配置(非法间隔 422 / 合法保存并回读)',
dbScheduleInvalid.status === 422 &&
dbScheduleSave.status === 200 &&
dbScheduleSave.payload?.data?.enabled === false &&
dbScheduleSave.payload?.data?.intervalHours === 24,
`非法=${dbScheduleInvalid.status} 保存=${dbScheduleSave.status} enabled=${dbScheduleSave.payload?.data?.enabled}`,
);
if (dbStatusData.provider === 'sqlite') {
const sqliteFile = await fetch(`${BASE_URL}/api/database/sqlite-file`, {
headers: { Authorization: `Bearer ${adminToken}` },
});
const sqliteBytes = sqliteFile.status === 200 ? (await sqliteFile.arrayBuffer()).byteLength : 0;
record(
'SQLite 数据文件下载(快捷导出 .db 文件)',
sqliteFile.status === 200 && sqliteBytes > 0,
`status=${sqliteFile.status} bytes=${sqliteBytes}`,
);
}
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
const realtimeNoticeId = createdNotification.payload?.data?.id;
if (realtimeNoticeId) {
@@ -2388,6 +2629,42 @@ async function main() {
} 残留班级=${leftoverClasses.map((item) => item.name).join(',') || '无'}`,
);
// ---------------------------------------------------------------- 更新检查
// 验的是"结构完整 + 权限 + 可降级",**刻意不断言"一定有新版本"** ——
// 那会把用例绑死在外网可达上,而学校机房/内网服务器查不到 GitHub 是常态
// (`ok:false` 是设计内的结果,不是失败)。真实联网取值在交付前人工验一次。
const updateAnon = await api('/update/check');
record('更新检查未登录 401', updateAnon.status === 401, `status=${updateAnon.status}`);
const updateRes = await api('/update/check', { token: adminToken });
const updateData = updateRes.payload?.data ?? {};
const isVersionLike = (value) => typeof value === 'string' && /^\d/.test(value);
record(
'更新检查(管理员):结构完整,本机版本取自 package.json',
updateRes.status === 200 &&
isVersionLike(updateData.currentVersion) &&
typeof updateData.ok === 'boolean' &&
typeof updateData.hasUpdate === 'boolean' &&
(updateData.ok
? isVersionLike(updateData.latestVersion) &&
Array.isArray(updateData.assets) &&
/^https:\/\//.test(updateData.releaseUrl ?? '') &&
updateData.error === null
: typeof updateData.error === 'string' && updateData.error.length > 0),
`ok=${updateData.ok} 本机=${updateData.currentVersion} 最新=${updateData.latestVersion ?? '-'} ` +
`有新版本=${updateData.hasUpdate} 附件=${(updateData.assets ?? []).length}` +
`${updateData.error ? ` 原因=${updateData.error}` : ''}`,
);
// force 只对管理员生效:教师带 force=1 也必须命中缓存(否则任意登录用户都能刷爆匿名限流)
const updateTeacherForced = await api('/update/check?force=1', { token: teacherToken });
record(
'更新检查:教师可查(公开信息),但 force 仅管理员生效',
updateTeacherForced.status === 200 &&
updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt,
`status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`,
);
socket.close();
// ---------------------------------------------------------------- 汇总
+5 -3
View File
@@ -6,6 +6,7 @@ import { env } from './config/env.js';
import { pingDatabase } from './lib/db.js';
import { sendOk } from './lib/http.js';
import { logger } from './lib/logger.js';
import { APP_VERSION } from './lib/version.js';
import { mountWebAdmin, resolveWebDistDir } from './lib/web-static.js';
import { errorHandler, notFoundHandler } from './middleware/error.js';
import { apiRateLimiter, loginRateLimiter, requestLogger, securityHeaders } from './middleware/security.js';
@@ -37,7 +38,8 @@ async function healthHandler(_req: Request, res: Response): Promise<void> {
res,
{
status: 'ok',
version: '0.1.0',
// 版本号取自 package.json(见 lib/version.ts):更新检查要拿它跟 GitHub 上的 Release 比
version: APP_VERSION,
env: env.nodeEnv,
uptimeSeconds: Math.round(process.uptime()),
database: {
@@ -68,7 +70,7 @@ function createRootHandler(webDistDir: string | null) {
res,
{
service: 'class-helper-server',
version: '0.1.0',
version: APP_VERSION,
environment: env.nodeEnv,
apiPrefix: API_PREFIX,
socket: '/socket.io',
@@ -115,7 +117,7 @@ function renderLandingPage(webAdminServed: boolean): string {
<body>
<div class="card">
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
<p class="sub">版本 0.1.0 · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub">版本 ${APP_VERSION} · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
<p class="sub">
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
存活 /healthz · 就绪 /readyz
-1
View File
@@ -156,7 +156,6 @@ export const env = {
.map((item) => item.trim())
.filter(Boolean),
bcryptRounds: raw.BCRYPT_ROUNDS,
/** 新增学生时未指定密码时的初始密码 */
/** 新建班级时的默认班级密码(班级账号登录) */
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
/** 新建/重置教师账号时的初始密码 */
+3
View File
@@ -9,6 +9,7 @@ import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
import { logger } from './lib/logger.js';
import { resolveWebDistDir } from './lib/web-static.js';
import { initRealtime } from './realtime/socket.js';
import { startBackupScheduler } from './modules/database/database.service.js';
/** 数据库自检与可选的首次初始化 */
async function prepareDatabase(): Promise<void> {
@@ -85,6 +86,8 @@ httpServer.listen(env.port, env.host, () => {
}
logger.info(`探针:/healthz(存活) · /readyz(就绪) · ${API_PREFIX}/health(详情)`);
logger.info(`WebSocket:ws://${displayHost}:${env.port}/socket.io`);
// 数据库管理模块的定时备份调度(配置与"到点时间"持久化在 data/database-settings.json)
startBackupScheduler();
});
let shuttingDown = false;
+15 -9
View File
@@ -3,27 +3,33 @@ import { PrismaClient } from '../generated/prisma/client.js';
import { env } from '../config/env.js';
import { logger } from './logger.js';
/** 本项目支持的主数据库类型(Redis 等键值库不是 Prisma 支持的主库,不在范围内) */
export type DatabaseProvider = 'sqlite' | 'mysql';
/**
* Prisma 7 要求所有数据库都通过 driver adapter 接入:
* - SQLite -> @prisma/adapter-libsql(libSQL 内嵌模式,npm 预编译二进制,无需本地编译)
* - MySQL -> @prisma/adapter-mariadb(切换数据库时安装,见 docs/mysql.md)
* - MySQL -> @prisma/adapter-mariadb(依赖里常驻,切换数据库时直接可用)
*
* MySQL 分支使用「非字面量模块名」动态导入,这样未安装 mariadb 适配器时
* 也不会影响类型检查与 SQLite 模式的运行。
*
* 该工厂独立于进程级单例:数据库管理模块的「连接测试」与跨库迁移的工具进程
* 都需要按任意 provider/url 构建临时客户端,而不是读 env 单例。
*/
async function createAdapter(): Promise<unknown> {
if (env.databaseProvider === 'mysql') {
export async function createPrismaClient(
provider: DatabaseProvider,
url: string,
): Promise<PrismaClient> {
if (provider === 'mysql') {
const adapterModule = '@prisma/adapter-mariadb';
const mod = (await import(adapterModule)) as { PrismaMariaDb: new (url: string) => unknown };
logger.info('已加载 MySQL/MariaDB 驱动适配器');
return new mod.PrismaMariaDb(env.databaseUrl);
return new PrismaClient({ adapter: new mod.PrismaMariaDb(url) as never });
}
return new PrismaLibSql({ url: env.databaseUrl });
return new PrismaClient({ adapter: new PrismaLibSql({ url }) as never });
}
const adapter = await createAdapter();
export const prisma = new PrismaClient({ adapter: adapter as never });
export const prisma = await createPrismaClient(env.databaseProvider, env.databaseUrl);
logger.info(`Prisma 客户端已初始化(provider=${env.databaseProvider})`);
+159
View File
@@ -0,0 +1,159 @@
import type { PrismaClient } from '../generated/prisma/client.js';
import type { DatabaseProvider } from './db.js';
/**
* 全库 JSON 快照:备份 / 恢复 / 导入导出 / 跨库迁移共用这一种格式。
*
* 为什么不用「复制数据库文件」或 mysqldump:
* - 文件级备份只对 SQLite 有意义,跨库迁移根本用不上;
* - JSON 快照与 provider 无关,一份实现同时服务备份、导入导出和 SQLite⇄MySQL 迁移。
*
* 外键注意:schema 里 User.classId → Class 且 Class.teacherId → User(互相引用),
* 恢复时必须**临时关闭外键检查**再按序写入,否则任何排序都会被其中一侧卡死。
* - SQLite:PRAGMA foreign_keys=OFF(libsql 连接级,进程内安全)
* - MySQL:SET FOREIGN_KEY_CHECKS=0/1(会话级,连接池下同一客户端实例内生效)
*/
export const SNAPSHOT_FORMAT = 'classhelper-snapshot';
export const SNAPSHOT_VERSION = 1;
/** 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查) */
export const SNAPSHOT_TABLES = [
'User',
'Class',
'Course',
'ClassTeacher',
'Enrollment',
'Schedule',
'TimeLayout',
'Homework',
'HomeworkStatus',
'Notification',
'NotificationRead',
'Grade',
'IntegrationDevice',
'ClassIslandPush',
] as const;
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
export interface DatabaseSnapshot {
format: string;
version: number;
createdAt: string;
source: { provider: string };
/** 每张表的行数(导出时统计,恢复时校验总量用) */
counts: Record<string, number>;
/** 表名 → 行数组(DateTime 已是 ISO 字符串,Prisma 接受 ISO 字符串作为 DateTime 输入) */
data: Record<SnapshotTable, Record<string, unknown>[]>;
}
/** 模型代理的最小接口(避免 any:Prisma 各模型的 delegate 形状一致) */
interface ModelDelegate {
findMany(): Promise<Record<string, unknown>[]>;
count(): Promise<number>;
createMany(args: { data: Record<string, unknown>[] }): Promise<unknown>;
deleteMany(): Promise<unknown>;
}
function delegatesOf(prisma: PrismaClient): Record<SnapshotTable, ModelDelegate> {
return prisma as unknown as Record<SnapshotTable, ModelDelegate>;
}
/** 单次 createMany 的批量大小(班级规模数据量小,小批次足够且省内存) */
const CHUNK_SIZE = 200;
/** 导出全库快照(行内 DateTime 为 Date 对象,JSON.stringify 时自动转 ISO) */
export async function exportSnapshot(
prisma: PrismaClient,
provider: DatabaseProvider,
): Promise<DatabaseSnapshot> {
const delegates = delegatesOf(prisma);
const data = {} as Record<SnapshotTable, Record<string, unknown>[]>;
const counts: Record<string, number> = {};
for (const table of SNAPSHOT_TABLES) {
const rows = await delegates[table].findMany();
data[table] = rows;
counts[table] = rows.length;
}
return {
format: SNAPSHOT_FORMAT,
version: SNAPSHOT_VERSION,
createdAt: new Date().toISOString(),
source: { provider },
counts,
data,
};
}
/** 快照结构校验:给出可读的失败原因("为什么恢复失败"比失败本身重要) */
export function assertValidSnapshot(value: unknown): asserts value is DatabaseSnapshot {
if (typeof value !== 'object' || value === null) {
throw new Error('快照内容不是合法的 JSON 对象');
}
const snapshot = value as Partial<DatabaseSnapshot>;
if (snapshot.format !== SNAPSHOT_FORMAT) {
throw new Error(`快照格式不正确(期望 ${SNAPSHOT_FORMAT},收到 ${String(snapshot.format ?? '未知')})`);
}
if (snapshot.version !== SNAPSHOT_VERSION) {
throw new Error(`快照版本不兼容(期望 v${SNAPSHOT_VERSION},收到 v${String(snapshot.version)})`);
}
if (typeof snapshot.data !== 'object' || snapshot.data === null) {
throw new Error('快照缺少 data 字段');
}
for (const table of SNAPSHOT_TABLES) {
const rows = (snapshot.data as Record<string, unknown>)[table];
if (!Array.isArray(rows)) {
throw new Error(`快照缺少表 ${table} 的数据`);
}
}
}
async function setForeignKeys(prisma: PrismaClient, provider: DatabaseProvider, enabled: boolean): Promise<void> {
const sql =
provider === 'mysql'
? `SET FOREIGN_KEY_CHECKS=${enabled ? 1 : 0}`
: `PRAGMA foreign_keys=${enabled ? 'ON' : 'OFF'}`;
await prisma.$executeRawUnsafe(sql);
}
/**
* 把快照写入目标库(先清空再写入)。
* 用于:备份恢复、快照导入、跨库迁移的子进程。
*/
export async function restoreSnapshot(
prisma: PrismaClient,
provider: DatabaseProvider,
snapshot: DatabaseSnapshot,
options: { clean?: boolean } = {},
): Promise<{ counts: Record<string, number> }> {
assertValidSnapshot(snapshot);
const delegates = delegatesOf(prisma);
const clean = options.clean !== false;
const counts: Record<string, number> = {};
await setForeignKeys(prisma, provider, false);
try {
if (clean) {
// 反向清空,保证被引用的表最后清(外键关闭后只是防御性的,保持确定性)
for (const table of [...SNAPSHOT_TABLES].reverse()) {
await delegates[table].deleteMany();
}
}
for (const table of SNAPSHOT_TABLES) {
const rows = snapshot.data[table];
for (let index = 0; index < rows.length; index += CHUNK_SIZE) {
await delegates[table].createMany({ data: rows.slice(index, index + CHUNK_SIZE) });
}
counts[table] = rows.length;
}
} finally {
await setForeignKeys(prisma, provider, true).catch(() => undefined);
}
return { counts };
}
/** 汇总快照总行数(列表展示 / 切换任务结果用) */
export function snapshotTotalRows(snapshot: DatabaseSnapshot): number {
return Object.values(snapshot.counts).reduce((sum, count) => sum + count, 0);
}
+41
View File
@@ -0,0 +1,41 @@
import fs from 'node:fs';
import path from 'node:path';
import { env } from '../config/env.js';
import { logger } from './logger.js';
/**
* 服务端版本号。
*
* 为什么不再硬编码:`app.ts` 里原本写了两个字面量 `'1.0.0'`(健康检查 + 首页),
* 而版本号在仓库里已经有 5 份 package.json —— 更新检查要靠它判断"本机是不是旧版",
* 再留一份手写副本就一定会漂移。
*
* 取哪一份(按顺序):
* 1. `serverRoot/..` —— 打包形态下是运行时清单(`scripts/dist-server.mjs` 把**根**
* package.json 的 version 写进 `staging/package.json`),也就是产物命名用的那个版本;
* 2. `serverRoot` —— 开发形态下是 `packages/server/package.json`。
*
* 与 `config/env.ts` 加载 `.env` 的双候选写法同一思路:不依赖启动时的 cwd。
*/
function readVersion(): string {
const candidates = [
path.resolve(env.serverRoot, '..', 'package.json'),
path.resolve(env.serverRoot, 'package.json'),
];
for (const file of candidates) {
try {
const parsed = JSON.parse(fs.readFileSync(file, 'utf8')) as { version?: unknown };
if (typeof parsed.version === 'string' && parsed.version.trim()) {
return parsed.version.trim();
}
} catch {
// 换下一个候选
}
}
logger.warn(`未能从 package.json 读到版本号(候选:${candidates.join('、')}),暂时按 0.0.0 处理`);
return '0.0.0';
}
export const APP_VERSION = readVersion();
+1 -7
View File
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
import express, { type Express, type RequestHandler } from 'express';
import express, { type Express } from 'express';
import { env } from '../config/env.js';
import { logger } from './logger.js';
@@ -100,9 +100,3 @@ export function mountWebAdmin(app: Express): string | null {
logger.info(`Web 管理端已挂载:${distDir}`);
return distDir;
}
/** 静态资源请求判断(用于日志降噪等场景) */
export const staticAssetMatcher: RequestHandler = (req, _res, next) => {
req.headers['x-classhelper-static'] = req.path.startsWith('/assets/') ? '1' : '0';
next();
};
+26 -3
View File
@@ -11,8 +11,13 @@ import { logger } from '../lib/logger.js';
* - style-src 需要 'unsafe-inline':Element Plus 运行时注入行内样式
* - connect-src 需要放行 WebSocket(同源 ws/wss)与已配置的跨域后端
* - 桌面客户端通过 file:// 加载页面,因此不启用 COOP/COEP,资源策略设为 cross-origin
* - helmet 默认 CSP 自带 upgrade-insecure-requests,它会把页面**子资源**请求全部改写成
* https —— 而安装器的默认形态是 http://IP:端口 直连(无 TLS),改写后的请求必然
* 连接失败,表现为「管理端一片空白、标签页标题正常」(2026-10-05 实测踩到)。
* HSTS 在明文响应里按规范本就会被浏览器忽略,但 CSP 指令没有这层豁免,
* 所以这两者都只在 HTTPS 请求下下发(见 buildHelmet 的 httpsRequest 参数)。
*/
export function securityHeaders(): RequestHandler {
function buildHelmet(httpsRequest: boolean): RequestHandler {
const connectSrc = new Set<string>(["'self'", 'ws:', 'wss:']);
if (Array.isArray(env.corsOrigins)) {
for (const origin of env.corsOrigins) connectSrc.add(origin);
@@ -32,6 +37,7 @@ export function securityHeaders(): RequestHandler {
'base-uri': ["'self'"],
'frame-ancestors': ["'none'"],
'form-action': ["'self'"],
'upgrade-insecure-requests': httpsRequest ? [] : null,
},
},
crossOriginEmbedderPolicy: false,
@@ -39,10 +45,18 @@ export function securityHeaders(): RequestHandler {
// 允许桌面客户端等跨源场景读取静态资源与接口响应
crossOriginResourcePolicy: { policy: 'cross-origin' },
referrerPolicy: { policy: 'no-referrer' },
hsts: env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
hsts: httpsRequest && env.isProduction ? { maxAge: 15552000, includeSubDomains: true } : false,
});
}
export function securityHeaders(): RequestHandler {
const secure = buildHelmet(true);
const plain = buildHelmet(false);
// req.secure:直连 TLS 时为 true;前面有反代时由 x-forwarded-proto 决定
// (app.ts 里 app.set('trust proxy', env.trustProxy) 已生效)
return (req, res, next) => (req.secure ? secure : plain)(req, res, next);
}
/** 通用接口限流:防止单 IP 高频刷接口 */
export function apiRateLimiter(): RequestHandler {
const limiter = rateLimit({
@@ -85,11 +99,20 @@ export function loginRateLimiter(): RequestHandler {
});
}
/** 请求耗时日志(生产 info,开发 debug) */
/**
* 请求耗时日志(生产 info,开发 debug)。
*
* 静态构建产物(`/assets/*`,带内容哈希、长缓存)**成功的请求不打点**:
* Web 管理端首屏一次就有十几个资源请求,逐条刷 info 会把真正有用的接口日志淹掉,
* 而且每条都要走一次 stdout 写入。失败的静态请求(404 / 500)照常记录 ——
* 那种情况恰恰是最需要看见的(产物缺失、路径不对)。
*/
export function requestLogger(): RequestHandler {
return (req, res, next) => {
const isStaticAsset = req.path.startsWith('/assets/');
const startedAt = process.hrtime.bigint();
res.on('finish', () => {
if (isStaticAsset && res.statusCode < 400) return;
const durationMs = Number(process.hrtime.bigint() - startedAt) / 1_000_000;
const message = `${req.method} ${req.originalUrl} ${res.statusCode} ${durationMs.toFixed(1)}ms`;
if (res.statusCode >= 500) logger.error(message);
@@ -0,0 +1,124 @@
import fs from 'node:fs';
import path from 'node:path';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, requireRole } from '../../middleware/auth.js';
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
backupNameParamSchema,
backupScheduleSchema,
importSnapshotSchema,
jobIdParamSchema,
switchDatabaseSchema,
testConnectionSchema,
type BackupScheduleInput,
type ImportSnapshotInput,
type SwitchDatabaseInput,
type TestConnectionInput,
} from './database.schemas.js';
import * as databaseService from './database.service.js';
/**
* 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
*
* 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下;
* 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。
*
* 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。
* Redis 是键值库,不是 Prisma 支持的主数据库,因此不在此模块范围内。
*/
const router = Router();
router.use(authenticate());
router.use(requireRole('ADMIN'));
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
router.get('/status', async (_req, res) => {
sendOk(res, await databaseService.getDatabaseStatus());
});
/** POST /api/database/test-connection - 测试任意目标库连通性(切换前的前置检查) */
router.post('/test-connection', validate({ body: testConnectionSchema }), async (req, res) => {
const result = await databaseService.testConnection(validatedBody<TestConnectionInput>(req));
sendOk(res, result, result.ok ? '连接成功' : '连接失败');
});
/** GET /api/database/backups - 备份列表 */
router.get('/backups', (_req, res) => {
sendOk(res, databaseService.listBackups());
});
/** POST /api/database/backups - 立即创建一份手动备份 */
router.post('/backups', async (_req, res) => {
sendCreated(res, await databaseService.createBackup('manual'), '备份已创建');
});
/** POST /api/database/backups/:name/restore - 从备份恢复(整库覆盖) */
router.post('/backups/:name/restore', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req);
sendOk(res, await databaseService.restoreBackup(name), '已从备份恢复');
});
/** DELETE /api/database/backups/:name - 删除备份 */
router.delete('/backups/:name', validate({ params: backupNameParamSchema }), async (req, res) => {
const { name } = validatedParams<{ name: string }>(req);
databaseService.deleteBackup(name);
sendOk(res, { name }, '备份已删除');
});
/** GET /api/database/export - 下载当前库的 JSON 快照 */
router.get('/export', async (_req, res) => {
const json = await databaseService.exportSnapshotJson();
const filename = `classhelper-snapshot-${new Date().toISOString().slice(0, 19).replaceAll(':', '')}.json`;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Content-Disposition', `attachment; filename="${filename}"`);
res.status(200).send(json);
});
/** GET /api/database/sqlite-file - 下载数据库文件(仅 SQLite 模式) */
router.get('/sqlite-file', (_req, res) => {
const filePath = databaseService.sqliteFilePathForDownload();
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="${path.basename(filePath)}"`);
res.status(200).send(fs.readFileSync(filePath));
});
/** POST /api/database/import - 导入快照(整库覆盖;base64 随 JSON 请求体) */
router.post('/import', validate({ body: importSnapshotSchema }), async (req, res) => {
const { data } = validatedBody<ImportSnapshotInput>(req);
sendOk(res, await databaseService.importSnapshotBase64(data), '快照已导入(原数据已被覆盖)');
});
/** GET /api/database/backup-schedule - 定时备份配置 */
router.get('/backup-schedule', (_req, res) => {
sendOk(res, databaseService.getBackupSchedule());
});
/** PUT /api/database/backup-schedule - 更新定时备份配置(立即生效,重启后仍有效) */
router.put('/backup-schedule', validate({ body: backupScheduleSchema }), async (req, res) => {
sendOk(
res,
databaseService.saveSchedulePatch(validatedBody<BackupScheduleInput>(req)),
'定时备份配置已保存',
);
});
/** POST /api/database/switch - 一键切换数据库(异步任务,返回 jobId) */
router.post('/switch', validate({ body: switchDatabaseSchema }), async (req, res) => {
const job = databaseService.startSwitch(validatedBody<SwitchDatabaseInput>(req));
sendCreated(res, { jobId: job.id }, '切换任务已启动');
});
/** GET /api/database/switch/jobs/:id - 查询切换任务进度 */
router.get('/switch/jobs/:id', validate({ params: jobIdParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
const job = databaseService.getSwitchJob(id);
sendOk(res, job, job ? 'ok' : '任务不存在');
});
export const databaseModule = defineModule({
name: 'database',
basePath: '/database',
router,
});
@@ -0,0 +1,65 @@
import { z } from 'zod';
/** 本项目支持的主数据库类型。Redis 等键值库不是 Prisma 支持的主库,不提供切换。 */
export const databaseProviderSchema = z.enum(['sqlite', 'mysql']);
export type DatabaseProviderInput = z.infer<typeof databaseProviderSchema>;
/** 连接串与 provider 配套校验:SQLite 必须 file: 前缀,MySQL 必须 mysql:// 前缀 */
export const targetConnectionSchema = z
.object({
provider: databaseProviderSchema,
url: z.string().min(1, '请填写连接串'),
})
.superRefine((value, ctx) => {
const url = value.url.trim();
if (value.provider === 'sqlite' && !url.startsWith('file:')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['url'],
message: 'SQLite 连接串必须以 file: 开头,例如 file:./data/classhelper.db',
});
}
if (value.provider === 'mysql' && !/^mysqls?:\/\//.test(url)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['url'],
message: 'MySQL 连接串必须以 mysql:// 或 mysqls:// 开头',
});
}
});
export type TargetConnectionInput = z.infer<typeof targetConnectionSchema>;
/** POST /database/test-connection:测试任意目标库的连通性 */
export const testConnectionSchema = targetConnectionSchema;
export type TestConnectionInput = z.infer<typeof testConnectionSchema>;
/** POST /database/switch:一键切换数据库(含数据迁移) */
export const switchDatabaseSchema = targetConnectionSchema;
export type SwitchDatabaseInput = z.infer<typeof switchDatabaseSchema>;
/** POST /database/import:上传快照(base64,随 JSON 请求体,受 12MB 请求体限制) */
export const importSnapshotSchema = z.object({
data: z.string().min(1, '请提供快照内容(base64)'),
});
export type ImportSnapshotInput = z.infer<typeof importSnapshotSchema>;
/** PUT /database/backup-schedule:定时备份配置 */
export const backupScheduleSchema = z.object({
enabled: z.boolean(),
/** 间隔小时数(1~720,默认 24 = 每天一次) */
intervalHours: z.number().int().min(1).max(720),
/** 最多保留多少份自动备份(超出后删最旧的;手动备份不受影响) */
keepCount: z.number().int().min(1).max(100),
});
export type BackupScheduleInput = z.infer<typeof backupScheduleSchema>;
/** 路径参数:备份文件名(必须是本服务生成的固定格式,杜绝路径穿越) */
export const backupNameParamSchema = z.object({
name: z.string().regex(/^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/, '备份文件名不合法'),
});
/** 路径参数:切换任务 id(本服务生成,字母数字与连字符) */
export const jobIdParamSchema = z.object({
id: z.string().regex(/^[0-9a-z-]{6,40}$/, '任务 id 不合法'),
});
@@ -0,0 +1,771 @@
import { spawn } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import zlib from 'node:zlib';
import { env } from '../../config/env.js';
import { createPrismaClient, prisma, type DatabaseProvider } from '../../lib/db.js';
import { logger } from '../../lib/logger.js';
import {
assertValidSnapshot,
exportSnapshot,
restoreSnapshot,
SNAPSHOT_TABLES,
type DatabaseSnapshot,
} from '../../lib/snapshot.js';
import { ApiError } from '../../lib/http.js';
import type {
BackupScheduleInput,
TargetConnectionInput,
} from './database.schemas.js';
/**
* 数据库管理服务:状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
*
* 所有实现都基于 `lib/snapshot.ts` 的 JSON 快照:与 provider 无关,
* 因此备份可以恢复回任意一种受支持的数据库(备份即迁移的载体)。
*/
/* ================================================================ 路径 */
/** 数据目录:SQLite = 数据文件所在目录;MySQL = 安装根的 data 目录(与部署约定一致) */
function dataDir(): string {
if (env.databaseProvider === 'sqlite' && env.sqliteFilePath) {
return path.dirname(env.sqliteFilePath);
}
return path.join(env.serverRoot, '..', 'data');
}
function backupDir(): string {
return path.join(dataDir(), 'backups');
}
function settingsFilePath(): string {
return path.join(dataDir(), 'database-settings.json');
}
function ensureBackupDir(): string {
const dir = backupDir();
fs.mkdirSync(dir, { recursive: true });
return dir;
}
/* ================================================================ 定时备份配置 */
interface BackupSchedule {
enabled: boolean;
intervalHours: number;
keepCount: number;
lastAutoBackupAt: string | null;
}
const DEFAULT_SCHEDULE: BackupSchedule = {
enabled: false,
intervalHours: 24,
keepCount: 7,
lastAutoBackupAt: null,
};
function readSchedule(): BackupSchedule {
try {
const raw = JSON.parse(fs.readFileSync(settingsFilePath(), 'utf8')) as Partial<BackupSchedule>;
return {
enabled: raw.enabled === true,
intervalHours:
typeof raw.intervalHours === 'number' && raw.intervalHours >= 1 ? Math.floor(raw.intervalHours) : DEFAULT_SCHEDULE.intervalHours,
keepCount: typeof raw.keepCount === 'number' && raw.keepCount >= 1 ? Math.floor(raw.keepCount) : DEFAULT_SCHEDULE.keepCount,
lastAutoBackupAt: typeof raw.lastAutoBackupAt === 'string' ? raw.lastAutoBackupAt : null,
};
} catch {
return { ...DEFAULT_SCHEDULE };
}
}
function writeSchedule(schedule: BackupSchedule): void {
ensureBackupDir();
fs.writeFileSync(settingsFilePath(), `${JSON.stringify(schedule, null, 2)}\n`, 'utf8');
}
/** 读取定时备份配置(GET /backup-schedule) */
export function getBackupSchedule(): BackupSchedule {
return readSchedule();
}
/** 更新定时备份配置(PUT /backup-schedule),写盘后立即生效 */
export function saveSchedulePatch(patch: BackupScheduleInput): BackupSchedule {
const next: BackupSchedule = { ...readSchedule(), ...patch };
writeSchedule(next);
return next;
}
let schedulerTimer: NodeJS.Timeout | null = null;
/**
* 定时备份调度:每 10 分钟检查一次,到点自动备份并清理旧备份。
* 依赖服务端进程常驻(进程不运行不会补跑);lastAutoBackupAt 持久化在配置文件里,
* 重启后不会立刻重复备份。
*/
export function startBackupScheduler(): void {
if (schedulerTimer) return;
const tick = async (): Promise<void> => {
if (isSwitchRunning()) return;
const schedule = readSchedule();
if (!schedule.enabled) return;
const last = schedule.lastAutoBackupAt ? Date.parse(schedule.lastAutoBackupAt) : 0;
const dueAt = (Number.isFinite(last) ? last : 0) + schedule.intervalHours * 3_600_000;
if (Date.now() < dueAt) return;
try {
await createBackup('auto');
writeSchedule({ ...schedule, lastAutoBackupAt: new Date().toISOString() });
logger.info('定时备份完成');
} catch (error) {
logger.error('定时备份失败', error);
}
};
schedulerTimer = setInterval(() => void tick(), 10 * 60_000);
schedulerTimer.unref();
logger.info('定时备份调度已启动(每 10 分钟检查一次到点任务)');
}
/* ================================================================ 备份 / 恢复 */
export interface BackupMeta {
name: string;
kind: 'manual' | 'auto';
sizeBytes: number;
createdAt: string;
}
function isBackupName(name: string): boolean {
return /^backup-(manual|auto)-\d{8}-\d{6}\.json\.gz$/.test(name);
}
function backupPath(name: string): string {
if (!isBackupName(name)) {
throw ApiError.badRequest('备份文件名不合法');
}
const target = path.join(backupDir(), name);
if (path.dirname(target) !== backupDir()) {
throw ApiError.badRequest('备份文件名不合法');
}
return target;
}
function timestampSlug(): string {
const now = new Date();
const pad = (value: number): string => String(value).padStart(2, '0');
return (
`${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}` +
`-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
);
}
/** 创建一份备份(gzip 压缩的 JSON 快照)。kind 决定文件名前缀与自动清理策略。 */
export async function createBackup(kind: 'manual' | 'auto'): Promise<BackupMeta> {
assertNotSwitching();
const dir = ensureBackupDir();
const snapshot = await exportSnapshot(prisma, env.databaseProvider);
const payload = zlib.gzipSync(Buffer.from(JSON.stringify(snapshot), 'utf8'));
const name = `backup-${kind}-${timestampSlug()}.json.gz`;
fs.writeFileSync(path.join(dir, name), payload);
if (kind === 'auto') pruneAutoBackups();
const meta: BackupMeta = { name, kind, sizeBytes: payload.length, createdAt: snapshot.createdAt };
logger.info(`已创建${kind === 'auto' ? '定时' : '手动'}备份:${name}`);
return meta;
}
/** 自动备份按 keepCount 清理最旧的(手动备份不受影响) */
function pruneAutoBackups(): void {
const schedule = readSchedule();
const autos = listBackups()
.filter((item) => item.kind === 'auto')
.sort((a, b) => a.createdAt.localeCompare(b.createdAt));
for (const stale of autos.slice(0, Math.max(0, autos.length - schedule.keepCount))) {
try {
fs.unlinkSync(path.join(backupDir(), stale.name));
} catch {
// 清理失败不影响本次备份
}
}
}
export function listBackups(): BackupMeta[] {
const dir = backupDir();
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir)
.filter(isBackupName)
.map((name) => {
const stat = fs.statSync(path.join(dir, name));
return {
name,
kind: name.includes('-manual-') ? ('manual' as const) : ('auto' as const),
sizeBytes: stat.size,
createdAt: stat.mtime.toISOString(),
};
})
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
}
function readBackupSnapshot(name: string): DatabaseSnapshot {
const file = backupPath(name);
if (!fs.existsSync(file)) {
throw ApiError.notFound('备份不存在(可能已被删除)');
}
const snapshot = JSON.parse(zlib.gunzipSync(fs.readFileSync(file)).toString('utf8')) as DatabaseSnapshot;
assertValidSnapshot(snapshot);
return snapshot;
}
/** 从备份恢复(整库覆盖:先清空现有数据再写入备份内容) */
export async function restoreBackup(name: string): Promise<{ counts: Record<string, number> }> {
assertNotSwitching();
const snapshot = readBackupSnapshot(name);
return restoreSnapshot(prisma, env.databaseProvider, snapshot);
}
export function deleteBackup(name: string): void {
const file = backupPath(name);
if (!fs.existsSync(file)) {
throw ApiError.notFound('备份不存在(可能已被删除)');
}
fs.unlinkSync(file);
}
/* ================================================================ 导入 / 导出 */
/** 导出当前库的快照 JSON(未压缩,供下载与跨库迁移使用) */
export async function exportSnapshotJson(): Promise<string> {
assertNotSwitching();
return JSON.stringify(await exportSnapshot(prisma, env.databaseProvider));
}
/** 从 base64 快照导入(整库覆盖)。用于"快捷导入"。 */
export async function importSnapshotBase64(data: string): Promise<{ counts: Record<string, number> }> {
assertNotSwitching();
let snapshot: DatabaseSnapshot;
try {
const json = Buffer.from(data, 'base64').toString('utf8');
snapshot = JSON.parse(json) as DatabaseSnapshot;
} catch {
throw ApiError.badRequest('快照内容不是合法的 base64 JSON');
}
return restoreSnapshot(prisma, env.databaseProvider, snapshot);
}
/** SQLite 数据文件下载(仅 SQLite 模式提供;MySQL 无"单文件"概念) */
export function sqliteFilePathForDownload(): string {
if (env.databaseProvider !== 'sqlite' || !env.sqliteFilePath || !fs.existsSync(env.sqliteFilePath)) {
throw ApiError.badRequest('当前不是 SQLite 数据库或数据文件不存在');
}
return env.sqliteFilePath;
}
/* ================================================================ 状态 / 连接测试 */
export interface TableCount {
name: string;
count: number;
}
async function databaseVersion(provider: DatabaseProvider, client: typeof prisma): Promise<string> {
if (provider === 'mysql') {
const rows = (await client.$queryRawUnsafe('SELECT VERSION() AS version')) as { version?: string }[];
return rows[0]?.version ?? 'unknown';
}
const rows = (await client.$queryRawUnsafe('SELECT sqlite_version() AS version')) as {
version?: string;
}[];
return rows[0]?.version ?? 'unknown';
}
async function databaseSizeBytes(provider: DatabaseProvider, client: typeof prisma): Promise<number | null> {
if (provider === 'sqlite') {
return env.sqliteFilePath && fs.existsSync(env.sqliteFilePath) ? fs.statSync(env.sqliteFilePath).size : 0;
}
const rows = (await client.$queryRawUnsafe(
'SELECT COALESCE(SUM(data_length + index_length), 0) AS size FROM information_schema.tables WHERE table_schema = DATABASE()',
)) as { size?: bigint | number }[];
const size = rows[0]?.size;
return size === undefined ? null : Number(size);
}
export interface DatabaseStatus {
provider: DatabaseProvider;
connected: boolean;
latencyMs: number | null;
version: string | null;
sizeBytes: number | null;
/** 当前连接串的脱敏展示(MySQL 隐藏密码) */
databaseUrlMasked: string;
/** SQLite 数据文件绝对路径(MySQL 为空) */
sqliteFilePath: string;
dataDir: string;
backupDir: string;
tables: TableCount[];
backups: ReturnType<typeof listBackups>;
schedule: BackupSchedule;
/** 是否有正在进行的切换任务(期间禁止备份/导入/恢复) */
switchRunning: boolean;
}
/** 连接串脱敏:mysql://user:password@host → mysql://user:***@host */
function maskDatabaseUrl(provider: DatabaseProvider, url: string): string {
if (provider !== 'mysql') return url;
return url.replace(/(\/\/[^:/@]+:)[^@]*(@)/, '$1***$2');
}
export async function getDatabaseStatus(): Promise<DatabaseStatus> {
const startedAt = Date.now();
let connected = false;
let version: string | null = null;
try {
version = await databaseVersion(env.databaseProvider, prisma);
connected = true;
} catch {
connected = false;
}
const latencyMs = connected ? Date.now() - startedAt : null;
const delegates = prisma as unknown as Record<
(typeof SNAPSHOT_TABLES)[number],
{ count(): Promise<number> }
>;
const tables: TableCount[] = connected
? await Promise.all(
SNAPSHOT_TABLES.map(async (name): Promise<TableCount> => {
try {
return { name, count: await delegates[name].count() };
} catch {
return { name, count: -1 };
}
}),
)
: [];
return {
provider: env.databaseProvider,
connected,
latencyMs,
version,
sizeBytes: connected ? await databaseSizeBytes(env.databaseProvider, prisma).catch(() => null) : null,
databaseUrlMasked: maskDatabaseUrl(env.databaseProvider, env.databaseUrl),
sqliteFilePath: env.sqliteFilePath,
dataDir: dataDir(),
backupDir: backupDir(),
tables,
backups: listBackups(),
schedule: readSchedule(),
switchRunning: isSwitchRunning(),
};
}
export interface ConnectionTestResult {
ok: boolean;
provider: DatabaseProvider;
latencyMs: number | null;
version: string | null;
/** 新建 SQLite 库时目标文件还不存在,属于正常情况(切换时会自动建库) */
note?: string;
error?: string;
}
/** 测试任意目标库的连通性(发起切换前的第一步) */
export async function testConnection(target: TargetConnectionInput): Promise<ConnectionTestResult> {
const startedAt = Date.now();
let note: string | undefined;
let url = target.url.trim();
if (target.provider === 'sqlite') {
// 与 env.resolveSqliteUrl 同口径:相对路径以 serverRoot 为基准解析为绝对路径
const rawPath = url.slice('file:'.length).replace(/\//g, path.sep);
const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath);
if (!fs.existsSync(absolute)) {
const parent = path.dirname(absolute);
if (!fs.existsSync(parent)) {
return { ok: false, provider: target.provider, latencyMs: null, version: null, error: `目录不存在:${parent}` };
}
note = '目标文件不存在,属新库(切换时会自动建表)';
}
url = `file:${absolute.replace(/\\/g, '/')}`;
}
let client: Awaited<ReturnType<typeof createPrismaClient>> | null = null;
try {
client = await createPrismaClient(target.provider, url);
const version = await databaseVersion(target.provider, client);
return { ok: true, provider: target.provider, latencyMs: Date.now() - startedAt, version, note };
} catch (error) {
return {
ok: false,
provider: target.provider,
latencyMs: null,
version: null,
error: error instanceof Error ? error.message : String(error),
};
} finally {
await client?.$disconnect().catch(() => undefined);
}
}
/* ================================================================ 一键切换 */
export interface SwitchStep {
name: string;
status: 'running' | 'done' | 'error';
detail?: string;
}
export interface SwitchJob {
id: string;
status: 'running' | 'done' | 'error';
target: { provider: DatabaseProvider; url: string };
steps: SwitchStep[];
error?: string;
/** 完成后必填:需要重启服务端才能让新数据库生效 */
restartRequired: boolean;
counts?: Record<string, number>;
backupName?: string;
startedAt: string;
finishedAt?: string;
}
const switchJobs = new Map<string, SwitchJob>();
function isSwitchRunning(): boolean {
for (const job of switchJobs.values()) {
if (job.status === 'running') return true;
}
return false;
}
function assertNotSwitching(): void {
if (isSwitchRunning()) {
throw ApiError.conflict('正在执行数据库切换,请等待完成后再进行备份/导入/恢复');
}
}
/** 防止路径穿越:任务 id 只由本服务生成(时间戳 + 随机数) */
function newJobId(): string {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
}
export function getSwitchJob(id: string): SwitchJob | null {
return switchJobs.get(id) ?? null;
}
function stepOf(job: SwitchJob, name: string): SwitchStep {
let step = job.steps.find((item) => item.name === name);
if (!step) {
step = { name, status: 'running' };
job.steps.push(step);
}
return step;
}
function finishStep(job: SwitchJob, name: string, detail?: string): void {
const step = stepOf(job, name);
step.status = 'done';
if (detail) step.detail = detail;
}
function appendDetail(job: SwitchJob, name: string, detail: string): void {
const step = stepOf(job, name);
step.detail = `${step.detail ? `${step.detail}\n` : ''}${detail}`.slice(-4000);
}
/** 校验目标库必须是空库(防误覆盖):有表即拒绝 */
async function assertTargetEmpty(target: TargetConnectionInput): Promise<string> {
let url = target.url.trim();
let sqliteAbsolute: string | null = null;
if (target.provider === 'sqlite') {
const rawPath = url.slice('file:'.length).replace(/\//g, path.sep);
const absolute = path.isAbsolute(rawPath) ? rawPath : path.resolve(env.serverRoot, rawPath);
sqliteAbsolute = absolute;
url = `file:${absolute.replace(/\\/g, '/')}`;
}
const client = await createPrismaClient(target.provider, url);
try {
const rows =
target.provider === 'sqlite'
? (await client.$queryRawUnsafe(
"SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
)) as { count?: bigint | number }[]
: (await client.$queryRawUnsafe(
'SELECT COUNT(*) AS count FROM information_schema.tables WHERE table_schema = DATABASE()',
)) as { count?: bigint | number }[];
const tableCount = Number(rows[0]?.count ?? 0);
if (tableCount > 0) {
throw ApiError.badRequest(
`目标数据库不是空库(已有 ${tableCount} 张表)。为防误覆盖,只允许迁移到空库;` +
'如需重用已有库,请先备份并清空它。',
);
}
return target.provider === 'sqlite' ? `目标文件:${sqliteAbsolute}` : '目标 MySQL 库为空';
} finally {
await client.$disconnect().catch(() => undefined);
}
}
const SCHEMA_FILE = path.join(env.serverRoot, 'prisma', 'schema.prisma');
function rewriteSchemaProvider(provider: DatabaseProvider): string {
const original = fs.readFileSync(SCHEMA_FILE, 'utf8');
const updated = original.replace(/provider\s*=\s*"(sqlite|mysql)"/, `provider = "${provider}"`);
if (updated === original) {
throw new Error('未能改写 schema.prisma 的 provider(未匹配到 provider 声明)');
}
fs.writeFileSync(SCHEMA_FILE, updated, 'utf8');
return original;
}
/** 子进程运行 Prisma CLI(generate / db push)。连接串通过环境变量显式传入,不依赖 .env。 */
function runPrismaCli(
job: SwitchJob,
stepName: string,
args: string[],
target: TargetConnectionInput,
): Promise<void> {
const candidates = [
path.join(env.serverRoot, 'node_modules', 'prisma', 'build', 'index.js'),
path.join(env.serverRoot, '..', 'node_modules', 'prisma', 'build', 'index.js'),
];
const cli = candidates.find((file) => fs.existsSync(file));
if (!cli) {
throw new Error('未找到 Prisma CLI(node_modules/prisma)。安装版请确认安装包完整。');
}
return runNodeTool(job, stepName, [cli, ...args], {
...process.env,
DATABASE_PROVIDER: target.provider,
DATABASE_URL: target.url.trim(),
});
}
/** 编译新生成的 Prisma 客户端(仅生产打包形态需要:dist 里跑的是编译产物) */
async function compileGeneratedClient(job: SwitchJob): Promise<void> {
// 开发模式(tsx 直接跑 src)不需要编译;打包形态(存在 dist/index.js)才需要
if (!fs.existsSync(path.join(env.serverRoot, 'dist', 'index.js'))) return;
const candidates = [
path.join(env.serverRoot, 'node_modules', 'typescript', 'lib', 'tsc.js'),
path.join(env.serverRoot, '..', 'node_modules', 'typescript', 'lib', 'tsc.js'),
];
const tsc = candidates.find((file) => fs.existsSync(file));
if (!tsc) {
throw new Error('未找到 TypeScript 编译器(node_modules/typescript)。安装包请确认完整。');
}
await runNodeTool(job, '编译新生成的 Prisma 客户端', [tsc, '-p', 'tsconfig.generate.json'], process.env);
}
/** 跨库迁移的数据写入走独立子进程:运行中的服务进程缓存的是旧方言的 Prisma Client */
function runApplySnapshotTool(job: SwitchJob, target: TargetConnectionInput, snapshotFile: string): Promise<Record<string, number>> {
const distTool = path.join(env.serverRoot, 'dist', 'tools', 'apply-snapshot.js');
const srcTool = path.join(env.serverRoot, 'src', 'tools', 'apply-snapshot.ts');
const tsxCli = path.join(env.serverRoot, 'node_modules', 'tsx', 'dist', 'cli.mjs');
let args: string[];
if (fs.existsSync(distTool)) {
args = [distTool];
} else if (fs.existsSync(tsxCli) && fs.existsSync(srcTool)) {
args = [tsxCli, srcTool];
} else {
throw new Error('未找到快照导入工具(dist/tools/apply-snapshot.js),请先构建服务端');
}
args.push('--provider', target.provider, '--url', target.url.trim(), '--file', snapshotFile);
const capture: { text: string } = { text: '' };
return runNodeTool(job, '迁移数据到目标库', args, process.env, capture).then(() => {
const lines = capture.text.trim().split(/\r?\n/).filter(Boolean);
const last = lines[lines.length - 1] ?? '';
try {
const parsed = JSON.parse(last) as { ok?: boolean; counts?: Record<string, number>; error?: string };
if (!parsed.ok) throw new Error(parsed.error ?? '快照导入工具返回失败');
return parsed.counts ?? {};
} catch (error) {
throw new Error(
`无法解析快照导入工具的输出:${error instanceof Error ? error.message : String(error)}(输出尾部:${last.slice(0, 400)})`,
);
}
});
}
function runNodeTool(
job: SwitchJob,
stepName: string,
args: string[],
nodeEnv: NodeJS.ProcessEnv,
capture?: { text: string },
timeoutMs = 10 * 60_000,
): Promise<void> {
return new Promise((resolve, reject) => {
const child = spawn(process.execPath, args, {
cwd: env.serverRoot,
env: nodeEnv,
windowsHide: true,
});
let output = '';
const collect = (chunk: Buffer | string): void => {
output += String(chunk);
if (output.length > 20_000) output = output.slice(-20_000);
if (capture) capture.text = output;
};
child.stdout?.on('data', collect);
child.stderr?.on('data', collect);
const timer = setTimeout(() => {
child.kill();
reject(new Error(`${stepName} 超时(${Math.round(timeoutMs / 60_000)} 分钟)`));
}, timeoutMs);
child.on('error', (error) => {
clearTimeout(timer);
reject(new Error(`${stepName} 启动失败:${error.message}`));
});
child.on('exit', (code) => {
clearTimeout(timer);
if (code === 0) {
appendDetail(job, stepName, output.trim().split(/\r?\n/).slice(-6).join('\n'));
resolve();
return;
}
const tail = output.trim().split(/\r?\n/).slice(-10).join('\n');
reject(new Error(`${stepName} 失败(exit ${code ?? 'signal'}):${tail || '无输出'}`));
});
});
}
/** 切换完成后改写 .env(DATABASE_PROVIDER / DATABASE_URL 两行) */
function updateEnvFiles(target: TargetConnectionInput): string {
const candidates = [path.join(env.serverRoot, '.env'), path.join(env.serverRoot, '..', '.env')];
const existing = candidates.filter((file) => fs.existsSync(file));
const files = existing.length > 0 ? existing : [candidates[0]];
const updated: string[] = [];
for (const file of files) {
let content = fs.readFileSync(file, 'utf8');
const providerLine = `DATABASE_PROVIDER=${target.provider}`;
const urlLine = `DATABASE_URL="${target.url.trim()}"`;
if (/^DATABASE_PROVIDER=.*$/m.test(content)) {
content = content.replace(/^DATABASE_PROVIDER=.*$/m, providerLine);
} else {
content = `${content.trimEnd()}\n${providerLine}\n`;
}
if (/^DATABASE_URL=.*$/m.test(content)) {
content = content.replace(/^DATABASE_URL=.*$/m, urlLine);
} else {
content = `${content.trimEnd()}\n${urlLine}\n`;
}
fs.writeFileSync(file, content, 'utf8');
updated.push(file);
}
return updated.join(' , ');
}
/**
* 发起一键切换(异步任务)。流程与失败语义见模块注释:
* 数据写入全部成功后才改写 .env;中途失败回滚 schema.prisma,现有库不受影响。
*/
export function startSwitch(target: TargetConnectionInput): SwitchJob {
if (isSwitchRunning()) {
throw ApiError.conflict('已有切换任务正在进行,请等待完成');
}
if (target.provider === env.databaseProvider && target.url.trim() === env.databaseUrl) {
throw ApiError.badRequest('目标数据库与当前使用的数据库相同');
}
const job: SwitchJob = {
id: newJobId(),
status: 'running',
target: { provider: target.provider, url: target.url.trim() },
steps: [],
restartRequired: false,
startedAt: new Date().toISOString(),
};
switchJobs.set(job.id, job);
// 不 await:HTTP 立即返回 jobId,前端轮询 GET /switch/jobs/:id
void runSwitch(job, target).catch(() => undefined);
return job;
}
async function runSwitch(job: SwitchJob, target: TargetConnectionInput): Promise<void> {
let schemaOriginal: string | null = null;
let snapshotFile: string | null = null;
try {
// 1. 先备份当前库(切换失败时的兜底)
stepOf(job, '备份当前数据库');
const backup = await createBackup('manual');
job.backupName = backup.name;
finishStep(job, '备份当前数据库', `备份文件 ${backup.name}`);
// 2. 导出快照到临时文件(供子进程写入目标库)
stepOf(job, '导出当前数据');
fs.mkdirSync(backupDir(), { recursive: true });
snapshotFile = path.join(backupDir(), `switch-${job.id}.json`);
fs.writeFileSync(snapshotFile, await exportSnapshotJson(), 'utf8');
finishStep(job, '导出当前数据');
// 3. 测试目标连接
stepOf(job, '测试目标库连接');
const test = await testConnection(target);
if (!test.ok) {
throw new Error(test.error ?? '目标库连接失败');
}
finishStep(job, '测试目标库连接', test.note ?? `连接正常(${test.latencyMs ?? '-'}ms,${test.version ?? '-'})`);
// 4. 目标库必须为空
stepOf(job, '检查目标库为空');
finishStep(job, '检查目标库为空', await assertTargetEmpty(target));
// 5. 改写 schema.prisma 的 provider(失败时回滚)
stepOf(job, '改写 schema.prisma');
schemaOriginal = rewriteSchemaProvider(target.provider);
finishStep(job, '改写 schema.prisma', `provider → ${target.provider}`);
// 6. 生成新方言的 Prisma 客户端
stepOf(job, '生成 Prisma 客户端');
await runPrismaCli(job, '生成 Prisma 客户端', ['generate'], target);
finishStep(job, '生成 Prisma 客户端');
// 7. 生产形态:把生成出来的 .ts 客户端编译进 dist(dev 由 tsx 直接跑 src,跳过)
await compileGeneratedClient(job);
// 8. 目标库建表(Prisma 7 的 db push 不再自动生成客户端,generate 是独立步骤 6)
stepOf(job, '在目标库建表');
await runPrismaCli(job, '在目标库建表', ['db', 'push', '--accept-data-loss'], target);
finishStep(job, '在目标库建表');
// 9. 迁移数据(独立子进程加载新方言客户端)
stepOf(job, '迁移数据到目标库');
const counts = await runApplySnapshotTool(job, target, snapshotFile);
job.counts = counts;
const total = Object.values(counts).reduce((sum, count) => sum + count, 0);
finishStep(job, '迁移数据到目标库', `共写入 ${total} 行`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
// 失败发生在哪一步,就把那一步标成 error(UI 上能直接看到卡在哪)
const running = [...job.steps].reverse().find((item) => item.status === 'running');
if (running) {
running.status = 'error';
running.detail = message.slice(0, 1000);
}
job.error = message;
job.status = 'error';
job.finishedAt = new Date().toISOString();
// 回滚 schema(.env 尚未改写,现有库不受影响)
if (schemaOriginal !== null) {
try {
fs.writeFileSync(SCHEMA_FILE, schemaOriginal, 'utf8');
} catch (rollbackError) {
logger.error('回滚 schema.prisma 失败,请手工核对 provider 设置', rollbackError);
}
}
logger.error(`数据库切换失败(job=${job.id}):${message}`);
return;
} finally {
if (snapshotFile) fs.rmSync(snapshotFile, { force: true });
}
// 10. 全部成功后才切换 .env(失败已在上面的 catch 里返回)
try {
stepOf(job, '写入 .env 配置');
const files = updateEnvFiles(target);
finishStep(job, '写入 .env 配置', files);
job.status = 'done';
job.restartRequired = true;
job.finishedAt = new Date().toISOString();
logger.info(`数据库切换任务完成(job=${job.id})→ ${target.provider},重启服务端后生效`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
job.error = `数据已迁移到目标库,但写入 .env 失败:${message}(请手工修改 .env 的 DATABASE_PROVIDER / DATABASE_URL 后重启)`;
job.status = 'error';
job.finishedAt = new Date().toISOString();
}
}
+4
View File
@@ -2,6 +2,7 @@ import { authModule } from './auth/auth.module.js';
import { callsModule } from './calls/calls.module.js';
import { classesModule } from './classes/classes.module.js';
import { coursesModule } from './courses/courses.module.js';
import { databaseModule } from './database/database.module.js';
import { dashboardModule } from './dashboard/dashboard.module.js';
import { gradesModule } from './grades/grades.module.js';
import { homeworksModule } from './homeworks/homeworks.module.js';
@@ -11,6 +12,7 @@ import { notificationsModule } from './notifications/notifications.module.js';
import { schedulesModule } from './schedules/schedules.module.js';
import { studentsModule } from './students/students.module.js';
import { teachersModule } from './teachers/teachers.module.js';
import { updateModule } from './update/update.module.js';
import type { ApiModule } from './module.types.js';
/**
@@ -34,4 +36,6 @@ export const apiModules: ApiModule[] = [
studentsModule,
teachersModule,
dashboardModule,
databaseModule,
updateModule,
].filter((module) => module.enabled !== false);
@@ -0,0 +1,32 @@
import { Router } from 'express';
import { sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser } from '../../middleware/auth.js';
import { defineModule } from '../module.types.js';
import * as updateService from './update.service.js';
const router = Router();
router.use(authenticate());
/**
* GET /api/update/check?force=1 - 检查 GitHub 上有没有比本机新的版本
*
* 为什么 Web 管理端不直连 GitHub:浏览器的请求受服务端下发的 CSP 约束,
* `connect-src` 只放行 `'self'` 与 WebSocket(见 `middleware/security.ts`),
* 直连 `api.github.com` 会被浏览器拦掉。走服务端转发还顺带让所有管理员共享同一份缓存。
*
* 桌面客户端不受这条约束(它由主进程直连),但两边拿到的 `UpdateInfo` 结构是一致的。
*
* 权限:任何已登录用户都能查(信息本身来自公开仓库)。`force=1` 绕过服务端缓存,
* 但**只有管理员生效** —— 否则任意登录用户都能靠刷这个参数把匿名限流打满。
*/
router.get('/check', async (req, res) => {
const user = getAuthUser(req);
const force = req.query.force === '1' && user.role === 'ADMIN';
sendOk(res, await updateService.checkForUpdates({ force }), '更新检查完成');
});
export const updateModule = defineModule({
name: 'update',
basePath: '/update',
router,
});
@@ -0,0 +1,152 @@
import {
GITHUB_RELEASES_LATEST_API,
GITHUB_RELEASES_PAGE,
UPDATE_CHECK_CACHE_TTL_MS,
UPDATE_CHECK_TIMEOUT_MS,
UPDATE_CHECK_USER_AGENT,
isNewerVersion,
normalizeVersion,
type UpdateAssetDto,
type UpdateInfo,
} from '@classhelper/shared';
import { logger } from '../../lib/logger.js';
import { APP_VERSION } from '../../lib/version.js';
/**
* 更新检查:问 GitHub 上最新的 Release 是不是比本机新。
*
* 三个设计约束:
* 1. **失败是常态,不是异常**:学校的服务器常常在只有内网的机房网络里,
* 因此离线 / 限流 / 超时一律收敛成 `ok:false` + 一句人话,绝不抛给路由层(否则管理员看到的会是 500)。
* 2. **必须带 User-Agent**:GitHub API 对没有 UA 的请求直接 403。
* 3. **必须有缓存**:匿名调用限流是 60 次/小时/IP,而 Web 管理端每次打开弹窗都会问一次。
*
* 这里用 Node 内置的 `fetch`(Node 20+ / Electron 同源),不引入任何 HTTP 依赖 ——
* 这也是本服务端**唯一**的出网请求。
*/
/** GitHub Releases API 里我们真正用到的字段(其余忽略,避免把整个响应建模一遍) */
interface GithubReleasePayload {
tag_name?: unknown;
name?: unknown;
published_at?: unknown;
body?: unknown;
html_url?: unknown;
assets?: unknown;
}
let cache: { at: number; info: UpdateInfo } | null = null;
/** 失败的统一出口:结构完整,界面不必做 null 判断 */
function failure(error: string): UpdateInfo {
return {
ok: false,
error,
currentVersion: APP_VERSION,
latestVersion: null,
hasUpdate: false,
releaseUrl: GITHUB_RELEASES_PAGE,
releaseName: null,
publishedAt: null,
notes: null,
assets: [],
checkedAt: new Date().toISOString(),
};
}
function toAssets(raw: unknown): UpdateAssetDto[] {
if (!Array.isArray(raw)) return [];
return raw
.map((item) => item as { name?: unknown; browser_download_url?: unknown; size?: unknown })
.filter(
(item): item is { name: string; browser_download_url: string; size?: unknown } =>
typeof item.name === 'string' && typeof item.browser_download_url === 'string',
)
.map((item) => ({
name: item.name,
downloadUrl: item.browser_download_url,
size: typeof item.size === 'number' && Number.isFinite(item.size) ? item.size : 0,
}));
}
function toInfo(payload: GithubReleasePayload): UpdateInfo {
const latestVersion = normalizeVersion(typeof payload.tag_name === 'string' ? payload.tag_name : '');
// 拿不到版本号就当"查不到",不要伪造一个 latestVersion 出来
if (!latestVersion) {
logger.warn('更新检查:GitHub 返回的 Release 里没有可识别的 tag_name');
return failure('GitHub 返回的版本号无法识别');
}
return {
ok: true,
error: null,
currentVersion: APP_VERSION,
latestVersion,
hasUpdate: isNewerVersion(APP_VERSION, latestVersion),
// 优先用 Release 自己的 html_url,拿不到才回落到列表页
releaseUrl:
typeof payload.html_url === 'string' && payload.html_url ? payload.html_url : GITHUB_RELEASES_PAGE,
releaseName: typeof payload.name === 'string' ? payload.name : null,
publishedAt: typeof payload.published_at === 'string' ? payload.published_at : null,
notes: typeof payload.body === 'string' ? payload.body : null,
assets: toAssets(payload.assets),
checkedAt: new Date().toISOString(),
};
}
/** 把各种失败翻译成一句管理员能看懂的话 */
function describeFailure(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
if (error instanceof Error && error.name === 'TimeoutError') {
return `连接 GitHub 超时(${UPDATE_CHECK_TIMEOUT_MS / 1000} 秒),本机可能没有外网`;
}
if (/fetch failed|ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|certificate/i.test(message)) {
return '无法连接 GitHub,本机可能没有外网';
}
return `检查更新失败:${message}`;
}
/**
* 检查更新(结果带缓存)。
*
* @param options.force 绕过缓存(由路由层限定为管理员才能生效)
*/
export async function checkForUpdates(options: { force?: boolean } = {}): Promise<UpdateInfo> {
const now = Date.now();
if (!options.force && cache && now - cache.at < UPDATE_CHECK_CACHE_TTL_MS) {
return cache.info;
}
let info: UpdateInfo;
try {
const response = await fetch(GITHUB_RELEASES_LATEST_API, {
headers: {
'User-Agent': UPDATE_CHECK_USER_AGENT,
Accept: 'application/vnd.github+json',
},
signal: AbortSignal.timeout(UPDATE_CHECK_TIMEOUT_MS),
});
if (!response.ok) {
// 403 / 429 基本都是匿名限流(60 次/小时/IP)—— 给出可操作的提示而不是原始状态码
info =
response.status === 403 || response.status === 429
? failure('GitHub 暂时拒绝了请求(匿名查询次数过多),请过一会儿再试')
: failure(`GitHub 返回 HTTP ${response.status}`);
logger.warn(`更新检查失败:HTTP ${response.status}(本机版本 ${APP_VERSION})`);
} else {
info = toInfo((await response.json()) as GithubReleasePayload);
logger.info(
`更新检查:本机 ${APP_VERSION} / 最新 ${info.latestVersion ?? '-'}` +
`${info.hasUpdate ? '(有新版本)' : '(已是最新)'}`,
);
}
} catch (error) {
info = failure(describeFailure(error));
logger.warn(`更新检查失败:${info.error}(本机版本 ${APP_VERSION})`);
}
// 失败结果**也缓存**:否则机房断网时,管理员每点一次按钮都要白等一个超时。
cache = { at: now, info };
return info;
}
@@ -0,0 +1,59 @@
import fs from 'node:fs';
import process from 'node:process';
import { createPrismaClient, type DatabaseProvider } from '../lib/db.js';
import { assertValidSnapshot, restoreSnapshot, type DatabaseSnapshot } from '../lib/snapshot.js';
/**
* 快照导入工具(供跨库迁移的**子进程**调用,也可手工使用)。
*
* 为什么是子进程:切换数据库时 `prisma generate` 会重新生成 Prisma Client(SQL 方言变了),
* 而正在运行的服务进程里缓存的是**旧方言**的客户端模块 —— 只有新进程才能加载重新生成的客户端,
* 把快照写进新库。用法:
*
* node dist/tools/apply-snapshot.js --provider mysql --url "mysql://..." --file snapshot.json
*
* stdout 最后一行输出 JSON:{ ok, counts },父进程据此回填任务进度;失败时 exit 1 并输出原因。
* 注意 --file 是**未压缩**的 JSON(父进程负责解压到临时文件)。
*/
interface ToolArgs {
provider: DatabaseProvider;
url: string;
file: string;
}
function parseArgs(argv: readonly string[]): ToolArgs {
const read = (name: string): string => {
const index = argv.indexOf(`--${name}`);
const value = index >= 0 ? argv[index + 1] : undefined;
if (!value) throw new Error(`缺少参数 --${name}`);
return value;
};
const provider = read('provider');
if (provider !== 'sqlite' && provider !== 'mysql') {
throw new Error(`不支持的 provider:${provider}(只支持 sqlite / mysql)`);
}
return { provider, url: read('url'), file: read('file') };
}
async function main(): Promise<void> {
const args = parseArgs(process.argv.slice(2));
const raw = fs.readFileSync(args.file, 'utf8');
const snapshot = JSON.parse(raw) as DatabaseSnapshot;
assertValidSnapshot(snapshot);
const client = await createPrismaClient(args.provider, args.url);
try {
const { counts } = await restoreSnapshot(client, args.provider, snapshot);
process.stdout.write(`${JSON.stringify({ ok: true, counts })}\n`);
} finally {
await client.$disconnect().catch(() => undefined);
}
}
main().catch((error: unknown) => {
process.stdout.write(
`${JSON.stringify({ ok: false, error: error instanceof Error ? error.message : String(error) })}\n`,
);
process.exit(1);
});
+5 -1
View File
@@ -3,7 +3,11 @@
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"noEmit": false
"noEmit": false,
"removeComments": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/**/*.ts", "src/**/*.d.ts"]
}
+26
View File
@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2023",
"lib": ["ES2023"],
"module": "NodeNext",
"moduleResolution": "NodeNext",
"types": ["node"],
"strict": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"allowImportingTsExtensions": true,
"rewriteRelativeImportExtensions": true,
"noEmit": false,
"outDir": "dist",
"rootDir": "src",
"removeComments": true,
"declaration": false,
"declarationMap": false,
"sourceMap": false
},
"include": ["src/generated/**/*.ts"]
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/shared",
"version": "0.1.0",
"version": "1.1.2",
"private": true,
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
"type": "module",
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more