feat(permissions,clients,ai,system,server,web,client,shared)!: 权限组 + 客户端设备远控与自动更新 + 大模型助手 + 系统诊断(1.3.0)
功能级准入改为「权限键」,`requireRole` 退成默认模板。 权限组 - 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission - 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺; CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板 - 新增「权限组」页:组增删改、成员分配、生效权限预览 - 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖 客户端设备(ClassHelper 桌面端) - 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留) - 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒 - 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、 60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次 - 新增「客户端设备」页:版本一览、一键推送更新、下发远控 客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制) - POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size - 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装 大模型助手:只解析、不落库 - POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人), 真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用 - 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信) - API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出 - provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥) 系统与运维 - 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥) - 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable, **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项 - 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌 客户端 - 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次), 未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例) - 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推 发布内容批量 - POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果 - 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课 (自动建课会把创建者写成任课老师,等于越权改任教关系) - 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。 未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。 服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 / AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
This commit is contained in:
1 parent
e51879667a
commit
817af02357
131 files changed
+12910
-453
No files matched your search
@@ -118,10 +118,13 @@ website/ 产品官网(**纯静态、无构建步骤**,
|
||||
-x64-server-setup.exe),它们是离线兜底链接的唯一依据。
|
||||
packages/shared/src/ types.ts / constants.ts / permissions.ts / motion.ts / utils.ts / index.ts
|
||||
packages/server/
|
||||
prisma/schema.prisma 数据模型(16 个 model,无 enum、无 @db.*;连接串在 prisma.config.ts)
|
||||
prisma/migrations/ 16 个迁移(到 add_schedule_source 为止是旧模型;之后 6 个是
|
||||
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)
|
||||
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 项)
|
||||
@@ -134,10 +137,12 @@ packages/server/
|
||||
password schemas session term version(读 package.json) web-static
|
||||
src/middleware/ auth validate error security
|
||||
src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
|
||||
src/modules/ 17 个功能模块 + registry.ts + module.types.ts
|
||||
(含 archives 毕业归档、term 学期周次)
|
||||
packages/web-admin/src/ api stores router layouts views(14) components(5) composables styles config.ts
|
||||
(含 ArchivesView 毕业归档、TermWeeksView 学期周次)
|
||||
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 本体**)
|
||||
@@ -163,8 +168,8 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
|
||||
|
||||
| 项 | 状态 |
|
||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Node | **v24.19.0**(`D:\nodejs\node.exe`;要求 ≥ 20.19,Prisma 7 硬性要求) |
|
||||
| pnpm | 11.8.0(`packageManager` 锁定 11.8.0),宿主就是上面这个 node |
|
||||
| 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` |
|
||||
@@ -180,6 +185,43 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
|
||||
> **旧文档里的 `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
|
||||
@@ -251,7 +293,7 @@ pnpm build:classisland-plugin
|
||||
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
|
||||
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 44 项) |
|
||||
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 74 项) |
|
||||
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 全部 |
|
||||
| `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「版本迭代」) |
|
||||
@@ -281,20 +323,37 @@ pnpm build:classisland-plugin
|
||||
releases/
|
||||
client/<版本>/
|
||||
安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
||||
免安装/ electron-builder 的 win-unpacked 内容
|
||||
构建中间/ builder-*.yml、*.nsis.7z
|
||||
server/<版本>/
|
||||
安装包/ ClassHelper 服务端-<版本>-x64-setup.exe
|
||||
免安装/ classhelper-server 免安装目录(含 .env、node.exe、data/、logs/)
|
||||
linux-x64/ classhelper-server-linux-x64-<版本>.tar.gz + .sha256 + 同内容解包目录
|
||||
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 跨组件哈希清单(人读,由发布流程维护)
|
||||
```
|
||||
|
||||
**`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/`),别把上一次的覆盖掉。
|
||||
@@ -302,14 +361,20 @@ releases/
|
||||
```bash
|
||||
pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过)
|
||||
pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④)
|
||||
pnpm dist:win # 客户端:安装包 + 免安装目录;dist:dir 只出免安装目录
|
||||
pnpm dist:win # 客户端:安装包 + 便携版
|
||||
pnpm dist:classisland-plugin
|
||||
```
|
||||
|
||||
`dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后
|
||||
再把原始布局整理成上面的分层(`win-unpacked/` → `免安装/`、`*.exe` → `安装包/`、`*.yml` → `构建中间/`);
|
||||
再把原始布局整理成上面的分层(`*.exe` → `安装包/`、`*.yml` → `构建中间/`),**最后删掉 `win-unpacked/`**;
|
||||
显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。
|
||||
**交付物的完整性标签修正(§7 第 7 条)也挂在这条收尾链路上,别把 `tidyArtifacts` 之后的
|
||||
两条别动的顺序约束:
|
||||
- **删 `win-unpacked` 必须排在整理之后**:portable 目标就是拿这份解包结果做自解压包,
|
||||
提前删会让 `-portable.exe` 直接失败。
|
||||
- **输出目录的完整性标签要在打包前压回 Low**:上一轮收尾把它标成 Medium 之后,7za(自身带 Low)
|
||||
读不动那些目录,pack 时报 `cannot open file ... Access is denied`(实测踩过)。
|
||||
|
||||
**交付物的完整性标签修正(§7 第 7 条)挂在收尾链路上,别把 `tidyArtifacts` 之后的
|
||||
`normalizeIntegrityLabel(destDir)` 拆掉。**
|
||||
|
||||
### 版本迭代(每一次 releases,不是每一次提交)
|
||||
@@ -359,7 +424,8 @@ pnpm build && pnpm verify:web
|
||||
|
||||
# ④ 打安装包(产物落在 releases/<组件>/<版本>/)
|
||||
pnpm dist:all
|
||||
pnpm verify:packaged --exe "releases/client/<版本>/免安装/ClassHelper.exe"
|
||||
# 对**打包后的副本**复验:便携版就是自解压单文件,直接拿它当被测副本最省事
|
||||
pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe"
|
||||
|
||||
# ⑤ 整理跨组件哈希清单 releases/SHA256SUMS-<版本>.txt(人读;Linux 那一行由 CI 合并进来)
|
||||
# ⑥ 提交并推上去
|
||||
@@ -446,9 +512,9 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站(docfx 构建
|
||||
## 5. 架构硬约定(改代码前必须知道)
|
||||
|
||||
1. **模块注册表是唯一入口。** 服务端每个功能是 `src/modules/<name>/`(`*.module.ts` 路由、
|
||||
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 17 个:`auth / classes / courses / schedules /
|
||||
`*.schemas.ts` zod 校验、`*.service.ts` 业务)。当前 21 个:`auth / classes / courses / schedules /
|
||||
homeworks / notifications / calls / imports / integrations / grades / students / teachers / archives /
|
||||
term / dashboard / database / update`。
|
||||
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`,不要各写一套。
|
||||
@@ -1149,6 +1215,90 @@ term / dashboard / database / update`。
|
||||
联网拉取调休建议(`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` 判据别放宽。
|
||||
|
||||
## 6. 代码风格
|
||||
|
||||
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
|
||||
@@ -1226,12 +1376,17 @@ term / dashboard / database / update`。
|
||||
判据与修法(都是实测过的):
|
||||
|
||||
```bash
|
||||
# 判据:看交付物(以及 node_modules/electron/dist)有没有 Low 标签
|
||||
icacls packages\desktop-client\release\win-unpacked\ClassHelper.exe | findstr Mandatory
|
||||
# 判据:看交付物(以及 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 packages\desktop-client\release /setintegritylevel Medium /T /C
|
||||
icacls "releases\client\<版本>" /setintegritylevel Medium /T /C
|
||||
# 目录自身要还原成继承,否则下次构建时自带 Low 标签的 7za / makensis 写不进去
|
||||
icacls packages\desktop-client\release /setintegritylevel "(OI)(CI)Low"
|
||||
#(这一步 dist-win.mjs 现在在**打包前**就做了,见 §4)
|
||||
icacls "releases\client\<版本>" /setintegritylevel "(OI)(CI)Low"
|
||||
```
|
||||
|
||||
连带的三条经验:**归档内部的载荷标签无所谓**(装到仓库外得到的是默认 Medium 文件),
|
||||
@@ -1294,11 +1449,12 @@ term / dashboard / database / update`。
|
||||
同一个排查动作 —— 先把 `TEMP` 指到仓库内一个**真实存在的 Windows 绝对路径**再重试:
|
||||
- `pnpm dist:server` 报 `Internal compiler error #12345: error creating mmap the size of N`:模板用了
|
||||
`SetCompressor /SOLID lzma`,makensis 会在 **`%TEMP%` 所在的盘**建一个上百 MB 的 mmap 临时文件,盘紧就失败
|
||||
(免安装目录其实已生成好,只是最后一步打不出安装包)。加 `--reuse-deps` 还能跳过依赖安装。
|
||||
(组装出来的运行时其实已经落在 `.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` 与 `*.nsis.7z` 此时都已生成(整理进 `安装包/`、删 `win-unpacked/` 的那一步在打包
|
||||
成功之后才跑,失败时还没执行),**只需重跑,不必重建前端**:
|
||||
|
||||
```bash
|
||||
@@ -1412,12 +1568,15 @@ Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
|
||||
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
|
||||
开发产物通过不等于用户机器上的副本通过。
|
||||
|
||||
**改过 `pruneRuntime()`(§5 第 55 条)时,还要对免安装目录本身跑一遍**(开发形态跑通不代表裁剪后跑通)。
|
||||
从 `releases/server/<版本>/免安装/` 起,用**自带的 node.exe**:
|
||||
**改过 `pruneRuntime()`(§5 第 55 条)时,还要对裁剪后的产物本身跑一遍**(开发形态跑通不代表裁剪后跑通)。
|
||||
产物有两处现成的,**都带裁剪**:装好的服务端 `%LOCALAPPDATA%\Programs\ClassHelperServer`,
|
||||
或打包过程中的组装目录 `.cache/server-staging/<版本>/win/`(`dist-server.mjs` 在成功收尾时会删掉它,
|
||||
所以要么在打包失败时留用、要么临时注掉那一步 `fs.rmSync(staging, ...)`)。
|
||||
从那一层起,用**自带的 node.exe**:
|
||||
|
||||
```bash
|
||||
# ① 起服务:探针通 + 能真实查表(AUTO_MIGRATE 会建库;PORT 用环境变量避开开发端口)
|
||||
cd releases/server/<版本>/免安装 && PORT=4100 ./node.exe server/dist/index.js
|
||||
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"}'
|
||||
@@ -1437,3 +1596,48 @@ DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \
|
||||
这里的目标是脚本刚建出来的空临时库,不是真实数据。)
|
||||
|
||||
自测本地接口记得绕过代理(见 §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%`。
|
||||
- 判断某个目录能不能删,就问一句:**"它能不能用仓库里的脚本一条命令重建?"** 能就删。
|
||||
|
||||
@@ -23,7 +23,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
target: runtime
|
||||
image: classhelper-server:${APP_VERSION:-1.2.0}
|
||||
image: classhelper-server:${APP_VERSION:-1.3.0}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
|
||||
@@ -60,7 +60,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: deploy/Dockerfile
|
||||
target: runtime
|
||||
image: classhelper-server:${APP_VERSION:-1.2.0}
|
||||
image: classhelper-server:${APP_VERSION:-1.3.0}
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
# --node-tarball <url|路径> 直接用指定的 Node 压缩包(内网离线场景)
|
||||
# --with-nginx 顺带放一份 Nginx 反代配置(不自动申请证书)
|
||||
# --open-firewall 按检测到的防火墙放行端口
|
||||
# --no-autostart 不设开机自启(默认设为自启;装完仍可 systemctl enable 改回来)
|
||||
# --yes 全部用默认值,不再交互(CI / 验收用)
|
||||
#
|
||||
# 幂等:重复执行 = 覆盖安装。已有 config.env(含 JWT_SECRET)与 data/ 一律保留,不重置密钥。
|
||||
@@ -91,6 +92,10 @@ NODE_DIR_NAME="runtime/node"
|
||||
|
||||
WITH_NGINX=0
|
||||
OPEN_FIREWALL=0
|
||||
# 是否设为开机自启(systemd enable)。需求:「支持选择开机自启动」——
|
||||
# 安装器只负责**默认值**,装完之后随时可以用 `systemctl disable/enable classhelper` 改
|
||||
# (Windows 侧对应 Web 管理端「系统设置」里的开关,见 PUT /api/system/autostart)。
|
||||
AUTOSTART=1
|
||||
ASSUME_YES=0
|
||||
CHECK_ONLY=0
|
||||
UNINSTALL=0
|
||||
@@ -231,6 +236,8 @@ parse_args() {
|
||||
--node-tarball) NODE_TARBALL="${2:-}"; NODE_SOURCE="tarball"; shift 2 ;;
|
||||
--with-nginx) WITH_NGINX=1; shift ;;
|
||||
--open-firewall) OPEN_FIREWALL=1; shift ;;
|
||||
--autostart) AUTOSTART=1; shift ;;
|
||||
--no-autostart) AUTOSTART=0; shift ;;
|
||||
--install-docker) INSTALL_DOCKER=1; shift ;;
|
||||
--yes|-y) ASSUME_YES=1; shift ;;
|
||||
--force) FORCE=1; shift ;;
|
||||
@@ -477,6 +484,7 @@ collect_answers() {
|
||||
fi
|
||||
if ask_yes_no "顺带放一份 Nginx 反向代理配置到 ${CONFIG_DIR}/nginx.conf?" "n"; then WITH_NGINX=1; fi
|
||||
if ask_yes_no "按检测到的防火墙放行端口 ${PORT}?" "n"; then OPEN_FIREWALL=1; fi
|
||||
if ask_yes_no "设为开机自启(systemctl enable ${SERVICE_NAME})?" "y"; then AUTOSTART=1; else AUTOSTART=0; fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------- 用户与目录
|
||||
@@ -901,8 +909,12 @@ EOF
|
||||
fi
|
||||
chmod 644 "/etc/systemd/system/${SERVICE_NAME}.service"
|
||||
systemctl daemon-reload
|
||||
if [ "${AUTOSTART}" = "1" ]; then
|
||||
systemctl enable "$SERVICE_NAME" >/dev/null
|
||||
log "已安装 /etc/systemd/system/${SERVICE_NAME}.service 并设为开机自启"
|
||||
else
|
||||
log "已安装 /etc/systemd/system/${SERVICE_NAME}.service(**未**设为开机自启;需要时执行 systemctl enable ${SERVICE_NAME})"
|
||||
fi
|
||||
}
|
||||
|
||||
install_aux_files() {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "classhelper-server-runtime",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
||||
|
||||
+33
-2
@@ -22,6 +22,12 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
|
||||
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) |
|
||||
| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) |
|
||||
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
|
||||
| GET | `/permissions/me` | 登录 | 当前账号的生效权限(**无权限键**;班级端会话返回空集) |
|
||||
| GET | `/permissions/catalog` | `permission:manage` | 权限键目录(权限页整棵勾选树的唯一来源) |
|
||||
| GET / POST | `/permissions/groups` | `permission:manage` | 权限组列表 / 新建 |
|
||||
| PATCH / DELETE | `/permissions/groups/:id` | `permission:manage` | 改名 / 改描述 / 整体替换权限键;内置组不可删 |
|
||||
| GET | `/permissions/users` | `permission:manage` | 成员列表(所属权限组 + 生效权限数量) |
|
||||
| PUT | `/permissions/users/:id/groups` | `permission:manage` | 整体替换某账号的权限组(空数组 = 回落角色默认模板) |
|
||||
| GET | `/classes` | 登录 | 班级列表(按权限收敛) |
|
||||
| GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 班主任 / 各科任课老师) |
|
||||
| POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) |
|
||||
@@ -46,11 +52,15 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
|
||||
| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) |
|
||||
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) |
|
||||
| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 |
|
||||
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) |
|
||||
| POST | `/homeworks/bulk` | `homework:write` | 一次给多个班级布置同一份作业(`subjectName` 跨班按名匹配科目;逐班结果) |
|
||||
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态;另支持 `date` / `from` / `to` / `createdBy`) |
|
||||
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
|
||||
| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发紧急通知需 `confirmDuringClass` |
|
||||
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) |
|
||||
| DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 |
|
||||
| GET | `/notifications/days?classId=&from=&to=&createdBy=` | 登录 | 通知按天归档(日期 + 条数,日期取 `assignDate`) |
|
||||
| GET | `/notifications/creators?classId=&from=&to=&date=` | 登录 | 通知按发布人归档 |
|
||||
| POST | `/notifications/bulk` | `notification:publish` | 一次给多个班级发同一条通知(逐班结果,**单班失败不影响整批**) |
|
||||
| POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) |
|
||||
| GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) |
|
||||
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 |
|
||||
@@ -72,7 +82,7 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
|
||||
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
|
||||
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
|
||||
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
|
||||
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 16 张表行数 / 备份列表 / 定时配置) |
|
||||
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 23 张表行数 / 备份列表 / 定时配置) |
|
||||
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
|
||||
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
|
||||
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
|
||||
@@ -85,6 +95,23 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
|
||||
| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) |
|
||||
| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland |
|
||||
| GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) |
|
||||
| POST | `/clients/heartbeat` | 登录 | 客户端每 30 秒上报(**无权限键**);返回更新目标 + 安装包信息 + 一条待执行命令 |
|
||||
| POST | `/clients/commands/:id/ack` | 登录 | 命令执行回执(**无权限键**,靠 body 的 `deviceKey` 校验归属,幂等) |
|
||||
| POST | `/clients/commands` | `remote:control` | 下发远控命令(关机 / 重启 / 注销 / 锁屏;自动撤销该机更早的未回执命令) |
|
||||
| GET | `/clients/devices?classId=` | `client:manage` | 客户端设备列表(版本 / 在线 / 待执行命令) |
|
||||
| POST | `/clients/updates` | `client:update-push` | 统一下发客户端更新(按设备 / 班级 / 全部) |
|
||||
| DELETE | `/clients/devices/:id` | `client:manage` | 移除设备记录(换机 / 旧机器清理) |
|
||||
| GET | `/update/client/releases` | `client:manage` | 服务端已缓存了哪些客户端安装包 |
|
||||
| POST | `/update/client/fetch` | `client:update-push` | 服务端代教室机器从 GitHub 下载安装包并登记 sha256 + size |
|
||||
| POST | `/update/client/releases` | `client:update-push` | 手工登记已放进磁盘的安装包(内网 / U 盘分发兜底) |
|
||||
| DELETE | `/update/client/releases/:version` | `client:update-push` | 删除缓存的安装包(元数据 + 磁盘文件) |
|
||||
| GET | `/update/client/download/:version` | 登录 | 下载安装包(**故意不挂权限键**,班级端要能下;`res.download`) |
|
||||
| GET / PUT | `/ai/settings` | `ai:manage` | 大模型设置(API Key 加密存储、**只进不出**,只回 `hasApiKey` / `keyHint`) |
|
||||
| POST | `/ai/interpret` | `ai:use` | 一句话解析(**只解析不落库**);回 `action` / `params` / `preview` / `confidence` |
|
||||
| GET | `/ai/audit?limit=` | `ai:manage` | 助手使用记录(每次解析写一条 `AiAudit`) |
|
||||
| GET / PUT | `/system/settings` | `system:manage` | 系统设置(`developerMode`:开启后 500 响应带 `error.stack`) |
|
||||
| GET / PUT | `/system/autostart` | `system:manage` | 开机自启状态 / 开关(Windows 注册表 Run 项,Linux systemd 单元) |
|
||||
| GET | `/system/diagnostics` | `system:manage` | 运行诊断(版本 / Node / 运行时长 / 数据目录 / 在线客户端数 / 已加载模块) |
|
||||
| GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块) |
|
||||
|
||||
另有三个探针(不带 `/api` 前缀):`/healthz`(存活)、`/readyz`(就绪)、以及 `/api/health`(详情)。
|
||||
@@ -99,6 +126,10 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
|
||||
| **角色判定在密码校验之前** | 否则"密码错 / 密码对但账号停用"的差异会把学生账号密码变成可离线验证的预言机 |
|
||||
| **会渲染成链接的字段要校验 scheme** | `Homework.attachmentUrl` 只允许 `http(s)://` 或站内相对路径 |
|
||||
| **表格导入 8MB ↔ 请求体 12MB** | 文件以 base64 放进 JSON(约 4/3 倍),超限映射成 413 `IMPORT_TOO_LARGE` |
|
||||
| **三个接口故意不挂权限键** | `/permissions/me`、`/clients/heartbeat`、`/update/client/download/:version` —— 前两者由班级端(`CLASS_DEVICE`,权限集为空)调用,下载接口要能装包;保护只有"已登录 + 限流" |
|
||||
| **查询参数校验失败是 422** | `middleware/validate.ts` 的 zod 解析错误由错误中间件映射成 422 `VALIDATION_ERROR`(不是 400) |
|
||||
| **批量发布逐班判定、不整批回滚** | `/notifications/bulk` 与 `/homeworks/bulk` 的单班失败(403 越权 / `URGENT_DURING_CLASS`)不影响其它班,成功班照常落库并实时推送 |
|
||||
| **`/ai/interpret` 只解析不落库** | 助手只回结构化参数与中文预览;落库由前端确认后调既有端点 —— 权限与上课时段保护都在那条路径上 |
|
||||
|
||||
## 相关
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ class-helper/
|
||||
│ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons)
|
||||
│ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS)
|
||||
│ ├── version.mjs # 版本号单一来源的读写与一致性校验(= pnpm version:*)
|
||||
│ ├── dist-server.mjs # 服务端 + Web 管理端打包(免安装目录 + NSIS 安装程序)
|
||||
│ ├── dist-server.mjs # 服务端 + Web 管理端打包(组装运行时 → NSIS 安装程序)
|
||||
│ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟
|
||||
│ ├── lib/ # electron-env / node-runtime / smoke-class
|
||||
│ ├── nsis/server-installer.nsi# 安装程序脚本模板(版本号由 dist-server 注入)
|
||||
@@ -66,7 +66,7 @@ class-helper/
|
||||
├── shared/ # 三端共享:类型契约、常量、权限矩阵、动效令牌、工具函数
|
||||
│ └── src/{types,constants,permissions,motion,island-layout,utils,index}.ts
|
||||
├── server/ # 后端服务
|
||||
│ ├── prisma/schema.prisma # 数据模型(14 个 model,只有一份,切库改 provider)
|
||||
│ ├── prisma/schema.prisma # 数据模型(24 个 model,只有一份,切库改 provider)
|
||||
│ ├── prisma/migrations/ # 迁移历史(10 个)
|
||||
│ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据)
|
||||
│ ├── scripts/ # verify-e2e.mjs / verify-classisland.mjs / apply-column-migrations.cjs
|
||||
@@ -105,9 +105,10 @@ class-helper/
|
||||
后端每个功能是一个独立目录,统一契约:
|
||||
|
||||
```ts
|
||||
// packages/server/src/modules/registry.ts(当前 15 个模块)
|
||||
// packages/server/src/modules/registry.ts(当前 21 个模块)
|
||||
export const apiModules: ApiModule[] = [
|
||||
authModule,
|
||||
aiModule, // 大模型助手(只解析不落库,见 docs/dev/api.md)
|
||||
classesModule,
|
||||
coursesModule,
|
||||
schedulesModule,
|
||||
@@ -119,9 +120,14 @@ export const apiModules: ApiModule[] = [
|
||||
gradesModule,
|
||||
studentsModule,
|
||||
teachersModule,
|
||||
archivesModule,
|
||||
termModule,
|
||||
dashboardModule,
|
||||
databaseModule, // 数据库管理(仅管理员)
|
||||
updateModule, // 更新检查(服务端唯一出网请求)
|
||||
updateModule, // 更新检查 + 客户端安装包缓存与统一下发
|
||||
permissionsModule, // 权限组管理
|
||||
clientsModule, // 客户端设备心跳 / 远控命令
|
||||
systemModule, // 开发者模式 / 开机自启 / 运行诊断
|
||||
].filter((module) => module.enabled !== false);
|
||||
```
|
||||
|
||||
@@ -138,7 +144,8 @@ export const apiModules: ApiModule[] = [
|
||||
| 约定 | 为什么 |
|
||||
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ |
|
||||
| **统一响应与校验** | 用 `lib/http.ts` 的 `sendOk/sendCreated`,用 `middleware/validate.ts` 挂 zod,错误交给 `middleware/error.ts` |
|
||||
| **权限判在服务层** | 路由只做角色过滤,班级维度的判断用 `lib/access.ts` 断言 —— 前端隐藏 ≠ 权限 |
|
||||
| **功能级准入看权限键** | 路由挂 `requirePermission('…')` / `requireAnyPermission(…)`,角色只是默认模板(`ROLE_DEFAULT_PERMISSIONS`)—— 见 `docs/dev/permissions.md` |
|
||||
| **权限判在服务层** | 权限组只管功能级;班级维度与科目维度仍用 `lib/access.ts` 的断言(`assertCanManageSubjectContent` 等),**权限组不可覆盖** —— 前端隐藏 ≠ 权限 |
|
||||
| **周一为新一周的第一天** | 周次口径唯一:`resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)` |
|
||||
| **作业按 `assignDate` 归类** | 用 `createdAt`(UTC)会在晚上把作业算到第二天 |
|
||||
| **新增实时事件要改三处** | 服务端 bus/socket、Web 端订阅、客户端订阅(灵动岛也依赖) |
|
||||
|
||||
+34
-9
@@ -1,6 +1,6 @@
|
||||
---
|
||||
title: 数据模型
|
||||
description: 16 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。
|
||||
description: 24 张表的职责、跨库通用约定、迁移历史与启动时自动迁移。
|
||||
---
|
||||
|
||||
# 数据模型
|
||||
@@ -30,11 +30,11 @@ description: 16 张表的职责、跨库通用约定、迁移历史与启动时
|
||||
同一位教师可以在同一个班同时是班主任和某科科任老师:两处指向同一个 `User`,不重复建号,
|
||||
权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
|
||||
|
||||
## 16 张表
|
||||
## 24 张表
|
||||
|
||||
| 表 | 职责 | 值得注意的字段 |
|
||||
| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- |
|
||||
| `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`;**学生不在这里** |
|
||||
| `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`、`careMode`(关怀模式偏好,目前只由本机界面读写);**学生不在这里** |
|
||||
| `Student` | 学生名单记录(**没有账号**) | `studentNo`(学号,唯一,唯一标识与查询键)、`classId`、`status`、`archivedYearId` |
|
||||
| `StudentClassTransfer` | 调班 / 转出历史 | 班级 id 与班级名**都存快照且不建外键**(班级删了历史仍要能读)、`mode`(single|batch|transfer-out) |
|
||||
| `ArchivedYear` | 毕业归档的届别档案 | `enrollmentYear`(届别,唯一)、`graduationYear`、归档时的统计快照 |
|
||||
@@ -44,12 +44,20 @@ description: 16 张表的职责、跨库通用约定、迁移历史与启动时
|
||||
| `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual|classisland) |
|
||||
| `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 |
|
||||
| `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, studentId])` |
|
||||
| `Notification` | 通知 | `priority` |
|
||||
| `Notification` | 通知 | `priority`、`assignDate`(所属本地日期,禁止 UTC 切片);`@@index([classId, assignDate])` 供按天归档 |
|
||||
| `NotificationRead` | 已读记录 | `@@unique([notificationId, studentId])` |
|
||||
| `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` |
|
||||
| `Grade` | 成绩 | `studentId`、`score` / `totalScore`、`levelType`(percent|letter|custom)与 `level` |
|
||||
| `IntegrationDevice` | ClassHelper 班级端 / ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`lastHeartbeatAt`(在线判定)、`lastSeenAt`(最后一次带状态的上报)、三个方向开关 |
|
||||
| `ClassIslandPush` | 下发给 ClassIsland 的提醒 | `kind`(notification|call)、`ackedAt`、`expiresAt` |
|
||||
| `PermissionGroup` | 权限组(角色之上的**默认模板**) | `name`(唯一)、`builtin`(内置组不可删);生效集合见 [角色与权限](permissions.md) |
|
||||
| `PermissionGroupItem` | 权限组里的一个权限键(一行一个键) | `@@unique([groupId, key])`;键取值必须是 shared 的 `PERMISSION_KEYS` 之一 |
|
||||
| `UserPermissionGroup` | 账号 ←→ 权限组 的分配关系(可属多个组) | `@@unique([userId, groupId])`;**没有任何行 = 回落角色默认模板** |
|
||||
| `AppSetting` | 全局键值设置(大模型配置 / 开发者模式) | `key` 即主键、`value` 存字符串(复杂结构存 JSON);密钥类值做 AES-256-GCM 加密后存这里(**只进不出**) |
|
||||
| `ClientDevice` | ClassHelper 客户端设备(教室 / 学生机) | `deviceKey`(唯一,客户端生成)、`classId`、`clientVersion`、`updateTargetVersion`、`lastHeartbeatAt`(**在线判定看它**) |
|
||||
| `ClientCommand` | 下发给客户端的远控指令 | `kind`(shutdown|restart|logoff|lock)、`expiresAt`(10 分钟)、`ackedAt` / `result`、`cancelledAt` |
|
||||
| `ClientRelease` | 服务端缓存的客户端安装包 | `version`(唯一,白名单校验防路径穿越)、`fileName`、`sha256`、`size`;文件在 `data/client-releases/` |
|
||||
| `AiAudit` | 大模型指令审计(意图与结果,不记密钥) | `userId`、`prompt`、`intent`、`status`(pending|confirmed|rejected|failed)、`rawReply` |
|
||||
|
||||
### 几个容易忽略的设计
|
||||
|
||||
@@ -74,17 +82,26 @@ description: 16 张表的职责、跨库通用约定、迁移历史与启动时
|
||||
**`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。
|
||||
保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。
|
||||
|
||||
**`Notification.assignDate`** —— 与 `Homework.assignDate` 同口径:**本地日期**(`YYYY-MM-DD`),
|
||||
创建时用 `dayKeyLocal()` 写入,按天 / 区间归档都读它。**不要用 `createdAt` 切 UTC 当"哪一天"** ——
|
||||
晚上发的通知会被算到第二天。`@@index([classId, assignDate])` 是归档查询的支撑。
|
||||
|
||||
**`User.careMode`** —— 关怀模式的账号级偏好列(放大正文与控件)。**目前服务端没有任何接口读写它**:
|
||||
Web 端存在 `localStorage`(`classhelper.admin.careMode` + `careScale`),客户端存在本机 `config.json`。
|
||||
列先建好是为了将来跨设备同步偏好时不必再迁移;别把它当成"已有服务端开关"。
|
||||
|
||||
**`TermWeek.classId` 用空串而不是 null 表示"全校默认"** —— SQLite 与 MySQL 的唯一约束都把
|
||||
NULL 视作互不相同,`@@unique([classId, weekNumber])` 用 null 就形同虚设。
|
||||
|
||||
## 迁移历史(16 个)
|
||||
## 迁移历史(17 个)
|
||||
|
||||
```
|
||||
init → add_time_layout → add_class_account → add_schedule_week_parity →
|
||||
add_class_term_weeks → add_classisland_integration → add_class_notification_channel →
|
||||
add_homework_assign_date → add_push_kind → add_schedule_source →
|
||||
add_student_table → add_student_grade_query → add_grade_level →
|
||||
add_classhelper_heartbeat → add_archives → add_term_weeks
|
||||
add_classhelper_heartbeat → add_archives → add_term_weeks →
|
||||
add_permission_groups_and_clients
|
||||
```
|
||||
|
||||
`add_student_table` 是一次真正的表重构(不是加列):新建 `Student` / `StudentClassTransfer`、
|
||||
@@ -92,6 +109,12 @@ add_classhelper_heartbeat → add_archives → add_term_weeks
|
||||
`NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
|
||||
因为学生行的 id 保持不变,三张个人数据表的 `userId` 可以直接当 `studentId` 用,历史一条不丢。
|
||||
|
||||
`add_permission_groups_and_clients` 是纯加表 / 加列:新建 `PermissionGroup` /
|
||||
`PermissionGroupItem` / `UserPermissionGroup`(权限组)、`AppSetting`(全局键值设置)、
|
||||
`ClientDevice` / `ClientCommand`(客户端设备与远控)、`ClientRelease`(安装包缓存)、
|
||||
`AiAudit`(大模型审计),并给 `Notification` 加 `assignDate` + `[classId, assignDate]` 索引、
|
||||
给 `User` 加 `careMode`。没有数据搬运,旧库直接补跑即可。
|
||||
|
||||
### 启动时自动迁移
|
||||
|
||||
`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名:
|
||||
@@ -107,8 +130,10 @@ add_classhelper_heartbeat → add_archives → add_term_weeks
|
||||
|
||||
## 备份格式就是这份模型的拓扑序导出
|
||||
|
||||
「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:16 张表按拓扑序导出
|
||||
(见 `lib/snapshot.ts` 的 `SNAPSHOT_TABLES`),恢复时**临时关闭外键检查** ——
|
||||
「数据库管理」里的备份 / 导出导入 / 跨库迁移共用一种 JSON 快照:**23 张表按拓扑序导出**
|
||||
(见 `lib/snapshot.ts` 的 `SNAPSHOT_TABLES`,现在共 23 项,顺序为
|
||||
`User` → `PermissionGroup` → `PermissionGroupItem` → `UserPermissionGroup` → `AppSetting` →
|
||||
`ArchivedYear` → `Class` → `Student` → … → `AiAudit`),恢复时**临时关闭外键检查** ——
|
||||
因为 `Class.teacherId → User` 与 `Student.classId → Class` 是一条链、
|
||||
`Class/Student.archivedYearId → ArchivedYear` 又是一条,任何排序都会被其中一侧卡死。
|
||||
见[数据库管理](../management/database.md)。
|
||||
@@ -127,4 +152,4 @@ add_classhelper_heartbeat → add_archives → add_term_weeks
|
||||
再回到 `packages/server/scripts/apply-column-migrations.cjs` 补上可 `ALTER TABLE` 的简单列。
|
||||
|
||||
模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准;
|
||||
本文里的数字标注的是当前版本(1.2.0)的状态。
|
||||
本文里的数字标注的是**当前代码库**的状态。
|
||||
+1
-1
@@ -38,7 +38,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
||||
| [架构总览](architecture.md) | 目录结构、技术栈、模块化设计、几条贯穿全局的约定 |
|
||||
| [REST API](api.md) | 全部接口一览(方法 / 路径 / 权限) |
|
||||
| [实时事件(Socket.IO)](realtime.md) | 事件清单、房间规则、新增事件要改哪三处 |
|
||||
| [数据模型](data-model.md) | 16 张表、迁移历史、跨库约定 |
|
||||
| [数据模型](data-model.md) | 24 张表、迁移历史、跨库约定 |
|
||||
| [角色与权限](permissions.md) | 权限矩阵与实现位置 |
|
||||
| [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 |
|
||||
| [ClassIsland 插件](classisland-plugin.md) | 插件契约、镜像为什么要带 `startTime`、部署与调试 |
|
||||
|
||||
+49
-2
@@ -67,9 +67,50 @@ description: 权限矩阵、科目维度的收口规则,以及"前端隐藏
|
||||
实现在 `lib/access.ts` 的 **`assertCanManageSubjectContent(user, classId, courseId, action, { allowClassDevice })`**,
|
||||
作业 / 成绩 / 叫人三处写入统一走它。
|
||||
|
||||
## 权限键与权限组(2026-10 起:权限组驱动,角色只是默认模板)
|
||||
|
||||
从权限组重构开始,**功能级准入不再看角色,而看权限键**:键目录同样是
|
||||
`packages/shared/src/permissions.ts` 的 `PERMISSION_KEYS`(39 个,形如 `domain:action`,
|
||||
中文名在 `PERMISSION_LABELS`、分组在 `PERMISSION_CATEGORIES`)。路由上挂的是
|
||||
|
||||
```ts
|
||||
requirePermission('notification:publish') // 需要全部列出的键
|
||||
requireAnyPermission('grade:write', 'grade:import') // 有任意一个即可
|
||||
```
|
||||
|
||||
**生效规则(四句话)**:
|
||||
|
||||
| 账号 | 有效权限 |
|
||||
| --------------------------------------- | -------------------------------------------------------------- |
|
||||
| `ADMIN` | 全集;其中 `permission:manage` / `system:manage` **不可被剥夺** |
|
||||
| `CLASS_DEVICE`(班级端) | 空集,**不受任何分组影响**(它的能力来自班级维度断言) |
|
||||
| 被分配了权限分组 | 分组键的**并集**(可以比角色默认更少 —— 这就是"能收权") |
|
||||
| 没有被分配任何分组 | 回落到角色默认模板 `defaultPermissionsForRole(role)` |
|
||||
|
||||
默认模板逐条镜像重构前的 `requireRole`,所以"不动分组"= 行为与以前完全一致。
|
||||
实现见 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()`:它**每个请求实时查库**
|
||||
(不缓存),因此管理员改完分组**立刻生效**,不需要等令牌过期 —— 与下面「鉴权中间件会回查数据库」是同一个理由。
|
||||
|
||||
**管理接口**(`packages/server/src/modules/permissions/`):
|
||||
|
||||
| 方法与路径 | 权限要求 | 说明 |
|
||||
| --------------------------------- | -------------------- | -------------------------------------------------------- |
|
||||
| `GET /api/permissions/me` | 登录即可 | 当前账号的有效权限与来源(前端据此显示/隐藏入口) |
|
||||
| `GET /api/permissions/catalog` | `permission:manage` | 全部权限键(含中文名、分类、默认角色、不可移除标记) |
|
||||
| `GET/POST /api/permissions/groups`、`PATCH/DELETE /groups/:id` | `permission:manage` | 分组 CRUD;内置分组(`builtin=true`)可改不可删 |
|
||||
| `GET /api/permissions/users`、`PUT /api/permissions/users/:id/groups` | `permission:manage` | 成员列表与「整组替换」式分配;拒绝给 ADMIN / CLASS_DEVICE 分配 |
|
||||
|
||||
**一条不能松的边界**:权限组只管**功能/路由级**准入;**班级维度的科目规则不可被权限组覆盖**。
|
||||
换句话说,给某人授了 `homework:write` 只代表"他能进布置作业这条路由",
|
||||
**具体到哪个班、哪个科目**仍然由 `assertCanManageSubjectContent` 判(下一节)。
|
||||
否则「班主任能布置别人科目的作业」这个老 bug 会从权限组这条新路重新长出来。
|
||||
|
||||
## 实现要点
|
||||
|
||||
**路由级只做角色过滤**:`requireRole('ADMIN', 'TEACHER')`。
|
||||
**路由级只做权限过滤**:`requirePermission('…')`(需要列出的键全有)或
|
||||
`requireAnyPermission('…')`(任意一个即可)。改造前这里是 `requireRole('ADMIN','TEACHER')`,
|
||||
按键目录里的 `PERMISSION_DEFAULT_ROLES` 逐条映射过来,因此**默认行为没有变化**。
|
||||
403 的文案会直接点名缺哪个键:「当前账号缺少「X」权限,请联系管理员在「权限组」里分配」。
|
||||
|
||||
**班级维度的判断在服务层**:`src/lib/access.ts` 提供
|
||||
|
||||
@@ -93,10 +134,16 @@ assertCanManageGrades / assertCanManageSubjectContent
|
||||
|
||||
前端的隐藏只是"别让人看见入口":
|
||||
|
||||
- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理」;
|
||||
- 教师登录时侧边栏不显示「班级管理 / 学生管理 / 教师管理 / 毕业归档 / 学期周次 / 数据库管理 / 权限组 / 客户端设备」;
|
||||
- 课表页的增删改按钮只对「管理员或本班班主任」显示;
|
||||
- 成绩页的录入 / 删除按钮对教师显示(科任也能用,**具体到某一条由后端按科目判定**)。
|
||||
|
||||
**门控怎么写的**:路由 `meta.roles` 做粗筛(角色),`meta.permission` 做细筛(权限键)——
|
||||
`packages/web-admin/src/router/index.ts` 的 `beforeEach` 会 `await auth.ensurePermissions()`
|
||||
再问 `auth.can(key)`(管理员短路为 true),不通过就 **fail-closed** 回仪表盘;
|
||||
侧栏菜单项同理带 `permission` 字段(`AdminLayout.vue` 的 `visibleMenuItems`)。
|
||||
`auth.ensurePermissions()` 读的就是 `GET /api/permissions/me`。
|
||||
|
||||
但这些页面**直接输网址也进不去**(路由 `meta.roles` 会把非管理员挡回仪表盘),
|
||||
而且**所有写入请求服务端都会再断言一次**。绕过菜单、绕过前端,一样 403。
|
||||
|
||||
|
||||
+6
-2
@@ -95,11 +95,15 @@ pnpm build && pnpm verify:web
|
||||
pnpm dist:all # = dist:server + dist:win + dist:classisland-plugin
|
||||
```
|
||||
|
||||
产物落在 `releases/<组件>/<版本>/`:`安装包/`、`免安装/`、`构建中间/`。
|
||||
产物落在 `releases/<组件>/<版本>/`:`安装包/`(交付物)、`构建中间/`(可重新生成)。
|
||||
**没有免安装目录** —— 组装/解包出来的那一份是中间物,打包脚本用完就删(见仓库根 `AGENTS.md` §4
|
||||
「交付产物归集」)。
|
||||
|
||||
打完之后**对打包副本再验一次**(开发产物通过 ≠ 用户机器上的副本通过):
|
||||
|
||||
```bash
|
||||
pnpm verify:packaged --exe "releases/client/<版本>/免安装/ClassHelper.exe"
|
||||
# 默认指向装好的客户端;推荐直接拿刚打出来的便携版单文件当被测副本
|
||||
pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版本>-x64-portable.exe"
|
||||
```
|
||||
|
||||
> Linux 包**只能在 Linux 上构建**(依赖是平台相关的),所以不在这条命令里 ——
|
||||
|
||||
@@ -20,7 +20,7 @@ description: 在教室机器上安装 ClassHelper桌面客户端,用班级码
|
||||
要自己打:
|
||||
|
||||
```bash
|
||||
pnpm dist:win # 安装包 + 便携版;只要免安装目录用 pnpm dist:dir
|
||||
pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的解包目录)
|
||||
```
|
||||
|
||||
> **装不上 / 双击没反应 / 一闪就没**(报 `0x80000003`)通常不是产物坏了,而是交付物被打了
|
||||
|
||||
@@ -31,9 +31,12 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已装依赖,跳过
|
||||
产物:
|
||||
|
||||
| 路径 | 说明 |
|
||||
| --------------------------------------------------------------------- | ------------------------------------------- |
|
||||
| ----------------------------------------------------------------------- | -------------------------- |
|
||||
| `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
||||
| `releases/server/<版本>/免安装/` | 免安装目录,拷到任意 Windows x64 机器即可跑 |
|
||||
|
||||
> 交付产物只有安装程序。组装出来的那份运行时(内置 Node + 依赖 + 后端与 Web 产物,约 335 MB)
|
||||
> 只是打安装包的中间物,落在 `.cache/server-staging/<版本>/` 下,打完脚本就删掉了 ——
|
||||
> 想留一份可直接双击运行的副本,见本文末「不用安装程序的手动部署」。
|
||||
|
||||
包内自带 Node 运行时、后端产物、Web 管理端产物、生产依赖、迁移 SQL、随机 `JWT_SECRET` 的 `.env`
|
||||
与启停脚本。目标机器**不需要预先安装 Node.js**。
|
||||
|
||||
@@ -12,7 +12,7 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
|
||||
|
||||
| 能力 | 说明 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、16 张表的行数、连接串(MySQL 密码脱敏) |
|
||||
| 连接状态检测 | 当前类型(SQLite / MySQL)、连通性与延迟、引擎版本、数据体积、23 张表的行数、连接串(MySQL 密码脱敏) |
|
||||
| 连接测试 | 对任意目标库试连(切换前的第一步) |
|
||||
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → 生成客户端 / 建表 → 子进程迁移全部数据 → 改写 `.env`」,异步任务带步骤进度,**失败自动回滚** |
|
||||
| 备份 / 恢复 | gzip JSON 快照存在 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
|
||||
@@ -23,7 +23,7 @@ Web 管理端有一个「数据库管理」页(`/database`,**仅管理员可
|
||||
|
||||
**备份、导入导出、跨库迁移共用同一种「JSON 快照」**:
|
||||
|
||||
- 16 张表按**拓扑序**导出;
|
||||
- 23 张表按**拓扑序**导出;
|
||||
- 恢复时临时**关闭外键检查** —— 因为 `User.classId → Class` 与 `Class.teacherId → User`
|
||||
是**互相引用**的,任何排序都会被其中一侧卡死;
|
||||
- 分批 `createMany` 写入。
|
||||
|
||||
@@ -14,7 +14,7 @@ description: 部署形态怎么选、备份与恢复、换数据库、升级与
|
||||
| **Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | NSIS 安装程序,内置 Node 运行时 | [生产部署指南](production.md) |
|
||||
| **Linux 原生 + systemd** | 学校已有 Linux 服务器 | 一键安装器 + `classhelper` 运维命令 | [Linux 部署与运维](linux-deploy.md) |
|
||||
| **Docker** | 云服务器、跟别的服务共用一台机器 | `deploy/docker-compose*.yml` | [生产部署指南](production.md) |
|
||||
| **手动部署** | 已有 Node 环境、需要自定义 | 免安装目录 | [生产部署指南](production.md) |
|
||||
| **手动部署** | 已有 Node 环境、需要自定义 | 仓库源码(`pnpm build` 后直接跑) | [生产部署指南](production.md) |
|
||||
|
||||
一句话的取舍:
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
| **A. Windows 安装包** | 学校机房、教师办公电脑(单机即完整系统) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | ✅ 已完整实测(静默安装/启停/卸载/UI) |
|
||||
| **B. Docker + MySQL** | 云服务器、多终端共享一套数据(推荐长期方案) | `deploy/Dockerfile` + `deploy/docker-compose.yml` | ⚠️ 文件已提供,本机无 Docker 未实测 |
|
||||
| **C. Linux 原生部署** | 已有 Linux 服务器(systemd,SQLite 或 MySQL) | [`deploy/install.sh`](https://github.com/LoveChengke/classhelper/blob/master/deploy/install.sh) + `classhelper` 运维命令 | ⚠️ 本机无 Linux,实机验收见 [linux-deploy.md](linux-deploy.md) §10 |
|
||||
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | `pnpm dist:server` 的免安装目录 | ✅ 免安装目录已实测(163 项端到端) |
|
||||
| **D. 手动部署** | 已有 Node 环境的服务器、需要自定义 | 在仓库里构建后直接跑(见第四节) | ✅ 已实测(163 项端到端) |
|
||||
|
||||
---
|
||||
|
||||
@@ -23,10 +23,13 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
||||
产物:
|
||||
|
||||
| 路径 | 说明 |
|
||||
| ----------------------------------------------------------------------- | ----------------------------------------------- |
|
||||
| `releases/server/<版本>/免安装/` | 免安装目录,可直接拷到任意 Windows x64 机器运行 |
|
||||
| ----------------------------------------------------------------------- | -------------------------- |
|
||||
| `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` | 安装程序(约 62 MB,NSIS) |
|
||||
|
||||
> 组装出来的运行时(内置 Node + 生产依赖 + 后端与 Web 产物,约 335 MB)是打安装包的中间物,
|
||||
> 落在 `.cache/server-staging/<版本>/`,脚本打完就删 —— 交付目录里只留安装程序。
|
||||
> 需要在有 Node 的机器上手动跑,见第四节。
|
||||
|
||||
打包内容:内置 **Node 运行时**(目标机无需安装 Node.js)、后端产物、Web 管理端产物、
|
||||
生产依赖(真实目录,非软链)、迁移 SQL、`.env`(随机 JWT 密钥)、启停脚本。
|
||||
|
||||
@@ -38,7 +41,8 @@ pnpm dist:server -- --reuse-deps # 迭代打包:复用已安装依赖,跳
|
||||
- 静默安装:`"ClassHelper 服务端-1.2.0-x64-setup.exe" /S`
|
||||
- 开始菜单:启动服务 / 停止服务 / 重启服务 / 打开管理端 / 使用说明 / 卸载
|
||||
- 桌面:`ClassHelper 服务端.lnk`(打开管理端)
|
||||
- 自动加入当前用户开机自启(`HKCU\...\CurrentVersion\Run`)
|
||||
- **开机自启动可选**:安装向导的组件页里勾选(默认勾选),装完之后还能在管理端「系统设置」里随时开关
|
||||
(`GET/PUT /api/system/autostart`);勾选时写的是 `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClassHelperServer`
|
||||
- 卸载信息注册到「应用和功能」
|
||||
|
||||
安装完成后服务会自动启动并完成首次初始化:
|
||||
@@ -274,21 +278,27 @@ sudo bash /tmp/verify-linux.sh --full --package /tmp/classhelper-server-linux-x6
|
||||
|
||||
## 四、手动部署(形态 D)
|
||||
|
||||
目标机已有 Node.js(≥ 20.19)时,不需要任何安装包,直接在仓库里构建后跑:
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm build:shared
|
||||
pnpm --filter @classhelper/server build
|
||||
pnpm --filter @classhelper/web-admin build
|
||||
pnpm dist:server -- --reuse-deps # 得到 releases/server/<版本>/免安装/ 免安装目录
|
||||
```
|
||||
|
||||
把该目录拷贝到目标机,编辑 `.env`(端口、数据库、密钥、跨域),然后:
|
||||
按目标机的实际情况写好 `packages/server/.env`(端口、数据库、密钥、跨域),然后:
|
||||
|
||||
```cmd
|
||||
start.cmd :: Windows
|
||||
# 或直接:node server/dist/index.js (Linux/macOS 用系统 node,见上面的形态 C)
|
||||
```bash
|
||||
pnpm --filter @classhelper/server start # 或 node packages/server/dist/index.js
|
||||
```
|
||||
|
||||
> 想把这份构建结果整体拷到另一台机器,就自己带上这四样:
|
||||
> `packages/server/dist`、`packages/server/node_modules`(含 `src/generated/prisma`)、
|
||||
> `packages/web-admin/dist`(服务端按 `web/` 静态托管)、以及 `packages/shared/dist`。
|
||||
> `pnpm dist:server` 组装的正是这套东西,只是它现在把结果当中间物用、打完即删 ——
|
||||
> 需要留档时把 `.cache/server-staging/<版本>/win/` 在打包过程中拷出来即可。
|
||||
|
||||
数据库初始化二选一:
|
||||
|
||||
- 让服务自愈:`.env` 中 `AUTO_MIGRATE=true`(SQLite 首次启动自动建表 + 建管理员)
|
||||
|
||||
@@ -98,7 +98,7 @@ docker compose up -d
|
||||
|
||||
### 手动 / 源码
|
||||
|
||||
拉新代码或替换免安装目录 → 重启服务端。启动时 `db-bootstrap` 会用账本表 `_ch_migrations`
|
||||
拉新代码(或覆盖安装新的服务端程序)→ 重启服务端。启动时 `db-bootstrap` 会用账本表 `_ch_migrations`
|
||||
对比已执行的迁移,只补跑缺的那些:
|
||||
|
||||
- 全新安装:依次执行全部迁移并创建初始管理员;
|
||||
|
||||
+224
-45
@@ -1,7 +1,7 @@
|
||||
# ClassHelper · 完整参考
|
||||
|
||||
班级信息管理系统。一个 pnpm monorepo,一份代码产出四个交付物:**后端服务**、**Web 管理端**(教师 / 管理员)、
|
||||
**桌面客户端**(ClassHelper 班级端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。**当前版本 1.2.0**。
|
||||
**桌面客户端**(ClassHelper 班级端 / 教室机器)与 **ClassIsland 联动插件**(装在教室的 ClassIsland 上)。
|
||||
|
||||
核心链路:
|
||||
|
||||
@@ -43,6 +43,13 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
||||
- [4.8 ClassIsland 联动](#48-classisland-联动)
|
||||
- [4.9 数据库管理](#49-数据库管理仅管理员)
|
||||
- [4.10 更新检查](#410-更新检查)
|
||||
- [4.11 毕业归档(仅管理员)](#411-毕业归档仅管理员)
|
||||
- [4.12 学期周次](#412-学期周次可逐周覆盖仅管理员)
|
||||
- [4.13 课表同步方向](#413-课表同步方向服务端--教室)
|
||||
- [4.14 权限组管理(角色只是默认模板)](#414-权限组管理角色只是默认模板)
|
||||
- [4.15 客户端设备统一更新与远控](#415-客户端设备统一更新与远控)
|
||||
- [4.16 通知按天归档与批量发布](#416-通知按天归档与批量发布)
|
||||
- [4.17 大模型助手与开发者模式 / 关怀模式](#417-大模型助手与开发者模式--关怀模式)
|
||||
- [5. 技术参考](#5-技术参考)
|
||||
- [5.1 目录结构](#51-目录结构)
|
||||
- [5.2 技术栈](#52-技术栈)
|
||||
@@ -86,8 +93,9 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
||||
| **ClassIsland 联动插件** | `releases/classisland-plugin/<版本>/安装包/ClassHelper.ClassIslandPlugin.cipx` | 装在教室机器的 ClassIsland 上(也可把 `ClassHelper.ClassIslandPlugin/` 目录丢进 Plugins) |
|
||||
|
||||
> 这些产物**都不入库**(`releases/` 已 gitignore + eslint ignore)。三个打包脚本**直接输出**到
|
||||
> `releases/<组件>/<版本>/`,每个版本目录下按 `安装包/`、`免安装/`、`构建中间/` 分层,
|
||||
> 历史版本原样保留 —— 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §4。
|
||||
> `releases/<组件>/<版本>/`,每个版本目录下按 `安装包/`(交付物)、`构建中间/`(可重新生成)分层,
|
||||
> 历史版本原样保留。**没有免安装目录**:组装/解包出来的那一份只是中间物,脚本打完就删 ——
|
||||
> 详见 [AGENTS.md](https://github.com/LoveChengke/classhelper/blob/master/AGENTS.md) §4。
|
||||
|
||||
四种部署形态按场景选(Linux 完整步骤见 [docs/management/linux-deploy.md](management/linux-deploy.md),
|
||||
其余见 [docs/management/production.md](management/production.md)):
|
||||
@@ -97,7 +105,7 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
|
||||
| 学校机房 / 教师电脑(Windows 单机) | Windows 服务端安装程序(内置 Node,双击即用) | `releases/server/<版本>/安装包/ClassHelper 服务端-<版本>-x64-setup.exe` |
|
||||
| 云服务器 / 多终端共享(推荐长期) | Docker Compose + MySQL(另有 SQLite 单容器版) | `deploy/Dockerfile`、`deploy/docker-compose{,.sqlite}.yml` |
|
||||
| 已有 Linux 服务器 | **一键安装器 + `classhelper` 运维命令**(systemd / docker) | `sudo bash deploy/install.sh`(先 `--check` 体检);运维见 `classhelper help` |
|
||||
| 自定义 / 已有 Node 环境 | 手动部署免安装目录 | `pnpm dist:server` → `releases/server/<版本>/免安装/` |
|
||||
| 自定义 / 已有 Node 环境 | 在仓库里构建后直接跑(手动部署) | `pnpm --filter @classhelper/server start` |
|
||||
|
||||
**Linux 服务器最快路径是一行命令**:脚本会自己从 GitHub Release 取最新版服务端包、取发布页上的
|
||||
`.sha256` 校验,再装成 systemd 服务并带上 `classhelper` 运维命令。完整步骤与验收清单见
|
||||
@@ -211,8 +219,7 @@ pnpm verify:desktop
|
||||
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8) |
|
||||
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` |
|
||||
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 界面内品牌标) |
|
||||
| `pnpm dist:server` | 打包服务端 + Web 管理端(免安装目录 + NSIS 安装程序,内置 Node) |
|
||||
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(最快) |
|
||||
| `pnpm dist:server` | 打包服务端 + Web 管理端(NSIS 安装程序,内置 Node) |
|
||||
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
|
||||
| `pnpm dist:all` | 服务端安装程序 + 客户端安装包 + 插件一起打 |
|
||||
| `pnpm verify:e2e` | 后端端到端验收(需服务端已启动) |
|
||||
@@ -765,14 +772,14 @@ Web 管理端「数据库管理」页(`/database`,仅 ADMIN 可见,后端
|
||||
|
||||
| 能力 | 说明 |
|
||||
| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 连接状态检测 | 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、16 张表行数、连接串(MySQL 密码脱敏) |
|
||||
| 连接状态检测 | 当前类型(SQLite/MySQL)、连通性与延迟、引擎版本、数据体积、23 张表行数、连接串(MySQL 密码脱敏) |
|
||||
| 连接测试 | 任意目标库的连通性试连(切换前的第一步) |
|
||||
| **一键切换** | SQLite ⇄ MySQL:自动「备份当前库 → 改写 schema provider → prisma generate / db push 建表 → 子进程迁移全部数据 → 改写 .env」,异步任务带步骤进度,失败自动回滚 |
|
||||
| 备份 / 恢复 | gzip JSON 快照存 `<数据目录>/backups/`;手动备份 / 从备份恢复(整库覆盖)/ 删除 |
|
||||
| 定时备份 | enabled + 间隔小时数 + 保留份数,存 `data/database-settings.json`;服务端每 10 分钟检查一次到点任务(进程不运行不补跑) |
|
||||
| 快捷导入导出 | 下载快照 JSON(跨库通用)/ 下载数据库文件(仅 SQLite)/ 导入快照(整库覆盖,base64 上传) |
|
||||
|
||||
关键设计:**备份、导入导出、跨库迁移共用同一种「JSON 快照」**(`lib/snapshot.ts`,16 张表按拓扑序导出,
|
||||
关键设计:**备份、导入导出、跨库迁移共用同一种「JSON 快照」**(`lib/snapshot.ts`,23 张表按拓扑序导出,
|
||||
恢复时临时关外键检查 —— `User.classId` 与 `Class.teacherId` 互相引用),因此任意备份都能恢复回任意一种受支持的数据库。
|
||||
MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-mariadb`(已在依赖里,不再是「切换时手动安装」)。
|
||||
|
||||
@@ -785,7 +792,8 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
|
||||
> - 安装包体积因此增大(约 34MB → **62MB**):切换需要随包内置 prisma CLI 与 TypeScript 编译器
|
||||
> (Prisma 7 的生成器产出 .ts 源码,切换后要在目标机编译进 dist)。
|
||||
> 打包末尾会做一次**运行时裁剪**(删掉 Prisma 为其它数据库方言准备的查询编译器副本等死载荷,
|
||||
> 见 `scripts/dist-server.mjs` 的 `pruneRuntime()`),免安装目录 469MB → 335MB。
|
||||
> 见 `scripts/dist-server.mjs` 的 `pruneRuntime()`),组装出来的运行时 469MB → 335MB
|
||||
> (这份运行时现在只是中间物,打完安装程序即删)。
|
||||
> - 真实切换(SQLite→MySQL)已在本机用完整子进程链路实测(快照 728 行逐表一致);e2e 只覆盖校验分支
|
||||
> (同库 400 / 非法 provider 422 / 目标库非空拒绝),避免测试改写 `.env`。
|
||||
|
||||
@@ -824,8 +832,6 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
|
||||
|
||||
只提示、不自动下载、不自动安装 —— 安装包仍由人从 Release 页取(`SHA256SUMS-<版本>.txt` 可核对)。
|
||||
|
||||
## 5. 技术参考
|
||||
|
||||
### 4.11 毕业归档(仅管理员)
|
||||
|
||||
按**年度**把毕业的班级与学生归档,并留档当年转出的学生、以及毕业班级发过的作业与通知。
|
||||
@@ -892,6 +898,117 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
|
||||
> 插件侧对应 `ReportSettingsDto.scheduleRequested` 与 `ReportAsync(..., forceSchedule: true)`;
|
||||
> 补发发生在**镜像之前**(顺序反了会把刚拿到的课表盖掉)。
|
||||
|
||||
### 4.14 权限组管理(角色只是默认模板)
|
||||
|
||||
入口:Web 管理端 →「权限组管理」。在这之前,账号能干什么完全由角色(`ADMIN` / `TEACHER`)决定;
|
||||
现在多了一层**权限组**,角色退化成「没有分组时」的默认模板。
|
||||
|
||||
| 账号 | 生效权限 |
|
||||
| ------------------------- | ----------------------------------------------------------------------------- |
|
||||
| `ADMIN` 管理员 | 全集;`permission:manage` 与 `system:manage` **不可被剥夺**(避免把自己锁死) |
|
||||
| 有分组的账号 | 所属权限组权限键的**并集** —— 因此可以比角色默认**更少**(用来收回权限) |
|
||||
| 没有任何分组的账号 | 回落角色默认模板 `defaultPermissionsForRole(role)` |
|
||||
| `CLASS_DEVICE` 班级端账号 | **空集,不受权限组影响**;它的能力由班级维度断言决定 |
|
||||
|
||||
- 生效集合的**唯一计算入口**是 `src/lib/permissions.ts` 的 `loadEffectivePermissions()`,
|
||||
**每请求实时查询、不缓存** —— 改完权限立即生效(代价是每请求多一次分组查询)。
|
||||
- 权限键目录(`PERMISSION_KEYS` / `PERMISSION_LABELS` / `PERMISSION_CATEGORIES`)只有一份,
|
||||
放在 `@classhelper/shared`:权限页的勾选树与后端的 `requirePermission` 读的是同一份。
|
||||
- 保存权限组时是**整体替换**权限键(漏传的等于取消勾选,不会留下幽灵权限);
|
||||
`builtin` 内置组可以改名、可以调键,但**不能删除**;删掉一个组后原成员回到角色默认模板。
|
||||
- 端点:`GET /permissions/me`(**无权限键**,两端据此决定入口显不显示)、`GET /permissions/catalog`、
|
||||
`GET / POST /permissions/groups`、`PATCH / DELETE /permissions/groups/:id`、`GET /permissions/users`、
|
||||
`PUT /permissions/users/:id/groups`(除 `/me` 外都要求 `permission:manage`)。
|
||||
- 判定规则与完整矩阵见[角色与权限](dev/permissions.md);**服务端始终是唯一拦截点**,
|
||||
前端只按 `GET /permissions/me` 隐藏入口,别把它当安全边界。
|
||||
|
||||
### 4.15 客户端设备统一更新与远控
|
||||
|
||||
每台教室机器的心跳间隔 `CLIENT_HEARTBEAT_INTERVAL_MS` = **30 秒**,一次请求把两件事带回:
|
||||
「要不要更新 / 装哪个版本」与「有没有待执行的远控命令」(`POST /clients/heartbeat`,**不挂权限键**)。
|
||||
|
||||
| 项 | 口径 |
|
||||
| ---------------- | ------------------------------------------------------------------------------ |
|
||||
| 在线判定 | 最后一次心跳在 `CLIENT_ONLINE_WINDOW_MS`(90 秒)内算在线 —— 心跳间隔的 3 倍 |
|
||||
| 命令有效期 | `CLIENT_COMMAND_EXPIRE_MS`(10 分钟);机器离线几天后不会被突然关机 |
|
||||
| 命令种类 | 关机 / 重启 / 注销 / 锁屏(`CLIENT_COMMAND_KINDS` + `CLIENT_COMMAND_LABELS`) |
|
||||
| 新命令取代旧命令 | 同一台机器未回执的旧命令会被自动撤销(先关机后重启不会排队执行) |
|
||||
| 回执 | `POST /clients/commands/:id/ack`,body 里的 `deviceKey` 既是身份也是护栏(班级端令牌全班共用),且**幂等** |
|
||||
| 二次确认 | 客户端执行前必须在界面上二次确认;回执里带成功 / 失败原因 |
|
||||
|
||||
**安装包由服务端中继下发**(教室机器常常没有外网,Web 端的 CSP 也不允许直连 GitHub):
|
||||
|
||||
- `POST /update/client/fetch` 让服务端代下并登记 sha256 + size;下不动时用
|
||||
`POST /update/client/releases` 手工登记(文件先放进 `data/client-releases/<版本>/`);
|
||||
- 缓存在 `<serverRoot>/../data/client-releases/<版本>/`,版本号与文件名都要过白名单
|
||||
`^[0-9A-Za-z._-]+$`(**防路径穿越**);
|
||||
- 客户端从 `GET /update/client/download/:version` 取包(**故意不挂权限键**,班级端要能下载);
|
||||
- 安装时机三个条件缺一不可:**不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)**、包已下载。
|
||||
|
||||
权限三键:`remote:control`(下发远控命令)、`client:manage`(`GET /clients/devices` 列表、移除设备)、
|
||||
`client:update-push`(下发更新、登记与删除安装包)。
|
||||
|
||||
### 4.16 通知按天归档与批量发布
|
||||
|
||||
通知多了以后「哪天发了什么」比「什么时候创建的」更重要,所以通知也跟作业一样有了**所属日期**:
|
||||
|
||||
| 项 | 说明 |
|
||||
| ------------ | ------------------------------------------------------------------------------- |
|
||||
| `assignDate` | `Notification.assignDate` 是**本地日期**(创建时 `dayKeyLocal()` 写入),不是 `createdAt` 切 UTC |
|
||||
| 索引 | `@@index([classId, assignDate])` 支撑按天 / 区间查询 |
|
||||
| 按天归档 | `GET /notifications/days`(日期 + 条数)、`GET /notifications/creators`(按发布人) |
|
||||
| 列表筛选 | `GET /notifications?date=&from=&to=&createdBy=`(与 `classId` / `priority` / `unreadOnly` / `keyword` 叠加) |
|
||||
| 批量发布 | `POST /notifications/bulk`(`notification:publish`)、`POST /homeworks/bulk`(`homework:write`) |
|
||||
|
||||
**批量发布逐班判定、绝不整批回滚**:
|
||||
|
||||
```ts
|
||||
BulkPublishResult { total, succeeded, failed, results: BulkPublishClassResult[] }
|
||||
BulkPublishClassResult { classId, ok, id?, code?, message?, details? }
|
||||
```
|
||||
|
||||
某个班没权限(403)或撞上「上课时段只许发紧急通知」只让**那一个班**失败,其它班照常落库并实时推送;
|
||||
失败项带可直接展示的中文 `message`,界面按班列出来。作业批量里每个班可以给 `subjectName`,
|
||||
服务端按**科目名**在该班匹配课程 —— 匹配不上就是那个班失败,不会退化成"挂到班主任名下"。
|
||||
|
||||
> **不要用 `toISOString().slice(0, 10)` 算"哪一天"**:它按 UTC 切,晚上 8 点以后发的通知会算到第二天。
|
||||
> 统一用 `@classhelper/shared` 的 `dayKeyLocal / isDayKey / shiftDayKey`。
|
||||
|
||||
### 4.17 大模型助手与开发者模式 / 关怀模式
|
||||
|
||||
**大模型助手**(Web 管理端 →「AI 助手」)用一句话办事,但助手**只解析、不落库**:
|
||||
|
||||
| 环节 | 口径 |
|
||||
| -------- | --------------------------------------------------------------------------------------- |
|
||||
| 设置 | `GET / PUT /ai/settings`(`ai:manage`):开关 / provider / `baseUrl` / 模型 / 提示词补充 |
|
||||
| 密钥 | API Key 用 **AES-256-GCM** 加密后存 `AppSetting`(密钥由 `JWT_SECRET` 经 scrypt 派生),**只进不出**:GET 只回 `hasApiKey` 与 `keyHint`(末 4 位) |
|
||||
| 解析 | `POST /ai/interpret`(`ai:use`)→ `AiInterpretResult{ action, params, preview, confidence, needConfirm }` |
|
||||
| 动作 | `action ∈ create_homework / create_notification / call_student / unknown` |
|
||||
| 二次校验 | `params` 由服务端用**与手动入口同一份 zod** 再校验一遍,不合规直接判 `unknown` |
|
||||
| 落库 | 前端展示 `preview`(班级 / 科目 / 学生已解析成中文),用户确认后调**既有端点**发布 —— 权限与上课时段保护都还在那条路径上 |
|
||||
| provider | `openai` 走 `{baseUrl}/chat/completions`;`mock` 是**离线确定性解析**(自动化验收不联网) |
|
||||
| 审计 | 每次解析写一条 `AiAudit`;`GET /ai/audit`(`ai:manage`)可查谁在什么时候让助手做了什么 |
|
||||
|
||||
**系统设置**(`system:manage`,管理员)入口在「大模型设置」页底部的两张卡片:
|
||||
|
||||
- `GET / PUT /system/settings`:`developerMode` 开启后 **500 响应会附带 `error.stack`**
|
||||
(排查"服务端到底报了什么"),默认关闭;
|
||||
- `GET / PUT /system/autostart`:Windows 改
|
||||
`HKCU\Software\Microsoft\Windows\CurrentVersion\Run\ClassHelperServer`,Linux 走
|
||||
`systemctl enable/disable classhelper`;**非安装形态(开发机 / 免安装 / 容器)返回 `supported:false`**
|
||||
并给出原因,不假装成功。安装器 `scripts/nsis/server-installer.nsi` 已把开机自启做成
|
||||
**组件页上的可选 Section(默认勾选)**;
|
||||
- `GET /system/diagnostics`:运行诊断(版本 / Node / 平台 / 运行时长 / 数据库 provider /
|
||||
在线客户端数 / 已缓存安装包数 / 已加载模块数 / 服务端根目录 / 数据目录,**不含任何密钥**)。
|
||||
|
||||
**关怀模式与菜单回缩**(纯界面偏好,不落服务端):Web 端把「关怀模式」存在 `localStorage`
|
||||
(`classhelper.admin.careMode` + `careScale`,1.0~1.5、默认 1.2),开启时给 `<html>` 挂 `care-mode`
|
||||
并把倍数写进 `--ch-font-scale`;客户端把 `careMode` 存本机 `config.json`,同口径放大界面文字。
|
||||
侧栏折叠同样是本机偏好。`User.careMode` 列已经建好,但**服务端目前没有读写它** ——
|
||||
别把它当成"已经有服务端开关"。
|
||||
|
||||
## 5. 技术参考
|
||||
|
||||
### 5.1 目录结构
|
||||
|
||||
```
|
||||
@@ -907,7 +1024,7 @@ class-helper/
|
||||
│ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手(改写 schema.prisma 的 provider)
|
||||
│ ├── generate-icons.mjs # 启动 Electron 跑 icons/render.cjs(= pnpm icons)
|
||||
│ ├── icons/render.cjs # 把 classhelper.png 重采样成各端图标(CommonJS)
|
||||
│ ├── dist-server.mjs # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序)
|
||||
│ ├── dist-server.mjs # 服务端 + Web 管理端 打包(组装运行时 → NSIS 安装程序)
|
||||
│ ├── verify-packaged.mjs # 对打包后 / 已安装的客户端 EXE 复跑冒烟
|
||||
│ ├── lib/electron-env.mjs # 启动 Electron 的公共处理(清理 RUN_AS_NODE + 定位可执行文件)
|
||||
│ ├── lib/node-runtime.mjs # 定位真正的 node.exe(打包与 electron-builder 用)
|
||||
@@ -938,8 +1055,8 @@ class-helper/
|
||||
├── shared/ # 三端共享:类型契约、常量、权限矩阵、工具函数
|
||||
│ └── src/{types,constants,permissions,utils,index}.ts
|
||||
├── server/ # 后端服务
|
||||
│ ├── prisma/schema.prisma # 数据模型(14 个 model)
|
||||
│ ├── prisma/migrations/ # 迁移历史(10 个)
|
||||
│ ├── prisma/schema.prisma # 数据模型(24 个 model)
|
||||
│ ├── prisma/migrations/ # 迁移历史(17 个)
|
||||
│ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据)
|
||||
│ ├── prisma.config.ts # Prisma 7 配置(schema / 迁移 / 种子 / 连接串)
|
||||
│ ├── prisma/backups/ # 备份快照与定时配置(运行期生成,已 gitignore)
|
||||
@@ -957,7 +1074,7 @@ class-helper/
|
||||
│ ├── tools/apply-snapshot.ts # 跨库迁移的子进程入口(编译到 dist/tools/)
|
||||
│ ├── middleware/ # auth / error / validate / security(helmet+限流+耗时日志)
|
||||
│ ├── realtime/ # socket.ts + bus.ts(事件总线)
|
||||
│ └── modules/ # 15 个功能模块 + registry.ts(模块注册表)
|
||||
│ └── modules/ # 21 个功能模块 + registry.ts(模块注册表)
|
||||
│ ├── database/ # 数据库管理(状态/备份/导入导出/定时/一键切换,仅管理员)
|
||||
│ └── update/ # 更新检查(查 GitHub 最新 Release,服务端唯一出网请求)
|
||||
├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace)
|
||||
@@ -1042,7 +1159,7 @@ class-helper/
|
||||
| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) |
|
||||
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) |
|
||||
| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 |
|
||||
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) |
|
||||
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=&date=&from=&to=&createdBy=` | 登录 | 通知列表(带已读状态;可按所属日期与发布人筛选) |
|
||||
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
|
||||
| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` |
|
||||
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) |
|
||||
@@ -1068,7 +1185,7 @@ class-helper/
|
||||
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
|
||||
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
|
||||
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
|
||||
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 16 张表行数 / 备份列表 / 定时配置) |
|
||||
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 23 张表行数 / 备份列表 / 定时配置) |
|
||||
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
|
||||
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
|
||||
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
|
||||
@@ -1082,6 +1199,38 @@ class-helper/
|
||||
| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland |
|
||||
| GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) |
|
||||
| GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块);另有 `/healthz` 存活与 `/readyz` 就绪探针 |
|
||||
| GET | `/permissions/me` | 登录 | 我的生效权限(**无权限键**;班级端会话返回空集),两端据此隐藏入口 |
|
||||
| GET | `/permissions/catalog` | `permission:manage` | 权限键目录(分类 / 名称,供勾选树用) |
|
||||
| GET / POST | `/permissions/groups` | `permission:manage` | 权限组列表 / 新建 |
|
||||
| PATCH / DELETE | `/permissions/groups/:id` | `permission:manage` | 改名 / 改描述 / 整体替换权限键;内置组不可删 |
|
||||
| GET | `/permissions/users` | `permission:manage` | 账号列表(带各自所属权限组) |
|
||||
| PUT | `/permissions/users/:id/groups` | `permission:manage` | 设置账号所属权限组(空数组 = 回落角色默认模板) |
|
||||
| POST | `/clients/heartbeat` | 登录 | 客户端心跳(**无权限键**,班级端也调):返回更新目标 + 一条待执行命令 |
|
||||
| POST | `/clients/commands/:id/ack` | 登录 | 命令回执(**无权限键**;body 的 `deviceKey` 校验归属,幂等) |
|
||||
| POST | `/clients/commands` | `remote:control` | 下发远控命令(关机 / 重启 / 注销 / 锁屏;自动撤销该机未回执的旧命令) |
|
||||
| GET | `/clients/devices?classId=` | `client:manage` | 客户端设备列表(在线判定窗口 90 秒) |
|
||||
| POST | `/clients/updates` | `client:update-push` | 给指定设备下发更新目标版本 |
|
||||
| DELETE | `/clients/devices/:id` | `client:manage` | 移除设备记录 |
|
||||
| GET | `/update/client/releases` | `client:manage` | 已缓存的客户端安装包列表 |
|
||||
| POST | `/update/client/fetch` | `client:update-push` | 服务端代下 GitHub 上的安装包并登记 sha256 + size |
|
||||
| POST | `/update/client/releases` | `client:update-push` | 手工登记安装包(文件先放进 `data/client-releases/<版本>/`) |
|
||||
| DELETE | `/update/client/releases/:version` | `client:update-push` | 删除缓存的安装包 |
|
||||
| GET | `/update/client/download/:version` | 登录 | 下载安装包(**故意不挂权限键**,班级端要能下载;版本号白名单防路径穿越) |
|
||||
| POST | `/homeworks/bulk` | `homework:write` | 跨班批量发布作业(每班可选 `subjectName`,按科目名在该班匹配) |
|
||||
| GET | `/notifications/days?classId=&from=&to=&createdBy=` | 登录 | 哪些天有通知(按天归档,供日期高亮) |
|
||||
| GET | `/notifications/creators?classId=&from=&to=&createdBy=&date=` | 登录 | 发布人列表(含每人条数) |
|
||||
| POST | `/notifications/bulk` | `notification:publish` | 跨班批量发布通知(逐班判定,单班失败不影响整批) |
|
||||
| GET / PUT | `/ai/settings` | `ai:manage` | 大模型设置(API Key 加密存储、**只进不出**,只回 `hasApiKey` / `keyHint`) |
|
||||
| POST | `/ai/interpret` | `ai:use` | 助手解析自然语言(**只解析不落库**,返回 `action` / `params` / `preview`) |
|
||||
| GET | `/ai/audit?limit=` | `ai:manage` | 助手使用审计(谁在什么时候让助手做了什么) |
|
||||
| GET / PUT | `/system/settings` | `system:manage` | 系统设置(`developerMode`:开启后 500 响应带 `error.stack`) |
|
||||
| GET / PUT | `/system/autostart` | `system:manage` | 服务端开机自启状态 / 开关(非安装形态 `supported:false` 并给原因) |
|
||||
| GET | `/system/diagnostics` | `system:manage` | 运行诊断(版本 / Node / 运行时长 / 在线客户端数 / 数据目录…,不含密钥) |
|
||||
|
||||
> 新增的这几组用**权限键**(`permission:manage` / `client:manage` / `remote:control` /
|
||||
> `client:update-push` / `ai:use` / `ai:manage` / `system:manage` / `homework:write` /
|
||||
> `notification:publish`)标权限,规则见 [§5.5](#55-角色与权限rbac) 与
|
||||
> [角色与权限](dev/permissions.md);上表其余行写的是**角色默认模板**下的口径。
|
||||
|
||||
### 5.4 WebSocket 事件
|
||||
|
||||
@@ -1147,28 +1296,45 @@ class-helper/
|
||||
- 成绩相关写入按「老师端可用 + 不越权」实现(需求 6 / 7 口径):录入 / 修改 / 删除成绩与成绩模板下载 / 预览 / 导入
|
||||
都是「管理员 + 本班班主任」,学生名单模板与导入仅管理员,班科任老师一律 403 —— 服务层兜底,前端只是按同一矩阵隐藏。
|
||||
|
||||
**权限组:角色只是默认模板**(完整规则见[角色与权限](dev/permissions.md)):
|
||||
|
||||
- 上面的矩阵描述的是**角色默认模板**(`defaultPermissionsForRole`)—— 它只在账号**没有加入任何权限组**时生效。
|
||||
- 账号可以加入若干权限组,生效权限 = 这些组权限键的**并集**,因此可以比角色默认**更少**
|
||||
(要收回权限就建一个只勾部分键的组);删掉组后成员回到角色默认模板,不会变成空权限。
|
||||
- `ADMIN` 恒定全集,`permission:manage` / `system:manage` **不可被剥夺**(否则可能把自己锁在外面);
|
||||
`CLASS_DEVICE` 恒定空集,不受权限组影响。
|
||||
- 唯一计算入口是 `src/lib/permissions.ts` 的 `loadEffectivePermissions()`,**每请求实时查询、不缓存**;
|
||||
路由上的 `requirePermission('<key>')` 与权限页的勾选树共用 `@classhelper/shared` 里同一份权限键目录,
|
||||
`PERMISSION_MATRIX` 与它不再是两套规则(矩阵就是默认模板的说明)。
|
||||
|
||||
### 5.6 模块化设计
|
||||
|
||||
后端每个功能是一个独立目录,统一契约 `ApiModule = { name, basePath, router, enabled? }`:
|
||||
|
||||
```ts
|
||||
// packages/server/src/modules/registry.ts(当前 15 个模块)
|
||||
// packages/server/src/modules/registry.ts(当前 21 个模块)
|
||||
export const apiModules: ApiModule[] = [
|
||||
authModule,
|
||||
aiModule, // ← 大模型助手
|
||||
classesModule,
|
||||
clientsModule, // ← 客户端设备 / 心跳 / 远控
|
||||
coursesModule,
|
||||
schedulesModule,
|
||||
homeworksModule,
|
||||
notificationsModule,
|
||||
permissionsModule, // ← 权限组
|
||||
callsModule,
|
||||
importsModule,
|
||||
integrationsModule, // ← ClassIsland 联动
|
||||
gradesModule,
|
||||
studentsModule,
|
||||
systemModule, // ← 系统设置(开发者模式 / 开机自启 / 诊断)
|
||||
teachersModule,
|
||||
archivesModule,
|
||||
termModule,
|
||||
dashboardModule,
|
||||
databaseModule, // ← 数据库管理(仅管理员)
|
||||
updateModule, // ← 更新检查(查 GitHub 最新 Release)
|
||||
updateModule, // ← 更新检查 + 客户端安装包(查 GitHub 最新 Release)
|
||||
].filter((module) => module.enabled !== false);
|
||||
```
|
||||
|
||||
@@ -1192,21 +1358,28 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
||||
|
||||
> MySQL 驱动适配器 `@prisma/adapter-mariadb` **已随包内置**(数据库管理模块的连接测试 / 迁移需要),无需手动安装。
|
||||
|
||||
数据模型共 **16 张表**:`User`(纯账号表)/ `Student`(名单,不是账号)/ `StudentClassTransfer`(调班与转出历史)/
|
||||
数据模型共 **24 张表**:`User`(纯账号表)/ `Student`(名单,不是账号)/ `StudentClassTransfer`(调班与转出历史)/
|
||||
`ArchivedYear`(毕业归档届别)/ `Class` / `TermWeek`(学期周次区间)/ `Course`(= 班级 + 科目 + 教师的任课关系)/
|
||||
`Schedule` / `Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `TimeLayout` / `Grade` /
|
||||
`IntegrationDevice` / `ClassIslandPush`。
|
||||
`IntegrationDevice` / `ClassIslandPush` / `PermissionGroup` / `PermissionGroupItem` / `UserPermissionGroup`(权限组)/
|
||||
`AppSetting`(全局键值设置)/ `ClientDevice` / `ClientCommand`(客户端设备与远控)/ `ClientRelease`(安装包缓存)/
|
||||
`AiAudit`(大模型审计)。
|
||||
其中 `Class.name` 是「2026级1班」这种**届别称呼**;`Class.code` / `Class.passwordHash` 是班级端账号字段;
|
||||
`Class.notificationChannel` 决定提醒弹在哪端;`Class.studentGradeQueryEnabled` 是班级端查成绩的开关;
|
||||
`Student.studentNo` 是学生的唯一标识与查询键;`Grade.levelType` / `level` 是等级口径与等级;
|
||||
`Homework.assignDate` 是作业的「所属日期」;`Schedule.weekParity` 是单双周;`Schedule.source` 是课表来源;
|
||||
`ClassIslandPush.kind` 区分通知类与叫人。
|
||||
`Homework.assignDate` 是作业的「所属日期」;`Notification.assignDate` 是通知的「所属日期」(本地日期,
|
||||
配 `@@index([classId, assignDate])` 支撑按天查询);`User.careMode` 是关怀模式的预留列(服务端暂未读写);
|
||||
`Schedule.weekParity` 是单双周;`Schedule.source` 是课表来源;`ClassIslandPush.kind` 区分通知类与叫人。
|
||||
|
||||
迁移历史(**16 个**,按时间顺序):`init` → `add_time_layout` → `add_class_account` → `add_schedule_week_parity` →
|
||||
备份 / 导出导入共用 `src/lib/snapshot.ts` 的 `SNAPSHOT_TABLES`,按拓扑序导出 **23 张**
|
||||
(`TermWeek` 不在其中 —— 学期周次不进快照,换库后按开学日期重新生成)。
|
||||
|
||||
迁移历史(**17 个**,按时间顺序):`init` → `add_time_layout` → `add_class_account` → `add_schedule_week_parity` →
|
||||
`add_class_term_weeks` → `add_classisland_integration` → `add_class_notification_channel` →
|
||||
`add_homework_assign_date` → `add_push_kind` → `add_schedule_source` →
|
||||
`add_student_table` → `add_student_grade_query` → `add_grade_level` →
|
||||
`add_classhelper_heartbeat` → `add_archives` → `add_term_weeks`。
|
||||
`add_classhelper_heartbeat` → `add_archives` → `add_term_weeks` → `add_permission_groups_and_clients`
|
||||
(最后一个只加表 / 加列:8 张新表 + `Notification.assignDate` 与索引 + `User.careMode`,无数据搬运)。
|
||||
|
||||
**启动时迁移(安装版自动升级)**:`src/lib/db-bootstrap.ts` 用一张账本表 `_ch_migrations` 记录已执行的迁移目录名 ——
|
||||
全新安装依次执行全部迁移并创建初始管理员;覆盖安装只补跑账本里没有的迁移(已存在的表 / 索引自动跳过),
|
||||
@@ -1307,15 +1480,15 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
||||
| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文 / 占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` |
|
||||
| **新手引导(首启弹出 + 随时重看)** | ✅ | Web 管理端:首次登录弹出 `el-tour` 聚焦引导,头像菜单「使用引导」重看(`verify:web`);客户端:首启六步向导,登录页 / 设置页可重看(`verify:desktop`) |
|
||||
| **客户端设置改版(分组侧栏 / 深色模式 / 关于页)** | ✅ | `verify:desktop`:「侧边栏点击导航」适配「学习 / 设置」分组;「主题切换与侧边栏折叠(真实点击、写回配置)」——`html.dark` 翻转 + 折叠宽度 200→64 + 双双落盘;「关于页渲染」含 configPath |
|
||||
| **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `verify:e2e` 13 项:状态与 14 表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路真机实测**:快照 728 行 → generate → db push → apply-snapshot 逐表一致;`verify:web`:数据库管理页(仅管理员)状态卡与备份表渲染 |
|
||||
| **更新检查(服务端 / Web 端 / 客户端)** | ✅ | `verify:e2e` 4 项:未登录 401、管理员结构完整且本机版本取自 package.json、教师可查但 `force` 仅管理员生效、健康检查暴露版本号与 15 个模块;`verify:desktop` 4 项:IPC 返回结构正确(无外网时降级 `ok=false` 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;`verify:web`:「顶栏用户菜单 → 弹窗给出结论并可关闭」。**真机联网实测**:`GET /api/update/check` → `ok=true`、`latestVersion=1.0.0`、5 个附件、更新说明 1358 字 |
|
||||
| **数据库管理(仅管理员:状态 / 备份 / 导入导出 / 一键切换)** | ✅ | `verify:e2e` 13 项:状态与全部数据表行数、教师全端点 403、连接测试(当前库可达 / 不可达 MySQL ok=false)、**Redis 主库 422**、切换校验分支(同库 400)、备份 → 快照导入 → 备份恢复 roundtrip(临时数据被清除)、定时配置校验、SQLite 文件下载;**切换子进程链路真机实测**:快照 728 行 → generate → db push → apply-snapshot 逐表一致;`verify:web`:数据库管理页(仅管理员)状态卡与备份表渲染 |
|
||||
| **更新检查(服务端 / Web 端 / 客户端)** | ✅ | `verify:e2e` 4 项:未登录 401、管理员结构完整且本机版本取自 package.json、教师可查但 `force` 仅管理员生效、健康检查暴露版本号与模块清单(含 update 模块);`verify:desktop` 4 项:IPC 返回结构正确(无外网时降级 `ok=false` 且带原因)、结果带缓存、冒烟模式跳过启动自动检查、关于页出现「检查更新」;`verify:web`:「顶栏用户菜单 → 弹窗给出结论并可关闭」。**真机联网实测**:`GET /api/update/check` → `ok=true`、`latestVersion=1.0.0`、5 个附件、更新说明 1358 字 |
|
||||
| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送」「叫人也会推送」;2026-09-27 在真机(ClassIsland 2.1.0.1 + 本插件)上实测过,当时的抓图留在 `docs/screenshots/classisland/` |
|
||||
| **作业按天查看(日期选择器 + 有作业日期高亮)** | ✅ | `verify:e2e`:`assignDate` 默认服务器当天、`?date=` 只返回该天、`/homeworks/days` 返回有作业日期、非法日期 422 |
|
||||
| **支持在客户端录入作业(含自定义快捷短语)** | ✅ | `verify:e2e`:班级账号录入 201(归属班主任)、跨班 403;客户端录入弹窗 + 「设置 → 通用」管理短语 |
|
||||
| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 提醒」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置);服务端按班级设置决定是否推送(`verify:classisland`:`client` 时通知与联动页下发都被跳过) |
|
||||
| **ClassHelper能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增 / 更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 |
|
||||
| **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收 ClassHelper提醒」(本机总闸)+ Web 端「下发提醒」(标题 / 内容 / 时长 / 优先级 / 语音朗读 / 是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 |
|
||||
| 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe` / `win-unpacked/*.exe`(见 [7. 打包与交付](#7-打包与交付)) |
|
||||
| 能成功打包 Windows EXE | ✅ | `-x64-setup.exe` / `-x64-portable.exe`(见 [7. 打包与交付](#7-打包与交付)) |
|
||||
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、数据库、打包与常见问题 |
|
||||
|
||||
### 6.2 最近一次实测
|
||||
@@ -1370,10 +1543,10 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
||||
| ----------------------------------- | -------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 客户端渲染产物 `dist/renderer` | 3.2MB | **2.1MB** | 成绩页图表从 echarts 全量(1.12MB,占 35%)换成内置 HTML/SVG 组件;Element Plus 图标从全量注册 293 个改为按需 42/33 个 |
|
||||
| 客户端主进程产物体积 | 242KB | 242KB | 未变(冒烟代码拆分会重复 `island`/`config` 单例,esbuild 的 CJS 打包无法代码分割,故保留) |
|
||||
| 客户端 `win-unpacked` | 372MB | **323MB** | `electronLanguages: [zh-CN]`,只留中文语言包(其余 54 个 `locales/*.pak` 共 48MB) |
|
||||
| 客户端解包目录(中间物,打完即删) | 372MB | **323MB** | `electronLanguages: [zh-CN]`,只留中文语言包(其余 54 个 `locales/*.pak` 共 48MB) |
|
||||
| 客户端 `setup.exe` / `portable.exe` | 107MB | **98MB** | 同上。**安装包有硬底**:`ClassHelper.exe`(electron.exe)压缩后本身就有 85~90MB,60MB 不可达 |
|
||||
| 服务端 `dist` | 3.8MB | **0.6MB** | `tsconfig.build.json` 关掉 `declaration`/`declarationMap`/`sourceMap`(占原体积 82%) |
|
||||
| 服务端免安装目录 | 436MB | **335MB** | 打包末尾的运行时裁剪:删掉 Prisma 为 postgresql/cockroachdb/sqlserver 准备的查询编译器副本、`*.map`、文档与测试夹具(共 2329 项 / 101MB) |
|
||||
| 服务端组装运行时(中间物,打完即删) | 436MB | **335MB** | 打包末尾的运行时裁剪:删掉 Prisma 为 postgresql/cockroachdb/sqlserver 准备的查询编译器副本、`*.map`、文档与测试夹具(共 2329 项 / 101MB) |
|
||||
| 服务端安装程序 | 71MB | **62MB** | 同上 |
|
||||
| Web 端前端产物 | 3.3MB | **2.1MB** | 同样是 echarts → 内置 SVG/CSS 图表 + 图标按需 |
|
||||
| 灵动岛主进程常驻开销 | 每 60ms 2 次系统调用 | 每 60ms 1 次 | 光标位置每跳只读一次(命中判定与"跟随鼠标屏幕"共用);窗口不可见时整跳 |
|
||||
@@ -1386,7 +1559,6 @@ pnpm db:generate && pnpm --filter @classhelper/server db:deploy && pnpm db:seed
|
||||
### 客户端(Windows EXE)
|
||||
|
||||
```bash
|
||||
pnpm dist:dir # 免安装目录:releases/client/<版本>/免安装/ClassHelper.exe
|
||||
pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS 工具,可用镜像)
|
||||
```
|
||||
|
||||
@@ -1394,32 +1566,39 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
|
||||
(`--config.electronDist`,不重复下载 150MB+);默认走国内镜像(`ELECTRON_MIRROR`、
|
||||
`ELECTRON_BUILDER_BINARIES_MIRROR`,可用环境变量覆盖);构建缓存固定到仓库内 `.cache/`;
|
||||
用 `--config.directories.output` 把输出目录覆盖成 `releases/client/<版本>/`,打包成功后再把
|
||||
electron-builder 的原始布局整理成 `安装包/`、`免安装/`、`构建中间/`;
|
||||
收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,见 [8. 常见问题](#8-常见问题))。
|
||||
electron-builder 的原始布局整理成 `安装包/`、`构建中间/`,并把 `win-unpacked/`(约 323MB 的中间物)
|
||||
删掉;收尾把交付物完整性标签改成 Medium(Low 标签会导致装不上 / 双击没反应,
|
||||
见 [8. 常见问题](#8-常见问题))。
|
||||
|
||||
产物结构:`免安装/ClassHelper.exe`(Electron 运行时)+ `resources/app.asar`
|
||||
(仅含 `dist/` 与 `package.json`,运行时不需要 node_modules)。
|
||||
**只有两个交付物**,都落在 `releases/client/<版本>/安装包/`:`-x64-setup.exe`(NSIS 安装程序)
|
||||
与 `-x64-portable.exe`(自解压单文件,双击即用)。解包目录 `win-unpacked/` 只是生成这两个 exe 的
|
||||
中间产物 —— 便携版是拿它自己做成的自解压包,所以它必须活到 portable 这一步之后才能删,
|
||||
`tidyArtifacts()` 就排在最后。
|
||||
|
||||
> **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `releases/client/<版本>/`。
|
||||
> **把修复交付到「已安装的客户端」**:`pnpm dist:win` 只产出 `releases/client/<版本>/` 里的两个安装包。
|
||||
> 若学生机上是**安装版**(例如装在 `D:\class\@classhelperdesktop-client`),只更新 release 而不同步安装目录,
|
||||
> 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。覆盖升级(用户配置在 `%APPDATA%`,不受影响):
|
||||
> 用户打开的还是旧 `resources/app.asar`,会出现「改了但界面不生效」。两个可行做法:
|
||||
>
|
||||
> ```powershell
|
||||
> # 1) 先退出客户端(托盘右键退出;有进程残留就精确结束 PID)
|
||||
> # 2) 用新产物覆盖安装目录(不删 Uninstall 等安装器文件,所以不加 /MIR)
|
||||
> robocopy "packages\desktop-client\release\win-unpacked" "D:\class\@classhelperdesktop-client" /E /NFL /NDL /NJH /NJS /R:2 /W:1
|
||||
> # 3) 实测**那个副本**真的带上了修复(packaged=true)
|
||||
> # 做法一(推荐):直接跑新安装包覆盖升级。用户配置在 %APPDATA%,不受影响。
|
||||
> # 静默:releases\client\<版本>\安装包\ClassHelper-<版本>-x64-setup.exe /S
|
||||
> # 再实测**装好那个副本**真的带上了修复(packaged=true)
|
||||
> pnpm verify:packaged --exe "D:\class\@classhelperdesktop-client\ClassHelper.exe"
|
||||
>
|
||||
> # 做法二:要按文件覆盖(例如只换 app.asar),先让 portable 自解压出一份再拷
|
||||
> # 便携版双击后会解到 %TEMP% 下的一个随机目录,从那里 robocopy 即可。
|
||||
> ```
|
||||
>
|
||||
> 注意路径变了:`dist-win.mjs` 不再在 releases 里留解包目录,
|
||||
> 所以 `packages\desktop-client\release\win-unpacked` 这个旧路径已经不存在了。
|
||||
|
||||
最近一次打包实测(产物不入库):
|
||||
|
||||
| 产物 | 大小 | 冒烟结果 |
|
||||
| -------------------------------------- | ------ | -------------------------------------------------------------------- |
|
||||
| ------------------------------------------------- | ------ | -------------------------------------------------------------------- |
|
||||
| 服务端安装程序(内置 Node + Web 端) | ~62MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 |
|
||||
| `releases/client/<版本>/免安装/ClassHelper.exe` | ~235MB | ✅ 打包版冒烟全绿,`packaged: true`,退出码 0 |
|
||||
| `releases/client/<版本>/安装包/…-portable.exe` | ~98MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 |
|
||||
| `releases/client/<版本>/安装包/…-setup.exe` | ~98MB | ✅ 构建成功,已嵌入自定义图标 |
|
||||
| `releases/client/<版本>/安装包/…-portable.exe` | ~99MB | ✅ 打包后冒烟全绿,`packaged: true`,退出码 0 |
|
||||
| `releases/client/<版本>/安装包/…-setup.exe` | ~99MB | ✅ 构建成功,已嵌入自定义图标 |
|
||||
| `releases/classisland-plugin/<版本>/…cipx` | ~88KB | ✅ 全量编译通过(1 警告 0 错误),包内 DLL 哈希与 `bin/Release` 一致 |
|
||||
|
||||
### ClassIsland 插件
|
||||
|
||||
+1
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "class-helper",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||
"packageManager": "pnpm@11.8.0",
|
||||
@@ -44,7 +44,6 @@
|
||||
"dev:site": "pnpm docs:build && node website/serve.mjs",
|
||||
"docs:check": "node docs/build.mjs --check",
|
||||
"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",
|
||||
|
||||
@@ -14,6 +14,6 @@ description: |-
|
||||
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
|
||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||
url: https://github.com/LoveChengke/ClassHelper
|
||||
version: 1.2.0.0
|
||||
version: 1.3.0.0
|
||||
apiVersion: 2.0.0.0
|
||||
author: ClassHelper
|
||||
@@ -149,25 +149,14 @@ if (!fs.existsSync(cipxFile)) {
|
||||
// 归集到 releases/classisland-plugin/<版本>/(与客户端、服务端交付产物同一约定;releases/ 不入库)。
|
||||
// 只清理**当前版本**目录,历史版本原样保留 —— 原先这里是 rmSync 整个组件目录,重打一次就把
|
||||
// 上一版的留档抹掉了(插件版本是四段 <产品版本>.0,目录名取前三段与产品版本对齐)。
|
||||
// 产物只有 `安装包/*.cipx` + `checksums.md`:**.cipx 本身就是个 zip**,解压即用的那份
|
||||
// 「免安装目录」与它内容完全一样,纯属重复留档,已不再产出。
|
||||
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.rmSync(path.join(versionDir, '免安装'), { recursive: true, force: true });
|
||||
fs.mkdirSync(installerDir, { recursive: true });
|
||||
fs.mkdirSync(portableDir, { recursive: true });
|
||||
|
||||
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
|
||||
for (const name of fs.readdirSync(outputDir)) {
|
||||
const from = path.join(outputDir, name);
|
||||
if (!fs.statSync(from).isFile()) continue;
|
||||
// 依赖清单与调试符号对加载没影响,但会让老师误以为"要装一堆东西",因此只带最小集合
|
||||
if (!['ClassHelper.ClassIslandPlugin.dll', 'manifest.yml', 'icon.png', 'README.md'].includes(name)) {
|
||||
continue;
|
||||
}
|
||||
fs.copyFileSync(from, path.join(portableDir, name));
|
||||
}
|
||||
|
||||
for (const file of fs.readdirSync(cipxDir)) {
|
||||
// .cipx 是插件包本体,校验清单跟着版本目录走
|
||||
@@ -177,4 +166,3 @@ for (const file of fs.readdirSync(cipxDir)) {
|
||||
|
||||
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}`);
|
||||
@@ -507,7 +507,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
/* ---------------------------------------------------------------- 内部实现 */
|
||||
|
||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||
private const string PluginVersion = "1.2.0.0";
|
||||
private const string PluginVersion = "1.3.0.0";
|
||||
|
||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||
private static string SafeAppVersion()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/desktop-client",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||
"author": "Class Helper",
|
||||
@@ -14,8 +14,7 @@
|
||||
"start": "electron .",
|
||||
"smoke": "node scripts/smoke.mjs",
|
||||
"typecheck": "vue-tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.node.json",
|
||||
"dist:win": "npm run build && node scripts/dist-win.mjs",
|
||||
"dist:dir": "npm run build && node scripts/dist-win.mjs --dir"
|
||||
"dist:win": "npm run build && node scripts/dist-win.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@classhelper/shared": "workspace:*",
|
||||
|
||||
@@ -10,12 +10,15 @@
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/dist-win.mjs # nsis 安装包 + portable 单文件
|
||||
* node scripts/dist-win.mjs --dir # 仅生成免安装目录
|
||||
*
|
||||
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
|
||||
* 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
||||
* 免安装/ electron-builder 的 win-unpacked 内容
|
||||
* 构建中间/ builder-*.yml、*.nsis.7z
|
||||
*
|
||||
* 产物里**没有免安装目录**:electron-builder 的 `win-unpacked/` 只是生成两个 exe 的中间物
|
||||
* (每版 320MB 上下),打完就删 —— 便携版 `-portable.exe` 已是自解压单文件,交付不需要解包目录。
|
||||
* 因此也没有 `--dir`:那等于只产出中间物、拿不到任何交付物。需要临时跑一下解包目录时直接调
|
||||
* electron-builder,别往 releases/ 里塞。
|
||||
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
|
||||
* 显式传入 `-c.directories.output=...` 时不覆盖,整理(tidyArtifacts)以传入的目录为准。
|
||||
*/
|
||||
@@ -27,9 +30,7 @@ import { resolveNodeRuntime } from '../../../scripts/lib/node-runtime.mjs';
|
||||
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = path.resolve(root, '..', '..');
|
||||
const args = process.argv.slice(2);
|
||||
const dirOnly = args.includes('--dir');
|
||||
const passthrough = args.filter((item) => item !== '--dir');
|
||||
const passthrough = process.argv.slice(2);
|
||||
|
||||
// makensis 会在 %TEMP% 所在位置建临时文件:Git Bash 里 TEMP=/tmp 是 MSYS 路径,
|
||||
// 原生程序会把它当成「当前盘根下的 tmp」(D:\tmp,不存在)而报
|
||||
@@ -74,7 +75,7 @@ const destDir = explicitOutput
|
||||
: path.join(repoRoot, 'releases', 'client', clientVersion);
|
||||
if (!explicitOutput) overrides.push(`--config.directories.output=${destDir}`);
|
||||
|
||||
const cliArgs = [cli, '--win', ...(dirOnly ? ['--dir'] : []), ...overrides, ...passthrough];
|
||||
const cliArgs = [cli, '--win', ...overrides, ...passthrough];
|
||||
console.log('[dist] electron-builder', cliArgs.slice(1).join(' '));
|
||||
|
||||
// 必须用真正的 node 执行 electron-builder CLI:
|
||||
@@ -98,7 +99,17 @@ for (const name of ['win-unpacked', '免安装', '安装包', '构建中间']) {
|
||||
console.warn(`[dist] 无法清理旧产物 ${name}:${error.message}`);
|
||||
}
|
||||
}
|
||||
if (cleaned) console.log('[dist] 已清理上一轮的免安装目录 / 安装包 / 构建中间');
|
||||
if (cleaned) console.log('[dist] 已清理上一轮的中间物与安装包');
|
||||
|
||||
// 打包**之前**就要把输出目录的完整性标签压回 Low(继承),不留到收尾 ——
|
||||
// 实测过一次:上一轮留下的 win-unpacked 是收尾时用 icacls 标成 Medium 的,
|
||||
// 而 7za 自身带 Low 标签,pack 时读不动这些 Medium 目录,报
|
||||
// "cannot open file ... Access is denied",打包直接失败。
|
||||
try {
|
||||
execFileSync('icacls', [destDir, '/setintegritylevel', '(OI)(CI)Low'], { stdio: 'pipe', windowsHide: true });
|
||||
} catch {
|
||||
/* 宿主目录树本就没有 Low 标签体系时会被系统拒绝,保持继承即可 */
|
||||
}
|
||||
|
||||
const child = spawn(runtime.path, cliArgs, { cwd: root, stdio: 'inherit', env });
|
||||
|
||||
@@ -138,9 +149,9 @@ function normalizeIntegrityLabel(dir) {
|
||||
|
||||
/**
|
||||
* 把 electron-builder 的原始输出整理成 releases 的固定分层:
|
||||
* win-unpacked/ → 免安装/
|
||||
* *.exe / *.blockmap → 安装包/
|
||||
* *.yml / *.nsis.7z → 构建中间/
|
||||
* win-unpacked/ → **直接删掉**(见文件头:它只是生成两个 exe 的中间物)
|
||||
* electron-builder 每次都会按自己的布局重写这些路径,因此整理只放在打包成功之后做;
|
||||
* 同名文件用 renameSync 同盘移动,不会产生大文件的额外拷贝。
|
||||
*/
|
||||
@@ -152,11 +163,12 @@ function tidyArtifacts(dir) {
|
||||
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);
|
||||
// 必须留在最后删:electron-builder 的 portable 目标就是拿它自己解包出来的 app 做自解压包,
|
||||
// 提前删会让 `-portable.exe` 这一步找不到源文件。
|
||||
const throwaway = [];
|
||||
for (const name of ['win-unpacked', '免安装']) {
|
||||
const from = path.join(dir, name);
|
||||
if (fs.existsSync(from)) throwaway.push(from);
|
||||
}
|
||||
|
||||
for (const name of fs.readdirSync(dir)) {
|
||||
@@ -165,6 +177,31 @@ function tidyArtifacts(dir) {
|
||||
if (/\.(exe|blockmap)$/i.test(name)) moveInto(from, '安装包');
|
||||
else if (/\.(yml|7z)$/i.test(name)) moveInto(from, '构建中间');
|
||||
}
|
||||
|
||||
for (const from of throwaway) {
|
||||
// 体积必须在删之前量:删完再 walk 只会得到 0
|
||||
const mb = Math.round(dirSizeMb(from));
|
||||
try {
|
||||
fs.rmSync(from, { recursive: true, force: true });
|
||||
console.log(`[dist] 已删除中间物 ${path.basename(from)}/(约 ${mb} MB)`);
|
||||
} catch (error) {
|
||||
console.warn(`[dist] 中间物 ${path.basename(from)}/ 删除失败(不影响交付物):${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 交付目录里没有现成的体积统计函数,这里只在打日志时用一次 */
|
||||
function dirSizeMb(target) {
|
||||
let total = 0;
|
||||
const walk = (dir) => {
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) walk(full);
|
||||
else total += fs.statSync(full).size;
|
||||
}
|
||||
};
|
||||
if (fs.existsSync(target)) walk(target);
|
||||
return total / 1024 / 1024;
|
||||
}
|
||||
|
||||
child.on('exit', (code) => {
|
||||
|
||||
@@ -67,6 +67,15 @@ const isCall = computed(() => kind.value === 'call');
|
||||
const isHomework = computed(() => kind.value === 'homework');
|
||||
const queueCount = computed(() => state.value.queued.length);
|
||||
|
||||
/**
|
||||
* 是不是"点了知道了 / 标为已读"之后的**收回动画**阶段(`reason === 'closing'`)。
|
||||
*
|
||||
* 这一阶段主进程已经把状态清空(`active` 为 null、`queued` 为空),形态却切回了胶囊:
|
||||
* 卡片正沿既有的形变路径收回,**胶囊里不该显示任何文字** —— 否则会闪出一句
|
||||
* "新消息 · 点击查看"(那是"有新消息"的语义,与"正在消失"正好相反)。
|
||||
*/
|
||||
const isClosing = computed(() => state.value.reason === 'closing');
|
||||
|
||||
/**
|
||||
* 这一批**未处理**的消息(正在展示的 + 队列里的),按「重要程度 → 时间」排序。
|
||||
* 与主进程 `pendingNotifications()` 同一口径(同一份 `PRIORITY_RANK`)。
|
||||
@@ -460,6 +469,8 @@ const islandClass = computed(() => ({
|
||||
call: isCall.value,
|
||||
homework: isHomework.value,
|
||||
'after-class': showAfterClassHint.value,
|
||||
// 收回动画:胶囊里不显示内容(见 isClosing 与 .island-card.closing 的说明)
|
||||
closing: isClosing.value,
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -1048,6 +1059,18 @@ body {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
/**
|
||||
* 收回动画(`reason === 'closing'`):胶囊收回过程中**不显示任何胶囊内容**。
|
||||
*
|
||||
* 这时状态已经在同一帧清空(没有可展示的消息),若照常渲染胶囊层,卡片刚收回就会闪出
|
||||
* 一句"新消息 · 点击查看" —— 语义正好相反。这里保留 DOM(宽度固定为 `--island-w`,
|
||||
* 内容盒不会重排,符合"开合时胶囊内容不许重排"的约定)只把内容隐藏,
|
||||
* 卡片形状照样沿既有弹簧收回,随后由主进程淡出窗口。
|
||||
*/
|
||||
.island-card.closing .pill-inner {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.pill-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,304 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { BrowserWindow, app } from 'electron';
|
||||
import { CLIENT_UPDATE_IDLE_MS, type ClientUpdateInfoDto } from '@classhelper/shared';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { island } from './island.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
|
||||
*
|
||||
* 设计要点(都是"教室机器"这个使用场景逼出来的):
|
||||
* 1. **下载随时进行、安装只在完全空闲时**:教室机的带宽与注意力都有限,上课期间重启应用是灾难,
|
||||
* 所以安装的三个必要条件缺一不可 —— 不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)、
|
||||
* 本进程还没装过;
|
||||
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
|
||||
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
|
||||
* 3. **安装脚本脱离本进程**:`start /wait` 等安装器跑完后重新拉起应用,
|
||||
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
|
||||
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
|
||||
*/
|
||||
|
||||
/** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */
|
||||
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
|
||||
|
||||
/** 下载文件名的合法性(服务端给的 fileName 会拼进本地路径,必须挡住路径穿越) */
|
||||
const SAFE_FILE_NAME = /^[A-Za-z0-9._-]+$/;
|
||||
|
||||
/** 待安装更新的目录:`<userData>/updates`(放在 userData 下,随配置一起被卸载/重置清掉) */
|
||||
export function pendingUpdateDir(): string {
|
||||
return path.join(app.getPath('userData'), 'updates');
|
||||
}
|
||||
|
||||
/** 主窗口"已隐藏多久"的起点(null = 窗口当前可见或还没建) */
|
||||
let mainWindowHiddenAt: number | null = null;
|
||||
/** 本进程是否已经发起过安装(防止心跳与 hide 事件重复触发) */
|
||||
let installing = false;
|
||||
/**
|
||||
* 退出本进程的回调(由 index.ts 注册成既有的 `quitApp()`)。
|
||||
*
|
||||
* 为什么用注册而不是直接 `import { quitApp } from './index.js'`:那会形成
|
||||
* index.ts ⇄ auto-update.ts 的循环导入,ESM 下 `quitApp` 的绑定在求值顺序变化时会变成未初始化引用。
|
||||
*/
|
||||
let quitHandler: (() => void) | null = null;
|
||||
|
||||
/** 注册"安装前退出本进程"的回调(index.ts 启动时调用一次) */
|
||||
export function setQuitHandler(handler: () => void): void {
|
||||
quitHandler = handler;
|
||||
}
|
||||
/** 正在下载的版本(同一版本不并发下载) */
|
||||
const downloading = new Set<string>();
|
||||
|
||||
/** 这次运行的主窗口(安装判定要看它可见性;窗口重建后重新挂一遍) */
|
||||
let hookedWindow: BrowserWindow | null = null;
|
||||
|
||||
/**
|
||||
* 挂主窗口的显示/隐藏监听(index.ts 在 `createWindow()` 之后调用)。
|
||||
*
|
||||
* 为什么要"记时间戳"而不是每次现算:`BrowserWindow` 没有"隐藏了多久"这种查询,
|
||||
* 而「窗口隐藏满 5 分钟」是本功能的核心判据,只能在 hide/show 事件上自己打点。
|
||||
*/
|
||||
export function watchMainWindow(win: BrowserWindow): void {
|
||||
if (hookedWindow === win) return;
|
||||
hookedWindow = win;
|
||||
win.on('hide', () => {
|
||||
if (mainWindowHiddenAt === null) mainWindowHiddenAt = Date.now();
|
||||
// 刚隐藏正是最合适的安装时机(人已经走了),立即判一次
|
||||
void maybeInstallPendingUpdate();
|
||||
});
|
||||
win.on('show', () => {
|
||||
mainWindowHiddenAt = null;
|
||||
});
|
||||
win.on('closed', () => {
|
||||
mainWindowHiddenAt = null;
|
||||
if (hookedWindow === win) hookedWindow = null;
|
||||
});
|
||||
if (win.isVisible()) mainWindowHiddenAt = null;
|
||||
}
|
||||
|
||||
/** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */
|
||||
function mainWindowIdleLongEnough(): boolean {
|
||||
if (mainWindowHiddenAt === null) return false;
|
||||
return Date.now() - mainWindowHiddenAt >= CLIENT_UPDATE_IDLE_MS;
|
||||
}
|
||||
|
||||
/** 主窗口是否可见(空闲判定的一部分:可见 ⇒ 有人在用,绝不重启) */
|
||||
function mainWindowVisible(): boolean {
|
||||
if (hookedWindow && !hookedWindow.isDestroyed()) return hookedWindow.isVisible();
|
||||
return BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible());
|
||||
}
|
||||
|
||||
/** 服务端是否要求升级 / 本机是否确实更旧 */
|
||||
export function shouldConsider(version: string): boolean {
|
||||
return getConfig().autoUpdate !== false && !!version.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理一次服务端下发的更新信息:该下就下、下好了就写进待安装配置。
|
||||
*
|
||||
* - `autoUpdate === false`:用户/管理员关掉了自动更新,直接返回(设置页会显示服务端的目标版本,由人工处理);
|
||||
* - 同一版本已经下载过(`pendingUpdateVersion` 命中且文件还在):跳过下载,直接尝试安装。
|
||||
*/
|
||||
export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: string): Promise<void> {
|
||||
const version = targetVersion.trim() || info.version.trim();
|
||||
if (!shouldConsider(version)) {
|
||||
logger.info(`忽略服务端下发的更新 ${version || '(未指定版本)'}:自动更新已在设置中关闭`);
|
||||
return;
|
||||
}
|
||||
if (downloading.has(version)) {
|
||||
logger.debug(`更新 ${version} 正在下载中,跳过重复请求`);
|
||||
return;
|
||||
}
|
||||
|
||||
const config = getConfig();
|
||||
if (config.pendingUpdateVersion === version) {
|
||||
const existing = config.pendingUpdateFile;
|
||||
if (existing && fs.existsSync(existing)) {
|
||||
logger.info(`更新 ${version} 早已下载完成,直接尝试安装`);
|
||||
await maybeInstallPendingUpdate();
|
||||
return;
|
||||
}
|
||||
logger.warn(`更新 ${version} 的安装包已不在磁盘上(${existing || '未记录路径'}),重新下载`);
|
||||
}
|
||||
|
||||
if (!SAFE_FILE_NAME.test(info.fileName)) {
|
||||
logger.error(`拒绝下载更新:服务端给的文件名不合法(${info.fileName})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = pendingUpdateDir();
|
||||
const target = path.join(dir, info.fileName);
|
||||
const temp = `${target}.part`;
|
||||
const token = config.token?.trim() ?? '';
|
||||
if (!token) {
|
||||
logger.warn(`暂不下载更新 ${version}:尚未登录(没有可用的下载令牌)`);
|
||||
return;
|
||||
}
|
||||
|
||||
downloading.add(version);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// 先删掉上次中断留下的临时文件
|
||||
await fsp.rm(temp, { force: true });
|
||||
|
||||
const url = `${config.serverUrl}${info.url}`;
|
||||
logger.info(`开始下载更新 ${version}(${info.fileName},${formatSize(info.size)})`);
|
||||
const response = await fetch(url, {
|
||||
headers: { authorization: `Bearer ${token}` },
|
||||
signal: AbortSignal.timeout(DOWNLOAD_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败:HTTP ${response.status}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error('下载失败:响应没有内容');
|
||||
}
|
||||
|
||||
// 流式写盘:教室机的安装包几十 MB,不能整个读进内存
|
||||
const handle = await fsp.open(temp, 'w');
|
||||
try {
|
||||
for await (const chunk of response.body as unknown as AsyncIterable<Uint8Array>) {
|
||||
await handle.write(chunk);
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
|
||||
const digest = await sha256File(temp);
|
||||
const expected = info.sha256.trim().toLowerCase();
|
||||
if (!expected) {
|
||||
// 服务端没给哈希就没法校验:宁可不装,也不留一个来源不明的安装包在磁盘上
|
||||
await fsp.rm(temp, { force: true });
|
||||
logger.error(`更新 ${version} 的安装包没有提供 sha256,已删除(不做信任放行)`);
|
||||
return;
|
||||
}
|
||||
if (digest !== expected) {
|
||||
await fsp.rm(temp, { force: true });
|
||||
logger.error(
|
||||
`更新 ${version} 的安装包校验失败(sha256 不符),已删除:期望 ${expected},实际 ${digest}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await fsp.rm(target, { force: true });
|
||||
await fsp.rename(temp, target);
|
||||
saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target });
|
||||
logger.info(
|
||||
`更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round(
|
||||
(Date.now() - startedAt) / 1000,
|
||||
)} 秒),将在空闲时自动安装`,
|
||||
);
|
||||
await maybeInstallPendingUpdate();
|
||||
} catch (error) {
|
||||
await fsp.rm(temp, { force: true }).catch(() => undefined);
|
||||
logger.error(`下载更新 ${version} 失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
downloading.delete(version);
|
||||
}
|
||||
}
|
||||
|
||||
/** 文件的 sha256(十六进制小写) */
|
||||
async function sha256File(file: string): Promise<string> {
|
||||
const hash = crypto.createHash('sha256');
|
||||
const handle = await fsp.open(file, 'r');
|
||||
try {
|
||||
const buffer = Buffer.alloc(1024 * 256);
|
||||
for (;;) {
|
||||
const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
|
||||
if (bytesRead <= 0) break;
|
||||
hash.update(buffer.subarray(0, bytesRead));
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/** 人类可读的字节数(日志与界面提示用) */
|
||||
function formatSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes <= 0) return '大小未知';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 如果条件全部满足就装更新。
|
||||
*
|
||||
* 条件(缺一不可):非冒烟模式 / 配置里有待安装版本 / 文件还在 / 不在上课 /
|
||||
* 主窗口已隐藏满 5 分钟 / 本进程没装过。**每次心跳后与主窗口 hide 后都会调用**,
|
||||
* 所以"老师下课关掉窗口 → 5 分钟后自动装上"就是靠这里触发的。
|
||||
*/
|
||||
export async function maybeInstallPendingUpdate(): Promise<void> {
|
||||
if (installing) return;
|
||||
if (process.env.ELECTRON_SMOKE_TEST === '1') return;
|
||||
|
||||
const config = getConfig();
|
||||
const version = config.pendingUpdateVersion?.trim() ?? '';
|
||||
const installer = config.pendingUpdateFile?.trim() ?? '';
|
||||
if (!version || !installer) return;
|
||||
if (!fs.existsSync(installer)) {
|
||||
logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`);
|
||||
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
|
||||
return;
|
||||
}
|
||||
if (island.getState().inClass) {
|
||||
logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`);
|
||||
return;
|
||||
}
|
||||
if (mainWindowVisible()) {
|
||||
logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`);
|
||||
return;
|
||||
}
|
||||
if (!mainWindowIdleLongEnough()) {
|
||||
const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000);
|
||||
logger.debug(
|
||||
`更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` +
|
||||
`(需满 ${Math.round(CLIENT_UPDATE_IDLE_MS / 1000)} 秒),暂不安装`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (process.platform !== 'win32') {
|
||||
// 本项目客户端只发 Windows 版;其它平台(开发机上跑 Linux/macOS)只记日志
|
||||
logger.warn(
|
||||
`更新 ${version} 已就绪,但当前平台(${process.platform})不支持静默安装,请手动运行:${installer}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
installing = true;
|
||||
logger.info(`开始静默安装更新 ${version}:${installer}`);
|
||||
try {
|
||||
const exe = app.getPath('exe');
|
||||
const command = `start "" /wait "${installer}" /S && start "" "${exe}"`;
|
||||
const child = spawn('cmd.exe', ['/c', command], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: true,
|
||||
});
|
||||
child.unref();
|
||||
// 安装脚本会等安装器结束后重新拉起应用;本进程随即退出(复用既有退出路径,保证配置已落盘)
|
||||
if (quitHandler) {
|
||||
quitHandler();
|
||||
} else {
|
||||
logger.warn('没有注册退出回调,改为自行退出(配置会在退出前同步落盘)');
|
||||
app.quit();
|
||||
}
|
||||
} catch (error) {
|
||||
installing = false;
|
||||
logger.error(
|
||||
`启动更新安装失败(将在下次空闲时重试):${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 仅供冒烟/自检使用:当前是否处于"窗口已隐藏满 5 分钟"状态 */
|
||||
export function isMainWindowIdleLongEnough(): boolean {
|
||||
return mainWindowIdleLongEnough();
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* IPC 通道名集中管理(主进程与 preload 共用同一份字符串)。
|
||||
*
|
||||
* **为什么单独一个文件、而且这里一个 import 都不能有**:preload 需要这些字符串,
|
||||
* 但 preload 在 `sandbox: true` 下运行 —— 沙箱化 preload **不能 require `node:fs` /
|
||||
* `node:path` / `node:crypto` 这类内建模块**。而 esbuild 会把 preload 的**运行时 import**
|
||||
* 整条依赖链打进同一个产物:preload 一旦 `import { IPC_CHANNELS } from './ipc.js'`,
|
||||
* 就会把 `ipc.js` → `client-agent.js` / `config.js` / `logger.js` / `auto-update.js`
|
||||
* 一起拉进来(它们都要 `node:fs`),于是 preload 在沙箱里**加载即失败**,
|
||||
* 表现是渲染进程里 `window.desktop === undefined`(冒烟断言「preload contextBridge 注入」直接红,
|
||||
* 后续所有 `window.desktop.*` 调用抛 `Cannot read properties of undefined`)。
|
||||
*
|
||||
* 所以这个文件是**叶子模块**:只有字符串常量,没有任何 import、没有任何副作用。
|
||||
* 新增通道时加在这里,`main/ipc.ts` 与 `preload/index.ts` 各自 import 它。
|
||||
*/
|
||||
export const IPC_CHANNELS = {
|
||||
getConfig: 'classhelper:config:get',
|
||||
saveConfig: 'classhelper:config:save',
|
||||
clearConfig: 'classhelper:config:clear',
|
||||
appInfo: 'classhelper:app:info',
|
||||
openExternal: 'classhelper:shell:open-external',
|
||||
updateCheck: 'classhelper:update:check',
|
||||
updateIgnore: 'classhelper:update:ignore',
|
||||
getAutoLaunch: 'classhelper:app:get-autolaunch',
|
||||
setAutoLaunch: 'classhelper:app:set-autolaunch',
|
||||
openLogDir: 'classhelper:app:open-log-dir',
|
||||
openDevTools: 'classhelper:app:open-devtools',
|
||||
clientStatus: 'classhelper:client:status',
|
||||
clientBeatNow: 'classhelper:client:beat-now',
|
||||
} as const;
|
||||
|
||||
/** 主进程 → 渲染进程的单向推送:有可用更新(同理,供 preload 与主进程共用) */
|
||||
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
|
||||
@@ -0,0 +1,414 @@
|
||||
import { spawn } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import { BrowserWindow, app, dialog } from 'electron';
|
||||
import {
|
||||
CLIENT_COMMAND_LABELS,
|
||||
CLIENT_HEARTBEAT_INTERVAL_MS,
|
||||
isNewerVersion,
|
||||
type ClientCommandDto,
|
||||
type ClientCommandKind,
|
||||
type ClientHeartbeatRequest,
|
||||
type ClientHeartbeatResponse,
|
||||
type ClientUpdateInfoDto,
|
||||
} from '@classhelper/shared';
|
||||
import { maybeInstallPendingUpdate, considerUpdate } from './auto-update.js';
|
||||
import { getConfig } from './config.js';
|
||||
import { island } from './island.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* 桌面客户端的服务端联动代理(心跳上报)。
|
||||
*
|
||||
* **为什么是"客户端定时拉"而不是服务端推**:教室机的连接经常断(代理、休眠、重启),
|
||||
* 而「这台机器是什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上 —— 所以统一用
|
||||
* 「服务端记状态 + 客户端定时上报」的范式(与 ClassIsland 插件的上报同一套路),不引入新的长连接握手。
|
||||
* 每次上报的响应里同时带回「服务端指定的目标版本 + 待执行命令」。
|
||||
*
|
||||
* 三条边界:
|
||||
* 1. **没有 token 就不上报**:未登录时静默跳过(登录后下一次自然就上报了),
|
||||
* 不能拿空令牌去打服务端,否则日志里会刷满 401;
|
||||
* 2. **401/403 退避到 5 分钟**:令牌可能已失效(班级密码被改 / 令牌过期),
|
||||
* 继续 30 秒一次只是白刷服务端与日志;
|
||||
* 3. **冒烟模式默认不上报**:每次冒烟都在服务端库里造一条设备记录没有意义,
|
||||
* 只有显式 `ELECTRON_SMOKE_CLIENT_AGENT=1` 时才照常跑(给需要验证这条链路的用例用)。
|
||||
*/
|
||||
|
||||
/** 启动后第一次上报的延迟:等窗口、登录流程与灵动岛都就绪,也不跟首屏抢带宽 */
|
||||
const FIRST_BEAT_DELAY_MS = 3_000;
|
||||
|
||||
/** 令牌失效(401/403)后的退避间隔:5 分钟一次,直到用户重新登录 */
|
||||
const UNAUTHORIZED_BACKOFF_MS = 5 * 60_000;
|
||||
|
||||
/** 单次上报的超时:教室机网络慢,但也不能挂着不放 */
|
||||
const HEARTBEAT_TIMEOUT_MS = 10_000;
|
||||
|
||||
/**
|
||||
* 远控命令的「本机二次确认」等待时长:60 秒内没人点确认就按取消处理。
|
||||
*
|
||||
* 为什么必须有这个超时:管理员在办公室下发关机,而教室里可能已经没人了 ——
|
||||
* 一个永远挂着等确认的对话框会让这条命令一直"待执行",既没执行也看不出失败。
|
||||
*/
|
||||
const COMMAND_CONFIRM_TIMEOUT_MS = 60_000;
|
||||
|
||||
/**
|
||||
* 「在线」判定窗口:与 shared 的 `CLIENT_ONLINE_WINDOW_MS`(90 秒)同口径 ——
|
||||
* 连续丢两次心跳(30 秒 × 3)才算离线。这里不复用那个常量是因为它属于**服务端**的判定口径,
|
||||
* 客户端展示的是"我这边的上报还新不新",单独留一个本地常量更容易看出两边的关系。
|
||||
*/
|
||||
const ONLINE_WINDOW_MS = 90_000;
|
||||
|
||||
/** 对外暴露的联动状态(设置页「服务端联动状态」与冒烟断言用) */
|
||||
export interface ClientAgentStatus {
|
||||
/** 设备标识尾号(服务端设备列表里显示的那串用来对账,完整 UUID 不上界面) */
|
||||
deviceKeyTail: string;
|
||||
/** 最近一次上报成功的时刻(ISO 字符串;从未上报过为 null) */
|
||||
lastHeartbeatAt: string | null;
|
||||
/** 最近一次失败的原因(空串 = 最近一次是成功的) */
|
||||
lastError: string;
|
||||
/** 已下载待安装的版本(空串 = 无) */
|
||||
pendingUpdateVersion: string;
|
||||
/** 服务端最近一次要求升到的版本(空串 = 没要求) */
|
||||
updateTargetVersion: string;
|
||||
/** 是否接受服务端统一下发的自动更新(配置值,供设置页回显) */
|
||||
autoUpdate: boolean;
|
||||
/** 最近 90 秒内上报成功过(与服务端的在线口径一致) */
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
interface AgentState {
|
||||
running: boolean;
|
||||
/** 当前心跳定时器(退避期间与正常节奏共用它) */
|
||||
timer: ReturnType<typeof setTimeout> | null;
|
||||
/** 正在上报:避免手动「立即上报」与定时器撞在一起 */
|
||||
beating: boolean;
|
||||
lastHeartbeatAt: string | null;
|
||||
lastError: string;
|
||||
updateTargetVersion: string;
|
||||
}
|
||||
|
||||
const state: AgentState = {
|
||||
running: false,
|
||||
timer: null,
|
||||
beating: false,
|
||||
lastHeartbeatAt: null,
|
||||
lastError: '',
|
||||
updateTargetVersion: '',
|
||||
};
|
||||
|
||||
/** 设备标识尾号:取后 8 位(足够对账,又不至于把完整 UUID 贴到界面上) */
|
||||
export function deviceKeyTail(deviceKey: string): string {
|
||||
const trimmed = deviceKey.trim();
|
||||
if (!trimmed) return '';
|
||||
return trimmed.length > 8 ? trimmed.slice(-8) : trimmed;
|
||||
}
|
||||
|
||||
/** 冒烟模式且没有显式打开联动开关时,一律不上报(避免每次冒烟都在库里造设备记录) */
|
||||
function isAgentSuppressed(): boolean {
|
||||
return process.env.ELECTRON_SMOKE_TEST === '1' && process.env.ELECTRON_SMOKE_CLIENT_AGENT !== '1';
|
||||
}
|
||||
|
||||
/**
|
||||
* 上报里的"这台机器现在在干什么"。
|
||||
*
|
||||
* 取值就三种(服务端设备列表直接展示这段文字):
|
||||
* - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(此时绝不安装更新);
|
||||
* - `使用中` —— 主窗口可见,人正在用;
|
||||
* - `待机` —— 窗口隐藏(最小化到托盘),机器空闲。
|
||||
*/
|
||||
function currentStateNote(): string {
|
||||
if (island.getState().inClass) return '上课中';
|
||||
const win = BrowserWindow.getAllWindows().find((item) => !item.isDestroyed());
|
||||
return win?.isVisible() ? '使用中' : '待机';
|
||||
}
|
||||
|
||||
/** 已下载待安装的版本;没有则 null(服务端把它记进设备的 `pendingVersion`) */
|
||||
function pendingVersionOrNull(): string | null {
|
||||
const pending = getConfig().pendingUpdateVersion.trim();
|
||||
return pending ? pending : null;
|
||||
}
|
||||
|
||||
/** 清掉待执行的心跳定时器(幂等) */
|
||||
function clearTimer(): void {
|
||||
if (state.timer !== null) {
|
||||
clearTimeout(state.timer);
|
||||
state.timer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 排下一次上报;`delayMs` 不传时用正常节奏 */
|
||||
function scheduleNext(delayMs: number = CLIENT_HEARTBEAT_INTERVAL_MS): void {
|
||||
if (!state.running) return;
|
||||
clearTimer();
|
||||
state.timer = setTimeout(() => {
|
||||
state.timer = null;
|
||||
void beat();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次上报(含响应处理)。
|
||||
*
|
||||
* 失败一律**不抛**:网络抖动是教室机的常态,只记一行日志并按原节奏重试;
|
||||
* 只有 401/403 才退避(见文件头说明)。
|
||||
*/
|
||||
async function beat(): Promise<void> {
|
||||
if (state.beating) return;
|
||||
if (isAgentSuppressed()) {
|
||||
// 冒烟模式:照原节奏空转(不碰服务端,也不在库里造设备记录)
|
||||
state.lastError = '';
|
||||
scheduleNext();
|
||||
return;
|
||||
}
|
||||
const config = getConfig();
|
||||
const token = config.token?.trim() ?? '';
|
||||
if (!token) {
|
||||
// 未登录:不打服务端、不记错误,等下次心跳(登录后自然会带上令牌)
|
||||
logger.debug('客户端上报:尚未登录(没有令牌),本次跳过');
|
||||
scheduleNext();
|
||||
return;
|
||||
}
|
||||
|
||||
state.beating = true;
|
||||
let backoff = false;
|
||||
try {
|
||||
const body: ClientHeartbeatRequest = {
|
||||
deviceKey: config.deviceKey,
|
||||
name: os.hostname(),
|
||||
clientVersion: app.getVersion(),
|
||||
platform: `${process.platform}-${process.arch}`,
|
||||
pendingVersion: pendingVersionOrNull(),
|
||||
stateNote: currentStateNote(),
|
||||
};
|
||||
const response = await fetch(`${config.serverUrl}/api/clients/heartbeat`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// 令牌失效:退避到 5 分钟一次,直到用户重新登录
|
||||
backoff = true;
|
||||
state.lastError = `上报被拒绝(HTTP ${response.status}),可能登录已过期,请重新登录`;
|
||||
logger.warn(`客户端上报:服务端返回 HTTP ${response.status},退避为 5 分钟一次`);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
state.lastError = `服务端返回 HTTP ${response.status}`;
|
||||
logger.warn(`客户端上报失败:HTTP ${response.status}(${HEARTBEAT_INTERVAL_TEXT})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as ClientHeartbeatResponse;
|
||||
state.lastHeartbeatAt = new Date().toISOString();
|
||||
state.lastError = '';
|
||||
state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? '';
|
||||
logger.debug(
|
||||
`客户端上报成功(设备尾号 ${deviceKeyTail(config.deviceKey)},状态 ${body.stateNote ?? '-'}` +
|
||||
`${state.updateTargetVersion ? `,服务端要求升到 ${state.updateTargetVersion}` : ''})`,
|
||||
);
|
||||
|
||||
void handleCommand(payload.command);
|
||||
await handleUpdate(payload.update, payload.updateTargetVersion);
|
||||
} catch (error) {
|
||||
// 网络错误 / 超时 / JSON 解析失败:按原节奏重试
|
||||
state.lastError = error instanceof Error ? error.message : String(error);
|
||||
logger.warn(`客户端上报失败(将于 ${HEARTBEAT_INTERVAL_TEXT} 后重试):${state.lastError}`);
|
||||
} finally {
|
||||
state.beating = false;
|
||||
// 每次心跳(无论成败)都顺手看一下有没有下载好的更新该装了 —— 上课/窗口可见时会自动跳过
|
||||
void maybeInstallPendingUpdate();
|
||||
scheduleNext(backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
const HEARTBEAT_INTERVAL_TEXT = `${Math.round(CLIENT_HEARTBEAT_INTERVAL_MS / 1000)} 秒`;
|
||||
|
||||
/**
|
||||
* 服务端下发的远控命令(关机 / 重启 / 注销 / 锁屏)。
|
||||
*
|
||||
* 两道护栏缺一不可(用户要求「必须二次确认」):
|
||||
* 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 ——
|
||||
* 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道;
|
||||
* 2. **同一条命令只弹一次**:它随每次心跳重复下发,用内存集合去重,避免 30 秒弹一个框。
|
||||
*
|
||||
* 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey):
|
||||
* 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存,
|
||||
* 下一次心跳只重试回执、不重复弹窗。
|
||||
*/
|
||||
const inFlightCommands = new Set<string>();
|
||||
const unackedResults = new Map<string, { ok: boolean; result: string }>();
|
||||
|
||||
async function handleCommand(command: ClientCommandDto | null): Promise<void> {
|
||||
if (!command) return;
|
||||
const label = CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
|
||||
|
||||
const expired =
|
||||
command.expiresAt !== null && Number.isFinite(Date.parse(command.expiresAt))
|
||||
? Date.parse(command.expiresAt) < Date.now()
|
||||
: false;
|
||||
if (expired) {
|
||||
logger.warn(`收到已过期的远控命令(${label}),已忽略:${command.payload || '无备注'}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 处理过但回执没送达:只重试回执
|
||||
const pending = unackedResults.get(command.id);
|
||||
if (pending) {
|
||||
await ackCommand(command.id, pending.ok, pending.result);
|
||||
return;
|
||||
}
|
||||
// 正在等本机确认:不要再弹第二个框
|
||||
if (inFlightCommands.has(command.id)) return;
|
||||
|
||||
inFlightCommands.add(command.id);
|
||||
try {
|
||||
const confirmed = await confirmCommand(label, command.payload);
|
||||
if (!confirmed) {
|
||||
const result = '本机用户取消(或 60 秒未确认)';
|
||||
logger.warn(`远控命令「${label}」未执行:${result}`);
|
||||
await ackCommand(command.id, false, result);
|
||||
return;
|
||||
}
|
||||
const outcome = await executeCommand(command.kind);
|
||||
logger.warn(`远控命令「${label}」执行${outcome.ok ? '成功' : '失败'}:${outcome.result}`);
|
||||
await ackCommand(command.id, outcome.ok, outcome.result);
|
||||
} finally {
|
||||
inFlightCommands.delete(command.id);
|
||||
}
|
||||
}
|
||||
|
||||
/** 本机二次确认对话框:默认「取消」,60 秒未操作按取消处理 */
|
||||
async function confirmCommand(label: string, note: string): Promise<boolean> {
|
||||
const answered = dialog
|
||||
.showMessageBox({
|
||||
type: 'warning',
|
||||
title: '来自管理员的远程指令',
|
||||
message: `管理员要求本机「${label}」`,
|
||||
detail: `${note ? `备注:${note}\n` : ''}确认后本机将立即${label};60 秒内未确认视为取消。`,
|
||||
buttons: ['取消', '确认执行'],
|
||||
defaultId: 0,
|
||||
cancelId: 0,
|
||||
noLink: true,
|
||||
})
|
||||
.then((result) => result.response === 1)
|
||||
.catch(() => false);
|
||||
|
||||
return Promise.race([
|
||||
answered,
|
||||
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), COMMAND_CONFIRM_TIMEOUT_MS)),
|
||||
]);
|
||||
}
|
||||
|
||||
/** 执行系统命令:只支持 Windows(本项目客户端只发 Windows 版) */
|
||||
async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; result: string }> {
|
||||
if (process.platform !== 'win32') return { ok: false, result: '仅支持 Windows' };
|
||||
|
||||
const commands: Record<ClientCommandKind, { file: string; args: string[] }> = {
|
||||
shutdown: { file: 'shutdown.exe', args: ['/s', '/t', '0'] },
|
||||
restart: { file: 'shutdown.exe', args: ['/r', '/t', '0'] },
|
||||
logoff: { file: 'shutdown.exe', args: ['/l'] },
|
||||
lock: { file: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
|
||||
};
|
||||
const plan = commands[kind];
|
||||
|
||||
try {
|
||||
// detached + unref:不能等它退出(关机/注销会把本进程一起带走)
|
||||
spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
|
||||
return { ok: true, result: '已下达系统命令' };
|
||||
} catch (error) {
|
||||
return { ok: false, result: error instanceof Error ? error.message : String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
/** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */
|
||||
async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> {
|
||||
const config = getConfig();
|
||||
if (!config.token) return;
|
||||
try {
|
||||
const response = await fetch(`${config.serverUrl}/api/clients/commands/${commandId}/ack`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', authorization: `Bearer ${config.token}` },
|
||||
body: JSON.stringify({ deviceKey: config.deviceKey, ok, result }),
|
||||
signal: AbortSignal.timeout(HEARTBEAT_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
logger.warn(`远控命令回执失败(HTTP ${response.status}),将在下次心跳重试`);
|
||||
unackedResults.set(commandId, { ok, result });
|
||||
return;
|
||||
}
|
||||
unackedResults.delete(commandId);
|
||||
logger.info(`远控命令回执已送达(${ok ? '成功' : '失败'}):${result}`);
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`远控命令回执失败(${error instanceof Error ? error.message : String(error)}),将在下次心跳重试`,
|
||||
);
|
||||
unackedResults.set(commandId, { ok, result });
|
||||
}
|
||||
}
|
||||
|
||||
/** 响应里的更新信息:服务端指定了目标版本、且该版本确实比本机新,才交给 auto-update */
|
||||
async function handleUpdate(info: ClientUpdateInfoDto | null, targetVersion: string | null): Promise<void> {
|
||||
const target = targetVersion?.trim() ?? '';
|
||||
if (!target) return;
|
||||
if (!info) {
|
||||
// 服务端指定了目标版本但还没有这份安装包:只记一行,客户端只能提示手动更新
|
||||
logger.warn(`服务端要求升到 ${target},但服务端还没有这个版本的安装包(无法自动下载)`);
|
||||
return;
|
||||
}
|
||||
if (!isNewerVersion(app.getVersion(), target)) return;
|
||||
await considerUpdate(info, target);
|
||||
}
|
||||
|
||||
/** 启动心跳(重复调用无副作用) */
|
||||
export function startClientAgent(): void {
|
||||
if (state.running) return;
|
||||
state.running = true;
|
||||
logger.info(
|
||||
isAgentSuppressed()
|
||||
? '客户端上报代理:冒烟模式下不上报(需要时设 ELECTRON_SMOKE_CLIENT_AGENT=1)'
|
||||
: `客户端上报代理已启动:${FIRST_BEAT_DELAY_MS / 1000} 秒后首次上报,之后每 ${HEARTBEAT_INTERVAL_TEXT} 一次`,
|
||||
);
|
||||
scheduleNext(FIRST_BEAT_DELAY_MS);
|
||||
}
|
||||
|
||||
/** 停止心跳(退出清理调用;幂等) */
|
||||
export function stopClientAgent(): void {
|
||||
if (!state.running) return;
|
||||
state.running = false;
|
||||
clearTimer();
|
||||
logger.info('客户端上报代理已停止');
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发一次上报并返回最新状态(设置页「立即上报」按钮)。
|
||||
* 返回的是**报告之后**的状态,界面直接照它刷新即可。
|
||||
*/
|
||||
export async function beatNow(): Promise<ClientAgentStatus> {
|
||||
if (!state.running) startClientAgent();
|
||||
if (isAgentSuppressed()) {
|
||||
logger.info('客户端上报:冒烟模式下跳过手动上报');
|
||||
return getClientAgentStatus();
|
||||
}
|
||||
clearTimer();
|
||||
await beat();
|
||||
return getClientAgentStatus();
|
||||
}
|
||||
|
||||
/** 当前联动状态(纯数据,可直接跨 contextBridge 传给渲染进程) */
|
||||
export function getClientAgentStatus(): ClientAgentStatus {
|
||||
const config = getConfig();
|
||||
const lastAt = state.lastHeartbeatAt ? Date.parse(state.lastHeartbeatAt) : Number.NaN;
|
||||
return {
|
||||
deviceKeyTail: deviceKeyTail(config.deviceKey),
|
||||
lastHeartbeatAt: state.lastHeartbeatAt,
|
||||
lastError: state.lastError,
|
||||
pendingUpdateVersion: config.pendingUpdateVersion ?? '',
|
||||
updateTargetVersion: state.updateTargetVersion,
|
||||
autoUpdate: config.autoUpdate !== false,
|
||||
online: Number.isFinite(lastAt) && Date.now() - lastAt < ONLINE_WINDOW_MS,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app, safeStorage } from 'electron';
|
||||
@@ -44,6 +45,20 @@ interface PersistedConfig {
|
||||
sidebarCollapsed?: boolean;
|
||||
/** 用户选择「忽略此版本」的版本号(向后兼容:旧配置没有该字段视为没忽略过任何版本) */
|
||||
ignoredUpdateVersion?: string;
|
||||
/** 本机设备标识(向后兼容:旧配置没有该字段时首次读取生成并落盘,之后永不变) */
|
||||
deviceKey?: string;
|
||||
/** 是否开机自启(向后兼容:旧配置没有该字段视为关闭 —— 由用户在设置页自己打开) */
|
||||
autoLaunch?: boolean;
|
||||
/** 是否接受服务端统一下发的自动更新(向后兼容:旧配置没有该字段视为开启) */
|
||||
autoUpdate?: boolean;
|
||||
/** 开发者模式:日志调到 debug 级 + 设置里多出「开发者」页(向后兼容:旧配置没有该字段视为关闭) */
|
||||
developerMode?: boolean;
|
||||
/** 关怀模式:整体放大正文与控件字号,供视力不佳的用户使用(向后兼容:旧配置没有该字段视为关闭) */
|
||||
careMode?: boolean;
|
||||
/** 已下载待安装的更新版本(空串 = 无;跨重启保留) */
|
||||
pendingUpdateVersion?: string;
|
||||
/** 已下载待安装的安装包绝对路径(空串 = 无;跨重启保留) */
|
||||
pendingUpdateFile?: string;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PersistedConfig = {
|
||||
@@ -59,6 +74,13 @@ const DEFAULT_CONFIG: PersistedConfig = {
|
||||
theme: 'light',
|
||||
sidebarCollapsed: false,
|
||||
ignoredUpdateVersion: '',
|
||||
deviceKey: '',
|
||||
autoLaunch: false,
|
||||
autoUpdate: true,
|
||||
developerMode: false,
|
||||
careMode: false,
|
||||
pendingUpdateVersion: '',
|
||||
pendingUpdateFile: '',
|
||||
};
|
||||
|
||||
/** 主题白名单:非法值一律回落浅色 */
|
||||
@@ -203,6 +225,15 @@ function readPersistedFromDisk(): PersistedConfig {
|
||||
sidebarCollapsed: parsed.sidebarCollapsed === true,
|
||||
ignoredUpdateVersion:
|
||||
typeof parsed.ignoredUpdateVersion === 'string' ? parsed.ignoredUpdateVersion : '',
|
||||
deviceKey: typeof parsed.deviceKey === 'string' ? parsed.deviceKey : '',
|
||||
autoLaunch: parsed.autoLaunch === true,
|
||||
// 默认开启:老配置文件里没有这个字段时按"接受服务端统一下发的更新"处理
|
||||
autoUpdate: parsed.autoUpdate !== false,
|
||||
developerMode: parsed.developerMode === true,
|
||||
careMode: parsed.careMode === true,
|
||||
pendingUpdateVersion:
|
||||
typeof parsed.pendingUpdateVersion === 'string' ? parsed.pendingUpdateVersion : '',
|
||||
pendingUpdateFile: typeof parsed.pendingUpdateFile === 'string' ? parsed.pendingUpdateFile : '',
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
@@ -356,9 +387,52 @@ function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
|
||||
theme: normalizeTheme(persisted.theme),
|
||||
sidebarCollapsed: persisted.sidebarCollapsed === true,
|
||||
ignoredUpdateVersion: persisted.ignoredUpdateVersion ?? '',
|
||||
deviceKey: ensureDeviceKey(persisted),
|
||||
autoLaunch: persisted.autoLaunch === true,
|
||||
autoUpdate: persisted.autoUpdate !== false,
|
||||
developerMode: persisted.developerMode === true,
|
||||
careMode: persisted.careMode === true,
|
||||
pendingUpdateVersion: persisted.pendingUpdateVersion ?? '',
|
||||
pendingUpdateFile: persisted.pendingUpdateFile ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `crypto.randomUUID()`:Node 18+ / Chromium 里都有,但**不能假设它一定存在**
|
||||
* (打包后的运行时或某些受限环境里 `crypto.randomUUID` 为 undefined,直接调用会抛)。
|
||||
* 兜底用时间戳 + 随机字节拼一个同形状的 UUID v4。
|
||||
*/
|
||||
function generateDeviceKey(): string {
|
||||
try {
|
||||
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
|
||||
} catch {
|
||||
// 落到下面的兜底
|
||||
}
|
||||
const hex = crypto.randomBytes(16).toString('hex');
|
||||
return (
|
||||
`${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(12, 15)}` +
|
||||
`-a${hex.slice(15, 18)}-${hex.slice(18, 30)}`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备标识:**首次读取时生成并落盘,之后永不变**(它是这台机器在服务端的身份,
|
||||
* 服务端按 `deviceKey` upsert 设备记录,换了它就等于换了一台机器)。
|
||||
*
|
||||
* 为什么放在这里生成:`getConfig()` 是所有读取路径的唯一出口(主进程各处 + 渲染进程 IPC),
|
||||
* 且此时必然已经过了 `app.setPath('userData', …)`(见 `loadCache()` 的懒初始化说明),
|
||||
* 写的就是当前 profile 的 config.json。落盘走既有的 120ms 合并窗口,退出时 `flushConfigSync()` 兜底。
|
||||
*/
|
||||
function ensureDeviceKey(persisted: PersistedConfig): string {
|
||||
const existing = typeof persisted.deviceKey === 'string' ? persisted.deviceKey.trim() : '';
|
||||
if (existing) return existing;
|
||||
const created = generateDeviceKey();
|
||||
persisted.deviceKey = created;
|
||||
scheduleWrite(persisted);
|
||||
logger.info(`已为本机生成设备标识(服务端据此识别这台电脑):${created}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
/** 读取配置(token 已解密),供渲染进程使用 */
|
||||
export function getConfig(): DesktopStoredConfig {
|
||||
return toDesktopConfig(loadCache());
|
||||
@@ -407,6 +481,30 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
||||
if (typeof patch.ignoredUpdateVersion === 'string') {
|
||||
persisted.ignoredUpdateVersion = patch.ignoredUpdateVersion;
|
||||
}
|
||||
if (patch.autoLaunch !== undefined) {
|
||||
persisted.autoLaunch = patch.autoLaunch === true;
|
||||
}
|
||||
if (patch.autoUpdate !== undefined) {
|
||||
persisted.autoUpdate = patch.autoUpdate === true;
|
||||
}
|
||||
if (patch.developerMode !== undefined) {
|
||||
persisted.developerMode = patch.developerMode === true;
|
||||
}
|
||||
if (patch.careMode !== undefined) {
|
||||
persisted.careMode = patch.careMode === true;
|
||||
}
|
||||
// 待安装的更新只由主进程的 auto-update 模块写入;渲染进程传进来也照收(退出登录/重置走 clearConfig)
|
||||
if (typeof patch.pendingUpdateVersion === 'string') {
|
||||
persisted.pendingUpdateVersion = patch.pendingUpdateVersion;
|
||||
}
|
||||
if (typeof patch.pendingUpdateFile === 'string') {
|
||||
persisted.pendingUpdateFile = patch.pendingUpdateFile;
|
||||
}
|
||||
// deviceKey **不接受外部改写**:它是这台机器在服务端的身份,改了服务端就会当成一台新机器
|
||||
// (旧记录永远停在上一次心跳、新记录从零开始)。要重置身份只能手工删掉 config.json 里的该字段。
|
||||
if (typeof patch.deviceKey === 'string' && patch.deviceKey && patch.deviceKey !== persisted.deviceKey) {
|
||||
logger.warn('忽略对设备标识(deviceKey)的改写:它是这台机器在服务端的身份,不允许变更');
|
||||
}
|
||||
if (patch.token !== undefined) {
|
||||
const encrypted = encryptToken(patch.token);
|
||||
persisted.token = encrypted.token;
|
||||
@@ -419,9 +517,27 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
||||
return toDesktopConfig(state);
|
||||
}
|
||||
|
||||
/** 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟 */
|
||||
/**
|
||||
* 清空为默认配置(退出登录 / 重置)。立即落盘:它对应退出登录,不该被合并窗口延迟。
|
||||
*
|
||||
* **保留"这台机器"的身份与本地开关**:`deviceKey`(服务端按它识别设备,换了就等于换一台机器)、
|
||||
* 开机自启 / 自动更新 / 开发者模式 / 关怀模式,以及已下载待安装的更新记录 —— 退出登录不该在服务端
|
||||
* 多出一台陌生设备,也不该把用户刚打开的开关悄悄关回默认值。
|
||||
*/
|
||||
export function clearConfig(): DesktopStoredConfig {
|
||||
const state: ConfigCache = { config: { ...DEFAULT_CONFIG }, token: null };
|
||||
const current = cache?.config ?? DEFAULT_CONFIG;
|
||||
const kept: PersistedConfig = {
|
||||
...DEFAULT_CONFIG,
|
||||
deviceKey: current.deviceKey ?? '',
|
||||
autoLaunch: current.autoLaunch === true,
|
||||
autoUpdate: current.autoUpdate !== false,
|
||||
developerMode: current.developerMode === true,
|
||||
careMode: current.careMode === true,
|
||||
pendingUpdateVersion: current.pendingUpdateVersion ?? '',
|
||||
pendingUpdateFile: current.pendingUpdateFile ?? '',
|
||||
};
|
||||
ensureDeviceKey(kept);
|
||||
const state: ConfigCache = { config: kept, token: null };
|
||||
cache = state;
|
||||
scheduleWrite(state.config, true);
|
||||
return toDesktopConfig(state);
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import { app, type BrowserWindow, type RenderProcessGoneDetails } from 'electron';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* 崩溃自愈(主窗口)。
|
||||
*
|
||||
* 教室机的运行环境很不友好:显卡驱动老旧、长期不重启、内存被其它教学软件挤占 ——
|
||||
* 渲染进程偶发崩溃与无响应是常态。原先这种情况表现为"窗口还在、一片空白、点哪都没反应",
|
||||
* 只能人工去重启,因此这里补三条自愈路径:
|
||||
*
|
||||
* 1. `render-process-gone` —— 渲染进程没了(崩溃 / 被杀),**静默重建窗口**;
|
||||
* 2. `unresponsive` —— 界面卡住超过 10 秒还没缓过来,同样重建;期间恢复(`responsive`)就取消;
|
||||
* 3. `uncaughtException` / `unhandledRejection` —— 主进程的未捕获错误,
|
||||
* 记完整堆栈,并且**只有在本机确认空闲时**才重启自己。
|
||||
*
|
||||
* 两条限流(照抄灵动岛 supervisor 的口径,避免"崩溃 → 重建 → 立刻又崩"死循环刷日志):
|
||||
* **10 分钟内最多 3 次**,超了只记日志、停下来等人工处理。
|
||||
* 另外**冒烟模式下绝不 `app.relaunch()`**:验证实例重启自己会让父进程拿到一个莫名其妙的子进程。
|
||||
*/
|
||||
|
||||
/** 限流:窗口内最多几次 */
|
||||
const RECOVER_MAX_TIMES = 3;
|
||||
/** 限流窗口:10 分钟 */
|
||||
const RECOVER_WINDOW_MS = 10 * 60_000;
|
||||
/** 界面无响应多久算卡死(Electron 的 `unresponsive` 事件不会再给第二次机会) */
|
||||
const UNRESPONSIVE_GRACE_MS = 10_000;
|
||||
|
||||
export interface CrashGuardOptions {
|
||||
/** 销毁并重建主窗口(index.ts 里复用原有的"建窗 + 载入界面"逻辑) */
|
||||
recoverMainWindow: () => void;
|
||||
/** 本机是否空闲(不在上课且主窗口不可见)—— 决定主进程崩溃后能不能自动重启 */
|
||||
isIdle: () => boolean;
|
||||
/** 退出应用(复用既有的退出路径,保证配置落盘) */
|
||||
quitApp: () => void;
|
||||
}
|
||||
|
||||
const windowRecoverAt: number[] = [];
|
||||
const restartAt: number[] = [];
|
||||
let unresponsiveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 正在重建:重建过程中窗口又会发 `closed` / `destroyed`,不能再触发一次 */
|
||||
let recovering = false;
|
||||
/** 当前主窗口(重建后会换成新实例) */
|
||||
let mainWindow: BrowserWindow | null = null;
|
||||
/** install 时拿到的回调集合(事件回调里复用) */
|
||||
let options: CrashGuardOptions | null = null;
|
||||
|
||||
/**
|
||||
* 安装崩溃自愈(在 `app.whenReady()` 里、主窗口建好之后调用一次)。
|
||||
*/
|
||||
export function installCrashGuard(guard: CrashGuardOptions): void {
|
||||
options = guard;
|
||||
|
||||
app.on('render-process-gone', (_event, contents, details: RenderProcessGoneDetails) => {
|
||||
// 灵动岛窗口有自己的 supervisor(island.ts),这里只管主窗口
|
||||
if (!isMainWindowContents(contents.id)) return;
|
||||
logger.error(
|
||||
`主窗口渲染进程异常退出:reason=${details.reason} exitCode=${details.exitCode}(准备重建窗口)`,
|
||||
);
|
||||
recoverWindow(`渲染进程退出(${details.reason})`);
|
||||
});
|
||||
|
||||
app.on('child-process-gone', (_event, details) => {
|
||||
logger.error(
|
||||
`子进程异常退出:type=${details.type} reason=${details.reason} exitCode=${details.exitCode}`,
|
||||
);
|
||||
});
|
||||
|
||||
process.on('uncaughtException', (error) => {
|
||||
logger.error('主进程未捕获异常:', error);
|
||||
restartIfIdle('uncaughtException');
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
logger.error('主进程未处理的 Promise 拒绝:', reason);
|
||||
restartIfIdle('unhandledRejection');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录当前主窗口并挂上无响应监听。
|
||||
* `createWindow()` 之后调用;窗口重建后要重新调用一次(新实例、新监听)。
|
||||
*/
|
||||
export function watchMainWindow(win: BrowserWindow): void {
|
||||
mainWindow = win;
|
||||
win.on('unresponsive', () => {
|
||||
logger.error('主窗口界面无响应(10 秒内未恢复将重建窗口)');
|
||||
armUnresponsiveTimer();
|
||||
});
|
||||
win.on('responsive', () => {
|
||||
if (unresponsiveTimer !== null) {
|
||||
clearTimeout(unresponsiveTimer);
|
||||
unresponsiveTimer = null;
|
||||
logger.info('主窗口界面已恢复响应,取消重建');
|
||||
}
|
||||
});
|
||||
win.on('closed', () => {
|
||||
if (mainWindow === win) mainWindow = null;
|
||||
if (unresponsiveTimer !== null) {
|
||||
clearTimeout(unresponsiveTimer);
|
||||
unresponsiveTimer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function armUnresponsiveTimer(): void {
|
||||
if (unresponsiveTimer !== null) clearTimeout(unresponsiveTimer);
|
||||
unresponsiveTimer = setTimeout(() => {
|
||||
unresponsiveTimer = null;
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return;
|
||||
recoverWindow('界面无响应超过 10 秒');
|
||||
}, UNRESPONSIVE_GRACE_MS);
|
||||
}
|
||||
|
||||
function isMainWindowContents(id: number): boolean {
|
||||
// 主窗口引用还没建立(启动早期)时按"就是它"处理,避免漏掉重建
|
||||
if (!mainWindow || mainWindow.isDestroyed()) return true;
|
||||
return mainWindow.webContents.id === id;
|
||||
}
|
||||
|
||||
/** 静默重建主窗口(限流) */
|
||||
function recoverWindow(reason: string): void {
|
||||
const recover = options?.recoverMainWindow;
|
||||
if (recovering) return;
|
||||
if (!recover) {
|
||||
logger.error(`主窗口需要重建(${reason}),但没有可用的重建回调`);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
while (windowRecoverAt.length > 0 && now - windowRecoverAt[0] > RECOVER_WINDOW_MS) {
|
||||
windowRecoverAt.shift();
|
||||
}
|
||||
if (windowRecoverAt.length >= RECOVER_MAX_TIMES) {
|
||||
logger.error(
|
||||
`主窗口在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已重建 ${RECOVER_MAX_TIMES} 次,停止自动重建(${reason})` +
|
||||
':请检查显卡驱动或教学软件冲突后手动重启应用',
|
||||
);
|
||||
return;
|
||||
}
|
||||
windowRecoverAt.push(now);
|
||||
recovering = true;
|
||||
try {
|
||||
const win = mainWindow;
|
||||
if (win && !win.isDestroyed()) {
|
||||
// 先隐藏再销毁:跟灵动岛的 supervisor 一样,避免留住一帧残影/白屏
|
||||
win.hide();
|
||||
}
|
||||
logger.warn(`开始重建主窗口(${reason},本窗口期第 ${windowRecoverAt.length} 次)`);
|
||||
recover();
|
||||
} catch (error) {
|
||||
logger.error(`重建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
} finally {
|
||||
recovering = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 主进程崩溃后按需重启自己(限流 + 空闲判定 + 冒烟豁免) */
|
||||
function restartIfIdle(reason: string): void {
|
||||
if (process.env.ELECTRON_SMOKE_TEST === '1') {
|
||||
// 冒烟模式只记日志:验证实例重启自己会污染验证结果
|
||||
logger.warn(`冒烟模式:不因 ${reason} 重启应用(仅记录日志)`);
|
||||
return;
|
||||
}
|
||||
let idle = false;
|
||||
try {
|
||||
idle = options?.isIdle() === true;
|
||||
} catch (error) {
|
||||
logger.error(`判断本机是否空闲时出错:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
if (!idle) {
|
||||
logger.warn(`本机正在使用中(${reason}),不自动重启应用,以免打断课堂`);
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
while (restartAt.length > 0 && now - restartAt[0] > RECOVER_WINDOW_MS) {
|
||||
restartAt.shift();
|
||||
}
|
||||
if (restartAt.length >= RECOVER_MAX_TIMES) {
|
||||
logger.error(
|
||||
`应用在 ${RECOVER_WINDOW_MS / 60_000} 分钟内已自动重启 ${RECOVER_MAX_TIMES} 次,停止自动重启(${reason})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
restartAt.push(now);
|
||||
logger.warn(`本机空闲,因 ${reason} 自动重启应用(本窗口期第 ${restartAt.length} 次)`);
|
||||
app.relaunch();
|
||||
options?.quitApp();
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
import path from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { BrowserWindow, app, shell } from 'electron';
|
||||
import { setQuitHandler, watchMainWindow as watchAutoUpdateWindow } from './auto-update.js';
|
||||
import { startClientAgent, stopClientAgent } from './client-agent.js';
|
||||
import { flushConfigSync, getConfig } from './config.js';
|
||||
import { installCrashGuard, watchMainWindow as watchCrashGuardWindow } from './crash-guard.js';
|
||||
import { registerIpcHandlers } from './ipc.js';
|
||||
import { island, registerIslandIpc } from './island.js';
|
||||
import { logger } from './logger.js';
|
||||
@@ -16,6 +19,13 @@ const currentDir = __dirname;
|
||||
const devServerUrl = process.env.VITE_DEV_SERVER_URL;
|
||||
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
|
||||
|
||||
/**
|
||||
* 这次启动是否需要"直接进托盘、不弹窗"——开机自启专用(见 `applyAutoLaunch()`)。
|
||||
* 判定依据是我们自己在登录项里注册的命令行参数 `--hidden`(Electron 44 的
|
||||
* `setLoginItemSettings` 在 Windows 上**没有** `openAsHidden` 这个开关,只能自己带参数)。
|
||||
*/
|
||||
const startHidden = !isSmokeTest && process.argv.includes('--hidden');
|
||||
|
||||
// 自动化验证使用独立的 userData:否则用户正在运行的客户端会占着单实例锁,
|
||||
// 冒烟进程会"静默退出(退出码 0、无输出)",看起来像构建坏了。
|
||||
const smokeProfile = process.env.ELECTRON_SMOKE_PROFILE;
|
||||
@@ -50,7 +60,7 @@ function isAllowedNavigation(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function createWindow(): BrowserWindow {
|
||||
function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
|
||||
// 启动底色跟主题走:深色主题下若仍用浅色底,窗口先白后黑会"闪一下"
|
||||
const theme = getConfig().theme;
|
||||
const win = new BrowserWindow({
|
||||
@@ -61,8 +71,8 @@ function createWindow(): BrowserWindow {
|
||||
minHeight: 540,
|
||||
center: true,
|
||||
title: 'ClassHelper',
|
||||
// 冒烟验证时不弹窗,避免打扰使用者
|
||||
show: !isSmokeTest,
|
||||
// 冒烟验证时不弹窗,避免打扰使用者;开机自启(--hidden)时直接进托盘
|
||||
show: !isSmokeTest && options.hidden !== true,
|
||||
autoHideMenuBar: true,
|
||||
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
|
||||
webPreferences: {
|
||||
@@ -87,12 +97,34 @@ function createWindow(): BrowserWindow {
|
||||
});
|
||||
|
||||
win.on('closed', () => {
|
||||
mainWindow = null;
|
||||
if (mainWindow === win) mainWindow = null;
|
||||
});
|
||||
|
||||
// 崩溃自愈与「空闲安装更新」都要盯着主窗口:窗口重建后换的是新实例,因此每次建窗都重挂一次
|
||||
watchCrashGuardWindow(win);
|
||||
watchAutoUpdateWindow(win);
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
/**
|
||||
* 取当前主窗口,必要时重建。
|
||||
*
|
||||
* 与 `createWindow()` 的区别:这里会把"窗口已经被崩溃自愈销毁 / 还没建"的情况一并处理掉
|
||||
* (托盘、二次启动、`activate` 都通过它拿窗口),因此调用方拿到的要么是活着的窗口、要么是 null。
|
||||
*/
|
||||
function ensureMainWindow(options: { hidden?: boolean } = {}): BrowserWindow | null {
|
||||
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
|
||||
try {
|
||||
mainWindow = createWindow(options);
|
||||
void loadRenderer(mainWindow);
|
||||
} catch (error) {
|
||||
logger.error(`创建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
mainWindow = null;
|
||||
}
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
async function loadRenderer(win: BrowserWindow): Promise<void> {
|
||||
if (devServerUrl) {
|
||||
await win.loadURL(devServerUrl);
|
||||
@@ -103,24 +135,74 @@ async function loadRenderer(win: BrowserWindow): Promise<void> {
|
||||
await win.loadFile(path.join(currentDir, '../renderer/index.html'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按配置里的 `autoLaunch` 应用「开机自启」。
|
||||
*
|
||||
* 两个实测过的坑:
|
||||
* 1. Electron 44 的 `setLoginItemSettings` 在 **Windows 上没有 `openAsHidden`**
|
||||
* (那是旧的 macOS 选项),所以"登录后不弹主窗口"只能自己带参数 —— 启动时注册 `--hidden`,
|
||||
* `createWindow()` 看到它就把窗口建成隐藏态(见 `startHidden`);
|
||||
* 2. **冒烟验证绝不写登录项**:那会改开发机真实的注册表启动项,属于"验证污染环境"。
|
||||
*/
|
||||
function applyAutoLaunch(): void {
|
||||
if (isSmokeTest) return;
|
||||
const enabled = getConfig().autoLaunch === true;
|
||||
try {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: enabled,
|
||||
path: process.execPath,
|
||||
args: enabled ? ['--hidden'] : [],
|
||||
});
|
||||
logger.info(enabled ? '已开启开机自启(登录 Windows 后静默启动,不弹主窗口)' : '开机自启未开启');
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`设置开机自启失败(不影响主功能,可在设置页重试):${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 单实例:重复启动时聚焦已有窗口
|
||||
const gotLock = app.requestSingleInstanceLock();
|
||||
if (!gotLock) {
|
||||
app.quit();
|
||||
} else {
|
||||
app.on('second-instance', () => {
|
||||
if (!mainWindow) return;
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.focus();
|
||||
showMainWindow();
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」)
|
||||
logger.setVerbose(getConfig().developerMode === true);
|
||||
registerIpcHandlers();
|
||||
registerIslandIpc();
|
||||
|
||||
mainWindow = createWindow();
|
||||
// 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步)
|
||||
applyAutoLaunch();
|
||||
|
||||
mainWindow = createWindow({ hidden: startHidden });
|
||||
await loadRenderer(mainWindow);
|
||||
|
||||
// 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启
|
||||
installCrashGuard({
|
||||
recoverMainWindow: () => {
|
||||
const alive = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
|
||||
if (alive) {
|
||||
alive.hide();
|
||||
alive.destroy();
|
||||
}
|
||||
mainWindow = ensureMainWindow();
|
||||
},
|
||||
isIdle: () =>
|
||||
island.getState().inClass === false &&
|
||||
mainWindow?.isVisible() !== true &&
|
||||
!BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible()),
|
||||
quitApp: () => quitApp(),
|
||||
});
|
||||
// 空闲安装更新:先把手动退出的回调交给 auto-update(它不能再 import 本文件,否则循环导入)
|
||||
setQuitHandler(() => quitApp());
|
||||
|
||||
// 灵动岛:独立的置顶透明小窗口(桌面中上方)
|
||||
try {
|
||||
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
|
||||
@@ -167,6 +249,9 @@ if (!gotLock) {
|
||||
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
|
||||
scheduleStartupUpdateCheck(() => mainWindow);
|
||||
|
||||
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
|
||||
startClientAgent();
|
||||
|
||||
if (isSmokeTest) {
|
||||
try {
|
||||
await runSmokeTest(mainWindow);
|
||||
@@ -177,15 +262,13 @@ if (!gotLock) {
|
||||
}
|
||||
});
|
||||
|
||||
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动) */
|
||||
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动 / 灵动岛"打开应用") */
|
||||
function showMainWindow(): void {
|
||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
||||
mainWindow = createWindow();
|
||||
void loadRenderer(mainWindow);
|
||||
}
|
||||
if (mainWindow.isMinimized()) mainWindow.restore();
|
||||
mainWindow.show();
|
||||
mainWindow.focus();
|
||||
const win = ensureMainWindow();
|
||||
if (!win) return;
|
||||
if (win.isMinimized()) win.restore();
|
||||
win.show();
|
||||
win.focus();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -194,6 +277,8 @@ if (!gotLock) {
|
||||
*/
|
||||
function shutdownResources(): string {
|
||||
const done: string[] = [];
|
||||
// 上报代理先停:否则退出过程中它还会再排一次心跳(幂等,重复调用无副作用)
|
||||
stopClientAgent();
|
||||
// 配置是**异步合并写**的(见 main/config.ts):退出前必须同步落地一次,
|
||||
// 否则"刚改完设置就退出"这一类操作会丢掉最后一次改动。
|
||||
// 放在最前面:后面会销毁窗口与灵动岛,先落盘不依赖任何窗口是否还在。
|
||||
@@ -243,8 +328,8 @@ if (!gotLock) {
|
||||
|
||||
app.on('activate', async () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
mainWindow = createWindow();
|
||||
await loadRenderer(mainWindow);
|
||||
const win = ensureMainWindow();
|
||||
if (win) await loadRenderer(win);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,19 +1,15 @@
|
||||
import { app, ipcMain, shell } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, shell } from 'electron';
|
||||
import type { UpdateInfo } from '@classhelper/shared';
|
||||
import { IPC_CHANNELS } from './channels.js';
|
||||
import { beatNow, getClientAgentStatus } from './client-agent.js';
|
||||
import { clearConfig, getConfig, getConfigPath, saveConfig } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
import { checkForUpdates, ignoreUpdateVersion } from './update.js';
|
||||
import type { DesktopAppInfo, DesktopStoredConfig } from '../types/desktop.js';
|
||||
|
||||
/** IPC 通道名集中管理,preload 与主进程共用同一份字符串 */
|
||||
export const IPC_CHANNELS = {
|
||||
getConfig: 'classhelper:config:get',
|
||||
saveConfig: 'classhelper:config:save',
|
||||
clearConfig: 'classhelper:config:clear',
|
||||
appInfo: 'classhelper:app:info',
|
||||
openExternal: 'classhelper:shell:open-external',
|
||||
updateCheck: 'classhelper:update:check',
|
||||
updateIgnore: 'classhelper:update:ignore',
|
||||
} as const;
|
||||
// 通道名定义搬到了叶子模块 `channels.ts`(原因见那里的注释:preload 在沙箱里不能碰 node 内建模块);
|
||||
// 这里原样再导出,保持既有 `import { IPC_CHANNELS } from './ipc.js'` 的调用点不变。
|
||||
export { IPC_CHANNELS };
|
||||
|
||||
export function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.getConfig, (): DesktopStoredConfig => getConfig());
|
||||
@@ -58,6 +54,84 @@ export function registerIpcHandlers(): void {
|
||||
ipcMain.handle(IPC_CHANNELS.updateIgnore, (_event, version: string): void => {
|
||||
if (typeof version === 'string') ignoreUpdateVersion(version);
|
||||
});
|
||||
|
||||
/** 开机自启:读的是系统登录项,不是配置文件里的值(两者可能被外部改过) */
|
||||
ipcMain.handle(IPC_CHANNELS.getAutoLaunch, (): boolean => {
|
||||
try {
|
||||
return app.getLoginItemSettings().openAtLogin === true;
|
||||
} catch {
|
||||
return getConfig().autoLaunch === true;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 开机自启:写配置 + 同步系统登录项。
|
||||
*
|
||||
* 与 `main/index.ts` 的 `applyAutoLaunch()` 同一套参数(`--hidden` 是"登录后不弹主窗口"的实现方式,
|
||||
* 因为 Electron 44 在 Windows 上没有 `openAsHidden`),返回**系统实际生效**的值。
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.setAutoLaunch, (_event, enabled: boolean): boolean => {
|
||||
const wanted = enabled === true;
|
||||
saveConfig({ autoLaunch: wanted });
|
||||
try {
|
||||
app.setLoginItemSettings({
|
||||
openAtLogin: wanted,
|
||||
path: process.execPath,
|
||||
args: wanted ? ['--hidden'] : [],
|
||||
});
|
||||
return app.getLoginItemSettings().openAtLogin === true;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`写入开机自启失败(配置已保存,下次启动会重试):${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return wanted;
|
||||
}
|
||||
});
|
||||
|
||||
/** 打开日志目录(设置页「打开日志目录」;日志文件是 main.log,见 main/logger.ts) */
|
||||
ipcMain.handle(IPC_CHANNELS.openLogDir, async (): Promise<boolean> => {
|
||||
try {
|
||||
const message = await shell.openPath(app.getPath('logs'));
|
||||
if (message) {
|
||||
logger.warn(`打开日志目录失败:${message}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.warn(`打开日志目录失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 打开开发者工具(「开发者」页的按钮;detached 独立窗口)。
|
||||
*
|
||||
* 两处刻意的限制:
|
||||
* - **冒烟模式下直接拒绝**。冒烟验证会给窗口注入合成事件、测量侧栏几何、整屏截图取样;
|
||||
* 多出一个 DevTools 窗口会抢焦点、并且 detached 窗口会改变 `capturePage()` 的取样范围,
|
||||
* 把既有断言打乱。返回 false 让页面提示"冒烟模式下不可用"。
|
||||
* - 窗口取自 `event.sender` 而不是模块级变量:IPC 只可能由主窗口(或无关的岛窗口)发起,
|
||||
* 按发送方取窗口既不需要在 ipc.ts 里持有 mainWindow 引用(避免与 main/index.ts 循环依赖),
|
||||
* 也保证打开的是**发起调用的那个窗口**的 DevTools。
|
||||
*/
|
||||
ipcMain.handle(IPC_CHANNELS.openDevTools, (event): boolean => {
|
||||
if (process.env.ELECTRON_SMOKE_TEST === '1') {
|
||||
logger.warn('冒烟验证模式下不允许打开开发者工具(会打乱焦点与截图断言)');
|
||||
return false;
|
||||
}
|
||||
const target = BrowserWindow.fromWebContents(event.sender);
|
||||
if (!target || target.isDestroyed()) return false;
|
||||
target.webContents.openDevTools({ mode: 'detach' });
|
||||
return true;
|
||||
});
|
||||
|
||||
/** 服务端联动状态(纯数据:跨 contextBridge 只能传普通对象,见 AGENTS.md §5 第 9 条) */
|
||||
ipcMain.handle(IPC_CHANNELS.clientStatus, () => getClientAgentStatus());
|
||||
|
||||
/** 立即上报一次(设置页「立即上报」按钮) */
|
||||
ipcMain.handle(IPC_CHANNELS.clientBeatNow, () => beatNow());
|
||||
}
|
||||
|
||||
export function getDiagnostics(): { configPath: string; config: DesktopStoredConfig } {
|
||||
|
||||
@@ -101,6 +101,15 @@ let SLIVER_SIZE = { width: ISLAND_SLIVER_WIDTH, height: 22 };
|
||||
*/
|
||||
let BBOX_SIZE = { width: 0, height: 0 };
|
||||
|
||||
/**
|
||||
* 「收回动画」时长(毫秒)。
|
||||
*
|
||||
* 用户点了「知道了 / 标为已读」之后,主进程把形态切回胶囊、让渲染进程沿**既有的形变路径**
|
||||
* 把卡片收回去,到点再淡出隐藏。取值与渲染进程的形变弹簧(约 300~500ms)同量级;
|
||||
* 外观设置里的"动画速度"会同比缩放它(见 `beginClose()`)。
|
||||
*/
|
||||
const CLOSE_ANIMATION_MS = 360;
|
||||
|
||||
/** 各状态的自动收起/隐藏时长(毫秒),0 表示不自动收起 */
|
||||
const TIMEOUTS = {
|
||||
pill: 15_000,
|
||||
@@ -150,6 +159,11 @@ class IslandController {
|
||||
private ready = false;
|
||||
private pendingState: IslandState | null = null;
|
||||
private collapseTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/**
|
||||
* 「收回动画」定时器:点知道了 / 标为已读之后,延时把窗口淡出隐藏。
|
||||
* 任何让岛重新可见的路径(`activate()` 等)都会经 `clearTimers()` 取消它。
|
||||
*/
|
||||
private closeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
/** 尺寸(形变)动画令牌:新动画会作废上一轮,避免互相打架 */
|
||||
private boundsToken = 0;
|
||||
/** 透明度(淡入淡出)动画令牌,与尺寸动画分开,否则淡入会立刻取消形变 */
|
||||
@@ -786,6 +800,48 @@ class IslandController {
|
||||
this.setState({ mode: 'hidden', reason: null });
|
||||
return;
|
||||
}
|
||||
this.beginClose();
|
||||
}
|
||||
|
||||
/**
|
||||
* 收回动画:把形态切回胶囊,让渲染进程沿**既有的胶囊形变路径**把卡片收回去,
|
||||
* `CLOSE_ANIMATION_MS` 之后再淡出隐藏。
|
||||
*
|
||||
* 起因:用户反馈"点「知道了」「已读」时没有收回动画"(其他场景都有)。
|
||||
* 状态**在同一帧就已经落地**(调用方 `dismissActive()` / `clearPendingForClose()` 已清空
|
||||
* active/queued),这里只是把形态切回胶囊 —— **不引入任何跨进程握手,也没有收回快照**
|
||||
* (见 AGENTS.md §5 第 34 条:早先自研的 `closing` / `closingSnapshot` 已整体删除,
|
||||
* 它在触摸屏上会露旧帧;这里复用的是已被 `collapse` 用例验收过的那条路径)。
|
||||
*/
|
||||
private beginClose(): void {
|
||||
// 关掉动画(含"减少动态效果"):保持原行为,直接淡出
|
||||
if (!this.appearance.animations) {
|
||||
this.finishClose();
|
||||
return;
|
||||
}
|
||||
/**
|
||||
* **必须 `skipWindow`**:收回动画是"卡片沿既有的胶囊形变路径缩回去",
|
||||
* 而窗口此刻还是展开/列表态的大包围盒 —— 若这里同步缩窗,卡片会在动画的 360ms 里
|
||||
* 被窗口裁掉(冒烟用例「整批关闭过程中卡片不被窗口裁切」实测最大越界 554.9px,
|
||||
* 用户看到的是一瞬间的硬裁边)。窗口要变小一律等**隐藏之后**由 `syncWindow()` 做,
|
||||
* 这与触摸模式那条"窗口可见期间只增不减"(AGENTS.md §5 第 30 条)是同一条纪律。
|
||||
*/
|
||||
this.setState({ mode: 'pill', reason: 'closing' }, { skipWindow: true });
|
||||
const speed = clamp(this.appearance.speed, 0.5, 2, 1);
|
||||
this.closeTimer = setTimeout(() => {
|
||||
this.closeTimer = null;
|
||||
this.finishClose();
|
||||
}, Math.round(CLOSE_ANIMATION_MS / speed));
|
||||
}
|
||||
|
||||
/**
|
||||
* 收回动画收尾:淡出窗口并把状态落成 hidden —— 与原来的 `hide()` 完全一致,
|
||||
* 所以"窗口最终会隐藏"的既有断言(含轮询写法)不受影响。
|
||||
*/
|
||||
private finishClose(): void {
|
||||
// 动画期间又来了新消息 / 被显式唤起:这次收回归属于旧的一批,放弃收尾
|
||||
if (this.state.mode !== 'pill' || this.state.reason !== 'closing') return;
|
||||
if (this.state.active || this.state.queued.length > 0) return;
|
||||
this.fadeOut();
|
||||
this.setState({ mode: 'hidden', reason: null }, { skipWindow: true });
|
||||
}
|
||||
@@ -908,6 +964,11 @@ class IslandController {
|
||||
clearTimeout(this.collapseTimer);
|
||||
this.collapseTimer = null;
|
||||
}
|
||||
// 收回动画定时器:岛一旦被重新唤起(新消息/显式展开/预览),必须取消这次收尾
|
||||
if (this.closeTimer) {
|
||||
clearTimeout(this.closeTimer);
|
||||
this.closeTimer = null;
|
||||
}
|
||||
if (this.previewTimer) {
|
||||
clearTimeout(this.previewTimer);
|
||||
this.previewTimer = null;
|
||||
@@ -1690,8 +1751,6 @@ export function getSliverSize(): { width: number; height: number } {
|
||||
return { ...SLIVER_SIZE };
|
||||
}
|
||||
|
||||
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
|
||||
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
||||
/**
|
||||
* 夹紧数值:**非法值回落到该字段的默认值**,而不是变成 NaN。
|
||||
*
|
||||
@@ -1700,11 +1759,18 @@ export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
||||
* 渲染进程传一个显式 `undefined` 字段就会得到 NaN,随后 `setOpacity(NaN)` / `setBounds({x:NaN})`
|
||||
* 抛异常;它在非 async 的 ipcMain 监听里抛出,主进程没有兜底,会直接崩掉
|
||||
* (连带托盘、灵动岛、实时连接一起消失)。因此这里补上 fallback。
|
||||
*
|
||||
* 提到模块作用域:收回动画(`beginClose()`)也要用它按 `appearance.speed` 折算时长,
|
||||
* 原先它只定义在 `normalizeAppearance()` 内部,`beginClose()` 引用不到(编译报
|
||||
* `TS2304: Cannot find name 'clamp'`)。
|
||||
*/
|
||||
const clamp = (value: number, min: number, max: number, fallback: number): number => {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return fallback;
|
||||
return Math.min(max, Math.max(min, Math.round(value * 100) / 100));
|
||||
};
|
||||
|
||||
/** 夹紧外观取值(越界值直接收敛,避免用户配置破坏布局) */
|
||||
export function normalizeAppearance(input: IslandAppearance): IslandAppearance {
|
||||
const fallbackOf = DEFAULT_ISLAND_APPEARANCE;
|
||||
return {
|
||||
height: clamp(input.height, 36, 72, fallbackOf.height),
|
||||
|
||||
@@ -1,25 +1,126 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app } from 'electron';
|
||||
|
||||
/**
|
||||
* 主进程日志(控制台输出)。
|
||||
* 主进程日志:控制台输出 + 落盘。
|
||||
*
|
||||
* 打包后的 GUI 进程没有可见的控制台,因此关键信息同时通过:
|
||||
* - 冒烟验证的 ELECTRON_SMOKE_RESULT 结果文件
|
||||
* - 主窗口右上角的连接状态 / 灵动岛状态
|
||||
* - **本文件写出的日志文件**(设置页「打开日志目录」直达)
|
||||
* 对外可见,避免"出了问题却看不到日志"。
|
||||
*
|
||||
* 两条实现约束:
|
||||
* 1. `app.getPath('logs')` **不能在 app ready 之前调用**,所以日志文件路径是**懒解析**的:
|
||||
* 解析失败(或还没 ready)时只走 console,之后任意一次写入会重新尝试;
|
||||
* 2. 单文件超过上限就改名成 `main.log.1`(只留一份旧的,更老的直接删除),
|
||||
* 避免长期运行的教室机器把磁盘写满。
|
||||
*/
|
||||
|
||||
/** 单文件大小上限:超过就轮转(1MB 足够放下很多次崩溃堆栈) */
|
||||
const LOG_MAX_BYTES = 1024 * 1024;
|
||||
/** 轮转后的旧文件名 */
|
||||
const LOG_ROTATED_NAME = 'main.log.1';
|
||||
|
||||
/** 日志文件名(在 app ready 之后解析;null = 暂不可用) */
|
||||
let filePath: string | null = null;
|
||||
/** verbose(= 配置里的 developerMode)时 debug 级别才输出 */
|
||||
let verbose = false;
|
||||
|
||||
/** 解析日志文件路径:失败只返回 null(不抛),调用方继续走 console */
|
||||
function resolveLogFile(): string | null {
|
||||
try {
|
||||
return path.join(app.getPath('logs'), 'main.log');
|
||||
} catch {
|
||||
// app ready 之前调用会抛;此时不写文件即可,控制台输出不受影响
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 文件超过上限就轮转:main.log → main.log.1(覆盖上一份旧的) */
|
||||
function rotateIfNeeded(target: string): void {
|
||||
try {
|
||||
const stat = fs.statSync(target);
|
||||
if (stat.size <= LOG_MAX_BYTES) return;
|
||||
const rotated = path.join(path.dirname(target), LOG_ROTATED_NAME);
|
||||
// 先删旧的再改名:只保留一份历史日志
|
||||
fs.rmSync(rotated, { force: true });
|
||||
fs.renameSync(target, rotated);
|
||||
} catch {
|
||||
// 文件还不存在(第一次写)/ 被占用:都无需处理
|
||||
}
|
||||
}
|
||||
|
||||
/** 落盘一行。任何失败都静默忽略 —— 日志不能反过来把主进程搞崩 */
|
||||
function write(line: string): void {
|
||||
if (!filePath) filePath = resolveLogFile();
|
||||
const target = filePath;
|
||||
if (!target) return;
|
||||
try {
|
||||
fs.mkdirSync(path.dirname(target), { recursive: true });
|
||||
fs.appendFileSync(target, `${line}\n`, 'utf8');
|
||||
rotateIfNeeded(target);
|
||||
} catch {
|
||||
// 忽略:磁盘满 / 权限不足等都不该影响应用运行
|
||||
}
|
||||
}
|
||||
|
||||
function timestamp(): string {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
/**
|
||||
* 设置日志级别(由 `main/index.ts` 按 `config.developerMode` 调用)。
|
||||
* 开启后 debug 级别的日志才会同时进控制台与日志文件。
|
||||
*/
|
||||
setVerbose(enabled: boolean): void {
|
||||
verbose = enabled === true;
|
||||
},
|
||||
/** 当前是否 verbose(供设置页/冒烟断言) */
|
||||
isVerbose(): boolean {
|
||||
return verbose;
|
||||
},
|
||||
debug(message: string, ...args: unknown[]): void {
|
||||
console.log(`[${timestamp()}] DEBUG ${message}`, ...args);
|
||||
if (!verbose) return;
|
||||
const line = `[${timestamp()}] DEBUG ${message}`;
|
||||
console.log(line, ...args);
|
||||
write(`${line}${formatArgs(args)}`);
|
||||
},
|
||||
info(message: string, ...args: unknown[]): void {
|
||||
console.log(`[${timestamp()}] INFO ${message}`, ...args);
|
||||
const line = `[${timestamp()}] INFO ${message}`;
|
||||
console.log(line, ...args);
|
||||
write(`${line}${formatArgs(args)}`);
|
||||
},
|
||||
warn(message: string, ...args: unknown[]): void {
|
||||
console.warn(`[${timestamp()}] WARN ${message}`, ...args);
|
||||
const line = `[${timestamp()}] WARN ${message}`;
|
||||
console.warn(line, ...args);
|
||||
write(`${line}${formatArgs(args)}`);
|
||||
},
|
||||
error(message: string, ...args: unknown[]): void {
|
||||
console.error(`[${timestamp()}] ERROR ${message}`, ...args);
|
||||
const line = `[${timestamp()}] ERROR ${message}`;
|
||||
console.error(line, ...args);
|
||||
write(`${line}${formatArgs(args)}`);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 额外参数转成一行文本(落盘用)。
|
||||
*
|
||||
* 崩溃自愈那边传进来的常常是 Error 对象,**必须把完整堆栈写进文件**,
|
||||
* 否则事后再看 `main.log` 只有一个光秃秃的 message,等于没记。
|
||||
*/
|
||||
function formatArgs(args: unknown[]): string {
|
||||
if (args.length === 0) return '';
|
||||
const parts = args.map((item) => {
|
||||
if (item instanceof Error) return item.stack ?? `${item.name}: ${item.message}`;
|
||||
if (typeof item === 'string') return item;
|
||||
try {
|
||||
return JSON.stringify(item);
|
||||
} catch {
|
||||
return String(item);
|
||||
}
|
||||
});
|
||||
return ` ${parts.join(' ')}`;
|
||||
}
|
||||
@@ -2029,6 +2029,39 @@ async function runIslandChecks(
|
||||
);
|
||||
await sleep(120);
|
||||
const duringClose = { state: island.getState() };
|
||||
|
||||
/**
|
||||
* 收回动画(用户要求:"点知道了 / 标为已读时也要有回收动画")。
|
||||
*
|
||||
* 主进程收到 dismiss / dismiss-all / mark-read / mark-all-read 之后:状态**同一帧就清空**,
|
||||
* 形态先切回胶囊(`reason === 'closing'`)让渲染进程把卡片收回去,`CLOSE_ANIMATION_MS`(360ms,
|
||||
* 会按外观里的"动画速度"缩放)之后才淡出隐藏。这里在点击后的小窗口内轮询采样,
|
||||
* 断言"确实经过了这个阶段、且动画期间窗口仍然可见"。
|
||||
*/
|
||||
let closingSample: { mode: string; reason: string | null; visible: boolean } | null = null;
|
||||
for (let attempt = 0; attempt < 20 && !closingSample; attempt += 1) {
|
||||
const probe = island.getState();
|
||||
if (probe.reason === 'closing') {
|
||||
closingSample = {
|
||||
mode: probe.mode,
|
||||
reason: probe.reason,
|
||||
visible: islandWindow?.isVisible() ?? false,
|
||||
};
|
||||
break;
|
||||
}
|
||||
await sleep(25);
|
||||
}
|
||||
record(
|
||||
'点「知道了」有收回动画(先收回胶囊再淡出,动画期间窗口可见)',
|
||||
closingSample !== null &&
|
||||
closingSample.mode === 'pill' &&
|
||||
closingSample.visible &&
|
||||
duringClose.state.active === null,
|
||||
closingSample
|
||||
? `采样=${closingSample.mode}/${closingSample.reason}/可见:${closingSample.visible}`
|
||||
: '未捕捉到 closing 阶段(可能动画被关掉或耗时过短)',
|
||||
);
|
||||
|
||||
for (let attempt = 0; attempt < 26; attempt += 1) {
|
||||
await probeClosingClip();
|
||||
await sleep(40);
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
type UpdateAssetDto,
|
||||
type UpdateInfo,
|
||||
} from '@classhelper/shared';
|
||||
import { UPDATE_AVAILABLE_CHANNEL } from './channels.js';
|
||||
import { getConfig, saveConfig } from './config.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
@@ -25,8 +26,8 @@ import { logger } from './logger.js';
|
||||
* 客户端没有这个限制,按用户选择**直连 GitHub**。两边归一化出的 `UpdateInfo` 结构一致。
|
||||
*/
|
||||
|
||||
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示) */
|
||||
export const UPDATE_AVAILABLE_CHANNEL = 'classhelper:update:available';
|
||||
/** 主进程 → 渲染进程:发现新版本(渲染进程据此弹一次性提示);定义在叶子模块 `channels.ts` */
|
||||
export { UPDATE_AVAILABLE_CHANNEL };
|
||||
|
||||
/** 启动后多久自动查一次:等窗口与登录流程就绪,也不跟首屏请求抢带宽 */
|
||||
const STARTUP_DELAY_MS = 5_000;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { contextBridge, ipcRenderer } from 'electron';
|
||||
import { IPC_CHANNELS } from '../main/ipc.js';
|
||||
import { UPDATE_AVAILABLE_CHANNEL } from '../main/update.js';
|
||||
import { IPC_CHANNELS, UPDATE_AVAILABLE_CHANNEL } from '../main/channels.js';
|
||||
import type { ClientAgentStatus } from '../main/client-agent.js';
|
||||
import type {
|
||||
DesktopAppInfo,
|
||||
DesktopBridge,
|
||||
@@ -66,6 +66,15 @@ const bridge: DesktopBridge = {
|
||||
onIslandMarkAllRead: (handler: (ids: string[]) => void): void => {
|
||||
ipcRenderer.on('island:mark-all-read', (_event, ids: string[]) => handler(ids ?? []));
|
||||
},
|
||||
|
||||
/* 开机自启 / 日志 / 服务端联动 */
|
||||
getAutoLaunch: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.getAutoLaunch),
|
||||
setAutoLaunch: (enabled: boolean): Promise<boolean> =>
|
||||
ipcRenderer.invoke(IPC_CHANNELS.setAutoLaunch, enabled === true),
|
||||
openLogDir: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openLogDir),
|
||||
openDevTools: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.openDevTools),
|
||||
getClientAgentStatus: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientStatus),
|
||||
beatClientNow: (): Promise<ClientAgentStatus> => ipcRenderer.invoke(IPC_CHANNELS.clientBeatNow),
|
||||
};
|
||||
|
||||
contextBridge.exposeInMainWorld('desktop', bridge);
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router';
|
||||
import { ElButton, ElNotification } from 'element-plus';
|
||||
import type { UpdateInfo } from '@classhelper/shared';
|
||||
import {
|
||||
replayMissedNotifications,
|
||||
startIslandBridge,
|
||||
stopIslandBridge,
|
||||
subscribeIslandMarkAllRead,
|
||||
@@ -72,6 +73,20 @@ function showUpdateAvailable(info: UpdateInfo): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 上线补弹「离线期间收到的消息」(需求 11)。
|
||||
*
|
||||
* **为什么订阅放在 App.vue**:与 `onUpdateAvailable` 同一条理由 —— App.vue 整个应用生命周期
|
||||
* 只挂载一次,而 `ClientLayout` 会随"登出 → 登录"反复挂载。而且恢复事件本身是"可达性由不可达
|
||||
* 变为可达"的那一刻,一次会话里可能发生多次,处理器必须是**幂等**的(`replayMissedNotifications`
|
||||
* 推完就清掉离线起点,重复触发时第二次读到空值、直接返回 0)。
|
||||
*
|
||||
* 处理器是具名函数:`stores/app.ts` 的订阅表按函数引用 off,匿名箭头函数在 onUnmounted 里摘不掉。
|
||||
*/
|
||||
function handleServerRecovered(): void {
|
||||
void replayMissedNotifications();
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动流程:
|
||||
* 1) 从主进程配置恢复服务器地址与登录令牌
|
||||
@@ -107,6 +122,9 @@ onMounted(async () => {
|
||||
subscribeIslandMarkRead();
|
||||
subscribeIslandMarkAllRead();
|
||||
|
||||
// 离线期间收到的消息:可达性恢复的那一刻补弹到灵动岛(App.vue 常驻,所以每次恢复只跑一次)
|
||||
appStore.onServerRecovered(handleServerRecovered);
|
||||
|
||||
// 启动自动检查发现新版本时由主进程推过来(订阅只挂一次,理由见 showUpdateAvailable)
|
||||
window.desktop?.onUpdateAvailable?.(showUpdateAvailable);
|
||||
|
||||
@@ -129,6 +147,7 @@ onMounted(async () => {
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
appStore.offServerRecovered(handleServerRecovered);
|
||||
stopIslandBridge();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1048,6 +1048,15 @@ export async function layoutChromeSelfTest(): Promise<SmokeCheckResult> {
|
||||
// 2.2) 导轨必须"一眼看全":先把设置组展开(11 行图标 = 最坏情况),再断言菜单区没有纵向溢出。
|
||||
// 折叠态页脚必须把**高度**也收成 0 —— 只淡出不留高度的话,那 ~70px 会把最后两个图标
|
||||
// 顶出可视区,用户就得上下滑动(反馈原话:"菜单图标无法完全显示需要上下滑动")。
|
||||
//
|
||||
// ⚠️ 量之前必须等页脚真的收成 0 高:宽度到 64px 与页脚收拢各有一条 260ms 过渡,
|
||||
// 而 `--nav-ease`(Material 曲线)在 74% 处就越过"宽度 ≤ 80px"这个等待条件,
|
||||
// 此时页脚还剩十几像素 —— 直接量 `.menu` 会读出"溢出 16px"的**假红**
|
||||
// (实测出现过一次:同一份产物上一次全绿、下一次红)。这里等的是**稳态几何**,
|
||||
// 与这条断言本来的意图一致(CSS 里那句"量到的是稳态")。
|
||||
const railFooter = document.querySelector<HTMLElement>('.aside-footer');
|
||||
await waitUntil(() => (railFooter?.offsetHeight ?? 0) <= 1, 2000);
|
||||
|
||||
const settingsItem = Array.from(
|
||||
document.querySelectorAll<HTMLElement>('.aside .el-sub-menu .el-menu-item'),
|
||||
).find((element) =>
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
type NotificationDto,
|
||||
type ScheduleDto,
|
||||
} from '@classhelper/shared';
|
||||
import { scheduleApi } from '../api/index.js';
|
||||
import { notificationApi, scheduleApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useNotificationStore } from '../stores/notifications.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
@@ -190,6 +190,81 @@ export function pushCallToIsland(call: NotificationDto): void {
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 补弹离线期间收到的消息(需求 11) */
|
||||
|
||||
/**
|
||||
* 补弹条数上限。
|
||||
*
|
||||
* 灵动岛的多条通知列表默认只显示 `ISLAND_LIST_METRICS.defaultVisibleRows`(3)条 +
|
||||
* 「展开更多(还有 N 条)」。所以一条不补、和一次补十几条,都是同样的阅读体验 ——
|
||||
* 封顶 5 条 = 3 条可见 + 2 条藏在"展开更多"里,正好是打开就能读完的量;
|
||||
* 补太多只会让"展开更多"变成一个没有意义的按钮。
|
||||
*/
|
||||
const REPLAY_MAX_COUNT = 5;
|
||||
|
||||
/**
|
||||
* 上线后补弹「离线期间收到的消息」(需求 11)。
|
||||
*
|
||||
* 调用时机:`App.vue` 订阅 `useAppStore().onServerRecovered`,也就是**可达性由不可达变为可达**
|
||||
* 的那一刻(断网期间的实时通道是断的,那些通知既没弹过、也没进过用户的视野)。
|
||||
*
|
||||
* 数据来源复用既有的通知接口(`notificationApi.list({ unreadOnly: true })`):
|
||||
* 客户端本来就是离线优先的(`fetchWithCache` 会把数据落 IndexedDB),**不为此新建数据源** ——
|
||||
* 补弹只需要"服务端现在有哪些未读",而通知列表本来就是这个问题的答案。
|
||||
*
|
||||
* 实现上有四条约定:
|
||||
* 1. **没有离线起点就直接返回 0**(首次启动 / 一直在线):离线起点只由
|
||||
* `stores/app.ts` 在"由可达变不可达"的那一刻写入,没有它就说明这段时间根本没断过网,
|
||||
* 此时补弹等于把**历史未读**全弹一遍,那不是「离线期间收到的」。
|
||||
* 2. **只补离线起点之后创建的**(`createdAt > offlineSince`),并且在服务端排序之外再排一次
|
||||
* **按时间升序**投递 —— 顺序投递才能让灵动岛的队列保持时间顺序。
|
||||
* 3. **取最近的 5 条**(升序列表的尾部):断网一天可能积压几十条,取最早的那 5 条等于
|
||||
* 让用户先看最旧的、最新的反而被"展开更多"挡住。取最近的 5 条更符合"我刚错过了什么"。
|
||||
* 4. **推完就清掉离线起点**:同一次离线只补一次,否则每次可达性抖动都会重复弹同一批。
|
||||
*
|
||||
* @returns 实际补弹的条数(0 = 没补)
|
||||
*/
|
||||
export async function replayMissedNotifications(): Promise<number> {
|
||||
// 冒烟保护:`verify:desktop` / `verify:packaged` 有一条「灵动岛队列」的断言链
|
||||
// (100+ 条断言量的是卡片的条数、顺序、高度与"展开更多"的分页),
|
||||
// 冒烟脚本会反复断开/恢复网络,注入的合成通知与这里的补弹会互相打乱队列,
|
||||
// 让那些断言随机变红。冒烟模式下**直接跳过补弹**(也不动离线起点),
|
||||
// 补弹本身由人工/单元路径覆盖,不进冒烟。
|
||||
if (window.desktop?.smokeTest === true) return 0;
|
||||
|
||||
const offlineSince = readOfflineSince();
|
||||
if (!offlineSince) return 0;
|
||||
const sinceMs = Date.parse(offlineSince);
|
||||
if (Number.isNaN(sinceMs)) {
|
||||
// 存储里是坏值(手工改过 / 旧版本写入格式不同):清掉,别让它永远挡住补弹
|
||||
clearOfflineSince();
|
||||
return 0;
|
||||
}
|
||||
|
||||
let unread: NotificationDto[];
|
||||
try {
|
||||
unread = await notificationApi.list({ unreadOnly: true });
|
||||
} catch {
|
||||
// 刚恢复就又断了(或服务端还没真正就绪):保留离线起点,等下一次恢复再补
|
||||
return 0;
|
||||
}
|
||||
|
||||
const missed = unread
|
||||
.filter((item) => {
|
||||
const at = Date.parse(item.createdAt);
|
||||
return !Number.isNaN(at) && at > sinceMs;
|
||||
})
|
||||
.sort((a, b) => Date.parse(a.createdAt) - Date.parse(b.createdAt));
|
||||
|
||||
const picked = missed.slice(-REPLAY_MAX_COUNT);
|
||||
for (const item of picked) pushNotificationToIsland(item);
|
||||
|
||||
// 无论补到几条都清掉:这一轮离线已经被处理过了
|
||||
clearOfflineSince();
|
||||
console.info(`[离线补弹] 补弹离线消息 ${picked.length} 条(离线起点 ${offlineSince})`);
|
||||
return picked.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* 灵动岛点了"标为已读" → 在通知中心里也标记已读(并刷新未读红点)。
|
||||
* 由 App.vue 在挂载时订阅一次。
|
||||
|
||||
@@ -25,9 +25,9 @@ const ui = useUiStore();
|
||||
*
|
||||
* - **一级项**:课表 / 作业 / 通知 / 成绩 —— 直接铺在侧栏上,**不再套「学习」分组**。
|
||||
* 分组标题白占一行、还得多点一次展开,折叠成图标导轨时尤其明显(用户要求拆掉)。
|
||||
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由)。
|
||||
* 6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。折叠后是图标导轨,
|
||||
* 分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
|
||||
* - **分组**:只剩「设置」(通用 / 外观 / 灵动岛 / 提醒 / 账号 / 关于,各自是独立路由;
|
||||
* 开发者模式下再多一个「开发者」)。6 个子项平铺太长,保留分组与"默认只展开当前路由所在分组"。
|
||||
* 折叠后是图标导轨,分组子项与一级项一样以图标列出(见 styles/index.css 的折叠态规则)。
|
||||
*
|
||||
* 顶栏左侧汉堡按钮把整栏收成图标导轨(状态持久化在主进程配置里)。
|
||||
*/
|
||||
@@ -38,21 +38,39 @@ const primaryItems = [
|
||||
{ path: '/grades', title: '成绩', icon: 'Trophy' },
|
||||
];
|
||||
|
||||
const menuGroups = [
|
||||
{
|
||||
title: '设置',
|
||||
icon: 'Setting',
|
||||
items: [
|
||||
/** 「设置」分组的常规子项(顺序即侧栏顺序) */
|
||||
const settingItems = [
|
||||
{ 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' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* 菜单分组(**computed**:子项随开发者模式变化,见下)。
|
||||
*
|
||||
* 「开发者」(需求 10)只在 `ui.developerMode === true` 时出现 —— 它是个排查问题的入口
|
||||
* (版本/日志/更新/DevTools),常驻只会让教室机器上的使用者点进一个看不懂的页面。
|
||||
*
|
||||
* 图标用 `Connection`:新增图标必须同时进 `renderer/icons.ts` 的注册表,
|
||||
* 而那是本任务范围外的文件 —— 所以**只用已注册图标**(`scripts/check-icons.mjs`
|
||||
* 会在构建前拦下未注册的名字)。
|
||||
*
|
||||
* 折叠成图标导轨后多一行图标:开启开发者模式是用户自己的选择,默认关闭 ⇒
|
||||
* 冒烟(默认配置)里的导轨几何与"不出现滚动条"断言不受影响。
|
||||
*/
|
||||
const menuGroups = computed(() => [
|
||||
{
|
||||
title: '设置',
|
||||
icon: 'Setting',
|
||||
items: ui.developerMode
|
||||
? [...settingItems, { path: '/settings/developer', title: '开发者', icon: 'Connection' }]
|
||||
: settingItems,
|
||||
},
|
||||
]);
|
||||
|
||||
/* -------------------------------------------------------------- 导航指示器 */
|
||||
|
||||
/**
|
||||
@@ -70,18 +88,46 @@ const menuGroups = [
|
||||
*/
|
||||
const indicator = ref({ x: 0, y: 0, width: 0, height: 0, visible: false });
|
||||
|
||||
/**
|
||||
* 底片的**裁剪框**:与 `.menu` 的滚动视口逐像素重合。
|
||||
*
|
||||
* 底片挂在 `.aside` 上,而菜单项是在 `.menu` 里滚动的(`.menu` 是 `flex:1 + min-height:0 +
|
||||
* overflow-y:auto`,设置组展开时内容约 606px 会超过窗口高度)。激活项被滚出视口时,
|
||||
* 底片会跟着它跑到品牌区或页脚上 —— 套一层视口大小的 `overflow:hidden`,
|
||||
* 就是让底片"只在菜单看得见的地方存在"。
|
||||
*/
|
||||
const clipStyle = ref<Record<string, string>>({
|
||||
top: '0px',
|
||||
left: '0px',
|
||||
width: '0px',
|
||||
height: '0px',
|
||||
});
|
||||
|
||||
function syncIndicator(): void {
|
||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
const active = aside?.querySelector<HTMLElement>('.el-menu-item.is-active');
|
||||
if (!aside || !active) {
|
||||
const scroller = aside?.querySelector<HTMLElement>('.menu') ?? null;
|
||||
if (!aside || !scroller) {
|
||||
indicator.value = { ...indicator.value, visible: false };
|
||||
return;
|
||||
}
|
||||
const asideRect = aside.getBoundingClientRect();
|
||||
const boxRect = scroller.getBoundingClientRect();
|
||||
// 底片的坐标一律相对「菜单视口」,不再相对 `.aside` —— 裁剪框把两者的原点对齐了
|
||||
clipStyle.value = {
|
||||
top: `${boxRect.top - asideRect.top}px`,
|
||||
left: `${boxRect.left - asideRect.left}px`,
|
||||
width: `${boxRect.width}px`,
|
||||
height: `${boxRect.height}px`,
|
||||
};
|
||||
const active = scroller.querySelector<HTMLElement>('.el-menu-item.is-active');
|
||||
if (!active) {
|
||||
indicator.value = { ...indicator.value, visible: false };
|
||||
return;
|
||||
}
|
||||
const rect = active.getBoundingClientRect();
|
||||
indicator.value = {
|
||||
x: rect.left - asideRect.left,
|
||||
y: rect.top - asideRect.top,
|
||||
x: rect.left - boxRect.left,
|
||||
y: rect.top - boxRect.top,
|
||||
width: rect.width,
|
||||
height: rect.height,
|
||||
visible: rect.width > 0 && rect.height > 0,
|
||||
@@ -114,6 +160,32 @@ function scheduleIndicatorSync(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 菜单内部滚动:底片要**当帧**跟上,而且这一路上不能走过渡动画。
|
||||
*
|
||||
* 两点都和"滚动是连续手势"有关:
|
||||
* 1. 不能用 `scheduleIndicatorSync()`(rAF 推后一帧)—— 快速滚动时底片会肉眼可见地落后;
|
||||
* 滚动事件本身在布局之后派发,在这里同步量一次不会再插入一次强制布局。
|
||||
* 2. 不能走 `.nav-indicator` 的 LAYOUT 弹簧(397ms)—— 那是"切换菜单项"的动画参数,
|
||||
* 用在滚动上就成了"底片慢吞吞地追菜单项"。所以滚动期间挂 `is-following` 关掉过渡,
|
||||
* 停手 140ms 后恢复,下一次点菜单项照样有滑动动画。
|
||||
*/
|
||||
const SCROLL_SETTLE_MS = 140;
|
||||
|
||||
let menuScroller: HTMLElement | null = null;
|
||||
let scrollSettleTimer = 0;
|
||||
const indicatorFollowing = ref(false);
|
||||
|
||||
function handleMenuScroll(): void {
|
||||
if (!indicatorFollowing.value) indicatorFollowing.value = true;
|
||||
if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer);
|
||||
scrollSettleTimer = window.setTimeout(() => {
|
||||
scrollSettleTimer = 0;
|
||||
indicatorFollowing.value = false;
|
||||
}, SCROLL_SETTLE_MS);
|
||||
syncIndicator();
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次测量放在 `onMounted`:子组件先于父组件挂载完成,此刻 `.el-menu-item.is-active`
|
||||
* 已在文档里,量完再让浏览器首帧绘制 —— 指示器第一次出现就是正确位置,不会从左上角滑过去。
|
||||
@@ -125,6 +197,11 @@ function scheduleIndicatorSync(): void {
|
||||
onMounted(() => {
|
||||
syncIndicator();
|
||||
const aside = document.querySelector<HTMLElement>('.aside.ch-nav');
|
||||
if (aside) {
|
||||
// 菜单自己的滚动容器:翻菜单时底片必须跟着走(用户反馈的「上下滑动时项目选择块不移动」)
|
||||
menuScroller = aside.querySelector<HTMLElement>('.menu');
|
||||
menuScroller?.addEventListener('scroll', handleMenuScroll, { passive: true });
|
||||
}
|
||||
if (aside && typeof ResizeObserver !== 'undefined') {
|
||||
asideObserver = new ResizeObserver(scheduleIndicatorSync);
|
||||
asideObserver.observe(aside);
|
||||
@@ -134,6 +211,10 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
if (indicatorFrame) cancelAnimationFrame(indicatorFrame);
|
||||
indicatorFrame = 0;
|
||||
if (scrollSettleTimer) window.clearTimeout(scrollSettleTimer);
|
||||
scrollSettleTimer = 0;
|
||||
menuScroller?.removeEventListener('scroll', handleMenuScroll);
|
||||
menuScroller = null;
|
||||
asideObserver?.disconnect();
|
||||
asideObserver = null;
|
||||
});
|
||||
@@ -172,7 +253,7 @@ const activeMenu = computed(() => route.path);
|
||||
|
||||
/** 当前路由所属的分组标题(用于"只默认展开所在分组",与 ClassIsland 一致) */
|
||||
const activeGroupTitle = computed(() => {
|
||||
const group = menuGroups.find((item) => item.items.some((entry) => route.path === entry.path));
|
||||
const group = menuGroups.value.find((item) => item.items.some((entry) => route.path === entry.path));
|
||||
// 落在**一级项**(课表/作业/通知/成绩)上没有所属分组 —— 返回 null,别拿某个组的名字顶上去
|
||||
return group?.title ?? null;
|
||||
});
|
||||
@@ -190,7 +271,7 @@ const openedGroups = ref<string[]>(activeGroupTitle.value ? [activeGroupTitle.va
|
||||
watch(
|
||||
() => route.path,
|
||||
async (path) => {
|
||||
const group = menuGroups.find((item) => item.items.some((entry) => path === entry.path));
|
||||
const group = menuGroups.value.find((item) => item.items.some((entry) => path === entry.path));
|
||||
if (!group) return;
|
||||
// 展开当前组(不主动关闭用户已展开的其它组)
|
||||
await nextTick();
|
||||
@@ -215,7 +296,7 @@ function go(name: string): void {
|
||||
/** 开发期自检:菜单路径必须存在于路由表中 */
|
||||
if (import.meta.env.DEV) {
|
||||
const knownPaths = new Set(router.getRoutes().map((item) => item.path));
|
||||
for (const group of menuGroups) {
|
||||
for (const group of menuGroups.value) {
|
||||
for (const item of group.items) {
|
||||
if (!knownPaths.has(item.path)) {
|
||||
console.error(`[ClientLayout] 菜单项未注册对应路由:${item.path}`);
|
||||
@@ -301,8 +382,16 @@ onUnmounted(() => {
|
||||
<span class="brand-text">ClassHelper</span>
|
||||
</div>
|
||||
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
|
||||
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
|
||||
<div class="nav-indicator" :style="indicatorStyle" aria-hidden="true" />
|
||||
外层 .nav-indicator-clip 与 .menu 的视口重合(overflow:hidden),
|
||||
把"跟着菜单滚出视口"的底片裁掉,不让它盖到品牌区/页脚上。
|
||||
两层都 pointer-events:none —— 底片盖在菜单项上,否则会把点击吃掉。 -->
|
||||
<div class="nav-indicator-clip" :style="clipStyle" aria-hidden="true">
|
||||
<div
|
||||
class="nav-indicator"
|
||||
:class="{ 'is-following': indicatorFollowing }"
|
||||
:style="indicatorStyle"
|
||||
/>
|
||||
</div>
|
||||
<el-menu
|
||||
ref="menuRef"
|
||||
:default-active="activeMenu"
|
||||
@@ -444,6 +533,18 @@ onUnmounted(() => {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/*
|
||||
* 指示器的裁剪框:位置与尺寸每帧由 syncIndicator() 量成"与 .menu 视口重合"。
|
||||
* 少了它,激活项被滚出菜单视口时底片会跑到品牌区/页脚上(用户反馈「上下滑动时
|
||||
* 项目选择块不移动」的那次修复里一起加的)。
|
||||
*/
|
||||
.nav-indicator-clip {
|
||||
position: absolute;
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* 导航指示器:一个跟着激活项平移的底片,承载了原先"激活项底色 + 左侧强调条"两件事。
|
||||
*
|
||||
@@ -459,7 +560,6 @@ onUnmounted(() => {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
z-index: 0;
|
||||
border-radius: var(--ch-radius-control);
|
||||
background: var(--ch-accent-soft);
|
||||
/* 必须穿透:它盖在菜单项上,否则点击会被它吃掉 */
|
||||
@@ -472,6 +572,14 @@ onUnmounted(() => {
|
||||
opacity var(--ch-dur-fast) var(--ch-ease-out);
|
||||
}
|
||||
|
||||
/*
|
||||
* 菜单滚动期间(含停手后的 140ms)关掉过渡:滚动是连续手势,底片要"贴在"菜单项上一起走,
|
||||
* 而不是每帧都启动一次 397ms 的弹簧去追。松手后恢复,点菜单项照样有滑动动画。
|
||||
*/
|
||||
.nav-indicator.is-following {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
/* 左侧强调条(原有观感,现在跟着底片一起滑) */
|
||||
.nav-indicator::before {
|
||||
content: '';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useUiStore } from '../stores/ui.js';
|
||||
|
||||
/**
|
||||
* Electron 生产环境通过 file:// 加载页面,无法使用 history 模式,
|
||||
@@ -82,6 +83,18 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('../views/settings/AboutView.vue'),
|
||||
meta: { title: '关于' },
|
||||
},
|
||||
{
|
||||
/**
|
||||
* 开发者(需求 10)。入口只在 `config.developerMode === true` 时出现在侧栏上,
|
||||
* 但**路由始终注册**:关掉开关后侧栏项消失,已打开的页面靠一条守卫弹回「通用」,
|
||||
* 不做成条件注册 —— 条件注册会让 `router.getRoutes()` 随配置变化,
|
||||
* ClientLayout 的开发期自检与直接输 URL 的行为都会变得难以预期。
|
||||
*/
|
||||
path: 'settings/developer',
|
||||
name: 'settings-developer',
|
||||
component: () => import('../views/settings/DeveloperSettingsView.vue'),
|
||||
meta: { title: '开发者' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ path: '/:pathMatch(.*)*', redirect: '/schedule' },
|
||||
@@ -99,6 +112,16 @@ router.beforeEach((to) => {
|
||||
return true;
|
||||
}
|
||||
if (!auth.isAuthenticated) return { name: 'login' };
|
||||
/**
|
||||
* 开发者页(需求 10)只在开发者模式开启时可进:侧栏项本身也是条件显示的,
|
||||
* 但配置可能是在**停留在这个页面时**被关掉的(「通用」页的开关),
|
||||
* 所以需要这条守卫把它弹回「通用」,不让用户在看一个"已经不该存在"的页面。
|
||||
*
|
||||
* 这里读的是 ui store 里的状态(由 `ui.init()` 在首屏前从主进程配置恢复),
|
||||
* 不额外调 `window.desktop.getConfig()` —— 导航守卫是同步高频路径,
|
||||
* 每次跳转都跨一次 contextBridge 不值得。
|
||||
*/
|
||||
if (to.name === 'settings-developer' && !useUiStore().developerMode) return { name: 'settings-general' };
|
||||
return true;
|
||||
});
|
||||
|
||||
|
||||
@@ -13,6 +13,51 @@ import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
||||
|
||||
type RecoveryHandler = () => void;
|
||||
|
||||
/**
|
||||
* 「本次离线从什么时候开始」的本地时间戳键(需求 11)。
|
||||
*
|
||||
* 为什么用 localStorage 而不是主进程配置(config.json):
|
||||
* 它是一个**瞬时状态**,只描述"当前这一轮断网",一旦补弹完成(或进程重启后重新联网)就作废,
|
||||
* 不需要跨进程读、也不需要跟着设备身份走。放 localStorage 有三点好处:
|
||||
* - 写入发生在每次请求的**可达性回调**里(高频路径),localStorage 是同步内存写,
|
||||
* 不会像 saveConfig 那样排一次主进程 IPC + 120ms 合并窗口;
|
||||
* - 它是**渲染进程自己**的读-改-清闭环(bridge.ts 读它 → 补弹 → 清掉),不跨越 contextBridge;
|
||||
* - 跨重启也够用:网页存储随 profile 持久化,补弹窗口本来就只关心"最近一次离线"。
|
||||
*/
|
||||
const OFFLINE_SINCE_KEY = 'classhelper.offlineSince';
|
||||
|
||||
/**
|
||||
* 记下"本次离线从什么时候开始"(ISO 字符串)。
|
||||
* 读不到 / 写不进(隐私模式、存储被清)时静默跳过 —— 补弹只是锦上添花,不该影响可达性上报。
|
||||
*/
|
||||
function markOfflineSince(): void {
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
localStorage.setItem(OFFLINE_SINCE_KEY, now);
|
||||
console.info(`[离线] 已记录本次离线起点:${now}`);
|
||||
} catch {
|
||||
// 存储不可用:这次不补弹,但联网后的恢复流程照常
|
||||
}
|
||||
}
|
||||
|
||||
/** 读本次离线起点(ISO 字符串);没有记录(首次启动 / 一直在线)返回 null */
|
||||
export function readOfflineSince(): string | null {
|
||||
try {
|
||||
return localStorage.getItem(OFFLINE_SINCE_KEY);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 清掉离线起点:补弹完成后调用,避免同一次离线被重复补弹 */
|
||||
export function clearOfflineSince(): void {
|
||||
try {
|
||||
localStorage.removeItem(OFFLINE_SINCE_KEY);
|
||||
} catch {
|
||||
// 清不掉也不影响功能:下一次"由可达变不可达"会覆盖它
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用级状态:服务器地址、可达性、离线缓存统计、同步时间。
|
||||
* 「联网后自动同步」的触发点就在 ping() 里:不可达 -> 可达 时通知所有订阅者刷新数据。
|
||||
@@ -48,13 +93,22 @@ export const useAppStore = defineStore('app', () => {
|
||||
recoveryHandlers.delete(handler);
|
||||
}
|
||||
|
||||
/** 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性 */
|
||||
/**
|
||||
* 请求拦截器会把每次请求的结果回报过来,这里据此维护可达性。
|
||||
*
|
||||
* 「由可达变不可达」的那一刻顺手记下离线起点(需求 11 的补弹基准):
|
||||
* 只在**边界**上写,不在"一直不可达"的每次请求上重复覆盖,否则补弹窗口会被不停地
|
||||
* 推迟到"最后一次失败",离线期间收到的通知就永远落在窗口之外了。
|
||||
*/
|
||||
setReachabilityReporter((reachable) => {
|
||||
if (reachable && !serverReachable.value && initialized.value) {
|
||||
serverReachable.value = true;
|
||||
notifyRecovered();
|
||||
return;
|
||||
}
|
||||
if (!reachable && serverReachable.value && initialized.value) {
|
||||
markOfflineSince();
|
||||
}
|
||||
serverReachable.value = reachable;
|
||||
});
|
||||
|
||||
@@ -68,6 +122,9 @@ export const useAppStore = defineStore('app', () => {
|
||||
if (!serverReachable.value && initialized.value) notifyRecovered();
|
||||
serverReachable.value = true;
|
||||
} else {
|
||||
// 健康探针是机器**空闲时唯一**的可达性信号(没有任何业务请求在跑),
|
||||
// 所以这条路径也要记离线起点,否则空闲断网期间收到的通知补不回来。
|
||||
if (serverReachable.value && initialized.value) markOfflineSince();
|
||||
serverReachable.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,24 +3,47 @@ import { defineStore } from 'pinia';
|
||||
import type { DesktopStoredConfig } from '../../types/desktop.js';
|
||||
|
||||
/**
|
||||
* 界面偏好(主题 / 侧边栏折叠)。
|
||||
* 界面偏好(主题 / 侧边栏折叠 / 关怀模式)。
|
||||
*
|
||||
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed)里,
|
||||
* 持久化在主进程配置(DesktopStoredConfig.theme / sidebarCollapsed / careMode)里,
|
||||
* 与灵动岛外观等设置同一套落盘机制;浏览器调试(无 window.desktop)回落 localStorage。
|
||||
* 深色主题 = 在 <html> 上挂 .dark 类(Element Plus dark css-vars + styles/index.css 的
|
||||
* html.dark 品牌变量),应用主题的全部入口都必须走 setTheme,保证类名与配置同步。
|
||||
*
|
||||
* 关怀模式 = 在 <html> 上挂 .care-mode 类(styles/index.css 末尾那个集中覆盖块),
|
||||
* 只放大正文与控件字号,不动任何几何令牌 —— 理由写在那个块的注释里。
|
||||
*/
|
||||
|
||||
const THEME_KEY = 'classhelper.theme';
|
||||
const SIDEBAR_KEY = 'classhelper.sidebarCollapsed';
|
||||
const CARE_MODE_KEY = 'classhelper.careMode';
|
||||
const DEVELOPER_MODE_KEY = 'classhelper.developerMode';
|
||||
|
||||
function applyThemeClass(theme: 'light' | 'dark'): void {
|
||||
document.documentElement.classList.toggle('dark', theme === 'dark');
|
||||
}
|
||||
|
||||
/**
|
||||
* 关怀模式的类名开关。**只碰这一个类**:放大规则全在 CSS 里,
|
||||
* JS 侧不逐个元素改字号(那样既漏元素又把主题与字号耦合在一起)。
|
||||
*/
|
||||
function applyCareModeClass(enabled: boolean): void {
|
||||
document.documentElement.classList.toggle('care-mode', enabled);
|
||||
}
|
||||
|
||||
export const useUiStore = defineStore('ui', () => {
|
||||
const theme = ref<'light' | 'dark'>('light');
|
||||
const sidebarCollapsed = ref(false);
|
||||
const careMode = ref(false);
|
||||
/**
|
||||
* 开发者模式(需求 10)。
|
||||
*
|
||||
* 它既是主进程配置(`config.developerMode`,决定日志级别),也是**侧栏的一项** ——
|
||||
* 所以必须放在 store 里:关掉开关时「开发者」菜单项要立刻消失、路由守卫也要立刻生效,
|
||||
* 而「通用」页与 `ClientLayout.vue` 是两个不同的组件,靠各自的本地 ref 是传不过去的
|
||||
* (此前"改完设置要重启客户端才看到"多半就是这个原因)。
|
||||
*/
|
||||
const developerMode = ref(false);
|
||||
|
||||
/** 启动时从主进程配置恢复(App.vue 的 boot 流程里调用,早于首屏渲染) */
|
||||
async function init(): Promise<void> {
|
||||
@@ -29,10 +52,14 @@ export const useUiStore = defineStore('ui', () => {
|
||||
if (config) {
|
||||
theme.value = config.theme === 'dark' ? 'dark' : 'light';
|
||||
sidebarCollapsed.value = config.sidebarCollapsed === true;
|
||||
applyCareMode(config.careMode === true);
|
||||
developerMode.value = config.developerMode === true;
|
||||
}
|
||||
} else {
|
||||
theme.value = localStorage.getItem(THEME_KEY) === 'dark' ? 'dark' : 'light';
|
||||
sidebarCollapsed.value = localStorage.getItem(SIDEBAR_KEY) === '1';
|
||||
applyCareMode(localStorage.getItem(CARE_MODE_KEY) === '1');
|
||||
developerMode.value = localStorage.getItem(DEVELOPER_MODE_KEY) === '1';
|
||||
}
|
||||
applyThemeClass(theme.value);
|
||||
}
|
||||
@@ -66,8 +93,64 @@ export const useUiStore = defineStore('ui', () => {
|
||||
void setSidebarCollapsed(!sidebarCollapsed.value);
|
||||
}
|
||||
|
||||
return { theme, sidebarCollapsed, init, setTheme, toggleTheme, setSidebarCollapsed, toggleSidebar };
|
||||
/**
|
||||
* 应用关怀模式(需求 14):只挂类名 + 记状态,**不落盘**。
|
||||
* 启动时由 `init()` 在首屏渲染前调用,所以刷新页面不会看到"先正常、后放大"的跳变。
|
||||
*/
|
||||
function applyCareMode(enabled: boolean): void {
|
||||
careMode.value = enabled;
|
||||
applyCareModeClass(enabled);
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换关怀模式(「通用」设置页的开关):立刻生效并写回配置。
|
||||
*
|
||||
* 注意:用户明确开启时**不放大侧栏导轨**(见 styles/index.css 的 care-mode 块),
|
||||
* 也**不做**任何 AI 助手按钮的联动 —— 本客户端目前没有助手入口,那条联动只存在于
|
||||
* Web 管理端;这里不为了"看起来对称"去造一个入口。
|
||||
*/
|
||||
async function setCareMode(enabled: boolean): Promise<void> {
|
||||
applyCareMode(enabled);
|
||||
if (window.desktop) {
|
||||
await window.desktop.saveConfig({ careMode: enabled }).catch(() => undefined);
|
||||
} else {
|
||||
localStorage.setItem(CARE_MODE_KEY, enabled ? '1' : '0');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换开发者模式(「通用」设置页的开关):立刻生效并写回配置。
|
||||
*
|
||||
* 打开之后有三件事同时发生,这里只负责**状态与落盘**:
|
||||
* 1. 侧栏「设置」分组里出现「开发者」项(`ClientLayout.vue` 按 `ui.developerMode` 渲染);
|
||||
* 2. `main/index.ts` 在下次启动时把日志级别调到 debug(`logger.setVerbose`)——
|
||||
* 本次会话的日志级别**不重开**,避免开着日志复现问题时把级别改来改去;
|
||||
* 3. 「通用」页上给出"debug 级日志已开启"的提示。
|
||||
*/
|
||||
async function setDeveloperMode(enabled: boolean): Promise<void> {
|
||||
developerMode.value = enabled;
|
||||
if (window.desktop) {
|
||||
await window.desktop.saveConfig({ developerMode: enabled }).catch(() => undefined);
|
||||
} else {
|
||||
localStorage.setItem(DEVELOPER_MODE_KEY, enabled ? '1' : '0');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
theme,
|
||||
sidebarCollapsed,
|
||||
careMode,
|
||||
developerMode,
|
||||
init,
|
||||
setTheme,
|
||||
toggleTheme,
|
||||
setSidebarCollapsed,
|
||||
toggleSidebar,
|
||||
applyCareMode,
|
||||
setCareMode,
|
||||
setDeveloperMode,
|
||||
};
|
||||
});
|
||||
|
||||
/** 供类型检查使用(仅引用类型) */
|
||||
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed'>;
|
||||
export type UiConfig = Pick<DesktopStoredConfig, 'theme' | 'sidebarCollapsed' | 'careMode' | 'developerMode'>;
|
||||
@@ -95,6 +95,12 @@
|
||||
--el-border-color-lighter: rgba(0, 0, 0, 0.05);
|
||||
--el-fill-color-blank: transparent;
|
||||
--el-font-size-base: 13px;
|
||||
|
||||
/* 关怀模式的字号倍率(需求 14)。
|
||||
* 平时恒为 1:它只作为「当前有没有放大」的单一事实来源,方便 JS 侧读取与断言,
|
||||
* 真正的放大规则集中在文件末尾的 `html.care-mode` 块里(那里才是覆盖点,
|
||||
* 且**刻意只动字号**——圆角 / 阴影 / 侧栏宽度等几何令牌一律不碰)。 */
|
||||
--ch-font-scale: 1;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 深色主题 */
|
||||
@@ -946,3 +952,61 @@ textarea {
|
||||
::view-transition-new(root) {
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 关怀模式(需求 14)
|
||||
*
|
||||
* 给视力不佳的用户把**正文与控件**整体放大一档(约 +15%)。由 `stores/ui.ts` 的
|
||||
* `applyCareMode()` 在首屏渲染前按 `config.careMode` 给 `<html>` 挂/摘 `.care-mode` 类。
|
||||
*
|
||||
* 三条刻意的边界,改这个块之前先读:
|
||||
*
|
||||
* 1. **只放大字号,不动几何。** 圆角(`--ch-radius-*`)、阴影(`--ch-shadow-*`)、
|
||||
* 卡片内边距、侧栏宽度一律不碰 —— 放大字号已经会让文字换行,再动几何就是"另一个主题",
|
||||
* 而且 `verify:desktop` 的多条断言量的是像素几何。
|
||||
* 2. **侧栏导轨(`.ch-nav`)的条目字号不在这里放大。** §5 第 49 条那套"折叠成图标导轨"
|
||||
* 的硬要求(导轨宽度、条目高度、折叠态文字必须既无宽度又透明)是按固定几何量出来的,
|
||||
* 动导航条目字号会让折叠态文字重新占宽而断言变红。导航的可见标签保持原字号。
|
||||
* 3. **用 `html.care-mode` 而不是 `:root`**:特异性 (0,1,1) 高于普通类选择器,
|
||||
* 能稳定压过上面那些 `.page-title` / `.el-card__header` 等单类规则,不必写 `!important`。
|
||||
* 深色主题无关 —— 这里一条颜色都没写。
|
||||
*/
|
||||
html.care-mode {
|
||||
/* 放大倍率放在这里,下面的覆盖全部由它算出来 —— 想再调一档只改这一个数 */
|
||||
--ch-font-scale: 1.15;
|
||||
/* 覆盖 Element Plus 的基准字号:按钮、输入框、下拉、分页、消息框等控件都跟着它走
|
||||
(基准值是 :root 里的 13px,见上面的 Element Plus 变量对齐) */
|
||||
--el-font-size-base: calc(13px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
/* 页面标题与副标题(`views/*` 的通用页头) */
|
||||
html.care-mode .page-title {
|
||||
font-size: calc(20px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
html.care-mode .page-subtitle {
|
||||
font-size: calc(12.5px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
/* 卡片标题 / 折叠面板标题(这两处在上面被显式写成 13.5px,必须逐条覆盖) */
|
||||
html.care-mode .el-card__header,
|
||||
html.care-mode .el-collapse-item__header {
|
||||
font-size: calc(13.5px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
/* 表格:表头与单元格一起放大,行高由内容自然撑开(不设固定 height) */
|
||||
html.care-mode .el-table {
|
||||
font-size: calc(13px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
html.care-mode .el-table th.el-table__cell {
|
||||
font-size: calc(12.5px * var(--ch-font-scale));
|
||||
}
|
||||
|
||||
/* 表单里 Element Plus 自己的标签/说明文字(默认跟着 --el-font-size-base,
|
||||
* 但 `.el-form-item__label` 在部分页面被行内样式压过,这里统一一档更稳) */
|
||||
html.care-mode .el-form-item__label,
|
||||
html.care-mode .el-descriptions__label,
|
||||
html.care-mode .el-descriptions__content,
|
||||
html.care-mode .el-message-box__message {
|
||||
font-size: calc(13px * var(--ch-font-scale));
|
||||
}
|
||||
@@ -2,10 +2,13 @@
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue';
|
||||
import { ElMessage } from 'element-plus';
|
||||
import {
|
||||
NOTIFICATION_PRIORITIES,
|
||||
PRIORITY_LABELS,
|
||||
PRIORITY_TAG_TYPES,
|
||||
dayKeyLocal,
|
||||
formatDate,
|
||||
relativeTime,
|
||||
shiftDayKey,
|
||||
type NotificationDto,
|
||||
type NotificationPriority,
|
||||
} from '@classhelper/shared';
|
||||
@@ -24,6 +27,85 @@ const visibleItems = computed(() =>
|
||||
filter.value === 'unread' ? store.items.filter((item) => !item.read) : store.items,
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------- 按天归档 / 按发布人 / 按类型 */
|
||||
/**
|
||||
* 三个筛选都在**已加载的列表**上做,不再请求服务端:
|
||||
* 客户端是离线优先的(AGENTS.md §5 第 10 条),断网回退缓存时归档功能必须照常可用。
|
||||
* 口径与服务端一致:按 `assignDate`(本地日期)分组 —— 用 createdAt 的 UTC 切片会把晚上
|
||||
* 发出来的通知算到第二天(AGENTS.md §5 第 19 条)。
|
||||
*/
|
||||
const dayFilter = ref('');
|
||||
const creatorFilter = ref('');
|
||||
const priorityFilter = ref('');
|
||||
|
||||
/**
|
||||
* 一条通知归到哪一天。
|
||||
* `assignDate` 是服务端新加的字段,但**离线缓存里可能还留着加字段之前的旧数据**,
|
||||
* 所以这里按 createdAt 的本地日期兜底,避免分组头显示成 "undefined"。
|
||||
*/
|
||||
function assignDay(item: NotificationDto): string {
|
||||
return item.assignDate || dayKeyLocal(new Date(item.createdAt));
|
||||
}
|
||||
|
||||
const dayOptions = computed(() => {
|
||||
const map = new Map<string, number>();
|
||||
for (const item of store.items) {
|
||||
const day = assignDay(item);
|
||||
map.set(day, (map.get(day) ?? 0) + 1);
|
||||
}
|
||||
return [...map.entries()]
|
||||
.sort((a, b) => b[0].localeCompare(a[0]))
|
||||
.map(([date, count]) => ({ date, count }));
|
||||
});
|
||||
|
||||
const creatorOptions = computed(() => {
|
||||
const map = new Map<string, { name: string; count: number }>();
|
||||
for (const item of store.items) {
|
||||
const previous = map.get(item.createdBy);
|
||||
map.set(item.createdBy, {
|
||||
name: item.creator?.name ?? '未知',
|
||||
count: (previous?.count ?? 0) + 1,
|
||||
});
|
||||
}
|
||||
return [...map.entries()].map(([id, value]) => ({ id, ...value }));
|
||||
});
|
||||
|
||||
const hasFilter = computed(() => Boolean(dayFilter.value || creatorFilter.value || priorityFilter.value));
|
||||
|
||||
const filteredItems = computed(() =>
|
||||
visibleItems.value.filter(
|
||||
(item) =>
|
||||
(!dayFilter.value || assignDay(item) === dayFilter.value) &&
|
||||
(!creatorFilter.value || item.createdBy === creatorFilter.value) &&
|
||||
(!priorityFilter.value || item.priority === priorityFilter.value),
|
||||
),
|
||||
);
|
||||
|
||||
/** 按所属日期分块(同一天一块,块头显示"今天/昨天 + 日期 + 条数") */
|
||||
const groupedItems = computed(() => {
|
||||
const groups = new Map<string, NotificationDto[]>();
|
||||
for (const item of filteredItems.value) {
|
||||
const day = assignDay(item);
|
||||
const list = groups.get(day);
|
||||
if (list) list.push(item);
|
||||
else groups.set(day, [item]);
|
||||
}
|
||||
return [...groups.entries()].map(([date, items]) => ({ date, items }));
|
||||
});
|
||||
|
||||
function dayLabel(date: string): string {
|
||||
const today = dayKeyLocal(new Date());
|
||||
if (date === today) return '今天';
|
||||
if (date === shiftDayKey(today, -1)) return '昨天';
|
||||
return date;
|
||||
}
|
||||
|
||||
function resetFilters(): void {
|
||||
dayFilter.value = '';
|
||||
creatorFilter.value = '';
|
||||
priorityFilter.value = '';
|
||||
}
|
||||
|
||||
async function openDetail(item: NotificationDto): Promise<void> {
|
||||
current.value = item;
|
||||
detailVisible.value = true;
|
||||
@@ -76,6 +158,7 @@ onUnmounted(() => {
|
||||
<h2 class="page-title">通知</h2>
|
||||
<p class="page-subtitle">
|
||||
共 {{ store.items.length }} 条 · 未读 {{ store.unreadCount }} 条
|
||||
<span v-if="hasFilter">· 筛选后 {{ filteredItems.length }} 条</span>
|
||||
<el-tag v-if="store.fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||
</p>
|
||||
</div>
|
||||
@@ -92,10 +175,59 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<el-card v-loading="store.loading" shadow="never">
|
||||
<el-empty v-if="visibleItems.length === 0" description="暂无通知" />
|
||||
<ul v-else class="notice-list">
|
||||
<!-- 按天归档 + 按发布人 + 按类型;都在本地已加载的列表上筛(离线也能用) -->
|
||||
<div class="notice-filters">
|
||||
<el-select v-model="dayFilter" placeholder="按天归档" clearable size="small" style="width: 150px">
|
||||
<el-option
|
||||
v-for="item in dayOptions"
|
||||
:key="item.date"
|
||||
:label="`${item.date}(${item.count})`"
|
||||
:value="item.date"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="creatorFilter"
|
||||
placeholder="全部发布人"
|
||||
clearable
|
||||
size="small"
|
||||
style="width: 150px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in creatorOptions"
|
||||
:key="item.id"
|
||||
:label="`${item.name}(${item.count})`"
|
||||
:value="item.id"
|
||||
/>
|
||||
</el-select>
|
||||
<el-select
|
||||
v-model="priorityFilter"
|
||||
placeholder="全部类型"
|
||||
clearable
|
||||
size="small"
|
||||
style="width: 130px"
|
||||
>
|
||||
<el-option
|
||||
v-for="item in NOTIFICATION_PRIORITIES"
|
||||
:key="item"
|
||||
:label="PRIORITY_LABELS[item]"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
<el-button v-if="hasFilter" link type="primary" size="small" @click="resetFilters">
|
||||
清除筛选
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-empty v-if="filteredItems.length === 0" description="暂无通知" />
|
||||
<div v-else class="notice-groups">
|
||||
<section v-for="group in groupedItems" :key="group.date" class="notice-group">
|
||||
<div class="notice-group__header">
|
||||
<span>{{ dayLabel(group.date) }}</span>
|
||||
<span class="text-muted">{{ group.date }} · {{ group.items.length }} 条</span>
|
||||
</div>
|
||||
<ul class="notice-list">
|
||||
<li
|
||||
v-for="item in visibleItems"
|
||||
v-for="item in group.items"
|
||||
:key="item.id"
|
||||
class="notice-item"
|
||||
:class="{ unread: !item.read }"
|
||||
@@ -123,6 +255,8 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-drawer v-model="detailVisible" size="46%" :title="current?.title ?? '通知详情'">
|
||||
@@ -147,6 +281,30 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notice-filters {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.notice-group + .notice-group {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.notice-group__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 6px 6px 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: var(--ch-text-secondary);
|
||||
border-bottom: 1px solid var(--ch-border);
|
||||
}
|
||||
|
||||
.notice-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import { formatDate, relativeTime, type UpdateInfo } from '@classhelper/shared';
|
||||
import type { ClientAgentStatus, DesktopAppInfo } from '../../../types/desktop.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
import { useUiStore } from '../../stores/ui.js';
|
||||
|
||||
/**
|
||||
* 开发者(需求 10)。
|
||||
*
|
||||
* 入口只在 `config.developerMode === true` 时出现在侧栏「设置」分组里(`ClientLayout.vue`),
|
||||
* 路由守卫也会把"关掉开关后仍停留在这个页面"的用户弹回「通用」。
|
||||
*
|
||||
* 本页的定位是**把排查问题时需要的现场集中到一处**,所以刻意不做成"可操作的调试台":
|
||||
* 每一项都是只读状态 + 一两个既有桥上的动作,数据全部来自 `window.desktop.*`
|
||||
* 与既有 store,**不新增任何数据源、也不新增文件读写**。
|
||||
*
|
||||
* 几条取舍写在各区块的注释里,统一的原则是:
|
||||
* 「关于」页面向的是所有用户(干净、能截图给管理员),本页面向维护者(原始值、路径、内部开关)。
|
||||
*/
|
||||
const appStore = useAppStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const loading = ref(false);
|
||||
const appInfo = ref<DesktopAppInfo | null>(null);
|
||||
const agent = ref<ClientAgentStatus | null>(null);
|
||||
|
||||
/* ------------------------------------------------------------ 版本与平台 */
|
||||
|
||||
/** 版本、运行时与两个关键路径(用户数据目录 / 配置文件),排查"配置写哪去了"必看 */
|
||||
async function loadAppInfo(): Promise<void> {
|
||||
if (!window.desktop?.getAppInfo) return;
|
||||
appInfo.value = await window.desktop.getAppInfo().catch(() => null);
|
||||
}
|
||||
|
||||
/** 一次性把本页的所有状态拉新(刷新按钮与首次进页面共用) */
|
||||
async function loadAll(): Promise<void> {
|
||||
if (!window.desktop) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadAppInfo();
|
||||
await refreshAgent();
|
||||
await loadIgnoredVersion();
|
||||
await appStore.refreshCacheStats();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 设备与上报 */
|
||||
|
||||
/**
|
||||
* 服务端联动状态:设备尾号 / 最近上报 / 最近错误 / 目标版本 / 待安装版本 / 在线。
|
||||
*
|
||||
* 这一块与「通用」页的「服务端联动状态」是同一份数据 —— 有意保留的重叠:
|
||||
* 排查上报问题时人在「通用」页看现象、在「开发者」页看原因(最近错误原文 + 设备尾号),
|
||||
* 跳来跳去比重复几个字段更烦。
|
||||
*/
|
||||
const beating = ref(false);
|
||||
|
||||
const lastHeartbeatText = computed(() => {
|
||||
const value = agent.value?.lastHeartbeatAt;
|
||||
return value ? relativeTime(value) : '还没有上报过';
|
||||
});
|
||||
|
||||
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
|
||||
|
||||
async function refreshAgent(): Promise<void> {
|
||||
if (!window.desktop?.getClientAgentStatus) return;
|
||||
agent.value = await window.desktop.getClientAgentStatus().catch(() => null);
|
||||
}
|
||||
|
||||
async function beatNow(): Promise<void> {
|
||||
if (!window.desktop?.beatClientNow) return;
|
||||
beating.value = true;
|
||||
try {
|
||||
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
|
||||
agent.value = await window.desktop.beatClientNow();
|
||||
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
|
||||
else ElMessage.success('已向服务端上报本机状态');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
|
||||
} finally {
|
||||
beating.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 日志 */
|
||||
|
||||
/**
|
||||
* 日志文件路径。
|
||||
*
|
||||
* 桥上没有"读日志内容"的接口(有意不加:日志是主进程的文件,给渲染进程一个读任意文件的
|
||||
* 入口在安全边界上没有必要),所以这里只**提示路径** + 一个「打开日志目录」。
|
||||
* 路径由用户数据目录拼出来:主进程的 logger 固定写在 `<userData>/logs/main.log`,
|
||||
* 单文件 1MB 轮转(写满即改名为 main.old.log),所以"日志丢了"通常是轮转覆盖,不是没写。
|
||||
*/
|
||||
const logPathText = computed(() => {
|
||||
const base = appInfo.value?.userDataPath;
|
||||
if (!base) return 'logs/main.log(用户数据目录下)';
|
||||
const sep = (appInfo.value?.platform ?? '').startsWith('win') ? '\\' : '/';
|
||||
return `${base}${sep}logs${sep}main.log`;
|
||||
});
|
||||
|
||||
async function openLogDir(): Promise<void> {
|
||||
if (!window.desktop?.openLogDir) return;
|
||||
const ok = await window.desktop.openLogDir();
|
||||
if (!ok) ElMessage.warning('打开日志目录失败,请按上面的路径手动前往');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 更新 */
|
||||
|
||||
/**
|
||||
* 更新检查。
|
||||
*
|
||||
* 「立即检查」走 `checkForUpdates(true)`:**强制绕过主进程那 30 分钟缓存**。
|
||||
* 这是本页与「关于」页的区别 —— 那边用户点检查也可能是缓存结果(省流量),
|
||||
* 这边维护者要的是"现在到底拿得到什么",所以按钮默认就是强制的。
|
||||
*/
|
||||
const checking = ref(false);
|
||||
const updateInfo = ref<UpdateInfo | null>(null);
|
||||
const ignoredUpdateVersion = ref('');
|
||||
|
||||
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 loadIgnoredVersion(): Promise<void> {
|
||||
if (!window.desktop?.getConfig) return;
|
||||
const stored = await window.desktop.getConfig().catch(() => null);
|
||||
ignoredUpdateVersion.value = stored?.ignoredUpdateVersion ?? '';
|
||||
}
|
||||
|
||||
async function ignoreVersion(): Promise<void> {
|
||||
const version = updateInfo.value?.latestVersion;
|
||||
if (!version || !window.desktop?.ignoreUpdateVersion) return;
|
||||
await window.desktop.ignoreUpdateVersion(version);
|
||||
ignoredUpdateVersion.value = version;
|
||||
ElMessage.success(`已忽略 v${version}:启动时的自动检查不再提示它`);
|
||||
}
|
||||
|
||||
/** 忽略是可逆的(排查线上"用户说不再提示"时经常要把它清掉),顺手给一个恢复入口 */
|
||||
async function clearIgnoredVersion(): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
const saved = await window.desktop.saveConfig({ ignoredUpdateVersion: '' });
|
||||
ignoredUpdateVersion.value = saved.ignoredUpdateVersion;
|
||||
ElMessage.success('已恢复该版本的更新提示');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 调试工具 */
|
||||
|
||||
const openingDevTools = ref(false);
|
||||
|
||||
/**
|
||||
* 打开 DevTools(主进程 detached 独立窗口)。
|
||||
* 冒烟验证模式下主进程会拒绝(多一个窗口会把自动化断言打乱),返回 false —— 这里如实提示。
|
||||
*/
|
||||
async function openDevTools(): Promise<void> {
|
||||
if (!window.desktop?.openDevTools) return;
|
||||
openingDevTools.value = true;
|
||||
try {
|
||||
const ok = await window.desktop.openDevTools();
|
||||
if (ok) ElMessage.success('已在独立窗口打开开发者工具');
|
||||
else ElMessage.warning('当前是冒烟验证模式,主进程不允许打开 DevTools');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '打开开发者工具失败');
|
||||
} finally {
|
||||
openingDevTools.value = 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 clearCache(): Promise<void> {
|
||||
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
|
||||
type: 'warning',
|
||||
});
|
||||
await appStore.clearCache();
|
||||
ElMessage.success('离线缓存已清空');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAll();
|
||||
// 带出启动自动检查的结果(这次命中主进程缓存,不会真的再发一次请求)
|
||||
void checkUpdate(false);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">开发者</h2>
|
||||
<p class="page-subtitle">版本与平台、设备上报、日志、更新与调试工具</p>
|
||||
</div>
|
||||
<el-button :loading="loading" data-test="dev-refresh" @click="loadAll">
|
||||
<el-icon><Refresh /></el-icon>
|
||||
刷新
|
||||
</el-button>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
class="mb-12"
|
||||
type="warning"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="仅供排查问题"
|
||||
description="这一页显示的是原始状态与内部路径,不需要给日常使用者看。关闭「通用 → 开发者模式」后它就会从侧栏消失。"
|
||||
/>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="12">
|
||||
<!-- 版本与平台 -->
|
||||
<el-card shadow="never">
|
||||
<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 ?? '—' }} · Chromium {{ appInfo?.chrome ?? '—' }} · Node
|
||||
{{ appInfo?.node ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="平台">{{ appInfo?.platform ?? '—' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="用户数据目录">
|
||||
<span class="mono">{{ appInfo?.userDataPath ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="配置文件">
|
||||
<span class="mono">{{ appInfo?.configPath ?? '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="冒烟验证模式">
|
||||
<el-tag :type="appInfo?.smokeTest ? 'warning' : 'info'" size="small" effect="plain">
|
||||
{{ appInfo?.smokeTest ? '是(自动化验证中)' : '否' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</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="连接状态">
|
||||
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
|
||||
{{ agent?.online ? '在线(近期上报成功)' : '离线(等待上报)' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="设备标识尾号">
|
||||
<span class="mono">{{ agent?.deviceKeyTail || '—' }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务端目标版本">
|
||||
{{ agent?.updateTargetVersion || '无(保持当前版本)' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="待安装版本">
|
||||
{{ agent?.pendingUpdateVersion || '无' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近错误">
|
||||
<span :class="{ 'text-danger': lastErrorText !== '无' }">{{ lastErrorText }}</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button type="primary" :loading="beating" data-test="dev-beat-now" @click="beatNow">
|
||||
立即上报
|
||||
</el-button>
|
||||
<el-button :loading="loading" @click="loadAll">刷新状态</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="12">
|
||||
<!-- 日志 -->
|
||||
<el-card shadow="never">
|
||||
<template #header><span>日志</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="日志文件">
|
||||
<span class="mono">{{ logPathText }}</span>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="日志级别">
|
||||
<el-tag :type="ui.developerMode ? 'success' : 'info'" size="small" effect="plain">
|
||||
{{ ui.developerMode ? 'debug(开发者模式)' : 'info' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<p class="text-muted mt-12">
|
||||
单个日志文件 1MB 轮转:写满后改名为 main.old.log,新内容写进 main.log。
|
||||
所以"日志里找不到某次崩溃"多半是被轮转覆盖了,不是没写。
|
||||
</p>
|
||||
<!-- 不在这里用 <strong> 强调:中文整行的显示宽度是字符数的两倍,
|
||||
而 Vue 模板里夹了行内元素的长句会被 Prettier 折行并插入空格,
|
||||
渲染出来句子中间就多一个空格。要强调就单独起一行文字。 -->
|
||||
<p v-if="ui.developerMode" class="text-muted">
|
||||
debug 级日志已开启:级别在主进程启动时读取,改完开关后需重启客户端才生效。
|
||||
</p>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button data-test="dev-open-log-dir" @click="openLogDir">打开日志目录</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 更新 -->
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>更新</span></template>
|
||||
<div class="toolbar">
|
||||
<el-button :loading="checking" data-test="dev-check-update" @click="checkUpdate(true)">
|
||||
强制检查更新
|
||||
</el-button>
|
||||
<span class="text-muted">绕过主进程 30 分钟缓存,直接问 GitHub</span>
|
||||
</div>
|
||||
<el-descriptions :column="1" border size="small" class="mt-12">
|
||||
<el-descriptions-item label="本次结果">
|
||||
<template v-if="checking">正在检查…</template>
|
||||
<template v-else-if="!updateInfo">尚未检查</template>
|
||||
<template v-else-if="!updateInfo.ok">
|
||||
<span class="text-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="text-muted">当前 v{{ updateInfo.currentVersion }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-tag size="small" type="info" effect="plain">已是最新版本</el-tag>
|
||||
<span class="text-muted">v{{ updateInfo.currentVersion }}</span>
|
||||
</template>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="检查时间">
|
||||
{{ updateInfo?.checkedAt ? formatDate(updateInfo.checkedAt, true) : '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="已忽略的版本">
|
||||
<template v-if="ignoredUpdateVersion">
|
||||
<span class="mono">v{{ ignoredUpdateVersion }}</span>
|
||||
<el-button link type="primary" size="small" @click="clearIgnoredVersion">恢复提示</el-button>
|
||||
</template>
|
||||
<span v-else class="text-muted">无</span>
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button
|
||||
:disabled="!updateInfo?.latestVersion"
|
||||
data-test="dev-ignore-version"
|
||||
@click="ignoreVersion"
|
||||
>
|
||||
不再提示该版本
|
||||
</el-button>
|
||||
<span class="text-muted">写入配置后,启动时的自动检查会跳过它</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<!-- 调试工具 -->
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>调试工具</span></template>
|
||||
<div class="toolbar">
|
||||
<el-button :loading="openingDevTools" data-test="dev-open-devtools" @click="openDevTools">
|
||||
打开 DevTools
|
||||
</el-button>
|
||||
<span class="text-muted">独立窗口;冒烟验证模式下不允许打开</span>
|
||||
</div>
|
||||
<el-alert class="mt-12" type="info" :closable="false" show-icon title="灵动岛诊断(命令行)">
|
||||
<template #default>
|
||||
<p class="diag-copy">灵动岛开合"闪一下"这类问题要的是整屏逐帧取证,界面上给不出这个按钮。</p>
|
||||
<p class="diag-copy">跑冒烟时带上环境变量即可:</p>
|
||||
<p class="diag-copy mono">ELECTRON_SMOKE_ISLAND_DIAG=1 pnpm verify:desktop</p>
|
||||
<p class="diag-copy">它会在常规断言之外,对触摸/鼠标两种模式各跑一次收回动画的逐帧取证,</p>
|
||||
<p class="diag-copy">帧图与逐帧数据落在 `.cache/island-closing-frames/`。</p>
|
||||
</template>
|
||||
</el-alert>
|
||||
</el-card>
|
||||
|
||||
<!-- 离线缓存 -->
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<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 @click="appStore.refreshCacheStats()">刷新统计</el-button>
|
||||
<el-button type="danger" plain @click="clearCache">清空缓存</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/*
|
||||
* 路径与版本号这类"要抄下来发给别人"的值统一用等宽字体(避免 0/O、1/l 看混),
|
||||
* 并单独放开文本选中 —— body 上关掉了整页选中(更像原生应用),
|
||||
* 但这一页的路径就是要让人复制的。
|
||||
*
|
||||
* 字体栈写死而不新建 `--ch-font-mono` 令牌:全局只有这一处在用,
|
||||
* 为它加一个主题令牌(还得在 html.dark 里镜像一份)不划算。
|
||||
*/
|
||||
.mono {
|
||||
font-family: 'Cascadia Mono', Consolas, 'Courier New', monospace;
|
||||
font-size: 12.5px;
|
||||
word-break: break-all;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.diag-copy {
|
||||
margin: 0 0 6px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.diag-copy:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,12 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
HOMEWORK_PHRASE_DEFAULTS,
|
||||
HOMEWORK_PHRASE_MAX_COUNT,
|
||||
HOMEWORK_PHRASE_MAX_LENGTH,
|
||||
formatDate,
|
||||
relativeTime,
|
||||
} from '@classhelper/shared';
|
||||
import type { ClientAgentStatus } from '../../../types/desktop.js';
|
||||
import { pingHealth, setApiBaseUrl } from '../../api/http.js';
|
||||
import { normalizeServerUrl } from '../../config.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
@@ -14,6 +16,7 @@ import { useAuthStore } from '../../stores/auth.js';
|
||||
import { useNotificationStore } from '../../stores/notifications.js';
|
||||
import { useOnboardingStore } from '../../stores/onboarding.js';
|
||||
import { useRealtimeStore } from '../../stores/realtime.js';
|
||||
import { useUiStore } from '../../stores/ui.js';
|
||||
|
||||
/**
|
||||
* 通用设置:服务器连接、离线缓存与作业录入短语。
|
||||
@@ -24,6 +27,7 @@ const auth = useAuthStore();
|
||||
const onboarding = useOnboardingStore();
|
||||
const realtime = useRealtimeStore();
|
||||
const notifications = useNotificationStore();
|
||||
const ui = useUiStore();
|
||||
|
||||
const serverInput = ref(appStore.serverUrl);
|
||||
const testing = ref(false);
|
||||
@@ -153,9 +157,193 @@ async function resetPhrases(): Promise<void> {
|
||||
ElMessage.success('已恢复默认');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 启动与更新 / 服务端联动 */
|
||||
|
||||
/**
|
||||
* 开机自启与自动更新。
|
||||
*
|
||||
* 开机自启的"是否生效"以**系统登录项**为准(新增的 `getAutoLaunch` 桥读的是
|
||||
* `app.getLoginItemSettings()`,不是配置文件里的值)—— 用户可能在任务管理器的「启动」页
|
||||
* 里手动禁用了它,这时候配置写着 true 也不该显示成已开启。
|
||||
*/
|
||||
const autoLaunch = ref(false);
|
||||
const autoUpdate = ref(true);
|
||||
const savingLaunch = ref(false);
|
||||
const savingUpdate = ref(false);
|
||||
|
||||
/** 服务端联动状态(心跳上报 / 目标版本 / 待安装版本) */
|
||||
const agent = ref<ClientAgentStatus | null>(null);
|
||||
const agentLoading = ref(false);
|
||||
/** 本机客户端版本(与服务端下发的目标版本对照着看) */
|
||||
const appVersion = ref('—');
|
||||
|
||||
/** 最近上报时间的相对描述(没有上报过就显示占位符) */
|
||||
const lastHeartbeatText = computed(() => {
|
||||
const value = agent.value?.lastHeartbeatAt;
|
||||
if (!value) return '还没有上报过';
|
||||
return relativeTime(value);
|
||||
});
|
||||
|
||||
/** 服务端对本机版本的期望:空串表示"不需要更新" */
|
||||
const updateTargetText = computed(() => {
|
||||
const target = agent.value?.updateTargetVersion ?? '';
|
||||
if (!target) return '无(保持当前版本)';
|
||||
return target;
|
||||
});
|
||||
|
||||
const pendingUpdateText = computed(() => {
|
||||
const pending = agent.value?.pendingUpdateVersion ?? '';
|
||||
if (!pending) return '无';
|
||||
return `${pending}(已下载,空闲时自动安装)`;
|
||||
});
|
||||
|
||||
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
|
||||
|
||||
async function loadLaunchSettings(): Promise<void> {
|
||||
if (!window.desktop?.getConfig) return;
|
||||
const config = await window.desktop.getConfig();
|
||||
autoUpdate.value = config.autoUpdate !== false;
|
||||
autoLaunch.value = (await window.desktop.getAutoLaunch?.()) ?? config.autoLaunch === true;
|
||||
const info = await window.desktop.getAppInfo?.().catch(() => null);
|
||||
if (info?.appVersion) appVersion.value = info.appVersion;
|
||||
}
|
||||
|
||||
async function toggleAutoLaunch(value: string | number | boolean): Promise<void> {
|
||||
if (!window.desktop?.setAutoLaunch) return;
|
||||
const wanted = value === true;
|
||||
autoLaunch.value = wanted;
|
||||
savingLaunch.value = true;
|
||||
try {
|
||||
const effective = await window.desktop.setAutoLaunch(wanted);
|
||||
// 以系统实际生效值为准:写注册表失败时会与用户点的不一致,必须回显真实状态
|
||||
autoLaunch.value = effective;
|
||||
if (effective !== wanted) ElMessage.warning('系统未接受开机自启设置,请检查任务管理器里的启动项');
|
||||
else ElMessage.success(wanted ? '已开启开机自启' : '已关闭开机自启');
|
||||
} catch (error) {
|
||||
autoLaunch.value = !wanted;
|
||||
ElMessage.error(error instanceof Error ? error.message : '设置开机自启失败');
|
||||
} finally {
|
||||
savingLaunch.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAutoUpdate(value: string | number | boolean): Promise<void> {
|
||||
if (!window.desktop?.saveConfig) return;
|
||||
const wanted = value === true;
|
||||
autoUpdate.value = wanted;
|
||||
savingUpdate.value = true;
|
||||
try {
|
||||
// 注意:必须传纯布尔值 —— 跨 contextBridge 传响应式对象会在进入 preload 前就被结构化克隆拒绝
|
||||
const saved = await window.desktop.saveConfig({ autoUpdate: wanted });
|
||||
autoUpdate.value = saved.autoUpdate !== false;
|
||||
ElMessage.success(wanted ? '已开启自动更新' : '已关闭自动更新(仍可在「关于」页手动更新)');
|
||||
} catch (error) {
|
||||
autoUpdate.value = !wanted;
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存自动更新设置失败');
|
||||
} finally {
|
||||
savingUpdate.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 关怀模式 / 开发者模式 */
|
||||
|
||||
/**
|
||||
* 关怀模式(需求 14):放大**正文与控件**字号。
|
||||
*
|
||||
* 这里只挂 `<html>.care-mode` 类并落盘,放大规则全在 `styles/index.css` 末尾那个集中覆盖块里
|
||||
* (`--ch-font-scale` + 少数几个常用类),不在 JS 里逐个元素改字号。
|
||||
*
|
||||
* 开关直接读 ui store 的状态,**不用本地 ref 复制一份**:侧栏、路由守卫、主题页读的都是它,
|
||||
* 复制就会出现"两个开关各说各话"。`ui.init()` 在首屏渲染前已从主进程配置恢复过。
|
||||
*
|
||||
* 关于"与助手联动":开启关怀模式会放大 AI 助手的按钮 —— 这条联动**只存在于 Web 管理端**,
|
||||
* 本客户端目前没有助手入口,所以这里不放大任何按钮,也不为了"看起来对称"去造一个入口。
|
||||
*/
|
||||
const savingCareMode = ref(false);
|
||||
|
||||
async function toggleCareMode(value: string | number | boolean): Promise<void> {
|
||||
const wanted = value === true;
|
||||
savingCareMode.value = true;
|
||||
try {
|
||||
await ui.setCareMode(wanted);
|
||||
ElMessage.success(wanted ? '已开启关怀模式:正文与控件字号已放大' : '已关闭关怀模式');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存关怀模式设置失败');
|
||||
} finally {
|
||||
savingCareMode.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发者模式(需求 10):显示调试页面与详细日志,仅排查问题时打开。
|
||||
*
|
||||
* 打开后侧栏「设置」分组里**立刻**多出「开发者」页(`ClientLayout.vue` 读的是同一个
|
||||
* ui store 状态),下次启动时主进程会把日志级别调到 debug。
|
||||
*/
|
||||
const savingDeveloperMode = ref(false);
|
||||
|
||||
async function toggleDeveloperMode(value: string | number | boolean): Promise<void> {
|
||||
const wanted = value === true;
|
||||
savingDeveloperMode.value = true;
|
||||
try {
|
||||
await ui.setDeveloperMode(wanted);
|
||||
ElMessage.success(wanted ? '已开启开发者模式:侧栏出现「开发者」页' : '已关闭开发者模式');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '保存开发者模式设置失败');
|
||||
} finally {
|
||||
savingDeveloperMode.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAgentStatus(): Promise<void> {
|
||||
if (!window.desktop?.getClientAgentStatus) return;
|
||||
agentLoading.value = true;
|
||||
try {
|
||||
agent.value = await window.desktop.getClientAgentStatus();
|
||||
} finally {
|
||||
agentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function beatNow(): Promise<void> {
|
||||
if (!window.desktop?.beatClientNow) return;
|
||||
agentLoading.value = true;
|
||||
try {
|
||||
// 手动上报是"立刻打一次",成功/失败都刷新状态,失败原因展示在「最近错误」里
|
||||
agent.value = await window.desktop.beatClientNow();
|
||||
if (agent.value?.lastError) ElMessage.warning(`上报未成功:${agent.value.lastError}`);
|
||||
else ElMessage.success('已向服务端上报本机状态');
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '上报失败,请检查网络与登录状态');
|
||||
} finally {
|
||||
agentLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function openLogDir(): Promise<void> {
|
||||
if (!window.desktop?.openLogDir) return;
|
||||
const ok = await window.desktop.openLogDir();
|
||||
if (!ok) ElMessage.warning('打开日志目录失败,可在「关于」页复制诊断信息');
|
||||
}
|
||||
|
||||
/** 状态里的相对时间要跟着走,否则挂着的页面会一直显示"刚刚" */
|
||||
let statusTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
onMounted(async () => {
|
||||
await appStore.refreshCacheStats();
|
||||
await loadPhrases();
|
||||
await loadLaunchSettings();
|
||||
await refreshAgentStatus();
|
||||
statusTimer = setInterval(() => {
|
||||
void refreshAgentStatus();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (statusTimer !== null) {
|
||||
clearInterval(statusTimer);
|
||||
statusTimer = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -238,6 +426,77 @@ onMounted(async () => {
|
||||
<el-button @click="resetPhrases">恢复默认</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>启动与更新</span></template>
|
||||
<div class="switch-row">
|
||||
<div class="switch-main">
|
||||
<span>开机自启</span>
|
||||
<p class="text-muted">登录 Windows 后自动启动 ClassHelper;教室机器建议打开。</p>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="autoLaunch"
|
||||
:loading="savingLaunch"
|
||||
@update:model-value="toggleAutoLaunch"
|
||||
/>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<div class="switch-main">
|
||||
<span>自动更新</span>
|
||||
<p class="text-muted">
|
||||
安装只在
|
||||
<strong>不上课且窗口已隐藏 5 分钟后</strong>
|
||||
进行,下载随时可能发生。
|
||||
</p>
|
||||
<p class="text-muted">服务端可以统一要求更新,关掉它只会把安装推迟到手动处理。</p>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="autoUpdate"
|
||||
:loading="savingUpdate"
|
||||
@update:model-value="toggleAutoUpdate"
|
||||
/>
|
||||
</div>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button @click="openLogDir">打开日志目录</el-button>
|
||||
<span class="text-muted">日志文件 main.log,排查问题时可发给管理员</span>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>显示与调试</span></template>
|
||||
<div class="switch-row">
|
||||
<div class="switch-main">
|
||||
<span>关怀模式</span>
|
||||
<p class="text-muted">
|
||||
放大正文与控件字号(页面标题、表格、表单、卡片标题一起变大);圆角、投影与侧栏宽度等几何尺寸保持原样,侧栏图标导轨也不放大。
|
||||
</p>
|
||||
<p class="text-muted">
|
||||
「开启后同时放大 AI 助手按钮」这一条只在 Web 管理端生效 —— 本客户端目前没有助手入口。
|
||||
</p>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="ui.careMode"
|
||||
:loading="savingCareMode"
|
||||
data-test="care-mode-switch"
|
||||
@update:model-value="toggleCareMode"
|
||||
/>
|
||||
</div>
|
||||
<div class="switch-row">
|
||||
<div class="switch-main">
|
||||
<span>开发者模式</span>
|
||||
<p class="text-muted">显示调试页面与详细日志,仅排查问题时打开。</p>
|
||||
<p v-if="ui.developerMode" class="text-muted">
|
||||
debug 级日志已开启;侧栏「设置 → 开发者」可查看版本、设备上报、更新检查、日志目录与 DevTools。
|
||||
</p>
|
||||
</div>
|
||||
<el-switch
|
||||
:model-value="ui.developerMode"
|
||||
:loading="savingDeveloperMode"
|
||||
data-test="developer-mode-switch"
|
||||
@update:model-value="toggleDeveloperMode"
|
||||
/>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
|
||||
<el-col :xs="24" :md="12">
|
||||
@@ -265,6 +524,35 @@ onMounted(async () => {
|
||||
description="断网后课表、作业、通知、成绩都会从本地缓存读取;恢复网络后自动向服务器同步最新数据。"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>服务端联动状态</span></template>
|
||||
<p class="text-muted">
|
||||
本机每 30 秒向服务端上报一次状态(是否在上课、当前版本、已下载的更新),
|
||||
服务端据此下发统一更新与远控命令。本机标识尾号用于在服务端设备列表里核对这台电脑。
|
||||
</p>
|
||||
<el-descriptions class="mt-12" :column="1" border size="small">
|
||||
<el-descriptions-item label="连接状态">
|
||||
<el-tag :type="agent?.online ? 'success' : 'info'" size="small">
|
||||
{{ agent?.online ? '已连接(近期上报成功)' : '未连接(等待上报)' }}
|
||||
</el-tag>
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="本机版本">
|
||||
{{ appVersion }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="设备标识尾号">
|
||||
{{ agent?.deviceKeyTail || '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最近上报">{{ lastHeartbeatText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="服务端目标版本">{{ updateTargetText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="已下载待安装">{{ pendingUpdateText }}</el-descriptions-item>
|
||||
<el-descriptions-item label="最近错误">{{ lastErrorText }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
<div class="toolbar mt-12">
|
||||
<el-button type="primary" :loading="agentLoading" @click="beatNow">立即上报</el-button>
|
||||
<el-button :loading="agentLoading" @click="refreshAgentStatus">刷新状态</el-button>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
@@ -282,4 +570,29 @@ onMounted(async () => {
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* 开关行:左说明右开关,说明长时自动换行(「自动更新」的提示是两行) */
|
||||
.switch-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.switch-row + .switch-row {
|
||||
border-top: 1px solid var(--ch-border);
|
||||
}
|
||||
|
||||
.switch-main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.switch-main p {
|
||||
margin: 0;
|
||||
font-size: 12.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
+43
@@ -9,6 +9,10 @@ import type {
|
||||
IslandState,
|
||||
UpdateInfo,
|
||||
} from '@classhelper/shared';
|
||||
import type { ClientAgentStatus } from '../main/client-agent.js';
|
||||
|
||||
/** 服务端联动状态:渲染进程要显示它,因此在这里转出(`desktop.d.ts` 是两端契约的单一入口) */
|
||||
export type { ClientAgentStatus };
|
||||
|
||||
/** 作业页展示偏好(看板 / 列表,看板外观) */
|
||||
export interface HomeworkBoardSettings {
|
||||
@@ -66,6 +70,25 @@ export interface DesktopStoredConfig {
|
||||
* 在「关于」页手动检查时仍会正常显示结果。
|
||||
*/
|
||||
ignoredUpdateVersion: string;
|
||||
/**
|
||||
* 本机设备标识(UUID):这台机器在服务端的身份。
|
||||
*
|
||||
* 服务端按它 upsert 设备记录(在线状态 / 待执行命令 / 已下载版本都挂在它上面),
|
||||
* **首次读取配置时自动生成并落盘,之后永不变**;渲染进程只读,不允许改写。
|
||||
*/
|
||||
deviceKey: string;
|
||||
/** 是否开机自启(登录 Windows 后静默启动,不弹主窗口) */
|
||||
autoLaunch: boolean;
|
||||
/** 是否接受服务端统一下发的自动更新(下载随时进行,安装只在空闲时) */
|
||||
autoUpdate: boolean;
|
||||
/** 开发者模式:把日志调到 debug 级,并在设置里显示「开发者」页 */
|
||||
developerMode: boolean;
|
||||
/** 关怀模式:整体放大正文与控件字号(不动圆角/阴影/侧栏几何) */
|
||||
careMode: boolean;
|
||||
/** 已下载待安装的更新版本(空串 = 无) */
|
||||
pendingUpdateVersion: string;
|
||||
/** 已下载待安装的安装包绝对路径(空串 = 无) */
|
||||
pendingUpdateFile: string;
|
||||
}
|
||||
|
||||
export interface DesktopAppInfo {
|
||||
@@ -165,6 +188,26 @@ export interface DesktopBridge {
|
||||
onIslandMarkRead(handler: (id: string) => void): void;
|
||||
/** 订阅"灵动岛点了标为已读(多条通知的整批)",一次把这一批都标记已读 */
|
||||
onIslandMarkAllRead(handler: (ids: string[]) => void): void;
|
||||
|
||||
/* 开机自启 / 日志 / 服务端联动 */
|
||||
/** 读当前是否已注册为开机自启(以系统登录项为准,不是配置文件里的值) */
|
||||
getAutoLaunch(): Promise<boolean>;
|
||||
/** 设置开机自启:写配置 + 同步系统登录项,返回生效后的值 */
|
||||
setAutoLaunch(enabled: boolean): Promise<boolean>;
|
||||
/** 打开日志目录(排查问题用;日志文件是 main.log) */
|
||||
openLogDir(): Promise<boolean>;
|
||||
/**
|
||||
* 打开主窗口的开发者工具(detached 独立窗口)。
|
||||
*
|
||||
* 只在**非冒烟模式**下允许:冒烟验证会给窗口注入大量合成事件与截图,
|
||||
* 多一个 DevTools 窗口会抢焦点、改变窗口尺寸,把那些断言打乱。
|
||||
* 返回是否真的打开了(冒烟模式下返回 false)。
|
||||
*/
|
||||
openDevTools(): Promise<boolean>;
|
||||
/** 读服务端联动状态(设置页「服务端联动状态」区块) */
|
||||
getClientAgentStatus(): Promise<ClientAgentStatus>;
|
||||
/** 立即向服务端上报一次,返回上报后的最新状态 */
|
||||
beatClientNow(): Promise<ClientAgentStatus>;
|
||||
}
|
||||
|
||||
/** 灵动岛窗口自身的桥接(只暴露订阅状态与发送操作) */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@classhelper/server",
|
||||
"version": "1.2.0",
|
||||
"version": "1.3.0",
|
||||
"private": true,
|
||||
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||
"type": "module",
|
||||
|
||||
+173
@@ -0,0 +1,173 @@
|
||||
-- 权限组 / 全局设置 / 客户端设备与远控 / 客户端安装包缓存 / 大模型审计
|
||||
--
|
||||
-- 一、权限组(需求:「允许 admin 设置各用户权限,或创建权限组」)
|
||||
-- `PermissionGroup` 权限组本体(名字 + 说明 + 是否内置)
|
||||
-- `PermissionGroupItem` 组里的一条权限键(键目录在 shared 的 PERMISSION_KEYS)
|
||||
-- `UserPermissionGroup` 账号与组的分配关系(一个账号可属多个组)
|
||||
-- 语义:账号没有任何分组时回落角色默认模板(ROLE_DEFAULT_PERMISSIONS,逐条镜像改造前的
|
||||
-- 路由 requireRole);一旦分组,生效集合只由组的并集决定(因此也能用来收回权限)。
|
||||
--
|
||||
-- 二、全局设置 `AppSetting`(大模型开关与配置、开发者模式等)——值一律字符串,密钥加密后存。
|
||||
--
|
||||
-- 三、客户端设备与统一更新(需求:「服务端显示各班 ClassHelper 版本,可一键推送更新」)
|
||||
-- `ClientDevice` 每台客户端一条:班级 / 版本 / 平台 / 是否自动更新 / 目标版本 / 心跳时间
|
||||
-- `ClientCommand` 远控指令(关机 / 重启 / 注销 / 锁屏):心跳拉取 + 回执,不新增握手通道
|
||||
-- `ClientRelease` 服务端缓存的安装包:教室机器没有外网也能更新
|
||||
-- 在线判定看 `lastHeartbeatAt`(心跳 60 秒一次,窗口见 shared 的 CLASS_HELPER_ONLINE_WINDOW_MS)。
|
||||
--
|
||||
-- 四、通知按天归档(需求:「通知发布按天归档」)
|
||||
-- `Notification.assignDate` 与 `Homework.assignDate` 同口径(本地日期 YYYY-MM-DD,不带时区)。
|
||||
-- 回填:历史数据按 createdAt 的**本地日期**补写,避免归档页出现一堆"无日期"的旧通知。
|
||||
--
|
||||
-- 五、关怀模式 `User.careMode`(需求:「关怀模式(部分老师年纪大使用不习惯)」)。
|
||||
--
|
||||
-- 注意:本仓库的 SQLite / MySQL 双库约束是「只用 String / Int / Float / Boolean / DateTime」,
|
||||
-- 因此上面这些"枚举"(指令类型、审计状态)都是 String + 服务端 zod 校验。
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "User" ADD COLUMN "careMode" BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Notification" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 历史通知按本地日期回填(date() 解不了的值兜底成"今天",避免留空)
|
||||
UPDATE "Notification" SET "assignDate" = date("createdAt", 'localtime') WHERE "assignDate" = '';
|
||||
UPDATE "Notification" SET "assignDate" = date('now', 'localtime') WHERE "assignDate" = '';
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PermissionGroup" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"description" TEXT NOT NULL DEFAULT '',
|
||||
"builtin" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "PermissionGroupItem" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "PermissionGroupItem_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "PermissionGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "UserPermissionGroup" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "UserPermissionGroup_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "UserPermissionGroup_groupId_fkey" FOREIGN KEY ("groupId") REFERENCES "PermissionGroup" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AppSetting" (
|
||||
"key" TEXT NOT NULL PRIMARY KEY,
|
||||
"value" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ClientDevice" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT,
|
||||
"deviceKey" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL DEFAULT '',
|
||||
"clientVersion" TEXT NOT NULL DEFAULT '',
|
||||
"platform" TEXT NOT NULL DEFAULT '',
|
||||
"autoUpdate" BOOLEAN NOT NULL DEFAULT true,
|
||||
"updateTargetVersion" TEXT NOT NULL DEFAULT '',
|
||||
"pendingVersion" TEXT NOT NULL DEFAULT '',
|
||||
"lastHeartbeatAt" DATETIME,
|
||||
"lastIp" TEXT NOT NULL DEFAULT '',
|
||||
"stateNote" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "ClientDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ClientCommand" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"deviceId" TEXT NOT NULL,
|
||||
"kind" TEXT NOT NULL,
|
||||
"payload" TEXT NOT NULL DEFAULT '',
|
||||
"createdBy" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME,
|
||||
"ackedAt" DATETIME,
|
||||
"result" TEXT NOT NULL DEFAULT '',
|
||||
"cancelledAt" DATETIME,
|
||||
CONSTRAINT "ClientCommand_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "ClientDevice" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ClientRelease" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"version" TEXT NOT NULL,
|
||||
"fileName" TEXT NOT NULL,
|
||||
"sha256" TEXT NOT NULL DEFAULT '',
|
||||
"size" INTEGER NOT NULL DEFAULT 0,
|
||||
"notes" TEXT NOT NULL DEFAULT '',
|
||||
"createdBy" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AiAudit" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
"prompt" TEXT NOT NULL,
|
||||
"intent" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'pending',
|
||||
"message" TEXT NOT NULL DEFAULT '',
|
||||
"rawReply" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PermissionGroup_name_key" ON "PermissionGroup"("name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "PermissionGroupItem_groupId_key_key" ON "PermissionGroupItem"("groupId", "key");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "PermissionGroupItem_groupId_idx" ON "PermissionGroupItem"("groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "UserPermissionGroup_userId_groupId_key" ON "UserPermissionGroup"("userId", "groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserPermissionGroup_userId_idx" ON "UserPermissionGroup"("userId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "UserPermissionGroup_groupId_idx" ON "UserPermissionGroup"("groupId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ClientDevice_deviceKey_key" ON "ClientDevice"("deviceKey");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ClientDevice_classId_idx" ON "ClientDevice"("classId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ClientDevice_lastHeartbeatAt_idx" ON "ClientDevice"("lastHeartbeatAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ClientCommand_deviceId_ackedAt_idx" ON "ClientCommand"("deviceId", "ackedAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "ClientCommand_createdAt_idx" ON "ClientCommand"("createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ClientRelease_version_key" ON "ClientRelease"("version");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiAudit_userId_createdAt_idx" ON "AiAudit"("userId", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "Notification_classId_assignDate_idx" ON "Notification"("classId", "assignDate");
|
||||
@@ -43,6 +43,8 @@ model User {
|
||||
role String @default("TEACHER")
|
||||
/// 手机号(教师联系方式,可空)
|
||||
phone String @default("")
|
||||
/// 关怀模式(大字版界面):本人开关,Web 端与客户端各记一份
|
||||
careMode Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -52,6 +54,8 @@ model User {
|
||||
courses Course[]
|
||||
createdHomeworks Homework[] @relation("HomeworkCreator")
|
||||
createdNotices Notification[] @relation("NotificationCreator")
|
||||
/// 分配的权限组;**没有任何分组时**回落角色默认模板(见 shared 的 ROLE_DEFAULT_PERMISSIONS)
|
||||
permissionGroups UserPermissionGroup[]
|
||||
|
||||
@@index([role])
|
||||
}
|
||||
@@ -240,6 +244,7 @@ model Class {
|
||||
timeLayouts TimeLayout[]
|
||||
integrationDevices IntegrationDevice[]
|
||||
classIslandPushes ClassIslandPush[]
|
||||
clientDevices ClientDevice[]
|
||||
|
||||
@@index([teacherId])
|
||||
@@index([enrollmentYear])
|
||||
@@ -353,6 +358,9 @@ model Notification {
|
||||
title String
|
||||
content String
|
||||
priority String @default("NORMAL")
|
||||
/// 发布所属日期(**本地日期 YYYY-MM-DD,不带时区**):按天归档的唯一依据。
|
||||
/// 与 Homework.assignDate 同口径(shared 的 dayKeyLocal),**不要**用 createdAt 切 UTC。
|
||||
assignDate String @default("")
|
||||
createdBy String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
@@ -362,6 +370,7 @@ model Notification {
|
||||
reads NotificationRead[]
|
||||
|
||||
@@index([classId, createdAt])
|
||||
@@index([classId, assignDate])
|
||||
}
|
||||
|
||||
/// 通知已读记录
|
||||
@@ -516,3 +525,174 @@ model ClassIslandPush {
|
||||
@@index([classId, createdAt])
|
||||
@@index([deviceId, ackedAt])
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 权限组 / 全局设置 / 客户端设备 / 远控命令 / 客户端安装包 / 大模型审计 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/// 权限组:管理员自定义的一组**权限键**(`shared` 的 `PERMISSION_KEYS`),可分配给多个账号。
|
||||
///
|
||||
/// 语义(与 `packages/shared/src/permissions.ts` 的目录一一对应):
|
||||
/// - 账号**没有**任何 `UserPermissionGroup` 时,生效集合 = 角色默认模板(`ROLE_DEFAULT_PERMISSIONS`);
|
||||
/// - 一旦分了组,生效集合 = 这些组的键并集(可以比默认更少 ⇒ 也能用来**收回**权限);
|
||||
/// - `ADMIN` 永远补上 `permission:manage` / `system:manage`,避免把自己锁死。
|
||||
model PermissionGroup {
|
||||
id String @id @default(cuid())
|
||||
name String @unique
|
||||
description String @default("")
|
||||
/// 内置组:只在种子/引导时创建,管理员不可删除(可改成员与键)
|
||||
builtin Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
items PermissionGroupItem[]
|
||||
users UserPermissionGroup[]
|
||||
}
|
||||
|
||||
/// 权限组里的一个权限键(一行一个键,便于查询与去重)
|
||||
model PermissionGroupItem {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
/// 权限键,取值必须是 shared 的 PERMISSION_KEYS 之一(服务端 zod 校验)
|
||||
key String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([groupId, key])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
/// 账号 ←→ 权限组 的分配关系(一个账号可属多个组)
|
||||
model UserPermissionGroup {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
groupId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
group PermissionGroup @relation(fields: [groupId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, groupId])
|
||||
@@index([userId])
|
||||
@@index([groupId])
|
||||
}
|
||||
|
||||
/// 全局键值设置(大模型配置、开发者模式等)。
|
||||
///
|
||||
/// 值一律存字符串(复杂结构存 JSON 文本),键名常量在 `src/lib/settings.ts`;
|
||||
/// 密钥类值(如大模型 API Key)加密后存这里,接口**只进不出**。
|
||||
model AppSetting {
|
||||
key String @id
|
||||
value String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// ClassHelper 客户端设备(装在学生机/教室机上的桌面客户端)。
|
||||
///
|
||||
/// 身份由客户端主进程生成并持久化的 `deviceKey`(UUID)标识,心跳每 30 秒一次:
|
||||
/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控
|
||||
/// 指令按「查库 + 心跳拉取」的范式下发(不新增长连接握手,照抄
|
||||
/// `IntegrationDevice.requestScheduleReport` 的做法)。
|
||||
model ClientDevice {
|
||||
id String @id @default(cuid())
|
||||
/// 该设备当前登录的班级(未登录时为空)
|
||||
classId String?
|
||||
/// 设备唯一标识(客户端主进程生成,UUID)
|
||||
deviceKey String @unique
|
||||
/// 设备名(默认取 os.hostname(),管理员可改名)
|
||||
name String @default("")
|
||||
/// 客户端版本号(来自 app.getVersion())
|
||||
clientVersion String @default("")
|
||||
/// 系统与架构,如 win32-x64
|
||||
platform String @default("")
|
||||
/// 是否接受服务端统一下发的自动更新
|
||||
autoUpdate Boolean @default(true)
|
||||
/// 服务端指定的目标版本('' = 不指定,按最新版走)
|
||||
updateTargetVersion String @default("")
|
||||
/// 已下载待安装的版本('' = 无)
|
||||
pendingVersion String @default("")
|
||||
/// 最近一次心跳时间(**在线判定看它**)
|
||||
lastHeartbeatAt DateTime?
|
||||
/// 最近一次心跳来源 IP(排查用)
|
||||
lastIp String @default("")
|
||||
/// 客户端上报的当前状态说明(如"上课中/已隐藏",仅展示用)
|
||||
stateNote String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
|
||||
commands ClientCommand[]
|
||||
|
||||
@@index([classId])
|
||||
@@index([lastHeartbeatAt])
|
||||
}
|
||||
|
||||
/// 下发给客户端设备的指令(远控:关机 / 重启 / 注销 / 锁屏)。
|
||||
///
|
||||
/// 客户端心跳时拉取未确认且未过期的指令,**执行前必须在界面上二次确认**,
|
||||
/// 执行后回执(`ackedAt` + `result`)。
|
||||
model ClientCommand {
|
||||
id String @id @default(cuid())
|
||||
deviceId String
|
||||
/// shutdown | restart | logoff | lock
|
||||
kind String
|
||||
/// 附加参数(JSON 文本,预留:延时秒数、提示语)
|
||||
payload String @default("")
|
||||
createdBy String?
|
||||
createdAt DateTime @default(now())
|
||||
/// 过期时间:超过后不再下发(避免机器离线后几天突然关机)
|
||||
expiresAt DateTime?
|
||||
/// 客户端确认执行的时间
|
||||
ackedAt DateTime?
|
||||
/// 执行结果说明(成功 / 失败原因)
|
||||
result String @default("")
|
||||
/// 管理员撤销的时间(非空即不再下发)
|
||||
cancelledAt DateTime?
|
||||
|
||||
device ClientDevice @relation(fields: [deviceId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([deviceId, ackedAt])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// 服务端缓存的客户端安装包:教室机器**没有外网**时也能统一更新。
|
||||
///
|
||||
/// 管理员上传后落在 `data/client-releases/`,客户端从
|
||||
/// `GET /api/update/client/download/:version` 下载(继承守卫与限流)。
|
||||
model ClientRelease {
|
||||
id String @id @default(cuid())
|
||||
version String @unique
|
||||
fileName String
|
||||
/// 安装包 sha256(客户端下载后必须校验)
|
||||
sha256 String @default("")
|
||||
/// 字节数(管理员上传时记录,供下载进度与展示用)
|
||||
size Int @default(0)
|
||||
/// 发布说明(展示给管理员)
|
||||
notes String @default("")
|
||||
createdBy String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
/// 大模型指令审计:谁在什么时候用"一句话"做了什么、结果如何。
|
||||
///
|
||||
/// 只记**意图与结果**,不记密钥;`rawReply` 供排查模型输出格式问题时查看。
|
||||
model AiAudit {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
/// 用户原话
|
||||
prompt String
|
||||
/// 模型解析出的意图(如 homework.publish / notification.publish / unknown)
|
||||
intent String @default("")
|
||||
/// pending | confirmed | rejected | failed
|
||||
status String @default("pending")
|
||||
/// 结果说明(失败原因、生成的标题等)
|
||||
message String @default("")
|
||||
/// 模型原始回复(截断保存,排查用)
|
||||
rawReply String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([userId, createdAt])
|
||||
}
|
||||
@@ -70,6 +70,17 @@ function createRandom(seed: number): () => number {
|
||||
|
||||
async function resetDatabase(): Promise<void> {
|
||||
// 顺序敏感:先删子表,再删主表
|
||||
// 权限组 / 客户端设备 / 远端指令 / 安装包缓存 / 大模型审计(2026-10-10 新增表)
|
||||
// 注意:ClientDevice.classId 是 SetNull,不显式删会留下"孤儿设备"(班级已删、设备还在)
|
||||
await prisma.clientCommand.deleteMany();
|
||||
await prisma.clientDevice.deleteMany();
|
||||
await prisma.clientRelease.deleteMany();
|
||||
await prisma.aiAudit.deleteMany();
|
||||
await prisma.userPermissionGroup.deleteMany();
|
||||
await prisma.permissionGroupItem.deleteMany();
|
||||
await prisma.permissionGroup.deleteMany();
|
||||
// 全局设置(大模型配置 / 开发者模式)不属于业务数据,但演示库重置时一并清掉,保证可复现
|
||||
await prisma.appSetting.deleteMany();
|
||||
await prisma.grade.deleteMany();
|
||||
await prisma.homeworkStatus.deleteMany();
|
||||
await prisma.homework.deleteMany();
|
||||
@@ -117,6 +128,32 @@ async function main(): Promise<void> {
|
||||
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
|
||||
});
|
||||
|
||||
// 内置权限组示例:**没有分配给任何账号**,所以不影响默认行为(演示「创建权限组」这条需求)。
|
||||
// 一旦把某个老师加进组,他的生效权限就**只由组的并集决定**(可以比角色默认更少 ⇒ 也能收回权限)。
|
||||
await prisma.permissionGroup.create({
|
||||
data: {
|
||||
name: '教务协作(示例)',
|
||||
description: '可查看班级与课表、发布作业与通知、录入成绩;不能创建班级或管理教师账号',
|
||||
builtin: true,
|
||||
items: {
|
||||
create: [
|
||||
'class:read',
|
||||
'schedule:read',
|
||||
'homework:read',
|
||||
'homework:write',
|
||||
'notification:read',
|
||||
'notification:publish',
|
||||
'grade:read',
|
||||
'grade:write',
|
||||
'dashboard:read',
|
||||
'term:read',
|
||||
'client:read',
|
||||
'ai:use',
|
||||
].map((key) => ({ key })),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
|
||||
//
|
||||
// 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。
|
||||
|
||||
@@ -68,6 +68,18 @@ const COLUMNS = [
|
||||
sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`,
|
||||
note: '教师手机号',
|
||||
},
|
||||
{
|
||||
table: 'User',
|
||||
column: 'careMode',
|
||||
sql: `ALTER TABLE "User" ADD COLUMN "careMode" BOOLEAN NOT NULL DEFAULT false`,
|
||||
note: '关怀模式(大字版界面)',
|
||||
},
|
||||
{
|
||||
table: 'Notification',
|
||||
column: 'assignDate',
|
||||
sql: `ALTER TABLE "Notification" ADD COLUMN "assignDate" TEXT NOT NULL DEFAULT ''`,
|
||||
note: '通知所属日期(本地日期 YYYY-MM-DD,按天归档用)',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -167,9 +167,35 @@ async function main() {
|
||||
method: 'POST',
|
||||
body: { username: 'teacher2', password: 'teacher123' },
|
||||
});
|
||||
const teacher2Token = teacher2Login.payload?.data?.token;
|
||||
const teacher2Id = teacher2Login.payload?.data?.user?.id;
|
||||
let teacher2Token = teacher2Login.payload?.data?.token;
|
||||
let teacher2Id = teacher2Login.payload?.data?.user?.id;
|
||||
const teacher1ClassIds = new Set(classes.map((item) => item.id));
|
||||
// 演示库里的密码可能被人工改过(本机 teacher1 就有被改成 123456 的情况):登录失败时由管理员
|
||||
// 重置为验证环境已知值再重登。**teacher2 是"科任老师/班级隔离"整组用例的主体**,
|
||||
// 上一版漏了这条兜底,密码一改后面整片用例都只能跑空分支。
|
||||
if (!teacher2Token) {
|
||||
const staffList = await api('/teachers', { token: adminToken });
|
||||
const fallbackTeacher2Id =
|
||||
teacher2Id ?? (staffList.payload?.data ?? []).find((item) => item.username === 'teacher2')?.id;
|
||||
if (fallbackTeacher2Id) {
|
||||
await api(`/teachers/${fallbackTeacher2Id}/reset-password`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { newPassword: 'e2e-teacher2-123456' },
|
||||
});
|
||||
const retryTeacher2 = await api('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username: 'teacher2', password: 'e2e-teacher2-123456' },
|
||||
});
|
||||
teacher2Token = retryTeacher2.payload?.data?.token;
|
||||
teacher2Id = retryTeacher2.payload?.data?.user?.id;
|
||||
record(
|
||||
'教师 teacher2 密码被改过,已由管理员重置为验证环境已知值(登录环境自愈)',
|
||||
Boolean(teacher2Token),
|
||||
`status=${retryTeacher2.status}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
|
||||
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
|
||||
const probeClassIds = [];
|
||||
@@ -1191,6 +1217,13 @@ async function main() {
|
||||
if (subjectCourse) {
|
||||
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
|
||||
.payload?.data?.find((item) => item.teacherId !== teacher2Id);
|
||||
// 先确认"别人任教的科目"这个前提存在:缺了它,下面那条用例会因为"没带科目"
|
||||
// 而被拒(403),看着是绿的,其实没验到"非任教科目"这条规则。
|
||||
record(
|
||||
'存在"别人任教科目"用于越权校验',
|
||||
Boolean(otherCourse?.id),
|
||||
`科目=${otherCourse?.name ?? '-'}`,
|
||||
);
|
||||
const foreignHomework = await api('/homeworks', {
|
||||
method: 'POST',
|
||||
token: teacher2Token,
|
||||
@@ -1285,6 +1318,641 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 6.5 通知按天归档 + 批量发布
|
||||
{
|
||||
const DAY_KEY = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
// 按天归档:通知也有"所属日期"(assignDate,本地日期),晚间发布不会被 UTC 切片算到第二天
|
||||
const daysRes = await api(`/notifications/days?classId=${classId}`, { token: teacherToken });
|
||||
const days = daysRes.payload?.data?.days ?? [];
|
||||
record(
|
||||
'通知按天归档 GET /notifications/days(按所属日期汇总条数)',
|
||||
daysRes.status === 200 &&
|
||||
days.length > 0 &&
|
||||
days.every((item) => DAY_KEY.test(String(item.date)) && item.count > 0),
|
||||
`status=${daysRes.status} 天数=${days.length} 样例=${JSON.stringify(days[0] ?? null)}`,
|
||||
);
|
||||
|
||||
const creatorsRes = await api(`/notifications/creators?classId=${classId}`, { token: teacherToken });
|
||||
const creators = creatorsRes.payload?.data?.creators ?? [];
|
||||
record(
|
||||
'通知按发布人归档 GET /notifications/creators(带名字与条数)',
|
||||
creatorsRes.status === 200 &&
|
||||
creators.length > 0 &&
|
||||
creators.every((item) => item.id && item.name && item.count > 0),
|
||||
`status=${creatorsRes.status} 发布人=${creators.length} 样例=${JSON.stringify(creators[0] ?? null)}`,
|
||||
);
|
||||
|
||||
// 按天筛选:返回的每条通知 assignDate 必须等于筛选的日期
|
||||
const targetDay = days[0]?.date;
|
||||
if (targetDay) {
|
||||
const byDay = await api(`/notifications?classId=${classId}&date=${targetDay}`, { token: teacherToken });
|
||||
const rows = byDay.payload?.data ?? [];
|
||||
record(
|
||||
'通知按天筛选(?date=)只返回该天的通知',
|
||||
byDay.status === 200 && rows.length > 0 && rows.every((item) => item.assignDate === targetDay),
|
||||
`date=${targetDay} 条数=${rows.length}`,
|
||||
);
|
||||
}
|
||||
|
||||
const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, { token: teacherToken });
|
||||
record('通知归档接口拒绝非法日期(422)', badDay.status === 422, `status=${badDay.status}`);
|
||||
|
||||
// 批量发布通知:一次提交多个班,返回逐班结果
|
||||
const bulkClassIds = foreignClass ? [classId, foreignClass.id] : [classId];
|
||||
const bulkNotice = await api('/notifications/bulk', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: {
|
||||
classIds: bulkClassIds,
|
||||
title: `批量发布回归 ${Date.now()}`,
|
||||
content: '批量发布通知用例(管理员对多个班级一次发布)',
|
||||
priority: 'NORMAL',
|
||||
},
|
||||
});
|
||||
const bulkNoticeData = bulkNotice.payload?.data;
|
||||
record(
|
||||
'批量发布通知 POST /notifications/bulk(逐班结果)',
|
||||
bulkNotice.status === 201 &&
|
||||
bulkNoticeData?.total === bulkClassIds.length &&
|
||||
bulkNoticeData?.succeeded === bulkClassIds.length &&
|
||||
(bulkNoticeData?.results ?? []).every((item) => item.ok && item.id),
|
||||
`status=${bulkNotice.status} total=${bulkNoticeData?.total} 成功=${bulkNoticeData?.succeeded}`,
|
||||
);
|
||||
for (const item of bulkNoticeData?.results ?? []) {
|
||||
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: adminToken });
|
||||
}
|
||||
|
||||
if (foreignClass) {
|
||||
// 教师批量发到"别人的班":那个班逐班失败,自己的班照常发布,整批不能变成 500
|
||||
const mixed = await api('/notifications/bulk', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: {
|
||||
classIds: [classId, foreignClass.id],
|
||||
title: `批量越权回归 ${Date.now()}`,
|
||||
content: '其中一个班没有发布权限',
|
||||
priority: 'NORMAL',
|
||||
},
|
||||
});
|
||||
const mixedData = mixed.payload?.data;
|
||||
const denied = (mixedData?.results ?? []).find((item) => item.classId === foreignClass.id);
|
||||
record(
|
||||
'批量发布通知:无权限的班逐班失败、有权限的班照常发布',
|
||||
mixed.status === 201 && mixedData?.succeeded === 1 && denied?.ok === false,
|
||||
`status=${mixed.status} 成功=${mixedData?.succeeded} 失败code=${denied?.code ?? '-'} 失败原因=${denied?.message ?? '-'}`,
|
||||
);
|
||||
for (const item of mixedData?.results ?? []) {
|
||||
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: teacherToken });
|
||||
}
|
||||
|
||||
// 批量布置作业:跨班按**科目名称**匹配课程(课程是班级内的,不可能共用一个 courseId)
|
||||
const bulkHomework = await api('/homeworks/bulk', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: {
|
||||
classIds: [classId, foreignClass.id],
|
||||
subjectName: '语文',
|
||||
title: `批量布置回归 ${Date.now()}`,
|
||||
content: '批量布置作业用例(按科目名称跨班匹配)',
|
||||
},
|
||||
});
|
||||
const bulkHomeworkData = bulkHomework.payload?.data;
|
||||
record(
|
||||
'批量布置作业 POST /homeworks/bulk(逐班结果 + 按科目名称匹配课程)',
|
||||
bulkHomework.status === 201 &&
|
||||
bulkHomeworkData?.total === 2 &&
|
||||
(bulkHomeworkData?.results ?? []).every((item) => typeof item.ok === 'boolean'),
|
||||
`status=${bulkHomework.status} 成功=${bulkHomeworkData?.succeeded} 失败=${bulkHomeworkData?.failed} ` +
|
||||
`逐班=${JSON.stringify((bulkHomeworkData?.results ?? []).map((item) => item.code ?? 'ok'))}`,
|
||||
);
|
||||
for (const item of bulkHomeworkData?.results ?? []) {
|
||||
if (item.id) await api(`/homeworks/${item.id}`, { method: 'DELETE', token: adminToken });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 6.6 权限组
|
||||
{
|
||||
const meTeacher = await api('/permissions/me', { token: teacherToken });
|
||||
const meTeacherData = meTeacher.payload?.data;
|
||||
const meAdmin = await api('/permissions/me', { token: adminToken });
|
||||
const meAdminData = meAdmin.payload?.data;
|
||||
record(
|
||||
'当前账号生效权限 GET /permissions/me(教师有 homework:write、无 permission:manage)',
|
||||
meTeacher.status === 200 &&
|
||||
Array.isArray(meTeacherData?.permissions) &&
|
||||
meTeacherData.permissions.includes('homework:write') &&
|
||||
!meTeacherData.permissions.includes('permission:manage'),
|
||||
`status=${meTeacher.status} 权限数=${(meTeacherData?.permissions ?? []).length} ` +
|
||||
`fromGroups=${meTeacherData?.fromGroups}`,
|
||||
);
|
||||
record(
|
||||
'管理员生效权限是全集(含 permission:manage 与 remote:control)',
|
||||
meAdmin.status === 200 &&
|
||||
(meAdminData?.permissions ?? []).includes('permission:manage') &&
|
||||
(meAdminData?.permissions ?? []).includes('remote:control'),
|
||||
`status=${meAdmin.status} 权限数=${(meAdminData?.permissions ?? []).length}`,
|
||||
);
|
||||
|
||||
// 权限键目录是 permission:manage 专属:教师 403、管理员拿到整棵树
|
||||
const catalogDenied = await api('/permissions/catalog', { token: teacherToken });
|
||||
const catalog = await api('/permissions/catalog', { token: adminToken });
|
||||
const catalogData = catalog.payload?.data;
|
||||
record(
|
||||
'权限目录 GET /permissions/catalog(教师 403 / 管理员拿到 keys+categories+defaultRoles)',
|
||||
catalogDenied.status === 403 &&
|
||||
catalog.status === 200 &&
|
||||
Array.isArray(catalogData?.keys) &&
|
||||
catalogData.keys.includes('homework:write') &&
|
||||
Array.isArray(catalogData?.categories) &&
|
||||
typeof catalogData?.defaultRoles === 'object' &&
|
||||
catalogData.defaultRoles !== null,
|
||||
`教师=${catalogDenied.status} 管理员=${catalog.status} 键数=${(catalogData?.keys ?? []).length} ` +
|
||||
`分类数=${(catalogData?.categories ?? []).length}`,
|
||||
);
|
||||
|
||||
// 权限组可以比角色默认**更少**:只给 dashboard:read 的组分配给 teacher2 后,他只剩这一个键
|
||||
const groupName = `e2e-只读-${Date.now().toString().slice(-6)}`;
|
||||
const createdGroup = await api('/permissions/groups', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { name: groupName, description: '验证用临时权限组(收尾删除)', keys: ['dashboard:read'] },
|
||||
});
|
||||
const permissionGroupId = createdGroup.payload?.data?.id;
|
||||
|
||||
// 成员列表是"账号 id"的权威来源:登录响应里的 user.id 只是会话用户,
|
||||
// 权限页的成员 id 要按 username 从 GET /permissions/users 取(管理员本人也要在这里找)
|
||||
const membersRes = await api('/permissions/users', { token: adminToken });
|
||||
const members = membersRes.payload?.data ?? [];
|
||||
const teacher2Member = members.find((item) => item.username === 'teacher2') ?? null;
|
||||
const adminMember = members.find((item) => item.username === 'admin') ?? null;
|
||||
|
||||
// teacher2 的密码按脚本既有做法自愈(本机可能被人工改过),失败就重置成已知值再登
|
||||
const teacher2Password = 'teacher123';
|
||||
let teacher2Auth = await api('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username: 'teacher2', password: teacher2Password },
|
||||
});
|
||||
if (teacher2Auth.status !== 200 && teacher2Member) {
|
||||
await api(`/teachers/${teacher2Member.id}/reset-password`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { newPassword: teacher2Password },
|
||||
});
|
||||
teacher2Auth = await api('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username: 'teacher2', password: teacher2Password },
|
||||
});
|
||||
}
|
||||
const permissionTestToken = teacher2Auth.payload?.data?.token;
|
||||
|
||||
const assignLess = permissionGroupId
|
||||
? await api(`/permissions/users/${teacher2Member?.id}/groups`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { groupIds: [permissionGroupId] },
|
||||
})
|
||||
: { status: 0, payload: null };
|
||||
const meLess = permissionTestToken
|
||||
? await api('/permissions/me', { token: permissionTestToken })
|
||||
: { status: 0, payload: null };
|
||||
const teacher2LessPermissions = meLess.payload?.data?.permissions ?? [];
|
||||
record(
|
||||
'权限组可以比角色默认更少(只给 dashboard:read 的组 → 生效权限恰好只有这一个键)',
|
||||
Boolean(permissionGroupId) &&
|
||||
assignLess.status === 200 &&
|
||||
teacher2LessPermissions.length === 1 &&
|
||||
teacher2LessPermissions[0] === 'dashboard:read',
|
||||
`建组=${createdGroup.status} 分配=${assignLess.status} 生效权限=${JSON.stringify(teacher2LessPermissions)}`,
|
||||
);
|
||||
|
||||
const assignDefault = await api(`/permissions/users/${teacher2Member?.id}/groups`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { groupIds: [] },
|
||||
});
|
||||
const meReset = permissionTestToken
|
||||
? await api('/permissions/me', { token: permissionTestToken })
|
||||
: { status: 0, payload: null };
|
||||
const teacher2ResetPermissions = meReset.payload?.data?.permissions ?? [];
|
||||
record(
|
||||
'清空权限组后回到角色默认模板(PUT /users/:id/groups 传空数组 → 含 homework:write)',
|
||||
assignDefault.status === 200 &&
|
||||
teacher2ResetPermissions.includes('homework:write') &&
|
||||
!teacher2ResetPermissions.includes('permission:manage'),
|
||||
`status=${assignDefault.status} 生效权限数=${teacher2ResetPermissions.length} ` +
|
||||
`含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`,
|
||||
);
|
||||
|
||||
// 管理员固定拥有全部权限,不接受分配
|
||||
const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { groupIds: permissionGroupId ? [permissionGroupId] : [] },
|
||||
});
|
||||
record(
|
||||
'管理员不接受权限组分配(PUT /users/:id/groups → 400)',
|
||||
Boolean(adminMember) &&
|
||||
assignAdmin.status === 400 &&
|
||||
String(assignAdmin.payload?.message ?? '').includes('权限组'),
|
||||
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
// 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败)
|
||||
const groupsList = await api('/permissions/groups', { token: adminToken });
|
||||
const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null;
|
||||
if (builtinGroup) {
|
||||
const deleteBuiltin = await api(`/permissions/groups/${builtinGroup.id}`, { method: 'DELETE', token: adminToken });
|
||||
record(
|
||||
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
|
||||
deleteBuiltin.status === 400,
|
||||
`内置组=${builtinGroup.name} status=${deleteBuiltin.status} 响应=${deleteBuiltin.payload?.message ?? ''}`,
|
||||
);
|
||||
} else {
|
||||
record(
|
||||
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
|
||||
true,
|
||||
`跳过:当前库里没有任何 builtin=true 的权限组(GET /permissions/groups 返回 ${(groupsList.payload?.data ?? []).length} 个,种子里的「教务协作(示例)」不在库里)`,
|
||||
);
|
||||
}
|
||||
|
||||
// 收尾:teacher2 必须回到角色默认(否则后面所有以他为科任老师的用例全被权限组污染),临时组删掉
|
||||
await api(`/permissions/users/${teacher2Member?.id}/groups`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { groupIds: [] },
|
||||
});
|
||||
if (permissionGroupId) {
|
||||
await api(`/permissions/groups/${permissionGroupId}`, { method: 'DELETE', token: adminToken });
|
||||
}
|
||||
const groupsAfter = await api('/permissions/groups', { token: adminToken });
|
||||
record(
|
||||
'权限组用例收尾:teacher2 分组已清空、临时权限组已删除',
|
||||
!(groupsAfter.payload?.data ?? []).some((item) => item.id === permissionGroupId),
|
||||
`剩余权限组=${(groupsAfter.payload?.data ?? []).length}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 6.7 客户端设备与远控
|
||||
{
|
||||
const deviceKey = `e2e-device-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
||||
const heartbeatBase = {
|
||||
deviceKey,
|
||||
name: 'E2E 测试机',
|
||||
clientVersion: '1.0.0',
|
||||
platform: 'win32',
|
||||
stateNote: '待机',
|
||||
};
|
||||
|
||||
// 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 JWT 一律 401
|
||||
const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase });
|
||||
const heartbeat = await api('/clients/heartbeat', { method: 'POST', token: adminToken, body: heartbeatBase });
|
||||
const heartbeatData = heartbeat.payload?.data;
|
||||
record(
|
||||
'客户端心跳 POST /clients/heartbeat(未登录 401 / 登录后 200 且返回 deviceId 与 command 字段)',
|
||||
heartbeatNoAuth.status === 401 &&
|
||||
heartbeat.status === 200 &&
|
||||
Boolean(heartbeatData?.deviceId) &&
|
||||
'command' in (heartbeatData ?? {}),
|
||||
`未登录=${heartbeatNoAuth.status} 登录=${heartbeat.status} deviceId=${heartbeatData?.deviceId ?? '-'} ` +
|
||||
`command=${JSON.stringify(heartbeatData?.command ?? null)}`,
|
||||
);
|
||||
|
||||
// 越权:remote:control 默认只有管理员有
|
||||
const teacherCommandDenied = await api('/clients/commands', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { deviceIds: [heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: '越权用例' },
|
||||
});
|
||||
record(
|
||||
'远控命令越权被拒(教师无 remote:control → 403)',
|
||||
teacherCommandDenied.status === 403,
|
||||
`status=${teacherCommandDenied.status} 响应=${teacherCommandDenied.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
// 设备列表:刚心跳的这台要在线
|
||||
const devices = await api('/clients/devices', { token: adminToken });
|
||||
const device = (devices.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
|
||||
record(
|
||||
'设备列表 GET /clients/devices(刚心跳的设备在线)',
|
||||
devices.status === 200 && Boolean(device) && device.online === true,
|
||||
`status=${devices.status} 设备=${device?.name ?? '未找到'} online=${device?.online}`,
|
||||
);
|
||||
|
||||
// 下发锁屏命令(带备注)→ 下一次心跳必须带回这条命令
|
||||
const command = await api('/clients/commands', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: 'E2E 远控用例' },
|
||||
});
|
||||
const heartbeatWithCommand = await api('/clients/heartbeat', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { ...heartbeatBase, stateNote: '待机' },
|
||||
});
|
||||
const delivered = heartbeatWithCommand.payload?.data?.command;
|
||||
record(
|
||||
'远控命令下发 POST /clients/commands(lock)并在下次心跳送达(kind=lock)',
|
||||
command.status === 201 && Boolean(delivered?.id) && delivered?.kind === 'lock',
|
||||
`下发=${command.status} 送达=${JSON.stringify(delivered ?? null)}`,
|
||||
);
|
||||
|
||||
const ackBody = { deviceKey, ok: true, result: 'e2e' };
|
||||
const ackFirst = await api(`/clients/commands/${delivered?.id}/ack`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: ackBody,
|
||||
});
|
||||
// 幂等:客户端重试回执不报错、也不覆盖第一次结果
|
||||
const ackSecond = await api(`/clients/commands/${delivered?.id}/ack`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { deviceKey, ok: false, result: '重试不应覆盖结果' },
|
||||
});
|
||||
record(
|
||||
'命令回执 POST /clients/commands/:id/ack(首次 200,重复回执幂等仍 200)',
|
||||
ackFirst.status === 200 && ackSecond.status === 200,
|
||||
`首次=${ackFirst.status}(${ackFirst.payload?.data?.result ?? '-'}) 重复=${ackSecond.status}(${ackSecond.payload?.data?.result ?? '-'})`,
|
||||
);
|
||||
|
||||
const devicesAfterAck = await api('/clients/devices', { token: adminToken });
|
||||
const ackedDevice = (devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
|
||||
record(
|
||||
'回执后设备不再有待执行命令(pendingCommand 为空)',
|
||||
Boolean(ackedDevice) && ackedDevice.pendingCommand === null,
|
||||
`pendingCommand=${JSON.stringify(ackedDevice?.pendingCommand ?? null)}`,
|
||||
);
|
||||
|
||||
// 归属校验:别人拿自己的 deviceKey 回执同一 id 必须被拒
|
||||
const otherDeviceKey = `${deviceKey}-other`;
|
||||
const otherHeartbeat = await api('/clients/heartbeat', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { ...heartbeatBase, deviceKey: otherDeviceKey, name: 'E2E 另一台测试机' },
|
||||
});
|
||||
const otherDeviceId = otherHeartbeat.payload?.data?.deviceId ?? null;
|
||||
const ackWrongDevice = await api(`/clients/commands/${delivered?.id}/ack`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { deviceKey: otherDeviceKey, ok: true, result: 'e2e-other' },
|
||||
});
|
||||
record(
|
||||
'回执归属校验(别的 deviceKey 回执同一命令 → 403)',
|
||||
ackWrongDevice.status === 403,
|
||||
`status=${ackWrongDevice.status} code=${ackWrongDevice.payload?.code ?? '-'} ` +
|
||||
`响应=${ackWrongDevice.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
// 收尾:两台测试机都清掉(否则会长期挂在设备列表里)
|
||||
const deleteTestDevice = device
|
||||
? await api(`/clients/devices/${device.id}`, { method: 'DELETE', token: adminToken })
|
||||
: { status: 0, payload: null };
|
||||
const deleteOtherDevice = otherDeviceId
|
||||
? await api(`/clients/devices/${otherDeviceId}`, { method: 'DELETE', token: adminToken })
|
||||
: { status: 0, payload: null };
|
||||
const deviceAfterDelete = await api('/clients/devices', { token: adminToken });
|
||||
const leftoverKeys = (deviceAfterDelete.payload?.data ?? [])
|
||||
.filter((item) => item.deviceKey === deviceKey || item.deviceKey === otherDeviceKey)
|
||||
.map((item) => item.deviceKey);
|
||||
record(
|
||||
'客户端设备用例收尾:两台测试机都已移除',
|
||||
deleteTestDevice.status === 200 && deleteOtherDevice.status === 200 && leftoverKeys.length === 0,
|
||||
`删除测试机=${deleteTestDevice.status} 删除另一台=${deleteOtherDevice.status} ` +
|
||||
`残留=${leftoverKeys.length ? leftoverKeys.join(',') : '无'}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 6.8 客户端安装包
|
||||
{
|
||||
const releases = await api('/update/client/releases', { token: adminToken });
|
||||
record(
|
||||
'客户端安装包列表 GET /update/client/releases',
|
||||
releases.status === 200 && Array.isArray(releases.payload?.data),
|
||||
`status=${releases.status} 版本数=${(releases.payload?.data ?? []).length}`,
|
||||
);
|
||||
|
||||
// 登记只写元数据,**但要求磁盘上真的有这个版本的安装包**:不存在的版本必须被拒(400)
|
||||
const fakeVersion = '9.9.9-e2e';
|
||||
const registerMissing = await api('/update/client/releases', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { version: fakeVersion },
|
||||
});
|
||||
const releasesAfterRegister = await api('/update/client/releases', { token: adminToken });
|
||||
record(
|
||||
'登记不存在的客户端安装包版本被拒(400,且不会落库)',
|
||||
registerMissing.status === 400 &&
|
||||
!(releasesAfterRegister.payload?.data ?? []).some((item) => item.version === fakeVersion),
|
||||
`status=${registerMissing.status} code=${registerMissing.payload?.code ?? '-'} ` +
|
||||
`响应=${registerMissing.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
// 版本白名单:含路径分隔符的版本号连 schema 都过不去
|
||||
const badVersion = await api('/update/client/releases', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { version: '../evil' },
|
||||
});
|
||||
record(
|
||||
'版本号白名单(POST /update/client/releases 传 ../evil → 400/422)',
|
||||
badVersion.status === 400 || badVersion.status === 422,
|
||||
`status=${badVersion.status} code=${badVersion.payload?.code ?? '-'} ` +
|
||||
`响应=${badVersion.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
// 下载接口本身不挂权限键,但**要登录**:不带 token 会被 401 挡在鉴权层
|
||||
const downloadNoAuth = await api(`/update/client/download/${encodeURIComponent(fakeVersion)}`);
|
||||
const downloadMissing = await api(`/update/client/download/${encodeURIComponent(fakeVersion)}`, {
|
||||
token: adminToken,
|
||||
});
|
||||
record(
|
||||
'下载不存在的客户端安装包(未登录 401 / 已登录 404)',
|
||||
downloadNoAuth.status === 401 && downloadMissing.status === 404,
|
||||
`未登录=${downloadNoAuth.status} 已登录=${downloadMissing.status} ` +
|
||||
`响应=${downloadMissing.payload?.message ?? ''}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 6.9 大模型助手(一律 mock,绝不联网)
|
||||
{
|
||||
const aiSettingsDenied = await api('/ai/settings', { token: teacherToken });
|
||||
const aiSettings = await api('/ai/settings', { token: adminToken });
|
||||
const aiSettingsData = aiSettings.payload?.data ?? {};
|
||||
const aiSettingsText = JSON.stringify(aiSettingsData);
|
||||
record(
|
||||
'大模型设置 GET /ai/settings(教师 403 / 管理员 200,且响应里没有任何密钥字段)',
|
||||
aiSettingsDenied.status === 403 &&
|
||||
aiSettings.status === 200 &&
|
||||
typeof aiSettingsData.enabled === 'boolean' &&
|
||||
'provider' in aiSettingsData &&
|
||||
'baseUrl' in aiSettingsData &&
|
||||
'model' in aiSettingsData &&
|
||||
'hasApiKey' in aiSettingsData &&
|
||||
'keyHint' in aiSettingsData &&
|
||||
'systemPromptExtra' in aiSettingsData &&
|
||||
!aiSettingsText.includes('apiKey'),
|
||||
`教师=${aiSettingsDenied.status} 管理员=${aiSettings.status} 字段=${Object.keys(aiSettingsData).join(',')}`,
|
||||
);
|
||||
|
||||
// 助手没开启时 /interpret 会 400:先开启,且**强制走离线 mock 解析**(绝不联网)
|
||||
const enableAi = await api('/ai/settings', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { enabled: true, provider: 'mock' },
|
||||
});
|
||||
record(
|
||||
'开启大模型助手并固定为离线 mock(PUT /ai/settings)',
|
||||
enableAi.status === 200 && enableAi.payload?.data?.enabled === true && enableAi.payload?.data?.provider === 'mock',
|
||||
`status=${enableAi.status} enabled=${enableAi.payload?.data?.enabled} provider=${enableAi.payload?.data?.provider}`,
|
||||
);
|
||||
|
||||
const promptText = '给2026级1班布置作业,把卷子写完';
|
||||
const interpret = await api('/ai/interpret', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { text: promptText },
|
||||
});
|
||||
const interpretData = interpret.payload?.data;
|
||||
record(
|
||||
'助手解析布置作业(POST /ai/interpret → create_homework,需确认、班级非空、有预览)',
|
||||
interpret.status === 200 &&
|
||||
interpretData?.action === 'create_homework' &&
|
||||
interpretData?.needConfirm === true &&
|
||||
Boolean(interpretData?.params?.classId) &&
|
||||
Boolean(interpretData?.preview?.summary),
|
||||
`status=${interpret.status} action=${interpretData?.action} needConfirm=${interpretData?.needConfirm} ` +
|
||||
`classId=${interpretData?.params?.classId ?? '-'} summary=${interpretData?.preview?.summary ?? '-'}`,
|
||||
);
|
||||
|
||||
const unknownInterpret = await api('/ai/interpret', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { text: '今天天气不错' },
|
||||
});
|
||||
record(
|
||||
'助手听不懂时返回 unknown(不猜动作)',
|
||||
unknownInterpret.status === 200 && unknownInterpret.payload?.data?.action === 'unknown',
|
||||
`status=${unknownInterpret.status} action=${unknownInterpret.payload?.data?.action}`,
|
||||
);
|
||||
|
||||
const audit = await api('/ai/audit', { token: adminToken });
|
||||
const auditRows = audit.payload?.data ?? [];
|
||||
record(
|
||||
'助手使用审计 GET /ai/audit(能看到刚写入的那句话)',
|
||||
audit.status === 200 && auditRows.some((item) => item.prompt === promptText),
|
||||
`status=${audit.status} 记录数=${auditRows.length}`,
|
||||
);
|
||||
|
||||
// 收尾:助手必须还原成关闭(否则别的东西会以为已经配好了模型)
|
||||
const disableAi = await api('/ai/settings', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { enabled: false },
|
||||
});
|
||||
record(
|
||||
'大模型助手用例收尾:enabled 已还原为 false',
|
||||
disableAi.status === 200 && disableAi.payload?.data?.enabled === false,
|
||||
`status=${disableAi.status} enabled=${disableAi.payload?.data?.enabled}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 6.10 系统设置
|
||||
{
|
||||
const systemDenied = await api('/system/settings', { token: teacherToken });
|
||||
const systemSettings = await api('/system/settings', { token: adminToken });
|
||||
record(
|
||||
'系统设置 GET /system/settings(教师 403 / 管理员 200)',
|
||||
systemDenied.status === 403 &&
|
||||
systemSettings.status === 200 &&
|
||||
typeof systemSettings.payload?.data?.developerMode === 'boolean',
|
||||
`教师=${systemDenied.status} 管理员=${systemSettings.status} ` +
|
||||
`developerMode=${systemSettings.payload?.data?.developerMode}`,
|
||||
);
|
||||
|
||||
// 开发者模式:开启后错误响应会带上堆栈,所以**必须**还原
|
||||
const enableDeveloper = await api('/system/settings', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { developerMode: true },
|
||||
});
|
||||
const readBackDeveloper = await api('/system/settings', { token: adminToken });
|
||||
const disableDeveloper = await api('/system/settings', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { developerMode: false },
|
||||
});
|
||||
const readBackDisabled = await api('/system/settings', { token: adminToken });
|
||||
record(
|
||||
'开发者模式可开关并回读(PUT /system/settings → GET 一致,随后还原为 false)',
|
||||
enableDeveloper.status === 200 &&
|
||||
readBackDeveloper.payload?.data?.developerMode === true &&
|
||||
disableDeveloper.status === 200 &&
|
||||
readBackDisabled.payload?.data?.developerMode === false,
|
||||
`开启=${enableDeveloper.status} 回读=${readBackDeveloper.payload?.data?.developerMode} ` +
|
||||
`还原=${readBackDisabled.payload?.data?.developerMode}`,
|
||||
);
|
||||
|
||||
const autostart = await api('/system/autostart', { token: adminToken });
|
||||
const autostartData = autostart.payload?.data;
|
||||
const enableAutostart = await api('/system/autostart', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { enabled: true },
|
||||
});
|
||||
record(
|
||||
'开机自启:开发机不支持(GET supported=false,PUT 拒绝 400)',
|
||||
autostart.status === 200 &&
|
||||
autostartData?.supported === false &&
|
||||
typeof autostartData?.detail === 'string' &&
|
||||
autostartData.detail.length > 0 &&
|
||||
enableAutostart.status === 400,
|
||||
`GET=${autostart.status} supported=${autostartData?.supported} mechanism=${autostartData?.mechanism} ` +
|
||||
`detail=${autostartData?.detail ?? '-'} PUT=${enableAutostart.status} ` +
|
||||
`响应=${enableAutostart.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
const diagnostics = await api('/system/diagnostics', { token: adminToken });
|
||||
const diagnosticsText = JSON.stringify(diagnostics.payload?.data ?? {});
|
||||
record(
|
||||
'运行诊断 GET /system/diagnostics(200 且不泄漏 apiKey/password/token/secret)',
|
||||
diagnostics.status === 200 &&
|
||||
!/apiKey|password|token|secret/i.test(diagnosticsText),
|
||||
`status=${diagnostics.status} 字段=${Object.keys(diagnostics.payload?.data ?? {}).join(',')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 6.11 批量发布补漏
|
||||
{
|
||||
// 一个不存在的班级 + 一个真实班级:单班失败不能把整批带崩(逐班结果,不整批回滚)
|
||||
const missingClassId = `not-exist-${Date.now().toString(36)}`;
|
||||
const mixedBulk = await api('/notifications/bulk', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: {
|
||||
classIds: [missingClassId, classId],
|
||||
title: `批量失败隔离回归 ${Date.now()}`,
|
||||
content: '其中一个班不存在,另一个班照常发布',
|
||||
priority: 'NORMAL',
|
||||
},
|
||||
});
|
||||
const mixedBulkData = mixedBulk.payload?.data;
|
||||
const missingResult = (mixedBulkData?.results ?? []).find((item) => item.classId === missingClassId);
|
||||
record(
|
||||
'批量发布:不存在的班级逐班失败、真实班级照常成功(failed>=1 且 succeeded>=1)',
|
||||
mixedBulk.status === 201 &&
|
||||
(mixedBulkData?.failed ?? 0) >= 1 &&
|
||||
(mixedBulkData?.succeeded ?? 0) >= 1 &&
|
||||
missingResult?.ok === false,
|
||||
`status=${mixedBulk.status} total=${mixedBulkData?.total} 成功=${mixedBulkData?.succeeded} ` +
|
||||
`失败=${mixedBulkData?.failed} 失败code=${missingResult?.code ?? '-'}`,
|
||||
);
|
||||
// 清理:只删成功落库的那几条(失败的班没有 id)
|
||||
for (const item of mixedBulkData?.results ?? []) {
|
||||
if (item.id) await api(`/notifications/${item.id}`, { method: 'DELETE', token: adminToken });
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 7. 权限隔离
|
||||
if (foreignClass) {
|
||||
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
|
||||
@@ -1684,6 +2352,76 @@ async function main() {
|
||||
`status=${gradesByHeadTeacher.status} 跳过=${gradesByHeadTeacher.payload?.data?.skipped} 新增=${gradesByHeadTeacher.payload?.data?.inserted}`,
|
||||
);
|
||||
|
||||
// 8.3.1 科目归属越权回归(修复"非该科科任老师可以布置/导入其他科内容"):
|
||||
// ① 新建成同名课程必须被拒绝 —— 否则谁都能借"新建科目"把自己写成该科任课老师,
|
||||
// 后面那道 assertCanManageSubjectContent 的科目门随即失效;
|
||||
// ② 班主任导入**别人任教科目**的成绩必须逐行拒绝(原先只判到班级这一层)。
|
||||
{
|
||||
const guardedCourseName = `越权校验科目${Date.now().toString(36).slice(-5).toUpperCase()}`;
|
||||
const firstCourse = await api('/courses', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { classId, name: guardedCourseName },
|
||||
});
|
||||
const firstCourseId = firstCourse.payload?.data?.id;
|
||||
record(
|
||||
'新建科目成功(用于越权校验)',
|
||||
firstCourse.status === 201 && Boolean(firstCourseId),
|
||||
`status=${firstCourse.status} id=${firstCourseId ?? '-'}`,
|
||||
);
|
||||
|
||||
const duplicateCourse = await api('/courses', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { classId, name: guardedCourseName },
|
||||
});
|
||||
record(
|
||||
'同名课程被拒绝并返回已有课程 id(COURSE_EXISTS)',
|
||||
duplicateCourse.status === 409 &&
|
||||
duplicateCourse.payload?.code === 'COURSE_EXISTS' &&
|
||||
duplicateCourse.payload?.details?.courseId === firstCourseId,
|
||||
`status=${duplicateCourse.status} code=${duplicateCourse.payload?.code} details=${JSON.stringify(duplicateCourse.payload?.details ?? {})}`,
|
||||
);
|
||||
|
||||
// 这门课的任课老师是**管理员**(course.create 会把创建者写成任课老师):
|
||||
// 班主任导入它的成绩必须被拒,且不能落库。
|
||||
const guardedCsv = [
|
||||
'学号,考试名称,分数,总分,科目',
|
||||
`${targetStudent?.studentNo ?? '202601'},越权校验考试,88,100,${guardedCourseName}`,
|
||||
].join(NL);
|
||||
const guardedImport = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: {
|
||||
kind: 'grades',
|
||||
classId,
|
||||
fileName: 'guarded.csv',
|
||||
contentBase64: Buffer.from(`\ufeff${guardedCsv}${NL}`, 'utf8').toString('base64'),
|
||||
mapping: {
|
||||
studentNo: '学号',
|
||||
examName: '考试名称',
|
||||
score: '分数',
|
||||
totalScore: '总分',
|
||||
courseName: '科目',
|
||||
},
|
||||
mode: 'append',
|
||||
},
|
||||
});
|
||||
const guardedErrors = guardedImport.payload?.data?.errors ?? [];
|
||||
record(
|
||||
'班主任导入他人任教科目成绩被逐行拒绝(不落库)',
|
||||
guardedImport.status === 200 &&
|
||||
guardedImport.payload?.data?.inserted === 0 &&
|
||||
guardedImport.payload?.data?.failed === 1 &&
|
||||
String(guardedErrors[0]?.message ?? '').includes('不是你任教的科目'),
|
||||
`新增=${guardedImport.payload?.data?.inserted} 失败=${guardedImport.payload?.data?.failed} 提示=${guardedErrors[0]?.message ?? '-'}`,
|
||||
);
|
||||
|
||||
if (firstCourseId) {
|
||||
await api(`/courses/${firstCourseId}`, { method: 'DELETE', token: adminToken });
|
||||
}
|
||||
}
|
||||
|
||||
// 8.4 学生名单导入(仅管理员)+ 数据清理
|
||||
const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase();
|
||||
// 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」
|
||||
@@ -2433,17 +3171,48 @@ async function main() {
|
||||
// ---------------------------------------------------------------- 数据库管理(仅管理员)
|
||||
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
|
||||
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
|
||||
// 数据表清单随模型演进增长(2026-10-10 加了权限组 / 客户端管理 8 张表后共 23 张)。
|
||||
// **不写死总数**:断言"服务端快照枚举 == 这份清单",加模型时只改这一处。
|
||||
const REQUIRED_DB_TABLES = [
|
||||
'User',
|
||||
'ArchivedYear',
|
||||
'Class',
|
||||
'Student',
|
||||
'Course',
|
||||
'StudentClassTransfer',
|
||||
'Schedule',
|
||||
'TimeLayout',
|
||||
'Homework',
|
||||
'HomeworkStatus',
|
||||
'Notification',
|
||||
'NotificationRead',
|
||||
'Grade',
|
||||
'IntegrationDevice',
|
||||
'ClassIslandPush',
|
||||
'PermissionGroup',
|
||||
'PermissionGroupItem',
|
||||
'UserPermissionGroup',
|
||||
'AppSetting',
|
||||
'ClientDevice',
|
||||
'ClientCommand',
|
||||
'ClientRelease',
|
||||
'AiAudit',
|
||||
];
|
||||
const dbStatus = await api('/database/status', { token: adminToken });
|
||||
const dbStatusData = dbStatus.payload?.data ?? {};
|
||||
const dbStatusTableNames = Array.isArray(dbStatusData.tables)
|
||||
? dbStatusData.tables.map((item) => item.name)
|
||||
: [];
|
||||
record(
|
||||
'数据库状态(管理员):已连接且返回 15 张表行数',
|
||||
'数据库状态(管理员):已连接且返回全部数据表行数',
|
||||
dbStatus.status === 200 &&
|
||||
dbStatusData.connected === true &&
|
||||
Array.isArray(dbStatusData.tables) &&
|
||||
dbStatusData.tables.length === 15 &&
|
||||
dbStatusData.tables.every((item) => item.count >= 0),
|
||||
dbStatusData.tables.every((item) => item.count >= 0) &&
|
||||
dbStatusTableNames.length === REQUIRED_DB_TABLES.length &&
|
||||
REQUIRED_DB_TABLES.every((name) => dbStatusTableNames.includes(name)),
|
||||
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
|
||||
(dbStatusData.tables ?? []).length
|
||||
dbStatusTableNames.length
|
||||
}`,
|
||||
);
|
||||
|
||||
@@ -2556,13 +3325,17 @@ async function main() {
|
||||
} catch {
|
||||
snapshotParsed = null;
|
||||
}
|
||||
const snapshotTableNames = Object.keys(snapshotParsed?.data ?? {});
|
||||
record(
|
||||
'导出快照(JSON 格式正确、15 张表、账号数 > 0)',
|
||||
'导出快照(JSON 格式正确、全部数据表、账号数 > 0)',
|
||||
dbExport.status === 200 &&
|
||||
snapshotParsed?.format === 'classhelper-snapshot' &&
|
||||
Object.keys(snapshotParsed?.data ?? {}).length === 15 &&
|
||||
snapshotTableNames.length === REQUIRED_DB_TABLES.length &&
|
||||
REQUIRED_DB_TABLES.every((name) => snapshotTableNames.includes(name)) &&
|
||||
(snapshotParsed?.counts?.User ?? 0) > 0,
|
||||
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
|
||||
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 表=${snapshotTableNames.length} 用户=${
|
||||
snapshotParsed?.counts?.User ?? '-'
|
||||
}`,
|
||||
);
|
||||
|
||||
// 造一条临时通知 → 快照导入(应被清掉)→ 再造一条 → 从备份恢复(也应被清掉)
|
||||
|
||||
@@ -7,6 +7,7 @@ import { env } from './config/env.js';
|
||||
import { disconnectPrisma, pingDatabase } from './lib/db.js';
|
||||
import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
|
||||
import { logger } from './lib/logger.js';
|
||||
import { refreshDeveloperMode } from './lib/settings.js';
|
||||
import { resolveWebDistDir } from './lib/web-static.js';
|
||||
import { initRealtime } from './realtime/socket.js';
|
||||
import { startBackupScheduler } from './modules/database/database.service.js';
|
||||
@@ -51,6 +52,9 @@ async function prepareDatabase(): Promise<void> {
|
||||
|
||||
await prepareDatabase();
|
||||
|
||||
// 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts)
|
||||
await refreshDeveloperMode();
|
||||
|
||||
const app = createApp();
|
||||
const httpServer = createServer(app);
|
||||
const io = initRealtime(httpServer);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { BulkPublishClassResult, BulkPublishResult } from '@classhelper/shared';
|
||||
import { ApiError } from './http.js';
|
||||
import { logger } from './logger.js';
|
||||
|
||||
/**
|
||||
* 批量发布的公共收尾:把单个班级的失败翻译成可直接展示给用户的结果。
|
||||
*
|
||||
* 批量发布是**逐班判定**的 —— 某个班没有权限、或正在上课而通知是紧急的,
|
||||
* 都不该让整批失败:成功的班照常落库并实时推送,失败的班把自己的错误码与中文原因带回来,
|
||||
* 由 Web 端在结果弹窗里逐行列出。因此这里保留 ApiError 的 code/details 原样透传。
|
||||
*/
|
||||
export function toBulkFailure(classId: string, error: unknown, action = '发布'): BulkPublishClassResult {
|
||||
if (error instanceof ApiError) {
|
||||
return {
|
||||
classId,
|
||||
ok: false,
|
||||
code: error.code,
|
||||
message: error.message,
|
||||
...(error.details === undefined ? {} : { details: error.details as Record<string, unknown> }),
|
||||
};
|
||||
}
|
||||
|
||||
logger.error(
|
||||
`批量${action}出现未预期错误:班级=${classId} 原因=${error instanceof Error ? error.message : String(error)}`,
|
||||
);
|
||||
return { classId, ok: false, code: 'INTERNAL_ERROR', message: `${action}失败,请稍后重试` };
|
||||
}
|
||||
|
||||
/** 汇总逐班结果(succeeded/failed 由结果本身统计,调用方不必自己数) */
|
||||
export function summarizeBulk(results: BulkPublishClassResult[]): BulkPublishResult {
|
||||
const succeeded = results.filter((item) => item.ok).length;
|
||||
return { total: results.length, succeeded, failed: results.length - succeeded, results };
|
||||
}
|
||||
|
||||
/** 批量目标班级:去重并保持调用方给的顺序(多选控件重复提交时会带重复 id) */
|
||||
export function normalizeBulkClassIds(classIds: readonly string[]): string[] {
|
||||
return [...new Set(classIds)];
|
||||
}
|
||||
@@ -19,6 +19,14 @@ export type ApiErrorCode =
|
||||
| 'FORBIDDEN'
|
||||
| 'NOT_FOUND'
|
||||
| 'CONFLICT'
|
||||
/** 课程:该班级已有同名课程(前端应复用 `details.courseId`,而不是另建一门) */
|
||||
| 'COURSE_EXISTS'
|
||||
/** 大模型:功能未开启 / 还没配置服务地址与密钥 */
|
||||
| 'AI_NOT_CONFIGURED'
|
||||
/** 大模型:调用超时或网络不可达 */
|
||||
| 'AI_TIMEOUT'
|
||||
/** 大模型:返回的内容不是可用的结构化动作(已记日志,不把原始回复回传给前端) */
|
||||
| 'AI_INVALID_OUTPUT'
|
||||
| 'TOO_MANY_REQUESTS'
|
||||
/** 上课时间段发布紧急通知需要二次确认(前端据此弹出全屏警告 + 3 秒倒计时) */
|
||||
| 'URGENT_DURING_CLASS'
|
||||
|
||||
@@ -378,6 +378,8 @@ export interface NotificationLike {
|
||||
priority: string;
|
||||
createdBy: string;
|
||||
createdAt: Date;
|
||||
/** 通知所属日期(YYYY-MM-DD);老库可能为空串,DTO 层按 createdAt 本地日期兜底 */
|
||||
assignDate?: string;
|
||||
creator?: TeacherBriefLike | null;
|
||||
reads?: NotificationReadLike[];
|
||||
}
|
||||
@@ -403,6 +405,8 @@ export function toNotificationDto(
|
||||
priority: item.priority as NotificationPriority,
|
||||
createdBy: item.createdBy,
|
||||
createdAt: toIso(item.createdAt),
|
||||
// 老库兜底:assignDate 为空时按 createdAt 的本地日期回退,前端不必处理空值
|
||||
assignDate: item.assignDate || dayKeyLocal(item.createdAt),
|
||||
creator: item.creator ? toTeacherBrief(item.creator) : null,
|
||||
read: own ? true : false,
|
||||
readAt: toIsoOrNull(own?.readAt),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import {
|
||||
PERMISSION_KEYS,
|
||||
PERMISSION_LABELS,
|
||||
ROLE_DEFAULT_PERMISSIONS,
|
||||
defaultPermissionsForRole,
|
||||
type PermissionKey,
|
||||
} from '@classhelper/shared';
|
||||
import { prisma } from './db.js';
|
||||
import type { TokenPayload } from './jwt.js';
|
||||
|
||||
/**
|
||||
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
|
||||
*
|
||||
* 语义(与 shared 的 `PERMISSION_DEFAULT_ROLES` 一一对应):
|
||||
* - `ADMIN`:全集。另外**永远**保留 {@link ADMIN_UNREMOVABLE_PERMISSIONS},避免管理员
|
||||
* 把自己(或最后一个管理员)锁在权限页之外;
|
||||
* - `CLASS_DEVICE`(ClassHelper 班级端):**空集,且不受权限组影响** —— 班级端的会话主体是
|
||||
* 班级而非账号,它的能力由班级维度断言(`canManageSubjectContent(..., { allowClassDevice })`
|
||||
* 等)决定,给某个班级端"分配权限组"没有意义;
|
||||
* - 其他账号:**有分组**时取这些组的键并集(因此可以比默认更少 ⇒ 也能用来收回权限);
|
||||
* **没有任何分组**时回落角色默认模板(逐条镜像权限组上线前各路由的 `requireRole`)。
|
||||
*
|
||||
* 每次请求实时查询(不缓存):权限改动**立即生效**,代价是每请求多一条 `findMany`
|
||||
* (账号规模很小,这个代价换"改了立刻生效"是值的)。
|
||||
*/
|
||||
export async function loadEffectivePermissions(user: TokenPayload): Promise<Set<PermissionKey>> {
|
||||
const permissions = new Set<PermissionKey>();
|
||||
|
||||
if (user.classSession || user.role === 'CLASS_DEVICE') return permissions;
|
||||
|
||||
if (user.role === 'ADMIN') {
|
||||
for (const key of PERMISSION_KEYS) permissions.add(key);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const assignments = await prisma.userPermissionGroup.findMany({
|
||||
where: { userId: user.sub },
|
||||
select: { group: { select: { items: { select: { key: true } } } } },
|
||||
});
|
||||
|
||||
if (assignments.length === 0) {
|
||||
for (const key of defaultPermissionsForRole(user.role)) permissions.add(key);
|
||||
return permissions;
|
||||
}
|
||||
|
||||
const known = new Set<string>(PERMISSION_KEYS);
|
||||
for (const assignment of assignments) {
|
||||
for (const item of assignment.group.items) {
|
||||
// 库里可能留着已下线的键(旧版本写入的),这里静默忽略,避免整账号权限异常
|
||||
if (known.has(item.key)) permissions.add(item.key as PermissionKey);
|
||||
}
|
||||
}
|
||||
return permissions;
|
||||
}
|
||||
|
||||
/** 管理员不可被剥夺的权限键(防锁死) */
|
||||
export const ADMIN_UNREMOVABLE_PERMISSIONS: readonly PermissionKey[] = ['permission:manage', 'system:manage'];
|
||||
|
||||
/** 角色默认模板(供权限页展示"没分组时会是什么") */
|
||||
export function roleDefaultPermissions(role: TokenPayload['role']): readonly PermissionKey[] {
|
||||
return ROLE_DEFAULT_PERMISSIONS[role];
|
||||
}
|
||||
|
||||
/** 把权限键翻译成中文提示(403 文案与权限页共用) */
|
||||
export function permissionLabel(key: PermissionKey): string {
|
||||
return PERMISSION_LABELS[key];
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
/**
|
||||
* 全局设置里"密钥类"值的加解密(目前只有大模型的 API Key)。
|
||||
*
|
||||
* 为什么不用明文存 `AppSetting`:那张表会进备份/导出/跨库迁移的快照,
|
||||
* 明文密钥等于把它写进了每一次备份。这里用 **AES-256-GCM**,密钥由 `JWT_SECRET`
|
||||
* 经 scrypt 派生(不需要新的环境变量),密文带 `v1:` 前缀便于将来轮换算法。
|
||||
*
|
||||
* 代价:`JWT_SECRET` 一改,旧密文就解不开了 —— 这是有意的(换密钥本来就该重新填密钥),
|
||||
* `decryptSecret()` 解不开时返回空串,AI 设置页会显示"未配置"。
|
||||
*/
|
||||
const KEY = crypto.scryptSync(env.jwtSecret, 'classhelper-app-setting', 32);
|
||||
|
||||
export function encryptSecret(plain: string): string {
|
||||
if (!plain) return '';
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', KEY, iv);
|
||||
const encrypted = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
return `v1:${iv.toString('base64')}:${cipher.getAuthTag().toString('base64')}:${encrypted.toString('base64')}`;
|
||||
}
|
||||
|
||||
export function decryptSecret(payload: string): string {
|
||||
if (!payload.startsWith('v1:')) return '';
|
||||
const [, ivBase64 = '', tagBase64 = '', dataBase64 = ''] = payload.split(':');
|
||||
try {
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', KEY, Buffer.from(ivBase64, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tagBase64, 'base64'));
|
||||
return Buffer.concat([
|
||||
decipher.update(Buffer.from(dataBase64, 'base64')),
|
||||
decipher.final(),
|
||||
]).toString('utf8');
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { prisma } from './db.js';
|
||||
|
||||
/**
|
||||
* 全局设置(`AppSetting` 表的唯一读写入口)。
|
||||
*
|
||||
* 值一律存字符串:布尔存 `'true'`/`'false'`,复杂结构存 JSON 文本。
|
||||
* 键名集中在这里,避免各模块手写字符串导致拼错后"设置静默不生效"。
|
||||
*/
|
||||
export const SETTING_KEYS = {
|
||||
/** 服务端开发者模式(仅 ADMIN 可改):错误响应带堆栈、日志降到 debug 级 */
|
||||
developerMode: 'system.developerMode',
|
||||
/** 大模型功能开关与配置(见 modules/ai) */
|
||||
aiEnabled: 'ai.enabled',
|
||||
/** 服务端解析器的实现:openai=真接口 / mock=离线确定性结果(自动化验收用) */
|
||||
aiProvider: 'ai.provider',
|
||||
aiBaseUrl: 'ai.baseUrl',
|
||||
aiModel: 'ai.model',
|
||||
/** 加密后的 API Key(见 lib/secrets.ts;接口只进不出) */
|
||||
aiApiKey: 'ai.apiKey',
|
||||
/** 追加到系统提示词末尾的学校自定义要求(可空) */
|
||||
aiSystemPromptExtra: 'ai.systemPromptExtra',
|
||||
} as const;
|
||||
|
||||
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
|
||||
|
||||
export async function getSetting(key: SettingKey): Promise<string> {
|
||||
const row = await prisma.appSetting.findUnique({ where: { key }, select: { value: true } });
|
||||
return row?.value ?? '';
|
||||
}
|
||||
|
||||
/** 批量读(避免逐键一条 SQL) */
|
||||
export async function getSettings(keys: readonly SettingKey[]): Promise<Record<string, string>> {
|
||||
const rows = await prisma.appSetting.findMany({
|
||||
where: { key: { in: [...keys] } },
|
||||
select: { key: true, value: true },
|
||||
});
|
||||
const map: Record<string, string> = {};
|
||||
for (const key of keys) map[key] = '';
|
||||
for (const row of rows) map[row.key] = row.value;
|
||||
return map;
|
||||
}
|
||||
|
||||
export async function setSetting(key: SettingKey, value: string): Promise<void> {
|
||||
await prisma.appSetting.upsert({
|
||||
where: { key },
|
||||
create: { key, value },
|
||||
update: { value },
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBooleanSetting(key: SettingKey, fallback = false): Promise<boolean> {
|
||||
const raw = await getSetting(key);
|
||||
if (raw === '') return fallback;
|
||||
return raw === 'true' || raw === '1';
|
||||
}
|
||||
|
||||
export async function setBooleanSetting(key: SettingKey, value: boolean): Promise<void> {
|
||||
await setSetting(key, value ? 'true' : 'false');
|
||||
if (key === SETTING_KEYS.developerMode) developerModeCache = value;
|
||||
}
|
||||
|
||||
/**
|
||||
* 开发者模式的内存缓存。
|
||||
*
|
||||
* 为什么缓存:错误中间件与日志级别是**热路径**(每个出错请求都要问一次),
|
||||
* 不可能为此每请求查一次 `AppSetting`。缓存由三处维护:启动时 `refreshDeveloperMode()`、
|
||||
* 管理员改设置时(`setBooleanSetting` 顺手更新)、以及需要时可手动刷新。
|
||||
*/
|
||||
let developerModeCache = false;
|
||||
|
||||
export function isDeveloperMode(): boolean {
|
||||
return developerModeCache;
|
||||
}
|
||||
|
||||
export async function refreshDeveloperMode(): Promise<void> {
|
||||
developerModeCache = await getBooleanSetting(SETTING_KEYS.developerMode, false);
|
||||
}
|
||||
@@ -26,9 +26,18 @@ export const SNAPSHOT_VERSION = 1;
|
||||
* `StudentClassTransfer` 存调班与转出历史;`ArchivedYear` 是毕业归档的届别档案。
|
||||
* 历史上那两张 `ClassTeacher`(无科目的协作关系)与 `Enrollment`(与 Student.classId 双写)
|
||||
* 已随 2026-10-06 的重构删除。
|
||||
*
|
||||
* 2026-10-10 新增:权限组(`PermissionGroup` / `PermissionGroupItem` / `UserPermissionGroup`)、
|
||||
* 全局设置 `AppSetting`(大模型配置与开发者模式开关)、客户端设备与远控
|
||||
* (`ClientDevice` / `ClientCommand`)、客户端安装包缓存 `ClientRelease`、大模型审计 `AiAudit`。
|
||||
* **加新表一定同步这里**,否则备份/换库会静默丢表。
|
||||
*/
|
||||
export const SNAPSHOT_TABLES = [
|
||||
'User',
|
||||
'PermissionGroup',
|
||||
'PermissionGroupItem',
|
||||
'UserPermissionGroup',
|
||||
'AppSetting',
|
||||
'ArchivedYear',
|
||||
'Class',
|
||||
'Student',
|
||||
@@ -43,6 +52,10 @@ export const SNAPSHOT_TABLES = [
|
||||
'Grade',
|
||||
'IntegrationDevice',
|
||||
'ClassIslandPush',
|
||||
'ClientDevice',
|
||||
'ClientCommand',
|
||||
'ClientRelease',
|
||||
'AiAudit',
|
||||
] as const;
|
||||
|
||||
export type SnapshotTable = (typeof SNAPSHOT_TABLES)[number];
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import type { UserRole } from '@classhelper/shared';
|
||||
import type { PermissionKey, UserRole } from '@classhelper/shared';
|
||||
import { PERMISSION_LABELS } from '@classhelper/shared';
|
||||
import { prisma } from '../lib/db.js';
|
||||
import { ApiError } from '../lib/http.js';
|
||||
import { loadEffectivePermissions } from '../lib/permissions.js';
|
||||
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
|
||||
|
||||
/**
|
||||
* 认证中间件:解析 Bearer token 并回查数据库,
|
||||
* 保证角色 / 班级变更(例如管理员换了班主任)立即生效,不被过期载荷影响。
|
||||
*
|
||||
* 2026-10-10 起**顺带装载生效权限集合**(`req.auth.permissions`):
|
||||
* 角色判据换成权限键判据(`requirePermission`),角色只用来决定"没有分组时的默认模板"。
|
||||
*/
|
||||
export function authenticate(): RequestHandler {
|
||||
return async (req, _res, next) => {
|
||||
@@ -24,9 +29,7 @@ export function authenticate(): RequestHandler {
|
||||
});
|
||||
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
|
||||
|
||||
req.auth = {
|
||||
token,
|
||||
user: {
|
||||
const user: TokenPayload = {
|
||||
sub: record.id,
|
||||
username: record.code,
|
||||
name: record.name,
|
||||
@@ -34,8 +37,8 @@ export function authenticate(): RequestHandler {
|
||||
classId: record.id,
|
||||
classSession: true,
|
||||
classCode: record.code,
|
||||
},
|
||||
};
|
||||
req.auth = { token, user, permissions: await loadEffectivePermissions(user) };
|
||||
next();
|
||||
return;
|
||||
}
|
||||
@@ -47,16 +50,14 @@ export function authenticate(): RequestHandler {
|
||||
});
|
||||
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
|
||||
|
||||
req.auth = {
|
||||
token,
|
||||
user: {
|
||||
const authUser: TokenPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: null,
|
||||
},
|
||||
};
|
||||
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -64,7 +65,7 @@ export function authenticate(): RequestHandler {
|
||||
};
|
||||
}
|
||||
|
||||
/** 角色守卫 */
|
||||
/** 角色守卫(仍保留:少数场景要按角色而不是按权限键判断,如班级端专属入口) */
|
||||
export function requireRole(...roles: UserRole[]): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
const user = req.auth?.user;
|
||||
@@ -80,8 +81,64 @@ export function requireRole(...roles: UserRole[]): RequestHandler {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限守卫:要求当前登录主体拥有**全部**列出的权限键。
|
||||
*
|
||||
* 权限集合来自 {@link loadEffectivePermissions}(权限组并集,或角色默认模板)。
|
||||
* 提示文案用中文权限名,用户看到的是"缺少「导入成绩」权限"而不是一堆键名。
|
||||
*/
|
||||
export function requirePermission(...keys: PermissionKey[]): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
if (!req.auth?.user) {
|
||||
next(ApiError.unauthorized());
|
||||
return;
|
||||
}
|
||||
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
|
||||
const missing = keys.filter((key) => !permissions.has(key));
|
||||
if (missing.length > 0) {
|
||||
const labels = missing.map((key) => `「${PERMISSION_LABELS[key]}」`).join('');
|
||||
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 权限守卫(任一满足):只要拥有其中**一个**权限键就放行。
|
||||
*
|
||||
* 用在"同一入口服务多种能力"的接口上:如导入模板按 `kind` 区分能力,
|
||||
* 只要有一种导入能力就能取模板(具体哪种 kind 能不能导,由 service 里的 kind 断言决定)。
|
||||
*/
|
||||
export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
if (!req.auth?.user) {
|
||||
next(ApiError.unauthorized());
|
||||
return;
|
||||
}
|
||||
const permissions = req.auth.permissions ?? new Set<PermissionKey>();
|
||||
if (keys.length > 0 && !keys.some((key) => permissions.has(key))) {
|
||||
const labels = keys.map((key) => `「${PERMISSION_LABELS[key]}」`).join('、');
|
||||
next(ApiError.forbidden(`当前账号缺少权限(${labels} 中至少需要一项),请联系管理员在「权限组」里分配`));
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
/** 取出当前登录用户,未认证时抛 401 */
|
||||
export function getAuthUser(req: Request): TokenPayload {
|
||||
if (!req.auth?.user) throw ApiError.unauthorized();
|
||||
return req.auth.user;
|
||||
}
|
||||
|
||||
/** 取出当前登录主体的生效权限集合(未认证时抛 401) */
|
||||
export function getAuthPermissions(req: Request): ReadonlySet<PermissionKey> {
|
||||
if (!req.auth?.user) throw ApiError.unauthorized();
|
||||
return req.auth.permissions ?? new Set<PermissionKey>();
|
||||
}
|
||||
|
||||
/** 当前主体是否有某个权限键(页面/服务里做条件分支用,找不到也当无权限) */
|
||||
export function hasAuthPermission(req: Request, key: PermissionKey): boolean {
|
||||
return (req.auth?.permissions ?? new Set<PermissionKey>()).has(key);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ZodError } from 'zod';
|
||||
import type { ApiErrorResponse } from '@classhelper/shared';
|
||||
import { ApiError } from '../lib/http.js';
|
||||
import { logger } from '../lib/logger.js';
|
||||
import { isDeveloperMode } from '../lib/settings.js';
|
||||
import { env } from '../config/env.js';
|
||||
|
||||
function sendError(
|
||||
@@ -36,7 +37,21 @@ export const errorHandler: ErrorRequestHandler = (error, req, res, _next) => {
|
||||
|
||||
if (error instanceof ApiError) {
|
||||
if (error.status >= 500) logger.error(error.message, error.details);
|
||||
sendError(res, error.status, error.message, error.code, error.details);
|
||||
/**
|
||||
* 开发者模式:把 `error.stack` 一并回传,否则"服务端 500"在浏览器里只有一句中文,
|
||||
* 排查时还得去服务器上翻日志。**500 才带**、且只在管理员显式打开开发者模式时生效
|
||||
* (开关见 `modules/system`),普通用户看到的东西一个字段都不多。
|
||||
*/
|
||||
const withStack =
|
||||
isDeveloperMode() && error.status >= 500 && error.stack
|
||||
? {
|
||||
...(error.details && typeof error.details === 'object'
|
||||
? (error.details as Record<string, unknown>)
|
||||
: { details: error.details }),
|
||||
stack: error.stack,
|
||||
}
|
||||
: error.details;
|
||||
sendError(res, error.status, error.message, error.code, withStack);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Router } from 'express';
|
||||
import { API_PATHS } from '@classhelper/shared';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
aiAuditQuerySchema,
|
||||
aiInterpretSchema,
|
||||
updateAiSettingsSchema,
|
||||
type AiAuditQuery,
|
||||
type AiInterpretInput,
|
||||
type UpdateAiSettingsInput,
|
||||
} from './ai.schemas.js';
|
||||
import * as aiService from './ai.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* 大模型助手。
|
||||
*
|
||||
* 权限分成两把:`ai:manage`(设置,默认只有管理员)与 `ai:use`(用一句话办事,默认教师也有)。
|
||||
* **`/interpret` 只解析、绝不落库**:真正发布由前端确认后调既有端点,权限与上课时段保护
|
||||
* 都在那条路径上(AGENTS.md §5 第 5/14 条)。
|
||||
*/
|
||||
router.get('/settings', requirePermission('ai:manage'), async (_req, res) => {
|
||||
sendOk(res, await aiService.getAiSettings(), '获取大模型设置成功');
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/settings',
|
||||
requirePermission('ai:manage'),
|
||||
validate({ body: updateAiSettingsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<UpdateAiSettingsInput>(req);
|
||||
sendOk(res, await aiService.updateAiSettings(user, input), '大模型设置已保存');
|
||||
},
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/interpret',
|
||||
requirePermission('ai:use'),
|
||||
validate({ body: aiInterpretSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<AiInterpretInput>(req);
|
||||
sendOk(res, await aiService.interpret(user, input), '助手已解析,请确认后发布');
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/audit', requirePermission('ai:manage'), validate({ query: aiAuditQuerySchema }), async (req, res) => {
|
||||
const query = validatedQuery<AiAuditQuery>(req);
|
||||
sendOk(res, await aiService.listAudit(query.limit ?? 50), '获取助手使用记录成功');
|
||||
});
|
||||
|
||||
export const aiModule = defineModule({
|
||||
name: 'ai',
|
||||
basePath: API_PATHS.ai,
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** 大模型服务端实现:openai=真接口 / mock=离线确定性结果(自动化验收用,不联网) */
|
||||
export const AI_PROVIDERS = ['openai', 'mock'] as const;
|
||||
|
||||
/** `PUT /api/ai/settings`:字段都可选;`apiKey` 传字符串=换新、null=清除、不传=保持 */
|
||||
export const updateAiSettingsSchema = z.object({
|
||||
enabled: z.boolean().optional(),
|
||||
provider: z.enum(AI_PROVIDERS).optional(),
|
||||
baseUrl: z.string().trim().max(300).optional(),
|
||||
model: z.string().trim().max(120).optional(),
|
||||
apiKey: z.string().trim().max(300).nullable().optional(),
|
||||
systemPromptExtra: z.string().trim().max(1000).optional(),
|
||||
});
|
||||
|
||||
/** `POST /api/ai/interpret`:一句中文原话 */
|
||||
export const aiInterpretSchema = z.object({
|
||||
text: z.string().trim().min(1, '请说一句要做的事').max(500, '一句话最多 500 个字'),
|
||||
classId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const aiAuditQuerySchema = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
export type UpdateAiSettingsInput = z.infer<typeof updateAiSettingsSchema>;
|
||||
export type AiInterpretInput = z.infer<typeof aiInterpretSchema>;
|
||||
export type AiAuditQuery = z.infer<typeof aiAuditQuerySchema>;
|
||||
@@ -0,0 +1,367 @@
|
||||
import { dayKeyLocal, type AiActionKind, type AiAuditDto, type AiInterpretResult, type AiSettingsDto } from '@classhelper/shared';
|
||||
import { z } from 'zod';
|
||||
import { getAccessibleClassIds } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { decryptSecret, encryptSecret } from '../../lib/secrets.js';
|
||||
import { SETTING_KEYS, getSetting, getSettings, setSetting } from '../../lib/settings.js';
|
||||
import { createHomeworkSchema } from '../homeworks/homeworks.schemas.js';
|
||||
import { createNotificationSchema } from '../notifications/notifications.schemas.js';
|
||||
import type { AiInterpretInput, UpdateAiSettingsInput } from './ai.schemas.js';
|
||||
|
||||
/**
|
||||
* 大模型助手:**只解析、不落库**。
|
||||
*
|
||||
* 设计边界(照 approved 计划):
|
||||
* 1. 模型只输出结构化意图(`{action, params, confidence}`),服务端用**与手动入口同一份 zod**
|
||||
* 二次校验后返回预览;真正落库由前端确认后调既有端点(`POST /homeworks` 等),
|
||||
* 于是权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用(AGENTS.md §5 第 5/14 条);
|
||||
* 2. **密钥只进不出**:`AppSetting` 里存的是 `encryptSecret()` 的密文,任何 GET 都不回传;
|
||||
* 3. `provider: 'mock'` 用确定性规则离线解析 —— 自动化验收**绝不访问外网**(§5 第 52 条),
|
||||
* 真实模型走 `provider: 'openai'` 时的 `{baseUrl}/chat/completions`。
|
||||
*/
|
||||
const DEFAULT_TIMEOUT_MS = 20_000;
|
||||
|
||||
/** 与手动入口同源的参数校验(助手不得放宽任何字段约束) */
|
||||
const homeworkParamsSchema = createHomeworkSchema.pick({
|
||||
classId: true,
|
||||
courseId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
assignDate: true,
|
||||
});
|
||||
const notificationParamsSchema = createNotificationSchema.pick({
|
||||
classId: true,
|
||||
title: true,
|
||||
content: true,
|
||||
priority: true,
|
||||
});
|
||||
const callParamsSchema = z.object({
|
||||
classId: z.string().min(1),
|
||||
studentId: z.string().min(1).optional(),
|
||||
studentName: z.string().trim().max(40).optional(),
|
||||
message: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
const modelReplySchema = z.object({
|
||||
action: z.enum(['create_homework', 'create_notification', 'call_student', 'unknown']),
|
||||
params: z.record(z.string(), z.unknown()).default({}),
|
||||
confidence: z.number().min(0).max(1).optional(),
|
||||
reason: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export async function getAiSettings(): Promise<AiSettingsDto> {
|
||||
const values = await getSettings([
|
||||
SETTING_KEYS.aiEnabled,
|
||||
SETTING_KEYS.aiProvider,
|
||||
SETTING_KEYS.aiBaseUrl,
|
||||
SETTING_KEYS.aiModel,
|
||||
SETTING_KEYS.aiApiKey,
|
||||
SETTING_KEYS.aiSystemPromptExtra,
|
||||
]);
|
||||
const apiKey = decryptSecret(values[SETTING_KEYS.aiApiKey] ?? '');
|
||||
return {
|
||||
enabled: values[SETTING_KEYS.aiEnabled] === 'true',
|
||||
provider: values[SETTING_KEYS.aiProvider] === 'mock' ? 'mock' : 'openai',
|
||||
baseUrl: values[SETTING_KEYS.aiBaseUrl] ?? '',
|
||||
model: values[SETTING_KEYS.aiModel] ?? '',
|
||||
hasApiKey: apiKey.length > 0,
|
||||
keyHint: apiKey.length >= 4 ? apiKey.slice(-4) : '',
|
||||
systemPromptExtra: values[SETTING_KEYS.aiSystemPromptExtra] ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateAiSettings(
|
||||
user: TokenPayload,
|
||||
input: UpdateAiSettingsInput,
|
||||
): Promise<AiSettingsDto> {
|
||||
if (input.enabled !== undefined) await setSetting(SETTING_KEYS.aiEnabled, input.enabled ? 'true' : 'false');
|
||||
if (input.provider !== undefined) await setSetting(SETTING_KEYS.aiProvider, input.provider);
|
||||
if (input.baseUrl !== undefined) await setSetting(SETTING_KEYS.aiBaseUrl, input.baseUrl.trim());
|
||||
if (input.model !== undefined) await setSetting(SETTING_KEYS.aiModel, input.model.trim());
|
||||
if (input.systemPromptExtra !== undefined) {
|
||||
await setSetting(SETTING_KEYS.aiSystemPromptExtra, input.systemPromptExtra.trim());
|
||||
}
|
||||
if (input.apiKey !== undefined) {
|
||||
// 传 null = 清除;传字符串 = 换成新密钥(空串按清除处理)
|
||||
await setSetting(SETTING_KEYS.aiApiKey, input.apiKey ? encryptSecret(input.apiKey.trim()) : '');
|
||||
}
|
||||
logger.info(`大模型设置已更新(操作人=${user.username},密钥${input.apiKey ? '已更新' : '未改'})`);
|
||||
return getAiSettings();
|
||||
}
|
||||
|
||||
interface AssistantContext {
|
||||
classes: { id: string; name: string }[];
|
||||
courses: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
async function resolveContext(user: TokenPayload, classId?: string): Promise<AssistantContext> {
|
||||
const classIds = await getAccessibleClassIds(user);
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { id: { in: classIds } },
|
||||
select: { id: true, name: true },
|
||||
orderBy: { name: 'asc' },
|
||||
take: 80,
|
||||
});
|
||||
const courses = classId
|
||||
? await prisma.course.findMany({ where: { classId }, select: { id: true, name: true } })
|
||||
: [];
|
||||
return { classes, courses };
|
||||
}
|
||||
|
||||
function buildSystemPrompt(context: AssistantContext, extra: string): string {
|
||||
return [
|
||||
'你是班级管理系统的指令解析器。把老师的一句中文转成严格 JSON,不要输出任何解释。',
|
||||
'可选动作(action):create_homework(布置作业)、create_notification(发通知)、call_student(叫学生来)、unknown(看不懂)。',
|
||||
'输出格式:{"action":"…","params":{…},"confidence":0~1}。',
|
||||
'create_homework 的 params:{classId, courseId?, title, content, assignDate?(YYYY-MM-DD)}。',
|
||||
'create_notification 的 params:{classId, title, content, priority?(LOW|NORMAL|HIGH|URGENT)}。',
|
||||
'call_student 的 params:{classId, studentName, message?}。',
|
||||
`今天日期:${dayKeyLocal(new Date())}。`,
|
||||
`可用班级:${context.classes.map((item) => `${item.name}(${item.id})`).join('、') || '无'}。`,
|
||||
context.courses.length
|
||||
? `候选班级的科目:${context.courses.map((item) => `${item.name}(${item.id})`).join('、')}。`
|
||||
: '未指定班级时 courseId 留空。',
|
||||
'信息不足时把 confidence 压低并在 reason 里说明缺什么;不要编造不存在于上面的班级/科目 id。',
|
||||
extra ? `学校额外要求:${extra}` : '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** 离线确定性解析(provider=mock):只做关键词判断,供自动化与"没配模型时试跑"用 */
|
||||
function mockInterpret(text: string, context: AssistantContext): z.infer<typeof modelReplySchema> {
|
||||
// 优先用句子里**真正提到的**班级(「给2026级1班布置作业」);都没提到才退回按名称升序的第一个。
|
||||
// (曾经只取第一个,结果本机取到了已归档的 2023级1班,与句子里说的班级无关。)
|
||||
const mentioned = context.classes.find((item) => text.includes(item.name));
|
||||
const classId = mentioned?.id ?? context.classes[0]?.id ?? '';
|
||||
if (!classId) return { action: 'unknown', params: {}, confidence: 0.1, reason: '当前账号没有可发布的班级' };
|
||||
const course = context.courses.find((item) => text.includes(item.name));
|
||||
|
||||
if (/作业|布置|卷子|练习|预习|背诵/.test(text)) {
|
||||
return {
|
||||
action: 'create_homework',
|
||||
params: {
|
||||
classId,
|
||||
courseId: course?.id ?? null,
|
||||
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
|
||||
content: text,
|
||||
assignDate: dayKeyLocal(new Date()),
|
||||
},
|
||||
confidence: 0.55,
|
||||
};
|
||||
}
|
||||
if (/通知|告知|提醒|传达/.test(text)) {
|
||||
return {
|
||||
action: 'create_notification',
|
||||
params: {
|
||||
classId,
|
||||
title: text.length <= 40 ? text : `${text.slice(0, 38)}…`,
|
||||
content: text,
|
||||
priority: /紧急|立即|马上/.test(text) ? 'URGENT' : 'NORMAL',
|
||||
},
|
||||
confidence: 0.55,
|
||||
};
|
||||
}
|
||||
if (/叫|找|来一趟|办公室/.test(text)) {
|
||||
const matched = /\S{2,4}(?=同学|来|到)/.exec(text);
|
||||
return {
|
||||
action: 'call_student',
|
||||
params: { classId, studentName: matched?.[0] ?? '', message: text },
|
||||
confidence: 0.4,
|
||||
reason: '离线示例解析只按关键词猜测学生姓名',
|
||||
};
|
||||
}
|
||||
return { action: 'unknown', params: {}, confidence: 0.2, reason: '没听懂要做什么' };
|
||||
}
|
||||
|
||||
async function callOpenAi(
|
||||
input: AiInterpretInput,
|
||||
extension: { baseUrl: string; model: string; apiKey: string; prompt: string },
|
||||
): Promise<z.infer<typeof modelReplySchema>> {
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(`${extension.baseUrl.replace(/\/+$/, '')}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
authorization: `Bearer ${extension.apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: extension.model || 'gpt-4o-mini',
|
||||
temperature: 0.2,
|
||||
response_format: { type: 'json_object' },
|
||||
messages: [
|
||||
{ role: 'system', content: extension.prompt },
|
||||
{ role: 'user', content: input.text },
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(DEFAULT_TIMEOUT_MS),
|
||||
});
|
||||
} catch (error) {
|
||||
throw new ApiError(504, 'AI_TIMEOUT', `无法连接大模型服务:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw ApiError.badRequest(`大模型服务返回 HTTP ${response.status}(请检查服务地址、模型名与密钥)`);
|
||||
}
|
||||
|
||||
const body = (await response.json()) as { choices?: { message?: { content?: string } }[] };
|
||||
const content = body.choices?.[0]?.message?.content ?? '';
|
||||
try {
|
||||
return modelReplySchema.parse(JSON.parse(content));
|
||||
} catch {
|
||||
logger.warn(`大模型返回无法解析:${content.slice(0, 200)}`);
|
||||
throw new ApiError(502, 'AI_INVALID_OUTPUT', '大模型返回的内容不是可用的指令,请换一种说法');
|
||||
}
|
||||
}
|
||||
|
||||
/** 把校验通过的参数解析成"给人看的预览"(班级/科目/学生名都换成中文) */
|
||||
async function buildPreview(
|
||||
action: AiActionKind,
|
||||
params: Record<string, unknown>,
|
||||
): Promise<AiInterpretResult['preview']> {
|
||||
const warnings: string[] = [];
|
||||
const classId = typeof params.classId === 'string' ? params.classId : '';
|
||||
const classRecord = classId
|
||||
? await prisma.class.findUnique({ where: { id: classId }, select: { name: true } })
|
||||
: null;
|
||||
|
||||
if (action === 'create_homework') {
|
||||
const courseId = typeof params.courseId === 'string' ? params.courseId : '';
|
||||
const course = courseId
|
||||
? await prisma.course.findUnique({ where: { id: courseId }, select: { name: true } })
|
||||
: null;
|
||||
if (!course) warnings.push('没有指定科目:只有班主任/管理员能发不指定科目的作业');
|
||||
return {
|
||||
summary: `给${classRecord?.name ?? '未知班级'}布置作业:${String(params.title ?? '')}`,
|
||||
className: classRecord?.name ?? null,
|
||||
courseName: course?.name ?? null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'create_notification') {
|
||||
return {
|
||||
summary: `给${classRecord?.name ?? '未知班级'}发通知:${String(params.title ?? '')}`,
|
||||
className: classRecord?.name ?? null,
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
if (action === 'call_student') {
|
||||
const studentName = typeof params.studentName === 'string' ? params.studentName : '';
|
||||
const student = studentName && classId
|
||||
? await prisma.student.findFirst({
|
||||
where: { classId, name: studentName },
|
||||
select: { id: true, name: true },
|
||||
})
|
||||
: null;
|
||||
if (!student) warnings.push(studentName ? `没找到学生「${studentName}」` : '没识别出学生姓名');
|
||||
return {
|
||||
summary: `叫${student?.name ?? (studentName || '某位同学')}到老师这里`,
|
||||
className: classRecord?.name ?? null,
|
||||
studentName: student?.name ?? (studentName || null),
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return { summary: '没听懂要做什么,请换一种说法', warnings: ['助手没有解析出可用动作'] };
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/ai/interpret - 解析一句中文,返回**待确认**的结构化动作。
|
||||
* 绝不写业务表;只写一条 `AiAudit`(谁在什么时候让助手做了什么)。
|
||||
*/
|
||||
export async function interpret(user: TokenPayload, input: AiInterpretInput): Promise<AiInterpretResult> {
|
||||
const settings = await getAiSettings();
|
||||
if (!settings.enabled) {
|
||||
throw new ApiError(400, 'AI_NOT_CONFIGURED', '助手功能还没开启,请管理员在「大模型设置」里打开');
|
||||
}
|
||||
if (settings.provider === 'openai' && (!settings.hasApiKey || !settings.baseUrl)) {
|
||||
throw new ApiError(400, 'AI_NOT_CONFIGURED', '还没配置大模型服务地址或密钥,请管理员在「大模型设置」里填写');
|
||||
}
|
||||
|
||||
const context = await resolveContext(user, input.classId);
|
||||
const reply =
|
||||
settings.provider === 'mock'
|
||||
? mockInterpret(input.text, context)
|
||||
: await callOpenAi(input, {
|
||||
baseUrl: settings.baseUrl,
|
||||
model: settings.model,
|
||||
apiKey: decryptSecret(await getSetting(SETTING_KEYS.aiApiKey)),
|
||||
prompt: buildSystemPrompt(context, settings.systemPromptExtra),
|
||||
});
|
||||
|
||||
// 用与手动入口同一份 zod 再校验一次:模型给的东西**不可信**
|
||||
let action: AiActionKind = 'unknown';
|
||||
let params: Record<string, unknown> = {};
|
||||
const warnings: string[] = [];
|
||||
try {
|
||||
if (reply.action === 'create_homework') {
|
||||
const parsed = homeworkParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
|
||||
action = 'create_homework';
|
||||
params = parsed as unknown as Record<string, unknown>;
|
||||
} else if (reply.action === 'create_notification') {
|
||||
const parsed = notificationParamsSchema.parse({
|
||||
...reply.params,
|
||||
classId: reply.params.classId ?? input.classId,
|
||||
});
|
||||
action = 'create_notification';
|
||||
params = parsed as unknown as Record<string, unknown>;
|
||||
} else if (reply.action === 'call_student') {
|
||||
const parsed = callParamsSchema.parse({ ...reply.params, classId: reply.params.classId ?? input.classId });
|
||||
action = 'call_student';
|
||||
params = parsed as unknown as Record<string, unknown>;
|
||||
}
|
||||
} catch (error) {
|
||||
warnings.push('助手给出的参数不完整,请手动补全后再发布');
|
||||
logger.warn(`助手参数校验失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
const preview = await buildPreview(action, params);
|
||||
preview.warnings = [...preview.warnings, ...warnings];
|
||||
if (settings.provider === 'mock') preview.warnings.push('当前是离线示例解析(未配置真实模型),请仔细核对');
|
||||
|
||||
await prisma.aiAudit.create({
|
||||
data: {
|
||||
userId: user.sub,
|
||||
prompt: input.text,
|
||||
intent: action,
|
||||
status: 'pending',
|
||||
message: preview.summary,
|
||||
rawReply: JSON.stringify(reply).slice(0, 2000),
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
action,
|
||||
params,
|
||||
preview,
|
||||
confidence: reply.confidence ?? 0.5,
|
||||
needConfirm: true,
|
||||
rawReply: null,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/ai/audit - 助手使用审计(最近 N 条) */
|
||||
export async function listAudit(limit = 50): Promise<AiAuditDto[]> {
|
||||
const rows = await prisma.aiAudit.findMany({ orderBy: { createdAt: 'desc' }, take: limit });
|
||||
// `AiAudit.userId` 是普通字符串列(schema 里没有建 User 关系),所以姓名另外查一次
|
||||
const userIds = [...new Set(rows.map((row) => row.userId))];
|
||||
const users = userIds.length
|
||||
? await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, name: true } })
|
||||
: [];
|
||||
return rows.map((row) => ({
|
||||
id: row.id,
|
||||
userId: row.userId,
|
||||
userName: users.find((user) => user.id === row.userId)?.name ?? '',
|
||||
prompt: row.prompt,
|
||||
intent: row.intent,
|
||||
status: row.status,
|
||||
message: row.message,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
}));
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -25,13 +25,18 @@ router.use(authenticate());
|
||||
*/
|
||||
|
||||
/** GET /api/archives - 届别列表 */
|
||||
router.get('/', requireRole('ADMIN'), validate({ query: listArchivesQuerySchema }), async (req, res) => {
|
||||
router.get(
|
||||
'/',
|
||||
requirePermission('archive:manage'),
|
||||
validate({ query: listArchivesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/archives/summary - 归档页顶部汇总 + 可归档的届别建议 */
|
||||
router.get('/summary', requireRole('ADMIN'), async (req, res) => {
|
||||
router.get('/summary', requirePermission('archive:manage'), async (req, res) => {
|
||||
sendOk(
|
||||
res,
|
||||
{
|
||||
@@ -43,16 +48,21 @@ router.get('/summary', requireRole('ADMIN'), async (req, res) => {
|
||||
});
|
||||
|
||||
/** GET /api/archives/:id - 某届详情(班级 + 毕业生 + 转出的学生) */
|
||||
router.get('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||
router.get(
|
||||
'/:id',
|
||||
requirePermission('archive:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/archives/:id/classes/:classId/content - 归档班级的作业与通知(只读) */
|
||||
router.get(
|
||||
'/:id/classes/:classId/content',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('archive:manage'),
|
||||
validate({ params: archiveClassParamSchema, query: archivedContentQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -67,19 +77,29 @@ router.get(
|
||||
);
|
||||
|
||||
/** POST /api/archives - 执行毕业归档(按届别整届归档) */
|
||||
router.post('/', requireRole('ADMIN'), validate({ body: createArchiveSchema }), async (req, res) => {
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('archive:manage'),
|
||||
validate({ body: createArchiveSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await archiveService.createArchive(user, validatedBody(req));
|
||||
sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */
|
||||
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('archive:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await archiveService.deleteArchive(user, id);
|
||||
sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const archivesModule = defineModule({
|
||||
name: 'archives',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
|
||||
@@ -13,7 +13,7 @@ router.use(authenticate());
|
||||
* POST /api/calls - 叫人(老师点名让学生过来)
|
||||
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
|
||||
*/
|
||||
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createCallSchema }), async (req, res) => {
|
||||
router.post('/', requirePermission('call:create'), validate({ body: createCallSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import {
|
||||
validate,
|
||||
validatedBody,
|
||||
@@ -56,7 +56,7 @@ router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) =>
|
||||
*/
|
||||
router.post(
|
||||
'/promote',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ body: promoteClassesSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -73,7 +73,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
|
||||
});
|
||||
|
||||
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
|
||||
router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), async (req, res) => {
|
||||
router.post('/', requirePermission('class:manage'), validate({ body: createClassSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
|
||||
});
|
||||
@@ -81,7 +81,7 @@ router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), as
|
||||
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema, body: updateClassSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -93,7 +93,7 @@ router.patch(
|
||||
/** DELETE /api/classes/:id - 删除班级 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -109,7 +109,7 @@ router.delete(
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/class-account',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema, body: updateClassAccountSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -128,7 +128,7 @@ router.patch(
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/head-teacher',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -147,7 +147,7 @@ router.patch(
|
||||
*/
|
||||
router.put(
|
||||
'/:id/subject-teachers',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -244,7 +244,7 @@ router.get('/:id/students', validate({ params: idParamSchema }), async (req, res
|
||||
/** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */
|
||||
router.post(
|
||||
'/:id/students',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ params: idParamSchema, body: addStudentSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -256,7 +256,7 @@ router.post(
|
||||
/** DELETE /api/classes/:id/students/:studentId - 移出学生(回到「未分班」,名单记录保留) */
|
||||
router.delete(
|
||||
'/:id/students/:studentId',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ params: classStudentParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
clientCommandAckSchema,
|
||||
clientHeartbeatSchema,
|
||||
createClientCommandSchema,
|
||||
listClientDevicesQuerySchema,
|
||||
pushClientUpdateSchema,
|
||||
type ClientCommandAckInput,
|
||||
type ClientHeartbeatInput,
|
||||
type CreateClientCommandInput,
|
||||
type ListClientDevicesQuery,
|
||||
type PushClientUpdateInput,
|
||||
} from './clients.schemas.js';
|
||||
import * as clientService from './clients.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* POST /api/clients/heartbeat - 客户端每 30 秒上报一次
|
||||
*
|
||||
* **不挂权限键**:上报方是教室里的班级端会话(角色 CLASS_DEVICE,权限集为空)。
|
||||
* 返回值里同时带回"要不要更新 + 安装包信息 + 一条待执行命令",客户端一次请求全拿到。
|
||||
*/
|
||||
router.post('/heartbeat', validate({ body: clientHeartbeatSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<ClientHeartbeatInput>(req);
|
||||
sendOk(res, await clientService.heartbeat(user, input, req.ip ?? ''), '心跳已上报');
|
||||
});
|
||||
|
||||
/** POST /api/clients/commands/:id/ack - 执行回执(同样不挂权限键,靠 deviceKey 归属校验) */
|
||||
router.post(
|
||||
'/commands/:id/ack',
|
||||
validate({ params: idParamSchema, body: clientCommandAckSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const input = validatedBody<ClientCommandAckInput>(req);
|
||||
sendOk(res, await clientService.ackCommand(id, input), '回执已记录');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/clients/commands - 下发远控命令(关机 / 重启 / 注销 / 锁屏)
|
||||
*
|
||||
* 权限键 `remote:control`(默认只有管理员有):这是唯一能远程动别人电脑的接口。
|
||||
*/
|
||||
router.post(
|
||||
'/commands',
|
||||
requirePermission('remote:control'),
|
||||
validate({ body: createClientCommandSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<CreateClientCommandInput>(req);
|
||||
sendCreated(res, await clientService.createCommands(user, input), '命令已下发,将在这台电脑下次心跳时送达');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/clients/devices?classId= - Web 端设备列表(版本 / 在线 / 待执行命令) */
|
||||
router.get(
|
||||
'/devices',
|
||||
requirePermission('client:manage'),
|
||||
validate({ query: listClientDevicesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const query = validatedQuery<ListClientDevicesQuery>(req);
|
||||
sendOk(res, await clientService.listDevices({ classId: query.classId }), '获取设备列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/clients/updates - 统一下发客户端更新 */
|
||||
router.post(
|
||||
'/updates',
|
||||
requirePermission('client:update-push'),
|
||||
validate({ body: pushClientUpdateSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<PushClientUpdateInput>(req);
|
||||
sendOk(res, await clientService.pushUpdate(user, input), '更新已下发,将在这台电脑下次心跳时送达');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/clients/devices/:id - 移除设备记录(换机 / 旧机器清理) */
|
||||
router.delete(
|
||||
'/devices/:id',
|
||||
requirePermission('client:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await clientService.deleteDevice(id), '设备已移除');
|
||||
},
|
||||
);
|
||||
|
||||
export const clientsModule = defineModule({
|
||||
name: 'clients',
|
||||
basePath: '/clients',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,62 @@
|
||||
import { CLIENT_COMMAND_KINDS, CLIENT_COMMAND_RESULT_MAX, type ClientCommandKind } from '@classhelper/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 远控命令种类。用 `CLIENT_COMMAND_KINDS` 而不是再写一遍字面量数组:
|
||||
* 常量是 `readonly`,`z.enum` 要的是可变元组,所以这里显式转一次类型。
|
||||
*/
|
||||
const clientCommandKindSchema = z.enum(
|
||||
CLIENT_COMMAND_KINDS as unknown as [ClientCommandKind, ...ClientCommandKind[]],
|
||||
);
|
||||
|
||||
/** 设备标识(客户端主进程生成并持久化的 UUID) */
|
||||
const deviceKeySchema = z.string().trim().min(8, '设备标识不合法').max(80, '设备标识不合法');
|
||||
|
||||
/** `POST /api/clients/heartbeat`:客户端每 30 秒一次的上报 */
|
||||
export const clientHeartbeatSchema = z.object({
|
||||
deviceKey: deviceKeySchema,
|
||||
name: z.string().trim().max(60).optional(),
|
||||
clientVersion: z.string().trim().max(40).optional(),
|
||||
platform: z.string().trim().max(40).optional(),
|
||||
pendingVersion: z.string().trim().max(40).nullable().optional(),
|
||||
stateNote: z.string().trim().max(120).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* `POST /api/clients/commands/:id/ack`:执行回执。
|
||||
*
|
||||
* 带上 `deviceKey` 是为了"命令回执必须是那台机器签的字":一个班的班级端令牌
|
||||
* 全班共用,光凭命令 id 无法区分是班里哪一台。
|
||||
*/
|
||||
export const clientCommandAckSchema = z.object({
|
||||
deviceKey: deviceKeySchema,
|
||||
ok: z.boolean(),
|
||||
result: z.string().trim().max(CLIENT_COMMAND_RESULT_MAX).optional(),
|
||||
});
|
||||
|
||||
/** `POST /api/clients/commands`:给选中的电脑下发远控命令 */
|
||||
export const createClientCommandSchema = z.object({
|
||||
deviceIds: z.array(z.string().min(1)).min(1, '请选择要操作的电脑').max(200, '一次最多操作 200 台'),
|
||||
kind: clientCommandKindSchema,
|
||||
note: z.string().trim().max(80, '备注最多 80 个字').optional(),
|
||||
});
|
||||
|
||||
/** `GET /api/clients/devices?classId=` */
|
||||
export const listClientDevicesQuerySchema = z.object({
|
||||
classId: z.string().trim().min(1).optional(),
|
||||
});
|
||||
|
||||
/** `POST /api/clients/updates`:推送更新(按设备 / 按班级 / 全部) */
|
||||
export const pushClientUpdateSchema = z.object({
|
||||
deviceIds: z.array(z.string().min(1)).max(500).optional(),
|
||||
classIds: z.array(z.string().min(1)).max(200).optional(),
|
||||
all: z.boolean().optional(),
|
||||
version: z.string().trim().max(40).nullable().optional(),
|
||||
clear: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type ClientHeartbeatInput = z.infer<typeof clientHeartbeatSchema>;
|
||||
export type ClientCommandAckInput = z.infer<typeof clientCommandAckSchema>;
|
||||
export type CreateClientCommandInput = z.infer<typeof createClientCommandSchema>;
|
||||
export type ListClientDevicesQuery = z.infer<typeof listClientDevicesQuerySchema>;
|
||||
export type PushClientUpdateInput = z.infer<typeof pushClientUpdateSchema>;
|
||||
@@ -0,0 +1,372 @@
|
||||
import {
|
||||
CLIENT_COMMAND_EXPIRE_MS,
|
||||
CLIENT_COMMAND_LABELS,
|
||||
CLIENT_ONLINE_WINDOW_MS,
|
||||
type ClientCommandAckRequest,
|
||||
type ClientCommandDto,
|
||||
type ClientCommandKind,
|
||||
type ClientDeviceDto,
|
||||
type ClientHeartbeatResponse,
|
||||
type ClientPushResult,
|
||||
type ClientTargetResult,
|
||||
} from '@classhelper/shared';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import * as clientReleases from '../update/client-releases.service.js';
|
||||
import type { ClientHeartbeatInput, CreateClientCommandInput, PushClientUpdateInput } from './clients.schemas.js';
|
||||
|
||||
/**
|
||||
* ClassHelper 桌面客户端设备的心跳 / 远控命令 / 更新下发。
|
||||
*
|
||||
* 范式(AGENTS.md §5 第 67 条):**DB 记状态 + 客户端定时拉取** ——
|
||||
* 与 `IntegrationDevice.requestScheduleReport` 同一套路,不新增长连接握手。
|
||||
* 因此本文件里没有任何"推送"动作:所有下发都只是改 `ClientDevice` / `ClientCommand` 的字段,
|
||||
* 由客户端下一次心跳(30 秒内)带走。
|
||||
*/
|
||||
|
||||
type DeviceRow = {
|
||||
id: string;
|
||||
classId: string | null;
|
||||
deviceKey: string;
|
||||
name: string;
|
||||
clientVersion: string;
|
||||
platform: string;
|
||||
autoUpdate: boolean;
|
||||
updateTargetVersion: string;
|
||||
pendingVersion: string;
|
||||
lastHeartbeatAt: Date | null;
|
||||
lastIp: string;
|
||||
stateNote: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
class?: { id: string; name: string } | null;
|
||||
};
|
||||
|
||||
type CommandRow = {
|
||||
id: string;
|
||||
deviceId: string;
|
||||
kind: string;
|
||||
payload: string;
|
||||
createdAt: Date;
|
||||
expiresAt: Date | null;
|
||||
ackedAt: Date | null;
|
||||
result: string;
|
||||
};
|
||||
|
||||
/** 备注存 JSON 文本(`payload` 字段是预留结构,见 schema 注释) */
|
||||
function encodePayload(note: string): string {
|
||||
return JSON.stringify({ note });
|
||||
}
|
||||
|
||||
function decodePayload(raw: string): string {
|
||||
if (!raw) return '';
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { note?: unknown };
|
||||
return typeof parsed.note === 'string' ? parsed.note : '';
|
||||
} catch {
|
||||
// 兼容手工写进库的纯文本
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
function toCommandDto(row: CommandRow): ClientCommandDto {
|
||||
const kind = row.kind as ClientCommandKind;
|
||||
const label = CLIENT_COMMAND_LABELS[kind] ?? row.kind;
|
||||
const note = decodePayload(row.payload);
|
||||
return {
|
||||
id: row.id,
|
||||
deviceId: row.deviceId,
|
||||
kind,
|
||||
payload: note ? `${label}(${note})` : label,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
expiresAt: row.expiresAt ? row.expiresAt.toISOString() : null,
|
||||
ackedAt: row.ackedAt ? row.ackedAt.toISOString() : null,
|
||||
result: row.result,
|
||||
};
|
||||
}
|
||||
|
||||
/** 待执行的命令:未回执、未撤销、未过期 */
|
||||
function pendingCommandWhere(deviceIds: string[], now: Date) {
|
||||
return {
|
||||
deviceId: { in: deviceIds },
|
||||
ackedAt: null,
|
||||
cancelledAt: null,
|
||||
OR: [{ expiresAt: null }, { expiresAt: { gt: now } }],
|
||||
};
|
||||
}
|
||||
|
||||
function summarize(results: ClientTargetResult[]): ClientPushResult {
|
||||
const succeeded = results.filter((item) => item.ok).length;
|
||||
return { total: results.length, succeeded, failed: results.length - succeeded, results };
|
||||
}
|
||||
|
||||
/** 班级端会话(含班级账号)的代表主体就是那个班;教师/管理员个人电脑不绑定班级 */
|
||||
function resolveClassId(user: TokenPayload): string | null {
|
||||
return user.classSession || user.role === 'CLASS_DEVICE' ? user.sub : null;
|
||||
}
|
||||
|
||||
async function findPendingCommand(deviceId: string, now: Date): Promise<CommandRow | null> {
|
||||
const rows = (await prisma.clientCommand.findMany({
|
||||
where: pendingCommandWhere([deviceId], now),
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 1,
|
||||
})) as unknown as CommandRow[];
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clients/heartbeat - 客户端上报心跳,服务端在同一响应里带回
|
||||
* 「要不要更新 + 目标版本的安装包信息 + 一条待执行命令」。
|
||||
*/
|
||||
export async function heartbeat(
|
||||
user: TokenPayload,
|
||||
input: ClientHeartbeatInput,
|
||||
ip: string,
|
||||
): Promise<ClientHeartbeatResponse> {
|
||||
const now = new Date();
|
||||
const heartbeatAt = {
|
||||
classId: resolveClassId(user),
|
||||
name: input.name?.trim() || '未命名设备',
|
||||
clientVersion: input.clientVersion?.trim() ?? '',
|
||||
platform: input.platform?.trim() ?? '',
|
||||
pendingVersion: input.pendingVersion?.trim() ?? '',
|
||||
stateNote: input.stateNote?.trim() ?? '',
|
||||
lastHeartbeatAt: now,
|
||||
lastIp: ip,
|
||||
};
|
||||
|
||||
const device = (await prisma.clientDevice.upsert({
|
||||
where: { deviceKey: input.deviceKey },
|
||||
create: { deviceKey: input.deviceKey, ...heartbeatAt },
|
||||
update: heartbeatAt,
|
||||
})) as unknown as DeviceRow;
|
||||
|
||||
const targetVersion = device.updateTargetVersion.trim();
|
||||
const [command, update] = await Promise.all([
|
||||
findPendingCommand(device.id, now),
|
||||
targetVersion ? clientReleases.resolveUpdateInfo(targetVersion) : Promise.resolve(null),
|
||||
]);
|
||||
|
||||
return {
|
||||
deviceId: device.id,
|
||||
serverTime: now.toISOString(),
|
||||
updateTargetVersion: targetVersion || null,
|
||||
update,
|
||||
command: command ? toCommandDto(command) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** POST /api/clients/commands/:id/ack - 客户端执行完远控命令后的回执 */
|
||||
export async function ackCommand(
|
||||
commandId: string,
|
||||
input: ClientCommandAckRequest,
|
||||
): Promise<ClientCommandDto> {
|
||||
const command = (await prisma.clientCommand.findUnique({
|
||||
where: { id: commandId },
|
||||
include: { device: { select: { deviceKey: true, name: true } } },
|
||||
})) as (CommandRow & { device: { deviceKey: string; name: string } }) | null;
|
||||
if (!command) throw ApiError.notFound('命令不存在');
|
||||
|
||||
if (command.device.deviceKey !== input.deviceKey) {
|
||||
throw ApiError.forbidden('这条命令不是发给这台电脑的');
|
||||
}
|
||||
// 幂等:客户端重试回执不报错,也不覆盖第一次的结果
|
||||
if (command.ackedAt) return toCommandDto(command);
|
||||
|
||||
const detail = input.result?.trim();
|
||||
const result = `${input.ok ? '已执行' : '执行失败'}${detail ? `:${detail}` : ''}`;
|
||||
const updated = (await prisma.clientCommand.update({
|
||||
where: { id: commandId },
|
||||
data: { ackedAt: new Date(), result },
|
||||
})) as unknown as CommandRow;
|
||||
|
||||
logger.info(`远控命令回执:${command.device.name || command.device.deviceKey} ${result}`);
|
||||
return toCommandDto(updated);
|
||||
}
|
||||
|
||||
/** GET /api/clients/devices - Web 端「客户端设备」列表(含待执行命令) */
|
||||
export async function listDevices(options: { classId?: string } = {}): Promise<ClientDeviceDto[]> {
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: options.classId ? { classId: options.classId } : {},
|
||||
include: { class: { select: { id: true, name: true } } },
|
||||
orderBy: [{ lastHeartbeatAt: 'desc' }, { createdAt: 'desc' }],
|
||||
})) as unknown as DeviceRow[];
|
||||
|
||||
const now = new Date();
|
||||
const commands = (await prisma.clientCommand.findMany({
|
||||
where: pendingCommandWhere(
|
||||
rows.map((row) => row.id),
|
||||
now,
|
||||
),
|
||||
orderBy: { createdAt: 'asc' },
|
||||
})) as unknown as CommandRow[];
|
||||
|
||||
// 每台设备只留最早的一条(与心跳取的是同一条,界面上看到的才是真会执行的那条)
|
||||
const pendingByDevice = new Map<string, CommandRow>();
|
||||
for (const command of commands) {
|
||||
if (!pendingByDevice.has(command.deviceId)) pendingByDevice.set(command.deviceId, command);
|
||||
}
|
||||
|
||||
return rows.map((row) => {
|
||||
const heartbeat = row.lastHeartbeatAt;
|
||||
const pending = pendingByDevice.get(row.id);
|
||||
return {
|
||||
id: row.id,
|
||||
classId: row.classId,
|
||||
className: row.class?.name ?? null,
|
||||
deviceKey: row.deviceKey,
|
||||
name: row.name,
|
||||
clientVersion: row.clientVersion,
|
||||
platform: row.platform,
|
||||
autoUpdate: row.autoUpdate,
|
||||
updateTargetVersion: row.updateTargetVersion,
|
||||
pendingVersion: row.pendingVersion,
|
||||
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
||||
lastIp: row.lastIp,
|
||||
stateNote: row.stateNote,
|
||||
online: Boolean(heartbeat && now.getTime() - heartbeat.getTime() < CLIENT_ONLINE_WINDOW_MS),
|
||||
pendingCommand: pending ? toCommandDto(pending) : null,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/** 命令去重后的目标设备(保持请求顺序,便于结果一一对应) */
|
||||
async function findTargetDevices(deviceIds: string[]): Promise<Map<string, DeviceRow>> {
|
||||
const wanted = [...new Set(deviceIds)];
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: { id: { in: wanted } },
|
||||
select: { id: true, name: true, deviceKey: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
return new Map(rows.map((row) => [row.id, row]));
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clients/commands - 下发远控命令(关机/重启/注销/锁屏)
|
||||
*
|
||||
* 同一台机器上还没执行的旧命令会被**自动撤销**:否则"先关机、后重启"会排队执行,
|
||||
* 机器刚关就被下一条叫起来,看起来像失控。客户端侧还有一次弹窗二次确认。
|
||||
*/
|
||||
export async function createCommands(
|
||||
user: TokenPayload,
|
||||
input: CreateClientCommandInput,
|
||||
): Promise<ClientPushResult> {
|
||||
const targets = await findTargetDevices(input.deviceIds);
|
||||
const expiresAt = new Date(Date.now() + CLIENT_COMMAND_EXPIRE_MS);
|
||||
const note = input.note?.trim() ?? '';
|
||||
const results: ClientTargetResult[] = [];
|
||||
|
||||
for (const deviceId of [...new Set(input.deviceIds)]) {
|
||||
const device = targets.get(deviceId);
|
||||
if (!device) {
|
||||
results.push({
|
||||
deviceId,
|
||||
deviceName: '',
|
||||
ok: false,
|
||||
code: 'DEVICE_NOT_FOUND',
|
||||
message: '设备不存在(可能已被移除)',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
await prisma.clientCommand.updateMany({
|
||||
where: { deviceId, ackedAt: null, cancelledAt: null },
|
||||
data: { cancelledAt: new Date(), result: '已被新的命令取代' },
|
||||
});
|
||||
await prisma.clientCommand.create({
|
||||
data: { deviceId, kind: input.kind, payload: encodePayload(note), createdBy: user.sub, expiresAt },
|
||||
});
|
||||
results.push({ deviceId, deviceName: device.name, ok: true });
|
||||
}
|
||||
|
||||
const summary = summarize(results);
|
||||
logger.info(
|
||||
`下发远控命令 ${input.kind}:目标 ${summary.total} 台,成功 ${summary.succeeded} 台(发起人=${user.username})`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** 更新推送的目标集合:优先设备,其次班级,最后"全部" */
|
||||
async function resolveUpdateTargets(input: PushClientUpdateInput): Promise<DeviceRow[]> {
|
||||
const deviceIds = [...new Set(input.deviceIds ?? [])];
|
||||
if (deviceIds.length > 0) {
|
||||
const rows = (await prisma.clientDevice.findMany({
|
||||
where: { id: { in: deviceIds } },
|
||||
select: { id: true, name: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
return rows;
|
||||
}
|
||||
|
||||
const classIds = [...new Set(input.classIds ?? [])];
|
||||
if (classIds.length > 0) {
|
||||
return (await prisma.clientDevice.findMany({
|
||||
where: { classId: { in: classIds } },
|
||||
select: { id: true, name: true },
|
||||
})) as unknown as DeviceRow[];
|
||||
}
|
||||
|
||||
if (input.all) {
|
||||
return (await prisma.clientDevice.findMany({ select: { id: true, name: true } })) as unknown as DeviceRow[];
|
||||
}
|
||||
|
||||
throw ApiError.badRequest('请选择要推送的设备、班级,或选择"全部设备"');
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/clients/updates - 统一下发客户端更新
|
||||
*
|
||||
* `version` 省略时用服务端缓存里最新的那个版本;`clear: true` 取消下发。
|
||||
* 只改 `updateTargetVersion`,真正的下载与安装由客户端按自己的空闲判据决定
|
||||
* (上课时段 / 窗口可见都不装)。
|
||||
*/
|
||||
export async function pushUpdate(
|
||||
user: TokenPayload,
|
||||
input: PushClientUpdateInput,
|
||||
): Promise<ClientPushResult> {
|
||||
const clear = input.clear === true;
|
||||
let target = '';
|
||||
|
||||
if (!clear) {
|
||||
target = (input.version ?? '').trim() || (await clientReleases.latestReleaseVersion());
|
||||
if (!target) {
|
||||
throw ApiError.badRequest('服务端还没有缓存任何客户端安装包,请先下载或登记一份再推送');
|
||||
}
|
||||
const info = await clientReleases.resolveUpdateInfo(target);
|
||||
if (!info) throw ApiError.badRequest(`服务端缺少 v${target} 的安装包文件,请重新下载或登记`);
|
||||
}
|
||||
|
||||
const devices = await resolveUpdateTargets(input);
|
||||
if (devices.length === 0) throw ApiError.badRequest('没有匹配到任何客户端设备');
|
||||
|
||||
const results: ClientTargetResult[] = [];
|
||||
for (const device of devices) {
|
||||
await prisma.clientDevice.update({
|
||||
where: { id: device.id },
|
||||
data: { updateTargetVersion: target },
|
||||
});
|
||||
results.push({ deviceId: device.id, deviceName: device.name, ok: true });
|
||||
}
|
||||
|
||||
const summary = summarize(results);
|
||||
logger.info(
|
||||
clear
|
||||
? `取消更新下发:${summary.total} 台设备(发起人=${user.username})`
|
||||
: `推送客户端更新 v${target}:${summary.total} 台设备(发起人=${user.username})`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/** DELETE /api/clients/devices/:id - 移除一台设备记录(换机 / 旧机器清理) */
|
||||
export async function deleteDevice(deviceId: string): Promise<{ id: string }> {
|
||||
const device = await prisma.clientDevice.findUnique({
|
||||
where: { id: deviceId },
|
||||
select: { id: true, name: true, deviceKey: true },
|
||||
});
|
||||
if (!device) throw ApiError.notFound('设备不存在');
|
||||
await prisma.clientDevice.delete({ where: { id: deviceId } });
|
||||
logger.info(`移除客户端设备:${device.name || device.deviceKey}`);
|
||||
return { id: deviceId };
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -26,7 +26,7 @@ router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) =>
|
||||
/** POST /api/courses - 新增课程 */
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ body: createCourseSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -37,7 +37,7 @@ router.post(
|
||||
/** PATCH /api/courses/:id - 修改课程 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ params: idParamSchema, body: updateCourseSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -49,7 +49,7 @@ router.patch(
|
||||
/** DELETE /api/courses/:id - 删除课程 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { CourseDto } from '@classhelper/shared';
|
||||
import type { CourseDto } from '@classhelper/shared';
|
||||
import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
@@ -21,6 +21,28 @@ export async function listCourses(user: TokenPayload, classId?: string): Promise
|
||||
export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise<CourseDto> {
|
||||
await assertCanManageSchedule(user, input.classId);
|
||||
|
||||
/*
|
||||
* 同名去重(一个班级里科目名唯一)。
|
||||
*
|
||||
* 这条不是"友好提示",是**权限护栏**:`course.create` 会把创建者写成这门课的任课老师,
|
||||
* 于是只要能再建一门同名的「数学」,创建者就凭空成了「数学」的老师 ——
|
||||
* 后面那道 `assertCanManageSubjectContent` 的科目门随即形同虚设
|
||||
* (可以布置该科作业、录入该科成绩、删该科内容)。这就是"非该科科任老师
|
||||
* 能布置其他科作业"的入口。
|
||||
*
|
||||
* 处理办法是把**已有**课程的 id 回给前端(409 + details.courseId),让前端复用它;
|
||||
* 权限判定于是回到"你是不是这门课的任课老师"这个唯一事实上。
|
||||
*/
|
||||
const existing = await prisma.course.findFirst({
|
||||
where: { classId: input.classId, name: input.name },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ApiError(409, 'COURSE_EXISTS', `该班级已有名为「${input.name}」的课程`, {
|
||||
courseId: existing.id,
|
||||
});
|
||||
}
|
||||
|
||||
const created = await prisma.course.create({
|
||||
data: { name: input.name, classId: input.classId, teacherId: user.sub },
|
||||
include: { teacher: teacherSelect },
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { authenticate, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -22,7 +22,7 @@ import * as databaseService from './database.service.js';
|
||||
/**
|
||||
* 数据库管理(仅管理员):状态检测 / 连接测试 / 备份恢复 / 导入导出 / 定时备份 / 一键切换。
|
||||
*
|
||||
* 改动数据库属于最高危操作,全部接口挂在 requireRole('ADMIN') 之下;
|
||||
* 改动数据库属于最高危操作,全部接口挂在 requirePermission('database:manage') 之下;
|
||||
* 前端入口同样只对 ADMIN 显示(Web 端「数据库管理」页)。
|
||||
*
|
||||
* 一键切换是异步任务:POST /switch 返回 jobId,前端轮询 GET /switch/jobs/:id 展示步骤进度。
|
||||
@@ -31,7 +31,7 @@ import * as databaseService from './database.service.js';
|
||||
const router = Router();
|
||||
|
||||
router.use(authenticate());
|
||||
router.use(requireRole('ADMIN'));
|
||||
router.use(requirePermission('database:manage'));
|
||||
|
||||
/** GET /api/database/status - 当前数据库状态(连接/版本/大小/各表行数/备份与定时配置) */
|
||||
router.get('/status', async (_req, res) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -56,7 +56,7 @@ router.get(
|
||||
/** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */
|
||||
router.get(
|
||||
'/stats',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:read'),
|
||||
validate({ query: statsQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -68,7 +68,7 @@ router.get(
|
||||
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
|
||||
router.get(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:read'),
|
||||
validate({ query: listGradesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -83,7 +83,7 @@ router.get(
|
||||
);
|
||||
|
||||
/** POST /api/grades - 录入单条成绩(仅自己任教科目) */
|
||||
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
|
||||
router.post('/', requirePermission('grade:write'), validate({ body: createGradeSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(
|
||||
res,
|
||||
@@ -100,7 +100,7 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc
|
||||
*/
|
||||
router.patch(
|
||||
'/levels',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:write'),
|
||||
validate({ body: updateGradeLevelsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -112,7 +112,7 @@ router.patch(
|
||||
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */
|
||||
router.post(
|
||||
'/bulk',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:write'),
|
||||
validate({ body: bulkCreateGradeSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -124,7 +124,7 @@ router.post(
|
||||
/** PATCH /api/grades/:id - 修改成绩(含单个改等级) */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:write'),
|
||||
validate({ params: idParamSchema, body: updateGradeSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -136,7 +136,7 @@ router.patch(
|
||||
/** DELETE /api/grades/:id - 删除成绩 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('grade:write'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,16 +1,18 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
bulkCreateHomeworkSchema,
|
||||
createHomeworkSchema,
|
||||
listHomeworkDaysQuerySchema,
|
||||
listHomeworksQuerySchema,
|
||||
updateHomeworkSchema,
|
||||
updateHomeworkStatusSchema,
|
||||
updateHomeworkSubmissionsSchema,
|
||||
type BulkCreateHomeworkInput,
|
||||
type CreateHomeworkInput,
|
||||
type ListHomeworkDaysInput,
|
||||
type UpdateHomeworkInput,
|
||||
@@ -71,10 +73,32 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/homeworks/bulk - 一次给多个班级布置同一份作业(逐班结果)
|
||||
*
|
||||
* 与 POST / 的区别:批量发布是 Web 管理端的"多班同发"工作流,因此这里挂 `homework:write`;
|
||||
* 教室机器的班级账号仍走单班录入(POST /,权限在服务层判,见 AGENTS.md §5 第 20 条)。
|
||||
* 每个班的科目归属依然由 createHomework 里的 assertCanManageSubjectContent 逐个判定 ——
|
||||
* 批量不是绕过科目门的后门(§5 第 5 条)。
|
||||
*/
|
||||
router.post(
|
||||
'/bulk',
|
||||
requirePermission('homework:write'),
|
||||
validate({ body: bulkCreateHomeworkSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await homeworkService.bulkCreateHomeworks(
|
||||
user,
|
||||
req.body as BulkCreateHomeworkInput,
|
||||
);
|
||||
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/homeworks/:id - 修改作业 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('homework:write'),
|
||||
validate({ params: idParamSchema, body: updateHomeworkSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -90,7 +114,7 @@ router.patch(
|
||||
/** DELETE /api/homeworks/:id - 删除作业 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('homework:write'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -37,6 +37,26 @@ export const createHomeworkSchema = z.object({
|
||||
assignDate: dayKeySchema.nullish(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 一次给多个班级布置同一份作业(批量发布)。
|
||||
*
|
||||
* 上限 50 个班是刻意设的:逐班串行落库 + 逐班判定科目归属,
|
||||
* 一批塞几百个班会长时间占住请求。
|
||||
*/
|
||||
export const bulkCreateHomeworkSchema = z.object({
|
||||
classIds: z
|
||||
.array(z.string().min(1))
|
||||
.min(1, '请至少选择一个班级')
|
||||
.max(50, '一次最多发布 50 个班级'),
|
||||
courseId: z.string().min(1).nullish(),
|
||||
/** 按科目名称指定(跨班只能用名称:课程是班级内的,同一门课在每个班是不同的记录) */
|
||||
subjectName: z.string().trim().min(1).max(50).nullish(),
|
||||
title: z.string().trim().min(1, '请输入作业标题').max(120),
|
||||
content: z.string().min(1, '请输入作业内容').max(5000),
|
||||
assignDate: dayKeySchema.nullish(),
|
||||
attachmentUrl: attachmentUrlSchema,
|
||||
});
|
||||
|
||||
export const updateHomeworkSchema = z.object({
|
||||
courseId: z.string().min(1).nullish(),
|
||||
/** 改作业所属日期(把作业挪到另一天) */
|
||||
@@ -62,6 +82,7 @@ export const updateHomeworkSubmissionsSchema = z.object({
|
||||
|
||||
export type ListHomeworkDaysInput = z.infer<typeof listHomeworkDaysQuerySchema>;
|
||||
export type CreateHomeworkInput = z.infer<typeof createHomeworkSchema>;
|
||||
export type BulkCreateHomeworkInput = z.infer<typeof bulkCreateHomeworkSchema>;
|
||||
export type UpdateHomeworkInput = z.infer<typeof updateHomeworkSchema>;
|
||||
export type UpdateHomeworkStatusInput = z.infer<typeof updateHomeworkStatusSchema>;
|
||||
export type UpdateHomeworkSubmissionsInput = z.infer<typeof updateHomeworkSubmissionsSchema>;
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
SOCKET_EVENTS,
|
||||
dayKeyLocal,
|
||||
shiftDayKey,
|
||||
type BulkCreateHomeworkRequest,
|
||||
type BulkPublishClassResult,
|
||||
type BulkPublishResult,
|
||||
type HomeworkDaysDto,
|
||||
type HomeworkDto,
|
||||
type HomeworkStatusDto,
|
||||
@@ -15,9 +18,11 @@ import {
|
||||
isClassDevice,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
|
||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
@@ -182,6 +187,63 @@ export async function createHomework(user: TokenPayload, input: CreateHomeworkIn
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量布置作业:一次给多个班级发同一份内容。
|
||||
*
|
||||
* 逐班串行调用 `createHomework`(复用同一套科目归属断言 + 实时广播 + 班级端归属规则),
|
||||
* **某个班失败只记进结果**:例如批量里含一个"语文由别的老师任教"的班,
|
||||
* 那个班带着「不是你任教的科目」的失败原因回来,其余班照常发布。
|
||||
*
|
||||
* 科目用 `subjectName` 而不是 courseId:课程是班级内的,同一门课在每个班是不同的记录,
|
||||
* 跨班只能用名称逐个查(查不到只让那个班失败,不自动建课)。
|
||||
*/
|
||||
export async function bulkCreateHomeworks(
|
||||
user: TokenPayload,
|
||||
input: BulkCreateHomeworkRequest,
|
||||
): Promise<BulkPublishResult> {
|
||||
const classIds = normalizeBulkClassIds(input.classIds);
|
||||
const results: BulkPublishClassResult[] = [];
|
||||
|
||||
for (const classId of classIds) {
|
||||
try {
|
||||
// 批量跨班只能用科目「名称」定位课程:同一门课在每个班是不同的 Course 记录。
|
||||
// 该班没有这门课时只让这个班失败 —— 不自动建课(否则一次批量会在 N 个班里凭空
|
||||
// 建出课程,还会让发起人在这些班里变成"任课老师",绕过科目维度的写权限)。
|
||||
let courseId = input.courseId ?? null;
|
||||
if (input.subjectName) {
|
||||
const course = await prisma.course.findFirst({
|
||||
where: { classId, name: input.subjectName },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!course) {
|
||||
throw ApiError.badRequest(
|
||||
`该班级没有「${input.subjectName}」这门课程(跨班发布按科目名称匹配,请先让班主任/管理员在本班建课并分配任课老师,或改选其他科目)`,
|
||||
);
|
||||
}
|
||||
courseId = course.id;
|
||||
}
|
||||
|
||||
const dto = await createHomework(user, {
|
||||
classId,
|
||||
courseId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
assignDate: input.assignDate ?? null,
|
||||
attachmentUrl: input.attachmentUrl ?? null,
|
||||
});
|
||||
results.push({ classId, ok: true, id: dto.id });
|
||||
} catch (error) {
|
||||
results.push(toBulkFailure(classId, error, '布置作业'));
|
||||
}
|
||||
}
|
||||
|
||||
const summary = summarizeBulk(results);
|
||||
logger.info(
|
||||
`批量布置作业:目标 ${summary.total} 个班级,成功 ${summary.succeeded} 个,失败 ${summary.failed} 个(发起人=${user.username})`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
export async function updateHomework(
|
||||
user: TokenPayload,
|
||||
homeworkId: string,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { ApiError, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requireAnyPermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -48,7 +48,7 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
|
||||
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
|
||||
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员
|
||||
*/
|
||||
router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
|
||||
router.get('/template', requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'), (req, res) => {
|
||||
const kind = resolveImportKind(req.query.kind);
|
||||
assertImportKindAllowed(kind, getAuthUser(req).role);
|
||||
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
|
||||
@@ -72,7 +72,7 @@ router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
|
||||
*/
|
||||
router.post(
|
||||
'/table/preview',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
|
||||
validate({ body: tableFileSchema }),
|
||||
(req, res) => {
|
||||
const input = validatedBody<TableFileInput>(req);
|
||||
@@ -89,7 +89,7 @@ router.post(
|
||||
*/
|
||||
router.post(
|
||||
'/table/commit',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:schedule', 'import:class-teachers'),
|
||||
validate({ body: tableCommitSchema }),
|
||||
async (req, res) => {
|
||||
const input = validatedBody<TableCommitInput>(req);
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { hashPassword } from '../../lib/password.js';
|
||||
import { resolveUserClassRole } from '../../lib/access.js';
|
||||
import { canImport } from '@classhelper/shared';
|
||||
import { canImport, canManageSubjectContent } from '@classhelper/shared';
|
||||
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
|
||||
|
||||
/**
|
||||
@@ -337,9 +337,19 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
|
||||
const courses = await prisma.course.findMany({
|
||||
where: { classId },
|
||||
select: { id: true, name: true },
|
||||
select: { id: true, name: true, teacherId: true },
|
||||
});
|
||||
|
||||
/*
|
||||
* 科目维度的第二道门(`assertImportAllowed` 只到"班级"这一层)。
|
||||
*
|
||||
* 成绩是**逐行落到具体科目**上的,而班主任并不自动是本班所有科目的老师 ——
|
||||
* 少了这道检查,班主任就能导入别的老师任教科目的成绩,班里会平白多出一份
|
||||
* 不属于那门课的记录(且覆盖语义的 append/overwrite 会把它写进同一张表)。
|
||||
* 判定沿用 shared 的 canManageSubjectContent,与 Web 端单条录入、作业发布**同一份规则**。
|
||||
*/
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
let inserted = 0;
|
||||
@@ -390,6 +400,23 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
if (courseName && !course)
|
||||
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
|
||||
|
||||
// 该科目的成绩能不能由**当前这个人**写(管理员全通;挂了科目就只能是该科任课老师)
|
||||
const canWriteCourse = canManageSubjectContent({
|
||||
classRole,
|
||||
hasSubject: Boolean(course),
|
||||
isSubjectTeacherOfCourse: course ? course.teacherId === user.sub : false,
|
||||
allowClassDevice: false,
|
||||
});
|
||||
if (!canWriteCourse) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: course
|
||||
? `「${course.name}」不是你任教的科目,不能导入该科目的成绩`
|
||||
: '不指定科目的成绩只有班主任或管理员可以导入,请填写科目列后再导入',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
|
||||
const levelType = levelText ? 'custom' : 'percent';
|
||||
const existing = await prisma.grade.findFirst({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import * as gradeService from '../grades/grades.service.js';
|
||||
@@ -117,7 +117,7 @@ router.use(authenticate());
|
||||
*/
|
||||
router.get(
|
||||
'/devices',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ query: listDevicesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
@@ -131,14 +131,14 @@ router.get(
|
||||
* 管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课的班级。
|
||||
* 每条含:班级、设备名/设备号、在线/离线、最后在线时间、最后心跳时间。
|
||||
*/
|
||||
router.get('/classisland/status', requireRole('ADMIN', 'TEACHER'), async (req, res) => {
|
||||
router.get('/classisland/status', requirePermission('device:manage'), async (req, res) => {
|
||||
sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功');
|
||||
});
|
||||
|
||||
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
|
||||
router.post(
|
||||
'/devices',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ body: createDeviceSchema }),
|
||||
async (req, res) => {
|
||||
const result = await integrationService.createDevice(
|
||||
@@ -152,7 +152,7 @@ router.post(
|
||||
/** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */
|
||||
router.patch(
|
||||
'/devices/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema, body: updateDeviceSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -174,7 +174,7 @@ router.patch(
|
||||
*/
|
||||
router.post(
|
||||
'/devices/:id/request-schedule',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -186,7 +186,7 @@ router.post(
|
||||
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
|
||||
router.post(
|
||||
'/devices/:id/token',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -197,7 +197,7 @@ router.post(
|
||||
/** DELETE /api/integrations/devices/:id - 删除设备 */
|
||||
router.delete(
|
||||
'/devices/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('device:manage'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -209,7 +209,7 @@ router.delete(
|
||||
/** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */
|
||||
router.post(
|
||||
'/classisland/notify',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('classisland:notify'),
|
||||
validate({ body: sendNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const result = await integrationService.sendNotification(
|
||||
|
||||
@@ -1,32 +1,46 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
bulkCreateNotificationSchema,
|
||||
createNotificationSchema,
|
||||
listNotificationsQuerySchema,
|
||||
notificationArchiveQuerySchema,
|
||||
notificationScopeQuerySchema,
|
||||
type BulkCreateNotificationInput,
|
||||
type CreateNotificationInput,
|
||||
type ListNotificationQuery,
|
||||
type NotificationArchiveQuery,
|
||||
} from './notifications.schemas.js';
|
||||
import * as notificationService from './notifications.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/** GET /api/notifications?classId=&priority=&unreadOnly= - 通知列表 */
|
||||
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
|
||||
router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<{
|
||||
classId?: string;
|
||||
priority?: string;
|
||||
unreadOnly: boolean;
|
||||
keyword?: string;
|
||||
}>(req);
|
||||
const query = validatedQuery<ListNotificationQuery>(req);
|
||||
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
|
||||
});
|
||||
|
||||
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
|
||||
router.get('/days', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<NotificationArchiveQuery>(req);
|
||||
sendOk(res, await notificationService.listNotificationDays(user, query), '获取通知日期归档成功');
|
||||
});
|
||||
|
||||
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
|
||||
router.get('/creators', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<NotificationArchiveQuery>(req);
|
||||
sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功');
|
||||
});
|
||||
|
||||
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
|
||||
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -44,7 +58,7 @@ router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async
|
||||
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('notification:publish'),
|
||||
validate({ body: createNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -53,6 +67,21 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
|
||||
router.post(
|
||||
'/bulk',
|
||||
requirePermission('notification:publish'),
|
||||
validate({ body: bulkCreateNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await notificationService.bulkCreateNotifications(
|
||||
user,
|
||||
req.body as BulkCreateNotificationInput,
|
||||
);
|
||||
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/notifications/:id/read - 标记已读 */
|
||||
router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -63,7 +92,7 @@ router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) =
|
||||
/** DELETE /api/notifications/:id - 删除通知 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('notification:delete'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,13 +1,36 @@
|
||||
import { z } from 'zod';
|
||||
import { isDayKey } from '@classhelper/shared';
|
||||
import { booleanFlagSchema } from '../../lib/schemas.js';
|
||||
|
||||
export const notificationPrioritySchema = z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']);
|
||||
|
||||
/** 本地日期(YYYY-MM-DD):按天归档/筛选用它,不要用 createdAt 的 UTC 切片 */
|
||||
const dayKeySchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' });
|
||||
|
||||
export const listNotificationsQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
priority: notificationPrioritySchema.optional(),
|
||||
unreadOnly: booleanFlagSchema,
|
||||
keyword: z.string().trim().max(64).optional(),
|
||||
/** 只看某一天;与 from/to 二选一 */
|
||||
date: dayKeySchema.optional(),
|
||||
/** 日期区间(含边界) */
|
||||
from: dayKeySchema.optional(),
|
||||
to: dayKeySchema.optional(),
|
||||
/** 只看某位发布人 */
|
||||
createdBy: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/** GET /notifications/days 与 GET /notifications/creators 的筛选条件(不含 date 自身) */
|
||||
export const notificationArchiveQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
priority: notificationPrioritySchema.optional(),
|
||||
createdBy: z.string().min(1).optional(),
|
||||
from: dayKeySchema.optional(),
|
||||
to: dayKeySchema.optional(),
|
||||
});
|
||||
|
||||
export const createNotificationSchema = z.object({
|
||||
@@ -26,5 +49,25 @@ export const notificationScopeQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 一次给多个班级发同一条通知(批量发布)。
|
||||
*
|
||||
* 上限 50 个班是刻意设的:批量发布是逐班串行落库 + 逐班推 ClassIsland,
|
||||
* 一次塞几百个班会长时间占住请求,也会把教室大屏刷屏。
|
||||
*/
|
||||
export const bulkCreateNotificationSchema = z.object({
|
||||
classIds: z
|
||||
.array(z.string().min(1))
|
||||
.min(1, '请至少选择一个班级')
|
||||
.max(50, '一次最多发布 50 个班级'),
|
||||
title: z.string().trim().min(1, '请输入通知标题').max(120),
|
||||
content: z.string().min(1, '请输入通知内容').max(5000),
|
||||
priority: notificationPrioritySchema.optional(),
|
||||
confirmDuringClass: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type CreateNotificationInput = z.infer<typeof createNotificationSchema>;
|
||||
export type NotificationPriorityInput = z.infer<typeof notificationPrioritySchema>;
|
||||
export type ListNotificationQuery = z.infer<typeof listNotificationsQuerySchema>;
|
||||
export type NotificationArchiveQuery = z.infer<typeof notificationArchiveQuerySchema>;
|
||||
export type BulkCreateNotificationInput = z.infer<typeof bulkCreateNotificationSchema>;
|
||||
@@ -1,4 +1,15 @@
|
||||
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
dayKeyLocal,
|
||||
type BulkCreateNotificationRequest,
|
||||
type BulkPublishClassResult,
|
||||
type BulkPublishResult,
|
||||
type NotificationCreatorSummary,
|
||||
type NotificationCreatorsDto,
|
||||
type NotificationDaysDto,
|
||||
type NotificationDto,
|
||||
} from '@classhelper/shared';
|
||||
import { normalizeBulkClassIds, summarizeBulk, toBulkFailure } from '../../lib/bulk.js';
|
||||
import {
|
||||
assertClassAccess,
|
||||
assertCanPublishNotification,
|
||||
@@ -23,6 +34,37 @@ export interface ListNotificationOptions {
|
||||
priority?: string;
|
||||
unreadOnly?: boolean;
|
||||
keyword?: string;
|
||||
date?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
createdBy?: string;
|
||||
}
|
||||
|
||||
/** 归档筛选条件:按天 / 日期区间 / 发布人 / 优先级(assignDate 是本地日期字符串,字典序即时间序) */
|
||||
function archiveWhere(options: {
|
||||
priority?: string;
|
||||
createdBy?: string;
|
||||
date?: string;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}): Record<string, unknown> {
|
||||
if (options.date) {
|
||||
return {
|
||||
...(options.priority ? { priority: options.priority } : {}),
|
||||
...(options.createdBy ? { createdBy: options.createdBy } : {}),
|
||||
assignDate: options.date,
|
||||
};
|
||||
}
|
||||
|
||||
const range = {
|
||||
...(options.from ? { gte: options.from } : {}),
|
||||
...(options.to ? { lte: options.to } : {}),
|
||||
};
|
||||
return {
|
||||
...(options.priority ? { priority: options.priority } : {}),
|
||||
...(options.createdBy ? { createdBy: options.createdBy } : {}),
|
||||
...(Object.keys(range).length > 0 ? { assignDate: range } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listNotifications(
|
||||
@@ -38,7 +80,7 @@ export async function listNotifications(
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
...classScopeWhere(scope),
|
||||
...(options.priority ? { priority: options.priority } : {}),
|
||||
...archiveWhere(options),
|
||||
...(options.keyword
|
||||
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
|
||||
: {}),
|
||||
@@ -55,6 +97,98 @@ export async function listNotifications(
|
||||
return notifications.map((item) => toNotificationDto(item, { studentIds: personalIds, withStatus: isStaff }));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按天归档:列出「有通知的日期 + 当天条数」,供 Web 端左侧日期栏使用。
|
||||
*
|
||||
* 用 `assignDate`(本地日期)而不是 `createdAt`:晚上发的通知不该被算到第二天。
|
||||
* 传了 `date` 也刻意忽略 —— 日期栏是"归档索引",不该被当前选中的那天过滤掉。
|
||||
*/
|
||||
export async function listNotificationDays(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string },
|
||||
): Promise<NotificationDaysDto> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const grouped = await prisma.notification.groupBy({
|
||||
by: ['assignDate'],
|
||||
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const days = grouped
|
||||
.map((item) => ({ date: item.assignDate, count: item._count._all }))
|
||||
.filter((item) => item.date !== '')
|
||||
.sort((left, right) => right.date.localeCompare(left.date));
|
||||
|
||||
return { days };
|
||||
}
|
||||
|
||||
/** 按发布人归档:列出当前筛选范围内的发布人及其条数(供"按发布人看通知"用) */
|
||||
export async function listNotificationCreators(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string; priority?: string; date?: string; from?: string; to?: string },
|
||||
): Promise<NotificationCreatorsDto> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const grouped = await prisma.notification.groupBy({
|
||||
by: ['createdBy'],
|
||||
where: { ...classScopeWhere(scope), ...archiveWhere(options) },
|
||||
_count: { _all: true },
|
||||
});
|
||||
|
||||
const owners = await prisma.user.findMany({
|
||||
where: { id: { in: grouped.map((item) => item.createdBy) } },
|
||||
select: { id: true, name: true, username: true },
|
||||
});
|
||||
|
||||
const creators: NotificationCreatorSummary[] = grouped
|
||||
.map((item) => {
|
||||
const owner = owners.find((candidate) => candidate.id === item.createdBy);
|
||||
return {
|
||||
id: item.createdBy,
|
||||
name: owner?.name ?? '未知用户',
|
||||
username: owner?.username ?? '',
|
||||
count: item._count._all,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => right.count - left.count || left.name.localeCompare(right.name, 'zh-CN'));
|
||||
|
||||
return { creators };
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量发布通知:一次给多个班级发同一条内容。
|
||||
*
|
||||
* 逐班串行调用 `createNotification`(复用同一套权限断言 + 上课保护 + 实时广播 + ClassIsland 推送),
|
||||
* 某个班失败只记进结果,不影响其他班 —— 前端拿到逐班结果后自己决定怎么展示与重试。
|
||||
*/
|
||||
export async function bulkCreateNotifications(
|
||||
user: TokenPayload,
|
||||
input: BulkCreateNotificationRequest,
|
||||
): Promise<BulkPublishResult> {
|
||||
const classIds = normalizeBulkClassIds(input.classIds);
|
||||
const results: BulkPublishClassResult[] = [];
|
||||
|
||||
for (const classId of classIds) {
|
||||
try {
|
||||
const dto = await createNotification(user, {
|
||||
classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
priority: input.priority,
|
||||
confirmDuringClass: input.confirmDuringClass,
|
||||
});
|
||||
results.push({ classId, ok: true, id: dto.id });
|
||||
} catch (error) {
|
||||
results.push(toBulkFailure(classId, error, '发布通知'));
|
||||
}
|
||||
}
|
||||
|
||||
const summary = summarizeBulk(results);
|
||||
logger.info(
|
||||
`批量发布通知:目标 ${summary.total} 个班级,成功 ${summary.succeeded} 个,失败 ${summary.failed} 个(发起人=${user.username})`,
|
||||
);
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布通知并实时广播到 class:{classId} 房间。
|
||||
*
|
||||
@@ -92,6 +226,8 @@ export async function createNotification(
|
||||
content: input.content,
|
||||
priority,
|
||||
createdBy: user.sub,
|
||||
// 按天归档用「本地日期」:晚间发布的通知不会被 UTC 切片算到第二天(与作业同口径)
|
||||
assignDate: dayKeyLocal(new Date()),
|
||||
},
|
||||
include: { creator: creatorSelect, reads: true },
|
||||
});
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
assignUserGroupsSchema,
|
||||
createPermissionGroupSchema,
|
||||
updatePermissionGroupSchema,
|
||||
type AssignUserGroupsInput,
|
||||
type CreatePermissionGroupInput,
|
||||
type UpdatePermissionGroupInput,
|
||||
} from './permissions.schemas.js';
|
||||
import * as permissionService from './permissions.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* GET /api/permissions/me - 当前账号的生效权限。
|
||||
*
|
||||
* **故意不挂权限键**:任何登录账号都要能读自己的权限(两端据此决定入口显不显示),
|
||||
* 而"能读自己有什么权限"本身不是什么机密。班级端会话返回空集。
|
||||
*/
|
||||
router.get('/me', async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await permissionService.myPermissions(user), '获取当前权限成功');
|
||||
});
|
||||
|
||||
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树的唯一来源) */
|
||||
router.get('/catalog', requirePermission('permission:manage'), async (_req, res) => {
|
||||
sendOk(res, permissionService.permissionCatalog(), '获取权限目录成功');
|
||||
});
|
||||
|
||||
/** GET /api/permissions/groups - 权限组列表 */
|
||||
router.get('/groups', requirePermission('permission:manage'), async (_req, res) => {
|
||||
sendOk(res, await permissionService.listGroups(), '获取权限组成功');
|
||||
});
|
||||
|
||||
/** POST /api/permissions/groups - 新建权限组 */
|
||||
router.post(
|
||||
'/groups',
|
||||
requirePermission('permission:manage'),
|
||||
validate({ body: createPermissionGroupSchema }),
|
||||
async (req, res) => {
|
||||
const input = validatedBody<CreatePermissionGroupInput>(req);
|
||||
sendCreated(res, await permissionService.createGroup(input), '权限组已创建');
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/permissions/groups/:id - 改名 / 改描述 / 整体替换权限键 */
|
||||
router.patch(
|
||||
'/groups/:id',
|
||||
requirePermission('permission:manage'),
|
||||
validate({ params: idParamSchema, body: updatePermissionGroupSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const input = validatedBody<UpdatePermissionGroupInput>(req);
|
||||
sendOk(res, await permissionService.updateGroup(id, input), '权限组已更新');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/permissions/groups/:id - 删除权限组(内置组不允许删除) */
|
||||
router.delete(
|
||||
'/groups/:id',
|
||||
requirePermission('permission:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await permissionService.deleteGroup(id);
|
||||
sendOk(res, { id }, '权限组已删除,原成员回到角色默认权限');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/permissions/users - 成员列表(含所属权限组与生效权限数量) */
|
||||
router.get('/users', requirePermission('permission:manage'), async (_req, res) => {
|
||||
sendOk(res, await permissionService.listMembers(), '获取成员成功');
|
||||
});
|
||||
|
||||
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
|
||||
router.put(
|
||||
'/users/:id/groups',
|
||||
requirePermission('permission:manage'),
|
||||
validate({ params: idParamSchema, body: assignUserGroupsSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const input = validatedBody<AssignUserGroupsInput>(req);
|
||||
sendOk(res, await permissionService.assignUserGroups(id, input), '权限组分配已保存');
|
||||
},
|
||||
);
|
||||
|
||||
export const permissionsModule = defineModule({
|
||||
name: 'permissions',
|
||||
basePath: '/permissions',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { PERMISSION_KEYS, type PermissionKey } from '@classhelper/shared';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 权限键校验:必须是 shared 的 `PERMISSION_KEYS` 之一。
|
||||
*
|
||||
* 用 refine 而不是 `z.enum(PERMISSION_KEYS)`:后者需要一个非空元组类型,
|
||||
* 而 `PERMISSION_KEYS` 是 `as const` 推导出来的只读元组,转换写法又长又脆。
|
||||
*
|
||||
* 写入口一律拒绝未知键;**读出口静默忽略**(库里可能留着已下线的键)——
|
||||
* 见 `lib/permissions.ts` 的 known 集合。
|
||||
*/
|
||||
export const permissionKeySchema = z
|
||||
.string()
|
||||
.refine((value): value is PermissionKey => (PERMISSION_KEYS as readonly string[]).includes(value), {
|
||||
message: '未知的权限键',
|
||||
});
|
||||
|
||||
/** 一次最多 200 个键(目录现在不到 40 个,留余量同时挡住离谱的请求体) */
|
||||
const keysSchema = z.array(permissionKeySchema).max(200, '一次最多提交 200 个权限键');
|
||||
|
||||
export const createPermissionGroupSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字'),
|
||||
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
|
||||
keys: keysSchema.optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 改权限组:字段都是可选的,**传了就整体替换**。
|
||||
*
|
||||
* `keys` 是"整体替换"而不是增删(前端权限页本来就是一次提交整棵勾选树),
|
||||
* 这样不会出现"看着没勾上、其实还生效"的幽灵权限。
|
||||
*/
|
||||
export const updatePermissionGroupSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入权限组名称').max(40, '权限组名称最多 40 个字').optional(),
|
||||
description: z.string().trim().max(200, '描述最多 200 个字').optional(),
|
||||
keys: keysSchema.optional(),
|
||||
});
|
||||
|
||||
/** 给账号分配权限组:整体替换;传空数组 = 回到角色默认模板 */
|
||||
export const assignUserGroupsSchema = z.object({
|
||||
groupIds: z.array(z.string().min(1)).max(50, '一次最多分配 50 个权限组'),
|
||||
});
|
||||
|
||||
export type CreatePermissionGroupInput = z.infer<typeof createPermissionGroupSchema>;
|
||||
export type UpdatePermissionGroupInput = z.infer<typeof updatePermissionGroupSchema>;
|
||||
export type AssignUserGroupsInput = z.infer<typeof assignUserGroupsSchema>;
|
||||
@@ -0,0 +1,326 @@
|
||||
import {
|
||||
PERMISSION_CATEGORIES,
|
||||
PERMISSION_DEFAULT_ROLES,
|
||||
PERMISSION_KEYS,
|
||||
PERMISSION_LABELS,
|
||||
ROLE_DEFAULT_PERMISSIONS,
|
||||
type AssignUserGroupsRequest,
|
||||
type CreatePermissionGroupRequest,
|
||||
type MyPermissionsDto,
|
||||
type PermissionCatalogDto,
|
||||
type PermissionGroupDto,
|
||||
type PermissionKey,
|
||||
type PermissionMemberDto,
|
||||
type UpdatePermissionGroupRequest,
|
||||
type UserRole,
|
||||
} from '@classhelper/shared';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { ADMIN_UNREMOVABLE_PERMISSIONS, loadEffectivePermissions } from '../../lib/permissions.js';
|
||||
|
||||
/**
|
||||
* 权限组服务:组 / 键 / 成员三件事的全部读写都收在这里。
|
||||
*
|
||||
* 生效权限的计算**不在本文件**,而在 `lib/permissions.ts` 的 `loadEffectivePermissions`
|
||||
* (鉴权中间件每请求调用一次);本文件只负责"编辑这些组",两者共用同一份 shared 常量。
|
||||
*/
|
||||
|
||||
const GROUP_SELECT = {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
builtin: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
items: { select: { key: true } },
|
||||
_count: { select: { users: true } },
|
||||
} as const;
|
||||
|
||||
type GroupRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
builtin: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
items: { key: string }[];
|
||||
_count: { users: number };
|
||||
};
|
||||
|
||||
const MEMBER_SELECT = {
|
||||
id: true,
|
||||
username: true,
|
||||
name: true,
|
||||
role: true,
|
||||
permissionGroups: { select: { groupId: true } },
|
||||
} as const;
|
||||
|
||||
type MemberRow = {
|
||||
id: string;
|
||||
username: string;
|
||||
name: string;
|
||||
role: string;
|
||||
permissionGroups: { groupId: string }[];
|
||||
};
|
||||
|
||||
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
|
||||
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
|
||||
|
||||
function knownKey(key: string): key is PermissionKey {
|
||||
return KEY_ORDER.has(key);
|
||||
}
|
||||
|
||||
function byCatalogOrder(a: string, b: string): number {
|
||||
return (KEY_ORDER.get(a) ?? Number.MAX_SAFE_INTEGER) - (KEY_ORDER.get(b) ?? Number.MAX_SAFE_INTEGER);
|
||||
}
|
||||
|
||||
/** 去重 + 丢掉已下线的键 + 按目录顺序排序 */
|
||||
function normalizeKeys(keys: readonly string[] | undefined): PermissionKey[] {
|
||||
const unique = new Set<PermissionKey>();
|
||||
for (const key of keys ?? []) if (knownKey(key)) unique.add(key);
|
||||
return [...unique].sort(byCatalogOrder);
|
||||
}
|
||||
|
||||
function toGroupDto(row: GroupRow): PermissionGroupDto {
|
||||
return {
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
description: row.description,
|
||||
builtin: row.builtin,
|
||||
keys: row.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
|
||||
memberCount: row._count.users,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
|
||||
export function permissionCatalog(): PermissionCatalogDto {
|
||||
const roles = Object.keys(ROLE_DEFAULT_PERMISSIONS) as UserRole[];
|
||||
return {
|
||||
keys: [...PERMISSION_KEYS],
|
||||
labels: { ...PERMISSION_LABELS },
|
||||
categories: PERMISSION_CATEGORIES.map((category) => ({
|
||||
title: category.title,
|
||||
keys: [...category.keys],
|
||||
})),
|
||||
defaultRoles: Object.fromEntries(
|
||||
PERMISSION_KEYS.map((key) => [key, [...PERMISSION_DEFAULT_ROLES[key]]]),
|
||||
) as Record<PermissionKey, UserRole[]>,
|
||||
roleDefaults: Object.fromEntries(
|
||||
roles.map((role) => [role, [...ROLE_DEFAULT_PERMISSIONS[role]]]),
|
||||
) as Record<UserRole, PermissionKey[]>,
|
||||
unremovable: [...ADMIN_UNREMOVABLE_PERMISSIONS],
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/permissions/groups - 权限组列表(内置组排在最前,其余按创建时间) */
|
||||
export async function listGroups(): Promise<PermissionGroupDto[]> {
|
||||
const rows = await prisma.permissionGroup.findMany({
|
||||
select: GROUP_SELECT,
|
||||
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return (rows as GroupRow[]).map(toGroupDto);
|
||||
}
|
||||
|
||||
export async function createGroup(input: CreatePermissionGroupRequest): Promise<PermissionGroupDto> {
|
||||
const name = input.name.trim();
|
||||
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
|
||||
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
|
||||
|
||||
const keys = normalizeKeys(input.keys);
|
||||
const created = await prisma.permissionGroup.create({
|
||||
data: {
|
||||
name,
|
||||
description: input.description?.trim() ?? '',
|
||||
items: { create: keys.map((key) => ({ key })) },
|
||||
},
|
||||
select: GROUP_SELECT,
|
||||
});
|
||||
logger.info(`新建权限组「${name}」(${keys.length} 个权限键)`);
|
||||
return toGroupDto(created as GroupRow);
|
||||
}
|
||||
|
||||
export async function updateGroup(
|
||||
groupId: string,
|
||||
input: UpdatePermissionGroupRequest,
|
||||
): Promise<PermissionGroupDto> {
|
||||
const current = await prisma.permissionGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!current) throw ApiError.notFound('权限组不存在');
|
||||
|
||||
const name = input.name?.trim();
|
||||
if (name && name !== current.name) {
|
||||
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
|
||||
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
|
||||
}
|
||||
|
||||
const keys = input.keys ? normalizeKeys(input.keys) : null;
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.permissionGroup.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
...(name ? { name } : {}),
|
||||
...(input.description !== undefined ? { description: input.description.trim() } : {}),
|
||||
},
|
||||
});
|
||||
// 键是整体替换:先清后建。漏传某个键就等于"取消勾选",不会留下幽灵权限。
|
||||
if (keys) {
|
||||
await tx.permissionGroupItem.deleteMany({ where: { groupId } });
|
||||
if (keys.length > 0) {
|
||||
await tx.permissionGroupItem.createMany({ data: keys.map((key) => ({ groupId, key })) });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const updated = await prisma.permissionGroup.findUniqueOrThrow({
|
||||
where: { id: groupId },
|
||||
select: GROUP_SELECT,
|
||||
});
|
||||
logger.info(
|
||||
`更新权限组「${(updated as GroupRow).name}」(${keys ? `权限键 ${keys.length} 个` : '未改权限键'})`,
|
||||
);
|
||||
return toGroupDto(updated as GroupRow);
|
||||
}
|
||||
|
||||
export async function deleteGroup(groupId: string): Promise<{ id: string }> {
|
||||
const current = await prisma.permissionGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
select: { id: true, name: true, builtin: true, _count: { select: { users: true } } },
|
||||
});
|
||||
if (!current) throw ApiError.notFound('权限组不存在');
|
||||
if (current.builtin) {
|
||||
throw ApiError.badRequest(`「${current.name}」是内置权限组,不能删除(可以改名或调整里面的权限)`);
|
||||
}
|
||||
|
||||
// 键与成员关系都是 onDelete: Cascade,直接删组即可;
|
||||
// 组内成员随后"没有任何分组",会回落角色默认模板(不会变成空权限)。
|
||||
await prisma.permissionGroup.delete({ where: { id: groupId } });
|
||||
logger.info(
|
||||
`删除权限组「${current.name}」(原成员 ${current._count.users} 个,已回到角色默认权限)`,
|
||||
);
|
||||
return { id: groupId };
|
||||
}
|
||||
|
||||
/** 把一行 User(含分组关联)转成权限页用的成员 DTO */
|
||||
function toMemberDto(
|
||||
row: MemberRow,
|
||||
keysOfGroup: Map<string, PermissionKey[]>,
|
||||
): PermissionMemberDto {
|
||||
const groupIds = row.permissionGroups.map((link) => link.groupId);
|
||||
const union = new Set<PermissionKey>();
|
||||
for (const groupId of groupIds) for (const key of keysOfGroup.get(groupId) ?? []) union.add(key);
|
||||
|
||||
const role = row.role as UserRole;
|
||||
const permissionCount =
|
||||
role === 'ADMIN'
|
||||
? PERMISSION_KEYS.length
|
||||
: groupIds.length === 0
|
||||
? ROLE_DEFAULT_PERMISSIONS[role].length
|
||||
: union.size;
|
||||
|
||||
return {
|
||||
id: row.id,
|
||||
username: row.username,
|
||||
name: row.name,
|
||||
role,
|
||||
groupIds,
|
||||
permissionCount,
|
||||
fixed: role === 'ADMIN',
|
||||
};
|
||||
}
|
||||
|
||||
/** GET /api/permissions/users - 成员列表(管理员/教师账号;班级端账号不参与权限组) */
|
||||
export async function listMembers(): Promise<PermissionMemberDto[]> {
|
||||
const [rows, groups] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { role: { in: ['ADMIN', 'TEACHER'] } },
|
||||
select: MEMBER_SELECT,
|
||||
orderBy: [{ role: 'asc' }, { name: 'asc' }],
|
||||
}),
|
||||
prisma.permissionGroup.findMany({ select: { id: true, items: { select: { key: true } } } }),
|
||||
]);
|
||||
|
||||
const keysOfGroup = new Map<string, PermissionKey[]>(
|
||||
groups.map((group) => [
|
||||
group.id,
|
||||
group.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
|
||||
]),
|
||||
);
|
||||
|
||||
return (rows as MemberRow[]).map((row) => toMemberDto(row, keysOfGroup));
|
||||
}
|
||||
|
||||
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
|
||||
export async function assignUserGroups(
|
||||
userId: string,
|
||||
input: AssignUserGroupsRequest,
|
||||
): Promise<PermissionMemberDto> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
|
||||
if (!user) throw ApiError.notFound('账号不存在');
|
||||
|
||||
const row = user as MemberRow;
|
||||
if (row.role === 'ADMIN') {
|
||||
throw ApiError.badRequest('管理员固定拥有全部权限,不需要(也不能)分配权限组');
|
||||
}
|
||||
if (row.role === 'CLASS_DEVICE') {
|
||||
throw ApiError.badRequest(
|
||||
'ClassHelper 班级端账号的能力由"班级内角色"决定(见权限矩阵),不参与权限组分配',
|
||||
);
|
||||
}
|
||||
|
||||
const groupIds = [...new Set(input.groupIds)];
|
||||
if (groupIds.length > 0) {
|
||||
const found = await prisma.permissionGroup.count({ where: { id: { in: groupIds } } });
|
||||
if (found !== groupIds.length) throw ApiError.badRequest('有权限组不存在,请刷新后重试');
|
||||
}
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.userPermissionGroup.deleteMany({ where: { userId } });
|
||||
if (groupIds.length > 0) {
|
||||
await tx.userPermissionGroup.createMany({
|
||||
data: groupIds.map((groupId) => ({ userId, groupId })),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`分配权限组:${row.username} → ${groupIds.length > 0 ? `${groupIds.length} 个组` : '角色默认模板'}`,
|
||||
);
|
||||
|
||||
const groups = await prisma.permissionGroup.findMany({
|
||||
select: { id: true, items: { select: { key: true } } },
|
||||
});
|
||||
const keysOfGroup = new Map<string, PermissionKey[]>(
|
||||
groups.map((group) => [
|
||||
group.id,
|
||||
group.items.map((item) => item.key).filter(knownKey).sort(byCatalogOrder),
|
||||
]),
|
||||
);
|
||||
return toMemberDto({ ...row, permissionGroups: groupIds.map((groupId) => ({ groupId })) }, keysOfGroup);
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/permissions/me - 当前账号的生效权限。
|
||||
*
|
||||
* 两端按它决定"入口显不显示"(真正的拦截始终在服务端)。
|
||||
* 班级端会话恒为空集:它的能力由班级维度断言决定。
|
||||
*/
|
||||
export async function myPermissions(user: TokenPayload): Promise<MyPermissionsDto> {
|
||||
const permissions = await loadEffectivePermissions(user);
|
||||
const fromGroups =
|
||||
user.classSession || user.role === 'CLASS_DEVICE' || user.role === 'ADMIN'
|
||||
? false
|
||||
: (await prisma.userPermissionGroup.count({ where: { userId: user.sub } })) > 0;
|
||||
|
||||
return {
|
||||
role: user.role,
|
||||
permissions: [...permissions].sort(byCatalogOrder),
|
||||
fromGroups,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { aiModule } from './ai/ai.module.js';
|
||||
import { archivesModule } from './archives/archives.module.js';
|
||||
import { authModule } from './auth/auth.module.js';
|
||||
import { callsModule } from './calls/calls.module.js';
|
||||
import { classesModule } from './classes/classes.module.js';
|
||||
import { clientsModule } from './clients/clients.module.js';
|
||||
import { coursesModule } from './courses/courses.module.js';
|
||||
import { databaseModule } from './database/database.module.js';
|
||||
import { dashboardModule } from './dashboard/dashboard.module.js';
|
||||
@@ -10,8 +12,10 @@ import { homeworksModule } from './homeworks/homeworks.module.js';
|
||||
import { importsModule } from './imports/imports.module.js';
|
||||
import { integrationsModule } from './integrations/integrations.module.js';
|
||||
import { notificationsModule } from './notifications/notifications.module.js';
|
||||
import { permissionsModule } from './permissions/permissions.module.js';
|
||||
import { schedulesModule } from './schedules/schedules.module.js';
|
||||
import { studentsModule } from './students/students.module.js';
|
||||
import { systemModule } from './system/system.module.js';
|
||||
import { teachersModule } from './teachers/teachers.module.js';
|
||||
import { termModule } from './term/term.module.js';
|
||||
import { updateModule } from './update/update.module.js';
|
||||
@@ -26,16 +30,20 @@ import type { ApiModule } from './module.types.js';
|
||||
*/
|
||||
export const apiModules: ApiModule[] = [
|
||||
authModule,
|
||||
aiModule,
|
||||
classesModule,
|
||||
clientsModule,
|
||||
coursesModule,
|
||||
schedulesModule,
|
||||
homeworksModule,
|
||||
notificationsModule,
|
||||
permissionsModule,
|
||||
callsModule,
|
||||
importsModule,
|
||||
integrationsModule,
|
||||
gradesModule,
|
||||
studentsModule,
|
||||
systemModule,
|
||||
teachersModule,
|
||||
archivesModule,
|
||||
termModule,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -58,7 +58,7 @@ router.get('/current', validate({ query: classStatusQuerySchema }), async (req,
|
||||
/** POST /api/schedules - 新增课表 */
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ body: createScheduleSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -73,7 +73,7 @@ router.post(
|
||||
/** PATCH /api/schedules/:id - 修改课表 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ params: idParamSchema, body: updateScheduleSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -89,7 +89,7 @@ router.patch(
|
||||
/** DELETE /api/schedules/:id - 删除课表 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('schedule:write'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -33,7 +33,7 @@ router.use(authenticate());
|
||||
*/
|
||||
router.get(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ query: listStudentsQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -45,7 +45,7 @@ router.get(
|
||||
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
|
||||
router.get(
|
||||
'/transfers',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ query: listTransfersQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -62,7 +62,7 @@ router.get(
|
||||
*/
|
||||
router.post(
|
||||
'/transfer',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ body: transferStudentsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -74,7 +74,7 @@ router.post(
|
||||
/** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */
|
||||
router.post(
|
||||
'/transfer-out',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('class:manage'),
|
||||
validate({ body: transferOutStudentsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -86,7 +86,7 @@ router.post(
|
||||
/** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */
|
||||
router.get(
|
||||
'/:id/transfers',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -99,7 +99,7 @@ router.get(
|
||||
/** POST /api/students - 新建学生(名单记录,不是账号) */
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ body: createStudentSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -114,7 +114,7 @@ router.post(
|
||||
/** PATCH /api/students/:id - 修改学生信息 / 调班 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ params: idParamSchema, body: updateStudentSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -130,7 +130,7 @@ router.patch(
|
||||
/** DELETE /api/students/:id - 删除学生名单记录 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requirePermission('roster:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { Router } from 'express';
|
||||
import { API_PATHS } from '@classhelper/shared';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
updateAutostartSchema,
|
||||
updateSystemSettingsSchema,
|
||||
type UpdateAutostartInput,
|
||||
type UpdateSystemSettingsInput,
|
||||
} from './system.schemas.js';
|
||||
import * as systemService from './system.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* 系统设置与运维接口(**全部仅 ADMIN**,权限键 `system:manage`)。
|
||||
*
|
||||
* 这些接口改的是"这台服务器怎么运行"(开发者模式、开机自启),
|
||||
* 与业务数据无关,因此不走任何班级维度断言。
|
||||
*/
|
||||
router.get('/settings', requirePermission('system:manage'), async (_req, res) => {
|
||||
sendOk(res, await systemService.getSystemSettings(), '获取系统设置成功');
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/settings',
|
||||
requirePermission('system:manage'),
|
||||
validate({ body: updateSystemSettingsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const input = validatedBody<UpdateSystemSettingsInput>(req);
|
||||
sendOk(res, await systemService.updateSystemSettings(user, input), '系统设置已保存');
|
||||
},
|
||||
);
|
||||
|
||||
router.get('/autostart', requirePermission('system:manage'), async (_req, res) => {
|
||||
sendOk(res, await systemService.getAutostartStatus(), '获取开机自启状态成功');
|
||||
});
|
||||
|
||||
router.put(
|
||||
'/autostart',
|
||||
requirePermission('system:manage'),
|
||||
validate({ body: updateAutostartSchema }),
|
||||
async (req, res) => {
|
||||
const input = validatedBody<UpdateAutostartInput>(req);
|
||||
sendOk(res, await systemService.updateAutostart(input), '开机自启设置已生效');
|
||||
},
|
||||
);
|
||||
|
||||
/** 运行诊断:开发者模式下排查"服务端到底跑的哪份代码、数据在哪" */
|
||||
router.get('/diagnostics', requirePermission('system:manage'), async (_req, res) => {
|
||||
sendOk(res, await systemService.getDiagnostics(), '获取运行诊断成功');
|
||||
});
|
||||
|
||||
export const systemModule = defineModule({
|
||||
name: 'system',
|
||||
basePath: API_PATHS.system,
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** `PUT /api/system/settings`:目前只有开发者模式(字段都可选,传了就改) */
|
||||
export const updateSystemSettingsSchema = z.object({
|
||||
developerMode: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** `PUT /api/system/autostart`:开/关服务端开机自启 */
|
||||
export const updateAutostartSchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export type UpdateSystemSettingsInput = z.infer<typeof updateSystemSettingsSchema>;
|
||||
export type UpdateAutostartInput = z.infer<typeof updateAutostartSchema>;
|
||||
@@ -0,0 +1,153 @@
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import {
|
||||
CLIENT_ONLINE_WINDOW_MS,
|
||||
type AutostartStatusDto,
|
||||
type SystemDiagnosticsDto,
|
||||
type SystemSettingsDto,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { SETTING_KEYS, getBooleanSetting, setBooleanSetting } from '../../lib/settings.js';
|
||||
import { APP_VERSION } from '../../lib/version.js';
|
||||
import type { UpdateAutostartInput, UpdateSystemSettingsInput } from './system.schemas.js';
|
||||
|
||||
/**
|
||||
* 服务端系统设置:开发者模式、开机自启、运行诊断。
|
||||
*
|
||||
* 为什么"开机自启"要做成接口而不是只在安装器里勾一下:教室服务器换机器、改过安装目录、
|
||||
* 或者管理员当时没勾,事后都得能补 —— 安装器只负责**默认值**,运行期由这里改。
|
||||
* 它改的是**操作系统**的东西(注册表 Run 项 / systemd 单元),所以:
|
||||
* 1. 只在"装好的形态"里放行(开发机、免安装包、容器里返回 `supported:false`);
|
||||
* 2. 失败原因是运维性质的(权限不足、单元文件不存在),一律带原文回给管理员,不吞成 500。
|
||||
*/
|
||||
const RUN_KEY_PATH = 'HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run';
|
||||
const RUN_KEY_NAME = 'ClassHelperServer';
|
||||
const SYSTEMD_UNIT = 'classhelper';
|
||||
|
||||
function runCommand(file: string, args: string[]): { ok: boolean; output: string } {
|
||||
const result = spawnSync(file, args, { encoding: 'utf8', windowsHide: true });
|
||||
const output = `${result.stdout ?? ''}${result.stderr ?? ''}`.trim();
|
||||
return { ok: result.status === 0, output: output || (result.error ? result.error.message : '') };
|
||||
}
|
||||
|
||||
/** 安装器写在安装目录里的启动脚本:它存在才算"装好的形态" */
|
||||
function startCmdPath(): string {
|
||||
return path.join(path.dirname(process.execPath), 'start.cmd');
|
||||
}
|
||||
|
||||
export async function getSystemSettings(): Promise<SystemSettingsDto> {
|
||||
return { developerMode: await getBooleanSetting(SETTING_KEYS.developerMode, false) };
|
||||
}
|
||||
|
||||
export async function updateSystemSettings(
|
||||
user: TokenPayload,
|
||||
input: UpdateSystemSettingsInput,
|
||||
): Promise<SystemSettingsDto> {
|
||||
if (input.developerMode !== undefined) {
|
||||
await setBooleanSetting(SETTING_KEYS.developerMode, input.developerMode);
|
||||
logger.info(`开发者模式已${input.developerMode ? '开启' : '关闭'}(操作人=${user.username})`);
|
||||
}
|
||||
return getSystemSettings();
|
||||
}
|
||||
|
||||
export async function getAutostartStatus(): Promise<AutostartStatusDto> {
|
||||
if (process.platform === 'win32') {
|
||||
const cmd = startCmdPath();
|
||||
if (!fs.existsSync(cmd)) {
|
||||
return {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
mechanism: 'unsupported',
|
||||
detail: '未检测到安装形态(缺少 start.cmd):开发环境或免安装目录不提供开机自启切换',
|
||||
};
|
||||
}
|
||||
const query = runCommand('reg.exe', ['query', RUN_KEY_PATH, '/v', RUN_KEY_NAME]);
|
||||
return {
|
||||
supported: true,
|
||||
enabled: query.ok,
|
||||
mechanism: 'run-key',
|
||||
detail: `注册表 HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run\\${RUN_KEY_NAME} → "${cmd}"`,
|
||||
};
|
||||
}
|
||||
|
||||
if (process.platform === 'linux') {
|
||||
const unit = `/etc/systemd/system/${SYSTEMD_UNIT}.service`;
|
||||
if (!fs.existsSync(unit)) {
|
||||
return {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
mechanism: 'unsupported',
|
||||
detail: `未找到 ${unit}(不是 systemd 安装形态)`,
|
||||
};
|
||||
}
|
||||
const query = runCommand('systemctl', ['is-enabled', SYSTEMD_UNIT]);
|
||||
return {
|
||||
supported: true,
|
||||
enabled: query.ok,
|
||||
mechanism: 'systemd',
|
||||
detail: `systemctl is-enabled ${SYSTEMD_UNIT}`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
supported: false,
|
||||
enabled: false,
|
||||
mechanism: 'unsupported',
|
||||
detail: `当前平台(${process.platform})不支持切换开机自启`,
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateAutostart(input: UpdateAutostartInput): Promise<AutostartStatusDto> {
|
||||
const current = await getAutostartStatus();
|
||||
if (!current.supported) throw ApiError.badRequest(current.detail);
|
||||
|
||||
if (current.mechanism === 'run-key') {
|
||||
const cmd = startCmdPath();
|
||||
const result = input.enabled
|
||||
? runCommand('reg.exe', ['add', RUN_KEY_PATH, '/v', RUN_KEY_NAME, '/t', 'REG_SZ', '/d', `"${cmd}"`, '/f'])
|
||||
: runCommand('reg.exe', ['delete', RUN_KEY_PATH, '/v', RUN_KEY_NAME, '/f']);
|
||||
if (!result.ok) throw ApiError.internal(`写入开机自启项失败:${result.output}`);
|
||||
} else {
|
||||
const result = runCommand('systemctl', [input.enabled ? 'enable' : 'disable', SYSTEMD_UNIT]);
|
||||
if (!result.ok) {
|
||||
throw ApiError.badRequest(
|
||||
`systemctl ${input.enabled ? 'enable' : 'disable'} ${SYSTEMD_UNIT} 失败:${result.output}(服务是否以特权用户运行?)`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`服务端开机自启已${input.enabled ? '开启' : '关闭'}(机制=${current.mechanism})`);
|
||||
return getAutostartStatus();
|
||||
}
|
||||
|
||||
export async function getDiagnostics(): Promise<SystemDiagnosticsDto> {
|
||||
const now = Date.now();
|
||||
// 延迟 import:registry 会 import 本模块,顶层互相引用在 ESM 下会拿到未初始化的绑定
|
||||
const { apiModules } = await import('../registry.js');
|
||||
const [clientReleaseCount, onlineClientCount, settings] = await Promise.all([
|
||||
prisma.clientRelease.count(),
|
||||
prisma.clientDevice.count({
|
||||
where: { lastHeartbeatAt: { gt: new Date(now - CLIENT_ONLINE_WINDOW_MS) } },
|
||||
}),
|
||||
getSystemSettings(),
|
||||
]);
|
||||
|
||||
return {
|
||||
version: APP_VERSION,
|
||||
nodeVersion: process.versions.node,
|
||||
platform: `${process.platform}-${process.arch}`,
|
||||
uptimeSeconds: Math.round(process.uptime()),
|
||||
databaseProvider: env.databaseProvider,
|
||||
serverRoot: env.serverRoot,
|
||||
dataDir: path.resolve(env.serverRoot, '..', 'data'),
|
||||
clientReleaseCount,
|
||||
onlineClientCount,
|
||||
moduleCount: apiModules.length,
|
||||
developerMode: settings.developerMode,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -25,20 +25,30 @@ router.use(authenticate());
|
||||
*/
|
||||
|
||||
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
|
||||
router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => {
|
||||
router.get(
|
||||
'/',
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ query: listTeachersQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { keyword } = validatedQuery<{ keyword?: string }>(req);
|
||||
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/teachers - 录入教师账号(仅管理员) */
|
||||
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => {
|
||||
router.post(
|
||||
'/',
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ body: createTeacherSchema }),
|
||||
async (req, res) => {
|
||||
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ params: idParamSchema, body: updateTeacherSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -49,7 +59,7 @@ router.patch(
|
||||
/** POST /api/teachers/:id/reset-password - 重置教师密码 */
|
||||
router.post(
|
||||
'/:id/reset-password',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
@@ -59,11 +69,16 @@ router.post(
|
||||
);
|
||||
|
||||
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
|
||||
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||
router.delete(
|
||||
'/:id',
|
||||
requirePermission('teacher:manage'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await teacherService.deleteTeacher(id);
|
||||
sendOk(res, { id }, '教师账号已删除');
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
export const teachersModule = defineModule({
|
||||
name: 'teachers',
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
} from './teachers.schemas.js';
|
||||
|
||||
/**
|
||||
* 教师管理(**仅管理员**,路由层已用 requireRole('ADMIN') 兜住):
|
||||
* 教师管理(**仅管理员**,路由层已用 requirePermission('teacher:manage') 兜住):
|
||||
* 录入教师是管理员专属操作,班主任与科任老师都没有该权限。
|
||||
* 学生名单在 students 模块,两个模块结构保持一致(列表 / 新建 / 编辑 / 重置密码 / 删除)。
|
||||
*/
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Router } from 'express';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
@@ -23,7 +23,7 @@ router.use(authenticate());
|
||||
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
|
||||
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
|
||||
*/
|
||||
router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySchema }), async (req, res) => {
|
||||
router.get('/', requirePermission('term:read'), validate({ query: termQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
|
||||
@@ -32,7 +32,7 @@ router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySche
|
||||
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
|
||||
router.put(
|
||||
'/',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('term:write'),
|
||||
validate({ body: updateTermWeeksSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -51,7 +51,7 @@ router.put(
|
||||
*/
|
||||
router.post(
|
||||
'/auto',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('term:write'),
|
||||
validate({ body: autoTermWeeksSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -70,7 +70,7 @@ router.post(
|
||||
*/
|
||||
router.get(
|
||||
'/holidays',
|
||||
requireRole('ADMIN'),
|
||||
requirePermission('term:write'),
|
||||
validate({ query: holidayQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { year } = validatedQuery<{ year?: number }>(req);
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import crypto from 'node:crypto';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { Readable } from 'node:stream';
|
||||
import { pipeline } from 'node:stream/promises';
|
||||
import {
|
||||
API_PREFIX,
|
||||
CLIENT_RELEASE_DIR,
|
||||
clientReleaseDownloadUrl,
|
||||
clientReleaseFileName,
|
||||
compareVersions,
|
||||
type ClientReleaseDto,
|
||||
type ClientUpdateInfoDto,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import type { FetchClientReleaseInput, RegisterClientReleaseInput } from './update.schemas.js';
|
||||
|
||||
/**
|
||||
* 客户端安装包在服务端的缓存。
|
||||
*
|
||||
* 为什么要有这一层:教室机器常常**没有外网**,而"统一下发客户端更新"必须能落到
|
||||
* 那些机器上。于是服务端替它们代下(或由管理员手工放一份)并缓存到
|
||||
* `data/client-releases/<版本>/`,客户端再从 `GET /api/update/client/download/:version` 拉。
|
||||
*
|
||||
* 两个入口都**不传二进制**(服务端没有 multipart,也不打算为这一件事加):
|
||||
* - `fetch`:服务端自己去 GitHub Releases 下(Web 端受 CSP 约束不能直连 GitHub,见 §5 第 52 条);
|
||||
* - `register`:文件已由管理员放进磁盘(内网分发、U 盘、CI 产物),服务端只算 sha256 并登记。
|
||||
*/
|
||||
|
||||
/** 代下大文件时的超时(安装包约 100MB,机房线路慢要留足) */
|
||||
const FETCH_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
type ReleaseRow = {
|
||||
id: string;
|
||||
version: string;
|
||||
fileName: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
notes: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
function releaseRoot(): string {
|
||||
// 与 database 模块的备份目录同口径:<serverRoot>/../data
|
||||
const dir = path.join(env.serverRoot, '..', 'data', CLIENT_RELEASE_DIR);
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function releaseDir(version: string): string {
|
||||
return path.join(releaseRoot(), version);
|
||||
}
|
||||
|
||||
function releaseFilePath(version: string, fileName: string): string {
|
||||
return path.join(releaseDir(version), fileName);
|
||||
}
|
||||
|
||||
function toReleaseDto(row: ReleaseRow): ClientReleaseDto {
|
||||
return {
|
||||
version: row.version,
|
||||
fileName: row.fileName,
|
||||
size: row.size,
|
||||
sha256: row.sha256,
|
||||
notes: row.notes,
|
||||
fileReady: fs.existsSync(releaseFilePath(row.version, row.fileName)),
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
updatedAt: row.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
async function sha256Of(file: string): Promise<string> {
|
||||
const hash = crypto.createHash('sha256');
|
||||
await pipeline(fs.createReadStream(file), hash);
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
/** GET /api/update/client/releases - 已缓存的安装包(按版本从新到旧) */
|
||||
export async function listReleases(): Promise<ClientReleaseDto[]> {
|
||||
const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[];
|
||||
return rows
|
||||
.sort((a, b) => compareVersions(b.version, a.version))
|
||||
.map((row) => toReleaseDto(row));
|
||||
}
|
||||
|
||||
/** 服务端缓存的**最新**版本(推送更新时省略 version 就用它) */
|
||||
export async function latestReleaseVersion(): Promise<string> {
|
||||
const rows = (await prisma.clientRelease.findMany()) as unknown as ReleaseRow[];
|
||||
const ready = rows.filter((row) => fs.existsSync(releaseFilePath(row.version, row.fileName)));
|
||||
ready.sort((a, b) => compareVersions(b.version, a.version));
|
||||
return ready[0]?.version ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 某个版本的下载信息(心跳响应里带给客户端)。
|
||||
* 文件不在磁盘上时返回 null —— 客户端据此提示"服务端还没有这份安装包"。
|
||||
*/
|
||||
export async function resolveUpdateInfo(version: string): Promise<ClientUpdateInfoDto | null> {
|
||||
const wanted = version.trim();
|
||||
if (!wanted) return null;
|
||||
const row = (await prisma.clientRelease.findUnique({ where: { version: wanted } })) as ReleaseRow | null;
|
||||
if (!row) return null;
|
||||
if (!fs.existsSync(releaseFilePath(row.version, row.fileName))) return null;
|
||||
return {
|
||||
version: row.version,
|
||||
fileName: row.fileName,
|
||||
size: row.size,
|
||||
sha256: row.sha256,
|
||||
notes: row.notes,
|
||||
// 含 /api 前缀:客户端用「服务器地址 + 它」直接下载
|
||||
url: `${API_PREFIX}/update/client/download/${encodeURIComponent(row.version)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/** 绝对路径(下载路由用);文件不存在返回 null */
|
||||
export async function resolveReleaseFile(version: string): Promise<{ file: string; fileName: string } | null> {
|
||||
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
|
||||
if (!row) return null;
|
||||
const file = releaseFilePath(row.version, row.fileName);
|
||||
if (!fs.existsSync(file)) return null;
|
||||
return { file, fileName: row.fileName };
|
||||
}
|
||||
|
||||
/** 登记一份已经在磁盘上的安装包:算 sha256 / 大小后落库(可重复登记,会覆盖) */
|
||||
export async function registerRelease(
|
||||
userId: string,
|
||||
input: RegisterClientReleaseInput,
|
||||
): Promise<ClientReleaseDto> {
|
||||
const version = input.version;
|
||||
// 文件名默认走发布命名约定(与 GitHub Release、官网兜底直链一致)
|
||||
const known = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
|
||||
const fileName = input.fileName ?? known?.fileName ?? clientReleaseFileName(version);
|
||||
const file = releaseFilePath(version, fileName);
|
||||
|
||||
if (!fs.existsSync(file)) {
|
||||
throw ApiError.badRequest(
|
||||
`没找到安装包文件「${fileName}」,请先把它放进 data/${CLIENT_RELEASE_DIR}/${version}/ 再登记`,
|
||||
);
|
||||
}
|
||||
|
||||
const stat = fs.statSync(file);
|
||||
const sha256 = await sha256Of(file);
|
||||
const row = (await prisma.clientRelease.upsert({
|
||||
where: { version },
|
||||
create: {
|
||||
version,
|
||||
fileName,
|
||||
sha256,
|
||||
size: stat.size,
|
||||
notes: input.notes?.trim() ?? '',
|
||||
createdBy: userId,
|
||||
},
|
||||
update: {
|
||||
fileName,
|
||||
sha256,
|
||||
size: stat.size,
|
||||
...(input.notes !== undefined ? { notes: input.notes.trim() } : {}),
|
||||
},
|
||||
})) as ReleaseRow;
|
||||
|
||||
logger.info(`登记客户端安装包 v${version}(${fileName},${stat.size} 字节,sha256=${sha256.slice(0, 12)}…)`);
|
||||
return toReleaseDto(row);
|
||||
}
|
||||
|
||||
/** 让服务端从 GitHub 拉一份安装包并登记(教室无外网时由服务端代下) */
|
||||
export async function fetchReleaseFromGithub(
|
||||
userId: string,
|
||||
input: FetchClientReleaseInput,
|
||||
): Promise<ClientReleaseDto> {
|
||||
const version = input.version;
|
||||
const fileName = clientReleaseFileName(version);
|
||||
const url = clientReleaseDownloadUrl(version);
|
||||
const target = releaseFilePath(version, fileName);
|
||||
|
||||
fs.mkdirSync(releaseDir(version), { recursive: true });
|
||||
logger.info(`开始从 GitHub 拉取客户端安装包 v${version}:${url}`);
|
||||
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
redirect: 'follow',
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: { 'User-Agent': 'ClassHelper-Server' },
|
||||
});
|
||||
} catch (error) {
|
||||
throw ApiError.badRequest(
|
||||
`下载失败:${error instanceof Error ? error.message : String(error)}。` +
|
||||
`可以把安装包手工放进 data/${CLIENT_RELEASE_DIR}/${version}/ 再点「登记」`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw ApiError.badRequest(
|
||||
`GitHub 上没有 v${version} 的客户端安装包(HTTP ${response.status},文件名应为 ${fileName})`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await pipeline(Readable.fromWeb(response.body as never), fs.createWriteStream(target));
|
||||
} catch (error) {
|
||||
fs.rmSync(target, { force: true });
|
||||
throw ApiError.badRequest(`写入安装包失败:${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
|
||||
// 落盘后统一走登记:sha256 / 大小 / 落库都在同一处算
|
||||
return registerRelease(userId, { version, fileName });
|
||||
}
|
||||
|
||||
/** 删除一份缓存的安装包(元数据 + 磁盘文件) */
|
||||
export async function deleteRelease(version: string): Promise<{ version: string }> {
|
||||
const row = (await prisma.clientRelease.findUnique({ where: { version } })) as ReleaseRow | null;
|
||||
if (!row) throw ApiError.notFound('服务端没有这个版本的安装包');
|
||||
await prisma.clientRelease.delete({ where: { version } });
|
||||
fs.rmSync(releaseDir(version), { recursive: true, force: true });
|
||||
logger.info(`删除客户端安装包 v${version}(${row.fileName})`);
|
||||
return { version };
|
||||
}
|
||||
Loaded 100 of 131 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user