Files
ClassHelper/AGENTS.md
T
LoveChengke 227117ccb3 fix(integrations,grades,clients,web,client,plugin): 课表同步改为按需(修「课表被反复删改」)+ 客户端页合并 + 成绩排名(1.3.1)
线上问题的根因
- ClassIsland 插件默认每 15 秒上报一次,每次都带全量课表,上报完还顺手把课表镜像回教室
  (本机 `UploadSchedule` / `MirrorSchedule` 两个开关默认都是开)。于是教室的课表每 15 秒被重写、
  服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。
- 现在两个方向都只由 Web 端的一次性待办触发,插件侧那两个开关整体删除;
  定时上报只报**运行状态**(当前上什么课),不带课表。

课表同步(#3)
- 新增 `IntegrationDevice.requestMirrorSchedule` + `POST /integrations/devices/:id/mirror-schedule`
  (「下发课表」),与既有的 `request-schedule`(「获取课表」)对称;拉取成功即清标记
- 插件 `ClassPlanWriter.Apply(..., force: true)`:停用同一天同一单双周相位下冲突的旧课表
  —— **只停用不删除**,内容原样留在档案里,老师随时能重新启用
- 插件设置页删掉「自动上报课表」「镜像回 ClassIsland」两个开关,改为一句说明

「客户端」页(#4)
- 把「ClassIsland 联动」与「客户端设备」合成一页 `ClientHubView.vue`:一个班一张卡片,
  两条链路状态放一起,勾选可批量下发课表 / 更新,点卡片进详情做逐台操作
- 「下发提醒」也搬了过来;入口权限用 `classisland:notify`(默认 ADMIN + TEACHER),
  页内按钮再各自按 device:manage / remote:control / client:update-push 显示或禁用
- 两个旧页面 `meta.hidden: true` **保留路由**(安装包缓存管理、联动步骤说明仍在),
  从详情抽屉里给链接 —— 不删功能

成绩(#5/#6/#7)
- 新增 `Exam`(班级 + 考试名 + `countInTotal`)与迁移;`GET/PUT /api/grades/exams`、
  `GET /api/grades/ranking`(两条都**不挂权限键**,班级端的有效权限是空集,边界在 service 里)
- 排名口径写在服务端:**同一学生同一科目只取最高分**(补录 / 重考会多行,不去重等于"总分多一科")、
  并列同名次(1、2、2、4)
- 「成绩录入」页加「考试口径」开关(按班级 + 考试名记住);客户端「成绩」页新增总分榜 + 单科榜
- `updateStudentGradeQuery` 改成只有教师 / 管理员能改 —— 原先班级端自己也能改
- 客户端的成绩查询开关改为只读(只显示服务端下发的状态)

其他(#2/#5/#8/#9)
- 全校学期开始日期改成 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改、立刻重算周次
  (原先这一级只能来自 `.env`)
- 大模型助手发紧急通知补上「上课时段 3 秒全屏警告」:判据抽成 `composables/useClassInSession.ts`,
  与手动发布页共用
- 客户端去掉「关怀模式」「自动更新」两个开关(教室机器是统一投放的终端)
- 修掉「选中设置二级菜单后收起分组、高亮块仍留在原地」:底片只在路由 / 滚动 / 缩放时重测,
   而收起分组动不了 `.aside` 的盒子 —— 补上"子项被 overflow:hidden 裁掉"的判据 + 观察分组 `<ul>` 高度
- `src/types/express.d.ts` 里 `req.device` 改成引用 `DeviceContext`:原先手抄了一份结构,
  往接口加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;` 上,看不出真正原因

验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿;
后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、
Web 真实点击 **34/34**(含新增的「客户端」页用例)、客户端冒烟 **90/90**。
2026-10-11 16:21:38 +08:00

1764 lines
160 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# AGENTS.md — ClassHelper(Class Helper)
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
文中「本机环境」「已知坑」两节按**实测**写成;若与本机现状不符,以本机实测为准并顺手改掉这里。
---
## 1. 这是什么
班级信息管理系统,**pnpm monorepo**,一份代码产出多个交付物:
| 交付物 | 位置 | 形态 |
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------ |
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,`/api` 前缀 |
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) |
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(装在教室的 ClassIsland 上:上报课表与上课状态、弹出老师发的提醒) |
核心链路:
```
教师在 Web 端发布内容
│ REST API(JWT 鉴权)
▼
后端服务(Express + Prisma)
│ 写库 + 按班级房间广播
▼
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
ClassIsland(教室机器)──► ClassHelper 联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
▲ │
└──── 老师下发的提醒(上报返回值里顺带带回)◄┘
```
- 远端:`LoveChengke/ClassHelper`(仓库名大小写不敏感,本地 remote 存的是小写 `classhelper.git`)
- 默认分支:`master`
- 语言:**文档、注释、提交信息、界面文案一律中文**
---
## 2. 目录速查
```
package.json 根脚本(dev / build / db:* / verify:* / dist:* / icons)
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/classhelper.{png,ico} 图标源(**设计导出、手工放入**;`pnpm icons` 从它派生其余全部图标,见 §5 第 62 条)
scripts/
use-database.mjs SQLite ⇄ MySQL 切换(**改写 schema.prisma 里的 provider**,没有第二份 schema)
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 条)
version.mjs 版本号单一来源的读写与一致性校验(= `pnpm version:*`,见 §4.4)
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 / 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/ **文档站**(docfx 项目;不进 pnpm workspace)
⚠ 与官网(website/)共用**同一个** GitHub Pages 站点:官网在根、
文档站在 /docs/ 下。一个仓库在 Pages 上只有一个站点,
拼装由 scripts/build-pages.mjs 完成,见 §4.4「官网与文档站怎么发布」。
docfx.json / toc.yml(顶栏)+ 各章节自己的 toc.yml(侧栏)
get-started/ app/ management/ dev/(四章内容)
management/{production,linux-deploy,mysql}.md(部署与换库)
dev/winisland-design-tokens.md
images/(**展示图**:文档站 / README / 官网共用的同一份,1× 逻辑像素)
screenshots/(回归留档的截图:island / client / web-mobile / classisland,
页面不展示;两者分工见 docs/dev/testing.md「截图留档」)
templates/classhelper/(模板覆盖:_master.tmpl + public/main.css + 图标)
build.mjs(构建,注入版本号与 404 的 base)/ serve.mjs(本地预览 :5181)
_site/(构建产物,gitignored)
⚠ 站点的顶栏版本徽标取自根 package.json(build.mjs 用 --metadata 注入),
不要在文档里再写一份版本号。
⚠ 页面里的照片一律指向 images/ —— 它就是 README 与官网吃的那一份。
别再往 screenshots/ 引图:那是冒烟脚本滚动的留档,随时被下一次验证覆盖。
⚠ 模板目录里的 logo.png / favicon.png 由 `pnpm icons` 派生(见 §5 第 62 条),
别手工替换;改图标只改 build/classhelper.png 再跑 pnpm icons。
⚠ 文档站只发布 docs/ 这一棵树,页面里**不要**写指向 docs/ 之外的相对链接
(如 ../AGENTS.md)—— 发布出去全是 404,要用 GitHub 的绝对 URL。
website/ 产品官网(**纯静态、无构建步骤**,不进 pnpm workspace)
index.html(门面)/ download.html(下载页,首页的「立即下载」跳到它)
assets/{styles.css,motion.js,main.js,download.js,icon.png,shots/}
serve.mjs(本地预览)/ tools/capture-shots.mjs(实机图采集)
404.html(**站点根** 404,含 {{BASE}} 占位符,由 build-pages.mjs 注入)
⚠ serve.mjs 把 ../docs/_site 挂在 /docs/ 下,为的是让本地与线上同形 ——
否则页脚那几个「文档站」链接在本地全是 404。文档站得先 pnpm docs:build。
⚠ 首屏标题第二行是 .reveal-grad:**它不参与逐字揭示**
(main.js 会跳过它)。拆成 per-char 的 inline-block 后每个字各自建一层合成,
祖先的 background-clip: text 就穿不过去,渐变会整行失效。
⚠ assets/motion.js 是动效层:曲线与弹簧 token 逐条取自 beUI
(beui.dev/components/motion,见 docs/reference.md 的「官网」一节)。
改官网动效请改 token 表,不要在组件里另写 duration / cubic-bezier。
⚠ assets/shots/ 里全是**跑起来截的**实机图,重采集步骤见
docs/dev/testing.md 的「截图留档」;客户端那两张要配合冒烟的
ELECTRON_CLIENT_SHOTS_DIR / ELECTRON_SMOKE_ISLAND_* 开关。
⚠ download.html 的版本清单是**当场**读 GitHub Releases 的
(assets/download.js):版本分段控件 → 一行状态 → 「装什么」分段控件
(客户端 / 服务端 / 插件,**默认停在客户端**)→ 卡片。卡片是拉到数据之后
才建的,建完会派发一次 ch:content,由 main.js 补挂按压反馈
(新增动态内容照这个来)。两个分段控件共用同一套指示器实现。
卡片排版照 ClassIsland 官网那页:图标 + 名称 + 系统要求 + 主按钮 + 变体下拉
(<details>,点别处或 Esc 收起);插件那张只有一个文件,不摆点不开的箭头。
读不到时退回 <meta name="ch-version"> 并按发布命名约定拼直链 ——
那个 meta 是 version.mjs 的落点之一,发版必须一起改。
命名约定与 README「开始使用」表里那三个名字要对齐
(ClassHelper-<版本>-x64-client-setup.exe / -client-portable.exe /
-x64-server-setup.exe),它们是离线兜底链接的唯一依据。
packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts
packages/server/
prisma/schema.prisma 数据模型(24 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
prisma/migrations/ 17 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是
2026-10-06 的角色与权限重构:add_student_table / add_student_grade_query /
add_grade_level / add_classhelper_heartbeat / add_archives / add_term_weeks;
最后 1 个是 2026-10-10 的 add_permission_groups_and_clients:
权限组 3 表 + AppSetting + 客户端设备/命令/安装包 + AiAudit +
Notification.assignDate + User.careMode)
prisma/seed.ts 种子数据(会清空业务表后重建演示数据)
prisma.config.ts Prisma 7 配置:schema / migrations / seed / 连接串
scripts/verify-e2e.mjs 后端端到端验收(条数由脚本统计,2026-10-06 实测 205 项)
scripts/verify-classisland.mjs 联动链路验收(实测 44 项)
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 version(读 package.json) web-static
src/middleware/ auth validate error security
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
src/modules/ 21 个功能模块 + registry.ts + module.types.ts
(含 archives 毕业归档、term 学期周次、permissions 权限组、
clients 客户端心跳与远控、system 开发者模式/开机自启、ai 大模型助手)
packages/web-admin/src/ api stores router layouts views(18) components(5) composables styles config.ts
(含 ArchivesView 毕业归档、TermWeeksView 学期周次、PermissionsView 权限组、
ClientsView 客户端设备、AssistantView/aiSettingsView 大模型助手)
packages/desktop-client/
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/ ClassHelper 班级端渲染进程: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=1.2.0.0
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
src/Models/PluginSettings.cs
src/Services/ BridgeService(上报·接收·镜像)/ ScheduleMapper / ClassPlanWriter
ClassHelperNotificationProvider(把提醒显示到 ClassIsland 上)
src/Views/BridgeSettingsPage.axaml(.cs):Avalonia 设置页
src/Interop/ClassHelperClient.cs:HTTP 客户端 + 与服务端逐个字段对齐的 DTO
scripts/ build.mjs(编译 / 打包 .cipx)/ verify.mjs(静态契约校验,实测 74 项)
```
---
## 3. 本机环境(2026-09-30 实测)
| 项 | 状态 |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node | **v22.15.0**(`D:\Node.js\node.exe`;根 `engines` 要求 ≥ 20.19,Prisma 7 的 engine 约束是 `^20.19 \|\| ^22.12 \|\| >=24.0`,v22.15 都满足) |
| pnpm | 11.8.0(`packageManager` 锁定 11.8.0)。本机可用的本体在 `C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs`;PATH 里那个 `pnpm.ps1` 是 DSH 桌面端的转发壳,**别绕它**(见下) |
| npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) |
| 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) |
| 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` |
| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
| 数据库 | SQLite `packages/server/prisma/dev.db`:10 个迁移已应用、种子已写入(用户 18 / 班级 3 / 课程 54 / 课表 93 / 作业 15 / 通知 12 / 成绩 90) |
| `shared/dist` | 已构建(`pnpm build:shared`)—— **它是三端与 seed 的前置产物,缺了会 `ERR_MODULE_NOT_FOUND`** |
| Electron 二进制 | 已手动装好(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`,246 MB) |
| .NET SDK | ✅ **8.0.425**(`C:\Program Files\dotnet\sdk`,`dotnet --list-sdks` 有输出)⇒ 插件可编译/打包;**别再照旧文档说"没装"**(`dotnet` 在 PATH 里,`--list-sdks` 才是权威判据) |
| ClassIsland | **2.1.0.1,装在 `D:\Classisland`**(启动器 `D:\Classisland\ClassIsland.exe`,实际进程 `D:\Classisland\app-2.1.0.1-0\ClassIsland.Desktop.exe`) |
| ClassIsland 数据 | 数据根 **`D:\Classisland\data`**:`Config/`、`Profiles/`、`Plugins/`(当前为空,尚未装本插件)、`Logs/`、`Cache/`、`Settings.json` |
| 桌面会话 | 有(`ClassIsland.Desktop.exe` 跑在 session 1);**Electron GUI 在 AI 会话里也能跑**(需先修完整性标签,见 §7 第 7 条) |
> **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。
> 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。
#### AI 会话里跑 pnpm:别撞上 DSH 的转发壳(2026-10-11 实测)
在 DSH 的 shell 工具里直接写 `pnpm xxx` 会拿到
`pnpm.ps1 : Node.js runtime not found. Please run DeepSeek Harness Desktop to install it first.`,
而同一台机器上人在普通终端里跑得好好的。原因与绕过办法:
- PATH 里排在前面的是 **DSH 桌面端的转发壳** `C:\Users\hck16\AppData\Local\deepseek-harness\bin\pnpm.ps1`
(`.cmd` 同理)。它有两道会挡住的判据:① 认 `$env:DSH_NODE` / PATH 上的 `node`,
**但要求 `major ≥ 24` 或 `22.19+`**,本机的 `v22.15.0` 卡在门外;
② 它自己的兜底 `%APPDATA%\dsh-tauri\runtime\node.exe` 与 bundled pnpm
`%APPDATA%\dsh-tauri\dependencies\pnpm\bin\pnpm.cjs` **本机都不存在**。
- **正确解法是喂给这个壳两个环境变量**(它自己就认这两个覆盖开关,见 shim 第 10、52 行),
把 `node` 与 `pnpm` 都指到本机真实的那份,然后就**照常写 `pnpm xxx`**:
```powershell
$env:DSH_NODE = 'D:\Node.js\node.exe'
$env:DSH_PNPM_BIN = 'C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
pnpm typecheck # 与人在终端里跑完全等价
pnpm build:shared
```
- `DSH_NODE` 必须是**绝对路径**:壳拿到它就不再走"发现 PATH 上的 node 再卡版本"那段。
- `DSH_PNPM_BIN` 是**本体 .cjs 的路径**(不是目录,也不是 `pnpm.ps1`);壳会 `& $node $pnpmBin @args`
直接执行它。它同时替掉那条会失败的 `pnpmBin` 存在性检查。
- **`pnpm` 的嵌套调用**(`typecheck` = `build:shared && pnpm -r --no-bail typecheck`)靠这两个变量就够了:
子进程继承同一份环境,于是又落进同一个壳、又被同一对覆盖接住。
- **这三条都是踩过的坑**(同一个会话里依次试错,前两条各失败一次):
- 不要用 `DSH_PNPM`,那是"用户 pnpm 的可执行路径":壳转发给它时**仍然先过 ② 的版本门槛**,
而且**残留的 `DSH_PNPM` 会污染后面所有调用**——它与 `DSH_PNPM_BIN` 同时存在时壳会转发进
corepack 的 `pnpm.ps1`,那个壳又把它重置回不存在的 bundled 路径,于是原本能跑的
`typecheck` 报出 `[pnpm] pnpm not found`。要么别设,要么先 `Remove-Item Env:DSH_PNPM`。
- 也不要用 `node <pnpm.cjs> <script>` 绕开壳:只在**不带嵌套调用**的脚本上凑效
(`check:icons` / `lint` 能用),`typecheck` 这类内部还要再 `pnpm -r` 的会二次翻车。
- 反过来说:本机 **Node v22.15.0 够用**(根 `engines` ≥ 20.19,Prisma 7 允许 `^22.12`),
不需要为了跑命令去装 v24;上面那条 `runtime not found` 纯粹是壳的版本门槛,不是环境不满足要求。
### 从零重建环境(本机实测可行的顺序)
```bash
pnpm install # 需要下载 Electron 时加 NODE_OPTIONS=--use-system-ca
# 后端环境变量(.env 不入库,必须自己生成)
cp packages/server/.env.example packages/server/.env # 至少改 JWT_SECRET;TERM_START_DATE 建议 2026-09-07
pnpm db:generate # 生成 Prisma Client
pnpm --filter @classhelper/server db:deploy # 应用迁移(根 scripts 里没有 db:deploy,见 §7)
pnpm build:shared # 必须先构建 shared,否则 seed / 服务端启动会失败
pnpm db:seed # 写演示数据
# Electron 二进制不会随 pnpm install 下载,需要手动补:
# 工作目录 node_modules/.pnpm/electron@44.3.0/node_modules/electron
ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ node install.js
# ClassIsland 插件(.NET 8 SDK 已装:8.0.425)
pnpm build:classisland-plugin
```
### 演示账号(`pnpm db:seed` 产出)
| 角色 | 用户名 | 密码 |
| -------------- | ------------------------------- | ------------ |
| 管理员 | `admin` | `admin123` |
| 教师 | `teacher1` / `teacher2` | `teacher123` |
| ClassHelper 班级端班级账号 | 班级码 `G101` / `G102` / `G203` | `123456` |
> 密码是**种子脚本里硬编码**的(`admin123` / `teacher123` / 班级 `123456`);
> `.env` 的 `DEFAULT_CLASS_PASSWORD` 只影响**新建**班级时的初始班级密码。
>
> **学生不是账号**(2026-10-06 起由表结构保证):`Student` 表里根本没有 `passwordHash` / `role`,
> 学号(`202601`…`202615`)只是名单记录与查询键。拿学号去 `/auth/login` 只会得到 401。
> 教室机器统一用**班级码 + 班级密码**登录(会话主体是班级,角色 `CLASS_DEVICE`)。
>
> 演示数据布局:在读班级 `2026级1班`(码 `G101`)/ `2026级2班`(码 `G102`)/ `2025级3班`(码 `G203`),
> 外加一个**已归档**的 `2023级1班`(3 名毕业生,用于验证「毕业归档」页)。
> 任课关系刻意让**语文**由另一位老师任教,这样班主任在语文上没有编辑权 ——
> 正好覆盖「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
---
## 4. 常用命令
| 命令 | 说明 |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) |
| `pnpm build` | 构建 shared + 后端 + Web 端 + 客户端 |
| `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 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) |
| `pnpm --filter @classhelper/server db:deploy` | 应用已有迁移(**本机推荐**;根 scripts 里没有 `db:deploy`) |
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
| `pnpm db:studio` | Prisma Studio |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/management/mysql.md`) |
| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-10-06 实测 205 项全过) |
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) |
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) |
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 44 项) |
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 74 项) |
| `pnpm dist:server` / `dist:win` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 全部 |
| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 文档站 / 界面内品牌标) |
| `pnpm version:check` | 逐处列出并比对 14 个落点的版本号,不一致即 exit 1(见 §4.4「版本迭代」) |
| `pnpm version:bump patch\|minor\|major` | **改版本号的唯一入口**:一次改写全部 14 处(含插件清单、C# 里的常量、官网上手写的三处,以及下载页的兜底值) |
| `pnpm docs:build` | 构建文档站到 `docs/_site`(需要 .NET SDK 8 + `dotnet tool install -g docfx`) |
| `pnpm docs:serve` | 构建文档站并起本地预览 → http://127.0.0.1:5181 |
| `pnpm docs:check` | 构建文档站并把 docfx 的警告当错误(发布前用;最常见的警告是"某个 .md 没被任何 toc.yml 引用") |
| `pnpm pages:build` | 把官网(根)与文档站(/docs/)拼成一份 GitHub Pages 产物到 `.cache/pages`(CI 用;见 §4.4) |
| `pnpm dev:site` | 构建文档站后起官网预览 → http://127.0.0.1:5180(文档站挂在同端口的 /docs/ 下,与线上同形) |
### 端口
| 端口 | 用途 |
| ---- | --------------------------------------------------------- |
| 4000 | 后端 REST + Socket.IO + (生产/构建后)Web 管理端静态托管 |
| 5173 | Web 管理端开发服务器(`/api`、`/socket.io` 代理到 4000) |
| 5174 | 桌面客户端渲染进程开发服务器 |
| 5180 | 官网本地预览(`node website/serve.mjs`) |
| 5181 | 文档站本地预览(`pnpm docs:serve`) |
### 交付产物归集
**三个打包脚本都直接输出到仓库根 `releases/<组件>/<版本>/`,没有中间输出目录**
(该目录 gitignore + eslint ignore),人工归集这一步已经取消:
```
releases/
client/<版本>/
安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
构建中间/ builder-*.yml、*.nsis.7z
server/<版本>/
安装包/ ClassHelper 服务端-<版本>-x64-setup.exe
linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256
构建中间/ server-installer.generated.nsi
classisland-plugin/<版本>/
安装包/ ClassHelper.ClassIslandPlugin.cipx
checksums.md
SHA256SUMS-<版本>.txt 跨组件哈希清单(人读,由发布流程维护)
```
**`releases/` 里只放交付物,不放"解包出来的那一份"。** 这条是硬约定,2026-10-11 统一过一遍:
| 曾经有的目录 | 它是什么 | 现在 |
| --- | --- | --- |
| `client/<版本>/免安装/` | electron-builder 的 `win-unpacked`(约 323MB) | 只在打包过程中存在,生成两个 exe 后由 `tidyArtifacts()` 删除 |
| `server/<版本>/免安装/` | `pnpm dist:server` 组装的运行时(约 335MB) | 组装到 `.cache/server-staging/<版本>/win/`,打完 NSIS 即删 |
| `server/<版本>/linux-x64/classhelper-server-linux-x64/` | tar.gz 的解包留档(约 265MB) | 打完 tar.gz 即删(内容与 tar 完全一样) |
| `classisland-plugin/<版本>/免安装/` | `.cipx` 的解压副本 | 不再产出(`.cipx` 本身就是 zip,解压即用) |
理由:这些都是 300MB 量级的**中间物**,而且长得跟交付物一样像"该拷走的东西" ——
五个客户端版本 + 七个服务端版本堆下来就是 4.8GB,其中 3.4GB 是重复的中间物。
`-portable.exe` 是自解压单文件,服务端安装程序内置 Node,**用户侧一个解包目录都不需要**。
需要临时看一眼解包内容时,直接解压 portable,或按下面那张表的路径去 `.cache/` 找。
(三个脚本仍会把 `构建中间/` 留在版本目录里 —— `builder-*.yml`、`*.nsis.7z`、
`server-installer.generated.nsi`,那是每个版本几 KB 的打包脚本与元数据,**保留**。
实测 8 个版本目录合计约 50 KB。)
**Linux 包同理**:`.tar.gz` 旁边的同名解包目录(约 265MB)已不再保留,需要看内容就
`tar -xzf` 一份到 `.cache/` 里(见 §5 第 59 条 ④ 的命名约定)。
版本号取自根 `package.json`(插件用 `manifest.yml` 的前三段,与产品版本对齐);
重新打包只清**当前版本目录**里的产物,历史版本原样保留 —— 所以同版本多次构建留下的
不同产物要自己改名归档(例如 `1.0.0-重构建20261005/`),别把上一次的覆盖掉。
```bash
pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过)
pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④)
pnpm dist:win # 客户端:安装包 + 便携版
pnpm dist:classisland-plugin
```
`dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后
再把原始布局整理成上面的分层(`*.exe` → `安装包/`、`*.yml` → `构建中间/`),**最后删掉 `win-unpacked/`**;
显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。
两条别动的顺序约束:
- **删 `win-unpacked` 必须排在整理之后**:portable 目标就是拿这份解包结果做自解压包,
提前删会让 `-portable.exe` 直接失败。
- **输出目录的完整性标签要在打包前压回 Low**:上一轮收尾把它标成 Medium 之后,7za(自身带 Low)
读不动那些目录,pack 时报 `cannot open file ... Access is denied`(实测踩过)。
**交付物的完整性标签修正(§7 第 7 条)挂在收尾链路上,别把 `tidyArtifacts` 之后的
`normalizeIntegrityLabel(destDir)` 拆掉。**
### 版本迭代(每一次 releases,不是每一次提交)
> **版本号按「发布」迭代,不按「提交」迭代。** 日常的 `feat` / `fix` 提交不动版本号;
> 只有要产出一批安装包、在 GitHub 上开一个 Release 时才走一次。
> 理由很实际:安装包名、`.cipx`、Docker 镜像 tag、文档站顶栏的版本徽标、更新检查接口
> 全都按版本号对齐 —— 每次提交都动它,等于每天都在发版,用户那边的「检查更新」就废了。
>
> 算一次发布:攒够一批功能 / 修好一个用户能感知的 bug / 要给别人一份新安装包。
> 不算:改注释、改文档、重构、加测试。
**同一个版本号写在 14 处(12 个文件)里**(5 份 `package.json`、`deploy/package.runtime.json`、
两个 `docker-compose*.yml` 的镜像 tag、插件 `manifest.yml`、插件 C# 里的 `PluginVersion` 常量、
官网首页上三处手写值,以及下载页 `<meta name="ch-version">` 那个兜底值)。
手工改必然漏一处,而漏掉的那处**不会报错** —— 只会在某个不常走的分支上表现为"版本号对不上"
(官网那三处的后果是首页一直挂着旧版本号;下载页那一处的后果是离线访客点到一串不存在的直链)。
所以**改版本号的唯一入口是脚本**:
```bash
pnpm version:check # 逐处列出并比对,不一致 exit 1(默认就是检查模式)
pnpm version:bump patch # 1.1.2 → 1.1.3(另有 minor / major)
pnpm version:check # 再确认一次
```
`scripts/version.mjs` 按表逐个改写,**任何一处没匹配到或匹配到多处就直接失败退出**,
不做"尽力而为"的部分改写。
插件版本是**四段** `x.y.z.0`:前三段跟产品版本走,第 4 段留给"只改插件、不动产品版本"的补丁发布。
`--bump` / `--set` 会把第 4 段归零;要单独发插件补丁就手工改清单与 `BridgeService.cs` 那两处。
#### 一次完整发布的命令序列
```bash
# ① 改版本号
pnpm version:check && pnpm version:bump patch
# ② 本地体检
pnpm lint && pnpm typecheck && pnpm check:icons && pnpm docs:check
# ③ 跑验收(另开一个终端跑着 pnpm dev:server)
pnpm verify:e2e
pnpm verify:classisland
pnpm verify:classisland-plugin
pnpm build:desktop && pnpm verify:desktop
pnpm build && pnpm verify:web
# ④ 打安装包(产物落在 releases/<组件>/<版本>/)
pnpm dist:all
# 对**打包后的副本**复验:便携版就是自解压单文件,直接拿它当被测副本最省事
pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe"
# ⑤ 整理跨组件哈希清单 releases/SHA256SUMS-<版本>.txt(人读;Linux 那一行由 CI 合并进来)
# ⑥ 提交并推上去
git add -A && git commit -m "chore(release): <版本>" && git push origin master
# ⑦ 开 Release(gh release create 会创建并推送 tag,从而触发 Linux 包的 CI;见下)
# 正文用手写的更新日志,别只留 --generate-notes 的自动列表(见「每次发版必须一起交付的四样东西」)
gh release create v<版本> --title "ClassHelper v<版本>(<一句话定位>)" \
--notes-file releases/ReleaseNotes-<版本>.md
gh release upload v<版本> \
releases/client/<版本>/安装包/*.exe \
releases/classisland-plugin/<版本>/安装包/*.cipx \
releases/server/<版本>/安装包/*.exe \
releases/SHA256SUMS-<版本>.txt --clobber
```
> **上传前把三个 EXE 改成 ASCII 名。** 构建产物是中文名(客户端 `ClassHelper-<版本>-x64-setup.exe`
> / `-x64-portable.exe`,服务端 `ClassHelper 服务端-<版本>-x64-setup.exe`),而 Release 上的名字
> 是给别人看、要贴进脚本的 —— 一律用 ASCII 形式,与构建产物一一对应(内容相同、sha256 相同):
>
> | 构建产物 | Release 上的名字 |
> | --------------------------- | ------------------------------------------- |
> | `<客户端>-x64-setup.exe` | `ClassHelper-<版本>-x64-client-setup.exe` |
> | `<客户端>-x64-portable.exe` | `ClassHelper-<版本>-x64-client-portable.exe` |
> | `<服务端>-x64-setup.exe` | `ClassHelper-<版本>-x64-server-setup.exe` |
>
> **这三个名字是外部契约**:README 的「开始使用」表、官网下载页的离线兜底直链都按它们拼 ——
> 改名要三处一起改。上传前改名比上传后 `gh release edit` 省事:
>
> ```bash
> V=<版本>
> mkdir -p .cache/release-staging
> cp "releases/client/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-client-setup.exe"
> cp "releases/client/$V/安装包/"*-x64-portable.exe ".cache/release-staging/ClassHelper-$V-x64-client-portable.exe"
> cp "releases/server/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-server-setup.exe"
> cp "releases/classisland-plugin/$V/安装包/ClassHelper.ClassIslandPlugin.cipx" .cache/release-staging/
> gh release upload v$V .cache/release-staging/* "releases/SHA256SUMS-$V.txt" --clobber
> ```
**第 ⑦ 步的顺序有讲究**:`.github/workflows/release-linux-server.yml` 由 tag 推送触发,
它在**最后一步**才把自己的那一行**合并进** Release 上已有的 `SHA256SUMS-<版本>.txt`
(`grep -v` 掉旧的 linux 行再追加,Windows / 插件那几行原样保留)。
所以本地那份清单必须**先上传**;万一手速反了,重跑一次就能补回来:
```bash
gh workflow run release-linux-server.yml -f tag=v<版本> -f attach=true
```
**Linux 包只能在 Linux 上构建**(`@libsql/linux-x64-gnu` 这类依赖是平台相关的),
所以 `pnpm dist:all` 里没有它,必须走 CI(见 §5 第 59 条)。
**已发布的版本号不要复用**:客户端把它记进「忽略此版本」,两端各有 30 分钟缓存 ——
同号换内容会让「检查更新」永久失灵。要修就 `pnpm version:bump patch` 发新号。
#### 每次发版必须一起交付的四样东西
`pnpm dist:all` 只产出安装包。**每次版本迭代都要把这四样一起做掉**,少一样都算「发到一半」:
| # | 交付物 | 落在哪 | 怎么做 |
| -- | ------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- |
| ① | **更新日志** | GitHub Release 的正文 | 手写,草稿放 `releases/ReleaseNotes-<版本>.md`(与它描述的产物同一层),用 `gh release create/edit --notes-file` 发布。**不要**只挂 `--generate-notes` 的自动列表 |
| ② | **Linux 服务端包** | Release 上的 `classhelper-server-linux-x64-<版本>.tar.gz` | tag 推上去由 `release-linux-server.yml` 构建;**推完必须去 Actions 确认没红** |
| ③ | **文档** | `docs/reference.md`(功能说明 + 验收对照表)与相关章节 | 新增功能补对应小节;改交付形态一并改 §2 / §7 与 `docs/dev/*` |
| ④ | **官网与 README** | `website/index.html`(功能块)、`website/download.html`(兜底版本号)、`README.md`(功能清单) | 新功能补进功能块 / 清单;页面里的版本号由 `pnpm version:bump` 覆盖,不用手写 |
更新日志的骨架照 1.2.0 / 1.3.0 那两份:**一句话定位 → 分节讲功能 → ⚠️ 升级须知(含自动应用的迁移)→
验收表 → 产物表**。验收表里**只写真跑过的数字**,没跑就直说没跑,别照抄上一版的。
**② 有个很容易漏的坑:CI 是按 tag 的 ref 检出源码的**(`actions/checkout` 的
`ref: ${{ steps.ref.outputs.tag }}`)。所以发完版又发现构建脚本(或任何影响出包的代码)要修时,
**光推 master 不够** —— CI 仍会拿旧 tag 的源码跑,必须把 tag 挪到修复后的提交:
```bash
git tag -f v<版本> <修复后的提交> && git push --force origin v<版本>
```
tag 一变会自动重跑。v1.3.0 实测踩过:tag 指的那个提交里 `dist-server.mjs` 有个 bug
(见 §5 第 59 条 ④),CI 红了、Release 上少一个 Linux 包,而本地完全看不出来 ——
Windows 侧走的是另一条分支,`pnpm dist:server` 一直绿。
**推完 tag 去 `gh run list --limit 5` 看一眼。** `发布官网与文档站`(master 推送触发)与
`发布 Linux 服务端安装包`(tag 推送触发)是两条独立流水线,**前者绿不代表后者出来了**;
失败用 `gh run view <id>` 定位到具体是哪一步。
#### 官网与文档站怎么发布
**不用做任何事。** 推 master 时只要 `website/**`、`docs/**`、`scripts/build-pages.mjs` 或
`package.json` 变过,`.github/workflows/pages.yml` 就会构建并部署到 GitHub Pages:
```
https://lovechengke.github.io/ClassHelper/ ← 官网(website/,纯静态无构建)
https://lovechengke.github.io/ClassHelper/download.html ← 下载页(同一棵树里的一个页面)
https://lovechengke.github.io/ClassHelper/docs/ ← 文档站(docfx 构建 docs/)
```
> **一个仓库在 GitHub Pages 上只有一个站点**,所以两者是拼在一起发的
> (`scripts/build-pages.mjs` 把 `website/` 放到根、`docs/_site/` 放到 `docs/` 下)。
> 官网在根是因为产品页的地址最短最好分享;文档站多一层路径没有代价。
> 改这个布局要同时动三处:`scripts/build-pages.mjs`、本 workflow 里的 `DOCS_BASE`,
> 以及文档站模板 `docs/templates/classhelper/layout/_master.tmpl` 里那个 `{{_rel}}../` 的「官网」入口。
- 顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata _chVersion=…` 注入),
**不要在文档里再写一份版本号**;官网首页那排芯片里的 `v1.1.2` 与下载页的
`<meta name="ch-version">` 是手写的,发版时要一起改(都在 `pnpm version:bump` 的 14 个落点里);
- 下载页的版本清单**不在站点里存副本**:每次打开都由 `assets/download.js` 当场读
GitHub Releases(匿名接口 60 次/小时/IP),读不到才退回上面那个 meta 按发布命名约定拼直链。
所以发完 Release 不用重新部署这一页,但**兜底值必须跟着发版改**;
- 首次启用需要在仓库 Settings → Pages 把 Source 选成「GitHub Actions」(只需一次);
- 404 页的 `<base>` 由 `scripts/build-pages.mjs`(本地 `./`)与 workflow(CI 传 `/<仓库名>/`)注入 ——
项目页挂在 `/<仓库名>/` 子路径下,而浏览器是按**被请求的路径**解析 404 页里的相对链接的;
- 本地预览:`pnpm dev:site`(官网在 http://127.0.0.1:5180,文档站挂在同一个端口的 `/docs/` 下,
与线上同形);只想单独看文档站用 `pnpm docs:serve`。
---
## 5. 架构硬约定(改代码前必须知道)
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 21 个:`auth / classes / courses / schedules /
homeworks / notifications / calls / imports / integrations / grades / students / teachers / archives /
term / dashboard / database / update / permissions / clients / system`。
新增/下线功能**只改 `src/modules/registry.ts`**(加一行 / 改 `enabled: false`),不要动 `app.ts`。
2. **统一响应与校验。** 用 `src/lib/http.ts` 的 `sendOk / sendCreated / …` 返回,用
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用
`@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/
CLASS_DEVICE(班级端)/ NONE`,权限判 `canManageSchedule` / `canPublishNotification` /
`canManageRoster` / `canManageGrades` / `canManageSubjectContent` 等
(人类可读矩阵是 `PERMISSION_MATRIX`),**不要在页面里另写一套规则**。
4. **学生不是账号 —— 这是表结构保证的,别在代码里"兼容"学生账号。**
`Student` 表没有 `passwordHash` / `role`,学生不能登录、没有任何密码接口;
学号(`Student.studentNo`)是学生的唯一标识与查询键(名单 / 成绩 / 作业提交 / 导入导出全用它)。
曾经那套"`User.role='STUDENT'` + 空串密码占位 + 登录前判 role 再 403"的写法在 2026-10-06
整体删除,**不要再加回来**:任何 `role === 'STUDENT'` 的分支都是回归。
5. **任课关系的唯一来源是 `Course`(班级 + 科目 + 教师)。** 没有第二张任课表
(历史上那张无科目的 `ClassTeacher` 已删除)。作业与成绩的写权限必须走
**`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**:
挂了科目就只能由该科的任课老师改,没挂科目则归班主任 / 管理员;
班级端只有**作业**可以写(成绩不给)。**新增"发布内容"类入口必须复用它**,否则又会出现
"某个页面能改别人的科目"。
6. **共享契约只在 `packages/shared`。** 三端都 `import ... from '@classhelper/shared'`;它产出 `dist`,
改完要 `pnpm build:shared`(或跑着 `pnpm dev` 的 watch)。类型/常量**不要在两端各写一份**。
7. **数据模型跨库通用。** `schema.prisma` 只用 String / Int / Float / Boolean / DateTime,不用 Prisma enum、
不用 `@db.*`;角色与优先级存字符串并在 API 层用 zod 校验(这样 SQLite / MySQL 行为一致)。
**只有一份 `schema.prisma`**:切库是改写它的 `provider`(`pnpm db:switch:mysql`),没有 `schema.mysql.prisma`。
改模型要同步 `prisma/migrations/`(本机用 `pnpm --filter @classhelper/server db:deploy` 应用)。
8. **周次口径唯一。** 当前教学周 = `shared.resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)`,
`MAX_TERM_WEEK = 30`,班级还有自己的 `Class.termWeeks`(默认 20,**上限也是 30**)。别在别处重新实现周次换算。
周次上限三处必须一致:`MAX_TERM_WEEK`、`lib/schemas.ts` 的 `weekNumberSchema`、`classes.schemas.ts` 的 `termWeeks`
(曾经班级能设到 40 而课表只到 30,等于对外承诺了系统兑现不了的学期长度)。
`Class.termWeeks` 只有**管理员**能改(`assertCanManageClasses`),班主任不行。
9. **Electron 安全边界。** `src/preload/` 只通过 contextBridge 暴露白名单方法,**不要暴露 `ipcRenderer` 本体**;
主进程 ↔ 渲染进程的类型统一写在 `src/types/desktop.d.ts`。
**跨桥接只能传纯数据。** contextBridge 在参数跨越「主窗口 → 隔离世界」时就做结构化克隆,
传 Vue 的 `ref.value` / `reactive()` 对象(Proxy)会抛 `An object could not be cloned.`——
而且它在**进入 preload 函数体之前**就失败了,所以桥接层**无法兜底**,只能由调用方展开成
字面量再传(`saveConfig({ island: { ...island.value } })`)。这类失败是"半静默"的:
界面数字照常变化、只多一行 console error,看起来就是"设置不生效"。设置页曾因此翻车,
`verify:desktop` 里的「设置页真实 UI:拖拽滑块即时改变灵动岛外观」就是为它加的回归。
10. **客户端离线优先。** 渲染进程数据先落 IndexedDB(`renderer/cache/`),断网回退缓存并在顶部提示离线;
新增页面/数据源要按这个模式接。
11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root`
(`--ch-radius-xl/lg/md/sm/pill`、`--ch-shadow-sm/md`),组件里**不要写死数值**;Element Plus 通过覆盖它的
CSS 变量对齐。手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card`。
12. **ClassIsland 联动是独立鉴权通道,不要塞进 JWT。** 插件用设备令牌(`X-ClassIsland-Token`,库里只存 sha256,
明文前缀为 `chci_`)调 `/api/integrations/classisland/*`;Web 端管理设备仍走 JWT + `requireRole`。
新增插件接口时,路由挂 `integrations.module.ts`,并在 `integrations.schemas.ts` 里写 zod —— 插件侧的 C# DTO
必须**逐字段对齐**(`pnpm verify:classisland-plugin` 会校验字段名与路由)。
13. **课表镜像契约必须带 `startTime`/`endTime`。** ClassIsland 的 `ClassPlan.Classes[i]` 强制与时间表里
**第 i 个 `TimeType == 0`(上课)的时间点**对齐:镜像下发少一个时间点,整份课表就会错位,
而且表现为"课表看着有内容、每节课都对不上",极难排查。
14. **所有发布入口共用同一份 ClassIsland 推送实现**(`integrations.service.ts` 的 `pushToClassIsland`):
通知发布 / 叫人 / 联动页的下发都走它。新增「发通知」类入口时**必须**复用,不要再各写一套 ——
否则又会出现「某个页面发的通知不联动 ClassIsland」。
15. **提醒弹在哪个端由教室客户端选**,值存在 `Class.notificationChannel`(`both` / `client` / `classisland`):
客户端设置页写它(班级账号也有写权限,见 `PATCH /api/classes/:id/notification-channel`),
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 ——
用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
17. **镜像进 ClassIsland 的课表要带课间**:每两节之间补一段(上一节下课 → 下一节上课),
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
服务端合成节次时间表同口径。
18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段
客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);紧急(`URGENT`)同样立刻弹。
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件),
客户端的实现在 `renderer/island/bridge.ts`。改这条规则要两端一起改。
19. **作业按「所属日期」归类,不要用 createdAt 当"哪一天"**:`Homework.assignDate` 是本地日期
(`YYYY-MM-DD`,不带时区),按天查看 / 日期高亮 / 客户端"今天"都看它。
UTC 切片(`toISOString().slice(0,10)`)在晚上的录入会算到第二天 —— 统一用 `@classhelper/shared` 的
`dayKeyLocal / isDayKey / shiftDayKey`。新增按天接口时同样用 `?date=YYYY-MM-DD` + zod 的 `isDayKey` 校验。
20. **教室机器能录入作业**:`POST /homeworks` 的路由**故意不加 `requireRole`**,权限在
`homeworks.service.createHomework` 里判(staff 或本班班级账号)。班级账号的 `sub` 是**班级 id**,
写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键。
21. **灵动岛不要用窗口背景材质(`win.setBackgroundMaterial()`)**。它看起来像 WinIsland 的
`DWMWA_USE_HOSTBACKDROPBRUSH`,其实是**整窗系统材质**:刷在整个窗口矩形上(岛是"固定大包围盒窗口",
卡片之外还有留白),且明暗跟随应用主题(本客户端是浅色 Fluent 主题 ⇒ 材质必然是浅色),
于是卡片周围会出现一圈浅色"白底面板"。更坑的是**调用过一次就再也回不去逐像素透明**
(含 `'none'`:Electron 会让窗口回落到默认不透明底色 `#FFF`),所以从 `glass` 切到
`black`/`tinted` 后会永久留一圈**纯白**。CSS `backdrop-filter` 也替代不了(透明窗口里采样不到桌面)。
`glass` 因此用 CSS 半透明深色卡(WinIsland 无 backdrop 时的降级色 `rgba(32,32,36,.804)`)。
回归用例:「个性设置:卡片外圈透出桌面(三种风格都没有白底面板)」—— 它**整屏截图**取样
(`webContents.capturePage()` 拍不到窗口之后的材质,是当初漏掉这个 bug 的原因),
并在岛底下垫一块已知颜色的底板窗口,好让"环 = 底板色"可以被精确断言。
22. **灵动岛位置由「屏幕工作区 + 边距」算,默认边距 8px(`DEFAULT_ISLAND_APPEARANCE.marginX/marginY`)**:
改默认值会直接改外观,`verify:desktop` 的「停靠位置生效(6 个锚点)」断言的是像素坐标,一动就红。
新增边距类设置请只改"可调范围",别动默认值(夹紧规则收口在主进程 `normalizeIslandAppearance`)。
23. **课表行有"来源"字段 `Schedule.source`**(`manual` / `classisland`)。插件每次全量上报时,
服务端只清理「`source='classisland'` 且本次未再上报」的行 —— 这样老师在 ClassIsland 里删掉/挪动一节
能真正同步下来(原先 merge 只 upsert,服务端会永远留着幽灵课),**老师在小助手这边手排的课一律不删**。
因此:**新增写课表的入口要显式定 source**(Web 端录入/导入保持默认 `manual`,只有插件上报用 `classisland`),
否则要么幽灵课清不掉,要么手排的课被误删。回归用例见 `verify:classisland` 的「幽灵行清理」两条。
**`source` 只在 create 时确定,update 一律保持原值。** merge 的去重键是「星期 + 开始时间 + 单双周」,
老师在 Web 端手排的同一时间槽会被上报"收养";若收养时把来源改成 `classisland`,
下一次清理就会把这行手排的课删掉 —— 这个坑踩过一次:种子课表被删掉 2 行,
`verify:e2e` 的「上课状态接口」用例(AGENTS.md §7 已列为脆弱用例)随即报红。
同步时**不要**覆盖 `weekStart/weekEnd`:插件上报契约里没有周次范围,硬写"整学期"会把老师设的「第 1~10 周」抹掉。
24. **两个登录入口都必须挂 `loginRateLimiter()`**(`app.ts` 用数组形式同时挂 `/auth/login` 与 `/auth/class-login`)。
班级密码默认是 `123456`、班级码是固定字符集,班级登录曾是唯一没有暴力破解防护的入口。
另外 `auth.service.login` 里**角色判定必须在密码校验之前**:否则"密码错(401) / 密码对但学生账号已停用(403)"
的差异会把学生账号密码变成可离线验证的预言机。
25. **凡是会渲染成链接的字段都要校验 scheme。** `Homework.attachmentUrl` 在服务端用
`attachmentUrlSchema`(只允许 `http(s)://` 或 `/` 开头的站内相对路径),Web 端渲染前再兜一层
(`HomeworksView.vue` 的 `attachmentHref`)。放行 `javascript:` / `data:text/html` 就是一条存储型 XSS。
26. **桌面端必须保留 CSP**(`index.html` / `island.html` 的 `meta http-equiv="Content-Security-Policy"`)。
渲染进程通过 preload 能拿到 `getConfig()` 的**明文 token**(代表全班身份),CSP 是唯一能拦住
"加载并执行远程脚本"的一层。配套的两条导航防线也别删:主窗口 `isAllowedNavigation`(按同源/同一文件精确比对,
不要退回 `startsWith`)、灵动岛窗口的 `setWindowOpenHandler` + `will-navigate` 全拒。
27. **桌面端产物里不得出现任何凭据,构建有门禁。** `dist/main/index.js` 与 `dist/renderer/assets/*.js`
会被 electron-builder 打进 `app.asar` 发给每台学生机,而 asar 可直接解包
(历史问题:冒烟/自检代码里写死了 `teacher1/teacher123`,`grep` 产物即命中)。
冒烟与自检用的账号口令、班级码/班级密码**一律通过环境变量注入**
(`ELECTRON_SMOKE_USER` / `ELECTRON_SMOKE_PASSWORD` / `ELECTRON_SMOKE_CLASS_CODE` / `..._CLASS_PASSWORD`):
preload 读环境变量 → `window.desktop.smokeCredentials` → `setSmokeTeacherCredentials()`,
由 `scripts/smoke.mjs` 与 `scripts/verify-packaged.mjs` 提供(那两个脚本不进安装包)。
**把静态 import 改成 `await import('./smoke.js')` 解决不了这个问题**:esbuild 在
`format:'cjs'` + `bundle:true` 下不做代码分割,动态 import 同样被内联进同一个产物(已实测)。
`pnpm build:desktop` 结尾会跑 `check-bundle-secrets`(`packages/desktop-client/scripts/check-bundle-secrets.mjs`),
产物里出现种子口令/把种子班级码当凭据的写法即构建失败 —— 别把这条门禁摘掉。
28. **表格导入的体积上限是"请求体 12MB ↔ 文件 8MB"**:文件以 base64 放进 JSON(约 4/3 倍),
因此 `express.json({ limit: '12mb' })` 必须留够余量,否则声明了 8MB 却连 1.5MB 的文件都传不进来。
超限由 `middleware/error.ts` 映射成 413 `IMPORT_TOO_LARGE`(**不要**让它落进 500 兜底分支:
原先用户看到的是一句英文的「服务器内部错误:request entity too large」)。
29. **删除类接口的护栏要与 schema 的级联关系对齐。** `deleteTeacher` 的检查项必须覆盖
`Class.teacherId` / `Course.teacherId` / `ClassTeacher.teacherId` / `Homework.createdBy` /
`Notification.createdBy` 五项(后两项曾经漏检,会静默删掉该教师在各班发过的作业与通知;
回归用例见 `verify:e2e` 的「删除教师护栏覆盖"发布过作业/通知"的账号」)。
**改 schema 时先看哪些外键指向 `User` 且是 Cascade,再回来核对这个清单。**
30. **灵动岛在触摸屏上必须走「触摸模式」(`touchMode`)。** 打开命中的两条链路
(渲染进程的 mousemove 转发、主进程读 `screen.getCursorScreenPoint()`)**都以"鼠标指针移动"
为前提**,而手指触摸既不产生 mousemove、也不移动系统光标 ⇒ 窗口永远停在
`setIgnoreMouseEvents(true)` 的穿透态,**手指点不开岛**(用户反馈:希沃白板)。
触摸屏上唯一的解法是"触摸点处窗口可命中":Electron 没有 `SetWindowRgn`,
所以渲染进程按 `navigator.maxTouchPoints > 0` 上报(`island:set-touch-mode`)后,
主进程把窗口**贴合岛体**(`windowBox` = 当前形态 + 2×阴影留白)并**始终接收输入**
(`setInteractive` 在触摸模式下把入参强制为 true)。三条护栏别动:
- **窗口可见期间只增不减**:`syncTouchLayout()` 里 `if (shrinking && this.win.isVisible()) return;`
—— 需要变小一律等窗口隐藏之后再做(`syncWindow()` 的"不可见时对齐包围盒"那条)。立刻缩小既会裁掉
形变中的卡片,又会因为 DWM 还没重画桌面而留下上一帧(用户拍照反馈过"收回一瞬间闪一下");
(注:`cardFitsFormSize()` 这个"按岛体矩形判断能不能缩"的旧判据**已无调用**,别再照它理解现状。)
- `expectedHitRect` 必须按**窗口实际尺寸**算偏移(触摸模式下窗口不再是固定包围盒);
- 失焦宽限期用 `BLUR_GRACE_TOUCH_MS`(1200ms):触摸没有光标,`isCursorOnIsland()` 恒为 false,
而实测假失焦出现在 70~520ms(跨过 500ms 边界 ⇒ 会把刚展开的岛缩回胶囊)。
非触摸屏机器**完全不走这条路**(窗口仍是固定大包围盒 + 按光标命中),
`verify:desktop` 会先 `setTouchMode(false)` 再跑既有的光标注入用例,末尾单独断言触摸模式。
31. **"改密码"的入口只剩 Web 管理端两处,客户端一个都没有。** 教师/管理员改本人密码走
Web 顶栏下拉(`PATCH /api/auth/password`);班级密码由管理员在「班级管理 → 修改班级账号」
(`PATCH /classes/:id/class-account`)维护;教师设指定新密码用
`POST /teachers/:id/reset-password`(可选 `newPassword`,留空 = 默认初始密码)。
**2026-10-01 起学生个人账号整体清理**:学生只是名单(成绩/未交/叫人/已读按名单记录),
`POST /students/:id/reset-password` 已删除(调用返回 404,e2e 有负断言)、新建学生不设密码
(`User.passwordHash` 空串占位,`STUDENT` 的 403 登录判定在密码校验之前)、
`DEFAULT_STUDENT_PASSWORD` 已删;客户端设置页的「修改密码」按钮**是按用户要求主动移除的**
(防教室机器误改班级密码)——别当成"丢入口"的回归补回来。
32. **灵动岛卡片的投影(CSS `filter`)必须常驻,形态切换只能改参数。**
不要把 `filter: drop-shadow(...)` 只挂在 `.island-card.expanded` 上:**给元素增删 CSS filter**
会让 Chromium 新建/销毁它的渲染表面(effect node),首帧的合成可能早于新表面栅格化完成,
那一帧被当成空内容画出去 —— 而**卡片的底色恰好画在这个被过滤的元素上**(`.shape path` 的 `fill`),
于是开/合的那一瞬间底座会闪掉一帧、只剩未过滤的文字层(用户反馈的"开合时一瞬间的闪动")。
正确写法见 `IslandApp.vue` 的 `.shape path`:filter 常驻,胶囊态用 `0 0 0` 的退化投影
(硬轮廓正好压在路径底下,肉眼等同无投影,与 WinIsland「只有展开态有投影」的观感一致)。
回归用例:`verify:desktop` / `verify:packaged` 的「灵动岛任意形态的投影都常驻」。
33. **多条通知时展开是「竖排列表」,卡片高度由主进程与渲染进程**各算一次同一个数**。**
`@classhelper/shared` 的 `islandListLayout()`(度量表 `ISLAND_LIST_METRICS`)是唯一算法:
默认只显示 `defaultVisibleRows`(3)条 + `展开更多(还有 N 条)`,按重要程度排序
(`PRIORITY_RANK`,同级按时间新的在前);点"展开更多"后铺到 `maxCardHeight` 为止,
仍然放不下的改成「更多请前往应用内操作」——`maxCardHeight` 是主进程按工作区/任务栏算出的可用高度,
随状态下发。两条硬约束:
- **窗口包围盒必须跟着列表长高**(`IslandApp.vue` 的列表比任何普通形态都高,窗口不够高会把底部按钮裁掉);
长高发生在卡片形变**之前**(`setState` 里先 `relayout()` 再 `emit()`),用户看不到这一步;
- 列表态 CSS 的每个尺寸(行高/行距/内边距/标题行/提示行/按钮行)都要与那份度量**一一对应**,
改 CSS 必须改常量,否则"最后一行被切掉"这类问题会以"看着有内容、按钮点不到"的形式出现。
回归用例:`verify:desktop` 的「多条通知竖向排列…」「点展开更多…」「通知多到屏幕放不下…」。
写这类用例时注意:**断言"单条卡片"的用例(紧急卡/叫人卡/截图比例)必须先 `drainIsland()` 排空队列**,
否则量到的是列表高度。
34. **"知道了 / 标为已读"之后是「状态同步落地 + 窗口淡出」,没有跨进程的收回握手。**
收起一律是**主进程单向**的:`handleAction()` 收到 `dismiss` / `dismiss-all` / `mark-read` /
`mark-all-read` → `dismissActive()`(还有下一条则退胶囊)或 `clearPendingForClose()`
→ `hide()` → `fadeOut()`(每帧 `setOpacity(-0.2)`,到 0.02 后 `win.hide()`)。
状态在**同一帧就写成** `hidden` / 清空,**不等渲染进程任何回执** —— 调用方与冒烟按"同步语义"断言
(`island.ts` 的 `clearPendingForClose()` 注释:「状态同步落地(渲染进程随之收起卡片,窗口由 `hide()` 淡出)」)。
渲染进程那侧只有本地弹簧形变(`spring.ts` 的 `targetSize/progress`),是纯视觉动画,不参与淡出时机。
**曾经有过一层自研的"收回快照"动画,已按用户要求整体删除。** 它叫 `closing` / `closingSnapshot`:
点"知道了"后把卡片按"胶囊摘要"收回再淡出,是自研的形变状态机,**实测在触摸屏上会露出旧帧/重影**
(触摸模式窗口贴合岛体、卡片形变时窗口跟着动),用户明确要求"动画就用仓库里那套",于是整段删掉 ——
现状见 `IslandApp.vue` 顶部注释。**别照着一份不存在的握手机制去改代码。**
(`smoke.ts` 里"收回动画是渲染进程收敛上报 → 主进程淡出"只是一句过时注释,代码里没有这条链路。)
多条通知时这两个按钮都是**整批**操作:`dismiss-all` / `mark-all-read`(作业卡的合成 id
`homework-<id>` 要在主进程按 kind 过滤掉,否则通知中心会去查一条不存在的通知 ——
见 `handleAction()` 里 `mark-all-read` 分支的 `.filter((item) => item.kind !== 'homework')`)。
回归用例:`verify:desktop` 的「多条通知点「知道了」:整批关闭(状态同步清空,窗口淡出隐藏)」。
排查"收回时闪一下"这类反馈时注意:**先看是不是自动化验证实例的叠影**(见第 41、42 条),
别往这套已经不存在的握手机制上找原因。
35. **本机录入的作业不再上灵动岛。** 服务端是**先广播 `homework:new`、后回响应**的,
等响应拿到 id 再登记就晚了(事件可能已经到了、岛已经弹出来了)——所以
`renderer/island/bridge.ts` 在**发请求之前**按内容登记指纹(班级 + 日期 + 标题 + 正文),
回执拿到 id 后再补记一次,投递前用 id / 指纹双重判定。改作业录入入口时别忘了在请求前调
`markHomeworkCreatedLocally()`。回归用例:「本机录入的作业不再上灵动岛」。
36. **触摸模式下收回动画期间,窗口必须一动不动**(窗口可见期间一律不缩窗口,见 `syncTouchLayout()`)。
触摸模式窗口是"贴合岛体"的,而收回时卡片正在缩小:若跟着缩窗口,**中心停靠下窗口 x 还要右移**,
"让出去"的那块区域会留着上一帧(DWM 还没重画桌面)——同一帧里能看到"旧展开卡的左半截 + 新胶囊"
两份画面(用户拍照反馈的"收回一瞬间闪一下")。窗口尺寸一律等**隐藏之后**再对齐
(`syncWindow` 里"不可见时对齐包围盒"那条,不可见时改尺寸不会有残影)。
回归用例:「触摸模式收回:动画期间窗口不动」逐帧采样窗口矩形(只在"窗口可见 / 正在收回"时追究,
收完隐藏后对齐尺寸是正常的);「收回动画期间卡片不被窗口裁切」。
另:**别再去代码里找 `closing` 或 `cardFitsFormSize()`** —— 自研的那层"收回快照"(§5 第 34 条已说明
整体删除)与"按岛体矩形判断能否缩小"这个旧判据都已不在调用链上,最终判据只有上面那条
`shrinking && isVisible` 早退。"隐藏态改边距不生效"则是由 `setAppearance()` 无条件 `relayout()`
加 `applyWindowLayout()` 的"矩形没变就不调 setBounds"守卫保证的,同样不需要额外的可见性判断。
37. **开合时胶囊内容禁止跟着卡片重排/横移。** 卡片在 268 ⇄ 424 之间变宽变窄,中心停靠下左右边都在动;
胶囊内容若按卡片宽度布局,就会出现"文字先按宽卡片铺开、再随卡片收窄被省略号收回"
(用户反馈的"新消息那行字往右跳一下再缩回")。所以 `IslandApp.vue` 的胶囊层里套了一层
`.pill-inner`,**固定成胶囊自己的几何**(`width: var(--island-w)`),并按停靠方式对齐
(`data-anchor` = left/center/right → `justify-content` flex-start/center/flex-end):
左/右停靠贴对应边,居中则居中 —— 静止态外观与之前完全一致,动画全程文字位置与省略号都不变。
回归用例:「灵动岛开合时胶囊内容不横移」,逐帧采样 `.pill-title` 左右边缘,波动必须 ≤ 2px。
38. **跑自动化验证时,验证实例的岛要挪到左上角 + 真实输入穿透。**
`verify:desktop` / `verify:packaged` 会真的在屏幕上放一个灵动岛窗口(顶部居中),而用户自己的客户端
默认也在同一位置(他的设置常是 `top-center` + 较大 `marginY`)—— 两个岛一高一低叠在一起,用户会以为
**"屏幕上出现了第二个灵动岛"**,而且点它"反应不对"(它归验证脚本控制,会自己展开/收起/消失)。
实测踩过:用户连续两条反馈("收回去的时候闪一下/下半被截断")其实都是这个叠影造成的。
所以 `runIslandChecks` 开头把验证实例的岛做成**一眼可辨**:
`island.setAppearance({ accent: '#e91e8c', style: 'tinted' })`(洋红 = 验证实例;跑完不必还原,
验证用的是独立 profile),并且全程 `island.setTestInputPassthrough(true)` 让真实点击穿透。
**只改颜色、不要改停靠位置**:命中/几何断言都是按默认位置写的,把岛挪到角落会把
「空闲细缝态命中跟随光标」这类用例弄红(实测踩过)。
另外:冒烟会**真发通知**给当前班级(实时链路用例必须打到客户端所在班),所以验证期间用户的通知列表/
岛上会短暂出现几条「张老师 · _自检_」——它们用完即删(`createSmokeNotification` / `islandRealtimeCleanup`),
若验证被中途 kill 会留下残条,收尾时顺手清一下(标题里带「自检 / 红点位置校验」的那几条)。
39. **冒烟期间要让真实鼠标点击穿透(`island.setTestInputPassthrough(true)`)。** 自动化要跑几分钟,
而用户很可能正在同一块屏幕上 —— 他的客户端那个岛和冒烟这个窗口都在屏幕顶部居中、互相叠着,
他点到的是**冒烟这个窗口**:实测冒烟收到了人发出的 `collapse` / `mark-read`,把「点胶囊展开」
「点空白处收起」等用例整片弄红。开关只忽略**真实**输入:`cursorOverride`(`setHitTestCursor`)
一旦注入就照常按注入位置判定,所以「光标轮询校正命中」「岛外穿透」「触摸模式始终接收输入」
这些断言不受影响(触摸模式那条会临时关掉开关)。跑完在收尾处恢复。
40. **作业页的 `?date=` 深链必须在"已在作业页"时也生效。** `route.query.date` 只在组件 setup 里读
一次的话,同路由再推 query(冒烟切到"最近有作业的日期"、用户从别处深链回来)会被复用的组件
无视,看板停在原日期 —— 表现为"看板明明有历史作业却显示空"。修法是 watch `route.query.date`
(`HomeworkView.vue`,与 `selectDate` 的 `router.replace` 形成环时靠"值相同就跳过"防死循环)。
同理,冒烟里"切到有作业的日期"的判据是**看板里没有 `.board-card`**,不是"没有 `.board-host`"
(空看板照样渲染容器,拿容器当判据兜底永远不会触发)。
41. **打包版冒烟自检发的通知要"任何时段都可见"**:用 `priority: 'URGENT'` + `confirmDuringClass: true`
(上课时段普通通知会被**正确地**暂存到下课、紧急通知不带确认会被 409 `URGENT_DURING_CLASS` 拒掉 ——
两个都是产品功能,别为了测试去改产品行为)。凡是断言"窗口已隐藏"的用例一律**轮询**等待,
不要写死 sleep:`dismiss` 现在带 ~360ms 收回动画再淡出,机器忙时固定 sleep 会间歇性踩空
(「关闭空闲细缝后空闲再次完全隐藏」曾因此 4 连红)。
42. **用户报"打包版用不了/装不上"先查两件事,别急着怀疑产物**(2026-10-01 实测,两次全是环境):
① **后端起没起** —— 客户端配置指向 `127.0.0.1:4000`,后端不在就进离线模式、数据全空,
观感就是"软件坏了";② **托盘单实例锁** —— 关窗是隐藏到托盘、进程还在,再双击 EXE 会被
单实例锁秒退(看起来"点了没反应"),安装包也会因"应用正在运行"装不上:先从托盘退出。
完整性标签(§7 第 7 条)用 `icacls | findstr Mandatory` 一眼可辨,优先排除。
**还有第三件:安装目录缺文件 —— 症状是"窗口开着但全黑"。**(2026-10-11 实测)
Electron 的渲染进程要读 `dxil.dll`(`dxcompiler.dll` 的依赖)、`vk_swiftshader.dll` + `vulkan-1.dll`
(软件渲染回退)、`ffmpeg.dll`、`resources/elevate.exe` 这几份**主进程之外的**文件,
少任何一个都会让渲染进程起来就崩,表现是 `loadFile` 抛 `ERR_FAILED (-2) ... index.html` +
`Renderer process crashed`,而**主进程日志里一切正常**(托盘、灵动岛、更新检查全都照常打印)——
于是用户看到的就是"打开一个纯黑窗口,标题栏还是 ClassHelper"。
**注意 `installCrashGuard()` 排在 `await loadRenderer()` 之后**,所以这条崩溃路径上
连"主窗口渲染进程异常退出"那行都不会打,日志里只有一句 Node 的 UnhandledPromiseRejection。
排查顺序(都是实测走通过的):
1. 先确认是不是"黑屏"而不是"没窗口":`tasklist` 里有 `ClassHelper.exe` 但没有可见窗口时,
是另一回事(多为 `--hidden` / 托盘);有窗口且全黑才是缺文件。
2. **别信"我看了一眼目录",要按文件清单比对**:用 7-Zip 直接列安装包里的条目
(`7za l <setup.exe>`,bin 在 `.cache/electron-builder/7zip@*/bin/7za.exe`),
与安装目录 `find <dir> -type f` 的结果做差集。本次差集正好是上面那 5 个文件。
3. 用 `sha256sum <安装目录>/ClassHelper.exe <.cache 里解出来的>/ClassHelper.exe` 确认
装的就是当前构建(本次一致),排除"装的是旧版"。
4. 想复现/验证修复,直接把这 5 个文件从安装包载荷补回安装目录即可(`7za x` 解出来就是)。
**别拿便携版的 `%TEMP%` 解包目录当对照** —— 它是自解压出来的,文件数与安装版可能不同,
但只要两边的 `resources/app.asar` 哈希一致,就说明差的是 Electron 运行时文件而不是业务代码。
为什么值得单独记一条:这次的第一嫌疑(完整性标签、ACL、Chromium 沙箱、`--no-sandbox`、
用户配置)**全部试过且全部无关**,真正的原因是安装目录少文件;而没有上面这套比对方法,
很容易一路查到 `dist-server.mjs`、`electron-builder.yml` 甚至怀疑产物本身。
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 与真机上永远复现不了**,只在交叉构建时现形。
还有一条**两边都会踩**的:**交付目录必须自己 `mkdir`,别指望谁来顺带创建它。**
`releases/server/<版本>/linux-x64/` 曾经是 staging(`linux-x64/classhelper-server-linux-x64/`)的父目录,
2026-10-11 把 staging 挪进 `.cache/server-staging/` 之后它就没有创建者了,`tar -czf <那里>/…`
直接报 `tar (child): …tar.gz: Cannot open: No such file or directory`、退出码 2 ——
**Windows 侧踩不到**(`buildInstaller()` 里有 `mkdirSync(INSTALLER_DIR)`),本地 `pnpm dist:server`
永远绿的,只有 CI 上那一次 `pnpm dist:server:linux` 才会红(v1.3.0 实测,run 38109984623)。
现在 `packLinuxTarball()` 开头显式建目录,别再把这句删掉。
⑤ **密码永远走 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 仍属尽力而为。
⑦ **拼下载地址时,tag 必须补 `v` 前缀 —— `--version` 的用法是不带 v 的。**
Release 的 tag 是 `v1.3.0`,而安装/升级参数一律写成 `--version 1.3.0`;GitHub 的
`/releases/download/<tag>/` 是**按 tag 精确匹配**的(`gh api .../releases/tags/1.3.0` 直接 404),
所以中间少一步归一化就会拼出 `.../download/1.3.0/...` → **curl 22 / HTTP 404**。
两处拼地址的地方**必须都写** `tag="v${tag#v}"`:`classhelper` 的 `resolve_download()` 一直有这句,
而 `install.sh` 的 `resolve_package_url()` 2026-10-11 之前是直接 `local tag="$VERSION"` ——
于是同一个版本,`classhelper upgrade --version 1.3.0` 正常、`install.sh --version 1.3.0` 却 404,
只在 `deploy/` 内部对不齐,光看代码很难注意到。新增拼地址的地方照抄这句,别各写一套。
(注意:`VERSION` 本身**保持不带 v** —— `tag` 是另一个局部变量,装完写进安装目录的 VERSION 文件
仍是不带 v 的裸版本号,别把它一起改了。)
两条写这类脚本时的通用坑(本仓库踩过):**`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 那两档是**放大**,会略软 —— 那是源图分辨率的上限,要更清晰只能换更大的设计导出。
---
63. **实时推送只走房间,而且没有"学生房间"。** 服务端 `src/realtime/`(`SOCKET_ROOMS`:
`class:{classId}`、**`session:{id}`**、`teacher:{id}`、`role:{role}`、`students`、`teachers`;
`bus.ts` 事件总线),客户端 `renderer/stores/realtime.ts` 订阅。
`session:{id}` 里的 id 是**会话主体**:教师/管理员是自己的账号 id,
ClassHelper 班级端是**班级 id**(见 `socket.ts` 的握手逻辑)—— 「叫人」这类定向消息就发到它。
**2026-10-06 起把 `user:{id}` 改名成了 `session:{id}` 并且不再有学生房间**:学生不是账号,
没有任何客户端会订阅它。**新增实时事件要同时改三处**:服务端 bus/socket、Web 端订阅、
客户端订阅(灵动岛也依赖它)。事件名一律取自共享常量 `SOCKET_EVENTS`,不要手写字符串。
64. **ClassHelper 班级端主体是「班级」,`classId` 即身份。** 班级码 + 班级密码登录后角色是
`CLASS_DEVICE`,该设备的「作业完成 / 通知已读」由 `lib/session.ts` 的 `resolvePersonalIds()`
展开成**全班学生 id** 后再写(所以教师端的"完成人数 / 已读人数"统计依然准确)。
写 `createdBy` 之类的字段前要把它换成该班班主任 —— 班级端的 `sub` 是**班级 id**,
直接写会撞 `User` 外键(见 §5 第 20 条「教室机器能录入作业」)。
班级端的成绩查询受 `Class.studentGradeQueryEnabled` 这个**按班开关**控制(默认开启)。
65. **课表同步的主方向是「服务端 → 教室」,教室不再自动回传。**
`IntegrationDevice.mirrorScheduleToClassIsland` 默认 **true**(服务端改完课表自动下发),
`syncScheduleToServer` 默认 **false**。要取教室的课表走
`POST /integrations/devices/:id/request-schedule` —— 它只置一个待办
(`requestScheduleReport`),插件在下一次心跳看到就把课表推上来并清除标记。
Web 端点击前**必须弹警告**(会覆盖 / 合并本班课表)。
插件侧对应 `ReportSettingsDto.scheduleRequested` + `ReportAsync(..., forceSchedule: true)`,
且补发发生在**镜像之前**(顺序反了会把刚拿到的课表盖掉)——
`verify:classisland-plugin` 有 6 条护栏盯着这段契约,别删。
66. **学期周次可以逐周配,别再用"开学日期 + 每周七天"硬推。**
`TermWeek`(`classId = ''` 表示全校默认)存每一周的 `startDate`/`endDate`;
`lib/term.ts` 的 `loadTermContext(classId)` 负责把"班级覆盖 → 全校默认 → 纯线性"三级回落算出来,
`resolveCurrentWeek(now, context)` 优先按区间判定。
**调休 / 周末补课 / 错峰开学会让线性推算整体错位一周以上**,而课表、作业、成绩都按周次组织。
改周次(`PUT /api/term`、`POST /api/term/auto`)之后要 `notifyScheduleChanged()`,
让变更随课表下发给教室的 ClassHelper 班级端。
联网拉取调休建议(`GET /api/term/holidays`)**只给建议、不改数据**,且
**机房无外网时 `ok:false` 是正常结果**,失败结果同样进缓存(否则断网点一次要白等一个超时)。
67. **功能级准入看「权限键」,角色只是默认模板(2026-10 权限组重构)。**
路由上挂 `requirePermission('…')`(AND)/ `requireAnyPermission(…)`,键目录在
`packages/shared/src/permissions.ts`(`PERMISSION_KEYS` 39 键 / `PERMISSION_LABELS` /
`PERMISSION_CATEGORIES` / `PERMISSION_DEFAULT_ROLES` / `ROLE_DEFAULT_PERMISSIONS`)。
有效权限由 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()` 算:
ADMIN=全集且 `permission:manage`/`system:manage` 不可剥夺;CLASS_DEVICE=空集(不受分组影响);
有分组=并集(**可以比角色默认更少**,这就是"能收权");无分组=角色默认模板。
它**每请求实时查库**(不缓存),所以改完分组立刻生效。
**不可松的边界**:权限组只管功能/路由级;班级维度的科目规则(`assertCanManageSubjectContent`)
故意不可被权限组覆盖,否则「班主任布置别人科目作业」的老 bug 会从新路长回来。
Web 端门控是「`meta.roles` 粗筛 + `meta.permission` 细筛」(`beforeEach` 里
`await auth.ensurePermissions()` 再 `auth.can(key)`,不通过 fail-closed),
权限矩阵与生效规则见 `docs/dev/permissions.md`。
68. **客户端设备身份是 `deviceKey`,它不接受外部改写。** 客户端配置里的 `deviceKey` 是首次启动生成的
UUID,服务端按它 upsert `ClientDevice`;`saveConfig()` **拒绝**渲染进程改写它(改了就等于换一台机器),
退出登录走 `clearConfig()` 时也**保留**它(连同 `autoLaunch`/`autoUpdate`/`developerMode`/`careMode`
与待安装的更新记录)。上报走「客户端定时拉」而不是服务端推:
`POST /api/clients/heartbeat`(30 秒一次,首报延迟 3 秒),响应里带回「目标版本 + 一条待执行命令」;
在线判定窗口 `CLIENT_ONLINE_WINDOW_MS`=90 秒。三条边界:**没有 token 就不上报**(未登录静默跳过)、
**401/403 退避 5 分钟**、**冒烟默认不上报**(要验这条链路才设 `ELECTRON_SMOKE_CLIENT_AGENT=1`)。
69. **远控命令要两端各确认一次,而且客户端**必须**回执。** 服务端 `POST /api/clients/commands` 只对
`remote:control`(默认仅 ADMIN)开放,动作限 `shutdown`/`restart`/`logoff`/`lock`,有效期
`CLIENT_COMMAND_EXPIRE_MS`=10 分钟;客户端收到后弹**默认选中「取消」**的对话框(60 秒未确认按取消处理),
执行只支持 Windows(`shutdown.exe /s|/r /t 0`、`/l`、`rundll32 user32.dll,LockWorkStation`),
然后 `POST /api/clients/commands/:id/ack`(body 带 `deviceKey`,服务端据此校验归属,重复回执幂等)。
同一台机器上**同一条命令只弹一次**(内存去重),回执失败只暂存重试、不重复弹窗。
70. **客户端安装包由服务端下载并缓存,教室机不直连 GitHub。** 原因有两条:Web 端 CSP
(`connect-src 'self' ws wss …`)不允许浏览器直连 GitHub,而教室机常常根本没有外网。
所以 `POST /api/update/client/fetch` 由**服务端**下载到 `<serverRoot>/../data/client-releases/<版本>/`
并登记 sha256+size,`GET /api/update/client/download/:version` **故意不挂权限键**(班级端要能下载)。
版本号一律先用 `^[0-9A-Za-z._-]+$` 校验(防路径穿越)。客户端自动安装的三条护栏:
**没有 sha256 就拒收**、**必须等到"非上课 + 主窗口隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)"**、
冒烟模式**永不安装**。
71. **通知与作业一样按「所属日期」归档,批量发布在服务端做。** `Notification.assignDate` 与
`Homework.assignDate` 同口径(`dayKeyLocal()`,禁止 UTC 切片);列表支持 `date`/`from`/`to`/`createdBy`,
另有 `GET /notifications/days` 与 `/notifications/creators` 供"按天/按发布人归档"的界面用。
批量走 `POST /notifications/bulk` 与 `POST /homeworks/bulk`:**单班失败不影响整批**
(逐班返回 `BulkPublishClassResult`),作业的 `subjectName` 跨班**按名匹配**已有课程,
**找不到就整班记失败,绝不自动建课**(自动建课会把创建者写成任课老师,等于越权改任教关系)。
72. **大模型助手只解析、不落库。** `POST /api/ai/interpret` 返回的是**待确认**的结构化动作
(`create_homework` / `create_notification` / `call_student` / `unknown` + `params` + `preview`),
真正落库由前端确认后调**既有**端点 —— 于是权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用。
模型给的 `params` **必须用与手动入口同一份 zod 再校验一次**(模型输出不可信)。
API Key 用 `packages/server/src/lib/secrets.ts` 的 AES-256-GCM(密钥由 `JWT_SECRET` 经 scrypt 派生)
加密后存 `AppSetting`,**只进不出**(GET 只回 `hasApiKey` 与尾 4 位)。
`provider='mock'` 是离线确定性解析,**自动化验收一律用它**(§5 第 52 条:不许访问外网);
每次解析写一条 `AiAudit`(不含密钥)。
73. **开发者模式是服务端的一个内存缓存开关。** 真值存在 `AppSetting`(键 `system.developerMode`),
但热路径(错误中间件)不可能每请求查库,所以 `packages/server/src/lib/settings.ts` 用
`isDeveloperMode()` / `refreshDeveloperMode()` 缓存它:**启动时刷一次、管理员改设置时更新**。
开启后只多做一件事:500 的 `ApiError` 在 `details` 里附 `error.stack`(见 `middleware/error.ts`)。
`GET /api/system/diagnostics` **绝不回传任何密钥**。开关与诊断接口都要求 `system:manage`。
74. **开机自启动必须"可选择 + 运行期可改"。** 安装器只负责**默认值**:
Windows 在 `scripts/nsis/server-installer.nsi` 里是**组件页上一个独立的 Section**(默认勾选),
Linux 在 `deploy/install.sh`;装完之后一律走 `GET/PUT /api/system/autostart`
(Windows 改 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClassHelperServer`,
Linux `systemctl enable/disable classhelper`)。**非安装形态(开发机 / 免安装目录 / 容器)必须返回
`supported:false` 并说明原因** —— 别往开发机的注册表里写自启项。
75. **服务端菜单回缩与关怀模式:折叠只用 CSS,放大只动字号。** 侧栏折叠**不要用 `el-menu` 的
`:collapse`**(它会重建 DOM,与桌面客户端侧栏踩过的坑同源),而是 CSS 收拢宽度 + 折叠态文字
「既无宽度又不透明」;覆盖 Element Plus 缩进时要**照抄它自己的选择器形状**(0-5-0/0-6-0),
写低特异度的选择器会静默失效。关怀模式(`html.care-mode` + `--ch-font-scale`)只放大**正文字号与控件**,
**不动任何几何令牌**(圆角/阴影/侧栏宽),否则桌面客户端侧栏导轨的折叠几何断言会红。
76. **客户端上线补弹离线期间收到的消息只做一次。** 起点(`localStorage` 的
`classhelper.offlineSince`)**只在"可达 → 不可达"的边界写**(不在每次失败上覆盖,否则窗口被无限推迟),
恢复时按起点筛未读通知、取**最近 5 条**并按时间升序推给灵动岛,推完清起点(同一次离线只弹一次)。
**冒烟模式直接跳过**:冒烟会反复断开/恢复网络,补弹会与注入的合成通知互相打乱队列,
把既有的条数/顺序/列表高度断言变成随机红。
77. **客户端崩溃自愈只在"没在上课、没人看着"时才重启进程。** `render-process-gone` 与
`unresponsive`(超 10 秒)先**静默重建主窗口**(10 分钟内最多 3 次),
`uncaughtException`/`unhandledRejection` 也只在 `isIdle()`(非上课 + 无可见窗口)时才
`app.relaunch()`;**冒烟模式绝不重启**(会杀掉验证实例)。
自愈模块在 `packages/desktop-client/src/main/crash-guard.ts`,限流与 `isIdle` 判据别放宽。
78. **课表同步只有两个「一次性动作」,没有"一直同步"的开关。**(2026-10-11 定稿)
教室 → 服务端走 `IntegrationDevice.requestScheduleReport`(Web 端「获取课表」),
服务端 → 教室走新增的 `requestMirrorSchedule`(「下发课表」)—— 两者都是**待办标记**:
Web 端置位,插件在下一次心跳看到就做一次,做完由服务端清标记。
插件侧的 `UploadSchedule` / `MirrorSchedule` 两个本机开关**已删除**:定时上报现在只报
**运行状态**(当前上什么课),课表只在 `forceSchedule` 时才上传。
**为什么改**:原先默认 `ReportIntervalSeconds = 15` + 两个开关都开,等于教室课表每 15 秒被重写一次、
服务端每 15 秒 merge + 清理一次幽灵行 —— 老师看到的是"课表被反复删改"(线上实测)。
配套:`pullClassPlanForDevice` 拉取成功即清 `requestMirrorSchedule`;插件的
`ClassPlanWriter.Apply(…, force: true)` 会**停用**同一天同一单双周相位下冲突的旧课表
(只停用不删除,别改成删除)。契约锁在 `verify:classisland-plugin` 的两条断言里。
79. **「客户端」页 = 「ClassIsland 联动」+「客户端设备」合并成的一页。**(2026-10-11)
路由 `/clients`(`ClientHubView.vue`):一个班一张卡片,两条链路的状态放一起,
勾选可批量下发课表 / 更新,点卡片进详情。入口权限用 **`classisland:notify`**
(默认 ADMIN + TEACHER)而不是 `client:manage`(只给 ADMIN)—— 这一页同时承载
「向教室下发提醒」,老师要用;用后者会把教师整个挡在门外。
页内按钮再各自按 `device:manage` / `remote:control` / `client:update-push` 显示或禁用。
两个旧页面**还留着路由但已从侧栏摘掉**(`meta.hidden: true`,`AdminLayout` 的 `menuItems` 是手写列表):
`IntegrationsView` 留提醒下发与联动步骤,`ClientsView` 留客户端安装包缓存管理
(不先把包拉下来就没有版本可下发),从详情抽屉里的两条链接进。
**别把这两页删掉或把它们的路由一起摘了**,会丢功能。
80. **考试成绩的"总分"是考试的属性,不是分数的属性。** `Exam`(班级 + 考试名 + `countInTotal`)
单独一张表,与 `Grade` 只按名字弱关联(不建外键:成绩删光设置还在,补录时口径不丢)。
排名口径一律写在服务端 `modules/grades` 的 `getExamRanking`:**同一学生同一科目只取最高分**
(老师补录 / 重考会产生多行,不去重等于"总分多一科")、并列同名次(1、2、2、4)。
客户端「成绩」页的排名与"这次算不算总分"都只是**显示**,两个开关都不在客户端。
路由 `/grades/exams`(GET/PUT)与 `/grades/ranking`(GET)**故意不挂权限键** ——
班级端的有效权限是空集,挂 `grade:read` 会把教室机器挡在门外;边界在 service 里
(`assertClassAccess` + `canQueryStudentGrades`),与按学号查明细同一条。
81. **成绩查询开关(`Class.studentGradeQueryEnabled`)只有教师 / 管理员能改。**
原先班级端自己也能改(`updateStudentGradeQuery` 里的 `isClassDevice` 分支),
等于把"学校说不给看"变成一台机器说了算;现在班级端只读(客户端那个开关也撤掉了,
只显示状态)。**注意别拿"通知显示位置(`Class.notificationChannel`)也是班级端自己改"来类比** ——
那个确实是现场偏好,两者不再是一套模式。
82. **全校学期开始日期是 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改。**
原先这一级只能来自 `.env` 的 `TERM_START_DATE`,填错了得动文件 + 重启。
现在 `loadTermContext()` 的三级回落是「`Class.termStartDate` → 全校设置 → `.env`」,
逐周区间仍是「班级 → 全校默认 → 纯线性」。改完 `resolveCurrentWeek` 立刻按新日期算。
## 6. 代码风格
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
(写 `./foo.js`,即使源文件是 `foo.ts`)。`strict` 开启。
- **Prettier**(`.prettierrc.json`):单引号、`printWidth: 110`、`trailingComma: all`、`arrowParens: always`、
行尾 LF、缩进 2 空格。
- **ESLint**(`eslint.config.mjs`):`no-explicit-any` 为 warn;未使用变量为 warn,可用 `_` 前缀豁免;
`consistent-type-imports` 建议用 `import type`。`.cjs`(Electron 图标渲染、UI 冒烟)允许 `require`。
- **Vue**:视图命名 `XxxView.vue`,组件多单词;路由入口用 `meta.roles` 控制(如 `['ADMIN']`),
隐藏入口之外服务端也必须拦(前端隐藏 ≠ 权限)。
- **提交信息**: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 监听**。
新增图表请沿用这个做法,不要重新引入图表库。
- **改动交付形态 / 新增功能后**,同步更新 `docs/reference.md`(完整参考 = 产品说明书 + 验收对照表)。
仓库根的 `README.md` 是**展示页**(门面:简介、功能清单、截图、怎么装),不承载细节。改动交付形态、
新增功能、或**重采了展示图**之后,两边都要看一眼 —— 不要在 README 里堆实现细节。
---
## 7. 本机环境已知坑(踩过,别再踩)
1. **系统代理会拦截 `127.0.0.1` 的自测请求。**
`Invoke-WebRequest http://127.0.0.1:4000/api/health` 会返回**别人的 404**(响应带 `Alt-Svc: h3=…`、空 body),
看起来像后端路由没挂上。实际是走了代理。自测请加 `-NoProxy`(`curl.exe` 用 `--noproxy '*'`)。
项目内的 Node 脚本(`verify:*`)直连,不受影响。
**同一个坑对 ClassIsland 插件同样成立**:.NET 的 `HttpClient` 默认跟随系统代理,
教室机器上表现为"填好令牌后立刻 HTTP 404"(其实是代理的错误页)。插件里
`ClassHelperClient` 用 `LocalAwareProxy` 对本地/内网地址强制直连,
`verify:classisland-plugin` 有 4 项断言盯着这段逻辑,别删。
2. **`db:deploy` 只在 `packages/server` 包里,根 `package.json` 没有这个脚本。**
根上跑 `pnpm db:deploy` 会报 `Command "db:deploy" not found`(还会附带一句莫名其妙的
`文件名、目录名或卷标语法不正确。`)。正确写法:`pnpm --filter @classhelper/server db:deploy`。
根上只有 `db:generate / db:migrate / db:seed / db:reset / db:studio`。
3. **`pnpm db:migrate`(= `prisma migrate dev`)在本机不可靠**:首次报 `Schema engine error:`(空消息),
数据库建好后再跑会**长时间挂起**(无输出,需手动结束进程)。日常用 `db:deploy`;
要写种子直接 `pnpm db:seed`,或 `node ./node_modules/tsx/dist/cli.mjs prisma/seed.ts`(cwd 为 `packages/server`)。
4. **先 `pnpm build:shared`,否则后面全崩**:`packages/shared/dist` 不存在时,`pnpm db:seed` 与任何
消费 `@classhelper/shared` 的进程都会 `ERR_MODULE_NOT_FOUND`(报错指向
`packages/server/node_modules/@classhelper/shared/dist/index.js`)。`pnpm dev*` / `build` / `typecheck`
脚本已内联 `build:shared`,单独跑 `db:seed` 时记得自己先构建。
5. **Electron 二进制要手动装**:`pnpm install` 实测**不会**下载它(`allowBuilds` 里虽然写了 `electron: true`,
但 postinstall 没跑;日志里只看到 esbuild / prisma)。缺失时 `pnpm dev:desktop` / `verify:desktop` /
`verify:web` / `dist:win` 全部失败。装法(本机已装好):
```bash
cd node_modules/.pnpm/electron@44.3.0/node_modules/electron
ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ NODE_OPTIONS=--use-system-ca node install.js
```
6. **"AI 会话里启动 Electron GUI 会崩"是一个误诊 —— 真凶是下一条的 Low 完整性标签。**
2026-09-30 实测:把 Electron 发行目录的标签从 Low 改成 Medium 后,同一个 `electron.exe`
在 AI 会话里**正常启动**(窗口标题 `Electron`、userData 目录正常创建),
`pnpm verify:packaged` 也能完整跑完并出结果(86/90、77/78)。
⇒ `verify:desktop` / `verify:web` / `verify:packaged` / `dist:win` 在 AI 会话里**都能跑**;
一旦它们又"启动即崩",先按第 7 条查完整性标签,别急着甩锅给"会话环境"。
7. **交付物被打了 Low 完整性标签 ⇒ 客户端装不上、便捷版双击没反应、应用 0x80000003 秒崩。**
症状极易误判:`electron.exe --version` 都不打印就退出(0x80000003 STATUS_BREAKPOINT),
加 `--no-sandbox` 变成 0xC0000005,任何日志都写不出来(崩在日志初始化之前),
但 `ELECTRON_RUN_AS_NODE=1` 跑同一个 exe 完全正常、Edge 等系统 Chromium 也正常。
根因:**本仓库目录树被宿主沙箱打上了 `Mandatory Label\Low Mandatory Level:(OI)(CI)`(可继承)**,
于是仓库内构建出的一切都继承 Low 标签。后果是双重的:
- Windows 会把带 Low 标签的 exe **降级到 Low 完整性运行**:`setup.exe` 写不进 `%LOCALAPPDATA%`
(报"拒绝访问"→ 用户看到"装不上")、便捷版解不出 `%TEMP%`(→ 双击没反应);
- 即便装上了,落在 Low 标签目录里的应用文件也**读不了** —— Chromium 的 GPU/渲染子进程跑在
AppContainer(更低完整性)里,按"不能向上读"直接秒崩。
判据与修法(都是实测过的):
```bash
# 判据:看交付物(以及 node_modules/electron/dist)有没有 Low 标签。
# **正常状态是"没有 Mandatory Label 这一行"**(= 默认 Medium);出现
# `Mandatory Label\Low Mandatory Level` 才是坏的那个。所以别去找"是不是 Medium"。
# 目标就是下面这两个 exe —— 中间物 win-unpacked 打完就删了(§4「交付产物归集」)。
icacls "releases\client\<版本>\安装包\ClassHelper-<版本>-x64-setup.exe" | findstr Mandatory
icacls "releases\client\<版本>\安装包\ClassHelper-<版本>-x64-portable.exe" | findstr Mandatory
# 修法:把交付物改成 Medium。dist-win.mjs 收尾会自动做,手工补救用:
icacls "releases\client\<版本>" /setintegritylevel Medium /T /C
# 目录自身要还原成继承,否则下次构建时自带 Low 标签的 7za / makensis 写不进去
#(这一步 dist-win.mjs 现在在**打包前**就做了,见 §4)
icacls "releases\client\<版本>" /setintegritylevel "(OI)(CI)Low"
```
连带的三条经验:**归档内部的载荷标签无所谓**(装到仓库外得到的是默认 Medium 文件),
所以修标签必须在**打包完成之后**做,打包前改 appOutDir 反而会让 7za 读不到而失败;
**别把安装测试装进仓库树**(`install-test` 之类),那里的文件又会继承 Low;
**覆盖已存在的产物会被沙箱 ACL 拒绝**(`Can't open output file` / `拒绝访问`),
`dist-win.mjs` 因此会先清掉上一轮的 `*.exe / *.7z / *.blockmap` 再打包。
8. **.NET 8 SDK 已装(8.0.425)**,`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin` 都能跑
(2026-09-30 实测:`-t:Rebuild` 全量编译 8.8s,**1 个警告 0 个错误**;`.cipx` 打包成功,
包内 DLL 的 sha256 与 `bin/Release` 产物一致)。脚本会自动把
`NUGET_PACKAGES / TEMP / TMP / DOTNET_CLI_HOME` 指到仓库内 `.cache/`(`dotnet` 默认往 C 盘写,
C 盘紧张时会构建失败);手敲 `dotnet build` 时请自己带上这几个变量。
两个容易自我欺骗的地方:
- **光看输出会误判"编译通过"**:源码没变时 `dotnet build` 是**空转**(打印
`所有项目均是最新的,无法还原` + `已成功生成 / 0 警告 0 错误`),`CoreCompile` 根本没执行 ——
那行 `ClassHelper.ClassIslandPlugin -> …dll` 是拷贝回执,不是编译证据。
要证明"真的编译过",用 `node packages/classisland-plugin/scripts/build.mjs -t:Rebuild -v:n`
看有没有 `csc.dll` 调用,并核对 DLL 的 mtime 晚于所有 `.cs`。
- **那 1 个警告是预期的,别去"修"**:`AVLN3001: XAML resource
"…/BridgeSettingsPage.axaml" won't be reachable via runtime loader, as no public constructor was found`
—— 设置页只有 `BridgeSettingsPage(PluginSettings, BridgeService)` 这个 DI 构造函数、
没有公开无参构造,Avalonia 于是警告它无法走**运行时**加载器;本插件走的是编译期 XAML +
ClassIsland 的 DI 实例化,加个无参构造反而会把 DI 弄坏。
9. **`.cipx` 打包最后一步要 PowerShell**:SDK 的 `CreateCipx` 目标默认调 `pwsh`,
本机 PATH 里没有 pwsh 时会失败。构建脚本会探测并把结果透传给 MSBuild
(`-p:PowershellBinaryName=…`),直接跑 `dotnet build -p:CreateCipx=true` 则要自己加。
10. **仓库根 `.gitignore` 的第 42 行是 GBK 编码**(整文件 CRLF,只有那一行是 GBK 注释),
`apply_patch` 与 Prettier 都读不了它,按 UTF-8 读会看到 `\uFFFD`。要补忽略规则**只能用追加的方式**
(`printf 'xxx\n' >> .gitignore`,原有字节一个都别动),别试图整文件重写;临时性的可写
`.git/info/exclude`(本地生效、不入库)。
(`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore。)
11. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后,
这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error
(`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`);
- 插件配置在 `D:\Classisland\data\Config\Plugins\<id>\Settings.json`,**ClassIsland 运行中改会被内存里的
副本随时回写覆盖**,要改请趁它退出时改;该文件里存着这台机器的服务器地址与设备令牌,**别覆盖**;
- 改完 DLL 或配置都要重启 ClassIsland,否则跑的还是旧副本。
13. **读 ClassIsland 控制台日志要用 GBK**:`.NET` 的控制台输出是**系统 ANSI(GBK)**编码,
用 `-Encoding UTF8` 读会变成乱码、**中文关键字一条都搜不到**(只能搜到 ASCII 的类名)。
正确姿势:`[System.IO.File]::ReadAllLines($path, [System.Text.Encoding]::GetEncoding(936))`;
日志文件被运行中的进程占用时先 `Copy-Item` 一份再读。
14. **服务端改了代码要重启才生效**(`tsx src/index.ts` 不热载,`pnpm dev:server` 才有 watch)。
验证脚本报「某个断言突然不对」时,先确认后端跑的是不是最新代码 —— 这个坑踩过一次。
15. **后端启动时才发现 Web 产物**:`packages/web-admin/dist` 不存在就跳过静态托管,`http://127.0.0.1:4000/`
只会返回服务首页(文案「Web 管理端未随本服务托管」)。要跑 `verify:web`,先 `pnpm build` 再重启后端。
16. **打包版服务端有生产自检**:`NODE_ENV=production` 时拒绝 `dev.db`("生产环境仍在使用 dev.db")、
拒绝短 `JWT_SECRET` 与示例密钥;想用演示数据跑打包版就把库**复制**成 `data/classhelper.db` 再启动。
17. **makensis 建不了临时文件时,NSIS 那一步会失败(`dist:server` 与 `dist:win` 都会踩)。** 两种根因、
同一个排查动作 —— 先把 `TEMP` 指到仓库内一个**真实存在的 Windows 绝对路径**再重试:
- `pnpm dist:server` 报 `Internal compiler error #12345: error creating mmap the size of N`:模板用了
`SetCompressor /SOLID lzma`,makensis 会在 **`%TEMP%` 所在的盘**建一个上百 MB 的 mmap 临时文件,盘紧就失败
(组装出来的运行时其实已经落在 `.cache/server-staging/<版本>/win/`,只是最后一步打不出安装包)。
加 `--reuse-deps` 还能跳过依赖安装。
- `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` 与 `*.nsis.7z` 此时都已生成(整理进 `安装包/`、删 `win-unpacked/` 的那一步在打包
成功之后才跑,失败时还没执行),**只需重跑,不必重建前端**:
```bash
mkdir -p .cache/tmp
TEMP='D:\classhelper\.cache\tmp' TMP='D:\classhelper\.cache\tmp' \
node packages/desktop-client/scripts/dist-win.mjs
```
18. **依赖二进制下载需要 `NODE_OPTIONS=--use-system-ca`**(本机 TLS 中间人证书),否则报
`unable to verify the first certificate`。
19. **Prisma 版本锁死 7.10.0**:npm `latest` 已指向 8.x;生成器是 `prisma-client`,输出到
`src/generated/prisma`(gitignored),连接串在 `prisma.config.ts`,并且**必须走 driver adapter**
(`@prisma/adapter-libsql` + 预编译的 `@libsql/win32-x64-msvc`,避开 electron ABI 的原生编译坑)。
20. **服务端安装包里的 `.cmd` 提示是英文**:cmd.exe 按 GBK 解析脚本,中文会乱码,属有意为之
(中文在安装向导与 `README.txt`)。
21. **`pnpm format:check` 在 master 基线上本来就是失败的**(含 `tsconfig.base.json` 与 `scripts/*`),
不要拿它当门禁、也不要为了让它变绿去批量格式化。只检查你改过的文件:
`pnpm exec prettier --check <file>`。
**注意它会因为 CRLF 而"全面误报"**:`core.autocrlf=true` 让 git 里存 LF、工作区签出成 CRLF,
而 Prettier 配的是 `endOfLine: lf`,于是几乎每个文件都被标红。要判断是不是**你的**代码不合风格,
用 `sed 's/\r$//' <file> | pnpm exec prettier --check --stdin-filepath <file>`。
22. **写冒烟断言时别用"元素还在不在"判断 Element Plus 的弹框关没关。** `el-dialog` 关闭后
**DOM 会留在文档里**(Element Plus 只把 `.el-overlay` 置成 `display: none`),
`document.querySelector('.el-dialog')` 恒为真 —— `verify:desktop` / `verify:packaged` 里
「设置页『修改密码』对话框取消后未关闭」就是这么误报出来的。正确判据:
`getComputedStyle(dialog.closest('.el-overlay')).display !== 'none'`。
同理,断言岛体尺寸别写死"胶囊尺寸 + 留白":那一刻是胶囊还是卡片取决于前面用例留下的状态,
应改成**与形态无关的不变量**(如"窗口 = 卡片实测尺寸 + 2×阴影留白")。
23. **客户端安装包有硬底:`ClassHelper.exe` 单独压缩后就有 85~90MB,"压到 60MB"做不到 —— 别再为这个反复折腾。**
实测(2026-10-05,Electron 44.3.0):`win-unpacked` 372MB,其中 `ClassHelper.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` 的整套回归要重做。属于独立立项,不是顺手优化。
### 已知脆弱用例(不是环境问题,别去"修环境")
| 用例 | 触发条件 |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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 被限流、形变采样抓不到帧,属时序敏感;**先原样重跑一次再动手改代码**,别把偶发当成回归 |
---
## 8. 交付前检查
```bash
pnpm lint # ESLint(0 error 为底线)
pnpm typecheck # 全仓库类型检查
pnpm dev:server # 另开终端
pnpm verify:e2e # 后端端到端(2026-10-06 实测 205 项全过)
pnpm verify:classisland # ClassIsland 联动链路(实测 44 项)
pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 74 项)
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
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 条部署):
```powershell
# 1) 退出 ClassIsland(DLL 被占用时无法覆盖)→ 2) 覆盖插件 → 3) 重新启动
Get-Process ClassIsland.Desktop | Stop-Process -Force
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
# 4) 在 Web 端「ClassIsland 联动」发一条提醒,看日志里依次出现:
# 取到待弹出提醒 → 已弹出提醒 →(播完)已确认提醒
```
交付时要说明"编译 / 打包 / 静态契约已过,运行期需在装了 ClassIsland 的机器上实测"。
改动涉及打包/交付形态时,额外跑 `pnpm dist:all`(或 `dist:server` + `dist:win`),
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
开发产物通过不等于用户机器上的副本通过。
**改过 `pruneRuntime()`(§5 第 55 条)时,还要对裁剪后的产物本身跑一遍**(开发形态跑通不代表裁剪后跑通)。
产物有两处现成的,**都带裁剪**:装好的服务端 `%LOCALAPPDATA%\Programs\ClassHelperServer`,
或打包过程中的组装目录 `.cache/server-staging/<版本>/win/`(`dist-server.mjs` 在成功收尾时会删掉它,
所以要么在打包失败时留用、要么临时注掉那一步 `fs.rmSync(staging, ...)`)。
从那一层起,用**自带的 node.exe**:
```bash
# ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口)
cd %LOCALAPPDATA%\Programs\ClassHelperServer && set 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 条)。
---
## 9. 工作区维护(2026-10-11 整理留档)
工作区会随着每次发版慢慢长胖,而且长得**很像正经产物**。这一节记录清理的结论与边界,
下次觉得"盘要满了"直接照这里做,不要临场发挥。
### 这次删了什么、留了什么
| | 内容 | 量 |
| --- | --- | --- |
| 删 | `releases/**/免安装/`(客户端 4 版 + 服务端 4 版 + 插件 4 版) | 2.57 GB |
| 删 | `releases/server/{0.1.0,1.0.0,1.0.0-重构建20261005}`(旧产品名"班级小助手"时期的孤立目录) | 204 MB |
| 删 | `releases/server/{1.1.0,1.1.2}/linux-x64/classhelper-server-linux-x64/`(tar.gz 的解包留档) | 530 MB |
| 删 | `releases/server/1.1.2/安装包/班级小助手服务端-1.1.2-x64-setup.exe`(改名前的重复包) | 62 MB |
| 删 | `.cache/` 里的草稿:smoke 日志与 JSON、`ui-smoke-profile`、`pages`、`tmp`、截图目录、`ci-*.html/css`、`beui-*`、`release-notes-*`、一次性脚本、`dev.db.bak-*` | 276 MB |
| 删 | 根目录 `ClassHelper video.cachedbg3.log`(0 字节)、`.acl-recovery/`(沙箱 ACL 修复的一次性备份) | ~0 |
| 删 | `packages/classisland-plugin/{bin,obj,cipx}`(构建产物,gitignored,重跑即生成) | 1.1 MB |
| **留** | `.cache/{nuget,npm-cache,dotnet-home,mingit}`(工具缓存,按用户要求一个都不删) | 2.4 GB |
合计释放 **约 3.7 GB**,`releases/` 从 4.8 GB 降到 1.4 GB,其余是安装包本体。
### 边界(别删过界)
- **`releases/` 里的安装包本体与 `SHA256SUMS-*.txt` 一律留着**:每个都在 GitHub Release 上有对应资产
(v1.0.0 无 Linux 包,v1.1.0/v1.2.0 有),删了要重新下载才能对齐哈希。
- **`.cache/{nuget,npm-cache,dotnet-home,mingit}` 不删** —— 它们是工具缓存,删了要重新下载;
`dotnet-home` 里还有 docfx(`pnpm docs:build` 依赖)。
- **`packages/*/dist` 与 `packages/server/src/generated` 不删**:删了要重跑构建 + `db:generate`,
而且 `verify:*` 全部要重新准备前置产物。
- **`.cache/dev.db.bak-*` 这类"带日期的备份"删之前想一下**:本次删掉的是 2026-10-06 的
`dev.db` 快照,演示数据可用 `pnpm db:reset` 重建,所以算草稿。别把 `packages/server/prisma/` 下的库当草稿。
- **`.zcode/plans/` 与 `.claude/`** 是历史工具目录,内容已过时但不占空间,留着即可。
### 怎么不重新长回来
产出侧已经改成"只留交付物"(§4「交付产物归集」的硬约定)。另外接手时要清楚:
- **别把 `--reuse-deps` 的复用目录当成产物找回**:服务端的组装中间物现在在
`.cache/server-staging/<版本>/`,打完 `dist-server.mjs` 就会删掉它;想留一份能直接跑的副本,
就在打包过程中拷出来(或直接跑 `pnpm --filter @classhelper/server start`)。
- **别在 `releases/` 下留"临时跑一下"的东西**:需要看解包内容就解压 `-portable.exe` 到 `%TEMP%`。
- 判断某个目录能不能删,就问一句:**"它能不能用仓库里的脚本一条命令重建?"** 能就删。