chore(release): 1.3.3 发布并退役旧联动

This commit is contained in:
LoveChengke committed 2026-10-17 03:06:46 +08:00
1 parent b7cdc7c9ea
commit c0ebf88138
184 files changed
+5450 -11705

No files matched your search

+128 -195
View File
@@ -1,6 +1,24 @@
# AGENTS.md — ClassHelper(Class Helper) # AGENTS.md — ClassHelper(Class Helper)
本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。 本文件写给在本仓库里干活的 AI 编码代理。先读这里,再动代码。
**2026-10-16 更新**:当前仓库位于 `F:\ClassHelper`,F 盘可用。ClassIsland 联动插件、设备令牌、提醒通道与镜像接口已退役;离线课表 JSON 导入和界面设计参考保留。下文历史联动部署记录不再适用。
客户端维护只走 `/api/clients/*`,页面为 `ClientsView.vue`;默认权限按数据库中对应角色的默认组生效。
**2026-10-17 更新**:日常 `verify:desktop` / `verify:packaged` 默认是后台快速检查,不创建可见灵动岛或托盘;
完整桌面动画、透明度与截图验收必须显式加 `--visual`(`pnpm verify:desktop:visual`)。
下文关于置顶验证窗口、鼠标穿透与截图时序的历史约定仅适用于视觉模式。不要为了日常修改反复占用用户桌面。
联网冒烟显式启用,使用临时教师和班级;禁止回退到真实班级、重置其密码或向其发送测试通知。
本次 PowerShell 会话的 PATH 不含 pnpm:需要先把 `C:\Users\hck16\AppData\Local\deepseek-harness\bin`
加到 PATH 前面,再按下文设置 `DSH_NODE` 和 `DSH_PNPM_BIN`。Git Bash 外层找不到 pnpm 时可执行:
```bash
export DSH_NODE='D:\Node.js\node.exe'
export DSH_PNPM_BIN='C:\Users\hck16\AppData\Local\node\corepack\v1\pnpm\11.8.0\bin\pnpm.cjs'
node "C:/Users/hck16/AppData/Local/node/corepack/v1/pnpm/11.8.0/bin/pnpm.cjs" typecheck
```
外层直接调用用于解决找不到命令;环境变量让内部嵌套的 pnpm 仍使用真实运行时。
文中「本机环境」「已知坑」两节按**实测**写成;若与本机现状不符,以本机实测为准并顺手改掉这里。 文中「本机环境」「已知坑」两节按**实测**写成;若与本机现状不符,以本机实测为准并顺手改掉这里。
--- ---
@@ -9,13 +27,12 @@
班级信息管理系统,**pnpm monorepo**,一份代码产出多个交付物: 班级信息管理系统,**pnpm monorepo**,一份代码产出多个交付物:
| 交付物 | 位置 | 形态 | | 交付物 | 位置 | 形态 |
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------ | | ---------- | ------------------------- | -------------------------------------------------------- |
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,`/api` 前缀 | | 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,`/api` 前缀 |
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) | | Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用,PWA 可安装) |
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) | | 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 | | 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(装在教室的 ClassIsland 上:上报课表与上课状态、弹出老师发的提醒) |
核心链路: 核心链路:
@@ -28,9 +45,6 @@
▼ ▼
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
ClassIsland(教室机器)──► ClassHelper 联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
▲ │
└──── 老师下发的提醒(上报返回值里顺带带回)◄┘
``` ```
- 远端:`LoveChengke/ClassHelper`(仓库名大小写不敏感,本地 remote 存的是小写 `classhelper.git`) - 远端:`LoveChengke/ClassHelper`(仓库名大小写不敏感,本地 remote 存的是小写 `classhelper.git`)
@@ -137,7 +151,7 @@ packages/server/
password schemas session term version(读 package.json) web-static password schemas session term version(读 package.json) web-static
src/middleware/ auth validate error security src/middleware/ auth validate error security
src/realtime/ socket.ts(房间)+ bus.ts(事件总线) src/realtime/ socket.ts(房间)+ bus.ts(事件总线)
src/modules/ 21 个功能模块 + registry.ts + module.types.ts src/modules/ 20 个功能模块 + registry.ts + module.types.ts
(含 archives 毕业归档、term 学期周次、permissions 权限组、 (含 archives 毕业归档、term 学期周次、permissions 权限组、
clients 客户端心跳与远控、system 开发者模式/开机自启、ai 大模型助手) clients 客户端心跳与远控、system 开发者模式/开机自启、ai 大模型助手)
packages/web-admin/src/ api stores router layouts views(18) components(5) composables styles config.ts packages/web-admin/src/ api stores router layouts views(18) components(5) composables styles config.ts
@@ -150,37 +164,27 @@ packages/desktop-client/
src/renderer/ ClassHelper 班级端渲染进程:api / cache(IndexedDB) / stores / views / island / router src/renderer/ ClassHelper 班级端渲染进程:api / cache(IndexedDB) / stores / views / island / router
src/types/desktop.d.ts 主进程 ↔ 渲染进程契约 src/types/desktop.d.ts 主进程 ↔ 渲染进程契约
scripts/ build-main / dev / smoke / dist-win scripts/ build-main / dev / smoke / dist-win
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
manifest.yml 清单:id=classhelper.classisland.bridge / apiVersion=2.0.0.0 / version=1.2.0.0
ClassHelper.ClassIslandPlugin.csproj(TargetFramework=net8.0)
src/Plugin.cs 入口:读配置 → 注册提醒提供方 / 设置页 / BridgeService
src/Models/PluginSettings.cs
src/Services/ BridgeService(上报·接收·镜像)/ ScheduleMapper / ClassPlanWriter
ClassHelperNotificationProvider(把提醒显示到 ClassIsland 上)
src/Views/BridgeSettingsPage.axaml(.cs):Avalonia 设置页
src/Interop/ClassHelperClient.cs:HTTP 客户端 + 与服务端逐个字段对齐的 DTO
scripts/ build.mjs(编译 / 打包 .cipx)/ verify.mjs(静态契约校验,实测 74 项)
``` ```
--- ---
## 3. 本机环境(2026-09-30 实测) ## 3. 本机环境(2026-09-30 实测)
| 项 | 状态 | | 项 | 状态 |
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Node | **v22.15.0**(`D:\Node.js\node.exe`;根 `engines` 要求 ≥ 20.19,Prisma 7 的 engine 约束是 `^20.19 \|\| ^22.12 \|\| >=24.0`,v22.15 都满足) | | 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 桌面端的转发壳,**别绕它**(见下) | | 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`,取全局配置) | | npm registry | `https://registry.npmjs.org/`(仓库内无 `.npmrc`,取全局配置) |
| 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) | | 依赖 | 已装(707 包,`pnpm install` 实测 2m45s) |
| 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` | | 后端 `.env` | 已由 `.env.example` 生成(**gitignored**),`JWT_SECRET` 为本机随机值,`TERM_START_DATE=2026-09-07` |
| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) | | Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
| 数据库 | SQLite `packages/server/prisma/dev.db`:10 个迁移已应用、种子已写入(用户 18 / 班级 3 / 课程 54 / 课表 93 / 作业 15 / 通知 12 / 成绩 90) | | 数据库 | SQLite `packages/server/prisma/dev.db`:10 个迁移已应用、种子已写入(用户 18 / 班级 3 / 课程 54 / 课表 93 / 作业 15 / 通知 12 / 成绩 90) |
| `shared/dist` | 已构建(`pnpm build:shared`)—— **它是三端与 seed 的前置产物,缺了会 `ERR_MODULE_NOT_FOUND`** | | `shared/dist` | 已构建(`pnpm build:shared`)—— **它是三端与 seed 的前置产物,缺了会 `ERR_MODULE_NOT_FOUND`** |
| Electron 二进制 | 已手动装好(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`,246 MB) | | Electron 二进制 | 已手动装好(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`,246 MB) |
| .NET SDK | ✅ **8.0.425**(`C:\Program Files\dotnet\sdk`,`dotnet --list-sdks` 有输出)⇒ 插件可编译/打包;**别再照旧文档说"没装"**(`dotnet` 在 PATH 里,`--list-sdks` 才是权威判据) | | .NET SDK | ✅ **8.0.425**(`C:\Program Files\dotnet\sdk`,`dotnet --list-sdks` 有输出)⇒ 插件可编译/打包;**别再照旧文档说"没装"**(`dotnet` 在 PATH 里,`--list-sdks` 才是权威判据) |
| ClassIsland | **2.1.0.1,装在 `D:\Classisland`**(启动器 `D:\Classisland\ClassIsland.exe`,实际进程 `D:\Classisland\app-2.1.0.1-0\ClassIsland.Desktop.exe`) | | ClassIsland | **2.1.0.1,装在 `D:\Classisland`**(启动器 `D:\Classisland\ClassIsland.exe`,实际进程 `D:\Classisland\app-2.1.0.1-0\ClassIsland.Desktop.exe`) |
| ClassIsland 数据 | 数据根 **`D:\Classisland\data`**:`Config/`、`Profiles/`、`Plugins/`(当前为空,尚未装本插件)、`Logs/`、`Cache/`、`Settings.json` | | ClassIsland 数据 | 数据根 **`D:\Classisland\data`**:`Config/`、`Profiles/`、`Plugins/`(当前为空,尚未装本插件)、`Logs/`、`Cache/`、`Settings.json` |
| 桌面会话 | 有(`ClassIsland.Desktop.exe` 跑在 session 1);**Electron GUI 在 AI 会话里也能跑**(需先修完整性标签,见 §7 第 7 条) | | 桌面会话 | 有(`ClassIsland.Desktop.exe` 跑在 session 1);**Electron GUI 在 AI 会话里也能跑**(需先修完整性标签,见 §7 第 7 条) |
> **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。 > **旧文档里的 `F:\classisland` / `F:\data` 已失效**:本机只有 `C:` 与 `D:`,`F:` 盘不存在。
> 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。 > 另有一份旧副本在 `C:\Users\胡\Desktop\classisland`(数据在它自己的 `data/` 下,2026-09-18 用过),当前以 `D:\Classisland` 为准。
@@ -218,10 +222,10 @@ packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参
`typecheck` 报出 `[pnpm] pnpm not found`。要么别设,要么先 `Remove-Item Env:DSH_PNPM`。 `typecheck` 报出 `[pnpm] pnpm not found`。要么别设,要么先 `Remove-Item Env:DSH_PNPM`。
- 也不要用 `node <pnpm.cjs> <script>` 绕开壳:只在**不带嵌套调用**的脚本上凑效 - 也不要用 `node <pnpm.cjs> <script>` 绕开壳:只在**不带嵌套调用**的脚本上凑效
(`check:icons` / `lint` 能用),`typecheck` 这类内部还要再 `pnpm -r` 的会二次翻车。 (`check:icons` / `lint` 能用),`typecheck` 这类内部还要再 `pnpm -r` 的会二次翻车。
- 反过来说:本机 **Node v22.15.0 够用**(根 `engines` ≥ 20.19,Prisma 7 允许 `^22.12`), - 反过来说:本机 **Node v22.15.0 够用**(根 `engines` ≥ 20.19,Prisma 7 允许 `^22.12`),
不需要为了跑命令去装 v24;上面那条 `runtime not found` 纯粹是壳的版本门槛,不是环境不满足要求。 不需要为了跑命令去装 v24;上面那条 `runtime not found` 纯粹是壳的版本门槛,不是环境不满足要求。
### 从零重建环境(本机实测可行的顺序) ### 从零重建环境(本机实测可行的顺序)
```bash ```bash
@@ -239,16 +243,14 @@ pnpm db:seed # 写演示数据
# 工作目录 node_modules/.pnpm/electron@44.3.0/node_modules/electron # 工作目录 node_modules/.pnpm/electron@44.3.0/node_modules/electron
ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ node install.js ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/ node install.js
# ClassIsland 插件(.NET 8 SDK 已装:8.0.425)
pnpm build:classisland-plugin
``` ```
### 演示账号(`pnpm db:seed` 产出) ### 演示账号(`pnpm db:seed` 产出)
| 角色 | 用户名 | 密码 | | 角色 | 用户名 | 密码 |
| -------------- | ------------------------------- | ------------ | | -------------------------- | ------------------------------- | ------------ |
| 管理员 | `admin` | `admin123` | | 管理员 | `admin` | `admin123` |
| 教师 | `teacher1` / `teacher2` | `teacher123` | | 教师 | `teacher1` / `teacher2` | `teacher123` |
| ClassHelper 班级端班级账号 | 班级码 `G101` / `G102` / `G203` | `123456` | | ClassHelper 班级端班级账号 | 班级码 `G101` / `G102` / `G203` | `123456` |
> 密码是**种子脚本里硬编码**的(`admin123` / `teacher123` / 班级 `123456`); > 密码是**种子脚本里硬编码**的(`admin123` / `teacher123` / 班级 `123456`);
@@ -267,42 +269,38 @@ pnpm build:classisland-plugin
## 4. 常用命令 ## 4. 常用命令
| 命令 | 说明 | | 命令 | 说明 |
| --------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | | --------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) | | `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | | `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) | | `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) |
| `pnpm build` | 构建 shared + 后端 + Web 端 + 客户端 | | `pnpm build` | 构建 shared + 后端 + Web 端 + 客户端 |
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) | | `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) | | `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 `.cache/`) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/<版本>/` | | `pnpm lint` / `pnpm lint:fix` | ESLint |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm check:icons` | 图标注册表门禁(两端 build 已内联,这里可单独跑;见 §5 第 53 条) |
| `pnpm lint` / `pnpm lint:fix` | ESLint | | `pnpm format` / `pnpm format:check` | Prettier(`format:check` 在 master 基线上本就失败,见 §7 第 21 条) |
| `pnpm check:icons` | 图标注册表门禁(两端 build 已内联,这里可单独跑;见 §5 第 53 条) | | `pnpm db:generate` | 生成 Prisma Client |
| `pnpm format` / `pnpm format:check` | Prettier(`format:check` 在 master 基线上本就失败,见 §7 第 21 条) | | `pnpm db:migrate` | `prisma migrate dev`(本机不可靠,别用;见 §7) |
| `pnpm db:generate` | 生成 Prisma Client | | `pnpm --filter @classhelper/server db:deploy` | 应用已有迁移(**本机推荐**;根 scripts 里没有 `db:deploy`) |
| `pnpm db:migrate` | `prisma migrate dev`(本机不可靠,别用;见 §7) | | `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
| `pnpm --filter @classhelper/server db:deploy` | 应用已有迁移(**本机推荐**;根 scripts 里没有 `db:deploy`) | | `pnpm db:studio` | Prisma Studio |
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 | | `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/management/mysql.md`) |
| `pnpm db:studio` | Prisma Studio | | `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-10-06 实测 205 项全过) |
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 改写 `schema.prisma` 的 provider(配合 `docs/management/mysql.md`) | | `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) |
| `pnpm verify:e2e` | 后端端到端验收(**需后端已启动**;2026-10-06 实测 205 项全过) | | `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) |
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动,需后端已启动且 Web 产物已构建) | | `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) |
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | | `pnpm dist:server` / `dist:win` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 全部 |
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(`--exe` 指定路径) | | `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 |
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理;实测 44 项) | | `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 官网 / 文档站 / 界面内品牌标) |
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由 / 已修复坑的护栏;实测 74 项) | | `pnpm version:check` | 逐处列出并比对 12 个落点的版本号,不一致即 exit 1(见 §4.4「版本迭代」) |
| `pnpm dist:server` / `dist:win` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 全部 | | `pnpm version:bump patch\|minor\|major` | **改版本号的唯一入口**:一次改写全部 12 处(含官网三处及下载页兜底值) |
| `pnpm dist:server:linux` | 打包 **Linux 服务端安装包**(`classhelper-server-linux-x64-<版本>.tar.gz` + `.sha256`)—— 见 §5 第 59 条 | | `pnpm docs:build` | 构建文档站到 `docs/_site`(需要 .NET SDK 8 + `dotnet tool install -g docfx`) |
| `pnpm icons` | 从 `build/classhelper.png` 派生全部图标(PWA / favicon / 托盘 / 安装包 / 插件 / 官网 / 文档站 / 界面内品牌标) | | `pnpm docs:serve` | 构建文档站并起本地预览 → http://127.0.0.1:5181 |
| `pnpm version:check` | 逐处列出并比对 14 个落点的版本号,不一致即 exit 1(见 §4.4「版本迭代」) | | `pnpm docs:check` | 构建文档站并把 docfx 的警告当错误(发布前用;最常见的警告是"某个 .md 没被任何 toc.yml 引用") |
| `pnpm version:bump patch\|minor\|major` | **改版本号的唯一入口**:一次改写全部 14 处(含插件清单、C# 里的常量、官网上手写的三处,以及下载页的兜底值) | | `pnpm pages:build` | 把官网(根)与文档站(/docs/)拼成一份 GitHub Pages 产物到 `.cache/pages`(CI 用;见 §4.4) |
| `pnpm docs:build` | 构建文档站到 `docs/_site`(需要 .NET SDK 8 + `dotnet tool install -g docfx`) | | `pnpm dev:site` | 构建文档站后起官网预览 → http://127.0.0.1:5180(文档站挂在同端口的 /docs/ 下,与线上同形) |
| `pnpm docs:serve` | 构建文档站并起本地预览 → http://127.0.0.1:5181 |
| `pnpm docs:check` | 构建文档站并把 docfx 的警告当错误(发布前用;最常见的警告是"某个 .md 没被任何 toc.yml 引用") |
| `pnpm pages:build` | 把官网(根)与文档站(/docs/)拼成一份 GitHub Pages 产物到 `.cache/pages`(CI 用;见 §4.4) |
| `pnpm dev:site` | 构建文档站后起官网预览 → http://127.0.0.1:5180(文档站挂在同端口的 /docs/ 下,与线上同形) |
### 端口 ### 端口
@@ -336,12 +334,12 @@ releases/
**`releases/` 里只放交付物,不放"解包出来的那一份"。** 这条是硬约定,2026-10-11 统一过一遍: **`releases/` 里只放交付物,不放"解包出来的那一份"。** 这条是硬约定,2026-10-11 统一过一遍:
| 曾经有的目录 | 它是什么 | 现在 | | 曾经有的目录 | 它是什么 | 现在 |
| --- | --- | --- | | ------------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------ |
| `client/<版本>/免安装/` | electron-builder 的 `win-unpacked`(约 323MB) | 只在打包过程中存在,生成两个 exe 后由 `tidyArtifacts()` 删除 | | `client/<版本>/免安装/` | electron-builder 的 `win-unpacked`(约 323MB) | 只在打包过程中存在,生成两个 exe 后由 `tidyArtifacts()` 删除 |
| `server/<版本>/免安装/` | `pnpm dist:server` 组装的运行时(约 335MB) | 组装到 `.cache/server-staging/<版本>/win/`,打完 NSIS 即删 | | `server/<版本>/免安装/` | `pnpm dist:server` 组装的运行时(约 335MB) | 组装到 `.cache/server-staging/<版本>/win/`,打完 NSIS 即删 |
| `server/<版本>/linux-x64/classhelper-server-linux-x64/` | tar.gz 的解包留档(约 265MB) | 打完 tar.gz 即删(内容与 tar 完全一样) | | `server/<版本>/linux-x64/classhelper-server-linux-x64/` | tar.gz 的解包留档(约 265MB) | 打完 tar.gz 即删(内容与 tar 完全一样) |
| `classisland-plugin/<版本>/免安装/` | `.cipx` 的解压副本 | 不再产出(`.cipx` 本身就是 zip,解压即用) | | `classisland-plugin/<版本>/免安装/` | `.cipx` 的解压副本 | 不再产出(`.cipx` 本身就是 zip,解压即用) |
理由:这些都是 300MB 量级的**中间物**,而且长得跟交付物一样像"该拷走的东西" —— 理由:这些都是 300MB 量级的**中间物**,而且长得跟交付物一样像"该拷走的东西" ——
五个客户端版本 + 七个服务端版本堆下来就是 4.8GB,其中 3.4GB 是重复的中间物。 五个客户端版本 + 七个服务端版本堆下来就是 4.8GB,其中 3.4GB 是重复的中间物。
@@ -362,13 +360,13 @@ releases/
pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过) pnpm dist:server # 服务端 Windows(首次会跑一次 npm install;重试可加 --reuse-deps 跳过)
pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④) pnpm dist:server:linux # Linux 安装包(**必须在 Linux 上跑**,见 §5 第 59 条 ④)
pnpm dist:win # 客户端:安装包 + 便携版 pnpm dist:win # 客户端:安装包 + 便携版
pnpm dist:classisland-plugin
``` ```
`dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后 `dist-win.mjs` 用 `--config.directories.output` 覆盖 electron-builder 自己的输出目录,打包成功后
再把原始布局整理成上面的分层(`*.exe` → `安装包/`、`*.yml` → `构建中间/`),**最后删掉 `win-unpacked/`**; 再把原始布局整理成上面的分层(`*.exe` → `安装包/`、`*.yml` → `构建中间/`),**最后删掉 `win-unpacked/`**;
显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。 显式传 `-c.directories.output=<路径>` 时不再覆盖,整理以传入路径为准。
两条别动的顺序约束: 两条别动的顺序约束:
- **删 `win-unpacked` 必须排在整理之后**:portable 目标就是拿这份解包结果做自解压包, - **删 `win-unpacked` 必须排在整理之后**:portable 目标就是拿这份解包结果做自解压包,
提前删会让 `-portable.exe` 直接失败。 提前删会让 `-portable.exe` 直接失败。
- **输出目录的完整性标签要在打包前压回 Low**:上一轮收尾把它标成 Medium 之后,7za(自身带 Low) - **输出目录的完整性标签要在打包前压回 Low**:上一轮收尾把它标成 Medium 之后,7za(自身带 Low)
@@ -417,8 +415,6 @@ pnpm lint && pnpm typecheck && pnpm check:icons && pnpm docs:check
# ③ 跑验收(另开一个终端跑着 pnpm dev:server) # ③ 跑验收(另开一个终端跑着 pnpm dev:server)
pnpm verify:e2e pnpm verify:e2e
pnpm verify:classisland
pnpm verify:classisland-plugin
pnpm build:desktop && pnpm verify:desktop pnpm build:desktop && pnpm verify:desktop
pnpm build && pnpm verify:web pnpm build && pnpm verify:web
@@ -446,8 +442,8 @@ gh release upload v<版本> \
> / `-x64-portable.exe`,服务端 `ClassHelper 服务端-<版本>-x64-setup.exe`),而 Release 上的名字 > / `-x64-portable.exe`,服务端 `ClassHelper 服务端-<版本>-x64-setup.exe`),而 Release 上的名字
> 是给别人看、要贴进脚本的 —— 一律用 ASCII 形式,与构建产物一一对应(内容相同、sha256 相同): > 是给别人看、要贴进脚本的 —— 一律用 ASCII 形式,与构建产物一一对应(内容相同、sha256 相同):
> >
> | 构建产物 | Release 上的名字 | > | 构建产物 | Release 上的名字 |
> | --------------------------- | ------------------------------------------- | > | --------------------------- | -------------------------------------------- |
> | `<客户端>-x64-setup.exe` | `ClassHelper-<版本>-x64-client-setup.exe` | > | `<客户端>-x64-setup.exe` | `ClassHelper-<版本>-x64-client-setup.exe` |
> | `<客户端>-x64-portable.exe` | `ClassHelper-<版本>-x64-client-portable.exe` | > | `<客户端>-x64-portable.exe` | `ClassHelper-<版本>-x64-client-portable.exe` |
> | `<服务端>-x64-setup.exe` | `ClassHelper-<版本>-x64-server-setup.exe` | > | `<服务端>-x64-setup.exe` | `ClassHelper-<版本>-x64-server-setup.exe` |
@@ -484,12 +480,12 @@ gh workflow run release-linux-server.yml -f tag=v<版本> -f attach=true
`pnpm dist:all` 只产出安装包。**每次版本迭代都要把这四样一起做掉**,少一样都算「发到一半」: `pnpm dist:all` 只产出安装包。**每次版本迭代都要把这四样一起做掉**,少一样都算「发到一半」:
| # | 交付物 | 落在哪 | 怎么做 | | # | 交付物 | 落在哪 | 怎么做 |
| -- | ------------- | ------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------- | | --- | ------------------ | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| ① | **更新日志** | GitHub Release 的正文 | 手写,草稿放 `releases/ReleaseNotes-<版本>.md`(与它描述的产物同一层),用 `gh release create/edit --notes-file` 发布。**不要**只挂 `--generate-notes` 的自动列表 | | ① | **更新日志** | GitHub Release 的正文 | 手写,草稿放 `releases/ReleaseNotes-<版本>.md`(与它描述的产物同一层),用 `gh release create/edit --notes-file` 发布。**不要**只挂 `--generate-notes` 的自动列表 |
| ② | **Linux 服务端包** | Release 上的 `classhelper-server-linux-x64-<版本>.tar.gz` | tag 推上去由 `release-linux-server.yml` 构建;**推完必须去 Actions 确认没红** | | ② | **Linux 服务端包** | Release 上的 `classhelper-server-linux-x64-<版本>.tar.gz` | tag 推上去由 `release-linux-server.yml` 构建;**推完必须去 Actions 确认没红** |
| ③ | **文档** | `docs/reference.md`(功能说明 + 验收对照表)与相关章节 | 新增功能补对应小节;改交付形态一并改 §2 / §7 与 `docs/dev/*` | | ③ | **文档** | `docs/reference.md`(功能说明 + 验收对照表)与相关章节 | 新增功能补对应小节;改交付形态一并改 §2 / §7 与 `docs/dev/*` |
| ④ | **官网与 README** | `website/index.html`(功能块)、`website/download.html`(兜底版本号)、`README.md`(功能清单) | 新功能补进功能块 / 清单;页面里的版本号由 `pnpm version:bump` 覆盖,不用手写 | | ④ | **官网与 README** | `website/index.html`(功能块)、`website/download.html`(兜底版本号)、`README.md`(功能清单) | 新功能补进功能块 / 清单;页面里的版本号由 `pnpm version:bump` 覆盖,不用手写 |
更新日志的骨架照 1.2.0 / 1.3.0 那两份:**一句话定位 → 分节讲功能 → ⚠️ 升级须知(含自动应用的迁移)→ 更新日志的骨架照 1.2.0 / 1.3.0 那两份:**一句话定位 → 分节讲功能 → ⚠️ 升级须知(含自动应用的迁移)→
验收表 → 产物表**。验收表里**只写真跑过的数字**,没跑就直说没跑,别照抄上一版的。 验收表 → 产物表**。验收表里**只写真跑过的数字**,没跑就直说没跑,别照抄上一版的。
@@ -529,7 +525,7 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站(docfx 构建
- 顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata _chVersion=…` 注入), - 顶栏的版本徽标取自根 `package.json`(`docs/build.mjs` 用 `--metadata _chVersion=…` 注入),
**不要在文档里再写一份版本号**;官网首页那排芯片里的 `v1.1.2` 与下载页的 **不要在文档里再写一份版本号**;官网首页那排芯片里的 `v1.1.2` 与下载页的
`<meta name="ch-version">` 是手写的,发版时要一起改(都在 `pnpm version:bump` 的 14 个落点里); `<meta name="ch-version">` 是手写的,发版时要一起改(都在 `pnpm version:bump` 的 12 个落点里);
- 下载页的版本清单**不在站点里存副本**:每次打开都由 `assets/download.js` 当场读 - 下载页的版本清单**不在站点里存副本**:每次打开都由 `assets/download.js` 当场读
GitHub Releases(匿名接口 60 次/小时/IP),读不到才退回上面那个 meta 按发布命名约定拼直链。 GitHub Releases(匿名接口 60 次/小时/IP),读不到才退回上面那个 meta 按发布命名约定拼直链。
所以发完 Release 不用重新部署这一页,但**兜底值必须跟着发版改**; 所以发完 Release 不用重新部署这一页,但**兜底值必须跟着发版改**;
@@ -552,7 +548,7 @@ term / dashboard / database / update / permissions / clients / system`。
`middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。 `middleware/validate.ts` 挂 zod schema;错误交给 `middleware/error.ts`,不要各写一套。
3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用 3. **鉴权 = JWT + RBAC + 班级内角色。** 路由级用 `requireRole('ADMIN', 'TEACHER')`;班级维度用
`@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/ `@classhelper/shared` 的 `resolveClassRole` → `ADMIN / HEAD(班主任)/ SUBJECT(科任)/
CLASS_DEVICE(班级端)/ NONE`,权限判 `canManageSchedule` / `canPublishNotification` / CLASS_DEVICE(班级端)/ NONE`,权限判 `canManageSchedule` / `canPublishNotification` /
`canManageRoster` / `canManageGrades` / `canManageSubjectContent` 等 `canManageRoster` / `canManageGrades` / `canManageSubjectContent` 等
(人类可读矩阵是 `PERMISSION_MATRIX`),**不要在页面里另写一套规则**。 (人类可读矩阵是 `PERMISSION_MATRIX`),**不要在页面里另写一套规则**。
4. **学生不是账号 —— 这是表结构保证的,别在代码里"兼容"学生账号。** 4. **学生不是账号 —— 这是表结构保证的,别在代码里"兼容"学生账号。**
@@ -592,26 +588,19 @@ term / dashboard / database / update / permissions / clients / system`。
11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root` 11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root`
(`--ch-radius-xl/lg/md/sm/pill`、`--ch-shadow-sm/md`),组件里**不要写死数值**;Element Plus 通过覆盖它的 (`--ch-radius-xl/lg/md/sm/pill`、`--ch-shadow-sm/md`),组件里**不要写死数值**;Element Plus 通过覆盖它的
CSS 变量对齐。手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card`。 CSS 变量对齐。手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card`。
12. **ClassIsland 联动是独立鉴权通道,不要塞进 JWT。** 插件用设备令牌(`X-ClassIsland-Token`,库里只存 sha256, 12. 联动专用约定已退役;离线 JSON 导入继续保留。
明文前缀为 `chci_`)调 `/api/integrations/classisland/*`;Web 端管理设备仍走 JWT + `requireRole`。
新增插件接口时,路由挂 `integrations.module.ts`,并在 `integrations.schemas.ts` 里写 zod —— 插件侧的 C# DTO 13. 联动专用约定已退役;离线 JSON 导入继续保留。
必须**逐字段对齐**(`pnpm verify:classisland-plugin` 会校验字段名与路由)。
13. **课表镜像契约必须带 `startTime`/`endTime`。** ClassIsland 的 `ClassPlan.Classes[i]` 强制与时间表里 14. 联动专用约定已退役;离线 JSON 导入继续保留。
**第 i 个 `TimeType == 0`(上课)的时间点**对齐:镜像下发少一个时间点,整份课表就会错位,
而且表现为"课表看着有内容、每节课都对不上",极难排查。 15. 联动专用约定已退役;离线 JSON 导入继续保留。
14. **所有发布入口共用同一份 ClassIsland 推送实现**(`integrations.service.ts` 的 `pushToClassIsland`):
通知发布 / 叫人 / 联动页的下发都走它。新增「发通知」类入口时**必须**复用,不要再各写一套 ——
否则又会出现「某个页面发的通知不联动 ClassIsland」。
15. **提醒弹在哪个端由教室客户端选**,值存在 `Class.notificationChannel`(`both` / `client` / `classisland`):
客户端设置页写它(班级账号也有写权限,见 `PATCH /api/classes/:id/notification-channel`),
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线, 16. **灵动岛的左右边距只在「左/右停靠」时生效**:默认是「顶部居中」,水平锚点就是屏幕中线,
此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 —— 此时调 marginX 不会有任何变化(设置页会把滑块置灰并说明,别把这个提示删掉 ——
用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。 用户就是因此以为「调了不生效」)。上下边距与所有停靠位置都相关,不受影响。
17. **镜像进 ClassIsland 的课表要带课间**:每两节之间补一段(上一节下课 → 下一节上课), 17. 联动专用约定已退役;离线 JSON 导入继续保留。
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
服务端合成节次时间表同口径。
18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段 18. **提醒分两类,决定"上课时段弹不弹"**:`ClassIslandPush.kind` = `notification`(通知类,上课时段
客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);紧急(`URGENT`)同样立刻弹。 客户端与插件都**暂存**、下课补弹)/`call`(叫人,算「主动通知」,立刻弹);紧急(`URGENT`)同样立刻弹。
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件), 插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件),
@@ -636,16 +625,8 @@ term / dashboard / database / update / permissions / clients / system`。
22. **灵动岛位置由「屏幕工作区 + 边距」算,默认边距 8px(`DEFAULT_ISLAND_APPEARANCE.marginX/marginY`)**: 22. **灵动岛位置由「屏幕工作区 + 边距」算,默认边距 8px(`DEFAULT_ISLAND_APPEARANCE.marginX/marginY`)**:
改默认值会直接改外观,`verify:desktop` 的「停靠位置生效(6 个锚点)」断言的是像素坐标,一动就红。 改默认值会直接改外观,`verify:desktop` 的「停靠位置生效(6 个锚点)」断言的是像素坐标,一动就红。
新增边距类设置请只改"可调范围",别动默认值(夹紧规则收口在主进程 `normalizeIslandAppearance`)。 新增边距类设置请只改"可调范围",别动默认值(夹紧规则收口在主进程 `normalizeIslandAppearance`)。
23. **课表行有"来源"字段 `Schedule.source`**(`manual` / `classisland`)。插件每次全量上报时, 23. 联动专用约定已退役;离线 JSON 导入继续保留。
服务端只清理「`source='classisland'` 且本次未再上报」的行 —— 这样老师在 ClassIsland 里删掉/挪动一节
能真正同步下来(原先 merge 只 upsert,服务端会永远留着幽灵课),**老师在小助手这边手排的课一律不删**。
因此:**新增写课表的入口要显式定 source**(Web 端录入/导入保持默认 `manual`,只有插件上报用 `classisland`),
否则要么幽灵课清不掉,要么手排的课被误删。回归用例见 `verify:classisland` 的「幽灵行清理」两条。
**`source` 只在 create 时确定,update 一律保持原值。** merge 的去重键是「星期 + 开始时间 + 单双周」,
老师在 Web 端手排的同一时间槽会被上报"收养";若收养时把来源改成 `classisland`,
下一次清理就会把这行手排的课删掉 —— 这个坑踩过一次:种子课表被删掉 2 行,
`verify:e2e` 的「上课状态接口」用例(AGENTS.md §7 已列为脆弱用例)随即报红。
同步时**不要**覆盖 `weekStart/weekEnd`:插件上报契约里没有周次范围,硬写"整学期"会把老师设的「第 1~10 周」抹掉。
24. **两个登录入口都必须挂 `loginRateLimiter()`**(`app.ts` 用数组形式同时挂 `/auth/login` 与 `/auth/class-login`)。 24. **两个登录入口都必须挂 `loginRateLimiter()`**(`app.ts` 用数组形式同时挂 `/auth/login` 与 `/auth/class-login`)。
班级密码默认是 `123456`、班级码是固定字符集,班级登录曾是唯一没有暴力破解防护的入口。 班级密码默认是 `123456`、班级码是固定字符集,班级登录曾是唯一没有暴力破解防护的入口。
另外 `auth.service.login` 里**角色判定必须在密码校验之前**:否则"密码错(401) / 密码对但学生账号已停用(403)" 另外 `auth.service.login` 里**角色判定必须在密码校验之前**:否则"密码错(401) / 密码对但学生账号已停用(403)"
@@ -1271,15 +1252,7 @@ term / dashboard / database / update / permissions / clients / system`。
直接写会撞 `User` 外键(见 §5 第 20 条「教室机器能录入作业」)。 直接写会撞 `User` 外键(见 §5 第 20 条「教室机器能录入作业」)。
班级端的成绩查询受 `Class.studentGradeQueryEnabled` 这个**按班开关**控制(默认开启)。 班级端的成绩查询受 `Class.studentGradeQueryEnabled` 这个**按班开关**控制(默认开启)。
65. **课表同步的主方向是「服务端 → 教室」,教室不再自动回传。** 65. 联动专用约定已退役;离线 JSON 导入继续保留。
`IntegrationDevice.mirrorScheduleToClassIsland` 默认 **true**(服务端改完课表自动下发),
`syncScheduleToServer` 默认 **false**。要取教室的课表走
`POST /integrations/devices/:id/request-schedule` —— 它只置一个待办
(`requestScheduleReport`),插件在下一次心跳看到就把课表推上来并清除标记。
Web 端点击前**必须弹警告**(会覆盖 / 合并本班课表)。
插件侧对应 `ReportSettingsDto.scheduleRequested` + `ReportAsync(..., forceSchedule: true)`,
且补发发生在**镜像之前**(顺序反了会把刚拿到的课表盖掉)——
`verify:classisland-plugin` 有 6 条护栏盯着这段契约,别删。
66. **学期周次可以逐周配,别再用"开学日期 + 每周七天"硬推。** 66. **学期周次可以逐周配,别再用"开学日期 + 每周七天"硬推。**
`TermWeek`(`classId = ''` 表示全校默认)存每一周的 `startDate`/`endDate`; `TermWeek`(`classId = ''` 表示全校默认)存每一周的 `startDate`/`endDate`;
@@ -1296,8 +1269,8 @@ term / dashboard / database / update / permissions / clients / system`。
`packages/shared/src/permissions.ts`(`PERMISSION_KEYS` 39 键 / `PERMISSION_LABELS` / `packages/shared/src/permissions.ts`(`PERMISSION_KEYS` 39 键 / `PERMISSION_LABELS` /
`PERMISSION_CATEGORIES` / `PERMISSION_DEFAULT_ROLES` / `ROLE_DEFAULT_PERMISSIONS`)。 `PERMISSION_CATEGORIES` / `PERMISSION_DEFAULT_ROLES` / `ROLE_DEFAULT_PERMISSIONS`)。
有效权限由 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()` 算: 有效权限由 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()` 算:
ADMIN=全集且 `permission:manage`/`system:manage` 不可剥夺;CLASS_DEVICE=空集(不受分组影响); ADMIN 按可编辑默认组/已分配组取权限,且 `permission:manage`/`system:manage` 不可剥夺;CLASS_DEVICE=空集(不受分组影响);
有分组=并集(**可以比角色默认更少**,这就是"能收权");无分组=角色默认模板。 有分组=并集(**可以比角色默认更少**,这就是"能收权");无分组=数据库对应角色的默认组。
它**每请求实时查库**(不缓存),所以改完分组立刻生效。 它**每请求实时查库**(不缓存),所以改完分组立刻生效。
**不可松的边界**:权限组只管功能/路由级;班级维度的科目规则(`assertCanManageSubjectContent`) **不可松的边界**:权限组只管功能/路由级;班级维度的科目规则(`assertCanManageSubjectContent`)
故意不可被权限组覆盖,否则「班主任布置别人科目作业」的老 bug 会从新路长回来。 故意不可被权限组覆盖,否则「班主任布置别人科目作业」的老 bug 会从新路长回来。
@@ -1323,7 +1296,7 @@ term / dashboard / database / update / permissions / clients / system`。
**`executeCommand()` 必须显式监听子进程的 `error` 事件**(2026-10-11 修): **`executeCommand()` 必须显式监听子进程的 `error` 事件**(2026-10-11 修):
`spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在 next tick 发一个异步 `error` 事件 —— `spawn` 找不到可执行文件 / 没权限时**不抛异常**,而是在 next tick 发一个异步 `error` 事件 ——
只包 `try/catch` 会一路走到 `return { ok: true }`,回执写着"已下达系统命令",机器却纹丝不动。 只包 `try/catch` 会一路走到 `return { ok: true }`,回执写着"已下达系统命令",机器却纹丝不动。
现在等 200ms 的窗口确认没立刻报错,失败如实回执。 现在等待进程退出并检查退出码,失败如实回执;关机/重启延迟 5 秒留出回执时间。
**「点了没反应」必须看得出来**:命令被取消 / 过期后就从 `pendingCommand` 里消失,Web 端原本一片空白, **「点了没反应」必须看得出来**:命令被取消 / 过期后就从 `pendingCommand` 里消失,Web 端原本一片空白,
而最常见的两种失败恰恰是"本机没人确认(60 秒自动取消)"与"下达时机器离线"。 而最常见的两种失败恰恰是"本机没人确认(60 秒自动取消)"与"下达时机器离线"。
@@ -1337,7 +1310,7 @@ term / dashboard / database / update / permissions / clients / system`。
所以 `POST /api/update/client/fetch` 由**服务端**下载到 `<serverRoot>/../data/client-releases/<版本>/` 所以 `POST /api/update/client/fetch` 由**服务端**下载到 `<serverRoot>/../data/client-releases/<版本>/`
并登记 sha256+size,`GET /api/update/client/download/:version` **故意不挂权限键**(班级端要能下载)。 并登记 sha256+size,`GET /api/update/client/download/:version` **故意不挂权限键**(班级端要能下载)。
版本号一律先用 `^[0-9A-Za-z._-]+$` 校验(防路径穿越)。客户端自动安装的三条护栏: 版本号一律先用 `^[0-9A-Za-z._-]+$` 校验(防路径穿越)。客户端自动安装的三条护栏:
**没有 sha256 就拒收**、**必须等到"非上课 + 主窗口隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)"**、 **没有 sha256 就拒收**、**按下发策略等待:立即 / 放学后 / 空闲五分钟**、
冒烟模式**永不安装**。 冒烟模式**永不安装**。
71. **通知与作业一样按「所属日期」归档,批量发布在服务端做。** `Notification.assignDate` 与 71. **通知与作业一样按「所属日期」归档,批量发布在服务端做。** `Notification.assignDate` 与
@@ -1387,28 +1360,9 @@ term / dashboard / database / update / permissions / clients / system`。
`app.relaunch()`;**冒烟模式绝不重启**(会杀掉验证实例)。 `app.relaunch()`;**冒烟模式绝不重启**(会杀掉验证实例)。
自愈模块在 `packages/desktop-client/src/main/crash-guard.ts`,限流与 `isIdle` 判据别放宽。 自愈模块在 `packages/desktop-client/src/main/crash-guard.ts`,限流与 `isIdle` 判据别放宽。
78. **课表同步只有两个「一次性动作」,没有"一直同步"的开关。**(2026-10-11 定稿) 78. 联动专用约定已退役;离线 JSON 导入继续保留。
教室 → 服务端走 `IntegrationDevice.requestScheduleReport`(Web 端「获取课表」),
服务端 → 教室走新增的 `requestMirrorSchedule`(「下发课表」)—— 两者都是**待办标记**:
Web 端置位,插件在下一次心跳看到就做一次,做完由服务端清标记。
插件侧的 `UploadSchedule` / `MirrorSchedule` 两个本机开关**已删除**:定时上报现在只报
**运行状态**(当前上什么课),课表只在 `forceSchedule` 时才上传。
**为什么改**:原先默认 `ReportIntervalSeconds = 15` + 两个开关都开,等于教室课表每 15 秒被重写一次、
服务端每 15 秒 merge + 清理一次幽灵行 —— 老师看到的是"课表被反复删改"(线上实测)。
配套:`pullClassPlanForDevice` 拉取成功即清 `requestMirrorSchedule`;插件的
`ClassPlanWriter.Apply(…, force: true)` 会**停用**同一天同一单双周相位下冲突的旧课表
(只停用不删除,别改成删除)。契约锁在 `verify:classisland-plugin` 的两条断言里。
79. **「客户端」页 = 「ClassIsland 联动」+「客户端设备」合并成的一页。**(2026-10-11) 79. 联动专用约定已退役;离线 JSON 导入继续保留。
路由 `/clients`(`ClientHubView.vue`):一个班一张卡片,两条链路的状态放一起,
勾选可批量下发课表 / 更新,点卡片进详情。入口权限用 **`classisland:notify`**
(默认 ADMIN + TEACHER)而不是 `client:manage`(只给 ADMIN)—— 这一页同时承载
「向教室下发提醒」,老师要用;用后者会把教师整个挡在门外。
页内按钮再各自按 `device:manage` / `remote:control` / `client:update-push` 显示或禁用。
两个旧页面**还留着路由但已从侧栏摘掉**(`meta.hidden: true`,`AdminLayout` 的 `menuItems` 是手写列表):
`IntegrationsView` 留提醒下发与联动步骤,`ClientsView` 留客户端安装包缓存管理
(不先把包拉下来就没有版本可下发),从详情抽屉里的两条链接进。
**别把这两页删掉或把它们的路由一起摘了**,会丢功能。
80. **考试成绩的"总分"是考试的属性,不是分数的属性。** `Exam`(班级 + 考试名 + `countInTotal`) 80. **考试成绩的"总分"是考试的属性,不是分数的属性。** `Exam`(班级 + 考试名 + `countInTotal`)
单独一张表,与 `Grade` 只按名字弱关联(不建外键:成绩删光设置还在,补录时口径不丢)。 单独一张表,与 `Grade` 只按名字弱关联(不建外键:成绩删光设置还在,补录时口径不丢)。
@@ -1422,15 +1376,13 @@ term / dashboard / database / update / permissions / clients / system`。
81. **成绩查询开关(`Class.studentGradeQueryEnabled`)只有教师 / 管理员能改。** 81. **成绩查询开关(`Class.studentGradeQueryEnabled`)只有教师 / 管理员能改。**
原先班级端自己也能改(`updateStudentGradeQuery` 里的 `isClassDevice` 分支), 原先班级端自己也能改(`updateStudentGradeQuery` 里的 `isClassDevice` 分支),
等于把"学校说不给看"变成一台机器说了算;现在班级端只读(客户端那个开关也撤掉了, 等于把"学校说不给看"变成一台机器说了算;现在班级端只读(客户端那个开关也撤掉了,
只显示状态)。**注意别拿"通知显示位置(`Class.notificationChannel`)也是班级端自己改"来类比** —— 只显示状态)。成绩查询开关仅教师/管理员可改。
那个确实是现场偏好,两者不再是一套模式。
82. **全校学期开始日期是 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改。** 82. **全校学期开始日期是 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改。**
原先这一级只能来自 `.env` 的 `TERM_START_DATE`,填错了得动文件 + 重启。 原先这一级只能来自 `.env` 的 `TERM_START_DATE`,填错了得动文件 + 重启。
现在 `loadTermContext()` 的三级回落是「`Class.termStartDate` → 全校设置 → `.env`」, 现在 `loadTermContext()` 的三级回落是「`Class.termStartDate` → 全校设置 → `.env`」,
逐周区间仍是「班级 → 全校默认 → 纯线性」。改完 `resolveCurrentWeek` 立刻按新日期算。 逐周区间仍是「班级 → 全校默认 → 纯线性」。改完 `resolveCurrentWeek` 立刻按新日期算。
## 6. 代码风格 ## 6. 代码风格
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀** - **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
@@ -1635,13 +1587,13 @@ term / dashboard / database / update / permissions / clients / system`。
### 已知脆弱用例(不是环境问题,别去"修环境") ### 已知脆弱用例(不是环境问题,别去"修环境")
| 用例 | 触发条件 | | 用例 | 触发条件 |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把「周一第 1 节」限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`TERM_START_DATE` 对齐到 `2026-09-07` 即全绿(本机 `.env` 已这样配;`.env.example` 仍是 `2026-02-23`,重置 `.env` 时记得改回来) | | `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把「周一第 1 节」限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`TERM_START_DATE` 对齐到 `2026-09-07` 即全绿(本机 `.env` 已这样配;`.env.example` 仍是 `2026-02-23`,重置 `.env` 时记得改回来) |
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 | | `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤,`已结束=false` 判定失败 |
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 | | `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里没有可自动建课的「统一科目」分组 |
| `verify:desktop`「作业看板全屏自适应」 | 演示数据里某天有 6 条长作业、而冒烟窗口只有 ~543px 高时,`computeScale` 算出的缩放低于下限 0.45 被 `Math.max(minScale, …)` **钳住**,于是必然溢出(实测 565 > 543)。判据用的是 `host.clientHeight` / `inner.offsetHeight`,**这两个是布局属性、transform 不影响**,所以与动效层无关;数据在冒烟里建了又删,每次跑出来的高度还会差几十像素 | | `verify:desktop`「作业看板全屏自适应」 | 演示数据里某天有 6 条长作业、而冒烟窗口只有 ~543px 高时,`computeScale` 算出的缩放低于下限 0.45 被 `Math.max(minScale, …)` **钳住**,于是必然溢出(实测 565 > 543)。判据用的是 `host.clientHeight` / `inner.offsetHeight`,**这两个是布局属性、transform 不影响**,所以与动效层无关;数据在冒烟里建了又删,每次跑出来的高度还会差几十像素 |
| 岛交互 / 岛动画 / 岛截图留档这几个用例 | **AI 会话里会间歇性失败**:同一份产物连跑两次,失败项会在「点击展开 / 紧急展开动画 / 各状态截图留档」之间跳(2026-09-30 实测三轮结果各不相同)。根因是置顶透明小窗的 rAF 被限流、形变采样抓不到帧,属时序敏感;**先原样重跑一次再动手改代码**,别把偶发当成回归 | | 岛交互 / 岛动画 / 岛截图留档这几个用例 | **AI 会话里会间歇性失败**:同一份产物连跑两次,失败项会在「点击展开 / 紧急展开动画 / 各状态截图留档」之间跳(2026-09-30 实测三轮结果各不相同)。根因是置顶透明小窗的 rAF 被限流、形变采样抓不到帧,属时序敏感;**先原样重跑一次再动手改代码**,别把偶发当成回归 |
--- ---
@@ -1653,8 +1605,6 @@ pnpm typecheck # 全仓库类型检查
pnpm dev:server # 另开终端 pnpm dev:server # 另开终端
pnpm verify:e2e # 后端端到端(2026-10-06 实测 205 项全过) pnpm verify:e2e # 后端端到端(2026-10-06 实测 205 项全过)
pnpm verify:classisland # ClassIsland 联动链路(实测 44 项)
pnpm verify:classisland-plugin # 插件静态契约(不需要后端,实测 74 项)
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条) pnpm build:desktop && pnpm verify:desktop # 客户端冒烟(AI 会话可跑,先看 §7 第 7 条)
pnpm build && pnpm verify:web # Web 端真实点击回归(同上) pnpm build && pnpm verify:web # Web 端真实点击回归(同上)
``` ```
@@ -1681,21 +1631,6 @@ ssh root@<主机> "bash /tmp/verify-linux.sh --admin-password-stdin" < <(printf
**改过这条链路就要在真机上跑一遍**——脚本写在 Windows 上跑不出问题,`set -e` 的坑、 **改过这条链路就要在真机上跑一遍**——脚本写在 Windows 上跑不出问题,`set -e` 的坑、
systemd 的 `EnvironmentFile` 解析、glibc 差异都只有在 Linux 上才暴露。 systemd 的 `EnvironmentFile` 解析、glibc 差异都只有在 Linux 上才暴露。
改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`;
本机已装 ClassIsland 2.1.0.1(`D:\Classisland`)与 .NET 8 SDK(8.0.425),因此**真机验证是可行的**
(直接 `pnpm build:classisland-plugin` 编译,再按 §7 第 12 条部署):
```powershell
# 1) 退出 ClassIsland(DLL 被占用时无法覆盖)→ 2) 覆盖插件 → 3) 重新启动
Get-Process ClassIsland.Desktop | Stop-Process -Force
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll D:\Classisland\data\Plugins\classhelper.classisland.bridge\ -Force
Start-Process D:\Classisland\ClassIsland.exe -WorkingDirectory D:\Classisland
# 4) 在 Web 端「ClassIsland 联动」发一条提醒,看日志里依次出现:
# 取到待弹出提醒 → 已弹出提醒 →(播完)已确认提醒
```
交付时要说明"编译 / 打包 / 静态契约已过,运行期需在装了 ClassIsland 的机器上实测"。
改动涉及打包/交付形态时,额外跑 `pnpm dist:all`(或 `dist:server` + `dist:win`), 改动涉及打包/交付形态时,额外跑 `pnpm dist:all`(或 `dist:server` + `dist:win`),
并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 —— 并用 `pnpm verify:packaged --exe "<客户端路径>"` 对**打包后的副本**复验 ——
开发产物通过不等于用户机器上的副本通过。 开发产物通过不等于用户机器上的副本通过。
@@ -1738,16 +1673,15 @@ DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \
### 这次删了什么、留了什么 ### 这次删了什么、留了什么
| | 内容 | 量 | | | 内容 | 量 |
| --- | --- | --- | | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- |
| 删 | `releases/**/免安装/`(客户端 4 版 + 服务端 4 版 + 插件 4 版) | 2.57 GB | | 删 | `releases/**/免安装/`(客户端 4 版 + 服务端 4 版 + 插件 4 版) | 2.57 GB |
| 删 | `releases/server/{0.1.0,1.0.0,1.0.0-重构建20261005}`(旧产品名"班级小助手"时期的孤立目录) | 204 MB | | 删 | `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.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 | | 删 | `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 | | 删 | `.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 | | 删 | 根目录 `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 |
| **留** | `.cache/{nuget,npm-cache,dotnet-home,mingit}`(工具缓存,按用户要求一个都不删) | 2.4 GB |
合计释放 **约 3.7 GB**,`releases/` 从 4.8 GB 降到 1.4 GB,其余是安装包本体。 合计释放 **约 3.7 GB**,`releases/` 从 4.8 GB 降到 1.4 GB,其余是安装包本体。
@@ -1772,4 +1706,3 @@ DATABASE_PROVIDER=sqlite DATABASE_URL="file:./prune-test.db" \
就在打包过程中拷出来(或直接跑 `pnpm --filter @classhelper/server start`)。 就在打包过程中拷出来(或直接跑 `pnpm --filter @classhelper/server start`)。
- **别在 `releases/` 下留"临时跑一下"的东西**:需要看解包内容就解压 `-portable.exe` 到 `%TEMP%`。 - **别在 `releases/` 下留"临时跑一下"的东西**:需要看解包内容就解压 `-portable.exe` 到 `%TEMP%`。
- 判断某个目录能不能删,就问一句:**"它能不能用仓库里的脚本一条命令重建?"** 能就删。 - 判断某个目录能不能删,就问一句:**"它能不能用仓库里的脚本一条命令重建?"** 能就删。
+60 -68
View File
@@ -17,7 +17,7 @@
[![仓库大小](https://img.shields.io/github/repo-size/LoveChengke/ClassHelper?style=flat-square&color=3cb371)](https://github.com/LoveChengke/ClassHelper) [![仓库大小](https://img.shields.io/github/repo-size/LoveChengke/ClassHelper?style=flat-square&color=3cb371)](https://github.com/LoveChengke/ClassHelper)
ClassHelper是给中小学班级用的一套信息管理系统。<br/> ClassHelper是给中小学班级用的一套信息管理系统。<br/>
**老师发一条通知,教室那台机器上的灵动岛和 [ClassIsland](https://classisland.tech) 会同时弹出来**;<br/> **老师发一条通知,教室客户端与灵动岛会实时提醒**;<br/>
学生打开客户端就能看到当天的课表和作业。 学生打开客户端就能看到当天的课表和作业。
#### [🚀 软件下载](https://github.com/LoveChengke/ClassHelper/releases/latest) | [📚 使用文档](docs/index.md) | [🧭 完整参考](docs/reference.md) | [💬 反馈问题](https://github.com/LoveChengke/ClassHelper/issues) #### [🚀 软件下载](https://github.com/LoveChengke/ClassHelper/releases/latest) | [📚 使用文档](docs/index.md) | [🧭 完整参考](docs/reference.md) | [💬 反馈问题](https://github.com/LoveChengke/ClassHelper/issues)
@@ -26,17 +26,15 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
## 它由什么组成 ## 它由什么组成
一份代码,四个交付物: 一份代码,三个交付物:
| 装在哪 | 是什么 | 谁在用 | | 装在哪 | 是什么 | 谁在用 |
| -------------------------- | ---------------------------------------------------------------------------------- | ---------------------- | | ----------------------- | -------------------------------------------------------------------- | -------------------- |
| 教师的电脑 / 学校服务器 | **服务端**(Express + Prisma + Socket.IO),同时托管 Web 管理端 | 其它三端 | | 教师的电脑 / 学校服务器 | **服务端**(Express + Prisma + Socket.IO),同时托管 Web 管理端 | 管理端与客户端 |
| 浏览器(手机也行) | **Web 管理端**(Vue 3 + Element Plus,PWA 可安装) | 老师、班主任、管理员 | | 浏览器(手机也行) | **Web 管理端**(Vue 3 + Element Plus,PWA 可安装) | 老师、班主任、管理员 |
| 教室那台机器 / 学生电脑 | **桌面客户端**(Electron + Vue 3),含「灵动岛」浮窗、托盘、离线缓存 | 教室大屏、学生 | | 教室那台机器 / 学生电脑 | **桌面客户端**(Electron + Vue 3),含「灵动岛」浮窗、托盘、离线缓存 | 教室大屏、学生 |
| 教室机器上的 ClassIsland | **联动插件**(.NET 8 / C#),上报课表与上课状态、把老师的提醒弹到大屏上 | 教室机器 |
老师在 Web 端发布内容 → 服务端落库并按班级房间广播 → 客户端与灵动岛实时更新; 老师在 Web 端发布内容 → 服务端落库并按班级房间广播 → 客户端与灵动岛实时更新。
落到 ClassIsland 上的那部分走设备令牌的独立通道,教室网络不好也不影响。
## 功能 ## 功能
@@ -50,11 +48,9 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] 通知发布:三档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认 - [x] 通知发布:三档优先级;在**上课时段**发紧急通知会被拦下来并要求二次确认
- [x] 叫人:点名某位同学到办公室,分「普通」与「紧急」两级 - [x] 叫人:点名某位同学到办公室,分「普通」与「紧急」两级
- [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计;单科排名自动出,可选「各科相加排总分榜」 - [x] 成绩录入:单条 / 批量 / 表格导入,带等级分布与得分率统计;单科排名自动出,可选「各科相加排总分榜」
- [x] ClassIsland 联动:签发设备令牌、下发提醒、把课表镜像回教室大屏 - [x] 客户端管理:查看设备在线状态与版本,远控关机、重启、注销与锁屏;推送更新可选立即、放学后或空闲时安装
- [x] 数据库管理:连接体检、备份与恢复、SQLite ⇄ MySQL 一键切换 - [x] 数据库管理:连接体检、备份与恢复、SQLite ⇄ MySQL 一键切换
- [x] 权限组:39 个权限键,建组分配账号 —— 没分组的按角色默认生效,分组后只按组的并集算 - [x] 权限组:37 个权限键,建组分配账号 —— 默认组可自定义,没分组的按角色默认组生效,分组后只按组的并集算
- [x] 客户端(合并页):一个班一张卡片,大屏与灵动岛两条链路一起看;勾选可批量下发课表 / 更新,点进去做逐台操作
- [x] 课表同步按需:点「下发课表」把课表写进教室并停用冲突的旧课表,点「获取课表」把教室的课表取回来 —— 没有"一直同步"
- [x] 客户端设备:每台教室机器的版本、在线状态与最后心跳;一键推送更新,可下发关机 / 重启 / 注销 / 锁屏 - [x] 客户端设备:每台教室机器的版本、在线状态与最后心跳;一键推送更新,可下发关机 / 重启 / 注销 / 锁屏
- [x] 大模型助手:一句话解析成待确认的作业 / 通知 / 叫人,点确认才落库 - [x] 大模型助手:一句话解析成待确认的作业 / 通知 / 叫人,点确认才落库
- [x] 通知与作业批量发布:勾多个班一次发完,个别班失败不影响其余班级 - [x] 通知与作业批量发布:勾多个班一次发完,个别班失败不影响其余班级
@@ -83,13 +79,6 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
- [x] 纯黑 / 毛玻璃 / 主题色渐变三种底 - [x] 纯黑 / 毛玻璃 / 主题色渐变三种底
- [x] 触摸屏(希沃白板这类一体机)也能点开 - [x] 触摸屏(希沃白板这类一体机)也能点开
### ClassIsland 联动
- [x] 教室机器上报课表与上课状态,Web 端能看到「现在在上什么」
- [x] 老师发的提醒在教室大屏上全屏弹出,支持语音朗读,播完回执不重复弹
- [x] 可以把 ClassHelper排好的课表镜像回 ClassIsland,不影响老师原有的课表
- [x] 提醒弹在客户端还是 ClassIsland,由教室那台机器自己选
### 部署与运维 ### 部署与运维
- [x] Windows 一键安装:内置 Node 运行时,双击安装、开机自启、自动建库建号 - [x] Windows 一键安装:内置 Node 运行时,双击安装、开机自启、自动建库建号
@@ -109,18 +98,17 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
<details> <details>
<summary>展开其余页面……</summary> <summary>展开其余页面……</summary>
| 页面 | 截图 | | 页面 | 截图 |
| --- | --- | | ---------- | -------------------------------------------------------------- |
| 仪表盘 | <img src="docs/images/web-dashboard.jpg" alt="仪表盘" /> | | 仪表盘 | <img src="docs/images/web-dashboard.jpg" alt="仪表盘" /> |
| 班级管理 | <img src="docs/images/web-classes.jpg" alt="班级管理" /> | | 班级管理 | <img src="docs/images/web-classes.jpg" alt="班级管理" /> |
| 学生管理 | <img src="docs/images/web-students.jpg" alt="学生管理" /> | | 学生管理 | <img src="docs/images/web-students.jpg" alt="学生管理" /> |
| 教师管理 | <img src="docs/images/web-teachers.jpg" alt="教师管理" /> | | 教师管理 | <img src="docs/images/web-teachers.jpg" alt="教师管理" /> |
| 课表管理 | <img src="docs/images/web-schedules.jpg" alt="课表管理" /> | | 课表管理 | <img src="docs/images/web-schedules.jpg" alt="课表管理" /> |
| 作业发布 | <img src="docs/images/web-homeworks.jpg" alt="作业发布" /> | | 作业发布 | <img src="docs/images/web-homeworks.jpg" alt="作业发布" /> |
| 通知发布 | <img src="docs/images/web-notifications.jpg" alt="通知发布" /> | | 通知发布 | <img src="docs/images/web-notifications.jpg" alt="通知发布" /> |
| 成绩录入 | <img src="docs/images/web-grades.jpg" alt="成绩录入" /> | | 成绩录入 | <img src="docs/images/web-grades.jpg" alt="成绩录入" /> |
| ClassIsland 联动 | <img src="docs/images/web-integrations.jpg" alt="ClassIsland 联动" /> | | 数据库管理 | <img src="docs/images/web-database.jpg" alt="数据库管理" /> |
| 数据库管理 | <img src="docs/images/web-database.jpg" alt="数据库管理" /> |
</details> </details>
@@ -131,47 +119,46 @@ ClassHelper是给中小学班级用的一套信息管理系统。<br/>
<details> <details>
<summary>展开其余页面……</summary> <summary>展开其余页面……</summary>
| 页面 | 截图 | | 页面 | 截图 |
| --- | --- | | ----------------------- | -------------------------------------------------------------------------- |
| 作业看板 | <img src="docs/images/client-homeworks.png" alt="作业看板" /> | | 作业看板 | <img src="docs/images/client-homeworks.png" alt="作业看板" /> |
| 通知 | <img src="docs/images/client-notifications.png" alt="通知" /> | | 通知 | <img src="docs/images/client-notifications.png" alt="通知" /> |
| 成绩 | <img src="docs/images/client-grades.png" alt="成绩" /> | | 成绩 | <img src="docs/images/client-grades.png" alt="成绩" /> |
| 设置 · 灵动岛 | <img src="docs/images/client-settings-island.png" alt="设置 · 灵动岛" /> | | 设置 · 灵动岛 | <img src="docs/images/client-settings-island.png" alt="设置 · 灵动岛" /> |
| 设置 · 外观(深色主题) | <img src="docs/images/client-settings-appearance.png" alt="设置 · 外观" /> | | 设置 · 外观(深色主题) | <img src="docs/images/client-settings-appearance.png" alt="设置 · 外观" /> |
</details> </details>
### 灵动岛 ### 灵动岛
| 形态 | 截图 | 说明 | | 形态 | 截图 | 说明 |
| --- | --- | --- | | ------------------ | ----------------------------------------------------------------------- | ----------------------------------------------- |
| 新消息胶囊 | <img src="docs/images/island-4-pill.png" alt="新消息胶囊" /> | 非上课时段直接出现,点一下展开 | | 新消息胶囊 | <img src="docs/images/island-4-pill.png" alt="新消息胶囊" /> | 非上课时段直接出现,点一下展开 |
| 点开后的详情 | <img src="docs/images/island-5-clicked.png" alt="详情卡" /> | 标题、内容、时间 + 打开应用 / 标为已读 / 知道了 | | 点开后的详情 | <img src="docs/images/island-5-clicked.png" alt="详情卡" /> | 标题、内容、时间 + 打开应用 / 标为已读 / 知道了 |
| 多条消息列表 | <img src="docs/images/island-9-list.png" alt="多条消息列表" /> | 按重要程度排序,默认显示三条 | | 多条消息列表 | <img src="docs/images/island-9-list.png" alt="多条消息列表" /> | 按重要程度排序,默认显示三条 |
| 上课期间的紧急通知 | <img src="docs/images/island-3-urgent.png" alt="紧急通知" /> | 卡片内部红色呼吸光晕,无需点击 | | 上课期间的紧急通知 | <img src="docs/images/island-3-urgent.png" alt="紧急通知" /> | 卡片内部红色呼吸光晕,无需点击 |
| 下课后补弹 | <img src="docs/images/island-2-after-class.png" alt="下课后自动弹出" /> | 上课期间暂存的通知,下课后自动弹出来 | | 下课后补弹 | <img src="docs/images/island-2-after-class.png" alt="下课后自动弹出" /> | 上课期间暂存的通知,下课后自动弹出来 |
| 老师叫人 | <img src="docs/images/island-8-call.png" alt="叫人卡片" /> | 「请 XXX 同学找 XXX 老师」 | | 老师叫人 | <img src="docs/images/island-8-call.png" alt="叫人卡片" /> | 「请 XXX 同学找 XXX 老师」 |
| 新作业 | <img src="docs/images/island-7-homework.png" alt="新作业" /> | 教室机器自己录的那条不会弹给自己 | | 新作业 | <img src="docs/images/island-7-homework.png" alt="新作业" /> | 教室机器自己录的那条不会弹给自己 |
### 手机上 ### 手机上
<details> <details>
<summary>展开……</summary> <summary>展开……</summary>
| 场景 | 截图 | | 场景 | 截图 |
| --- | --- | | -------- | ------------------------------------------------------------------------ |
| 仪表盘 | <img src="docs/images/mobile-dashboard.jpg" alt="手机 · 仪表盘" /> | | 仪表盘 | <img src="docs/images/mobile-dashboard.jpg" alt="手机 · 仪表盘" /> |
| 通知发布 | <img src="docs/images/mobile-notifications.jpg" alt="手机 · 通知发布" /> | | 通知发布 | <img src="docs/images/mobile-notifications.jpg" alt="手机 · 通知发布" /> |
</details> </details>
## 开始使用 ## 开始使用
| 装在哪 | 装什么 | 从 Releases 里拿 | | 装在哪 | 装什么 | 从 Releases 里拿 |
| ----------------------- | ------------------------------------------------- | --------------------------------------------------------------------------- | | ----------------------- | ------------------------------------------- | --------------------------------------------------------------------------- |
| 教师的电脑 / 学校服务器 | **服务端**(内含 Web 管理端与 Node 运行时) | `ClassHelper-<版本>-x64-server-setup.exe` | | 教师的电脑 / 学校服务器 | **服务端**(内含 Web 管理端与 Node 运行时) | `ClassHelper-<版本>-x64-server-setup.exe` |
| 教室机器 / 学生电脑 | **学生客户端** | `ClassHelper-<版本>-x64-client-setup.exe`,或 `-client-portable.exe` 免安装 | | 教室机器 / 学生电脑 | **学生客户端** | `ClassHelper-<版本>-x64-client-setup.exe`,或 `-client-portable.exe` 免安装 |
| 教室机器的 ClassIsland | **联动插件** | `ClassHelper.ClassIslandPlugin.cipx` |
也可以在官网的[下载页](https://lovechengke.github.io/ClassHelper/download.html)上挑: 也可以在官网的[下载页](https://lovechengke.github.io/ClassHelper/download.html)上挑:
选一个版本,四个包排成四张卡,文件名、体积与 SHA256 清单都在那儿 —— 选一个版本,四个包排成四张卡,文件名、体积与 SHA256 清单都在那儿 ——
@@ -198,13 +185,12 @@ curl -fsSL -o /tmp/classhelper-install.sh https://raw.githubusercontent.com/Love
## 开发 ## 开发
| 层 | 选型 | | 层 | 选型 |
| -------------- | ------------------------------------------------------------------------ | | ---------- | ------------------------------------------------------------------- |
| 后端 | Node.js ≥ 20.19 + Express 5 + Prisma 7(SQLite / MySQL)+ Socket.IO | | 后端 | Node.js ≥ 20.19 + Express 5 + Prisma 7(SQLite / MySQL)+ Socket.IO |
| Web 管理端 | Vue 3 + Vite + Element Plus + Pinia | | Web 管理端 | Vue 3 + Vite + Element Plus + Pinia |
| 桌面客户端 | Electron 44 + Vue 3 + IndexedDB 离线缓存(含独立的灵动岛渲染进程) | | 桌面客户端 | Electron 44 + Vue 3 + IndexedDB 离线缓存(含独立的灵动岛渲染进程) |
| ClassIsland 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | | 共享契约 | `packages/shared`:类型、常量、权限矩阵、动效令牌 |
| 共享契约 | `packages/shared`:类型、常量、权限矩阵、动效令牌 |
```bash ```bash
pnpm install pnpm install
@@ -218,11 +204,11 @@ pnpm dev # 后端 4000 + Web 端 5
`pnpm db:seed` 会写一套演示数据(1 管理员 / 2 教师 / 3 个班 / 15 名学生 / 课表 / 作业 / 通知 / 成绩): `pnpm db:seed` 会写一套演示数据(1 管理员 / 2 教师 / 3 个班 / 15 名学生 / 课表 / 作业 / 通知 / 成绩):
| 角色 | 账号 | 密码 | | 角色 | 账号 | 密码 |
| -------------- | --------------- | ------------ | | -------------------------- | ------------- | ------------ |
| 管理员 | `admin` | `admin123` | | 管理员 | `admin` | `admin123` |
| 教师 | `teacher1` | `teacher123` | | 教师 | `teacher1` | `teacher123` |
| ClassHelper 班级端(班级) | 班级码 `G101` | `123456` | | ClassHelper 班级端(班级) | 班级码 `G101` | `123456` |
- [AGENTS.md](AGENTS.md) —— 工程约定与开发环境注意事项,**改代码前先读它** - [AGENTS.md](AGENTS.md) —— 工程约定与开发环境注意事项,**改代码前先读它**
- [docs/reference.md](docs/reference.md) —— 完整参考(功能行为、接口、验收记录) - [docs/reference.md](docs/reference.md) —— 完整参考(功能行为、接口、验收记录)
@@ -239,3 +225,9 @@ pnpm dev # 后端 4000 + Web 端 5
如果这个项目对你有帮助,欢迎点亮 Star ⭐ 如果这个项目对你有帮助,欢迎点亮 Star ⭐
</div> </div>
### 客户端维护
支持远程关机、重启、注销与锁屏(管理端和本机分别确认),可查看最近一次执行结果。
统一更新支持立即安装、放学后安装或空闲时安装,支持取消下发。
管理员与教师的默认权限组均可自定义;管理员保留权限管理和系统管理入口。
+1 -1
View File
@@ -23,7 +23,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.3.2} image: classhelper-server:${APP_VERSION:-1.3.3}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+1 -1
View File
@@ -60,7 +60,7 @@ services:
context: .. context: ..
dockerfile: deploy/Dockerfile dockerfile: deploy/Dockerfile
target: runtime target: runtime
image: classhelper-server:${APP_VERSION:-1.3.2} image: classhelper-server:${APP_VERSION:-1.3.3}
restart: unless-stopped restart: unless-stopped
environment: environment:
NODE_ENV: production NODE_ENV: production
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "classhelper-server-runtime", "name": "classhelper-server-runtime",
"version": "1.3.2", "version": "1.3.3",
"private": true, "private": true,
"type": "module", "type": "module",
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)", "description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
+2 -3
View File
@@ -19,7 +19,7 @@ description: 老师点名让学生过来:普通与紧急两档、快捷短语
## 两档 ## 两档
| 级别 | 落库级别 | ClassHelper 班级端行为 | | 级别 | 落库级别 | ClassHelper 班级端行为 |
| ------------ | -------- | ----------------------------------------------------------------------------------------------------- | | ------------ | -------- | ----------------------------------------------------------------------------------------------------- |
| **普通叫人** | `HIGH` | 课间先显示「叫人」胶囊,点击展开;**上课时段只进队列、不打断课堂**,下课后自动弹出详情(90 秒后收起) | | **普通叫人** | `HIGH` | 课间先显示「叫人」胶囊,点击展开;**上课时段只进队列、不打断课堂**,下课后自动弹出详情(90 秒后收起) |
| **紧急叫人** | `URGENT` | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 | | **紧急叫人** | `URGENT` | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 |
@@ -53,5 +53,4 @@ description: 老师点名让学生过来:普通与紧急两档、快捷短语
- `notification:new` 到班级房间(通知中心与灵动岛都靠它); - `notification:new` 到班级房间(通知中心与灵动岛都靠它);
- `call:new` 定向到被叫学生 / 班级设备。 - `call:new` 定向到被叫学生 / 班级设备。
如果教室的「通知显示位置」不是"只在 ClassHelper",这条叫人也会推给 ClassIsland, 叫人通过实时通道送达本班客户端与灵动岛。
教室大屏一并播报。
+1 -1
View File
@@ -27,7 +27,7 @@ description: ClassHelper每一项功能怎么用:课表、作业、通知、
[角色与权限](../dev/permissions.md)。 [角色与权限](../dev/permissions.md)。
**二、上课时段不打扰课堂。** 「上课时段」由课表实时判定,普通通知与普通叫人只进队列、下课补弹; **二、上课时段不打扰课堂。** 「上课时段」由课表实时判定,普通通知与普通叫人只进队列、下课补弹;
紧急通知与紧急叫人才会立刻打断。规则只有一份,客户端与 ClassIsland 都遵守它。 紧急通知与紧急叫人才会立刻打断。客户端与灵动岛共用这套规则。
细节见[通知与提醒](notifications.md)。 细节见[通知与提醒](notifications.md)。
**三、实时推送只走班级房间。** 老师一发内容,服务端写库后按班级房间广播,客户端立刻更新, **三、实时推送只走班级房间。** 老师一发内容,服务端写库后按班级房间广播,客户端立刻更新,
+8 -23
View File
@@ -10,7 +10,7 @@ description: 通知的三档优先级、上课时段策略、紧急通知的二
## 三档优先级 ## 三档优先级
| 优先级 | ClassHelper 班级端表现 | | 优先级 | ClassHelper 班级端表现 |
| ------ | -------------------------------------------------------------------------------------------- | | ------ | -------------------------------------------------------------------------------------------- |
| 普通 | 课间弹「新消息」胶囊,点击展开详情;**上课时段完全不显示**(窗口隐藏)并进队列,下课自动补弹 | | 普通 | 课间弹「新消息」胶囊,点击展开详情;**上课时段完全不显示**(窗口隐藏)并进队列,下课自动补弹 |
| 重要 | 同上,卡片上有优先级标签 | | 重要 | 同上,卡片上有优先级标签 |
@@ -23,12 +23,10 @@ description: 通知的三档优先级、上课时段策略、紧急通知的二
「上课时段」由课表实时判定(见[课表](schedules.md))。判定之后: 「上课时段」由课表实时判定(见[课表](schedules.md))。判定之后:
| ClassHelper 班级端行为 | 上课时段 | 课间 / 放学 | | ClassHelper 班级端行为 | 上课时段 | 课间 / 放学 |
| ---------- | ---------------------------------------- | ----------- | | ---------------------- | ---------------------------------------- | ----------- |
| 普通通知 | 收着不弹(窗口隐藏),进待发队列 | 直接弹 | | 普通通知 | 收着不弹(窗口隐藏),进待发队列 | 直接弹 |
| 紧急通知 | **立刻弹** | 立刻弹 | | 紧急通知 | **立刻弹** | 立刻弹 |
| 任何点击 | 一律不响应(不会展开、也不会回缩出胶囊) | 正常 | | 任何点击 | 一律不响应(不会展开、也不会回缩出胶囊) | 正常 |
ClassIsland 那一端遵守同一套规则,否则会出现"学生在客户端看不到、却在教室大屏上被打断"。
## 紧急通知的二次确认 ## 紧急通知的二次确认
@@ -48,20 +46,9 @@ Web 端在提交前会先查一次上课状态,命中就弹一个**全屏二
**这个拦截在服务端,不是前端提醒。** 绕过界面直接调接口一样会被拦 —— 这是产品行为,不是可以关掉的提示。 **这个拦截在服务端,不是前端提醒。** 绕过界面直接调接口一样会被拦 —— 这是产品行为,不是可以关掉的提示。
## 提醒弹在哪个端 ## 提醒显示
教室机器上的客户端「设置 → 提醒」里选,选择会同步到班级记录: 通知通过实时通道发送到 ClassHelper 客户端与灵动岛。设置页显示本机提醒规则,旧的跨应用通知渠道已移除。
| 选择 | ClassHelper 客户端 | ClassIsland |
| ----------------------------------------- | ------------------------------ | ----------- |
| `ClassHelper 与 ClassIsland 都弹`(默认) | 弹窗 + 灵动岛 | 全屏提醒 |
| `只在 ClassHelper 客户端弹` | 弹窗 + 灵动岛 | 不推送 |
| `只在 ClassIsland 上弹` | 只进通知中心(不弹窗、不上岛) | 全屏提醒 |
这条规则**任何入口都遵守**,包括 ClassIsland 联动页的「下发提醒」——
那种情况下会回一句"该班教室已设置为只在 ClassHelper 客户端显示"。
管理员与教师也可以用 `PATCH /api/classes/:id/notification-channel` 改,但默认由教室机器自己定。
## 已读、红点与删除 ## 已读、红点与删除
@@ -74,9 +61,7 @@ Web 端在提交前会先查一次上课状态,命中就弹一个**全屏二
## 发布入口只有一个实现 ## 发布入口只有一个实现
「通知发布」页、叫人、ClassIsland 联动页的「下发提醒」三个入口, 「通知发布」与叫人复用同一套服务端发布流程和实时广播。
**共用同一份 ClassIsland 推送实现**。所以不存在"某个页面发的通知不联动"这种事 ——
以前有过,现在是硬约定:新增发通知的入口必须复用同一份代码。
## 接口 ## 接口
+5 -17
View File
@@ -51,27 +51,15 @@ GET /api/schedules/current?classId=<班级 id>[&at=<ISO 时间>]
返回 `{ inClass, current, next, week, serverTime }`。`at` 参数可以覆盖判定时刻,用来联调与测试。 返回 `{ inClass, current, next, week, serverTime }`。`at` 参数可以覆盖判定时刻,用来联调与测试。
判定结果被用在四处,规则完全一致: 判定结果被用在三处,规则完全一致:
1. 客户端灵动岛:上课时段普通通知**不显示**(窗口直接隐藏)并进队列,下课自动补弹; 1. 客户端灵动岛:上课时段普通通知**不显示**(窗口直接隐藏)并进队列,下课自动补弹;
2. ClassIsland 插件:同样暂存,下课后补弹; 2. 服务端发布接口:上课时段发紧急通知会被拦(`409 URGENT_DURING_CLASS`),确认后放行;
3. 服务端发布接口:上课时段发紧急通知会被拦(`409 URGENT_DURING_CLASS`),确认后放行; 3. Web 端:提交紧急通知前先查一次状态,命中就弹全屏二次确认。
4. Web 端:提交紧急通知前先查一次状态,命中就弹全屏二次确认。
## 课表的"来源"字段 ## 课表来源
每一行课表都记着它是从哪来的: 课表由管理端维护,支持手工录入与离线 JSON 导入。升级会保留旧联动课表并将其来源归为 `manual`。
| 来源 | 什么时候产生 | 会被自动清理吗 |
| ------------- | ---------------------------------------------- | ---------------------------- |
| `manual` | Web 端手工录入、从 ClassIsland JSON 一次性导入 | **不会**,永远保留 |
| `classisland` | 教室插件上报同步过来的 | 会——本次没再上报的行会被删掉 |
这条规则解决了"老师在 ClassIsland 里删掉一节,小助手这边却永远留着"的幽灵课问题,
同时保证老师在小助手这边手排的课不会被插件覆盖掉。
> 去重键是「星期 + 开始时间 + 单双周」。如果老师手排的某一节恰好和上报的同一时间槽重合,
> 那一行会被上报"收养"(保留原本的 `manual` 来源),下一次清理不会删它。
## 排课 / 改课 / 导入 ## 排课 / 改课 / 导入
+100 -109
View File
@@ -10,129 +10,120 @@ description: ClassHelper后端全部接口一览:方法、路径、权限与
- 所有接口前缀 `/api`(设备接口也一样); - 所有接口前缀 `/api`(设备接口也一样);
- 统一响应体:`{ "success": true, "data": {}, "message": "" }`,错误为 `success: false` + `code`; - 统一响应体:`{ "success": true, "data": {}, "message": "" }`,错误为 `success: false` + `code`;
- 除登录、健康检查与设备接口外,都需要 `Authorization: Bearer <token>`; - 除登录、健康检查与设备接口外,都需要 `Authorization: Bearer <token>`;
- **ClassIsland 设备接口用 `X-ClassIsland-Token`**,不走 JWT(见[ClassIsland 插件](classisland-plugin.md));
- 请求体校验走 zod(`middleware/validate.ts`),错误由 `middleware/error.ts` 统一映射。 - 请求体校验走 zod(`middleware/validate.ts`),错误由 `middleware/error.ts` 统一映射。
## 接口一览 ## 接口一览
| 方法 | 路径 | 权限 | 说明 | | 方法 | 路径 | 权限 | 说明 |
| --------------------------- | ------------------------------------------------------------- | ---------------------------- | ------------------------------------------------------------------------------ | | --------------------------- | ------------------------------------------------------------- | ---------------------------- | --------------------------------------------------------------------------------- |
| POST | `/auth/login` | 公开 | 登录(教师 / 管理员的**工号** + 密码);学生不是账号,拿学号登录只会得到 401 | | POST | `/auth/login` | 公开 | 登录(教师 / 管理员的**工号** + 密码);学生不是账号,拿学号登录只会得到 401 |
| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → 班级会话 | | POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → 班级会话 |
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) | | GET | `/auth/me` | 登录 | 当前用户(学生附带班级 / 年级) |
| PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) | | PATCH | `/auth/password` | 登录 | 修改自己的密码(班级会话 → 班级密码) |
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | | POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
| GET | `/permissions/me` | 登录 | 当前账号的生效权限(**无权限键**;班级端会话返回空集) | | GET | `/permissions/me` | 登录 | 当前账号的生效权限(**无权限键**;班级端会话返回空集) |
| GET | `/permissions/catalog` | `permission:manage` | 权限键目录(权限页整棵勾选树的唯一来源) | | GET | `/permissions/catalog` | `permission:manage` | 权限键目录(权限页整棵勾选树的唯一来源) |
| GET / POST | `/permissions/groups` | `permission:manage` | 权限组列表 / 新建 | | GET / POST | `/permissions/groups` | `permission:manage` | 权限组列表 / 新建 |
| PATCH / DELETE | `/permissions/groups/:id` | `permission:manage` | 改名 / 改描述 / 整体替换权限键;内置组不可删 | | PATCH / DELETE | `/permissions/groups/:id` | `permission:manage` | 改名 / 改描述 / 整体替换权限键;内置组不可删 |
| GET | `/permissions/users` | `permission:manage` | 成员列表(所属权限组 + 生效权限数量) | | GET | `/permissions/users` | `permission:manage` | 成员列表(所属权限组 + 生效权限数量) |
| PUT | `/permissions/users/:id/groups` | `permission:manage` | 整体替换某账号的权限组(空数组 = 回落角色默认模板) | | PUT | `/permissions/users/:id/groups` | `permission:manage` | 整体替换某账号的权限组(空数组 = 回落角色默认模板) |
| GET | `/classes` | 登录 | 班级列表(按权限收敛) | | GET | `/classes` | 登录 | 班级列表(按权限收敛) |
| GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 班主任 / 各科任课老师) | | GET | `/classes/:id` | 班级可见 | 班级详情(学生 / 课程 / 班主任 / 各科任课老师) |
| POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) | | POST | `/classes` | 管理员 | 创建班级(自动生成班级码,可指定班主任) |
| PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 | | PATCH / DELETE | `/classes/:id` | 管理员 / 教师 | 编辑 / 删除班级 |
| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) | | PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) |
| PATCH | `/classes/:id/head-teacher` | 管理员 | 设置 / 更换班主任 | | PATCH | `/classes/:id/head-teacher` | 管理员 | 设置 / 更换班主任 |
| GET | `/classes/:id/classisland-status` | 班级可见 | 本班 ClassIsland 联动状态(教室客户端设置页用) | | GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生 |
| GET | `/classes/:id/notification-channel` | 班级可见 | 读取「通知显示到哪个端」 | | DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 |
| PATCH | `/classes/:id/notification-channel` | 班级账号 / 教师 / 管理员 | 设置「通知显示到哪个端」 | | PUT | `/classes/:id/subject-teachers` | ADMIN | 设置各科任课老师(班级 + 科目 + 教师) |
| GET / POST | `/classes/:id/students` | 班级可见 / ADMIN·TEACHER | 学生名单 / 添加学生 | | GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任 / 管理员) | 课程管理 |
| DELETE | `/classes/:id/students/:userId` | ADMIN·TEACHER | 移出学生 | | GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(`week` 过滤周次范围) |
| PUT | `/classes/:id/subject-teachers` | ADMIN | 设置各科任课老师(班级 + 科目 + 教师) | | GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) |
| GET / POST / PATCH / DELETE | `/courses` | 登录(写:班主任 / 管理员) | 课程管理 | | GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) |
| GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(`week` 过滤周次范围) | | POST / PATCH / DELETE | `/schedules[/:id]` | 班主任 / 管理员 | 课表增删改(广播 `schedule:updated`) |
| GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | | GET | `/homeworks?classId=&courseId=&date=&pendingOnly=&keyword=` | 登录 | 作业列表(支持按所属日期;学生带完成状态,教师带完成人数) |
| GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) | | GET | `/homeworks/days?classId=&from=&to=` | 登录 | 哪些天有作业(日期高亮) |
| POST / PATCH / DELETE | `/schedules[/:id]` | 班主任 / 管理员 | 课表增删改(广播 `schedule:updated`) | | GET | `/homeworks/:id` | 班级可见 | 作业详情 |
| GET | `/homeworks?classId=&courseId=&date=&pendingOnly=&keyword=` | 登录 | 作业列表(支持按所属日期;学生带完成状态,教师带完成人数) | | POST | `/homeworks` | 教师 / 管理员 / **班级账号** | 发布作业(附 `assignDate`;班级账号用于教室机器录入,归属班主任) |
| GET | `/homeworks/days?classId=&from=&to=` | 登录 | 哪些天有作业(日期高亮) | | PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) |
| GET | `/homeworks/:id` | 班级可见 | 作业详情 | | PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) |
| POST | `/homeworks` | 教师 / 管理员 / **班级账号** | 发布作业(附 `assignDate`;班级账号用于教室机器录入,归属班主任) | | GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 |
| PATCH / DELETE | `/homeworks/:id` | 教师 / 管理员 | 修改 / 删除(广播 `homework:new` / `homework:updated`) | | POST | `/homeworks/bulk` | `homework:write` | 一次给多个班级布置同一份作业(`subjectName` 跨班按名匹配科目;逐班结果) |
| PATCH | `/homeworks/:id/status` | 登录 | 标记完成 / 取消(广播 `homework:status`) | | GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态;另支持 `date` / `from` / `to` / `createdBy`) |
| GET / PATCH | `/homeworks/:id/submissions` | 登录 | 未交名单:读名单 / 回写全班完成状态 | | GET | `/notifications/unread-count` | 登录 | 未读数(红点) |
| POST | `/homeworks/bulk` | `homework:write` | 一次给多个班级布置同一份作业(`subjectName` 跨班按名匹配科目;逐班结果) | | POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发紧急通知需 `confirmDuringClass` |
| GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态;另支持 `date` / `from` / `to` / `createdBy`) | | POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) |
| GET | `/notifications/unread-count` | 登录 | 未读数(红点) | | DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 |
| POST | `/notifications` | 教师 / 管理员 | 发布通知(广播 `notification:new`);上课时段发紧急通知需 `confirmDuringClass` | | GET | `/notifications/days?classId=&from=&to=&createdBy=` | 登录 | 通知按天归档(日期 + 条数,日期取 `assignDate`) |
| POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读(班级设备会写全班) | | GET | `/notifications/creators?classId=&from=&to=&date=` | 登录 | 通知按发布人归档 |
| DELETE | `/notifications/:id` | 教师 / 管理员 | 删除通知 | | POST | `/notifications/bulk` | `notification:publish` | 一次给多个班级发同一条通知(逐班结果,**单班失败不影响整批**) |
| GET | `/notifications/days?classId=&from=&to=&createdBy=` | 登录 | 通知按天归档(日期 + 条数,日期取 `assignDate`) | | POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) |
| GET | `/notifications/creators?classId=&from=&to=&date=` | 登录 | 通知按发布人归档 | | GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) |
| POST | `/notifications/bulk` | `notification:publish` | 一次给多个班级发同一条通知(逐班结果,**单班失败不影响整批**) | | GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 |
| POST | `/calls` | 教师 / 管理员 | **叫人**(`urgent` 决定 URGENT / HIGH;广播 `notification:new` 与 `call:new`) | | GET | `/grades/stats?classId=&courseId=&examName=` | 教师 / 管理员 | 等级分布 + 各课程平均得分率 |
| GET | `/grades/my` | 登录 | 个人成绩(班级会话 → 全班总览) | | POST | `/grades`、`/grades/bulk` | 班主任 / 管理员 | 单条 / 批量录入(广播 `grade:updated`) |
| GET | `/grades?classId=&courseId=&userId=&examName=` | 教师 / 管理员 | 班级成绩 | | PATCH / DELETE | `/grades/:id` | 班主任 / 管理员 | 修改 / 删除成绩 |
| GET | `/grades/stats?classId=&courseId=&examName=` | 教师 / 管理员 | 等级分布 + 各课程平均得分率 | | GET | `/students?classId=&keyword=` | **管理员** | 学生名单 |
| POST | `/grades`、`/grades/bulk` | 班主任 / 管理员 | 单条 / 批量录入(广播 `grade:updated`) | | POST / PATCH / DELETE | `/students[/:id]` | **管理员** | 学生名单增删改(无密码概念;重置密码接口已下线 → 404) |
| PATCH / DELETE | `/grades/:id` | 班主任 / 管理员 | 修改 / 删除成绩 | | GET | `/teachers?keyword=` | **管理员** | 教师列表 |
| GET | `/students?classId=&keyword=` | **管理员** | 学生名单 | | POST / PATCH / DELETE | `/teachers[/:id]` | **管理员** | 新建 / 编辑 / 删除教师账号 |
| POST / PATCH / DELETE | `/students[/:id]` | **管理员** | 学生名单增删改(无密码概念;重置密码接口已下线 → 404) | | POST | `/teachers/:id/reset-password` | **管理员** | 修改 / 重置教师密码(`newPassword` 可选,留空 = 默认初始密码) |
| GET | `/teachers?keyword=` | **管理员** | 教师列表 | | GET | `/dashboard/summary`、`/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 |
| POST / PATCH / DELETE | `/teachers[/:id]` | **管理员** | 新建 / 编辑 / 删除教师账号 | | GET | `/imports/template?kind=&format=` | 管理员 / 班主任 | 导入模板下载(`csv` 走 JSON,`xlsx` 走二进制) |
| POST | `/teachers/:id/reset-password` | **管理员** | 修改 / 重置教师密码(`newPassword` 可选,留空 = 默认初始密码) | | POST | `/imports/table/preview` | 管理员 / 班主任 | 上传表格(base64)解析预览:列名 + 前 20 行 + 校验问题 + 建议映射 |
| GET | `/dashboard/summary`、`/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 | | POST | `/imports/table/commit` | 管理员 / 班主任 | 按字段映射与写入模式导入(成绩 / 学生名单 / 教师名单) |
| GET | `/imports/template?kind=&format=` | 管理员 / 班主任 | 导入模板下载(`csv` 走 JSON,`xlsx` 走二进制) | | POST | `/imports/time-layout/preview` | 管理员 / 本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) |
| POST | `/imports/table/preview` | 管理员 / 班主任 | 上传表格(base64)解析预览:列名 + 前 20 行 + 校验问题 + 建议映射 | | GET / POST / DELETE | `/imports/time-layout[/:id]` | 管理员 / 本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 |
| POST | `/imports/table/commit` | 管理员 / 班主任 | 按字段映射与写入模式导入(成绩 / 学生名单 / 教师名单) | | POST | `/imports/class-plan/preview`、`/imports/class-plan` | 管理员 / 本班班主任 | ClassIsland 课程表解析预览 / 导入(支持单双周) |
| POST | `/imports/time-layout/preview` | 管理员 / 本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) | | GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 23 张表行数 / 备份列表 / 定时配置) |
| GET / POST / DELETE | `/imports/time-layout[/:id]` | 管理员 / 本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 | | POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) |
| POST | `/imports/class-plan/preview`、`/imports/class-plan` | 管理员 / 本班班主任 | ClassIsland 课程表解析预览 / 导入(支持单双周) | | GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) |
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员 / 教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) | | POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 |
| POST | `/integrations/devices/:id/token` | 管理员 / 教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) | | GET | `/database/export`、`/database/sqlite-file` | **管理员** | 下载 JSON 快照 / 下载数据库文件(后者仅 SQLite) |
| POST | `/integrations/classisland/notify` | 管理员 / 教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) | | POST | `/database/import` | **管理员** | 导入快照(base64,整库覆盖;可在 SQLite / MySQL 之间互迁) |
| GET | `/database/status` | **管理员** | 数据库状态(连接 / 版本 / 体积 / 23 张表行数 / 备份列表 / 定时配置) | | GET / PUT | `/database/backup-schedule` | **管理员** | 定时备份配置(enabled / intervalHours / keepCount) |
| POST | `/database/test-connection` | **管理员** | 测试任意目标库连通性(provider 仅 sqlite / mysql) | | POST / GET | `/database/switch`、`/database/switch/jobs/:id` | **管理员** | **一键切换数据库**(异步任务)/ 轮询进度 |
| GET / POST | `/database/backups` | **管理员** | 备份列表 / 立即备份(gzip JSON 快照,存 `<数据目录>/backups/`) | | GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) |
| POST / DELETE | `/database/backups/:name/restore` · `/database/backups/:name` | **管理员** | 从备份恢复(整库覆盖)/ 删除备份 | | POST | `/clients/heartbeat` | 登录 | 客户端每 30 秒上报(**无权限键**);返回更新目标 + 安装包信息 + 一条待执行命令 |
| GET | `/database/export`、`/database/sqlite-file` | **管理员** | 下载 JSON 快照 / 下载数据库文件(后者仅 SQLite) | | POST | `/clients/commands/:id/ack` | 登录 | 命令执行回执(**无权限键**,靠 body 的 `deviceKey` 校验归属,幂等) |
| POST | `/database/import` | **管理员** | 导入快照(base64,整库覆盖;可在 SQLite / MySQL 之间互迁) | | POST | `/clients/commands` | `remote:control` | 下发远控命令(关机 / 重启 / 注销 / 锁屏;自动撤销该机更早的未回执命令) |
| GET / PUT | `/database/backup-schedule` | **管理员** | 定时备份配置(enabled / intervalHours / keepCount) | | GET | `/clients/devices?classId=` | `client:manage` | 客户端设备列表(版本 / 在线 / 待执行命令) |
| POST / GET | `/database/switch`、`/database/switch/jobs/:id` | **管理员** | **一键切换数据库**(异步任务)/ 轮询进度 | | POST | `/clients/updates` | `client:update-push` | 统一下发客户端更新(按设备 / 班级 / 全部) |
| POST | `/integrations/classisland/report` | **设备令牌** | 插件上报状态 + 课表 + 节次时间 | | DELETE | `/clients/devices/:id` | `client:manage` | 移除设备记录(换机 / 旧机器清理) |
| GET | `/integrations/classisland/pending` | **设备令牌** | 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐) | | GET | `/update/client/releases` | `client:manage` | 服务端已缓存了哪些客户端安装包 |
| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) | | POST | `/update/client/fetch` | `client:update-push` | 服务端代教室机器从 GitHub 下载安装包并登记 sha256 + size |
| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland | | POST | `/update/client/releases` | `client:update-push` | 手工登记已放进磁盘的安装包(内网 / U 盘分发兜底) |
| GET | `/update/check` | 登录 | 更新检查(服务端代查 GitHub 最新 Release;`?force=1` 绕过缓存仅管理员生效) | | DELETE | `/update/client/releases/:version` | `client:update-push` | 删除缓存的安装包(元数据 + 磁盘文件) |
| POST | `/clients/heartbeat` | 登录 | 客户端每 30 秒上报(**无权限键**);返回更新目标 + 安装包信息 + 一条待执行命令 | | GET | `/update/client/download/:version` | 登录 | 下载安装包(**故意不挂权限键**,班级端要能下;`res.download`) |
| POST | `/clients/commands/:id/ack` | 登录 | 命令执行回执(**无权限键**,靠 body 的 `deviceKey` 校验归属,幂等) | | GET / PUT | `/ai/settings` | `ai:manage` | 大模型设置(API Key 加密存储、**只进不出**,只回 `hasApiKey` / `keyHint`) |
| 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` | | POST | `/ai/interpret` | `ai:use` | 一句话解析(**只解析不落库**);回 `action` / `params` / `preview` / `confidence` |
| GET | `/ai/audit?limit=` | `ai:manage` | 助手使用记录(每次解析写一条 `AiAudit`) | | GET | `/ai/audit?limit=` | `ai:manage` | 助手使用记录(每次解析写一条 `AiAudit`) |
| GET / PUT | `/system/settings` | `system:manage` | 系统设置(`developerMode`:开启后 500 响应带 `error.stack`) | | GET / PUT | `/system/settings` | `system:manage` | 系统设置(`developerMode`:开启后 500 响应带 `error.stack`) |
| GET / PUT | `/system/autostart` | `system:manage` | 开机自启状态 / 开关(Windows 注册表 Run 项,Linux systemd 单元) | | GET / PUT | `/system/autostart` | `system:manage` | 开机自启状态 / 开关(Windows 注册表 Run 项,Linux systemd 单元) |
| GET | `/system/diagnostics` | `system:manage` | 运行诊断(版本 / Node / 运行时长 / 数据目录 / 在线客户端数 / 已加载模块) | | GET | `/system/diagnostics` | `system:manage` | 运行诊断(版本 / Node / 运行时长 / 数据目录 / 在线客户端数 / 已加载模块) |
| GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块) | | GET | `/health` | 公开 | 健康检查(含版本号与已挂载模块) |
另有三个探针(不带 `/api` 前缀):`/healthz`(存活)、`/readyz`(就绪)、以及 `/api/health`(详情)。 另有三个探针(不带 `/api` 前缀):`/healthz`(存活)、`/readyz`(就绪)、以及 `/api/health`(详情)。
## 几条容易踩的接口约定 ## 几条容易踩的接口约定
| 约定 | 说明 | | 约定 | 说明 |
| -------------------------------------------- | ------------------------------------------------------------------------------------------------ | | -------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **`POST /homeworks` 故意不加 `requireRole`** | 权限判在 `homeworks.service.createHomework` 里(staff 或本班班级账号),因为教室机器也要能录作业 | | **`POST /homeworks` 故意不加 `requireRole`** | 权限判在 `homeworks.service.createHomework` 里(staff 或本班班级账号),因为教室机器也要能录作业 |
| **班级账号的 `sub` 是班级 id** | 写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键 | | **班级账号的 `sub` 是班级 id** | 写 `createdBy` 前要换成该班班主任,否则撞 `User` 外键 |
| **登录限流挂在两个入口上** | `/auth/login` 与 `/auth/class-login` 都挂 `loginRateLimiter()` | | **登录限流挂在两个入口上** | `/auth/login` 与 `/auth/class-login` 都挂 `loginRateLimiter()` |
| **角色判定在密码校验之前** | 否则"密码错 / 密码对但账号停用"的差异会把学生账号密码变成可离线验证的预言机 | | **角色判定在密码校验之前** | 否则"密码错 / 密码对但账号停用"的差异会把学生账号密码变成可离线验证的预言机 |
| **会渲染成链接的字段要校验 scheme** | `Homework.attachmentUrl` 只允许 `http(s)://` 或站内相对路径 | | **会渲染成链接的字段要校验 scheme** | `Homework.attachmentUrl` 只允许 `http(s)://` 或站内相对路径 |
| **表格导入 8MB ↔ 请求体 12MB** | 文件以 base64 放进 JSON(约 4/3 倍),超限映射成 413 `IMPORT_TOO_LARGE` | | **表格导入 8MB ↔ 请求体 12MB** | 文件以 base64 放进 JSON(约 4/3 倍),超限映射成 413 `IMPORT_TOO_LARGE` |
| **三个接口故意不挂权限键** | `/permissions/me`、`/clients/heartbeat`、`/update/client/download/:version` —— 前两者由班级端(`CLASS_DEVICE`,权限集为空)调用,下载接口要能装包;保护只有"已登录 + 限流" | | **三个接口故意不挂权限键** | `/permissions/me`、`/clients/heartbeat`、`/update/client/download/:version` —— 前两者由班级端(`CLASS_DEVICE`,权限集为空)调用,下载接口要能装包;保护只有"已登录 + 限流" |
| **查询参数校验失败是 422** | `middleware/validate.ts` 的 zod 解析错误由错误中间件映射成 422 `VALIDATION_ERROR`(不是 400) | | **查询参数校验失败是 422** | `middleware/validate.ts` 的 zod 解析错误由错误中间件映射成 422 `VALIDATION_ERROR`(不是 400) |
| **批量发布逐班判定、不整批回滚** | `/notifications/bulk` 与 `/homeworks/bulk` 的单班失败(403 越权 / `URGENT_DURING_CLASS`)不影响其它班,成功班照常落库并实时推送 | | **批量发布逐班判定、不整批回滚** | `/notifications/bulk` 与 `/homeworks/bulk` 的单班失败(403 越权 / `URGENT_DURING_CLASS`)不影响其它班,成功班照常落库并实时推送 |
| **`/ai/interpret` 只解析不落库** | 助手只回结构化参数与中文预览;落库由前端确认后调既有端点 —— 权限与上课时段保护都在那条路径上 | | **`/ai/interpret` 只解析不落库** | 助手只回结构化参数与中文预览;落库由前端确认后调既有端点 —— 权限与上课时段保护都在那条路径上 |
## 相关 ## 相关
- 实时事件见[实时事件(Socket.IO)](realtime.md); - 实时事件见[实时事件(Socket.IO)](realtime.md);
- 权限矩阵见[角色与权限](permissions.md); - 权限矩阵见[角色与权限](permissions.md);
- 数据模型见[数据模型](data-model.md)。 - 数据模型见[数据模型](data-model.md)。
客户端心跳可携带 `agentProtocol: 2`,业务数据读取统一响应的 `data`。未带此标记时额外返回旧版需要的顶层字段;放学后更新在到时前不向旧客户端提供安装包。推送更新支持 `mode: immediate | after-school | idle`,`afterSchoolTime` 为服务端本地时区的 `HH:mm`。
+14 -17
View File
@@ -18,7 +18,6 @@ description: 目录结构、技术栈、模块化设计与几条贯穿全局的
| 校验 | zod | 4.6 | | 校验 | zod | 4.6 |
| Web 端 | Vue + Vite + Element Plus + Pinia + Vue Router + Axios | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 | | Web 端 | Vue + Vite + Element Plus + Pinia + Vue Router + Axios | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 |
| 客户端 | Electron + Vue + Element Plus + Pinia + Socket.IO Client + IndexedDB | 44.3 / 3.5 / 2.14 / 4.0 / 4.8 | | 客户端 | Electron + Vue + Element Plus + Pinia + Socket.IO Client + IndexedDB | 44.3 / 3.5 / 2.14 / 4.0 / 4.8 |
| 插件 | .NET 8 / C# + Avalonia(ClassIsland 插件 API 2.0) | net8.0 |
| 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 | | 打包 | electron-builder(nsis / portable)+ NSIS | 26.15 |
| 文档站 | docfx | 2.81 | | 文档站 | docfx | 2.81 |
@@ -56,7 +55,7 @@ class-helper/
│ ├── get-started/ app/ management/ dev/ # 四个章节 │ ├── get-started/ app/ management/ dev/ # 四个章节
│ ├── templates/classhelper/ # docfx 模板覆盖(_master.tmpl + public/main.css + 图标) │ ├── templates/classhelper/ # docfx 模板覆盖(_master.tmpl + public/main.css + 图标)
│ ├── images/ # 展示图:文档站 / README / 官网共用的同一份(1× 逻辑像素) │ ├── images/ # 展示图:文档站 / README / 官网共用的同一份(1× 逻辑像素)
│ ├── screenshots/ # 回归测试留档的截图(island / client / web-mobile / classisland),页面不展示 │ ├── screenshots/ # 回归测试留档的截图(island / client / web-mobile),页面不展示
│ ├── build.mjs # 构建入口(注入版本号与 404 的 base) │ ├── build.mjs # 构建入口(注入版本号与 404 的 base)
│ ├── serve.mjs # 本地预览(:5181) │ ├── serve.mjs # 本地预览(:5181)
│ └── _site/ # 构建产物(gitignore) │ └── _site/ # 构建产物(gitignore)
@@ -69,7 +68,7 @@ class-helper/
│ ├── prisma/schema.prisma # 数据模型(24 个 model,只有一份,切库改 provider) │ ├── prisma/schema.prisma # 数据模型(24 个 model,只有一份,切库改 provider)
│ ├── prisma/migrations/ # 迁移历史(10 个) │ ├── prisma/migrations/ # 迁移历史(10 个)
│ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据) │ ├── prisma/seed.ts # 种子数据(会清空业务表后重建演示数据)
│ ├── scripts/ # verify-e2e.mjs / verify-classisland.mjs / apply-column-migrations.cjs │ ├── scripts/ # verify-e2e.mjs / apply-column-migrations.cjs
│ └── src/ │ └── src/
│ ├── app.ts # Express 装配(探针 + 限流 + 模块挂载 + 静态托管 + 兜底) │ ├── app.ts # Express 装配(探针 + 限流 + 模块挂载 + 静态托管 + 兜底)
│ ├── index.ts # 启动入口(自检 + HTTP + Socket.IO + 优雅退出) │ ├── index.ts # 启动入口(自检 + HTTP + Socket.IO + 优雅退出)
@@ -80,7 +79,6 @@ class-helper/
│ ├── middleware/ # auth / error / validate / security(helmet + 限流 + 耗时日志) │ ├── middleware/ # auth / error / validate / security(helmet + 限流 + 耗时日志)
│ ├── realtime/ # socket.ts(房间)+ bus.ts(事件总线) │ ├── realtime/ # socket.ts(房间)+ bus.ts(事件总线)
│ └── modules/ # 15 个功能模块 + registry.ts(模块注册表) │ └── modules/ # 15 个功能模块 + registry.ts(模块注册表)
├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace)
│ ├── manifest.yml │ ├── manifest.yml
│ ├── src/Plugin.cs │ ├── src/Plugin.cs
│ ├── src/Services/ # BridgeService / ScheduleMapper / ClassPlanWriter / │ ├── src/Services/ # BridgeService / ScheduleMapper / ClassPlanWriter /
@@ -105,7 +103,7 @@ class-helper/
后端每个功能是一个独立目录,统一契约: 后端每个功能是一个独立目录,统一契约:
```ts ```ts
// packages/server/src/modules/registry.ts(当前 21 个模块) // packages/server/src/modules/registry.ts(当前 20 个模块)
export const apiModules: ApiModule[] = [ export const apiModules: ApiModule[] = [
authModule, authModule,
aiModule, // 大模型助手(只解析不落库,见 docs/dev/api.md) aiModule, // 大模型助手(只解析不落库,见 docs/dev/api.md)
@@ -116,7 +114,6 @@ export const apiModules: ApiModule[] = [
notificationsModule, notificationsModule,
callsModule, callsModule,
importsModule, importsModule,
integrationsModule,
gradesModule, gradesModule,
studentsModule, studentsModule,
teachersModule, teachersModule,
@@ -141,18 +138,18 @@ export const apiModules: ApiModule[] = [
这些都是长期演进中固化下来的,改代码前值得先看(完整版在 `AGENTS.md`): 这些都是长期演进中固化下来的,改代码前值得先看(完整版在 `AGENTS.md`):
| 约定 | 为什么 | | 约定 | 为什么 |
| ---------------------------- | ------------------------------------------------------------------------------------------------------------ | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- |
| **统一响应与校验** | 用 `lib/http.ts` 的 `sendOk/sendCreated`,用 `middleware/validate.ts` 挂 zod,错误交给 `middleware/error.ts` | | **统一响应与校验** | 用 `lib/http.ts` 的 `sendOk/sendCreated`,用 `middleware/validate.ts` 挂 zod,错误交给 `middleware/error.ts` |
| **功能级准入看权限键** | 路由挂 `requirePermission('…')` / `requireAnyPermission(…)`,角色只是默认模板(`ROLE_DEFAULT_PERMISSIONS`)—— 见 `docs/dev/permissions.md` | | **功能级准入看权限键** | 路由挂 `requirePermission('…')` / `requireAnyPermission(…)`,角色只是默认模板(`ROLE_DEFAULT_PERMISSIONS`)—— 见 `docs/dev/permissions.md` |
| **权限判在服务层** | 权限组只管功能级;班级维度与科目维度仍用 `lib/access.ts` 的断言(`assertCanManageSubjectContent` 等),**权限组不可覆盖** —— 前端隐藏 ≠ 权限 | | **权限判在服务层** | 权限组只管功能级;班级维度与科目维度仍用 `lib/access.ts` 的断言(`assertCanManageSubjectContent` 等),**权限组不可覆盖** —— 前端隐藏 ≠ 权限 |
| **周一为新一周的第一天** | 周次口径唯一:`resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)` | | **周一为新一周的第一天** | 周次口径唯一:`resolveCurrentWeek(TERM_START_DATE, now, MAX_TERM_WEEK)` |
| **作业按 `assignDate` 归类** | 用 `createdAt`(UTC)会在晚上把作业算到第二天 | | **作业按 `assignDate` 归类** | 用 `createdAt`(UTC)会在晚上把作业算到第二天 |
| **新增实时事件要改三处** | 服务端 bus/socket、Web 端订阅、客户端订阅(灵动岛也依赖) | | **新增实时事件要改三处** | 服务端 bus/socket、Web 端订阅、客户端订阅(灵动岛也依赖) |
| **跨桥接只传纯数据** | 传 Vue 的 `ref.value` 会在进入 preload 之前就抛 `could not be cloned` | | **跨桥接只传纯数据** | 传 Vue 的 `ref.value` 会在进入 preload 之前就抛 `could not be cloned` |
| **桌面端产物里不得有凭据** | asar 可直接解包;构建有 `check-bundle-secrets` 门禁 | | **桌面端产物里不得有凭据** | asar 可直接解包;构建有 `check-bundle-secrets` 门禁 |
| **图标只有一个图形源** | 别改 `scripts/icons/` 的派生逻辑,换图标就换 `build/classhelper.{png,ico}` | | **图标只有一个图形源** | 别改 `scripts/icons/` 的派生逻辑,换图标就换 `build/classhelper.{png,ico}` |
| **前端不写死颜色** | 深色主题靠语义令牌,写死的颜色在深色下会露馅 | | **前端不写死颜色** | 深色主题靠语义令牌,写死的颜色在深色下会露馅 |
## 本地开发 ## 本地开发
-149
View File
@@ -1,149 +0,0 @@
---
title: ClassIsland 插件
description: 联动插件的契约、镜像为什么要带节次时间、上课时段暂存,以及部署与调试。
---
# ClassIsland 插件
源码在 `packages/classisland-plugin/`,是一个 **.NET 8 / C#** 的 ClassIsland 插件
(ClassIsland 插件 API 2.0)。它**独立于 pnpm workspace**,不参与 `pnpm install`。
## 三条链路
```
ClassIsland(教室机器)
│ ① 上报:状态 + 课表 + 节次时间(每次上报顺带取回一条待弹提醒)
▼
/api/integrations/classisland/report ← X-ClassIsland-Token(设备令牌,非 JWT)
│
│ ② 下发:老师在 Web 端发提醒 → 落库 → 插件下次上报时取回 → ClassIsland 全屏弹出 → ack
▼
/api/integrations/classisland/pending、/ack
│ ③ 镜像(可选):服务端把本班课表下发,插件在 ClassIsland 里新建一份档案课表
▼
/api/integrations/classisland/class-plan
```
## 为什么走 HTTP 而不是 IPC
ClassIsland 的跨进程 IPC 数据质量不足以还原课表。插件内部**直接读**
`ILessonsService`(课程状态)与 `IProfileService`(档案课表)最准。
与后端之间用「**上报即拉取**」:每次上报的返回值里顺带带一条待弹出的提醒 ——
插件因此**不需要 WebSocket 依赖**,故障面更小,而且**上报间隔本身就是心跳**。
提醒是**轮询取回**的(默认最多 10 秒延迟,设置页「提醒轮询间隔」可调到 5 秒)。
## 设备令牌
- 令牌是**设备级**的,不是用户级;
- 插件在本机保存明文,服务端只存 **sha256** 与一个前缀提示(`chci_xxxxxxxx`)用于人眼识别;
- 同一班级下按 `deviceKey`(机器码)唯一 —— 插件重装后带同一 `deviceKey` 会**自动重绑**到原记录,
不会在设备列表里堆重复项;
- 关闭设备的「启用」开关后,该设备的上报与下发一律 403。
## 提醒的排队规则
| 提醒类型 | 上课时段 | 下课 / 放学 |
| ------------------------------------------------------ | ---------------------------- | ----------- |
| 通知类(`kind=notification`:通知发布页 / 联动页下发) | **暂存不弹** | 自动补弹 |
| 主动通知:紧急提醒、**叫人**(`kind=call`) | **立刻弹**(老师正在等学生) | 立刻弹 |
插件侧的实现在 `ClassHelperNotificationProvider`(`_deferred` + `FlushDeferred`,订阅课程事件);
客户端侧的实现在 `renderer/island/bridge.ts`。**改这条规则要两端一起改。**
暂存期间**不回执**,所以服务端会一直保留着待提醒;`ClassIslandPush.expiresAt` 到期后不再补发
(避免学生几天后突然弹出旧提醒)。
## 镜像:为什么必须带 `startTime` / `endTime`
ClassIsland 的 `ClassPlan.Classes[i]` **强制与时间表里第 i 个 `TimeType == 0`(上课)的时间点对齐**。
所以镜像下发时,每一条课目都要带上自己的 `startTime` / `endTime`。
> **少一个时间点,整份课表就会错位** —— 而且表现为"课表看着有内容、每节课都对不上",
> 极难排查。这是插件契约里最容易踩的坑。
另外镜像进 ClassIsland 的课表还要**带课间**:每两节之间补一段(上一节下课 → 下一节上课),
**最后一节之后不补**(放学)。实现见 `ClassPlanWriter.BuildTimeLayout`(自己按课目时间算),
服务端合成节次时间表用的是同一口径。
## 上报课表:单向还是双向
**双向对齐**。插件每次全量上报时,服务端只清理「`source='classisland'` 且本次未再上报」的行:
- 老师在 ClassIsland 里删掉 / 挪动一节 → 小助手这边跟着同步;
- 老师在小助手这边手排的课 → **一律不删**。
merge 的去重键是「星期 + 开始时间 + 单双周」。导入时**不要覆盖 `weekStart` / `weekEnd`** ——
插件上报契约里没有周次范围,硬写"整学期"会把老师设的「第 1~10 周」抹掉。
## 单双周的映射
ClassIsland 的 `WeekCountDiv` / `WeekCountDivTotal` ⇄ 本项目的 `ALL / ODD / EVEN`,
走 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` ——
**服务端与插件共用同一份语义**,两边不会各算各的。
## 代理:本地地址强制直连
`ClassHelperClient` 里有一个 `LocalAwareProxy`:对本地 / 内网地址**强制直连**,
不走系统代理。
原因:教室机器上如果开了系统代理,填好令牌后表现为"立刻 HTTP 404"(其实是代理返回的错误页)。
`pnpm verify:classisland-plugin` 有 4 项断言盯着这段逻辑,别删。
## 构建、打包与验收
```bash
pnpm build:classisland-plugin # 编译(.NET 8;缓存与临时目录自动指到仓库内 .cache/)
pnpm dist:classisland-plugin # 打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
pnpm verify:classisland-plugin # 静态契约校验(不需要 .NET,不需要服务端)
```
`verify:classisland-plugin` 会逐项校验:清单一致性、注册完整性、
**C# DTO ↔ 服务端 zod 与路由的字段对齐**,以及已修复坑的护栏。
### 那个预期内的编译警告
全量编译会有 **1 个警告 `AVLN3001`**:设置页只有 DI 构造函数、没有公开无参构造。
本插件走的是编译期 XAML + ClassIsland 的 DI 实例化,**加个无参构造反而会把 DI 弄坏**。
别为了消警告去改它。
## 部署到教室机器
把编出来的 DLL 覆盖到这台机器的 ClassIsland 数据目录(默认 `%USERPROFILE%\ClassIsland\data`,
便携版则是安装目录下的 `data\`)。ClassIsland **运行时会锁住 DLL,必须先完全退出它**:
```powershell
# 1) 完全退出 ClassIsland(它会锁住 DLL)
Get-Process ClassIsland.Desktop | Stop-Process -Force
# 2) 覆盖插件 DLL(**不要覆盖 Settings.json**,那里存着服务器地址与设备令牌)
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll `
"$env:USERPROFILE\ClassIsland\data\Plugins\classhelper.classisland.bridge\" -Force
# 3) 重新启动
Start-Process "$env:USERPROFILE\ClassIsland\ClassIsland.exe"
```
改配置要在 **ClassIsland 退出时**改:运行中改会被内存里的副本随时回写覆盖。
### 读日志要用 GBK
.NET 的控制台输出是**系统 ANSI(GBK)**编码。用 UTF-8 读会变成乱码,
**中文关键字一条都搜不到**(只能搜到 ASCII 的类名):
```powershell
[System.IO.File]::ReadAllLines($path, [System.Text.Encoding]::GetEncoding(936))
```
日志文件被运行中的进程占用时,先 `Copy-Item` 一份再读。
## 真机验收链路
`pnpm verify:classisland`(需要后端在跑)会真的走一遍:设备令牌 → 上报 → 提醒下发与回执 →
镜像契约 → 幽灵行清理。
> 这个脚本开头会先**抽干本班的历史积压提醒**。因为待提醒接口是「本班全部未确认提醒,
> 按时间升序取前 20 条」,而其他冒烟会真的往演示班发通知 —— 攒够 20 条之后,
> 本脚本刚发的提醒会被挤出窗口,表现为"待提醒里找不到刚发的那条",跟代码对不对毫无关系。
> **别把那个 drain 删掉。**
+24 -46
View File
@@ -32,52 +32,30 @@ description: 24 张表的职责、跨库通用约定、迁移历史与启动时
## 24 张表 ## 24 张表
| 表 | 职责 | 值得注意的字段 | | 表 | 职责 | 值得注意的字段 |
| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------- | | ------------------------------------------------------------------------------ | ------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`、`careMode`(关怀模式偏好,目前只由本机界面读写);**学生不在这里** | | `User` | **纯账号表**:管理员 / 教师 / ClassHelper 班级端 | `username`(教师即**工号**,班级端即班级码)、`role`、`phone`、`careMode`(关怀模式偏好,目前只由本机界面读写);**学生不在这里** |
| `Student` | 学生名单记录(**没有账号**) | `studentNo`(学号,唯一,唯一标识与查询键)、`classId`、`status`、`archivedYearId` | | `Student` | 学生名单记录(**没有账号**) | `studentNo`(学号,唯一,唯一标识与查询键)、`classId`、`status`、`archivedYearId` |
| `StudentClassTransfer` | 调班 / 转出历史 | 班级 id 与班级名**都存快照且不建外键**(班级删了历史仍要能读)、`mode`(single|batch|transfer-out) | | `StudentClassTransfer` | 调班 / 转出历史 | 班级 id 与班级名**都存快照且不建外键**(班级删了历史仍要能读)、`mode`(single|batch|transfer-out) |
| `ArchivedYear` | 毕业归档的届别档案 | `enrollmentYear`(届别,唯一)、`graduationYear`、归档时的统计快照 | | `ArchivedYear` | 毕业归档的届别档案 | `enrollmentYear`(届别,唯一)、`graduationYear`、归档时的统计快照 |
| `Class` | 班级 | `name`(**「2026级1班」**,由 `enrollmentYear` + `classIndex` 生成)、`code` + `passwordHash`(班级端账号)、`termWeeks`、`termStartDate`、`notificationChannel`、`studentGradeQueryEnabled`、`archivedYearId` | | `Class` | 班级 | `name`(**「2026级1班」**,由 `enrollmentYear` + `classIndex` 生成)、`code` + `passwordHash`(班级端账号)、`termWeeks`、`termStartDate`、`studentGradeQueryEnabled`、`archivedYearId` |
| `TermWeek` | 学期逐周日期区间 | `classId`(**空串 = 全校默认**)、`weekNumber`、`startDate` / `endDate` | | `TermWeek` | 学期逐周日期区间 | `classId`(**空串 = 全校默认**)、`weekNumber`、`startDate` / `endDate` |
| `Course` | 课程 = **「班级 + 科目 + 教师」任课关系** | `teacherId` 就是该班该科的任课老师;`@@unique([classId, name])` | | `Course` | 课程 = **「班级 + 科目 + 教师」任课关系** | `teacherId` 就是该班该科的任课老师;`@@unique([classId, name])` |
| `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual|classisland) | | `Schedule` | 课表条目 | `weekStart` / `weekEnd` / `weekParity`(单双周)/ `source`(manual) |
| `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 | | `Homework` | 作业 | `assignDate`(所属日期)、`attachmentUrl`;`dueAt` 是已下线的遗留列 |
| `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, studentId])` | | `HomeworkStatus` | 学生作业完成状态 | `@@unique([homeworkId, studentId])` |
| `Notification` | 通知 | `priority`、`assignDate`(所属本地日期,禁止 UTC 切片);`@@index([classId, assignDate])` 供按天归档 | | `Notification` | 通知 | `priority`、`assignDate`(所属本地日期,禁止 UTC 切片);`@@index([classId, assignDate])` 供按天归档 |
| `NotificationRead` | 已读记录 | `@@unique([notificationId, studentId])` | | `NotificationRead` | 已读记录 | `@@unique([notificationId, studentId])` |
| `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` | | `TimeLayout` | 节次时间配置(ClassIsland 同款 JSON 导入得到) | `items` 是 JSON 字符串:`[{ index, name, startTime, endTime, type, skipped }]` |
| `Grade` | 成绩 | `studentId`、`score` / `totalScore`、`levelType`(percent|letter|custom)与 `level` | | `Grade` | 成绩 | `studentId`、`score` / `totalScore`、`levelType`(percent|letter|custom)与 `level` |
| `IntegrationDevice` | ClassHelper 班级端 / ClassIsland 联动设备 | `tokenHash`(只存 sha256)、`lastHeartbeatAt`(在线判定)、`lastSeenAt`(最后一次带状态的上报)、三个方向开关 | | `PermissionGroup` | 权限组(角色之上的**默认模板**) | `name`(唯一)、`builtin`(内置组不可删);生效集合见 [角色与权限](permissions.md) |
| `ClassIslandPush` | 下发给 ClassIsland 的提醒 | `kind`(notification|call)、`ackedAt`、`expiresAt` | | `PermissionGroupItem` | 权限组里的一个权限键(一行一个键) | `@@unique([groupId, key])`;键取值必须是 shared 的 `PERMISSION_KEYS` 之一 |
| `PermissionGroup` | 权限组(角色之上的**默认模板**) | `name`(唯一)、`builtin`(内置组不可删);生效集合见 [角色与权限](permissions.md) | | `UserPermissionGroup` | 账号 ←→ 权限组 的分配关系(可属多个组) | `@@unique([userId, groupId])`;**没有任何行 = 回落角色默认模板** |
| `PermissionGroupItem` | 权限组里的一个权限键(一行一个键) | `@@unique([groupId, key])`;键取值必须是 shared 的 `PERMISSION_KEYS` 之一 | | `AppSetting` | 全局键值设置(大模型配置 / 开发者模式) | `key` 即主键、`value` 存字符串(复杂结构存 JSON);密钥类值做 AES-256-GCM 加密后存这里(**只进不出**) |
| `UserPermissionGroup` | 账号 ←→ 权限组 的分配关系(可属多个组) | `@@unique([userId, groupId])`;**没有任何行 = 回落角色默认模板** | | `ClientDevice` | ClassHelper 客户端设备(教室 / 学生机) | `deviceKey`(唯一,客户端生成)、`classId`、`clientVersion`、`updateTargetVersion`、`lastHeartbeatAt`(**客户端在线判定看 `ClientDevice.lastHeartbeatAt`**:每 30 秒心跳,90 秒内有成功上报即在线。 |
| `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` |
### 几个容易忽略的设计 **`Schedule.source`** —— 联动退役后历史同步课表统一归为 `manual`,保留排课数据。
**`ClassIslandPush` 与 `IntegrationDevice`** —— 提醒是**落库**的,不是"发出去就算"。
设备离线时通知不丢:插件重连后拉取未确认的那条立即弹出,弹完回执(`ackedAt`)。
`expiresAt` 防止学生几天后突然弹出旧提醒。
**在线判定看 `lastHeartbeatAt`,不看 `lastSeenAt`** —— 同一台设备的上报其实有两种:
心跳(每 60 秒一次,只表明"我还在线")与带状态的上报(课表 / 上课状态)。
合成一个字段后无法区分"在线但一直没新状态"和"整个失联",所以拆开:
`lastHeartbeatAt` 每次上报都刷(60 秒窗口判在线),`lastSeenAt` 只在带运行状态时刷。
**课表同步方向:服务端 → 教室是主方向。**
`IntegrationDevice.mirrorScheduleToClassIsland` 默认 **true**(服务端自动下发),
`syncScheduleToServer` 默认 **false**(教室不再自动回传)。要取教室的课表,走
`POST /integrations/devices/:id/request-schedule` 这个**人工动作**(前端点击前会弹警告)——
自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
**`Schedule.source`** —— 决定插件上报时能清理哪些行:只清 `classisland` 来源且本次未再上报的行。
**`source` 只在 create 时确定,update 一律保持原值**(否则手排的课会被"收养"后又被清掉)。
**`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。 **`Homework.dueAt`** —— 截止时间功能已下线,UI 与接口都不再读写它。
保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。 保留列是为了**不对已安装的库做破坏性迁移**;新库里它恒为 `null`。
@@ -148,7 +126,7 @@ add_permission_groups_and_clients
生成骨架,再手工补数据搬运语句; 生成骨架,再手工补数据搬运语句;
3. 改完核对 **`SNAPSHOT_TABLES`**(新增 / 删除表要同步)与 `deleteTeacher` 的护栏清单 3. 改完核对 **`SNAPSHOT_TABLES`**(新增 / 删除表要同步)与 `deleteTeacher` 的护栏清单
(它必须覆盖 `Class.teacherId` / `Course.teacherId` / `Homework.createdBy` / (它必须覆盖 `Class.teacherId` / `Course.teacherId` / `Homework.createdBy` /
`Notification.createdBy` 四项;`ClassIslandPush.createdBy` 是裸列、无外键、不级联,不用加); `Notification.createdBy` 四项);
再回到 `packages/server/scripts/apply-column-migrations.cjs` 补上可 `ALTER TABLE` 的简单列。 再回到 `packages/server/scripts/apply-column-migrations.cjs` 补上可 `ALTER TABLE` 的简单列。
模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准; 模型总数与迁移个数会随版本增长,以 `schema.prisma` 与 `prisma/migrations/` 的实际内容为准;
+6 -9
View File
@@ -7,13 +7,12 @@ description: ClassHelper的架构、接口、数据模型、权限、动效层
ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物: ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物:
| 交付物 | 位置 | 形态 | | 交付物 | 位置 | 形态 |
| -------------------- | ----------------------------- | --------------------------------------------------------------- | | ---------- | ------------------------- | --------------------------------------------------------------- |
| 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,接口前缀 `/api` | | 后端服务 | `packages/server` | Express 5 + Prisma 7 + Socket.IO,接口前缀 `/api` |
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师 / 管理员用,PWA 可安装) | | Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师 / 管理员用,PWA 可安装) |
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(ClassHelper 班级端 / 教室机器,含灵动岛浮窗) | | 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(ClassHelper 班级端 / 教室机器,含灵动岛浮窗) |
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 | | 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(**独立于 pnpm workspace**,不参与 `pnpm install`) |
核心链路: 核心链路:
@@ -26,7 +25,6 @@ ClassHelper是一个 **pnpm monorepo**,一份代码产出四个交付物:
▼ ▼
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI(含灵动岛) Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI(含灵动岛)
ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
▲ │ ▲ │
└──── 老师下发的提醒 ◄──────┘ └──── 老师下发的提醒 ◄──────┘
``` ```
@@ -41,7 +39,6 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
| [数据模型](data-model.md) | 24 张表、迁移历史、跨库约定 | | [数据模型](data-model.md) | 24 张表、迁移历史、跨库约定 |
| [角色与权限](permissions.md) | 权限矩阵与实现位置 | | [角色与权限](permissions.md) | 权限矩阵与实现位置 |
| [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 | | [动效层与主题](motion.md) | beUI 令牌、CSS `linear()` 弹簧、深色主题 |
| [ClassIsland 插件](classisland-plugin.md) | 插件契约、镜像为什么要带 `startTime`、部署与调试 |
| [灵动岛设计令牌](winisland-design-tokens.md) | 岛逐项的几何 / 配色 / 排版 token | | [灵动岛设计令牌](winisland-design-tokens.md) | 岛逐项的几何 / 配色 / 排版 token |
| [验收与测试](testing.md) | 五套验收脚本怎么跑、哪些用例是脆弱的 | | [验收与测试](testing.md) | 五套验收脚本怎么跑、哪些用例是脆弱的 |
| [版本迭代与发布](release.md) | 改版本号、出安装包、发 Release 的完整流程 | | [版本迭代与发布](release.md) | 改版本号、出安装包、发 Release 的完整流程 |
+70 -45
View File
@@ -20,46 +20,50 @@ description: 权限矩阵、科目维度的收口规则,以及"前端隐藏
角色只有三种,**没有 `STUDENT`** —— 学生不是账号(见[数据模型](data-model.md))。 角色只有三种,**没有 `STUDENT`** —— 学生不是账号(见[数据模型](data-model.md))。
班级内的角色由「账号角色 + 与该班级的关系」共同决定: 班级内的角色由「账号角色 + 与该班级的关系」共同决定:
| 角色 | 判定方式 | 可见范围 | | 角色 | 判定方式 | 可见范围 |
| --------------------------- | ------------------------------------------ | ------------------------------------ | | --------------------- | ---------------------------------------- | -------------------------- |
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 | | `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 | | 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
| 科任老师 `SUBJECT` | 该班存在 `Course.teacherId === 当前用户` | 被任课的班级 | | 科任老师 `SUBJECT` | 该班存在 `Course.teacherId === 当前用户` | 被任课的班级 |
| `CLASS_DEVICE` 班级端 | 班级码 + 班级密码登录(`classSession`) | 本班(代全班读写个人数据) | | `CLASS_DEVICE` 班级端 | 班级码 + 班级密码登录(`classSession`) | 本班(代全班读写个人数据) |
| `NONE` | 与这个班没有任何关系 | 什么都看不到 | | `NONE` | 与这个班没有任何关系 | 什么都看不到 |
判定函数是 `resolveClassRole()`。「科任」的判定源是 **`Course`**(班级 + 科目 + 教师), 判定函数是 `resolveClassRole()`。「科任」的判定源是 **`Course`**(班级 + 科目 + 教师),
不是那张已经没有的 `ClassTeacher` 协作表。 不是那张已经没有的 `ClassTeacher` 协作表。
## 权限矩阵 ## 权限矩阵
| 操作 | 管理员 | 班主任 | 科任老师 | 班级端 | 下表描述角色与班级关系允许的范围;员工还必须拥有对应功能权限,管理员也不例外。
| -------------------------------------------------------- | :----: | :----------: | :------: | :----: | 名单读取使用 `roster:read` / `teacher:read`,管理使用独立的 `roster:manage` / `teacher:manage`。
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | ❌ | 日志与客户端开发者模式不再单列无效权限键,服务端系统设置由 `system:manage` 控制。
| 分配班主任与各科任课老师 | ✅ | ❌ | ❌ | ❌ |
| 学生名单管理(增删 / 导入 / 调班 / 转出) | ✅ | ✅(仅本班) | ❌ | ❌ | | 操作 | 管理员 | 班主任 | 科任老师 | 班级端 |
| 教师录入(新建 / 导入名单 / 修改密码) | ✅ | ❌ | ❌ | ❌ | | ----------------------------------------------------------- | :----: | :----------: | :------: | :--------: |
| 毕业归档 / 学年升级 / 学期周次 | ✅ | ❌ | ❌ | ❌ | | 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ | ❌ |
| 数据库管理(备份 / 导入导出 / 一键切换) | ✅ | ❌ | ❌ | ❌ | | 分配班主任与各科任课老师 | ✅ | ❌ | ❌ | ❌ |
| 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ | ❌ | | 学生名单管理(增删 / 导入 / 调班 / 转出) | ✅ | ✅(仅本班) | ❌ | ❌ |
| 布置作业(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ✅ | | 教师录入(新建 / 导入名单 / 修改密码) | ✅ | ❌ | ❌ | ❌ |
| 叫人 / 发布通知 | ✅ | ✅ | ✅ | ✅ | | 毕业归档 / 学年升级 / 学期周次 | ✅ | ❌ | ❌ | ❌ |
| 成绩录入 / 修改 / 导入(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ❌ | | 数据库管理(备份 / 导入导出 / 一键切换) | ✅ | ❌ | ❌ | ❌ |
| 查看本班成绩汇总 | ✅ | ✅ | ✅ | ✅ | | 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ | ❌ |
| 按学号查询学生成绩明细 | ✅ | ✅ | ✅ | 需班级开关 | | 布置作业(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ✅ |
| 查看 ClassHelper 班级端在线状态 | ✅ | ✅ | ✅ | ❌ | | 叫人 / 发布通知 | ✅ | ✅ | ✅ | ✅ |
| 班级密码 / 班级码维护(`PATCH /classes/:id/class-account`) | ✅ | ❌ | ❌ | ❌ | | 成绩录入 / 修改 / 导入(**自己任教的科目**) | ✅ | ✅(本班) | ✅ | ❌ |
| 查看本班成绩汇总 | ✅ | ✅ | ✅ | ✅ |
| 按学号查询学生成绩明细 | ✅ | ✅ | ✅ | 需班级开关 |
| 查看 ClassHelper 班级端在线状态 | ✅ | ✅ | ✅ | ❌ |
| 班级密码 / 班级码维护(`PATCH /classes/:id/class-account`) | ✅ | ❌ | ❌ | ❌ |
## 科目维度的收口(本次重构的核心规则) ## 科目维度的收口(本次重构的核心规则)
作业与成绩不是"凡是能进这个班就能改",而是**再按科目收一道**: 作业与成绩不是"凡是能进这个班就能改",而是**再按科目收一道**:
| 情形 | 谁可以写 | | 情形 | 谁可以写 |
| --------------------------------------- | ------------------------------------------ | | --------------------------- | ------------------------------------------------------ |
| 管理员 | 全部 | | 管理员 | 全部 |
| 挂了科目(`courseId` 有值) | **该科的任课老师**(`Course.teacherId`) | | 挂了科目(`courseId` 有值) | **该科的任课老师**(`Course.teacherId`) |
| 没挂科目(`courseId` 为空) | 本班**班主任** | | 没挂科目(`courseId` 为空) | 本班**班主任** |
| ClassHelper 班级端 | 只有**作业**可以(老师在教室随手录一条);**成绩不给** | | ClassHelper 班级端 | 只有**作业**可以(老师在教室随手录一条);**成绩不给** |
于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求 于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
@@ -70,22 +74,22 @@ description: 权限矩阵、科目维度的收口规则,以及"前端隐藏
## 权限键与权限组(2026-10 起:权限组驱动,角色只是默认模板) ## 权限键与权限组(2026-10 起:权限组驱动,角色只是默认模板)
从权限组重构开始,**功能级准入不再看角色,而看权限键**:键目录同样是 从权限组重构开始,**功能级准入不再看角色,而看权限键**:键目录同样是
`packages/shared/src/permissions.ts` 的 `PERMISSION_KEYS`(39 个,形如 `domain:action`, `packages/shared/src/permissions.ts` 的 `PERMISSION_KEYS`(37 个,形如 `domain:action`,
中文名在 `PERMISSION_LABELS`、分组在 `PERMISSION_CATEGORIES`)。路由上挂的是 中文名在 `PERMISSION_LABELS`、分组在 `PERMISSION_CATEGORIES`)。路由上挂的是
```ts ```ts
requirePermission('notification:publish') // 需要全部列出的键 requirePermission('notification:publish'); // 需要全部列出的键
requireAnyPermission('grade:write', 'grade:import') // 有任意一个即可 requireAnyPermission('grade:write', 'grade:import'); // 有任意一个即可
``` ```
**生效规则(四句话)**: **生效规则(四句话)**:
| 账号 | 有效权限 | | 账号 | 有效权限 |
| --------------------------------------- | -------------------------------------------------------------- | | ------------------------ | ----------------------------------------------------------------------- |
| `ADMIN` | 全集;其中 `permission:manage` / `system:manage` **不可被剥夺** | | `ADMIN` | 按权限组计算;其中 `permission:manage` / `system:manage` **不可被剥夺** |
| `CLASS_DEVICE`(班级端) | 空集,**不受任何分组影响**(它的能力来自班级维度断言) | | `CLASS_DEVICE`(班级端) | 空集,**不受任何分组影响**(它的能力来自班级维度断言) |
| 被分配了权限分组 | 分组键的**并集**(可以比角色默认更少 —— 这就是"能收权") | | 被分配了权限分组 | 分组键的**并集**(可以比角色默认更少 —— 这就是"能收权") |
| 没有被分配任何分组 | 回落到角色默认模板 `defaultPermissionsForRole(role)` | | 没有被分配任何分组 | 使用可编辑的角色默认权限组 |
默认模板逐条镜像重构前的 `requireRole`,所以"不动分组"= 行为与以前完全一致。 默认模板逐条镜像重构前的 `requireRole`,所以"不动分组"= 行为与以前完全一致。
实现见 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()`:它**每个请求实时查库** 实现见 `packages/server/src/lib/permissions.ts` 的 `loadEffectivePermissions()`:它**每个请求实时查库**
@@ -93,12 +97,12 @@ requireAnyPermission('grade:write', 'grade:import') // 有任意一个即可
**管理接口**(`packages/server/src/modules/permissions/`): **管理接口**(`packages/server/src/modules/permissions/`):
| 方法与路径 | 权限要求 | 说明 | | 方法与路径 | 权限要求 | 说明 |
| --------------------------------- | -------------------- | -------------------------------------------------------- | | --------------------------------------------------------------------- | ------------------- | -------------------------------------------------------------- |
| `GET /api/permissions/me` | 登录即可 | 当前账号的有效权限与来源(前端据此显示/隐藏入口) | | `GET /api/permissions/me` | 登录即可 | 当前账号的有效权限与来源(前端据此显示/隐藏入口) |
| `GET /api/permissions/catalog` | `permission:manage` | 全部权限键(含中文名、分类、默认角色、不可移除标记) | | `GET /api/permissions/catalog` | `permission:manage` | 全部权限键(含中文名、分类、默认角色、不可移除标记) |
| `GET/POST /api/permissions/groups`、`PATCH/DELETE /groups/:id` | `permission:manage` | 分组 CRUD;内置分组(`builtin=true`)可改不可删 | | `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 分配 | | `GET /api/permissions/users`、`PUT /api/permissions/users/:id/groups` | `permission:manage` | 成员列表与「整组替换」式分配;管理员与教师可分配;班级端不参与 |
**一条不能松的边界**:权限组只管**功能/路由级**准入;**班级维度的科目规则不可被权限组覆盖**。 **一条不能松的边界**:权限组只管**功能/路由级**准入;**班级维度的科目规则不可被权限组覆盖**。
换句话说,给某人授了 `homework:write` 只代表"他能进布置作业这条路由", 换句话说,给某人授了 `homework:write` 只代表"他能进布置作业这条路由",
@@ -140,7 +144,7 @@ assertCanManageGrades / assertCanManageSubjectContent
**门控怎么写的**:路由 `meta.roles` 做粗筛(角色),`meta.permission` 做细筛(权限键)—— **门控怎么写的**:路由 `meta.roles` 做粗筛(角色),`meta.permission` 做细筛(权限键)——
`packages/web-admin/src/router/index.ts` 的 `beforeEach` 会 `await auth.ensurePermissions()` `packages/web-admin/src/router/index.ts` 的 `beforeEach` 会 `await auth.ensurePermissions()`
再问 `auth.can(key)`(管理员短路为 true),不通过就 **fail-closed** 回仪表盘; 再问 `auth.can(key)`(管理员同样按生效集合判断,仅保留权限和系统管理入口),不通过就 **fail-closed** 回仪表盘;
侧栏菜单项同理带 `permission` 字段(`AdminLayout.vue` 的 `visibleMenuItems`)。 侧栏菜单项同理带 `permission` 字段(`AdminLayout.vue` 的 `visibleMenuItems`)。
`auth.ensurePermissions()` 读的就是 `GET /api/permissions/me`。 `auth.ensurePermissions()` 读的就是 `GET /api/permissions/me`。
@@ -156,3 +160,24 @@ assertCanManageGrades / assertCanManageSubjectContent
拿学号当账号登录只会得到 401("工号或密码错误"),连"这个学号存在吗"都探测不出来。 拿学号当账号登录只会得到 401("工号或密码错误"),连"这个学号存在吗"都探测不出来。
2. **学生没有任何密码接口**。`POST /students/:id/reset-password` 已删除(调用返回 404), 2. **学生没有任何密码接口**。`POST /students/:id/reset-password` 已删除(调用返回 404),
新建学生也不设密码 —— `Student` 表里压根没有那一列。 新建学生也不设密码 —— `Student` 表里压根没有那一列。
## 默认权限组与客户端维护(2026-10-16)
管理员和教师各有一个默认权限组;未分配其他组时使用对应默认组,编辑后立即生效。
默认组可以改名、增减权限,但不能删除。分配多个组时取并集。
管理员始终保留 `permission:manage` 和 `system:manage`,防止失去管理入口。
班级端不参与账号权限组,班级与科目范围的检查保持生效。
「客户端」页统一显示设备心跳、版本、是否接受自动更新、最近指令结果和安装时机。
更新先从本服务器下载并校验 SHA256,再按所选时机安装:
- 立即更新:下载完成后安装并重启客户端,会中断当前客户端界面。
- 放学后更新:按服务器当天日期,等待所选最早时间与本班最后一节课结束时间的较晚者;上课中继续等待。
- 空闲时更新:不上课且主窗口隐藏满五分钟后安装。
离线设备上线后补领更新;取消下发后,已下载的安装包不再自动安装。
客户端关闭自动更新时,任何下发策略都不会强制安装。
远控关机、重启、注销和锁屏仍需管理端确认及本机确认,取消或系统执行失败都会回执。
联动插件及相关接口已下线;升级迁移会删除旧联动设备和推送记录,保留业务课表、通知及离线 JSON 导入。
服务端兼容旧客户端的心跳字段读取,旧设备可领取修复版;第一次升级仍遵循旧版空闲安装条件,新策略需修复版客户端。
+1 -3
View File
@@ -30,8 +30,6 @@ io(url, { auth: { token } }); // 握手阶段用 JWT 鉴权
| `schedule:updated` | 服务端 → 客户端 | `{ classId, action, schedule? }` | | `schedule:updated` | 服务端 → 客户端 | `{ classId, action, schedule? }` |
| `class:updated` | 服务端 → 客户端 | `{ classId, action }` | | `class:updated` | 服务端 → 客户端 | `{ classId, action }` |
| `call:new` | 服务端 → 客户端 | 叫人消息(定向到 `user:{studentId}` / 班级设备) | | `call:new` | 服务端 → 客户端 | 叫人消息(定向到 `user:{studentId}` / 班级设备) |
| `classisland:state` | 服务端 → 客户端 | `ClassIslandStateEvent`(教室现在上什么课) |
| `classisland:notification` | 服务端 → 客户端 | `ClassIslandNotificationEvent`(提醒已下发) |
| `class:join` / `class:leave` / `ping` | 客户端 → 服务端 | 手动订阅班级(服务端二次校验权限) | | `class:join` / `class:leave` / `ping` | 客户端 → 服务端 | 手动订阅班级(服务端二次校验权限) |
## 房间规则 ## 房间规则
@@ -44,7 +42,7 @@ io(url, { auth: { token } }); // 握手阶段用 JWT 鉴权
| `user:{userId}` | 某个用户自己的连接 | | `user:{userId}` | 某个用户自己的连接 |
| `teacher:{teacherId}` | 某个教师 | | `teacher:{teacherId}` | 某个教师 |
| `role:{role}` | 某一角色(`ADMIN` / `TEACHER` …) | | `role:{role}` | 某一角色(`ADMIN` / `TEACHER` …) |
| `students` | 全部 ClassHelper 班级端连接 | | `students` | 全部 ClassHelper 班级端连接 |
| `teachers` | 全部教师端连接 | | `teachers` | 全部教师端连接 |
服务端通过 `realtime/bus.ts` 的 `emitToClass` / `emitToUser` 发事件 —— 服务端通过 `realtime/bus.ts` 的 `emitToClass` / `emitToUser` 发事件 ——
+13 -23
View File
@@ -10,33 +10,31 @@ description: 改版本号、跑验收、打安装包、发 GitHub Release、发
> **版本号按「发布」迭代,不按「提交」迭代。** > **版本号按「发布」迭代,不按「提交」迭代。**
日常的 `feat` / `fix` 提交**不动版本号**。只有当你要产出一批安装包、在 GitHub 上开一个 Release 时, 日常的 `feat` / `fix` 提交**不动版本号**。只有当你要产出一批安装包、在 GitHub 上开一个 Release 时,
才走一次完整的版本迭代。理由很实际:安装包、`.cipx`、Docker 镜像 tag、文档站的版本徽标、
更新检查接口全都按版本号对齐;每次提交都动它,等于每天都在发版,用户那边的「检查更新」就废了。 更新检查接口全都按版本号对齐;每次提交都动它,等于每天都在发版,用户那边的「检查更新」就废了。
| 算一次发布 | 不算 | | 算一次发布 | 不算 |
| --------------------------------------- | ---------------------------- | | --------------------------------------- | ---------------------------- |
| 攒够一批功能 / 修好一个用户能感知的 bug | 改注释、改文档、重构、加测试 | | 攒够一批功能 / 修好一个用户能感知的 bug | 改注释、改文档、重构、加测试 |
| 要给别人一份新安装包 | 只在自己机器上跑 | | 要给别人一份新安装包 | 只在自己机器上跑 |
| 插件单独发补丁(只动插件第 4 段版本号) | — |
## 每次发版必须一起交付的四样东西 ## 每次发版必须一起交付的四样东西
打安装包只是其中一件。**少一样都算「发到一半」**: 打安装包只是其中一件。**少一样都算「发到一半」**:
| # | 交付物 | 落在哪 | 怎么做 | | # | 交付物 | 落在哪 | 怎么做 |
| -- | ----------------- | ------------------------------------------------------------------ | ------------------------------------------------------------ | | --- | ------------------ | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
| ① | **更新日志** | GitHub Release 的正文 | 手写,草稿放 `releases/ReleaseNotes-<版本>.md`,用 `--notes-file` 发布。别只挂自动生成的列表 | | ① | **更新日志** | GitHub Release 的正文 | 手写,草稿放 `releases/ReleaseNotes-<版本>.md`,用 `--notes-file` 发布。别只挂自动生成的列表 |
| ② | **Linux 服务端包** | Release 上的 `classhelper-server-linux-x64-<版本>.tar.gz` | tag 推上去由 CI 构建;**推完去 Actions 确认没红**(见第 7 步) | | ② | **Linux 服务端包** | Release 上的 `classhelper-server-linux-x64-<版本>.tar.gz` | tag 推上去由 CI 构建;**推完去 Actions 确认没红**(见第 7 步) |
| ③ | **文档** | `docs/reference.md`(功能说明 + 验收对照表)与相关章节 | 新增功能补对应小节;改交付形态一并改第 2 章 / 第 7 章 | | ③ | **文档** | `docs/reference.md`(功能说明 + 验收对照表)与相关章节 | 新增功能补对应小节;改交付形态一并改第 2 章 / 第 7 章 |
| ④ | **官网与 README** | `website/index.html`、`website/download.html`、`README.md` | 新功能补进官网功能块与 README 功能清单;版本号由脚本覆盖 | | ④ | **官网与 README** | `website/index.html`、`website/download.html`、`README.md` | 新功能补进官网功能块与 README 功能清单;版本号由脚本覆盖 |
更新日志的骨架(照 1.2.0 / 1.3.0 那两份写): 更新日志的骨架(照 1.2.0 / 1.3.0 那两份写):
**一句话定位 → 分节讲功能 → ⚠️ 升级须知(含会自动应用的迁移)→ 验收表 → 产物表**。 **一句话定位 → 分节讲功能 → ⚠️ 升级须知(含会自动应用的迁移)→ 验收表 → 产物表**。
验收表里**只写真跑过的数字**,没跑就直说没跑,不要照抄上一版的。 验收表里**只写真跑过的数字**,没跑就直说没跑,不要照抄上一版的。
## 版本号写在哪(14 处 / 12 个文件) ## 版本号写在哪(12 处 / 12 个文件)
同一个版本号分散在 14 个落点上,**手工改必然漏一处**,而漏掉的那处不会报错: 同一个版本号分散在 12 个落点上,**手工改必然漏一处**,而漏掉的那处不会报错:
| 文件 | 内容 | | 文件 | 内容 |
| ---------------------------------------------------------------- | ---------------------------------- | | ---------------------------------------------------------------- | ---------------------------------- |
@@ -44,8 +42,6 @@ description: 改版本号、跑验收、打安装包、发 GitHub Release、发
| `packages/{shared,server,web-admin,desktop-client}/package.json` | 各包的版本 | | `packages/{shared,server,web-admin,desktop-client}/package.json` | 各包的版本 |
| `deploy/package.runtime.json` | 安装包 / 容器里的运行时依赖清单 | | `deploy/package.runtime.json` | 安装包 / 容器里的运行时依赖清单 |
| `deploy/docker-compose.yml`、`docker-compose.sqlite.yml` | 镜像 tag 的默认值 | | `deploy/docker-compose.yml`、`docker-compose.sqlite.yml` | 镜像 tag 的默认值 |
| `packages/classisland-plugin/manifest.yml` | 插件清单(四段:`x.y.z.0`) |
| `packages/classisland-plugin/src/Services/BridgeService.cs` | 插件上报的 `PluginVersion` 常量 |
| `website/index.html` × 3 | 官网顶栏徽标 / 首屏芯片 / 页脚 | | `website/index.html` × 3 | 官网顶栏徽标 / 首屏芯片 / 页脚 |
| `website/download.html`(`<meta name="ch-version">`) | 下载页读不到版本清单时的兜底版本号 | | `website/download.html`(`<meta name="ch-version">`) | 下载页读不到版本清单时的兜底版本号 |
@@ -65,9 +61,6 @@ pnpm version:check # 再确认一次
脚本按表逐个改写,**任何一处没匹配到或匹配到多处就直接失败退出**,不做"尽力而为"的部分改写。 脚本按表逐个改写,**任何一处没匹配到或匹配到多处就直接失败退出**,不做"尽力而为"的部分改写。
> 插件版本是 **四段** `x.y.z.0`:前三段跟产品版本走,第 4 段留给"只改插件、不动产品版本"的补丁发布。
> `--bump` / `--set` 会把第 4 段归零;要单独发插件补丁就手工改那两处(清单 + `BridgeService.cs`)。
## 一次完整的发布 ## 一次完整的发布
下面这一串命令就是全部流程,逐条照抄即可。 下面这一串命令就是全部流程,逐条照抄即可。
@@ -94,8 +87,6 @@ pnpm docs:check # 文档站构建(警告即失败)
pnpm dev:server # 另开一个终端跑着 pnpm dev:server # 另开一个终端跑着
pnpm verify:e2e pnpm verify:e2e
pnpm verify:classisland
pnpm verify:classisland-plugin
pnpm build:desktop && pnpm verify:desktop pnpm build:desktop && pnpm verify:desktop
pnpm build && pnpm verify:web pnpm build && pnpm verify:web
@@ -107,7 +98,7 @@ pnpm build && pnpm verify:web
### 4. 打安装包 ### 4. 打安装包
```bash ```bash
pnpm dist:all # = dist:server + dist:win + dist:classisland-plugin
``` ```
产物落在 `releases/<组件>/<版本>/`:`安装包/`(交付物)、`构建中间/`(可重新生成)。 产物落在 `releases/<组件>/<版本>/`:`安装包/`(交付物)、`构建中间/`(可重新生成)。
@@ -127,7 +118,7 @@ pnpm verify:packaged --exe "releases/client/<版本>/安装包/ClassHelper-<版
### 5. 生成跨组件哈希清单 ### 5. 生成跨组件哈希清单
`releases/SHA256SUMS-<版本>.txt` 是**人读的**(表头 + 体积 + 说明),由发布流程维护, `releases/SHA256SUMS-<版本>.txt` 是**人读的**(表头 + 体积 + 说明),由发布流程维护,
本地只写 Windows 与插件那几行: 本地只写 Windows那几行:
```bash ```bash
cd releases/client/<版本>/安装包 && sha256sum *.exe > ../../../SHA256SUMS-<版本>.txt # 按实际文件名整理 cd releases/client/<版本>/安装包 && sha256sum *.exe > ../../../SHA256SUMS-<版本>.txt # 按实际文件名整理
@@ -156,7 +147,6 @@ mkdir -p .cache/release-staging
cp "releases/client/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-client-setup.exe" cp "releases/client/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-client-setup.exe"
cp "releases/client/$V/安装包/"*-x64-portable.exe ".cache/release-staging/ClassHelper-$V-x64-client-portable.exe" cp "releases/client/$V/安装包/"*-x64-portable.exe ".cache/release-staging/ClassHelper-$V-x64-client-portable.exe"
cp "releases/server/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-server-setup.exe" cp "releases/server/$V/安装包/"*-x64-setup.exe ".cache/release-staging/ClassHelper-$V-x64-server-setup.exe"
cp "releases/classisland-plugin/$V/安装包/ClassHelper.ClassIslandPlugin.cipx" .cache/release-staging/
gh release create v$V --title "ClassHelper v$V(<一句话定位>)" --notes-file "releases/ReleaseNotes-$V.md" gh release create v$V --title "ClassHelper v$V(<一句话定位>)" --notes-file "releases/ReleaseNotes-$V.md"
gh release upload v$V .cache/release-staging/* "releases/SHA256SUMS-$V.txt" --clobber gh release upload v$V .cache/release-staging/* "releases/SHA256SUMS-$V.txt" --clobber
@@ -176,7 +166,7 @@ gh release upload v$V .cache/release-staging/* "releases/SHA256SUMS-$V.txt" --cl
构建 Linux 包(ubuntu runner)。它跑完之后会: 构建 Linux 包(ubuntu runner)。它跑完之后会:
1. 把 `classhelper-server-linux-x64-<版本>.tar.gz` 与 `.sha256` 挂到 Release 上; 1. 把 `classhelper-server-linux-x64-<版本>.tar.gz` 与 `.sha256` 挂到 Release 上;
2. **把 Linux 那一行合并进** `SHA256SUMS-<版本>.txt`(保留 Windows / 插件那几行)。 2. **把 Linux 那一行合并进** `SHA256SUMS-<版本>.txt`(保留 Windows那几行)。
> **顺序很重要**:Linux workflow 是在最后一步才去合并 SHA256SUMS 的, > **顺序很重要**:Linux workflow 是在最后一步才去合并 SHA256SUMS 的,
> 所以第 5 步那份清单必须**先上传**。万一手速反了(workflow 先跑完、你后上传), > 所以第 5 步那份清单必须**先上传**。万一手速反了(workflow 先跑完、你后上传),
@@ -213,7 +203,7 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站
文档站顶栏的版本徽标取自根 `package.json`,所以第 1 步改完就跟着变了; 文档站顶栏的版本徽标取自根 `package.json`,所以第 1 步改完就跟着变了;
官网首屏那排芯片、顶栏徽标与页脚里的版本号,以及下载页那个兜底版本号, 官网首屏那排芯片、顶栏徽标与页脚里的版本号,以及下载页那个兜底版本号,
也都是**由 `pnpm version:bump` 一起改的**(它们是那 14 个落点里的四个)。 也都是**由 `pnpm version:bump` 一起改的**(它们是那 12 个落点里的四个)。
> 官网的「立即下载」会跳到 `/download.html`,那一页的版本清单是**当场**从 GitHub Releases 读的 > 官网的「立即下载」会跳到 `/download.html`,那一页的版本清单是**当场**从 GitHub Releases 读的
> (读不到才退回上面那个兜底版本号),所以发完 Release 不用重新部署那一页 —— 但**兜底值要跟着发版一起改**, > (读不到才退回上面那个兜底版本号),所以发完 Release 不用重新部署那一页 —— 但**兜底值要跟着发版一起改**,
@@ -221,7 +211,7 @@ https://lovechengke.github.io/ClassHelper/docs/ ← 文档站
### 9. 检查 ### 9. 检查
- [ ] Release 页面上四个组件都在,`SHA256SUMS-<版本>.txt` 里 Windows / 插件 / Linux 三种行齐全; - [ ] Release 页面上三个组件都在,`SHA256SUMS-<版本>.txt` 里 Windows / Linux 三种行齐全;
- [ ] 文档站能打开,顶栏版本号是新版; - [ ] 文档站能打开,顶栏版本号是新版;
- [ ] 装一台机器试一遍(或至少跑 `pnpm verify:packaged`)。 - [ ] 装一台机器试一遍(或至少跑 `pnpm verify:packaged`)。
+50
View File
@@ -0,0 +1,50 @@
---
title: 2026-10-17 审查修复记录
description: 按审查报告核对缺陷、修复行为与验收方式。
---
# 2026-10-17 审查修复记录
本次按仓库根的代码审查报告逐项核对,保留此前联动退役与更新策略改动。
| 报告条目 | 处理结果 |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 0.1 | 环境文档补充 PATH 与 Git Bash 启动方式;变更文档统一格式化。 |
| 1.1 | 恢复学期周次、毕业归档、权限组菜单,开发期检查路由是否遗漏菜单。 |
| 1.2 | 叫人通知写本地所属日期;启动时分批幂等回填旧通知,兼容 SQLite / MySQL。 |
| 1.3、2.14、3.10、3.14、4.5 | 每次建窗挂关闭隐藏处理;捕获加载失败;仅主窗口触发恢复,恢复期间等待异步加载;普通 Promise 拒绝不再重启进程,移除 activate 重复加载。 |
| 1.4、1.5 | 缓存按服务端和班级隔离,个人资料按服务端和登录名隔离;忽略跨会话的迟到响应,打开失败和版本切换后可重试 IndexedDB。旧无命名空间缓存不再直接作为新会话数据读取。 |
| 1.6 | 学期周统一加载后再取灵动岛课表,教学周变化触发重取;失败且无缓存时保留已知课表;主窗口禁用后台节流。 |
| 1.7、3.11 | IPC 拒绝渲染端改安装记录;安装只接受更新专用目录中的文件,拒绝目录外及软链接目标;取消、更换目标和更新完成后清理旧包。 |
| 2.1 | 空角色原本已保留旧值;现在未识别的非空值也保留原角色,只有明确的教师/管理员值才变更。 |
| 2.2、2.9 | 员工的成绩明细、看板、更新检查与强制检查落实权限;班级端仍由本班边界授权。增加学生/教师名单只读权限,旧管理组继承对应读取能力;下线没有独立服务端能力的日志与开发者模式权限键,系统设置仍由系统管理权限控制。 |
| 2.3 | 当前班级范围已由 CLASS_DEVICE 角色正确解析,不存在报告描述的必然拒绝,也不会查询全班学生。补齐 Socket 身份中的 classSession,保持元数据完整。 |
| 2.4、2.5 | 课表导入读取本班学期长度,创建课表按实际兜底周数做区间交叉校验。 |
| 2.6 | 姓名重复时要求正确学号,不能静默绑定到同名学生。 |
| 2.7 | 增加共享删除事件与两端订阅,并移除通知中心及灵动岛中的已删通知。 |
| 2.8 | 批量录入接口本身只接收单个 classId;真正缺口在跨班批量改等级,现已逐班广播。 |
| 2.10 | 调班历史按学生当前班级及显式班级过滤条件校验权限;未分班学生仅管理员可读。 |
| 2.11—2.13 | 引导使用主题文字变量,仅可见时响应 Esc;成绩明细优先展示保存的等级。 |
| 2.15—2.17 | 助手叫人校验非空消息;周次草稿提交前校验日期;通知日期改变后清除不再可选的发布人。发布人选择本身已有 change 处理,未重复添加。 |
| 3.1、3.4、3.9 | 列表丢弃过期响应;考试口径读写固定同一个班级/考试快照并禁用加载中的开关;切班立即清空课程候选。 |
| 3.2 | 提交函数增加互斥与 finally 释放,按钮同步显示 loading,快速双击不会重复发请求。 |
| 3.3 | 考试下拉从独立考试列表生成,不再由筛选后的成绩反推。 |
| 3.5—3.8 | 不回退到已归档届别;使用主题语义色、清理死样式;拒绝协议相对和反斜线附件地址;人数加载失败显示不可用。 |
| 3.12、3.13 | 取消确认框正常返回;列表加载与实时新增、删除、已读合并,防止快照抹掉飞行中的变更。 |
| 3.15 | 核对并修正登录、班级开关、设备权限、菜单数量、Socket 房间注释。登录限流当前已经一次挂载到两个入口并共享计数器,该条报告不成立。 |
| 4.1 | 百分制/字母制批量重算使用分数计算等级;自定义等级没有通用换算规则,禁止整批猜算,保留逐条编辑。 |
| 4.2 | 审计明确记录解析结果 interpreted / unrecognized,不再写成永远等待执行的 pending;它不宣称前端业务发布已成功。 |
| 4.3、4.4 | 助手上下文与预览参数均校验班级、科目范围;缺失通知优先级按普通通知展示。 |
| 4.6 | 外观 IPC 在一帧内合并滑块事件,读取时先刷新最后一帧,避免丢失最终值。 |
| 5 | 清理无调用的名单/课程封装和事件标签等;班级页接通既有任课关系导入。旧时间配置弹窗仍直接调用四个 API,按报告的保留要求保留组件及依赖,不盲目删除。 |
管理员的 Web 权限判断已移除全权限短路,与服务端保持一致;默认组收回的功能入口会隐藏。
日常冒烟采用后台快速模式,视觉验收显式执行;具体命令和验证范围见[验收与测试](testing.md)。
## 本次验证
- 全仓类型检查、ESLint、构建、变更格式检查通过,文档构建无警告。
- 隔离测试库端到端 243/243;审查接口回归 31/31;客户端维护接口 28/28。
- 无窗口逻辑回归 18/18;模拟远控及更新代理 15/15;旧库升级回归通过。
- 后台 Web 回归 37/37,约 38 秒;客户端联网冒烟 10/10,二次启动 9/9,总计约 15 秒。
- 本次使用独立 SQLite 测试库,没有执行真实关机或安装;未运行会占用桌面的完整视觉验收。
+50 -46
View File
@@ -1,27 +1,33 @@
--- ---
title: 验收与测试 title: 验收与测试
description: 五套验收脚本怎么跑、覆盖什么、哪些用例是脆弱的,以及怎么判断失败是不是真回归。 description: 验收验收脚本怎么跑、覆盖什么、哪些用例是脆弱的,以及怎么判断失败是不是真回归。
--- ---
# 验收与测试 # 验收与测试
这个仓库的验收方式是**跑真东西**:真的起后端、真的开 Electron 窗口、真的点击菜单、 验收分为后台快速冒烟、隔离的接口与逻辑回归、显式视觉验收。日常默认不显示窗口、不占用前台;
真的发一条通知。不是单元测试,是端到端回归。 涉及动画、桌面像素和灵动岛几何时,再单独运行视觉验收。
## 五套脚本 ## 验收脚本
| 命令 | 覆盖 | 需要什么 | | 命令 | 覆盖 | 需要什么 |
| -------------------------------- | -------------------------------------------------------------------------------- | --------------------------- | | ---------------------- | -------------------------------------------------------------------------------- | --------------------------- |
| `pnpm verify:e2e` | 后端 + 实时推送 + RBAC 越权 + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查 | **后端已启动** | | `pnpm verify:e2e` | 后端 + 实时推送 + RBAC 越权 + 上课时段 + 导入 + 班级账号 + 数据库管理 + 更新检查 | **后端已启动** |
| `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动) | 后端已启动 + Web 产物已构建 | | `pnpm verify:web` | Web 管理端真实点击回归(Electron 驱动) | 后端已启动 + Web 产物已构建 |
| `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | 已构建的客户端产物 | | `pnpm verify:desktop` | 客户端冒烟(Electron,无人工点击) | 已构建的客户端产物 |
| `pnpm verify:packaged` | 对**打包后 / 已安装**的客户端 EXE 复跑同一套冒烟(`--exe` 指定路径) | 打包产物 | | `pnpm verify:packaged` | 对**打包后 / 已安装**的客户端 EXE 复跑同一套冒烟(`--exe` 指定路径) | 打包产物 |
| `pnpm verify:classisland` | 设备令牌 / 上报 / 提醒下发与回执 / 镜像契约 / 幽灵行清理 | 后端已启动 |
| `pnpm verify:classisland-plugin` | 插件静态契约(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由) | 不需要 .NET,也不需要后端 |
条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`),**文档里不写死容易过期的总数** —— 条目数由脚本自行统计并打印(`=== 结果:N/N 项通过 ===`),**文档里不写死容易过期的总数** ——
每次跑完看脚本输出。 每次跑完看脚本输出。
客户端维护专项回归:
- `pnpm verify:client-agent`:模拟操作系统验证远控、回执重试与安装策略,不执行真实关机或安装器。
- `pnpm verify:client-migrations`:创建临时旧库验证升级、联动移除与默认组编辑持久化。
- `pnpm verify:clients-api`:面向 `http://127.0.0.1:4199` 的演示测试库(可用 `VERIFY_BASE_URL` 指定),验证权限、远控与更新接口。会创建并清理测试记录,请使用隔离测试服务。
- `pnpm verify:review-client`:无窗口验证缓存故障恢复、通知竞态、教学周同步、管理员界面权限与崩溃恢复。
- `pnpm verify:review-api`:针对审查缺陷验证接口权限、导入边界和实时删除等,必须连接隔离测试服务。
另外三套是"门禁"而不是验收:`pnpm typecheck`、`pnpm lint`、`pnpm check:icons`。 另外三套是"门禁"而不是验收:`pnpm typecheck`、`pnpm lint`、`pnpm check:icons`。
### 一次完整的交付前检查 ### 一次完整的交付前检查
@@ -32,8 +38,6 @@ pnpm typecheck # 全仓库类型检查(含 vue-tsc)
pnpm dev:server # 另开一个终端 pnpm dev:server # 另开一个终端
pnpm verify:e2e pnpm verify:e2e
pnpm verify:classisland
pnpm verify:classisland-plugin
pnpm build:desktop && pnpm verify:desktop pnpm build:desktop && pnpm verify:desktop
pnpm build && pnpm verify:web pnpm build && pnpm verify:web
@@ -46,47 +50,49 @@ pnpm build && pnpm verify:web
下面这些用例**不是回归**,遇到时先看条件。完整清单在 `AGENTS.md` 的「已知脆弱用例」表。 下面这些用例**不是回归**,遇到时先看条件。完整清单在 `AGENTS.md` 的「已知脆弱用例」表。
| 用例 | 触发条件 | | 用例 | 触发条件 |
| --------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | | ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| `verify:desktop`「真实通知链路」「灵动岛标为已读」等 3 项 | 要求**当前不在上课时段**。种子课表恰有课正在进行时,客户端会**正确地**把普通通知暂存到下课 —— 这三项就会红(日志里会写 `客户端判定上课中=true`) | | `verify:desktop:visual`「真实通知链路」「灵动岛标为已读」等 3 项 | 要求**当前不在上课时段**。种子课表恰有课正在进行时,客户端会**正确地**把普通通知暂存到下课 —— 这三项就会红(日志里会写 `客户端判定上课中=true`) |
| `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`.env` 的 `TERM_START_DATE` 对齐即可全绿 | | `verify:e2e`「上课状态接口(at=周一 08:10)」 | 种子把周一第 1 节限制在第 1 周,且用例把 `2026-09-07` 当第 1 周。`.env` 的 `TERM_START_DATE` 对齐即可全绿 |
| `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 | | `verify:web`「课表科目为全校统一目录」 | 种子给每个班建满了全部科目,下拉里就没有可自动建课的「统一科目」分组 |
| `verify:desktop`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 | | `verify:desktop:visual`「课表今天时间轴」 | 凌晨约 00:00–03:00 时「已结束」探针被夹紧成 0 长度而被过滤 |
| `verify:desktop`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 | | `verify:desktop:visual`「作业看板全屏自适应」 | 演示数据某天有 6 条长作业、而冒烟窗口只有约 543px 高时,缩放被下限 0.45 钳住 → 必然溢出 |
| 岛交互 / 岛动画 / 岛截图留档 | 窗口被遮挡或跑在远程会话里时会间歇性失败:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** | | 岛交互 / 岛动画 / 岛截图留档 | 窗口被遮挡或跑在远程会话里时会间歇性失败:置顶透明小窗的 rAF 被限流、形变采样抓不到帧。**先原样重跑一次再动手改** |
## 大面积报「登录失败 / 401」时 ## 大面积报「登录失败 / 401」时
先确认演示账号的**当前**口令。种子脚本硬编码的是 `teacher123`,但「修改密码」弹窗留空会落到 先检查隔离服务是否启动,以及测试管理员的当前口令。不要修改真实班级密码来迁就测试。
默认初始密码(`DEFAULT_TEACHER_PASSWORD`,默认 `123456`),任何一次误操作都会把它改掉 —— 联网客户端和 Web 回归会自行创建临时教师与班级;下面的环境变量只配置测试管理员:
而**两个演示账号不一定一起漂**,所以不能简单地全局替换。
**处理方式是用环境变量传实际凭据,不要去改数据。** | 脚本 | 管理员凭据 |
| ------------------- | -------------------------------------------------------- |
| 客户端联网/打包冒烟 | `ELECTRON_SMOKE_ADMIN` / `ELECTRON_SMOKE_ADMIN_PASSWORD` |
| Web 回归 | `UI_SMOKE_ADMIN` / `UI_SMOKE_ADMIN_PASS` |
| 后端端到端回归 | 使用隔离种子库,口令由种子脚本与验收脚本约定 |
| 脚本 | 覆盖凭据的方式 | 默认离线快速冒烟不需要服务端凭据。
| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `verify:desktop` | `set "ELECTRON_SMOKE_USER=teacher1" && set "ELECTRON_SMOKE_PASSWORD=123456" && pnpm verify:desktop` |
| `verify:packaged` | 同上(`ELECTRON_SMOKE_*`) |
| `verify:web` | `set "UI_SMOKE_USER=teacher1" && set "UI_SMOKE_PASS=123456" && pnpm verify:web`(另有 `UI_SMOKE_ADMIN*`) |
| `verify:e2e` | **不支持环境变量**(口令硬编码在脚本里),需自己用改过口令的临时副本跑;副本必须放在 `packages/server/scripts/` 里,否则 `import 'socket.io-client'` 会失败 |
## 冒烟会真的动你的屏幕 ## 默认后台运行,视觉验收单独执行
`verify:web` / `verify:desktop` / `verify:packaged` 会**真的弹窗口**、真的发通知、真的移动鼠标: `pnpm verify:desktop` 默认运行隐藏、不可聚焦的离屏窗口,仅检查启动、桥接白名单、缓存读写、
跨服务端/跨班级缓存隔离和关闭隐藏。二次启动也只跑快速检查,不再重复整套动画。
- 跑的时候别动键鼠; 需要联网时运行 `pnpm verify:desktop:online`,创建临时教师与临时班级,结束后删除。
- 验证实例的灵动岛是**洋红主题色**(`#e91e8c`),一眼可以与你自己客户端那个区分开。 不再自动探测并复用真实班级,也不会重置真实班级密码。Web 回归同样使用隔离的验证教师与班级。
两条反馈里的"屏幕上出现了第二个灵动岛""点它反应不对"都源于这个叠影;
- 冒烟会往演示班发几条「张老师 · _自检_」通知,用完即删;中途被 kill 会留下残条。 `pnpm verify:desktop:visual`(或客户端启动器 `--visual`)才运行灵动岛动画、透明度、桌面截图等完整验收,
这类用例确实需要使用桌面。`verify:packaged` 也默认快速模式,显式传 `--visual` 才跑视觉验收。
视觉验收不适合在用户操作屏幕时自动运行。截图采集命令需要传入 `--visual`(或设置 `ELECTRON_SMOKE_MODE=visual`)。
结果记录模式和耗时;启动器设置超时,避免验证进程无限等待。
## 截图留档 ## 截图留档
截图分两套,各管一件事: 截图分两套,各管一件事:
| 目录 | 谁在用 | 谁产出 | | 目录 | 谁在用 | 谁产出 |
| ------------------- | ----------------------------------------------------------- | --------------------------------------------------------------------- | | ------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------- |
| `docs/images/` | **文档站、README、官网用的是同一份图**,页面上展示的都是它 | `capture-shots.mjs`(Web / 手机)+ 客户端冒烟(客户端 / 灵动岛) | | `docs/images/` | **文档站、README、官网用的是同一份图**,页面上展示的都是它 | `capture-shots.mjs`(Web / 手机)+ 客户端冒烟(客户端 / 灵动岛) |
| `docs/screenshots/` | 回归留档,页面不展示;`verify:*` 的像素级断言比对的就是它们 | 各冒烟脚本自动写入(island / client / web-mobile / classisland 四组) | | `docs/screenshots/` | 回归留档,页面不展示;`verify:*` 的像素级断言比对的就是它们 | 各冒烟脚本自动写入(island / client / web-mobile 三组) |
**展示图重新采集**(保证文档站与 README、官网用的是同一份图): **展示图重新采集**(保证文档站与 README、官网用的是同一份图):
@@ -98,8 +104,7 @@ CAPTURE_OUT=docs/images,website/assets/shots node website/tools/capture-shots.mj
# (*-window.png / *-backdrop.png),所以先落到临时目录,再挑要用的拷进去。 # (*-window.png / *-backdrop.png),所以先落到临时目录,再挑要用的拷进去。
ISLAND_SHOTS_DIR="$PWD/.cache/shots" ELECTRON_CLIENT_SHOTS_DIR="$PWD/.cache/shots" \ ISLAND_SHOTS_DIR="$PWD/.cache/shots" ELECTRON_CLIENT_SHOTS_DIR="$PWD/.cache/shots" \
ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \ ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \
ELECTRON_SMOKE_USER=teacher1 ELECTRON_SMOKE_PASSWORD=123456 \ node packages/desktop-client/scripts/smoke.mjs --visual --online
node packages/desktop-client/scripts/smoke.mjs
cp .cache/shots/{island-*,client-*}.png docs/images/ cp .cache/shots/{island-*,client-*}.png docs/images/
rm -f docs/images/*-window.png docs/images/*-backdrop.png rm -f docs/images/*-window.png docs/images/*-backdrop.png
cp docs/images/{island-*,client-*}.png website/assets/shots/ # 官网那份保持同步 cp docs/images/{island-*,client-*}.png website/assets/shots/ # 官网那份保持同步
@@ -112,8 +117,7 @@ pnpm dev:server
ISLAND_SHOTS_DIR="$PWD/.cache/island-shots" \ ISLAND_SHOTS_DIR="$PWD/.cache/island-shots" \
ELECTRON_CLIENT_SHOTS_DIR="$PWD/.cache/client-shots" \ ELECTRON_CLIENT_SHOTS_DIR="$PWD/.cache/client-shots" \
ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \ ELECTRON_SMOKE_ISLAND_ACCENT='#0a84ff' ELECTRON_SMOKE_ISLAND_STYLE=black \
ELECTRON_SMOKE_USER=teacher1 ELECTRON_SMOKE_PASSWORD=123456 \ node packages/desktop-client/scripts/smoke.mjs --visual --online
node packages/desktop-client/scripts/smoke.mjs
``` ```
> 展示图都是**逻辑像素 1×**(灵动岛的 268×44、424×230 就是这个数,不再乘 2), > 展示图都是**逻辑像素 1×**(灵动岛的 268×44、424×230 就是这个数,不再乘 2),
+2 -2
View File
@@ -12,11 +12,11 @@
href: permissions.md href: permissions.md
- name: 动效层与主题 - name: 动效层与主题
href: motion.md href: motion.md
- name: ClassIsland 插件
href: classisland-plugin.md
- name: 灵动岛设计令牌 - name: 灵动岛设计令牌
href: winisland-design-tokens.md href: winisland-design-tokens.md
- name: 验收与测试 - name: 验收与测试
href: testing.md href: testing.md
- name: 2026-10-17 审查修复记录
href: review-2026-10-17.md
- name: 版本迭代与发布 - name: 版本迭代与发布
href: release.md href: release.md
+2 -2
View File
@@ -28,7 +28,7 @@ description: 装不上、连不上、改了不生效——最常被问到的那
1. 服务器地址要填**局域网 IP**(`http://192.168.1.10:4000`),不要写 `localhost`(那是客户端自己); 1. 服务器地址要填**局域网 IP**(`http://192.168.1.10:4000`),不要写 `localhost`(那是客户端自己);
2. 服务器防火墙放行 **4000**; 2. 服务器防火墙放行 **4000**;
3. 服务器上如果开了系统代理,内网地址可能被代理接管 —— 浏览器与客户端**不会**自动绕过 3. 服务器上如果开了系统代理,内网地址可能被代理接管 —— 浏览器与客户端**不会**自动绕过
(只有 ClassIsland 插件对本地 / 内网做了强制直连); 请在系统代理中为内网服务端设置绕过规则;
4. 在服务器本机 `curl.exe --noproxy '*' http://127.0.0.1:4000/healthz` 是不是 200? 4. 在服务器本机 `curl.exe --noproxy '*' http://127.0.0.1:4000/healthz` 是不是 200?
### 页面能打开,但接口全 401 ### 页面能打开,但接口全 401
@@ -57,7 +57,7 @@ description: 装不上、连不上、改了不生效——最常被问到的那
1. 客户端顶栏的实时通道是不是"已连接"? 1. 客户端顶栏的实时通道是不是"已连接"?
2. 现在是上课时段吗?(同上,是设计行为) 2. 现在是上课时段吗?(同上,是设计行为)
3. 教室客户端的「设置 → 提醒」是不是设成了"只在 ClassIsland 上弹"?那样客户端只进通知中心。 3. 确认客户端登录的是这条通知对应的班级。
4. 反向代理放行 `/socket.io/` 的 WebSocket 升级了吗? 4. 反向代理放行 `/socket.io/` 的 WebSocket 升级了吗?
### 屏幕上出现了第二个灵动岛,点它反应不对 ### 屏幕上出现了第二个灵动岛,点它反应不对
-120
View File
@@ -1,120 +0,0 @@
---
title: 接入 ClassIsland
description: 把教室里的 ClassIsland 接上 ClassHelper:下课表上报、大屏提醒下发、课表镜像三个方向。
---
# 接入 ClassIsland
课堂里真正"知道现在第几节、在上什么课"的是 [ClassIsland](https://classisland.tech)。
它跟 ClassHelper管的事情不重叠,接起来之后两边各拿各的好处:
| 方向 | 做什么 | 老师在哪看 |
| ------------------------------- | ----------------------------------------------------------------------------------- | ---------------------------------------- |
| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止),写进本班课表 | Web 端「ClassIsland 联动」的实时状态卡片 |
| ClassHelper → ClassIsland | 老师发一条**提醒** → 落库 → 教室插件取回 → ClassIsland 上**全屏弹出**(可语音朗读) | Web 端「ClassIsland 联动 → 下发提醒」 |
| (可选)班级 → ClassIsland 镜像 | 把小助手这边排好的课表**镜像**回 ClassIsland,新建一份档案,不动老师原有的课表 | 同一页的「开镜像」开关 |
> 没接 ClassIsland 也能用 ClassHelper,只是教室里少一个大屏播报的出口。**这一步是可选的。**
## 前置:ClassIsland 装好了
本插件按 ClassIsland 插件 API 2.0 编写,ClassIsland 2.0.x 与 2.1.0.x 都能加载。
先确认教室机器上 ClassIsland 本身能正常显示课表。
## 第一步:在 Web 端签发设备令牌
**ClassIsland 联动**页(管理员 / 本班班主任)→「接入新设备」→ 选班级 → 生成令牌。
令牌形如 `chci_xxxxxxxx…`,**明文只显示这一次**:服务端只存它的 sha256 与一个前缀提示
(`chci_80fa1c…`),用来在设备列表里人眼区分。丢了就重置一个。
## 第二步:在教室机器上装插件并填令牌
### 装插件
从 [Releases](https://github.com/LoveChengke/classhelper/releases) 取
`ClassHelper.ClassIslandPlugin.cipx`:
- **推荐**:在 ClassIsland 里直接安装这个 `.cipx`(插件页有「从文件安装」)。
- 或者手工解压:把包里的 `ClassHelper.ClassIslandPlugin/` 目录整个丢进
`<ClassIsland 数据目录>\Plugins\`,然后重启 ClassIsland。
> **ClassIsland 运行时会锁住 DLL**。要覆盖升级插件,必须先完全退出 ClassIsland 再覆盖,
> 否则写入会被拒。插件配置在 `Config\Plugins\<插件 id>\Settings.json`,
> 里面存着服务器地址与设备令牌 —— **升级时别覆盖这个文件**。
> 另外改配置要在 ClassIsland 退出时改,运行中改会被内存里的副本回写覆盖。
### 填令牌
ClassIsland 的**设置 → ClassHelper 联动**里填:
| 设置项 | 说明 |
| ---------------------------- | --------------------------------------------------------------- |
| 后端地址 | `http://<服务器IP>:4000` |
| 设备令牌 | 上一步生成的 `chci_…` |
| 上报课表到小助手 | 打开后才会把 ClassIsland 的课表同步过来 |
| 接收小助手提醒 | 关掉就收不到老师发的提醒 |
| 提醒轮询间隔 | 默认 10 秒(可调到 5 秒)。上报本身就是心跳,轮询只是顺带取提醒 |
| 把班级课表镜像回 ClassIsland | 见下面「镜像」一节 |
填完点**「立即上报」**。
## 第三步:验证
回到 Web 端的「ClassIsland 联动」页,应该出现设备卡片和「在线」标记,实时状态卡上会显示这台机器
现在上什么课。到这一步链路就通了。
如果一直离线:先看插件的日志,再看服务端能不能从教室机器访问到(**别让系统代理拦住内网地址** ——
插件对本地 / 内网做了强制直连,但填错地址一样连不上)。
## 提醒弹在哪个端:由教室客户端自己选
教室机器的**客户端**「设置 → 提醒」里选,选择会同步到班级记录:
| 选择 | ClassHelper 客户端 | ClassIsland |
| ----------------------------------------- | ------------------------------ | ----------- |
| `ClassHelper 与 ClassIsland 都弹`(默认) | 弹窗 + 灵动岛 | 全屏提醒 |
| `只在 ClassHelper 客户端弹` | 弹窗 + 灵动岛 | 不推送 |
| `只在 ClassIsland 上弹` | 只进通知中心(不弹窗、不上岛) | 全屏提醒 |
规则只有一条,**任何入口都遵守**:通知发布页、叫人、联动页的「下发提醒」三个入口共用同一份推送实现,
不存在"某个页面发的通知不联动"。
## 两个方向各需要哪些开关
| 要同步的内容 | 需要打开 |
| -------------------------------- | ------------------------------------------------------------------------------- |
| 服务端 → ClassIsland(镜像课表) | ① 插件设置「把班级课表镜像回 ClassIsland」+ ② Web 端该设备的「镜像课表」 |
| ClassIsland → 服务端(上报课表) | 插件设置「上报课表到 ClassHelper」 |
| 提醒下发 | ① 教室客户端的「通知显示位置」不是"只在 ClassHelper" + ② 插件「接收小助手提醒」 |
## 上课时段:两边同一套规则
老师在**上课时段**发通知时,教室里的两个端遵守同一条规则,否则会出现"学生在客户端看不到、
却在 ClassIsland 上被打断"的怪事:
| 提醒类型 | 上课时段 | 下课 / 放学 |
| ------------------------------------------------------ | ---------------------------- | ----------- |
| 通知类(`kind=notification`:通知发布页 / 联动页下发) | **暂存不弹**(两边都先收着) | 自动补弹 |
| 主动通知:**紧急提醒**、**叫人**(`kind=call`) | **立刻弹** | 立刻弹 |
ClassIsland 侧的暂存由插件实现:它订阅课程服务,下课后把暂存的提醒补弹出来;未弹出前不回执,
所以服务端会一直保留着待提醒,教室机器断网也不丢。
## 上报课表是"双向对齐"的
插件每次全量上报时,服务端只清理**「来源是 ClassIsland 且这次没再上报」**的课表行。于是:
- 老师在 ClassIsland 里删掉 / 挪动一节 → 小助手这边跟着同步;
- 老师在小助手这边手排的课 → 一律不删。
## 镜像:把小助手的课表写回 ClassIsland
开启后,插件会新建一份名为 `ClassHelper-<班级名>` 的档案课表,**不动老师原有的课表**。
镜像时会连节次时间一起带过去,并且**每两节之间补一段课间、最后一节之后不补**(放学)。
这一步不做的话,ClassIsland 的课表会整体错位 —— 表现为"看着有内容、每节课都对不上"。
细节与实现见[ClassIsland 插件](../dev/classisland-plugin.md)。
下一步:[发布第一条内容](first-content.md)。
+4 -6
View File
@@ -12,8 +12,8 @@ description: 在教室机器上安装 ClassHelper桌面客户端,用班级码
从 [Releases](https://github.com/LoveChengke/classhelper/releases) 取: 从 [Releases](https://github.com/LoveChengke/classhelper/releases) 取:
| 产物 | 用途 | | 产物 | 用途 |
| ------------------------------------ | ------------------------------------------ | | ------------------------------------- | ------------------------------------------ |
| `ClassHelper-<版本>-x64-setup.exe` | 安装包(约 98 MB),装到本机并建快捷方式 | | `ClassHelper-<版本>-x64-setup.exe` | 安装包(约 98 MB),装到本机并建快捷方式 |
| `ClassHelper-<版本>-x64-portable.exe` | 单文件便携版,双击即用,适合不改动机房机器 | | `ClassHelper-<版本>-x64-portable.exe` | 单文件便携版,双击即用,适合不改动机房机器 |
@@ -71,8 +71,8 @@ pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的
2. **调灵动岛** —— 设置 → 灵动岛:高度、宽度、字号、主题色、停靠位置、视觉风格。 2. **调灵动岛** —— 设置 → 灵动岛:高度、宽度、字号、主题色、停靠位置、视觉风格。
讲台机器上通常把字号调大一点、位置放到顶部居中。 讲台机器上通常把字号调大一点、位置放到顶部居中。
触摸屏(希沃白板等)走的是另一套命中逻辑,客户端会自动识别,不用手动开。 触摸屏(希沃白板等)走的是另一套命中逻辑,客户端会自动识别,不用手动开。
3. **选提醒弹在哪端** —— 设置 → 提醒:`ClassHelper 与 ClassIsland 都弹` / 只在客户端 / 只在 ClassIsland。 3. **查看提醒规则** —— 设置 → 提醒:普通通知下课显示,紧急提醒立即显示。
这个选择会同步到班级记录,服务端据此决定要不要推 ClassIsland。 这个选择会同步到班级记录,服务端据此决定要不要广播到客户端。
## 6. 确认实时链路是通的 ## 6. 确认实时链路是通的
@@ -83,5 +83,3 @@ pnpm dist:win # 安装包 + 便携版(两个都是成品,没有中间的
- 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗? - 服务器与教室机器之间能互相 ping 通吗?防火墙放行 4000 了吗?
排查清单见[常见问题](../faq.md)。 排查清单见[常见问题](../faq.md)。
下一步(可选):[接入 ClassIsland](classisland.md)。
+2 -20
View File
@@ -11,7 +11,7 @@ description: 发通知、布置作业、叫人、录成绩——把 ClassHelper
**通知发布** → 选班级 → 填标题和内容 → 选优先级 → 发布。 **通知发布** → 选班级 → 填标题和内容 → 选优先级 → 发布。
| 优先级 | ClassHelper 班级端表现 | | 优先级 | ClassHelper 班级端表现 |
| ------ | -------------------------------------------------------------------------- | | ------ | -------------------------------------------------------------------------- |
| 普通 | 课间弹胶囊,点击展开;**上课时段只进队列**,下课自动补弹 | | 普通 | 课间弹胶囊,点击展开;**上课时段只进队列**,下课自动补弹 |
| 重要 | 同上,卡片上带优先级标签 | | 重要 | 同上,卡片上带优先级标签 |
@@ -39,7 +39,7 @@ ClassHelper 班级端那边默认是**看板模式**:按科目分卡片、每
**通知发布 → 叫人**(或管理员在**学生管理 → 叫人**)→ 选学生 → 点一个快捷短语或自己写一句 → 发送。 **通知发布 → 叫人**(或管理员在**学生管理 → 叫人**)→ 选学生 → 点一个快捷短语或自己写一句 → 发送。
| 级别 | ClassHelper 班级端 | | 级别 | ClassHelper 班级端 |
| -------- | ------------------------------------------------------------------ | | -------- | ------------------------------------------------------------------ |
| 普通叫人 | 课间显示「叫人」胶囊,点击展开;**上课时段只进队列**,下课自动弹出 | | 普通叫人 | 课间显示「叫人」胶囊,点击展开;**上课时段只进队列**,下课自动弹出 |
| 紧急叫人 | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 | | 紧急叫人 | **无视上课时段立即展开**,上课时被收起后仍保留胶囊、可再次点开 |
@@ -58,21 +58,3 @@ ClassHelper 班级端那边默认是**看板模式**:按科目分卡片、每
- 导入结果会给逐行的错误与警告,`errors[].row` 是 **Excel 视角的行号**(含表头),改完可以再导。 - 导入结果会给逐行的错误与警告,`errors[].row` 是 **Excel 视角的行号**(含表头),改完可以再导。
ClassHelper 班级端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。 ClassHelper 班级端看到的是**全班总览**:表格(分数 / 得分率 / 等级)+ 柱状图 + 等级分布 + 最近更新。
## 在教室大屏上播一条提醒(需要 ClassIsland)
**ClassIsland 联动 → 下发提醒** → 选班级、填标题内容、设置显示时长与语音朗读 → 下发。
它会**落库**再推给教室。教室机器断网也不丢:插件重连后会拉取还没确认的那条,
立刻弹出来,弹完回执,不会重复弹。
如果教室客户端把「通知显示位置」设成了"只在 ClassHelper 客户端显示",这一步会回一句
「该班教室已设置为只在 ClassHelper 客户端显示」—— 那是设计行为,不是报错。
---
到这里你已经把整套系统跑通了。接下来:
- 每一项功能的完整行为 → [应用帮助](../app/index.md)
- 服务器怎么维护 → [管理运维](../management/index.md)
- 遇到问题 → [常见问题](../faq.md)
+8 -10
View File
@@ -1,18 +1,17 @@
--- ---
title: 快速上手 title: 快速上手
description: 从零把 ClassHelper跑起来:装服务端、认管理端、建班级排课、装 ClassHelper 班级端、接 ClassIsland、发第一条内容。 description: 从零把 ClassHelper跑起来:装服务端、认管理端、建班级排课、装 ClassHelper 班级端、发第一条内容。
--- ---
# 快速上手 # 快速上手
ClassHelper不是一个单机软件,它由四部分组成,**必须先有服务端,其他三端才有内容可看**: ClassHelper不是一个单机软件,它由三部分组成,**必须先有服务端,管理端与客户端才有内容可看**:
| 组成部分 | 装在哪 | 谁用 | 必须先装? | | 组成部分 | 装在哪 | 谁用 | 必须先装? |
| ------------------------ | --------------------------------------- | -------------------------- | ---------------- | | -------------- | --------------------------------------- | -------------------------- | ---------------- |
| **服务端** | 一台能一直开着的机器(服务器/办公电脑) | —(它同时托管 Web 管理端) | ✅ 是 | | **服务端** | 一台能一直开着的机器(服务器/办公电脑) | —(它同时托管 Web 管理端) | ✅ 是 |
| **Web 管理端** | 不用装 —— 浏览器打开服务端地址即可 | 老师、班主任、管理员 | 跟着服务端一起有 | | **Web 管理端** | 不用装 —— 浏览器打开服务端地址即可 | 老师、班主任、管理员 | 跟着服务端一起有 |
| **桌面客户端** | 教室的电脑 / 教室大屏 | 学生看课表作业、老师录作业 | 服务端就绪之后 | | **桌面客户端** | 教室的电脑 / 教室大屏 | 学生看课表作业、老师录作业 | 服务端就绪之后 |
| **ClassIsland 联动插件** | 教室那台装了 ClassIsland 的机器 | 教室大屏播报 | 可选 |
按顺序走下面这几页就行,全程大约半小时(不含下载时间): 按顺序走下面这几页就行,全程大约半小时(不含下载时间):
@@ -21,8 +20,7 @@ ClassHelper不是一个单机软件,它由四部分组成,**必须先有服
3. [认识 Web 管理端](web-admin.md) —— 登录进去,认一认界面 3. [认识 Web 管理端](web-admin.md) —— 登录进去,认一认界面
4. [建班级、排课表](setup-class.md) —— 这是后面所有功能的前提 4. [建班级、排课表](setup-class.md) —— 这是后面所有功能的前提
5. [装 ClassHelper 班级端](client.md) —— 教室机器用班级码登录 5. [装 ClassHelper 班级端](client.md) —— 教室机器用班级码登录
6. [接入 ClassIsland](classisland.md) —— 让教室大屏也会弹提醒(可选) 6. [发布第一条内容](first-content.md) —— 通知 / 作业 / 叫人 / 成绩
7. [发布第一条内容](first-content.md) —— 通知 / 作业 / 叫人 / 成绩
## 只想先看看它长什么样? ## 只想先看看它长什么样?
+1 -2
View File
@@ -65,8 +65,7 @@ ldd --version | head -1
| 公网访问 | 前面加 Nginx / Caddy 反代并启用 HTTPS,见[生产部署指南](../management/production.md) | | 公网访问 | 前面加 Nginx / Caddy 反代并启用 HTTPS,见[生产部署指南](../management/production.md) |
> **别用代理**。自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`; > **别用代理**。自测时用 `curl.exe --noproxy '*'`,PowerShell 用 `-NoProxy`;
> 教室机器上如果开了系统代理,填服务端地址后可能拿到代理返回的 404。插件里对本地 / 内网地址做了强制直连, > 教室机器上如果开了系统代理,填服务端地址后可能拿到代理返回的 404。请检查系统代理绕过列表是否包含服务端的局域网地址。
> 但浏览器与客户端本身不会绕过。
## 卸载 / 重置 ## 卸载 / 重置
+1 -1
View File
@@ -5,7 +5,7 @@ description: 建第一个班级、配班级账号、加学生与老师、排课
# 建班级、排课表 # 建班级、排课表
后面所有功能(发通知、布置作业、录成绩、接 ClassIsland)都挂在**班级**上,所以先把班级建出来。 后面所有功能(发通知、布置作业、录成绩)都挂在**班级**上,所以先把班级建出来。
## 1. 建班级(仅管理员) ## 1. 建班级(仅管理员)
-2
View File
@@ -10,7 +10,5 @@
href: setup-class.md href: setup-class.md
- name: 装 ClassHelper 班级端 - name: 装 ClassHelper 班级端
href: client.md href: client.md
- name: 接入 ClassIsland
href: classisland.md
- name: 发布第一条内容 - name: 发布第一条内容
href: first-content.md href: first-content.md
+13 -13
View File
@@ -20,19 +20,19 @@ Web 管理端不用装:服务端起来之后,浏览器打开 `http://<服务
布局沿用 1Panel 那一套:左边固定深色侧边栏,右边浅灰底 + 白色圆角卡片。 布局沿用 1Panel 那一套:左边固定深色侧边栏,右边浅灰底 + 白色圆角卡片。
| 页面 | 干什么 | 谁能进 | | 页面 | 干什么 | 谁能进 |
| ---------------- | ---------------------------------------------- | ---------------------- | | ---------- | ---------------------------------------------- | ---------------------- |
| 仪表盘 | 全校 / 本班的汇总数字与最近动态 | 所有登录用户 | | 仪表盘 | 全校 / 本班的汇总数字与最近动态 | 所有登录用户 |
| 课表管理 | 排课、导入 ClassIsland 课表、节次时间 | 管理员、本班班主任 | | 课表管理 | 排课、导入 ClassIsland 课表、节次时间 | 管理员、本班班主任 |
| 作业发布 | 发作业、按天查看、看未交名单 | 教师、管理员、班级账号 | | 作业发布 | 发作业、按天查看、看未交名单 | 教师、管理员、班级账号 |
| 通知发布 | 发通知、发叫人 | 教师、管理员 | | 通知发布 | 发通知、发叫人 | 教师、管理员 |
| 成绩录入 | 单条 / 批量录成绩、导入表格、看统计 | 管理员、本班班主任 | | 成绩录入 | 单条 / 批量录成绩、导入表格、看统计 | 管理员、本班班主任 |
| ClassIsland 联动 | 接入教室设备、下发提醒、看实时状态、开镜像 | 管理员、教师 | | 客户端管理 | 看设备状态、下发远控与更新 | 管理员、教师 |
| 班级管理 | 建班级、换班主任、改班级账号 | **仅管理员** | | 班级管理 | 建班级、换班主任、改班级账号 | **仅管理员** |
| 学生管理 | 学生名单增删、导入 | **仅管理员** | | 学生管理 | 学生名单增删、导入 | **仅管理员** |
| 教师管理 | 教师账号增删改、导入名单、重置密码 | **仅管理员** | | 教师管理 | 教师账号增删改、导入名单、重置密码 | **仅管理员** |
| 数据库管理 | 备份 / 恢复、导出导入、一键切换 SQLite ⇄ MySQL | **仅管理员** | | 数据库管理 | 备份 / 恢复、导出导入、一键切换 SQLite ⇄ MySQL | **仅管理员** |
| 检查更新 | 顶栏头像菜单里,查 GitHub 上有没有新版本 | 所有登录用户 | | 检查更新 | 顶栏头像菜单里,查 GitHub 上有没有新版本 | 所有登录用户 |
> **前端隐藏 ≠ 权限**。上面这张表只是"入口藏不藏",每一条写入请求服务端都会再判一次 > **前端隐藏 ≠ 权限**。上面这张表只是"入口藏不藏",每一条写入请求服务端都会再判一次
> (见[角色与权限](../dev/permissions.md))。直接输网址绕过菜单,一样会被挡回仪表盘。 > (见[角色与权限](../dev/permissions.md))。直接输网址绕过菜单,一样会被挡回仪表盘。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

+5 -7
View File
@@ -5,18 +5,17 @@ description: ClassHelper(Class Helper)的官方文档:安装部署、功
# ClassHelper文档 # ClassHelper文档
ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统。**一份代码,四个交付物**: ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统。**一份代码,三个交付物**:
装在服务器上的后端、给老师用的 Web 管理端、装在教室机器的桌面客户端(ClassHelper 班级端 / 教室大屏), 装在服务器上的后端、给老师用的 Web 管理端、装在教室机器的桌面客户端(ClassHelper 班级端 / 教室大屏)。
以及装在 [ClassIsland](https://classisland.tech) 上的联动插件。
<p class="ch-tagline"> <p class="ch-tagline">
老师发一条通知,教室那台机器上的灵动岛和 ClassIsland 会同时弹出来;学生打开客户端就能看到当天的课表和作业。 老师发一条通知,教室那台机器上的客户端和灵动岛会实时显示;学生打开客户端就能看到当天的课表和作业。
</p> </p>
<div class="ch-cards"> <div class="ch-cards">
<a class="ch-card" href="get-started/index.md"> <a class="ch-card" href="get-started/index.md">
<span class="ch-card-title">第一次装</span> <span class="ch-card-title">第一次装</span>
<span class="ch-card-desc">从零把服务端、管理端、ClassHelper 班级端和 ClassIsland 插件跑起来,大约半小时。</span> <span class="ch-card-desc">从零把服务端、管理端、ClassHelper 班级端跑起来,大约半小时。</span>
</a> </a>
<a class="ch-card" href="app/index.md"> <a class="ch-card" href="app/index.md">
<span class="ch-card-title">已经在用了</span> <span class="ch-card-title">已经在用了</span>
@@ -54,8 +53,7 @@ ClassHelper(Class Helper)是给中小学班级用的一套信息管理系统
## 这套系统的三件事 ## 这套系统的三件事
**一、老师发,教室弹。** Web 端发布的内容通过 Socket.IO 按班级房间广播,客户端与灵动岛实时更新; **一、老师发,教室弹。** Web 端发布的内容通过 Socket.IO 按班级房间广播,客户端与灵动岛实时更新。
落到 ClassIsland 上的那部分走设备令牌的独立通道,走 HTTP 上报,不依赖教室机器的网络环境有多好。
**二、上课时段不打扰。** 「上课时段」由课表实时判定,普通通知只进队列、下课自动补弹, **二、上课时段不打扰。** 「上课时段」由课表实时判定,普通通知只进队列、下课自动补弹,
紧急通知和叫人则立刻弹出来 —— 服务端强制拦截,绕开界面直接调接口一样会被拦。 紧急通知和叫人则立刻弹出来 —— 服务端强制拦截,绕开界面直接调接口一样会被拦。
+4 -5
View File
@@ -115,11 +115,6 @@ docker compose up -d
> 用户打开的还是旧的 `resources/app.asar` —— 表现为"改了但界面不生效"。 > 用户打开的还是旧的 `resources/app.asar` —— 表现为"改了但界面不生效"。
> 覆盖升级时用 `robocopy ... /E` 推一遍,再对**那个副本**跑一次打包版冒烟确认。 > 覆盖升级时用 `robocopy ... /E` 推一遍,再对**那个副本**跑一次打包版冒烟确认。
### ClassIsland 插件
**必须先完全退出 ClassIsland**(它会锁住 DLL),再覆盖插件,然后重启。
插件配置 `Config\Plugins\<插件 id>\Settings.json` 里存着服务器地址与设备令牌,**别覆盖它**。
## 升级前的准备清单 ## 升级前的准备清单
- [ ] 备份数据(`classhelper backup` / 复制 `data` 目录 / `mysqldump`); - [ ] 备份数据(`classhelper backup` / 复制 `data` 目录 / `mysqldump`);
@@ -130,3 +125,7 @@ docker compose up -d
## 给维护者 ## 给维护者
打版本、出安装包、发 Release 的完整流程见[版本迭代与发布](../dev/release.md)。 打版本、出安装包、发 Release 的完整流程见[版本迭代与发布](../dev/release.md)。
### 客户端远控与更新修复
先升级服务端,再推送修复后的客户端安装包。服务端兼容旧客户端读取顶层心跳字段,旧设备也可领取更新;第一次升级前仍遵循旧客户端的空闲安装条件。新客户端支持立即、放学后和空闲安装三种策略。放学后取指定的最早时间与当天最后一节课结束时间中的较晚者;设备离线时在再次上线后处理。
+329 -493
View File
File diff suppressed because it is too large. Load diff
+9 -6
View File
@@ -1,6 +1,6 @@
{ {
"name": "class-helper", "name": "class-helper",
"version": "1.3.2", "version": "1.3.3",
"private": true, "private": true,
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)", "description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
"packageManager": "pnpm@11.8.0", "packageManager": "pnpm@11.8.0",
@@ -15,7 +15,6 @@
"dev:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client dev", "dev:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client dev",
"build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/desktop-client build", "build": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && pnpm --filter @classhelper/desktop-client build",
"build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build", "build:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
"build:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs",
"check:icons": "node scripts/check-icons.mjs", "check:icons": "node scripts/check-icons.mjs",
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck", "typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
"lint": "eslint .", "lint": "eslint .",
@@ -30,11 +29,14 @@
"db:switch:mysql": "node scripts/use-database.mjs mysql", "db:switch:mysql": "node scripts/use-database.mjs mysql",
"db:switch:sqlite": "node scripts/use-database.mjs sqlite", "db:switch:sqlite": "node scripts/use-database.mjs sqlite",
"verify:e2e": "pnpm --filter @classhelper/server verify:e2e", "verify:e2e": "pnpm --filter @classhelper/server verify:e2e",
"verify:client-agent": "node scripts/verify-client-agent.mjs",
"verify:review-client": "node scripts/verify-review-client.mjs",
"verify:review-api": "node scripts/verify-review-api.mjs",
"verify:clients-api": "node scripts/verify-clients-api.mjs",
"verify:client-migrations": "node scripts/verify-client-migrations.mjs",
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke", "verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
"verify:web": "node scripts/ui-smoke/run.mjs", "verify:web": "node scripts/ui-smoke/run.mjs",
"verify:packaged": "node scripts/verify-packaged.mjs", "verify:packaged": "node scripts/verify-packaged.mjs",
"verify:classisland": "pnpm --filter @classhelper/server verify:classisland",
"verify:classisland-plugin": "node packages/classisland-plugin/scripts/verify.mjs",
"icons": "node scripts/generate-icons.mjs", "icons": "node scripts/generate-icons.mjs",
"version:check": "node scripts/version.mjs", "version:check": "node scripts/version.mjs",
"version:bump": "node scripts/version.mjs --bump", "version:bump": "node scripts/version.mjs --bump",
@@ -46,8 +48,9 @@
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win", "dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs", "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:server:linux": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs --platform linux",
"dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx", "dist:all": "pnpm dist:server && pnpm dist:win",
"dist:all": "pnpm dist:server && pnpm dist:win && pnpm dist:classisland-plugin" "verify:desktop:visual": "node packages/desktop-client/scripts/smoke.mjs --visual",
"verify:desktop:online": "node packages/desktop-client/scripts/smoke.mjs --online"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^10.0.5", "concurrently": "^10.0.5",
-4
View File
@@ -1,4 +0,0 @@
# .NET 构建产物(与仓库根的 .gitignore 分开:根 .gitignore 是 GBK 编码,apply_patch 读不了)
bin/
obj/
cipx/
@@ -1,45 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<!--
ClassIsland 2.0 / 2.1.0.x 面向 net8.0;2.1.1 起改为 net10.0。
本插件按 net8.0 交付,因此 SDK 版本固定到最后一个 net8.0 的版本(2.1.0.1),
这样 ClassIsland 2.0.x 与 2.1.0.x 都能加载(apiVersion 见 manifest.yml)。
-->
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>12</LangVersion>
<RootNamespace>ClassHelper.ClassIslandPlugin</RootNamespace>
<AssemblyName>ClassHelper.ClassIslandPlugin</AssemblyName>
<ClassIslandPluginSdkVersion>2.1.0.1</ClassIslandPluginSdkVersion>
<!-- 插件由 ClassIsland 加载,不需要生成自带运行时(ExcludeAssets 见下) -->
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<!-- 设置页是 .axaml:需要让 Avalonia 的默认项把 **/*.axaml 收成 AvaloniaXaml 并编译 -->
<EnableDefaultItems>true</EnableDefaultItems>
<!-- 绑定走运行时解析:插件与主程序各自编译,编译期绑定会因看不到对方的类型而失败 -->
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
<NoWarn>$(NoWarn);CS1591</NoWarn>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>
<ItemGroup>
<!-- 运行时与原生资源由 ClassIsland 本体提供,插件只编译期依赖 -->
<PackageReference Include="ClassIsland.PluginSdk" Version="$(ClassIslandPluginSdkVersion)">
<ExcludeAssets>runtime;native</ExcludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Update="manifest.yml">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="README.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
<None Update="icon.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>
@@ -1,10 +0,0 @@
{
"profiles": {
"ClassIsland 插件": {
"commandName": "Executable",
"executablePath": "$(ClassIsland_DebugBinaryFile)",
"commandLineArgs": "-epp $(TargetDir)",
"workingDirectory": "$(ClassIsland_DebugBinaryDirectory)"
}
}
}
-163
View File
@@ -1,163 +0,0 @@
# ClassHelper 联动插件
把 [ClassIsland](https://classisland.tech) 与 [ClassHelper](https://github.com/LoveChengke/ClassHelper) 连起来:
让 ClassHelper**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
## 它能做什么
| 方向 | 说明 |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
| ClassIsland → ClassHelper | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
| ClassHelper → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
| ClassHelper → ClassIsland(可选) | 把 ClassHelper上排好的课表**镜像**回 ClassIsland(新建 `ClassHelper-<班级名>` 档案课表,不改动老师原有的课表) |
## 安装
1. 把插件放进 ClassIsland 的插件目录,二选一:
- 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」;
- 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录
(Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。
2. 重启 ClassIsland,在「设置 → 插件」里确认「ClassHelper 联动」已启用。
3. 打开「设置 → ClassHelper 联动」,填入:
- **服务器地址**:ClassHelper后端地址,例如 `http://127.0.0.1:4000`;
- **设备令牌**:在 ClassHelper Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
## 配置项(全部在「设置 → ClassHelper 联动」里)
| 配置项 | 说明 |
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
| 服务器地址 | ClassHelper后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
| 上报课表到 ClassHelper | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
| 接收 ClassHelper提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
| 显示调试信息 | 展开设备标识、与服务端的时钟差、配置目录等,排查「为什么同步不上」时打开 |
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
> **上课时段策略(与 ClassHelper 客户端同一套规则)**:
> 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。
> 暂存期间不回执,服务端会保留这条待提醒。
> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段
> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来,
> 不依赖 ClassHelper那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
> **两个方向各自需要的开关**:
>
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
> - ClassIsland → 服务端(上报课表):插件「上报课表到 ClassHelper」打开;
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到 ClassHelper」开关已关闭)」),不是上报失败;
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
> **提醒到底会不会弹,取决于两个开关:**
>
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
> 服务端根本不会推过来(此时本插件收不到东西,属正常);
> 2. 本插件的「接收 ClassHelper提醒」关掉时,即使推过来也不弹。
>
> 两者都开着,提醒才会在 ClassIsland 上出现。
## 工作原理(为什么这么写)
- **不用跨进程 IPC,插件内直接读课程服务**:ClassIsland 的 IPC 暴露的是缓存数据,不足以还原课表;
插件直接读 `ILessonsService`(当前科目 / 状态 / 时间点)与 `IProfileService`(档案里的课表、时间表、科目),
拿到的才是「教室此刻真实的状态」。
- **「上报即拉取」,不引入 Socket 依赖**:每次上报的返回值里会顺带带一条「尚未确认的提醒」,
插件收到就交给提醒提供方显示;应用启动时额外拉一次 `pending`,
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
「ClassHelper提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
**第 i 个 `TimeType == 0`(上课)的时间点**一一对应,
因此镜像接口为每条课目带上 `startTime` / `endTime`,插件据此重建时间表
(课间点从服务端的时间表里按起始时刻对齐)。少一个时间点,整份课表就会错位。
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
- **非破坏性**:镜像只创建 / 更新名字以 `ClassHelper-` 开头的课表与时间表;
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
## 上机联调记录(ClassIsland 2.1.0.1 实测)
下面几条都是把插件装进真实 ClassIsland 跑出来的问题与结论,改动理由就写在这里,避免以后被“顺手优化”回去:
1. **提醒内容必须在 UI 线程构造并显示。** 上报回调在线程池上,而提醒模板里的图标是 Avalonia 对象
(`LucideIconSource → IconSource → AvaloniaObject` 的构造函数会 `Dispatcher.VerifyAccess()`),
在线程池上直接构造会抛 `Call from invalid thread`,表现就是“ClassHelper 客户端收到了、ClassIsland 毫无反应”。
现在统一用 `Dispatcher.UIThread.Post` 切回 UI 线程。
2. **同一条提醒在播放期间要按 id 去重。** 服务端在收到回执前会一直把提醒算作“待弹出”,
而轮询间隔(默认 10 秒)可能小于提醒时长(最长 120 秒)—— 不去重就会一遍遍重复弹。
只有播完(`Completed`/`Canceled`)才把 id 从“正在播放”里移除并回执。
3. **不要自己拼图标串。** 双图标遮罩模板的默认图标由模板自己创建(`new LucideIconSource("\ue0ff")` 这种**字形字符**),
自己传 `"fluent(...)"` 之类既可能格式不对、也可能字形不存在。紧急提醒改用 ClassIsland 自带的**滚动文本**正文来表达。
4. **`Subject.Fallback` 是占位符(名字就是 `???`)。** 没有课表时 ClassIsland 会把它挂到
`CurrentSubject`/`NextClassSubject` 上,直接上报会让 Web 端显示“正在上 ???”,因此上报前归一化成“没有科目”。
实测截图(真机弹出提醒的遮罩):[普通提醒](../../docs/screenshots/classisland/02-normal-mask.png)、
[紧急提醒](../../docs/screenshots/classisland/01-urgent-mask.png)。
## 构建与打包
```bash
pnpm build:classisland-plugin # Release 构建(产物:packages/classisland-plugin/bin/Release)
pnpm dist:classisland-plugin # 追加打包 .cipx 并归集到 releases/classisland-plugin/
pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性 / 注册完整性 / DTO ↔ 服务端契约)
```
构建脚本会把 NuGet 缓存与临时目录指到仓库内的 `.cache/`(避免写满 C 盘),
并在打包后自检「清单 / 程序集名 / 版本号」三者一致。
## 开发提示
- 需要 **.NET 8 SDK**;SDK 包固定 `ClassIsland.PluginSdk 2.1.0.1`(2.1.1 起改为 net10.0,
本插件按 net8.0 交付,因此 2.0.x / 2.1.0.x 都能加载,见 `manifest.yml` 的 `apiVersion`)。
- 设置页是 Avalonia 的 `.axaml`(继承 `SettingsPageBase`);绑定走运行时解析
(`AvaloniaUseCompiledBindingsByDefault=false`),因为插件与主程序各自编译,编译期绑定看不到对方类型。
- **开发机没有安装 ClassIsland 本体**,所以插件只能做到编译 + 打包 + 静态校验;
运行期行为需要在装了 ClassIsland 的机器上按上面的「安装」步骤实测。
## 常见问题
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是 ClassHelper的标准格式)**
这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里,
于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。
插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**;
如果仍然报 404,请依次检查:
1. 服务器地址是否写成了别的端口 / 别的机器(提示里会带上实际请求的完整 URL,照着核对最快);
2. 该地址在浏览器里打开 `<服务器地址>/api/health` 是否返回 `{"success":true,...}`;
3. 反向代理(Nginx 等)是否把 `/api/integrations` 也转发到了后端。
**上报失败,提示「设备令牌无效」**
在 Web 端重置该设备的令牌,然后把新令牌填回插件设置(旧令牌会立即失效)。
**发布提醒后 ClassIsland 没反应,或者要等一会儿才弹**
插件不持有长连接,提醒是**轮询取回**的:默认最多 10 秒(设置页「提醒轮询间隔」可调到 5 秒)。超过这个时间还没弹,按下面逐条排查。
**ClassIsland 上完全不弹提醒**
1. 确认插件设置里打开了「接收提醒」;
2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403);
3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。
**课表上报了但 ClassHelper上没变化**
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
**镜像开关打开了,但 ClassIsland 的课表没变**
镜像需要 Web 端该设备开启「镜像课表」且班级里已有课表;插件只在**上报时**顺路拉取镜像,
所以打开开关后点一下「立即上报」或等一个上报间隔即可。镜像不会覆盖老师原有的课表。
## 许可
与 ClassHelper仓库一致。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

-19
View File
@@ -1,19 +0,0 @@
# ClassHelper ClassIsland 联动插件 —— 清单文件
#
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
id: classhelper.classisland.bridge
name: ClassHelper 联动
description: |-
把 ClassIsland 的课表与上课状态同步到 ClassHelper,并在 ClassIsland 上显示老师下发的提醒。
功能:
1. 上报当前课表与节次时间(ClassHelper Web 端与桌面客户端据此显示课程);
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
3. 接收老师在 ClassHelper上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
url: https://github.com/LoveChengke/ClassHelper
version: 1.3.2.0
apiVersion: 2.0.0.0
author: ClassHelper
@@ -1,168 +0,0 @@
/**
* ClassIsland 联动插件构建脚本:`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin`
*
* 用法:
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
* node scripts/build.mjs --cipx # 追加打包 .cipx 并归集到 releases/classisland-plugin/<版本>/
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
*
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
* 1. `dotnet` 默认把 NuGet 缓存与临时文件写在 C 盘(`%USERPROFILE%\.nuget`、`%TEMP%`),
* 本机 C 盘紧张时会直接构建失败。这里统一改到仓库内 `.cache/`(已 gitignore)。
* 2. 插件包(.cipx)的最后一步要调 PowerShell 生成哈希清单,SDK 默认用 `pwsh`;
* 没有 pwsh 的机器会失败,因此这里探测一次并显式传给 MSBuild。
* 3. 构建完顺手校验"清单 / 程序集 / 版本号"三者一致,避免打出装不上的包。
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..');
const cacheDir = path.join(repoRoot, '.cache');
const releasesRoot = path.join(repoRoot, 'releases', 'classisland-plugin');
const argv = process.argv.slice(2);
const withCipx = argv.includes('--cipx');
/** `--` 之后的参数原样透传给 dotnet build;其余以 - 开头的参数同样透传 */
const passthrough = argv.filter((item) => item !== '--cipx' && item !== '--');
const log = (message) => console.log(`[classisland-plugin] ${message}`);
/* ------------------------------------------------------------ 1. 环境准备 */
const env = { ...process.env };
for (const sub of ['nuget', 'tmp', 'dotnet-home']) {
fs.mkdirSync(path.join(cacheDir, sub), { recursive: true });
}
const setDefault = (key, value) => {
if (!env[key]) env[key] = value;
};
setDefault('NUGET_PACKAGES', path.join(cacheDir, 'nuget'));
setDefault('TEMP', path.join(cacheDir, 'tmp'));
setDefault('TMP', env.TEMP);
setDefault('DOTNET_CLI_HOME', path.join(cacheDir, 'dotnet-home'));
env.DOTNET_NOLOGO = '1';
env.DOTNET_CLI_TELEMETRY_OPTOUT = '1';
/** 可执行文件探测:先看显式配置,再问 PATH,最后试常见安装路径 */
function resolveExecutable(envKey, names, fallbacks) {
const candidates = [];
if (env[envKey]) candidates.push(env[envKey]);
const finder = process.platform === 'win32' ? 'where' : 'which';
for (const name of names) {
const found = spawnSync(finder, [name], { encoding: 'utf8', env });
if (found.status === 0) {
for (const line of String(found.stdout ?? '').split(/\r?\n/)) candidates.push(line.trim());
}
}
candidates.push(...fallbacks);
for (const candidate of candidates) {
if (!candidate) continue;
try {
if (fs.statSync(candidate).isFile()) return candidate;
} catch {
/* 继续试下一个 */
}
}
return null;
}
const dotnet = resolveExecutable(
'DOTNET_EXE',
['dotnet'],
process.platform === 'win32'
? [path.join(env.ProgramFiles ?? 'C:\\Program Files', 'dotnet', 'dotnet.exe')]
: ['/usr/local/bin/dotnet', '/usr/bin/dotnet'],
);
if (!dotnet) {
throw new Error('未找到 dotnet SDK(需要 .NET 8 SDK)。安装后用 DOTNET_EXE 指定完整路径。');
}
const powerShell = resolveExecutable(
'POWERSHELL_EXE',
['pwsh', 'powershell'],
process.platform === 'win32'
? [path.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]
: [],
);
/* ------------------------------------------------------------ 2. 构建 */
const buildArgs = ['build', '-c', 'Release'];
if (withCipx) {
buildArgs.push('-p:CreateCipx=true');
// SDK 的 cipx 目标默认调 pwsh;这里按探测结果显式指定,避免"没有 pwsh 就打包失败"
if (powerShell) buildArgs.push(`-p:PowershellBinaryName=${powerShell}`);
}
buildArgs.push(...passthrough);
log(`dotnet build(缓存目录 ${path.relative(repoRoot, cacheDir)})`);
const build = spawnSync(dotnet, buildArgs, { cwd: pluginDir, stdio: 'inherit', env });
if (build.error) throw new Error(`无法启动 dotnet:${build.error.message}`);
if (build.status !== 0) throw new Error(`dotnet build 失败(退出码 ${build.status})`);
/* ------------------------------------------------------------ 3. 产物自检 */
const outputDir = path.join(pluginDir, 'bin', 'Release');
const manifestPath = path.join(outputDir, 'manifest.yml');
const assemblyPath = path.join(outputDir, 'ClassHelper.ClassIslandPlugin.dll');
for (const required of [manifestPath, assemblyPath, path.join(outputDir, 'icon.png')]) {
if (!fs.existsSync(required)) {
throw new Error(`构建产物缺少 ${path.relative(pluginDir, required)},ClassIsland 会加载失败`);
}
}
const manifest = fs.readFileSync(manifestPath, 'utf8');
// prettier 会把 YAML 字符串统一成单引号,因此两种引号都要能剥掉
const entrance = /^entranceAssembly:\s*(.+?)\s*$/m.exec(manifest)?.[1]?.replace(/^['"]|['"]$/g, '');
if (entrance !== path.basename(assemblyPath)) {
throw new Error(
`manifest.yml 的 entranceAssembly(${entrance})与程序集(${path.basename(assemblyPath)})不一致`,
);
}
const manifestVersion = /^version:\s*([\d.]+)/m.exec(manifest)?.[1];
const bridgeSource = fs.readFileSync(path.join(pluginDir, 'src', 'Services', 'BridgeService.cs'), 'utf8');
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
if (!manifestVersion || manifestVersion !== codeVersion) {
throw new Error(
`版本号不一致:manifest.yml=${manifestVersion ?? '缺失'},BridgeService.cs=${codeVersion ?? '缺失'}`,
);
}
log(`产物自检通过(版本 ${manifestVersion},入口程序集 ${entrance})`);
/* ------------------------------------------------------------ 4. 打包归集 */
if (!withCipx) {
log('完成。加 --cipx 可同时打包成 ClassIsland 插件包(.cipx)');
process.exit(0);
}
const cipxDir = path.join(pluginDir, 'cipx');
const cipxFile = path.join(cipxDir, 'ClassHelper.ClassIslandPlugin.cipx');
if (!fs.existsSync(cipxFile)) {
throw new Error(`未生成插件包:${path.relative(pluginDir, 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, '安装包');
fs.rmSync(installerDir, { recursive: true, force: true });
fs.rmSync(path.join(versionDir, '免安装'), { recursive: true, force: true });
fs.mkdirSync(installerDir, { recursive: true });
for (const file of fs.readdirSync(cipxDir)) {
// .cipx 是插件包本体,校验清单跟着版本目录走
const to = file.toLowerCase().endsWith('.cipx') ? installerDir : versionDir;
fs.copyFileSync(path.join(cipxDir, file), path.join(to, file));
}
log(`插件包已归集到 ${path.relative(repoRoot, versionDir)}`);
for (const file of fs.readdirSync(installerDir)) log(` 安装包/${file}`);
@@ -1,405 +0,0 @@
/**
* ClassIsland 联动插件静态校验:`pnpm verify:classisland-plugin`
*
* 为什么是"静态"校验:插件要跑起来必须有 ClassIsland 本体,而本机(以及 CI)都没有,
* 所以这里校验"装不上 / 对不上"的那几类问题 —— 它们不需要运行就能发现:
*
* 1. 清单三件套一致性:manifest.yml ↔ csproj ↔ 代码里的版本号;
* 2. 注册完整性:插件入口、提醒提供方、设置页是否都挂上了(挂漏了功能会"静默消失");
* 3. **接口契约**:C# DTO 的 JSON 字段名 ↔ 服务端路由 / 请求字段,
* 这类错误(比如 dayOfWeek 写成 dayofweek)在运行时只会表现为"上报成功但课表是空的",
* 最难排查,所以专门盯住。
*/
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(pluginDir, '..', '..');
const results = [];
const check = (name, ok, detail = '') => results.push({ name, ok: Boolean(ok), detail });
const read = (relative) => fs.readFileSync(path.join(pluginDir, relative), 'utf8');
const readRepo = (relative) => fs.readFileSync(path.join(repoRoot, relative), 'utf8');
const manifest = read('manifest.yml');
const csproj = read('ClassHelper.ClassIslandPlugin.csproj');
const pluginSource = read('src/Plugin.cs');
const providerSource = read('src/Services/ClassHelperNotificationProvider.cs');
const bridgeSource = read('src/Services/BridgeService.cs');
const clientSource = read('src/Interop/ClassHelperClient.cs');
const settingsPageSource = read('src/Views/BridgeSettingsPage.axaml.cs');
const settingsPageXaml = read('src/Views/BridgeSettingsPage.axaml');
const mapperSource = read('src/Services/ScheduleMapper.cs');
const writerSource = read('src/Services/ClassPlanWriter.cs');
const settingsSource = read('src/Models/PluginSettings.cs');
const serverModule = readRepo('packages/server/src/modules/integrations/integrations.module.ts');
const serverSchemas = readRepo('packages/server/src/modules/integrations/integrations.schemas.ts');
const serverService = readRepo('packages/server/src/modules/integrations/integrations.service.ts');
const sharedTypes = readRepo('packages/shared/src/types.ts');
const sharedConstants = readRepo('packages/shared/src/constants.ts');
/* ------------------------------------------------------------ 1. 清单一致性 */
/** 取 YAML 里某个顶层字段的值(prettier 会把字符串统一成单引号,两种引号都要能剥掉) */
function field(name) {
const raw = new RegExp('^' + name + ':\\s*(.+?)\\s*$', 'm').exec(manifest)?.[1];
return raw?.replace(/^['"]|['"]$/g, '');
}
check('manifest.yml 有插件 id', Boolean(field('id')));
check('manifest.yml 声明了入口程序集', Boolean(field('entranceAssembly')));
check('manifest.yml 声明了 apiVersion', /\d+\.\d+\.\d+\.\d+/.test(field('apiVersion') ?? ''));
const assemblyName = /<AssemblyName>([^<]+)<\/AssemblyName>/.exec(csproj)?.[1];
check(
'entranceAssembly 与 csproj 的 AssemblyName 一致',
field('entranceAssembly') === assemblyName + '.dll',
'entranceAssembly=' + field('entranceAssembly') + ' AssemblyName=' + assemblyName,
);
const manifestVersion = field('version');
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
check(
'插件版本号在清单与代码里一致',
Boolean(manifestVersion) && manifestVersion === codeVersion,
'manifest=' + manifestVersion + ' code=' + codeVersion,
);
check(
'图标与自述文件按清单约定命名(icon.png / README.md)',
fs.existsSync(path.join(pluginDir, 'icon.png')) && fs.existsSync(path.join(pluginDir, 'README.md')),
);
/* ------------------------------------------------------------ 2. 注册完整性 */
check(
'插件入口带 [PluginEntrance] 且继承 PluginBase',
/\[PluginEntrance\]/.test(pluginSource) && /:\s*PluginBase/.test(pluginSource),
);
check(
'初始化时注册了提醒提供方',
/AddNotificationProvider<\s*ClassHelperNotificationProvider\s*>/.test(pluginSource),
);
check('初始化时注册了设置页', /AddSettingsPage<\s*BridgeSettingsPage\s*>/.test(pluginSource));
check('初始化时把联动服务注册为单例', /AddSingleton<\s*BridgeService\s*>/.test(pluginSource));
check('应用启动后才接课程服务(不能更早)', /AppStarted/.test(pluginSource));
check(
'设置以 JSON 持久化到插件配置目录',
/PluginConfigFolder/.test(pluginSource) && /Settings\.json/.test(pluginSource),
);
const providerGuid = /ProviderGuidValue = "([0-9A-Fa-f-]{36})"/.exec(providerSource)?.[1];
check('提醒提供方声明了合法 GUID', Boolean(providerGuid));
check(
'提醒提供方属性引用同一个 GUID 常量',
/NotificationProviderInfo\(\s*ProviderGuidValue/.test(providerSource),
);
check(
'提醒提供方会在播完后回执给服务端',
/Completed/.test(providerSource) && /AckAsync/.test(providerSource),
);
check(
'设置页带 [SettingsPageInfo] 且声明了页面 id',
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"ClassHelper 联动"/.test(settingsPageSource),
);
check(
'设置页 XAML 的 x:Class 与代码后置类一致',
settingsPageXaml.includes('x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"') &&
/namespace ClassHelper\.ClassIslandPlugin\.Views;/.test(settingsPageSource),
);
check('设置页 XAML 继承 ClassIsland 的设置页基类', /SettingsPageBase/.test(settingsPageXaml));
/* ------------------------------------------------------------ 3. 接口契约 */
// 3.1 路由:插件调用的路径必须都在服务端注册过
const clientPaths = [...clientSource.matchAll(/"(\/classisland\/[a-z-]+)"/g)].map((item) => item[1]);
const registeredPaths = new Set(
[...serverModule.matchAll(/router\.(?:get|post|patch|delete)\(\s*'(\/classisland\/[a-z-]+)'/g)].map(
(item) => item[1],
),
);
for (const route of [
'/classisland/report',
'/classisland/pending',
'/classisland/ack',
'/classisland/class-plan',
]) {
check('服务端注册了 ' + route, registeredPaths.has(route));
}
check(
'插件调用的路由都存在于服务端',
clientPaths.length > 0 && clientPaths.every((item) => registeredPaths.has(item)),
'插件调用=[' + clientPaths.join(',') + ']',
);
check('插件把接口前缀拼成 /api/integrations', clientSource.includes('/api/integrations'));
check('插件用设备令牌头鉴权', clientSource.includes('X-ClassIsland-Token'));
// 3.2 上报请求字段:服务端 zod schema 里出现的字段名,插件必须真的会发
const reportBlock = /export const classIslandReportSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
const requestFields = [
'pluginVersion',
'classIslandVersion',
'state',
'schedule',
'timeLayout',
'inClass',
'subject',
'nextSubject',
'timeState',
'periodStart',
'periodEnd',
'classPlanLoaded',
'clientTime',
'mode',
'entries',
'items',
];
const missingRequestFields = requestFields.filter(
(name) => reportBlock.includes(name) && !new RegExp('\\b' + name + '\\b').test(clientSource),
);
check(
'上报请求字段都出现在插件 DTO 里',
missingRequestFields.length === 0,
missingRequestFields.length > 0 ? '缺少=' + missingRequestFields.join(',') : '',
);
// 3.3 课表条目字段:这些是"上报成功但课表为空"的典型来源
const entrySchema = /export const reportScheduleEntrySchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
const entryFields = [
'dayOfWeek',
'startTime',
'endTime',
'subject',
'teacherName',
'weekParity',
'weekCountDiv',
'weekCountDivTotal',
'planName',
];
const csharpJsonNames = new Set(
[...clientSource.matchAll(/\[JsonPropertyName\("([^"]+)"\)\]/g)].map((item) => item[1]),
);
const missingEntryFields = entryFields.filter(
(name) => entrySchema.includes(name) && !csharpJsonNames.has(name),
);
check(
'课表条目字段服务端 zod 与插件 DTO 对齐',
missingEntryFields.length === 0,
missingEntryFields.length > 0 ? '插件缺少=' + missingEntryFields.join(',') : '',
);
// 3.4 插件 DTO 里的 JSON 字段名必须能在共享契约里找到(防错拼)
const knownLocalOnly = new Set(['data', 'success', 'message', 'code']);
const unknownJsonNames = [...csharpJsonNames].filter((name) => {
if (knownLocalOnly.has(name)) return false;
return !new RegExp('\\b' + name + '\\b').test(sharedTypes);
});
check(
'插件 DTO 的 JSON 字段名都能在 @classhelper/shared 里找到',
unknownJsonNames.length === 0,
unknownJsonNames.length > 0 ? '疑似拼写错误=' + unknownJsonNames.join(',') : '',
);
// 3.5 课表镜像:服务端给了 Classes[i] 对齐所需的时间,插件必须读
for (const name of ['startTime', 'endTime', 'timeLayouts', 'profileName', 'weekDay', 'weekCountDivTotal']) {
check('镜像契约字段 ' + name + ' 在插件侧存在', csharpJsonNames.has(name));
}
// 3.6 提醒下发字段(Web 端 → 服务端 → 插件)
const notifySchema = /export const sendNotificationSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
for (const name of [
'classId',
'title',
'content',
'durationSeconds',
'speech',
'speechContent',
'saveToNotifications',
'priority',
]) {
check('下发提醒字段 ' + name + ' 在服务端 schema 里', notifySchema.includes(name));
}
check(
'提醒时长有上界(避免老师发出永不消失的提醒)',
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(serverSchemas) &&
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(sharedConstants),
);
// 3.7 实时事件:Web 端订阅的事件名必须在共享契约里声明过
check(
'共享契约声明了 classisland 实时事件',
sharedTypes.includes("'classisland:state'") && sharedTypes.includes("'classisland:notification'"),
);
check(
'服务端广播事件名取自共享常量(不是手写字符串)',
/SOCKET_EVENTS\.classislandState/.test(serverService),
);
// 3.8 网络层:教室机器(以及开发机)常配了系统代理,而 Windows 的代理设置默认连 127.0.0.1
// 也会走代理,表现为"填了令牌立刻 HTTP 404"(其实是代理自己的错误页,跟本服务无关)。
// 插件必须对本地/内网地址绕开系统代理,否则内网部署必然失败。
check(
'HttpClient 显式配置代理(不裸用默认行为)',
/SocketsHttpHandler\s*\{[^}]*Proxy\s*=/.test(clientSource),
);
check(
'本地/内网地址绕开系统代理',
/class LocalAwareProxy/.test(clientSource) && /IsBypassed\(Uri host\)/.test(clientSource),
);
check(
'代理判定覆盖回环与内网网段',
/IsLoopback/.test(clientSource) &&
/10\.0\.0\.0\/8/.test(clientSource) &&
/192\.168\.0\.0\/16/.test(clientSource),
);
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
// 3.10 课表同步方向(2026-10-11 定稿):**两个方向都只由 Web 端的一次性动作触发**。
// 2026-10-06 曾经把它做成"服务端 → 教室自动下发 + 本机两个开关",但默认 15 秒一轮的
// 自动上传 + 自动镜像会让服务端与教室里的课表每 15 秒互相覆盖一次,老师看到的是
// "课表被反复删改"(2026-10-11 实测)。现在本机没有任何"一直同步"的开关:
// · 教室 → 服务端:`scheduleRequested` 一次性待办(Web 端「从教室机器获取课表」)
// · 服务端 → 教室:`mirrorRequested` 一次性待办(Web 端「下发课表到教室」,强制替换)
// 这几条盯着新契约,别删。
check(
'课表两个方向都改为按需:本机不再有「自动回传 / 自动镜像」开关',
!/_uploadSchedule/.test(settingsSource) && !/_mirrorSchedule/.test(settingsSource),
);
check(
'定时上报只报运行状态,课表仅在服务端显式请求时上传',
/var uploadSchedule = forceSchedule;/.test(bridgeSource),
);
check(
'响应 DTO 里有 scheduleRequested / mirrorRequested(两个方向的一次性待办)',
/scheduleRequested/.test(clientSource) && /mirrorRequested/.test(clientSource),
);
check(
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
/ScheduleRequested\s*==\s*true/.test(bridgeSource) &&
/forceSchedule:\s*true/.test(bridgeSource),
);
check(
'只有收到 mirrorRequested 才镜像(不再跟着每一次上报走)',
/MirrorRequested\s*==\s*true/.test(bridgeSource),
);
check(
'镜像写入是「强制替换」(停用冲突的旧课表,而不是只留一条警告)',
/Apply\(profile, plan, force: true\)/.test(bridgeSource) &&
/IsEnabled = false/.test(writerSource),
);
check(
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
bridgeSource.indexOf('ScheduleRequested == true') < bridgeSource.indexOf('MirrorScheduleAsync()'),
);
check(
'课表拉取 DTO 带 weekRanges(管理员逐周配的学期周次区间)',
/weekRanges/.test(clientSource) && /TermWeekRangeDto/.test(clientSource),
);
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
check(
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
providerSource.includes('Dispatcher.UIThread.Post'),
);
check(
'同一条提醒在播放期间按 id 去重(否则轮询会让它反复弹)',
providerSource.includes('_playing') && providerSource.includes('HashSet<string>'),
);
check(
'不再自造图标串(用模板默认图标,紧急用滚动文本表达)',
// 不再给 CreateTwoIconsMask 传 leftIcon(自造图标串会踩格式/字形坑)
!providerSource.includes('leftIcon') && providerSource.includes('CreateRollingTextContent'),
);
check(
'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"',
mapperSource.includes('Subject.Fallback'),
);
check(
'上课时段暂存非主动通知(与客户端灵动岛同一套规则)',
providerSource.includes('_deferred') &&
providerSource.includes('IsImmediate') &&
providerSource.includes('FlushDeferred') &&
providerSource.includes('OnBreakingTime'),
);
check(
'主动通知(紧急 / 叫人)上课时段也立即弹',
providerSource.includes("\"call\"") && providerSource.includes('notification.Urgent'),
);
check(
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
bridgeSource.includes('上报课表到 ClassHelper') && bridgeSource.includes('开关已关闭'),
);
check(
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
settingsSource.includes('NotificationPollSeconds') &&
bridgeSource.includes('_notifyTimer') &&
bridgeSource.includes('NotificationPollSeconds'),
);
/* ------------------------------------------------------------ 8. 回归护栏:本次修复过的坑 */
check(
'课表配对用原始 TimeType==0 列表,**不用** ValidTimeLayoutItems(后者会剔除被停用的节次,导致整份课表错位)',
// 只查代码形态(`.Count`),因为源码注释里特意点名了这个属性来说明"为什么不能用它"
mapperSource.includes('classPoints[index]') && !mapperSource.includes('.ValidTimeLayoutItems.Count'),
);
check(
'上报字段按服务端 zod 上限截断(一个超长名字会让整批上报 400,连状态与心跳一起丢掉)',
mapperSource.includes('Truncate(') &&
mapperSource.includes('SubjectMaxLength') &&
mapperSource.includes('PlanNameMaxLength'),
);
check(
'课表镜像在 UI 线程改写档案(线程池上改 Observable 集合会与落盘并发,可能写坏档案)',
bridgeSource.includes('Dispatcher.UIThread.InvokeAsync') && bridgeSource.includes('SaveProfile()'),
);
check(
'镜像内容未变化时跳过(否则每次上报都重建课表并全量落盘)',
bridgeSource.includes('_lastMirrorSignature') && bridgeSource.includes('MirrorSignature('),
);
check(
'上报异常有 catch(fire-and-forget 的异常会被静默吞掉)',
bridgeSource.includes('catch (Exception exception)') && bridgeSource.includes('上报异常'),
);
check(
'镜像前过滤时间非法的条目(避免档案里出现 00:00 的课次,且必须与 Classes 同步过滤)',
read('src/Services/ClassPlanWriter.cs').includes('IsValidTime'),
);
check(
'上报体带上 deviceKey,且服务端 schema 也声明了该字段(回填机器码的契约两端一致)',
clientSource.includes('"deviceKey"') && serverSchemas.includes('deviceKey:'),
);
check(
'2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小',
clientSource.includes('响应不是 ClassHelper的标准格式') &&
clientSource.includes('MaxResponseContentBufferSize'),
);
check(
'提醒提供方惰性获取课程服务并重试(拿不到服务时"上课暂存"会永久失效且静默)',
providerSource.includes('EnsureLessons') && providerSource.includes('_lessonsHooked'),
);
check(
'设置落盘先写临时文件再原子替换(逐字符写盘 + 截断式写入会留下坏 JSON)',
pluginSource.includes('File.Move(temp, path, overwrite: true)'),
);
check(
'服务器地址只接受 http/https(裸 Uri.TryCreate 会放过 file:// / ftp://)',
settingsSource.includes('TryGetServerUri') && settingsSource.includes('UriSchemeHttp'),
);
/* ------------------------------------------------------------ 结果 */
const failed = results.filter((item) => !item.ok);
for (const item of results) {
console.log((item.ok ? '√ ' : '× ') + item.name + (item.detail ? ' (' + item.detail + ')' : ''));
}
console.log('');
console.log(
'ClassIsland 联动插件静态校验:' + (results.length - failed.length) + '/' + results.length + ' 项通过',
);
if (failed.length > 0) {
console.log('未通过:\n - ' + failed.map((item) => item.name).join('\n - '));
process.exit(1);
}
@@ -1,485 +0,0 @@
using System.Net;
using System.Net.Http;
using System.Net.Sockets;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using ClassHelper.ClassIslandPlugin.Models;
namespace ClassHelper.ClassIslandPlugin.Interop;
/* ---------------------------------------------------------------- 服务端契约(与 packages/shared 保持一致) */
/// <summary>插件上报的课表条目。</summary>
public class ScheduleEntryDto
{
[JsonPropertyName("dayOfWeek")] public int DayOfWeek { get; set; }
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
/// <summary>ALL / ODD / EVEN;留空则由服务端按 weekCountDiv 推导。</summary>
[JsonPropertyName("weekParity")] public string? WeekParity { get; set; }
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
[JsonPropertyName("planName")] public string? PlanName { get; set; }
}
/// <summary>插件上报的节次时间。</summary>
public class TimeLayoutItemDto
{
[JsonPropertyName("index")] public int Index { get; set; }
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
/// <summary>class / break / divider / action</summary>
[JsonPropertyName("type")] public string Type { get; set; } = "class";
[JsonPropertyName("skipped")] public bool Skipped { get; set; }
}
/// <summary>本机运行状态快照。</summary>
public class StateDto
{
[JsonPropertyName("inClass")] public bool InClass { get; set; }
[JsonPropertyName("subject")] public string? Subject { get; set; }
[JsonPropertyName("nextSubject")] public string? NextSubject { get; set; }
[JsonPropertyName("timeState")] public string? TimeState { get; set; }
[JsonPropertyName("periodStart")] public string? PeriodStart { get; set; }
[JsonPropertyName("periodEnd")] public string? PeriodEnd { get; set; }
[JsonPropertyName("week")] public int? Week { get; set; }
[JsonPropertyName("classPlanLoaded")] public bool ClassPlanLoaded { get; set; }
[JsonPropertyName("clientTime")] public string ClientTime { get; set; } = "";
}
public class SchedulePayloadDto
{
[JsonPropertyName("mode")] public string Mode { get; set; } = "merge";
[JsonPropertyName("entries")] public List<ScheduleEntryDto> Entries { get; set; } = new();
}
public class TimeLayoutPayloadDto
{
[JsonPropertyName("name")] public string Name { get; set; } = "ClassIsland 时间表";
[JsonPropertyName("mode")] public string Mode { get; set; } = "replace";
[JsonPropertyName("items")] public List<TimeLayoutItemDto> Items { get; set; } = new();
}
public class ReportRequestDto
{
[JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = "";
[JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = "";
/// <summary>本机机器码:设备创建时服务端还不知道它,靠这里回填(见服务端 syncDeviceKey)。</summary>
[JsonPropertyName("deviceKey")] public string DeviceKey { get; set; } = "";
[JsonPropertyName("state")] public StateDto? State { get; set; }
[JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; }
[JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; }
}
public class ReportSettingsDto
{
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
/// <summary>
/// 服务端请求**立刻上报一次课表**(老师在 Web 端点了「从教室机器获取课表」)。
///
/// 它与本机的「上报课表到 ClassHelper」开关无关:那个开关管的是"要不要**自动**回传",
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
/// </summary>
[JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; }
/// <summary>
/// 服务端请求**下发一次课表到教室**(老师在 Web 端点了「下发课表到教室」)。
///
/// 与 <see cref="ScheduleRequested"/> 反方向,同样是一次性待办:看到就拉本班课表并
/// **强制替换**写进 ClassIsland;拉取成功时由服务端清除标记。
/// 没有"后台定时镜像"那条路径了 —— 那会让教室课表被反复重写(见 BridgeService 的说明)。
/// </summary>
[JsonPropertyName("mirrorRequested")] public bool MirrorRequested { get; set; }
}
public class ReportResultDto
{
[JsonPropertyName("scheduleApplied")] public bool ScheduleApplied { get; set; }
[JsonPropertyName("scheduleCreated")] public int ScheduleCreated { get; set; }
[JsonPropertyName("scheduleUpdated")] public int ScheduleUpdated { get; set; }
[JsonPropertyName("courses")] public List<string> Courses { get; set; } = new();
[JsonPropertyName("timeLayoutApplied")] public bool TimeLayoutApplied { get; set; }
[JsonPropertyName("week")] public int Week { get; set; }
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
[JsonPropertyName("settings")] public ReportSettingsDto? Settings { get; set; }
[JsonPropertyName("pendingNotification")] public PushNotificationDto? PendingNotification { get; set; }
}
/// <summary>服务端下发的提醒。</summary>
public class PushNotificationDto
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("title")] public string Title { get; set; } = "";
[JsonPropertyName("content")] public string Content { get; set; } = "";
[JsonPropertyName("durationSeconds")] public int DurationSeconds { get; set; } = 8;
[JsonPropertyName("speechContent")] public string? SpeechContent { get; set; }
[JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = "";
[JsonPropertyName("urgent")] public bool Urgent { get; set; }
/// <summary>提醒类型:notification(默认)/ call(叫人,算"主动通知",上课时段也立刻弹)</summary>
[JsonPropertyName("kind")] public string? Kind { get; set; }
[JsonPropertyName("classId")] public string ClassId { get; set; } = "";
[JsonPropertyName("className")] public string? ClassName { get; set; }
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
}
public class PendingResultDto
{
[JsonPropertyName("notifications")] public List<PushNotificationDto> Notifications { get; set; } = new();
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
[JsonPropertyName("week")] public int Week { get; set; }
}
/* ---------------------------------------------------------------- 课表镜像(服务端 → ClassIsland) */
public class ClassPlanEntryMirrorDto
{
[JsonPropertyName("weekDay")] public int WeekDay { get; set; }
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
/// <summary>起止时间(HH:mm)。Classes[i] 必须与第 i 个上课时间点对齐,靠它重建时间表。</summary>
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
[JsonPropertyName("timeLayoutId")] public string TimeLayoutId { get; set; } = "";
}
public class TimeLayoutMirrorDto
{
[JsonPropertyName("id")] public string Id { get; set; } = "";
[JsonPropertyName("name")] public string Name { get; set; } = "";
[JsonPropertyName("layouts")] public List<TimeLayoutItemMirrorDto> Layouts { get; set; } = new();
}
public class TimeLayoutItemMirrorDto
{
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
[JsonPropertyName("timeType")] public int TimeType { get; set; }
}
public class ClassPlanMirrorDto
{
[JsonPropertyName("entries")] public List<ClassPlanEntryMirrorDto> Entries { get; set; } = new();
[JsonPropertyName("timeLayouts")] public List<TimeLayoutMirrorDto> TimeLayouts { get; set; } = new();
[JsonPropertyName("profileName")] public string ProfileName { get; set; } = "";
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
}
/// <summary>服务端逐周配的学期周次区间(管理员在「学期周次」里维护)。</summary>
public class TermWeekRangeDto
{
[JsonPropertyName("weekNumber")] public int WeekNumber { get; set; }
/// <summary>YYYY-MM-DD</summary>
[JsonPropertyName("startDate")] public string StartDate { get; set; } = "";
/// <summary>YYYY-MM-DD</summary>
[JsonPropertyName("endDate")] public string EndDate { get; set; } = "";
}
public class PullResultDto
{
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
[JsonPropertyName("week")] public int Week { get; set; }
/// <summary>
/// 学期逐周区间;空 = 服务端只按学期开始日期线性推算。
///
/// ClassIsland 自身只认"教学周序号 + 单双周",没有"按日期区间定义某一周"的模型
/// (本机装的 2.1.0.1 没有该字段),所以这份区间在插件侧只用于:
/// 1) 校正"现在是第几周"(调休会让线性推算整体错位);
/// 2) 在设置页里显示教室这台机器当前处在哪一段日期区间,方便老师核对。
/// 课表本身的周次口径仍由 <see cref="ClassPlanMirrorDto.TermStartDate"/> 与单双周表达。
/// </summary>
[JsonPropertyName("weekRanges")] public List<TermWeekRangeDto> WeekRanges { get; set; } = new();
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
}
/* ---------------------------------------------------------------- 统一响应体 */
internal class ApiEnvelope<T>
{
[JsonPropertyName("success")] public bool Success { get; set; }
[JsonPropertyName("data")] public T? Data { get; set; }
[JsonPropertyName("message")] public string Message { get; set; } = "";
[JsonPropertyName("code")] public string? Code { get; set; }
}
/// <summary>调用 ClassHelper后端的结果(不抛异常,便于设置页展示原因)。</summary>
public class ApiCallResult
{
public bool Ok { get; init; }
public string Message { get; init; } = "";
public bool NetworkError { get; init; }
public static ApiCallResult Success(string message) => new() { Ok = true, Message = message };
public static ApiCallResult Failure(string message) => new() { Ok = false, Message = message };
public static ApiCallResult Offline(string message) => new() { Ok = false, Message = message, NetworkError = true };
}
/// <summary>
/// ClassHelper后端客户端。
///
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
/// (见 https://docs.classisland.tech/dev/plugins/basics.html 的程序集隔离说明)。
/// </summary>
public sealed class ClassHelperClient : IDisposable
{
/// <summary>
/// 单次请求超时。教室机器可能是内网慢链路,但也不该卡住上报线程太久。
/// </summary>
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12);
/// <summary>
/// 响应体上限(4MB)。默认是 2GB —— 服务器地址填错(指到一个会吐大页面的地址)时,
/// 一次请求就能在 12 秒内把内存吃光。
/// </summary>
private const long MaxResponseBytes = 4 * 1024 * 1024;
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNameCaseInsensitive = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>
/// 与 ClassHelper通信的 HttpClient。
///
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
/// 那个 404 其实是**代理自己的错误页**,跟本服务毫无关系。
/// 因此这里用 <see cref="LocalAwareProxy"/>:本地/内网地址直连,公网地址照旧走系统代理。
/// </summary>
private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() })
{
Timeout = Timeout,
MaxResponseContentBufferSize = MaxResponseBytes,
};
public string LastError { get; private set; } = "";
public DateTimeOffset? LastSuccessAt { get; private set; }
/// <summary>
/// 包装系统代理:**本地/内网地址标记为"绕过"**,其余交给系统代理决定。
///
/// 判定为本地/内网的情形:localhost、回环地址、IPv4/IPv6 私有段与链路本地段,
/// 以及不含点的主机名(`server` / `classhelper` 这类校内机器名,DNS 也可能解析不出来)。
/// 判定为公网时保持原行为,避免"云服务器 + 公司代理"这种场景被我们改坏。
/// </summary>
private sealed class LocalAwareProxy : IWebProxy
{
private readonly IWebProxy _inner = HttpClient.DefaultProxy;
// 只读透传即可:HttpClient.DefaultProxy 是全局单例,改它的凭据会波及宿主进程里的其它请求
public ICredentials? Credentials { get; set; }
public Uri? GetProxy(Uri destination) => _inner.GetProxy(destination);
public bool IsBypassed(Uri host) => IsLocalHost(host) || _inner.IsBypassed(host);
/// <summary>该地址是否属于"不该走代理"的本地/内网范围。</summary>
internal static bool IsLocalHost(Uri uri)
{
var host = uri.Host;
if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) return true;
// 不含点的主机名(校内机器名)走直连:解析成内网 IP 的可能性远大于公网
if (!host.Contains('.')) return true;
if (!IPAddress.TryParse(host, out var address)) return false;
if (IPAddress.IsLoopback(address)) return true;
if (address.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = address.GetAddressBytes();
return bytes[0] == 10 // 10.0.0.0/8
|| (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) // 172.16.0.0/12
|| (bytes[0] == 192 && bytes[1] == 168) // 192.168.0.0/16
|| (bytes[0] == 169 && bytes[1] == 254); // 169.254.0.0/16(链路本地)
}
// IPv6:fc00::/7(唯一本地地址)与 fe80::/10(链路本地)
var first = address.GetAddressBytes()[0];
return (first & 0xFE) == 0xFC || (first & 0xC0) == 0x80;
}
}
/// <summary>拼接 API 地址,容忍用户填了带/不带末尾斜杠、甚至带 /api 后缀的地址。</summary>
private static string BuildUrl(string serverUrl, string path)
{
var baseUrl = (serverUrl ?? "").Trim().TrimEnd('/');
if (baseUrl.EndsWith("/api", StringComparison.OrdinalIgnoreCase))
{
baseUrl = baseUrl[..^4];
}
return $"{baseUrl}/api/integrations{path}";
}
private HttpRequestMessage BuildRequest(HttpMethod method, string serverUrl, string path, string token)
{
var request = new HttpRequestMessage(method, BuildUrl(serverUrl, path));
if (!string.IsNullOrWhiteSpace(token))
{
request.Headers.TryAddWithoutValidation("X-ClassIsland-Token", token);
}
return request;
}
private async Task<ApiEnvelope<T>?> SendAsync<T>(
HttpMethod method,
string serverUrl,
string path,
string token,
object? body,
CancellationToken cancellationToken)
{
try
{
using var request = BuildRequest(method, serverUrl, path, token);
if (body is not null)
{
var json = JsonSerializer.Serialize(body, JsonOptions);
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
}
using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
ApiEnvelope<T>? envelope = null;
try
{
envelope = JsonSerializer.Deserialize<ApiEnvelope<T>>(text, JsonOptions);
}
catch (JsonException)
{
// 非 JSON 响应(例如被网关拦截)——交给下面的兜底分支
}
if (!response.IsSuccessStatusCode)
{
var message = envelope?.Message;
if (string.IsNullOrWhiteSpace(message))
{
// 响应不是本服务的统一响应体:最常见的原因就是被系统代理 / 网关拦下来
// (典型表现:HTTP 404 且 body 是一张 HTML 错误页)。把 URL 一起报出来,
// 老师截图给我们就能一眼定位。
message = envelope is null
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确、" +
"或该系统代理是否拦截了内网请求"
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
}
LastError = message!;
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
}
if (envelope is null)
{
// 2xx 但响应不是本服务的统一响应体(网关/反代的 HTML 错误页、地址指到了别的服务等)。
// 必须留下原因:否则调用方拿到的是"成功但无数据 + 空错误消息",
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
LastError =
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
return new ApiEnvelope<T> { Success = false, Message = LastError };
}
LastError = "";
LastSuccessAt = DateTimeOffset.Now;
return envelope;
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
LastError = "请求超时(请检查服务器地址与网络)";
return null;
}
catch (HttpRequestException exception)
{
LastError = $"无法连接 ClassHelper服务:{exception.Message}";
return null;
}
catch (Exception exception)
{
LastError = exception.Message;
return null;
}
}
/// <summary>上报状态 +(可选)课表。</summary>
public async Task<(ApiCallResult Result, ReportResultDto? Data)> ReportAsync(
PluginSettings settings,
ReportRequestDto payload,
CancellationToken cancellationToken)
{
var envelope = await SendAsync<ReportResultDto>(
HttpMethod.Post, settings.ServerUrl, "/classisland/report", settings.DeviceToken, payload, cancellationToken);
if (envelope is null)
{
return (ApiCallResult.Offline(LastError), null);
}
if (!envelope.Success || envelope.Data is null)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
}
return (ApiCallResult.Success(envelope.Message), envelope.Data);
}
/// <summary>拉取尚未确认的下发提醒(插件启动/重连时补齐)。</summary>
public async Task<(ApiCallResult Result, List<PushNotificationDto> Items)> PendingAsync(
PluginSettings settings,
CancellationToken cancellationToken)
{
var envelope = await SendAsync<PendingResultDto>(
HttpMethod.Get, settings.ServerUrl, "/classisland/pending", settings.DeviceToken, null, cancellationToken);
if (envelope is null) return (ApiCallResult.Offline(LastError), new List<PushNotificationDto>());
if (!envelope.Success || envelope.Data is null)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError),
new List<PushNotificationDto>());
}
return (ApiCallResult.Success(envelope.Message), envelope.Data.Notifications);
}
/// <summary>确认提醒已弹出(之后服务端不再补发)。</summary>
public async Task<ApiCallResult> AckAsync(PluginSettings settings, string id, CancellationToken cancellationToken)
{
var envelope = await SendAsync<JsonElement>(
HttpMethod.Post, settings.ServerUrl, "/classisland/ack", settings.DeviceToken, new { id }, cancellationToken);
if (envelope is null) return ApiCallResult.Offline(LastError);
return envelope.Success
? ApiCallResult.Success("已确认")
: ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError);
}
/// <summary>拉取班级课表(用于镜像回 ClassIsland)。未开启镜像时服务端返回 data=null。</summary>
public async Task<(ApiCallResult Result, ClassPlanMirrorDto? Plan)> PullClassPlanAsync(
PluginSettings settings,
CancellationToken cancellationToken)
{
var envelope = await SendAsync<PullResultDto>(
HttpMethod.Get, settings.ServerUrl, "/classisland/class-plan", settings.DeviceToken, null, cancellationToken);
if (envelope is null) return (ApiCallResult.Offline(LastError), null);
if (!envelope.Success)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
}
if (envelope.Data?.ClassPlan is null)
{
return (ApiCallResult.Failure(envelope.Message.Length > 0
? envelope.Message
: "该设备未开启「把班级课表镜像到 ClassIsland」"), null);
}
return (ApiCallResult.Success(envelope.Message), envelope.Data.ClassPlan);
}
public void Dispose() => _http.Dispose();
}
@@ -1,159 +0,0 @@
using CommunityToolkit.Mvvm.ComponentModel;
namespace ClassHelper.ClassIslandPlugin.Models;
/// <summary>
/// 插件设置。
///
/// 存储由 ClassIsland 负责:插件入口把它保存在 <c>PluginConfigFolder/Settings.json</c>
/// (**不要**放在插件安装目录,那里不会随档案备份、升级时还会被清掉)。
/// 因此这里必须继承 <see cref="ObservableObject"/> 并让每个属性都走 <c>SetProperty</c>:
/// ClassIsland 通过监听 <c>PropertyChanged</c> 来即时保存。
/// </summary>
public class PluginSettings : ObservableObject
{
private string _serverUrl = "http://127.0.0.1:4000";
private string _deviceToken = "";
private string _deviceKey = "";
private bool _autoReport = true;
private int _reportIntervalSeconds = 60;
// 课表同步(2026-10-11 起):**两个方向都只由 Web 端的一次性动作触发** ——
// 「从教室机器获取课表」与「下发课表到教室」,都走服务端的一个待办标记。
// 原先这里有两个开关(自动回传 / 自动镜像),默认 15 秒一轮,结果是服务端与教室里
// 的课表每 15 秒互相覆盖一次,老师看到的就是"课表被反复删改"。
// 定时上报现在**只报运行状态**(当前上什么课),不带课表。
private bool _receiveNotifications = true;
private int _notificationDurationSeconds = 8;
private int _notificationPollSeconds = 10;
private bool _speechEnabled = false;
private bool _showDebugInfo = false;
/// <summary>
/// ClassHelper后端地址,例如 <c>http://127.0.0.1:4000</c>。
/// </summary>
public string ServerUrl
{
get => _serverUrl;
set => SetProperty(ref _serverUrl, value);
}
/// <summary>
/// 设备令牌(形如 <c>chci_...</c>):在 ClassHelper Web 端「ClassIsland 联动」页面生成。
/// </summary>
public string DeviceToken
{
get => _deviceToken;
// 在 setter 里就去掉首尾空白:粘贴令牌时带前导空格非常常见,
// 而 Normalize() 只在启动加载时跑一次 —— 运行期粘进来的脏值会让 IsConfigured
// 一直为 false,用户看着明明填好了却查不出原因。
set => SetProperty(ref _deviceToken, (value ?? "").Trim());
}
/// <summary>
/// 本机标识(首次生成后固定):服务端据此把同一台机器识别为同一设备,重装插件不会产生重复设备。
/// </summary>
public string DeviceKey
{
get => _deviceKey;
set => SetProperty(ref _deviceKey, value);
}
/// <summary>
/// 是否自动上报:启动后、课表变化时、以及每隔 <see cref="ReportIntervalSeconds"/> 秒各上报一次。
/// </summary>
public bool AutoReport
{
get => _autoReport;
set => SetProperty(ref _autoReport, value);
}
/// <summary>
/// 定时上报间隔(秒),最小 15 秒。
/// </summary>
public int ReportIntervalSeconds
{
get => _reportIntervalSeconds;
set => SetProperty(ref _reportIntervalSeconds, Math.Clamp(value, 15, 3600));
}
/// <summary>
/// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
/// </summary>
public bool ReceiveNotifications
{
get => _receiveNotifications;
set => SetProperty(ref _receiveNotifications, value);
}
/// <summary>
/// 提醒轮询间隔(秒),最小 5 秒。
///
/// 插件与后端之间没有长连接(刻意不引入 Socket 依赖),提醒是"轮询取回"的,
/// 因此这个值就是**老师按下发送到教室弹出之间的最大延迟**。
/// 单独一个轻量轮询(只查待提醒,不上传课表)比把上报间隔改小划算得多。
/// </summary>
public int NotificationPollSeconds
{
get => _notificationPollSeconds;
set => SetProperty(ref _notificationPollSeconds, Math.Clamp(value, 5, 600));
}
/// <summary>
/// 默认提醒显示时长(秒);老师在下发时可以单独指定更长的时长。
/// </summary>
public int NotificationDurationSeconds
{
get => _notificationDurationSeconds;
set => SetProperty(ref _notificationDurationSeconds, Math.Clamp(value, 1, 120));
}
/// <summary>
/// 提醒弹出时是否语音朗读(沿用 ClassIsland 的语音设置与语音提供方)。
/// </summary>
public bool SpeechEnabled
{
get => _speechEnabled;
set => SetProperty(ref _speechEnabled, value);
}
/// <summary>
/// 是否在设置页显示调试信息(最后一次上报结果、课表条目数等),排查问题时打开。
/// </summary>
public bool ShowDebugInfo
{
get => _showDebugInfo;
set => SetProperty(ref _showDebugInfo, value);
}
/// <summary>
/// 兼容旧配置:反序列化后把 DeviceKey 补齐(首次运行时由服务生成)。
/// </summary>
public void Normalize()
{
ServerUrl = (ServerUrl ?? "").Trim().TrimEnd('/');
DeviceToken = (DeviceToken ?? "").Trim();
DeviceKey = (DeviceKey ?? "").Trim();
ReportIntervalSeconds = Math.Clamp(ReportIntervalSeconds, 15, 3600);
NotificationDurationSeconds = Math.Clamp(NotificationDurationSeconds, 1, 120);
NotificationPollSeconds = Math.Clamp(NotificationPollSeconds, 5, 600);
}
/// <summary>是否已经配置好(地址 + 令牌都填了)。</summary>
public bool IsConfigured =>
TryGetServerUri(out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
/// <summary>
/// 解析服务器地址,**只接受 http/https 绝对地址**。
///
/// 不用裸的 `Uri.TryCreate(..., UriKind.Absolute)`:它会接受 `file://` / `ftp://`,
/// 那种地址会在发请求时抛一句难以理解的 NotSupportedException,用户根本猜不到是地址填错了。
/// </summary>
public bool TryGetServerUri(out Uri uri)
{
uri = null!;
if (!Uri.TryCreate((ServerUrl ?? "").Trim(), UriKind.Absolute, out var parsed)) return false;
if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) return false;
uri = parsed;
return true;
}
}
-171
View File
@@ -1,171 +0,0 @@
using System.Text.Json;
using ClassHelper.ClassIslandPlugin.Models;
using ClassHelper.ClassIslandPlugin.Services;
using ClassHelper.ClassIslandPlugin.Views;
using ClassIsland.Core;
using ClassIsland.Core.Abstractions;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Extensions.Registry;
using ClassIsland.Shared;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin;
/// <summary>
/// 插件入口。
///
/// 职责只有三件:读配置 → 注册扩展(提醒提供方 / 设置页 / 联动服务)→ 在应用启动后接上课程服务。
/// 真正的联动逻辑都在 <see cref="BridgeService"/> 里,入口保持"看一眼就知道装了哪些东西"。
/// </summary>
[PluginEntrance]
public class Plugin : PluginBase
{
/// <summary>插件设置文件名。存在 <see cref="PluginBase.PluginConfigFolder"/> 下:随档案备份走,升级插件不丢。</summary>
private const string SettingsFileName = "Settings.json";
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true,
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
};
/// <summary>插件设置。注册成单例后,设置页与 <see cref="BridgeService"/> 拿到的是同一个对象。</summary>
public PluginSettings Settings { get; private set; } = new();
/// <summary>联动只接一次(AppStarted 与"运行中启用"两条路都会走到这里)</summary>
private bool _bridgeStarted;
public override void Initialize(HostBuilderContext context, IServiceCollection services)
{
Settings = LoadSettings();
// 设置页与联动服务共用一个实例:设置页改了值,服务立刻能看到(不需要重启 ClassIsland)
services.AddSingleton(Settings);
services.AddSingleton<BridgeService>();
services.AddSettingsPage<BridgeSettingsPage>();
services.AddNotificationProvider<ClassHelperNotificationProvider>();
// 初始化阶段课程服务还没准备好(它在主机启动流程里才被创建),
// 因此这里先挂一个"应用已启动"的回调,等到那时再取 ILessonsService 并开始上报。
var application = AppBase.Current;
if (application is null)
{
Logger?.LogError("ClassHelper 联动:拿不到 ClassIsland 应用实例,联动未启动");
return;
}
application.AppStarted += (_, _) => StartBridge();
// 插件是"运行中才被启用"时,AppStarted 早就过去了 —— 主窗口已经存在就直接接一次
if (application.MainWindow is not null) StartBridge();
}
private void StartBridge()
{
if (_bridgeStarted) return;
try
{
var lessons = IAppHost.TryGetService<ILessonsService>();
if (lessons is null)
{
Logger?.LogError("ClassHelper 联动:拿不到课程服务,联动已停用");
return;
}
var bridge = IAppHost.TryGetService<BridgeService>();
if (bridge is null)
{
Logger?.LogError("ClassHelper 联动:拿不到联动服务,联动已停用");
return;
}
_bridgeStarted = true;
bridge.Attach(lessons);
bridge.RestartTimer();
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
// 不这么做的话,最长要等一个上报间隔才会弹出来
_ = bridge.PullPendingAsync();
Logger?.LogInformation("ClassHelper 联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
}
catch (Exception exception)
{
// 联动失败绝不能把 ClassIsland 本体拖下水
Logger?.LogError(exception, "ClassHelper 联动:初始化联动服务失败");
}
}
private ILogger<Plugin>? Logger => IAppHost.TryGetService<ILogger<Plugin>>();
/// <summary>
/// 读取设置。首次运行时 <c>DeviceKey</c> 为空,这里补一个随机值并立即落盘 ——
/// 服务端用它把同一台机器识别为同一设备,重装插件不会堆出重复设备。
/// </summary>
private PluginSettings LoadSettings()
{
var path = Path.Combine(PluginConfigFolder, SettingsFileName);
var settings = new PluginSettings();
try
{
if (File.Exists(path))
{
var loaded = JsonSerializer.Deserialize<PluginSettings>(File.ReadAllText(path), JsonOptions);
if (loaded is not null) settings = loaded;
}
}
catch (Exception exception)
{
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
"ClassHelper 联动:读取设置失败,已回退到默认设置({Path})", path);
}
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
settings.Normalize();
if (needGenerateKey)
{
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车。
// 注意长度:原先写的是 `[..Math.Min(24, machine.Length + 33)]`,而 machine.Length + 33
// 恒大于 24,所以实际**永远只取前 24 个字符** —— 机器名稍长就把随机段整个切掉,
// "同名机器不撞车"的保障就没了。这里改成"机器名按需截短 + 保留 8 位随机段"。
var machine = Environment.MachineName.ToLowerInvariant();
var suffix = Guid.NewGuid().ToString("N")[..8];
var maxMachineLength = Math.Max(4, 24 - suffix.Length - 1);
if (machine.Length > maxMachineLength) machine = machine[..maxMachineLength];
settings.DeviceKey = $"{machine}-{suffix}";
}
Settings = settings;
// 落盘:一是固定首次生成的 DeviceKey,二是把缺字段的旧配置补齐成完整文件
SaveSettings(settings, path);
// 设置变化时即时保存
settings.PropertyChanged += (_, _) => SaveSettings(settings, path);
return settings;
}
private static void SaveSettings(PluginSettings settings, string path)
{
try
{
var folder = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
// 先写临时文件再原子替换。
//
// 设置页的地址/令牌输入框是 TwoWay 绑定(逐字符提交),直接 `File.WriteAllText`
// 会就地截断原文件;一旦此刻进程被杀或断电,留下的是半截 JSON,而 LoadSettings 的
// catch 会**静默回退到默认设置** —— 用户看到的是"令牌和地址莫名其妙没了",
// 而且原文件已被覆盖,无从恢复。
var temp = path + ".tmp";
File.WriteAllText(temp, JsonSerializer.Serialize(settings, JsonOptions));
File.Move(temp, path, overwrite: true);
}
catch (Exception exception)
{
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
"ClassHelper 联动:保存设置失败({Path})", path);
}
}
}
@@ -1,647 +0,0 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using System.Text;
using Avalonia.Threading;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassHelper.ClassIslandPlugin.Models;
using ClassIsland.Core;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Core.Models.Notification;
using ClassIsland.Shared;
using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 联动主循环。三件事:
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
/// 把"当前上什么课"与(可选的)全量课表推给 ClassHelper;
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
/// 3. <b>镜像</b>:开启镜像开关时,把 ClassHelper上排好的课表写回 ClassIsland 档案。
///
/// 为什么用"上报即拉取"而不是长连接:
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
/// 而教室机器的上报间隔本身就是心跳 —— 顺路把待提醒带回来,依赖更少、故障面更小。
///
/// 线程约定:HttpClient 回调在线程池上,因此所有跨线程可见的状态都用 <c>lock</c> 或
/// 赋值引用(<c>string</c> / <c>int</c>)保护,属性变化通过 <see cref="PropertyChanged"/> 通知设置页。
/// </summary>
public sealed class BridgeService : INotifyPropertyChanged, IDisposable
{
private readonly PluginSettings _settings;
private readonly ILogger<BridgeService> _logger;
private readonly ClassHelperClient _client = new();
private readonly object _gate = new();
private ILessonsService? _lessons;
private Timer? _timer;
private Timer? _notifyTimer;
private bool _disposed;
private bool _reporting;
private bool _pulling;
private bool _eventsHooked;
/// <summary>
/// 上次成功镜像的课表内容指纹。内容没变就完全跳过镜像:既省掉每分钟一次的全量重建 + 落盘,
/// 也把"跨线程改档案"的窗口从"每分钟一次"降回"只在课表真的变化时"。
/// </summary>
private volatile string? _lastMirrorSignature;
public BridgeService(PluginSettings settings, ILogger<BridgeService> logger)
{
_settings = settings;
_logger = logger;
}
public event PropertyChangedEventHandler? PropertyChanged;
/// <summary>上报结果变化时触发(设置页据此刷新)。</summary>
public event EventHandler? StateChanged;
/// <summary>需要把提醒丢给提醒提供方去显示。</summary>
public event EventHandler<PushNotificationDto>? NotificationReceived;
/* ---------------------------------------------------------------- 供设置页绑定的状态 */
private string _lastReportMessage = "尚未上报";
/// <summary>最近一次上报的结果描述。</summary>
public string LastReportMessage
{
get => _lastReportMessage;
private set => SetField(ref _lastReportMessage, value);
}
private bool _lastReportOk;
public bool LastReportOk
{
get => _lastReportOk;
private set => SetField(ref _lastReportOk, value);
}
private DateTimeOffset? _lastReportAt;
public DateTimeOffset? LastReportAt
{
get => _lastReportAt;
private set
{
if (SetField(ref _lastReportAt, value)) Raise(nameof(LastReportAtText));
}
}
/// <summary>供界面直接显示的"最后上报时间"。</summary>
public string LastReportAtText => LastReportAt is null
? "从未"
: LastReportAt.Value.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
private int _lastScheduleEntryCount;
/// <summary>最近一次上报里的课表条目数。</summary>
public int LastScheduleEntryCount
{
get => _lastScheduleEntryCount;
private set => SetField(ref _lastScheduleEntryCount, value);
}
private int _lastTimeLayoutItemCount;
/// <summary>最近一次上报里的节次数量。</summary>
public int LastTimeLayoutItemCount
{
get => _lastTimeLayoutItemCount;
private set => SetField(ref _lastTimeLayoutItemCount, value);
}
private int? _lastServerWeek;
/// <summary>服务端返回的教学周。</summary>
public int? LastServerWeek
{
get => _lastServerWeek;
private set
{
if (SetField(ref _lastServerWeek, value)) Raise(nameof(ClientTimeOffsetText));
}
}
private int _notificationCount;
/// <summary>已弹出并回执的提醒数量。</summary>
public int NotificationCount
{
get => _notificationCount;
private set => SetField(ref _notificationCount, value);
}
private bool _busy;
/// <summary>是否正在上报("立即上报"按钮据此禁用)。</summary>
public bool Busy
{
get => _busy;
private set => SetField(ref _busy, value);
}
private TimeSpan? _serverTimeOffset;
/// <summary>服务端与本机的时钟差(诊断用:差得多说明教室机器时间不对)。</summary>
public string ClientTimeOffsetText => _serverTimeOffset is null
? "—"
: $"{_serverTimeOffset.Value.TotalSeconds:+0;-0;0} 秒";
/// <summary>当前配置是否完整(设置页据此提示"还没填令牌")。</summary>
public bool IsConfigured => _settings.IsConfigured;
/// <summary>是否已接入课程服务(拿到 ILessonsService 才算真正跑起来)。</summary>
public bool IsAttached
{
get { lock (_gate) return _lessons is not null; }
}
/* ---------------------------------------------------------------- 生命周期 */
/// <summary>接入课程服务并订阅课程事件(由插件入口在 AppStarted 时调用)。</summary>
public void Attach(ILessonsService lessons)
{
lock (_gate)
{
_lessons = lessons;
}
if (!_eventsHooked)
{
// 上课 / 下课 / 放学 / 状态变化时立刻上报一次,不等到下一个定时点。
// 这些事件在 ClassIsland 的公开约定里是在**主线程**触发的,可以直接订阅。
lessons.OnClass += (_, _) => RequestReport("上课");
lessons.OnBreakingTime += (_, _) => RequestReport("下课");
lessons.OnAfterSchool += (_, _) => RequestReport("放学");
lessons.CurrentTimeStateChanged += (_, _) => RequestReport("状态变化");
_eventsHooked = true;
}
Raise(nameof(IsAttached));
_logger.LogInformation("ClassHelper 联动:已订阅课程事件");
RequestReport("应用启动");
}
/// <summary>按设置重建定时器(设置页改间隔后调用)。</summary>
public void RestartTimer()
{
lock (_gate)
{
_timer?.Dispose();
_timer = null;
_notifyTimer?.Dispose();
_notifyTimer = null;
if (!_settings.IsConfigured) return;
if (_settings.AutoReport)
{
var interval = TimeSpan.FromSeconds(Math.Clamp(_settings.ReportIntervalSeconds, 15, 3600));
_timer = new Timer(_ => RequestReport("定时"), null, interval, interval);
}
// 提醒单独用一个轻量轮询(只查待提醒,不上传课表):
// 老师按下发送到教室弹出之间的延迟就是这个间隔,因此默认 10 秒,不跟随上报间隔。
if (_settings.ReceiveNotifications)
{
var poll = TimeSpan.FromSeconds(Math.Clamp(_settings.NotificationPollSeconds, 5, 600));
_notifyTimer = new Timer(_ => _ = PullPendingAsync(), null, poll, poll);
}
}
}
/// <summary>
/// 发起一次**仅状态**的上报(定时器与课程事件都走这里)。
///
/// 刻意不带课表:课表只在服务端显式点「从教室机器获取课表」时上传(见 <see cref="ReportAsync"/>
/// 的 <c>forceSchedule</c>)。原先每次上报都带全量课表、默认 15 秒一次,服务端于是每 15 秒
/// merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"(2026-10-11 实测)。
/// </summary>
public void RequestReport(string reason)
{
_ = ReportAsync(reason, includeSchedule: false);
}
/// <summary>
/// 执行一次上报并返回是否真的发出去了。
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
/// </summary>
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true, bool forceSchedule = false)
{
if (_disposed) return false;
ILessonsService? lessons;
lock (_gate) lessons = _lessons;
if (lessons is null) return false;
if (!_settings.IsConfigured)
{
SetState(false, "尚未配置服务器地址或设备令牌", 0, 0, null);
return false;
}
lock (_gate)
{
if (_reporting) return false;
_reporting = true;
}
Busy = true;
try
{
var profile = ResolveProfile();
var request = new ReportRequestDto
{
PluginVersion = PluginVersion,
ClassIslandVersion = SafeAppVersion(),
// 机器码:设备创建时服务端只能用 pending-… 占位,靠这里回填,Web 端设备列表才能与教室机器对上
DeviceKey = _settings.DeviceKey,
State = BuildState(lessons),
};
var entryCount = 0;
var layoutCount = 0;
// 课表**只在服务端显式请求时**上传:没有"本机自动回传"这条路径了
// (原先由本机 `UploadSchedule` 开关决定,默认开 + 定时 15 秒 = 服务端课表被反复 merge)。
// forceSchedule:服务端点了「从教室机器获取课表」,这是一次人工请求。
var uploadSchedule = forceSchedule;
if (includeSchedule && !uploadSchedule)
{
_logger.LogInformation("ClassHelper 联动:本次只上报运行状态;课表由 Web 端点「从教室机器获取课表」时才会拉取");
}
if (includeSchedule && uploadSchedule && profile is not null)
{
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
foreach (var warning in warnings) _logger.LogWarning("ClassHelper 联动:{Warning}", warning);
entryCount = entries.Count;
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
// 日志里只出现"仅状态"时,用户会以为上报失败,实际是 ClassIsland 里还没有课表。
if (entries.Count == 0)
{
request.Schedule = null;
_logger.LogWarning(
"ClassHelper 联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由 ClassHelper下发一份");
}
var layout = ResolvePrimaryTimeLayout(profile);
if (layout is not null)
{
var items = ScheduleMapper.MapTimeLayout(layout);
layoutCount = items.Count;
request.TimeLayout = new TimeLayoutPayloadDto
{
Name = ScheduleMapper.Truncate(
string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name,
ScheduleMapper.PlanNameMaxLength)!,
Mode = "replace",
Items = items,
};
}
}
var (result, data) = await _client.ReportAsync(_settings, request, CancellationToken.None)
.ConfigureAwait(false);
if (!result.Ok || data is null)
{
_logger.LogWarning("ClassHelper 联动上报失败({Reason}):{Message}", reason, result.Message);
SetState(false, result.Message, entryCount, layoutCount, null);
return false;
}
LastServerWeek = data.Week;
UpdateClockOffset(data.ServerTime);
if (data.ScheduleApplied)
{
_logger.LogInformation(
"ClassHelper 联动:课表已同步(新增 {Created} / 更新 {Updated})",
data.ScheduleCreated, data.ScheduleUpdated);
}
var detail = entryCount > 0
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
: uploadSchedule
? "仅状态(ClassIsland 里还没有启用的课表)"
: "仅状态(本机「上报课表到 ClassHelper」开关已关闭)";
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
entryCount, layoutCount, data.Week);
// 顺路把"还没弹出过的"提醒带回来
var pending = data.PendingNotification;
if (pending is not null && _settings.ReceiveNotifications)
{
NotificationReceived?.Invoke(this, pending);
}
// 服务端请求"立刻上报一次课表"(老师在 Web 端点了「从教室机器获取课表」)。
// 放在镜像之前:先把教室的现状推上去,再做服务端→教室的镜像,顺序反了会把刚拿到的课表盖掉。
if (data.Settings?.ScheduleRequested == true && !uploadSchedule)
{
_logger.LogInformation("ClassHelper 联动:服务端请求立即上报课表,正在补发一次…");
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
}
// 只有服务端点了「下发课表到教室」才镜像。原先跟着**每一次上报**走(本机 MirrorSchedule
// 开关 + 默认 15 秒周期),教室的课表被反复重写 —— 与服务端那边的 merge/清理叠在一起,
// 就是老师反馈的"课表一直在变"。现在是一次性待办,拉取成功由服务端清标记。
if (data.Settings?.MirrorRequested == true)
{
_logger.LogInformation("ClassHelper 联动:服务端请求下发课表,正在拉取并强制替换教室课表…");
await MirrorScheduleAsync().ConfigureAwait(false);
}
return true;
}
catch (Exception exception)
{
// ReportAsync 是通过 `_ = ReportAsync(reason)` 这种 fire-and-forget 方式发起的
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
_logger.LogError(exception, "ClassHelper 联动:上报异常({Reason})", reason);
SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
return false;
}
finally
{
Busy = false;
lock (_gate) _reporting = false;
}
}
/// <summary>
/// 拉取本班尚未弹出过的提醒并逐条交给提醒提供方(用于"应用刚起来还没到第一个上报点"的场景)。
/// </summary>
public async Task PullPendingAsync()
{
if (_disposed) return;
if (!_settings.IsConfigured || !_settings.ReceiveNotifications) return;
// 轮询与"上报顺路拉取"可能同时到达:同一时刻只放一个请求过去
lock (_gate)
{
if (_pulling) return;
_pulling = true;
}
try
{
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
if (!result.Ok)
{
_logger.LogInformation("ClassHelper 联动:拉取待提醒失败({Message})", result.Message);
return;
}
foreach (var item in items)
{
_logger.LogInformation("ClassHelper 联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
NotificationReceived?.Invoke(this, item);
}
}
finally
{
lock (_gate) _pulling = false;
}
}
/// <summary>把服务端的一条提醒标记为已弹出。</summary>
public async Task AckAsync(string id)
{
var result = await _client.AckAsync(_settings, id, CancellationToken.None).ConfigureAwait(false);
if (result.Ok)
{
NotificationCount += 1;
_logger.LogInformation("ClassHelper 联动:已确认提醒 {Id}", id);
}
else
{
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
_logger.LogWarning("ClassHelper 联动:确认提醒失败 {Message}", result.Message);
}
}
/// <summary>把 ClassHelper上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
public async Task MirrorScheduleAsync()
{
var profile = ResolveProfile();
if (profile is null) return;
var (result, plan) = await _client.PullClassPlanAsync(_settings, CancellationToken.None).ConfigureAwait(false);
if (!result.Ok || plan is null)
{
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
_logger.LogInformation(
"ClassHelper 联动:跳过课表镜像({Message})。" +
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
result.Message);
return;
}
// 内容没变就直接返回:上报是"启动 + 每个课程事件 + 每 N 秒"触发的,原先每次都要
// 重建全部课表对象并整体落盘 —— 无谓的网络/磁盘 I/O,还会把档案页刷得不停重绘。
var signature = MirrorSignature(plan);
if (signature == _lastMirrorSignature)
{
_logger.LogDebug("ClassHelper 联动:课表内容与上次一致,跳过镜像");
return;
}
try
{
// **必须在 UI 线程上改写档案**:ClassIsland 的档案是 ObservableDictionary /
// ObservableCollection,落盘是"属性变化即整体序列化 + FileStream 就地截断"。
// 而本方法是从线程池线程继续执行的(ReportAsync 里 ConfigureAwait(false) 之后,
// 定时器回调本就在线程池上),在线程池上改集合会与 UI 线程的保存/绑定并发:
// 轻则 InvalidOperationException(Collection was modified),重则两次写入互相截断,
// **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。
var outcome = await Dispatcher.UIThread.InvokeAsync(() =>
{
var applied = ClassPlanWriter.Apply(profile, plan, force: true);
if (!applied.Applied)
{
return applied;
}
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
return applied;
});
if (!outcome.Applied)
{
_logger.LogInformation("ClassHelper 联动:课表镜像未生效({Reason})",
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
return;
}
_lastMirrorSignature = signature;
_logger.LogInformation("ClassHelper 联动:已把班级课表写入 ClassIsland({Plans})",
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
foreach (var warning in outcome.Warnings)
{
_logger.LogWarning("ClassHelper 联动镜像提示:{Warning}", warning);
}
}
catch (Exception exception)
{
_logger.LogError(exception, "ClassHelper 联动:课表镜像失败");
}
}
/// <summary>镜像载荷的内容指纹(只用于"内容没变就跳过",不参与任何业务判断)</summary>
private static string MirrorSignature(ClassPlanMirrorDto plan)
{
var builder = new StringBuilder();
foreach (var entry in plan.Entries)
{
builder.Append(entry.WeekDay).Append('|').Append(entry.WeekCountDiv).Append('|')
.Append(entry.WeekCountDivTotal).Append('|').Append(entry.Subject).Append('|')
.Append(entry.StartTime).Append('|').Append(entry.EndTime).Append(';');
}
foreach (var layout in plan.TimeLayouts)
{
builder.Append('#').Append(layout.Id).Append(':');
foreach (var item in layout.Layouts)
{
builder.Append(item.StartTime).Append('-').Append(item.EndTime).Append('@')
.Append(item.TimeType).Append(',');
}
}
return builder.ToString();
}
/* ---------------------------------------------------------------- 内部实现 */
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
private const string PluginVersion = "1.3.2.0";
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
private static string SafeAppVersion()
{
try
{
return AppBase.AppVersion ?? "";
}
catch
{
return "";
}
}
/// <summary>
/// 取得当前状态快照。
/// <c>CurrentTimeLayoutItem</c> 在"没有课表"时是 <see cref="ClassIsland.Shared.Models.Profile.TimeLayoutItem.Empty"/>,
/// 语义上等价于"没有节次",因此要转成 null 而不是 00:00。
/// </summary>
private StateDto BuildState(ILessonsService lessons)
{
try
{
return ScheduleMapper.BuildState(lessons, LastServerWeek);
}
catch (Exception exception)
{
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
_logger.LogWarning(exception, "ClassHelper 联动:读取课程状态失败,本次只上报心跳");
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
}
}
/// <summary>
/// 选一份"最有代表性"的时间表上报给 ClassHelper:**上课点最多的那一份**。
///
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
/// 服务端那边的节次时间表就退化了。取上课点最多的那份,得到的是完整作息。
/// 并列时优先"每周"型(单双周的两份内容一致,每周型更通用)。
/// </summary>
private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout(
ClassIsland.Shared.Models.Profile.Profile profile)
{
if (profile.TimeLayouts.Count == 0) return null;
var best = profile.TimeLayouts
.Select(pair => (pair.Value, ClassPoints: pair.Value.Layouts.Count(item => item.TimeType == 0)))
.Where(item => item.ClassPoints > 0)
.OrderByDescending(item => item.ClassPoints)
.ToList();
if (best.Count > 0) return best[0].Value;
// 兜底:档案里被激活的时间表,最后才是任意一个
return profile.TimeLayouts.Values.FirstOrDefault(item => item.IsActivated)
?? profile.TimeLayouts.Values.First();
}
private static ClassIsland.Shared.Models.Profile.ClassPlan? CurrentClassPlan(
ClassIsland.Shared.Models.Profile.Profile profile)
{
// 用课程服务拿"今天该上哪张课表"最准
try
{
var lessons = IAppHost.TryGetService<ILessonsService>();
return lessons?.CurrentClassPlan;
}
catch
{
return null;
}
}
private ClassIsland.Shared.Models.Profile.Profile? ResolveProfile()
{
try
{
// 档案由 ProfileService 持有;这里用 TryGetService 避免服务未注册时抛异常
return IAppHost.TryGetService<IProfileService>()?.Profile;
}
catch (Exception exception)
{
_logger.LogWarning(exception, "ClassHelper 联动:读取档案失败");
return null;
}
}
/// <summary>用服务端时间校准本机时钟差(只影响设置页展示,不改系统时间)。</summary>
private void UpdateClockOffset(string serverTime)
{
if (!DateTimeOffset.TryParse(serverTime, out var parsed)) return;
_serverTimeOffset = parsed - DateTimeOffset.Now;
Raise(nameof(ClientTimeOffsetText));
}
private void SetState(bool ok, string message, int entryCount, int layoutCount, int? week)
{
LastReportOk = ok;
LastReportMessage = message;
LastReportAt = DateTimeOffset.Now;
LastScheduleEntryCount = entryCount;
LastTimeLayoutItemCount = layoutCount;
if (week.HasValue) LastServerWeek = week;
StateChanged?.Invoke(this, EventArgs.Empty);
}
private void Raise(string propertyName) =>
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
field = value;
Raise(propertyName!);
return true;
}
public void Dispose()
{
_disposed = true;
lock (_gate)
{
_timer?.Dispose();
_timer = null;
_notifyTimer?.Dispose();
_notifyTimer = null;
}
_client.Dispose();
}
}
@@ -1,260 +0,0 @@
using Avalonia.Threading;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassHelper.ClassIslandPlugin.Models;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Shared;
using ClassIsland.Shared.Enums;
using ClassIsland.Core.Abstractions.Services.NotificationProviders;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Models.Notification;
using Microsoft.Extensions.Logging;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 提醒提供方:把老师在 ClassHelper上发布的提醒显示到 ClassIsland 上。
///
/// 与 <see cref="BridgeService"/> 的分工:
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
/// 全屏特效 / 语音 / 音效那一整套流程。
///
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「ClassHelper 联动」设置页里,
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
/// </summary>
// 第 3 个参数是 Fluent 图标字体里的字形字符(ClassIsland 内置提醒提供方用的也是字形字符,
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
[NotificationProviderInfo(
ProviderGuidValue,
"ClassHelper提醒",
"\uE8BD",
"显示老师从 ClassHelper下发的提醒。")]
public sealed class ClassHelperNotificationProvider : NotificationProviderBase
{
/// <summary>
/// 提醒提供方 GUID(全局唯一,别改:改了等于换了一个提供方,用户原有的提醒设置会失联)。
/// 名字带 Value 后缀是为了不和基类的 <see cref="NotificationProviderBase.ProviderGuid"/> 属性撞名。
/// </summary>
public const string ProviderGuidValue = "7C4B1F6E-2A85-4A1F-9E3C-5B7D0C1A9E42";
private readonly BridgeService _bridge;
private readonly PluginSettings _settings;
private readonly ILogger<ClassHelperNotificationProvider> _logger;
/// <summary>
/// 正在播放(尚未回执)的提醒 id。
///
/// 服务端在提醒被回执前会**一直**把它算作"待弹出",而插件是按固定间隔轮询取回的,
/// 因此时长超过轮询间隔的提醒会被反复取到 —— 没有这层去重就会一遍遍重复弹。
/// 只有播完(或用户关掉)才从集合里移除,那一刻才回执。
/// </summary>
private readonly HashSet<string> _playing = new();
/// <summary>
/// 上课时段被暂存的提醒(与客户端灵动岛同一套规则)。
///
/// 为什么要有它:老师在**上课时段**发通知,教室里正上着课,直接全屏弹会打断课堂。
/// 客户端的灵动岛就是"上课先收着、下课再弹",ClassIsland 这侧必须一致,
/// 否则学生在客户端看不到、却在 ClassIsland 上被打断。
///
/// 例外("主动通知"):紧急提醒与**叫人**,老师正在等学生,必须立刻弹。
/// </summary>
private readonly List<PushNotificationDto> _deferred = new();
private ILessonsService? _lessons;
private bool _lessonsHooked;
public ClassHelperNotificationProvider(
BridgeService bridge,
PluginSettings settings,
ILogger<ClassHelperNotificationProvider> logger)
{
_bridge = bridge;
_settings = settings;
_logger = logger;
_bridge.NotificationReceived += OnNotificationReceived;
// 尽力而为地先订阅一次;拿不到也没关系,后面每次需要判定时都会重试
// (本类的构造函数可能在课程服务建好之前就被 DI 调用)
EnsureLessons();
}
/// <summary>
/// 惰性获取课程服务并**只订阅一次**课程事件(下课后补弹上课时段暂存的提醒)。
///
/// 为什么不能只在构造函数里订阅:提供方是随主机启动被构造的,而课程服务在启动流程里的
/// 创建时机并不固定。一旦构造函数里拿到 null,原先的实现既不重试、也不打日志,
/// 于是 `IsInClass()` 恒为 false —— "上课时段先暂存、下课再补弹"这条规则**永久失效**,
/// 老师在课上发的通知会直接全屏打断课堂,而且完全静默、无从排查。
/// </summary>
private void EnsureLessons()
{
if (_lessonsHooked) return;
try
{
_lessons = IAppHost.TryGetService<ILessonsService>();
if (_lessons is null) return;
_lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
_lessonsHooked = true;
_logger.LogInformation("ClassHelper 联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
}
catch (Exception exception)
{
_logger.LogWarning(exception, "ClassHelper 联动:订阅课程事件失败,将在下次提醒时重试");
}
}
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
private bool IsInClass()
{
EnsureLessons();
try
{
return _lessons?.CurrentState == TimeState.OnClass;
}
catch
{
return false;
}
}
/// <summary>
/// 这条提醒是否必须立刻弹("主动通知"):
/// 紧急提醒,或叫人(老师正在等学生)。
/// </summary>
private static bool IsImmediate(PushNotificationDto notification) =>
notification.Urgent || string.Equals(notification.Kind, "call", StringComparison.OrdinalIgnoreCase);
/// <summary>下课后补弹暂存的提醒(按进入顺序)</summary>
private void FlushDeferred()
{
if (IsInClass()) return; // 还在上课(例如只是状态在 OnClass 内部变化)
if (_deferred.Count == 0) return;
var pending = _deferred.ToList();
_deferred.Clear();
_logger.LogInformation("ClassHelper 联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
foreach (var item in pending) ShowOnUiThread(item);
}
private void OnNotificationReceived(object? sender, PushNotificationDto notification)
{
if (!_settings.ReceiveNotifications) return;
if (string.IsNullOrWhiteSpace(notification.Id)) return;
// 同一条提醒在播放期间会被后续轮询反复取回,这里按 id 去重(详见 _playing 的说明)
lock (_playing)
{
if (!_playing.Add(notification.Id))
{
_logger.LogDebug("ClassHelper 联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
return;
}
}
// 必须切回 UI 线程:提醒内容里的图标、以及提醒主机本身都是 Avalonia 的 UI 对象,
// 而本方法是从上报回调(线程池)里被调用的 —— 在线程池上直接显示会静默失败/抛异常。
Dispatcher.UIThread.Post(() => ShowOnUiThread(notification));
}
private void ShowOnUiThread(PushNotificationDto notification)
{
try
{
// 上课时段:非"主动通知"先收着(与客户端灵动岛同一套规则),下课后补弹
if (IsInClass() && !IsImmediate(notification))
{
_deferred.Add(notification);
_logger.LogInformation("ClassHelper 联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
return;
}
var request = BuildRequest(notification);
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
AckWhenFinished(request, notification);
ShowNotification(request);
_logger.LogInformation("ClassHelper 联动:已弹出提醒「{Title}」({Seconds} 秒)",
notification.Title, notification.DurationSeconds);
}
catch (Exception exception)
{
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
lock (_playing) _playing.Remove(notification.Id);
_logger.LogError(exception, "ClassHelper 联动:显示提醒「{Title}」失败", notification.Title);
}
}
/// <summary>
/// 把服务端下发的提醒转成 ClassIsland 的提醒请求。
///
/// 时长口径:老师指定的时长给**正文**(正文才是持续显示的那一段),遮罩只停留几秒 ——
/// 遮罩占满整个时长会把正文压没,不如"遮罩几秒 + 正文 N 秒"清楚。
/// </summary>
private NotificationRequest BuildRequest(PushNotificationDto notification)
{
var seconds = Math.Clamp(
notification.DurationSeconds > 0 ? notification.DurationSeconds : _settings.NotificationDurationSeconds,
1,
120);
var duration = TimeSpan.FromSeconds(seconds);
var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds));
var title = string.IsNullOrWhiteSpace(notification.Title) ? "ClassHelper" : notification.Title;
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
var speech = _settings.SpeechEnabled ? notification.SpeechContent : null;
var speechEnabled = !string.IsNullOrWhiteSpace(speech);
// 遮罩用双图标模板的**默认图标**:这两个图标由模板自己创建(LucideIconSource),
// 自己塞图标串很容易踩到"图标表达式格式不对/字形不存在",没必要冒这个险。
var mask = NotificationContent.CreateTwoIconsMask(title, factory: content_ =>
{
content_.Duration = maskDuration;
content_.SpeechContent = speech ?? string.Empty;
// 没勾朗读时明确关掉,否则 ClassIsland 会按默认设置把标题也念出来
content_.IsSpeechEnabled = speechEnabled;
});
// 正文:普通提醒用静态文本;紧急提醒改用**滚动文本**(ClassIsland 自带的抢注意力模板),
// 这样"紧急"在视觉上真的有区别,而不是只靠一个可能不存在的图标。
NotificationContent overlay = notification.Urgent
? NotificationContent.CreateRollingTextContent(content, factory: content_ =>
{
content_.Duration = duration;
content_.SpeechContent = speech ?? string.Empty;
content_.IsSpeechEnabled = speechEnabled;
})
: NotificationContent.CreateSimpleTextContent(content, factory: content_ =>
{
content_.Duration = duration;
content_.SpeechContent = speech ?? string.Empty;
content_.IsSpeechEnabled = speechEnabled;
});
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
}
/// <summary>提醒播完 / 被关掉后回执给 ClassHelper,服务端不再补发。</summary>
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
{
void Handler(object? sender, EventArgs args)
{
request.Completed -= Handler;
request.Canceled -= Handler;
// 播完再放行:即使回执失败(例如断网)也移除,让后续轮询能重新投递一次,
// 避免"回执丢了 + 不再重试"把提醒彻底吞掉
lock (_playing) _playing.Remove(notification.Id);
_ = _bridge.AckAsync(notification.Id);
}
request.Completed += Handler;
request.Canceled += Handler;
}
}
@@ -1,299 +0,0 @@
using System.Collections.ObjectModel;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassIsland.Shared.Models.Profile;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 把 ClassHelper拉回来的课表写进 ClassIsland 档案。
///
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
/// (即一个星期几 + 一个单双周相位),而 ClassHelper的课表同时覆盖 7 天与单双周。
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
///
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
/// <c>TimeLayout.Layouts</c> 里第 i 个 <c>TimeType == 0</c>(上课)的点一一对应。
/// 与其去"匹配服务端时间表里的时间点"(一旦对不齐整份课表就错位),
/// 这里**按分组内的条目顺序直接重建时间点**:每条课目生成一个上课点,
/// 并在其结束后接上服务端时间表里同一起始时刻的课间点。这样索引关系天然成立。
///
/// **非破坏性**:
/// - 只创建/更新名字带 <see cref="PlanNamePrefix"/> 前缀的课表与时间表;
/// - 老师手动建的课表一律不删除、不改名(同名星期的冲突会在日志里警告);
/// - 重复镜像时原地更新,不会越积越多。
/// </summary>
public static class ClassPlanWriter
{
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
public const string PlanNamePrefix = "ClassHelper-";
/// <summary>时间表名字后缀。</summary>
public const string TimeLayoutNameSuffix = "(ClassHelper)";
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
public sealed class ApplyResult
{
public List<string> PlanNames { get; } = new();
public List<string> Warnings { get; } = new();
public bool Applied { get; set; }
}
/// <summary>
/// 应用服务端下发的课表。
/// </summary>
/// <param name="force">
/// 「强制更换课表」:同一星期几 + 同一单双周相位下老师还开着别的课表时,**停用**它们,
/// 让教室大屏立刻改成以 ClassHelper 下发的这份为准。
/// 由 Web 端的「下发课表到教室」按钮触发(服务端那个一次性待办)。
/// **只停用不删除** —— 课表内容原样留在档案里,老师随时能在 ClassIsland 里重新启用。
/// </param>
public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source, bool force = false)
{
var result = new ApplyResult();
if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0)
{
result.Warnings.Add("服务端返回的课表为空,未改动 ClassIsland 档案");
return result;
}
var layout = source.TimeLayouts[0];
// 1) 补齐科目(同名的复用,避免每次镜像堆出新的 Subject)
EnsureSubjects(profile, source);
// 2) 按(星期几 + 单双周)分组,每组一份课表
var groups = source.Entries
.GroupBy(entry => new
{
entry.WeekDay,
entry.WeekCountDiv,
entry.WeekCountDivTotal,
})
.Select(group => new
{
group.Key.WeekDay,
group.Key.WeekCountDiv,
group.Key.WeekCountDivTotal,
// 时间点顺序 = 课表顺序:`Classes[i]` 靠这个顺序与上课时间点对齐,
// 「HH:mm」是定长字符串,字典序就是时间序
Entries = group.OrderBy(entry => entry.StartTime, StringComparer.Ordinal).ToList(),
})
.OrderBy(group => group.WeekDay)
.ToList();
foreach (var group in groups)
{
var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv,
group.WeekCountDivTotal, groups.Count);
// 时间非法的条目必须**在这里**就整批丢掉:`Classes` 与时间表里的上课点必须等长且同序,
// 只在一处跳过会让整份课表错位。原先 ParseTime 把无法解析的值悄悄变成 00:00,
// 于是档案里凭空出现 00:00-00:00 的课次(时间轴显示与当前节次判断都会乱),还没有任何日志。
var usable = group.Entries
.Where(entry => IsValidTime(entry.StartTime) && IsValidTime(entry.EndTime))
.ToList();
if (usable.Count < group.Entries.Count)
{
result.Warnings.Add(
$"「{planName}」有 {group.Entries.Count - usable.Count} 节的时间无法解析(需要 HH:mm),已跳过");
}
if (usable.Count == 0) continue;
var planId = FindPlanId(profile, planName);
var (timeLayoutId, timeLayout) = BuildTimeLayout(profile, planName, usable);
var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
? existing
: new ClassPlan();
plan.Name = planName;
plan.TimeLayoutId = timeLayoutId;
plan.TimeRule = new TimeRule
{
WeekDay = group.WeekDay,
WeekCountDiv = group.WeekCountDiv,
WeekCountDivTotal = group.WeekCountDivTotal,
};
// Classes[i] ↔ 第 i 个上课点:按分组内条目的时间顺序直接构造,索引天然一致
// ClassIsland 的 `ClassInfo.CurrentTimeLayoutItem` = CurrentTimeLayout 里第 Index 个上课点,
// 因此这里必须显式把 Index 与 CurrentTimeLayout 一起写上 —— 只在构造时依赖
// `RefreshClassesList()` 是不够的:更新已有课表时 `TimeLayouts` 引用没变,
// 档案不会重新触发刷新,课次就会停留在旧时间点上。
var classes = new ObservableCollection<ClassInfo>();
var index = 0;
foreach (var entry in usable)
{
var subjectId = profile.Subjects
.FirstOrDefault(pair => pair.Value.Name == entry.Subject).Key;
classes.Add(new ClassInfo
{
Index = index++,
CurrentTimeLayout = timeLayout,
SubjectId = subjectId,
IsEnabled = true,
});
}
plan.Classes = classes;
plan.IsEnabled = true;
profile.ClassPlans[planId] = plan;
result.PlanNames.Add(planName);
// 冲突处理:同一星期几 + 同一单双周相位下老师还开着别的课表。
// ClassIsland 在同一个时间点上只会用一份**启用**的课表,所以"强制更换"就是把冲突的
// 那份停用 —— 教室大屏上立刻显示成 ClassHelper 下发的这一份。
var conflicts = profile.ClassPlans
.Where(pair => pair.Key != planId
&& pair.Value.IsEnabled
&& !pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)
&& pair.Value.TimeRule.WeekDay == group.WeekDay
&& pair.Value.TimeRule.WeekCountDivTotal == group.WeekCountDivTotal
&& pair.Value.TimeRule.WeekCountDiv == group.WeekCountDiv)
.ToList();
if (conflicts.Count > 0)
{
var names = string.Join("、", conflicts.Select(pair => pair.Value.Name));
if (force)
{
foreach (var pair in conflicts) pair.Value.IsEnabled = false;
result.Warnings.Add(
$"星期 {group.WeekDay} 原有的课表({names})已停用,教室显示改以 ClassHelper 下发的课表为准" +
"(只停用、未删除,可在 ClassIsland 的课表面板里重新启用)");
}
else
{
result.Warnings.Add(
$"星期 {group.WeekDay} 已有启用的课表({names}),两者重叠时以 ClassHelper 同步的课表为准");
}
}
}
// 3) 停用上一次镜像留下来的、本次不再需要的课表(只动自己创建的)
var currentNames = new HashSet<string>(result.PlanNames);
foreach (var pair in profile.ClassPlans)
{
if (!pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)) continue;
if (currentNames.Contains(pair.Value.Name)) continue;
pair.Value.IsEnabled = false;
}
result.Applied = true;
return result;
}
private static void EnsureSubjects(Profile profile, ClassPlanMirrorDto source)
{
foreach (var name in source.Entries.Select(entry => entry.Subject).Distinct())
{
if (string.IsNullOrWhiteSpace(name)) continue;
if (profile.Subjects.Any(pair => pair.Value.Name == name)) continue;
profile.Subjects[Guid.NewGuid()] = new Subject { Name = name };
}
}
private static Guid FindPlanId(Profile profile, string planName)
{
var existing = profile.ClassPlans.FirstOrDefault(pair => pair.Value.Name == planName);
return existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
}
/// <summary>
/// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
///
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
/// 不依赖服务端有没有给课间点 —— 这样无论 ClassHelper那边是否维护节次时间表,
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
/// </summary>
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
Profile profile,
string planName,
IReadOnlyList<ClassPlanEntryMirrorDto> entries)
{
var layoutName = planName + TimeLayoutNameSuffix;
var existing = profile.TimeLayouts.FirstOrDefault(pair => pair.Value.Name == layoutName);
var id = existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
var layout = new TimeLayout { Name = layoutName, IsActivated = true };
for (var index = 0; index < entries.Count; index++)
{
var entry = entries[index];
layout.Layouts.Add(new TimeLayoutItem
{
TimeType = 0, // 上课
StartTime = ParseTime(entry.StartTime),
EndTime = ParseTime(entry.EndTime),
});
// 课间:下一节开始晚于本节结束才有空隙;最后一节之后(放学)不插
if (index + 1 >= entries.Count) continue;
var next = entries[index + 1];
var breakStart = ParseTime(entry.EndTime);
var breakEnd = ParseTime(next.StartTime);
if (breakEnd <= breakStart) continue;
layout.Layouts.Add(new TimeLayoutItem
{
TimeType = 1, // 课间
StartTime = breakStart,
EndTime = breakEnd,
});
}
profile.TimeLayouts[id] = layout;
return (id, layout);
}
/// <summary>
/// 课表名:单组时用「ClassHelper-&lt;班级名&gt;」;多组时补上星期与单双周以免互相覆盖。
/// </summary>
private static string BuildPlanName(
string profileName,
int weekDay,
int weekCountDiv,
int weekCountDivTotal,
int groupCount)
{
var baseName = string.IsNullOrWhiteSpace(profileName) ? "ClassHelper课表" : profileName.Trim();
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
{
baseName = PlanNamePrefix + baseName;
}
if (groupCount <= 1) return baseName;
var weekday = weekDay switch
{
0 => "周日",
1 => "周一",
2 => "周二",
3 => "周三",
4 => "周四",
5 => "周五",
6 => "周六",
_ => $"星期{weekDay}",
};
var parity = weekCountDivTotal <= 1
? "每周"
: $"第{weekCountDiv}/{weekCountDivTotal}周";
return $"{baseName} · {weekday} · {parity}";
}
/// <summary>"HH:mm" / "HH:mm:ss" → TimeSpan(解析失败回退到 0 点)。</summary>
private static TimeSpan ParseTime(string value)
{
return TimeSpan.TryParse(value, out var parsed) ? parsed : TimeSpan.Zero;
}
/// <summary>
/// 时间字符串是否可用(HH:mm / HH:mm:ss)。
///
/// 服务端输出侧没有校验 `Schedule.startTime`(老数据/异常数据可能是空串),
/// 而 <see cref="ParseTime"/> 会把非法值变成 0 点 —— 因此调用方必须先用本方法过滤,
/// 并把 Classes 与时间表**同时**按过滤后的列表构造,保证索引仍然一致。
/// </summary>
private static bool IsValidTime(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return false;
return TimeSpan.TryParse(value, out var parsed)
&& parsed >= TimeSpan.Zero
&& parsed < TimeSpan.FromDays(1);
}
}
@@ -1,272 +0,0 @@
using System.Collections.ObjectModel;
using ClassHelper.ClassIslandPlugin.Interop;
using ClassIsland.Core.Abstractions.Services;
using ClassIsland.Shared;
using ClassIsland.Shared.Enums;
using ClassIsland.Shared.Models.Profile;
namespace ClassHelper.ClassIslandPlugin.Services;
/// <summary>
/// 课表 / 状态 → 上报载荷 的换算层。
///
/// 这里集中全部"ClassIsland 口径 ↔ ClassHelper口径"的转换,保证只有一处需要跟着
/// ClassIsland 的模型变化而修改:
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
/// - 节次:ClassIsland <c>TimeLayoutItem.TimeType</c> 0=上课 1=课间 2=分割线 3=行动。
/// </summary>
public static class ScheduleMapper
{
/// <summary>
/// ClassIsland 的 WeekDay(0=周日…6=周六)→ 本系统 dayOfWeek(1=周一…7=周日)。
/// </summary>
public static int ToServerDayOfWeek(int weekDay)
{
var normalized = ((weekDay % 7) + 7) % 7;
return normalized == 0 ? 7 : normalized;
}
/// <summary>
/// ClassIsland 的 WeekCountDiv/Total → 本系统单双周。
/// 与 <c>packages/shared/src/utils.ts</c> 的 <c>weekParityFromDiv</c> 完全一致:
/// 3 周及以上的循环本系统无法表达,降级为「每周」。
/// </summary>
public static string ToWeekParity(int weekCountDiv, int weekCountDivTotal)
{
if (weekCountDiv <= 0 || weekCountDivTotal <= 1) return "ALL";
var position = ((weekCountDiv - 1) % weekCountDivTotal) + 1;
if (weekCountDivTotal == 2) return position == 1 ? "ODD" : "EVEN";
return "ALL";
}
/// <summary>ClassIsland TimeType → 本系统节次类型。</summary>
public static string ToTimeLayoutType(int timeType) => timeType switch
{
1 => "break",
2 => "divider",
3 => "action",
_ => "class",
};
/// <summary>TimeSpan → "HH:mm"(跨天的时间点做钳制,避免出现 24:xx)。</summary>
public static string ToHhMm(TimeSpan time)
{
var totalMinutes = (int)Math.Round(time.TotalMinutes);
totalMinutes = Math.Clamp(totalMinutes, 0, 24 * 60 - 1);
return $"{totalMinutes / 60:D2}:{totalMinutes % 60:D2}";
}
/// <summary>
/// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。
///
/// 与 ClassIsland 的语义严格对齐:<c>ClassPlan.Classes[i]</c> 对应
/// <c>TimeLayout.Layouts</c> 里**第 i 个 TimeType=0(上课)的点**,课间 / 分割线 / 行动不占位。
///
/// **注意不要用 `plan.ValidTimeLayoutItems`**:它的口径与 `Classes` 不同 ——
/// `GetValidTimeLayoutItems()`(ClassIsland ClassPlan.cs)会正反两遍扫描,
/// 把 `ClassInfo.IsEnabled == false`(老师在课表编辑里"停用"这一节)的课**连同其后的课间**
/// 一起从结果里剔除;而 `RefreshClassesList()` 只按 `TimeType == 0` 的**总数**对齐
/// `Classes` 的长度,`ClassInfo.CurrentTimeLayoutItem` 也是按原始列表的第 `Index` 个取的。
/// 于是只要停用过任意一节,用 ValidTimeLayoutItems 配对就会让索引整体前移:
/// 第 i 节配上第 i+1 节的时间点、最后一节丢失,且 `Math.Min` 会把缺口静默吃掉 ——
/// 表现就是"课表看着有内容、每节课都对不上",极难排查。
/// </summary>
public static List<ScheduleEntryDto> MapClassPlan(ClassPlan plan, Profile profile, out string? warning)
{
warning = null;
var entries = new List<ScheduleEntryDto>();
var timeLayout = ResolveTimeLayout(plan, profile);
if (timeLayout is null)
{
warning = $"课表「{plan.Name}」找不到对应的时间表,已跳过";
return entries;
}
// 只保留上课类型的时间点,顺序必须与 Classes 的索引一致(见上面注释)
var classPoints = timeLayout.Layouts.Where(item => item.TimeType == 0).ToList();
var weekDay = ToServerDayOfWeek(plan.TimeRule.WeekDay);
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
if (plan.TimeRule.WeekCountDivTotal > 2)
{
warning = "存在 3 周以上的轮换课表,ClassHelper只支持单双周,已按「每周」处理";
}
var classes = plan.Classes;
var count = Math.Min(classes.Count, classPoints.Count);
for (var index = 0; index < count; index++)
{
var info = classes[index];
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
var point = classPoints[index];
if (info is null) continue;
// 老师停用的一节:本系统没有"停用"概念,不上报(否则 ClassHelper 班级端会看到一节实际上不上的课)
if (!info.IsEnabled) continue;
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
var subject = ResolveSubjectName(info.SubjectId, profile);
if (string.IsNullOrWhiteSpace(subject)) continue;
entries.Add(new ScheduleEntryDto
{
DayOfWeek = weekDay,
StartTime = ToHhMm(point.StartTime),
EndTime = ToHhMm(point.EndTime),
// 服务端对这几个字段有 zod 长度上限,且是**整批**校验:一个超长名字会让整次上报
// 400,连"现在上什么课"的状态与心跳都不更新,Web 端把教室显示成离线。
Subject = Truncate(subject, SubjectMaxLength)!,
TeacherName = Truncate(ResolveSubjectTeacher(info.SubjectId, profile), NameMaxLength),
WeekParity = parity,
WeekCountDiv = plan.TimeRule.WeekCountDiv,
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
PlanName = Truncate(plan.Name, PlanNameMaxLength),
});
}
return entries;
}
/// <summary>服务端 zod 对各类名称的长度上限(超出会让整批上报 400,所以要截断) */
public const int SubjectMaxLength = 40;
public const int NameMaxLength = 40;
public const int PlanNameMaxLength = 60;
/// <summary>按服务端上限截断(null / 空串原样返回)</summary>
public static string? Truncate(string? value, int max)
{
if (string.IsNullOrEmpty(value)) return value;
var trimmed = value.Trim();
return trimmed.Length <= max ? trimmed : trimmed[..max];
}
/// <summary>把时间表(TimeLayout)转成本系统的节次时间配置。</summary>
public static List<TimeLayoutItemDto> MapTimeLayout(TimeLayout layout)
{
var items = new List<TimeLayoutItemDto>();
// ClassIsland 的上课时间点没有独立名称,这里按"它之前有几个上课点"编出"第 N 节"
var classPointIndex = 0;
for (var index = 0; index < layout.Layouts.Count; index++)
{
var point = layout.Layouts[index];
items.Add(new TimeLayoutItemDto
{
Index = index + 1,
// 节次名同样是服务端有上限的字段(max 40),超长会让整批上报失败
Name = Truncate(BuildTimePointName(point, ref classPointIndex), NameMaxLength)!,
StartTime = ToHhMm(point.StartTime),
EndTime = ToHhMm(point.EndTime),
Type = ToTimeLayoutType(point.TimeType),
Skipped = false,
});
}
return items;
}
/// <summary>
/// 节次名称:ClassIsland 只有课间有名字(BreakName),上课时间点没有独立名称,
/// 因此按类型生成"第 N 节 / 课间 / 分割线",保证本系统的时间轴可读。
/// </summary>
private static string BuildTimePointName(TimeLayoutItem point, ref int classPointIndex)
{
switch (point.TimeType)
{
case 1:
return string.IsNullOrWhiteSpace(point.BreakName) ? "课间" : point.BreakName!;
case 2:
return "分割线";
case 3:
return "行动";
default:
classPointIndex++;
return $"第 {classPointIndex} 节";
}
}
/// <summary>取出课表使用的时间表:优先按 TimeLayoutId,其次用档案里第一个。</summary>
public static TimeLayout? ResolveTimeLayout(ClassPlan plan, Profile profile)
{
if (profile.TimeLayouts.Count == 0) return null;
if (plan.TimeLayoutId != Guid.Empty && profile.TimeLayouts.TryGetValue(plan.TimeLayoutId, out var byId))
{
return byId;
}
return plan.TimeLayout ?? profile.TimeLayouts.Values.FirstOrDefault();
}
/// <summary>科目名(找不到时返回空字符串,由调用方决定是否跳过)。</summary>
public static string? ResolveSubjectName(Guid subjectId, Profile profile)
{
if (subjectId == Guid.Empty) return null;
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.Name : null;
}
/// <summary>科目任课老师(ClassIsland 在 Subject 上记录 TeacherName)。</summary>
public static string? ResolveSubjectTeacher(Guid subjectId, Profile profile)
{
if (subjectId == Guid.Empty) return null;
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.TeacherName : null;
}
/// <summary>
/// 收集档案里今天(按当前周次与星期)应该上的课表,
/// 用于"只上报今天"这类轻量场景;当前实现为上报全部课表(服务端按周次并存)。
/// </summary>
public static List<ScheduleEntryDto> MapAllClassPlans(Profile profile, out List<string> warnings)
{
warnings = new List<string>();
var all = new List<ScheduleEntryDto>();
foreach (var plan in profile.ClassPlans.Values)
{
if (!plan.IsEnabled) continue;
if (plan.IsOverlay) continue; // 临时层课表不下发,避免把临时调整固化到班级课表
var entries = MapClassPlan(plan, profile, out var warning);
if (!string.IsNullOrWhiteSpace(warning) && !warnings.Contains(warning!))
{
warnings.Add(warning!);
}
all.AddRange(entries);
}
return all;
}
/// <summary>
/// 科目名归一化。
///
/// 没有课表 / 没有当前科目时,ClassIsland 会把 <see cref="Subject.Fallback"/> 挂到
/// `CurrentSubject` / `NextClassSubject` 上(见 LessonsService:
/// `CurrentSubject = currentSubject ?? Subject.Fallback`),而它的名字就是占位符本身。
/// 直接上报会让 Web 端显示成"正在上 ???",因此这里统一折算成"没有科目"。
/// </summary>
private static string? NormalizeSubjectName(Subject? subject)
{
if (subject is null) return null;
if (ReferenceEquals(subject, Subject.Fallback)) return null;
var name = subject.Name?.Trim();
if (string.IsNullOrEmpty(name)) return null;
// 兜底:万一拿到的是另一个同名占位符实例(不是同一个引用)
if (string.Equals(name, Subject.Fallback.Name, StringComparison.Ordinal)) return null;
return name;
}
/// <summary>当前状态快照:服务端据此在 Web 端展示"现在上什么课"。</summary>
public static StateDto BuildState(ILessonsService lessons, int? currentWeek)
{
var current = lessons.CurrentTimeLayoutItem;
var state = lessons.CurrentState;
return new StateDto
{
InClass = state == TimeState.OnClass,
Subject = NormalizeSubjectName(lessons.CurrentSubject),
NextSubject = NormalizeSubjectName(lessons.NextClassSubject),
TimeState = state.ToString(),
PeriodStart = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.StartTime),
PeriodEnd = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.EndTime),
Week = currentWeek,
ClassPlanLoaded = lessons.IsClassPlanLoaded,
ClientTime = DateTimeOffset.Now.ToString("o"),
};
}
}
@@ -1,150 +0,0 @@
<controls:SettingsPageBase x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:local="clr-namespace:ClassHelper.ClassIslandPlugin.Views"
xmlns:controls="clr-namespace:ClassIsland.Core.Abstractions.Controls;assembly=ClassIsland.Core"
xmlns:ci="clr-namespace:ClassIsland.Core.Controls;assembly=ClassIsland.Core"
xmlns:fa="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
mc:Ignorable="d"
d:DesignHeight="450" d:DesignWidth="800"
d:DataContext="{d:DesignInstance local:BridgeSettingsPage}"
Loaded="OnLoaded"
Unloaded="OnUnloaded">
<ScrollViewer>
<StackPanel Classes="settings-container animated-intro" Spacing="0">
<!-- 连接 -->
<TextBlock Text="连接 ClassHelper"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="填入 ClassHelper后端的地址与设备令牌。设备令牌在 ClassHelper Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
<TextBox Watermark="http://127.0.0.1:4000"
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
<TextBox Margin="0 8 0 0"
Watermark="chci_..."
PasswordChar="●"
Text="{Binding Settings.DeviceToken, Mode=TwoWay}" />
<!-- 令牌是设备级长期凭证(只有管理员在 Web 端重置才会失效),而 ClassIsland 的插件配置
是明文 JSON 且会随档案备份/迁移一起被带走,所以这里明确提示用户别外传。 -->
<TextBlock Margin="0 6 0 0" TextWrapping="Wrap" Opacity="0.7"
Text="令牌是这台教室机器的长期凭证(仅管理员在 Web 端重置才会失效),会明文保存在本机配置文件里并随 ClassIsland 档案备份一起复制,请不要外传或提交到版本库。" />
<StackPanel Orientation="Horizontal" Margin="0 12 0 0" Spacing="8">
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="检查连接" Command="{Binding TestCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="拉取待提醒" Command="{Binding PullCommand}" IsEnabled="{Binding !Bridge.Busy}" />
<Button Content="重建档案课表" Command="{Binding MirrorCommand}" IsEnabled="{Binding !Bridge.Busy}" />
</StackPanel>
<!-- 状态 -->
<Border Margin="0 16 0 0" Padding="12" CornerRadius="4"
Background="{DynamicResource SystemFillColorNeutralBackgroundBrush}">
<StackPanel Spacing="4">
<TextBlock Text="{Binding StatusText}" TextWrapping="Wrap" FontWeight="SemiBold" />
<TextBlock Text="{Binding Bridge.LastReportMessage}" TextWrapping="Wrap" />
<TextBlock Text="{Binding DetailText}" TextWrapping="Wrap" Opacity="0.8" />
</StackPanel>
</Border>
<!-- 同步 -->
<TextBlock Margin="0 24 0 0" Text="课表同步"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="课表的两个方向都是按需的,由 Web 端「ClassIsland 联动」页的两个按钮触发,本机不再提供「一直同步」的开关:「从教室机器获取课表」把 ClassIsland 里启用的课表合并进 ClassHelper;「下发课表到教室」把 ClassHelper 排好的课表写回 ClassIsland,并把同一天同一单双周相位下冲突的旧课表停用(只停用、不删除,可在 ClassIsland 的课表面板里重新启用)。" />
<fa:SettingsExpander Header="自动上报"
Description="应用启动、课程事件以及每隔一段时间各上报一次**运行状态**(现在上什么课、ClassIsland 版本等)。课表不在这条路径上 —— 它只由 Web 端「从教室机器获取课表」按需拉取。关闭后只能用上面的「立即上报」手动触发。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE895;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.AutoReport, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="上报间隔(秒)" />
<TextBlock Text="最小 15 秒;教室机器建议 60 秒。" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" Width="140"
Minimum="15" Maximum="3600" Increment="15"
FormatString="0"
IsEnabled="{Binding Settings.AutoReport}"
Value="{Binding Settings.ReportIntervalSeconds, Mode=TwoWay}" />
</Grid>
<!-- 提醒 -->
<TextBlock Margin="0 24 0 0" Text="提醒"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
Text="老师在 ClassHelper上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
<fa:SettingsExpander Header="接收 ClassHelper提醒"
Description="关闭后本机会忽略老师在 ClassHelper上发布的提醒(提醒仍会留在班级通知中心)。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8BD;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.ReceiveNotifications, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<fa:SettingsExpander Header="语音朗读"
Description="提醒弹出时按 ClassIsland 的语音设置为全机朗读提醒内容。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE8D6;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.SpeechEnabled, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="默认显示时长(秒)" />
<TextBlock Text="老师在下发单条提醒时可以另外指定更长的时长。" Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" Width="140"
Minimum="1" Maximum="120" Increment="1"
FormatString="0"
Value="{Binding Settings.NotificationDurationSeconds, Mode=TwoWay}" />
</Grid>
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
<StackPanel>
<TextBlock Text="提醒轮询间隔(秒)" />
<TextBlock Text="老师按下发送到教室弹出之间的最大延迟(插件不持有长连接,是轮询取回提醒)。默认 10 秒。"
Opacity="0.7" TextWrapping="Wrap" />
</StackPanel>
<NumericUpDown Grid.Column="1" Width="140"
Minimum="5" Maximum="600" Increment="5"
FormatString="0"
IsEnabled="{Binding Settings.ReceiveNotifications}"
Value="{Binding Settings.NotificationPollSeconds, Mode=TwoWay}" />
</Grid>
<!-- 调试 -->
<TextBlock Margin="0 24 0 0" Text="调试"
Theme="{StaticResource SubtitleTextBlockStyle}" />
<fa:SettingsExpander Header="显示调试信息"
Description="显示设备标识、时钟差等细节,排查「为什么同步不上」时打开。">
<fa:SettingsExpander.IconSource>
<ci:FluentIconSource Glyph="&#xE7EF;" />
</fa:SettingsExpander.IconSource>
<fa:SettingsExpander.Footer>
<ToggleSwitch IsChecked="{Binding Settings.ShowDebugInfo, Mode=TwoWay}" />
</fa:SettingsExpander.Footer>
</fa:SettingsExpander>
<StackPanel Margin="0 8 0 0" Spacing="2" IsVisible="{Binding Settings.ShowDebugInfo}">
<TextBlock Text="{Binding DebugText}" TextWrapping="Wrap" FontFamily="Consolas,monospace" />
</StackPanel>
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
Text="提示:ClassHelper Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
</StackPanel>
</ScrollViewer>
</controls:SettingsPageBase>
@@ -1,198 +0,0 @@
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Avalonia.Threading;
using ClassHelper.ClassIslandPlugin.Models;
using ClassHelper.ClassIslandPlugin.Services;
using ClassIsland.Core.Abstractions.Controls;
using ClassIsland.Core.Attributes;
using ClassIsland.Core.Enums.SettingsWindow;
using CommunityToolkit.Mvvm.Input;
namespace ClassHelper.ClassIslandPlugin.Views;
/// <summary>
/// 「ClassHelper 联动」设置页。
///
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
/// 不需要再套一层 ViewModel,改配置项时也少一处"两边同步"的机会。
/// </summary>
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
[SettingsPageInfo(
"classhelper.classisland.bridge.settings",
"ClassHelper 联动",
"\uE71B",
"\uE71B",
SettingsPageCategory.External)]
public partial class BridgeSettingsPage : SettingsPageBase, INotifyPropertyChanged
{
public BridgeSettingsPage(PluginSettings settings, BridgeService bridge)
{
Settings = settings;
Bridge = bridge;
ReportCommand = new AsyncRelayCommand(ReportAsync);
TestCommand = new AsyncRelayCommand(TestAsync);
PullCommand = new AsyncRelayCommand(PullAsync);
MirrorCommand = new AsyncRelayCommand(MirrorAsync);
InitializeComponent();
DataContext = this;
Raise(nameof(StatusText));
}
public PluginSettings Settings { get; }
public BridgeService Bridge { get; }
public IAsyncRelayCommand ReportCommand { get; }
public IAsyncRelayCommand TestCommand { get; }
public IAsyncRelayCommand PullCommand { get; }
public IAsyncRelayCommand MirrorCommand { get; }
private PropertyChangedEventHandler? _propertyChanged;
/// <summary>
/// 显式实现 <see cref="INotifyPropertyChanged"/>:<c>AvaloniaObject</c> 自带一个同名但类型不同的事件,
/// 直接声明会遮蔽基类成员。显式实现既不影响 Avalonia 的绑定查找(它按接口取),也不会有歧义。
/// </summary>
event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged
{
add => _propertyChanged += value;
remove => _propertyChanged -= value;
}
/* ---------------------------------------------------------------- 绑定用的展示文本 */
/// <summary>一句话结论:还没配 / 已配置 / 已连接 / 连接异常。</summary>
public string StatusText
{
get
{
if (!Settings.IsConfigured) return "尚未配置:请填写服务器地址与设备令牌";
if (!Bridge.IsAttached) return "已填写配置,等待 ClassIsland 课程服务就绪";
return Bridge.LastReportOk ? "已连接" : "连接异常";
}
}
public string DetailText
{
get
{
var week = Bridge.LastServerWeek?.ToString() ?? "—";
return $"最后上报:{Bridge.LastReportAtText} 教学周:{week} " +
$"课表 {Bridge.LastScheduleEntryCount} 节 / 节次 {Bridge.LastTimeLayoutItemCount} 条 " +
$"已弹出提醒:{Bridge.NotificationCount} 条";
}
}
/// <summary>调试信息(设置页里打开"显示调试信息"才可见)。</summary>
public string DebugText
{
get
{
var lines = new List<string>
{
$"服务器地址:{(string.IsNullOrWhiteSpace(Settings.ServerUrl) ? "(未填)" : Settings.ServerUrl)}",
$"设备令牌:{(string.IsNullOrWhiteSpace(Settings.DeviceToken) ? "(未填)" : Mask(Settings.DeviceToken))}",
$"设备标识:{(string.IsNullOrWhiteSpace(Settings.DeviceKey) ? "(未生成)" : Settings.DeviceKey)}",
$"课程服务:{(Bridge.IsAttached ? "已接入" : "未接入")}",
$"与服务端时钟差:{Bridge.ClientTimeOffsetText}",
$"ClassIsland 版本:{SafeVersion()}",
$"配置目录:{PluginConfigFolder}",
};
return string.Join(Environment.NewLine, lines);
}
}
/// <summary>设置页所在的插件配置目录(从插件入口拿;拿不到时留空)。</summary>
private string PluginConfigFolder =>
ClassIsland.Shared.IAppHost.TryGetService<ClassIsland.Core.Abstractions.PluginBase>()?.PluginConfigFolder ?? "—";
private static string SafeVersion()
{
try { return ClassIsland.Core.AppBase.AppVersion ?? "—"; }
catch { return "—"; }
}
/// <summary>令牌只露头尾,避免设置页截图时把可用凭证泄露出去。</summary>
private static string Mask(string token) =>
token.Length <= 14 ? token : $"{token[..12]}…{token[^4..]}";
/* ---------------------------------------------------------------- 交互 */
private async Task ReportAsync()
{
await Bridge.ReportAsync("手动").ConfigureAwait(true);
}
/// <summary>不带课表上报一次:用来快速验证"地址 + 令牌"是否可用,不会因为课表为空而出错。</summary>
private async Task TestAsync()
{
await Bridge.ReportAsync("连接检查", includeSchedule: false).ConfigureAwait(true);
}
private async Task PullAsync()
{
await Bridge.PullPendingAsync().ConfigureAwait(true);
}
private async Task MirrorAsync()
{
await Bridge.MirrorScheduleAsync().ConfigureAwait(true);
}
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs args)
{
// 影响上报链路的改动需要立刻重建定时器;纯界面项(如"显示调试信息")不用管
if (args.PropertyName is nameof(PluginSettings.ServerUrl)
or nameof(PluginSettings.DeviceToken)
or nameof(PluginSettings.AutoReport)
or nameof(PluginSettings.ReportIntervalSeconds)
or nameof(PluginSettings.ReceiveNotifications)
or nameof(PluginSettings.NotificationPollSeconds))
{
Bridge.RestartTimer();
}
Raise(nameof(StatusText));
Raise(nameof(DebugText));
}
private void OnBridgeChanged(object? sender, PropertyChangedEventArgs args)
{
// Bridge 的属性变化可能来自线程池(HttpClient 回调),绑定必须在 UI 线程上更新
if (Dispatcher.UIThread.CheckAccess())
{
RaiseStateProperties();
}
else
{
Dispatcher.UIThread.Post(RaiseStateProperties);
}
}
private void RaiseStateProperties()
{
Raise(nameof(StatusText));
Raise(nameof(DetailText));
Raise(nameof(DebugText));
}
private void OnLoaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
// 改完地址/令牌/间隔要重建定时器,否则"改了设置但上报节奏还是旧的"
Settings.PropertyChanged += OnSettingsChanged;
// 运行状态(上一次上报结果 / 待弹出提醒数)变了要刷新状态卡片
Bridge.PropertyChanged += OnBridgeChanged;
}
private void OnUnloaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
Settings.PropertyChanged -= OnSettingsChanged;
Bridge.PropertyChanged -= OnBridgeChanged;
}
private void Raise([CallerMemberName] string? propertyName = null) =>
_propertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/desktop-client", "name": "@classhelper/desktop-client",
"version": "1.3.2", "version": "1.3.3",
"private": true, "private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)", "description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper", "author": "Class Helper",
+44 -121
View File
@@ -9,10 +9,17 @@ import { spawn } from 'node:child_process';
import fs from 'node:fs'; import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
import { provisionSmokeClass } from '../../../scripts/lib/smoke-class.mjs';
import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs'; import { resolveElectronEnv, resolveElectronExecutable } from '../../../scripts/lib/electron-env.mjs';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const repoRoot = path.resolve(root, '..', '..'); const repoRoot = path.resolve(root, '..', '..');
const startedAt = Date.now();
const mode = process.argv.includes('--visual') ? 'visual' : (process.env.ELECTRON_SMOKE_MODE ?? 'quick');
if (!['quick', 'visual'].includes(mode)) throw new Error('冒烟模式只能是 quick 或 visual');
const resultFile =
process.env.ELECTRON_SMOKE_RESULT ?? path.join(repoRoot, '.cache', 'desktop-smoke-result.json');
console.log(`[smoke] 模式:${mode === 'visual' ? '视觉验收(会使用桌面)' : '后台快速检查(不占用前台)'}`);
const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron')); const electronPath = resolveElectronExecutable(path.join(root, 'node_modules', 'electron'));
const required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')]; const required = [path.join(root, 'dist/main/index.js'), path.join(root, 'dist/renderer/index.html')];
const missing = required.filter((file) => !fs.existsSync(file)); const missing = required.filter((file) => !fs.existsSync(file));
@@ -37,124 +44,13 @@ try {
} }
console.log(`[smoke] 独立配置目录:${profileDir}`); console.log(`[smoke] 独立配置目录:${profileDir}`);
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试 // 联网验收必须显式指定,避免自动碰到用户正在使用的实例。
let online = process.env.ELECTRON_SMOKE_ONLINE; const online = process.argv.includes('--online') ? '1' : (process.env.ELECTRON_SMOKE_ONLINE ?? '0');
if (online === undefined) {
online = '0';
try {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), 2500);
const response = await fetch('http://127.0.0.1:4000/api/health', { signal: controller.signal });
clearTimeout(timer);
online = response.ok ? '1' : '0';
} catch {
online = '0';
}
console.log(
online === '1'
? '[smoke] 检测到后端在线,将执行联网集成与侧边栏点击测试'
: '[smoke] 后端未在线,仅执行本地检查(离线缓存/界面渲染);如需联网测试请先启动 pnpm dev:server',
);
}
/**
* 准备一个已知可用的班级账号(ClassHelper 班级端已改为班级码 + 班级密码登录):
* 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456),
* 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码,
* 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。
*/
async function provisionClassAccount() {
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
const username = process.env.ELECTRON_SMOKE_ADMIN ?? 'admin';
const adminPassword = process.env.ELECTRON_SMOKE_ADMIN_PASSWORD ?? 'admin123';
const password = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? 'smoke123456';
const CANDIDATES = [...new Set([password, process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456'])];
const tryLogin = async (code, candidate) => {
const response = await fetch(`${base}/auth/class-login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, password: candidate }),
});
return response.ok;
};
try {
const loginResponse = await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: adminPassword }),
});
const login = await loginResponse.json();
const token = login?.data?.token;
if (!token) return null;
const authHeaders = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const classesResponse = await fetch(`${base}/classes`, { headers: authHeaders });
const classes = (await classesResponse.json())?.data ?? [];
const target = classes[0];
if (!target) return null;
// 冒烟**永不修改班级码**(班级码是给学生用的,不应被测试工具改写):
// 只有"现有班级码 + 已知密码全部登录失败"时才重置密码,班级码原样保留。
const code = target.code;
if (!code) return null;
for (const candidate of CANDIDATES) {
if (await tryLogin(code, candidate)) return { code, password: candidate, className: target.name };
}
const patchResponse = await fetch(`${base}/classes/${target.id}/class-account`, {
method: 'PATCH',
headers: authHeaders,
body: JSON.stringify({ code, password }),
});
if (!patchResponse.ok) return null;
const patched = (await patchResponse.json())?.data;
// 记下"需要恢复",脚本收尾时改回种子默认密码(已知密码都不通时无法得知原密码)
patchedAccount = { id: target.id, code: patched?.code ?? code, base, authHeaders };
console.log(
`[smoke] 已知密码均不可用,已把班级 ${target.name} 的密码临时重置为 ${password}` +
`(结束时恢复为 ${CANDIDATES[1]})`,
);
return { code: patched?.code ?? code, password, className: target.name };
} catch {
return null;
}
}
/** 冒烟临时重置过的班级账号(结束时恢复) */
let patchedAccount = null;
/** 恢复被冒烟临时改过的班级密码(失败只告警,不影响冒烟结论) */
async function restoreClassAccount() {
if (!patchedAccount) return;
try {
const response = await fetch(`${patchedAccount.base}/classes/${patchedAccount.id}/class-account`, {
method: 'PATCH',
headers: patchedAccount.authHeaders,
body: JSON.stringify({
code: patchedAccount.code,
password: process.env.ELECTRON_SMOKE_SEED_PASSWORD ?? '123456',
}),
});
console.log(
response.ok
? '[smoke] 已恢复班级密码为种子默认 123456(冒烟不留副作用)'
: `[smoke] [WARN] 班级密码恢复失败:status=${response.status},请到后台重新设置`,
);
} catch (error) {
console.log(`[smoke] [WARN] 班级密码恢复失败:${error?.message ?? error}`);
}
}
let classCredentials = null; let classCredentials = null;
if (online === '1') { if (online === '1') {
classCredentials = await provisionClassAccount(); classCredentials = await provisionSmokeClass();
console.log( if (!classCredentials) throw new Error('无法建立独立验证班级,请检查测试服务端与管理员凭据');
classCredentials console.log('[smoke] 已建立独立验证班级:' + classCredentials.code);
? `[smoke] 班级账号就绪:${classCredentials.code}(${classCredentials.className})`
: '[smoke] 未能准备班级账号,联网集成将使用种子默认凭据',
);
} }
/** 启动一次冒烟(返回退出码) */ /** 启动一次冒烟(返回退出码) */
@@ -163,26 +59,48 @@ function launchSmoke(extraEnv) {
const child = spawn(electronPath, ['.'], { const child = spawn(electronPath, ['.'], {
cwd: root, cwd: root,
stdio: 'inherit', stdio: 'inherit',
windowsHide: true,
env: resolveElectronEnv({ env: resolveElectronEnv({
ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_TEST: '1',
ELECTRON_SMOKE_MODE: mode,
ELECTRON_SMOKE_RESULT: resultFile,
ELECTRON_SMOKE_ONLINE: online, ELECTRON_SMOKE_ONLINE: online,
ELECTRON_SMOKE_PROFILE: profileDir, ELECTRON_SMOKE_PROFILE: profileDir,
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '', ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '', ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
// 教师凭据只从这里传入:主进程的冒烟代码从环境变量读, // 教师凭据只从这里传入:主进程的冒烟代码从环境变量读,
// 因此安装包(app.asar)里不会留下教师口令字面量(本脚本不进安装包) // 因此安装包(app.asar)里不会留下教师口令字面量(本脚本不进安装包)
ELECTRON_SMOKE_USER: process.env.ELECTRON_SMOKE_USER ?? 'teacher1', ELECTRON_SMOKE_USER:
ELECTRON_SMOKE_PASSWORD: process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123', classCredentials?.teacherUsername ?? process.env.ELECTRON_SMOKE_USER ?? 'teacher1',
ELECTRON_SMOKE_PASSWORD:
classCredentials?.teacherPassword ?? process.env.ELECTRON_SMOKE_PASSWORD ?? 'teacher123',
ELECTRON_SMOKE_CLASS_ID: classCredentials?.classId ?? '',
...extraEnv, ...extraEnv,
}), }),
}); });
child.on('exit', (code) => resolve(code ?? 1)); const deadline = setTimeout(
() => {
console.error('[smoke] 超时,结束验证进程');
child.kill();
resolve(124);
},
mode === 'visual' ? 360_000 : 45_000,
);
child.once('exit', (code) => {
clearTimeout(deadline);
resolve(code ?? 1);
});
child.once('error', (error) => {
clearTimeout(deadline);
console.error(error);
resolve(1);
});
}); });
} }
const code = await launchSmoke(); const code = await launchSmoke();
console.log(`\n[smoke] Electron 退出,code=${code}`); console.log(`\n[smoke] Electron 退出,code=${code}`);
await restoreClassAccount(); await classCredentials?.cleanup();
if (code !== 0) process.exit(code); if (code !== 0) process.exit(code);
/** /**
@@ -208,7 +126,11 @@ try {
} }
console.log('\n[smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...'); console.log('\n[smoke] 二次启动校验(单实例锁 / 句柄是否已释放)...');
const relaunchCode = await launchSmoke({ ELECTRON_SMOKE_ONLINE: '0', ELECTRON_SMOKE_RESULT: relaunchResult }); const relaunchCode = await launchSmoke({
ELECTRON_SMOKE_ONLINE: '0',
ELECTRON_SMOKE_MODE: 'quick',
ELECTRON_SMOKE_RESULT: relaunchResult,
});
let relaunchPassed = 0; let relaunchPassed = 0;
let relaunchTotal = 0; let relaunchTotal = 0;
@@ -232,4 +154,5 @@ try {
} catch { } catch {
// 二次启动可能又建了目录,删除失败不影响结论(首次删除已证明锁已释放) // 二次启动可能又建了目录,删除失败不影响结论(首次删除已证明锁已释放)
} }
console.log('[smoke] 总耗时:' + ((Date.now() - startedAt) / 1000).toFixed(1) + ' 秒');
process.exit(resourcesOk ? 0 : 1); process.exit(resourcesOk ? 0 : 1);
@@ -208,7 +208,7 @@ function rowBadgeClass(item: IslandNotification): string {
const itemKind = rowKind(item); const itemKind = rowKind(item);
if (itemKind === 'call') return 'badge-call'; if (itemKind === 'call') return 'badge-call';
if (itemKind === 'homework') return 'badge-homework'; if (itemKind === 'homework') return 'badge-homework';
return `badge-${item.priority.toLowerCase()}`; return `badge-${(item.priority ?? 'NORMAL').toLowerCase()}`;
} }
function rowSubText(item: IslandNotification): string { function rowSubText(item: IslandNotification): string {
@@ -674,37 +674,37 @@ onMounted(async () => {
再随卡片变窄被省略号收回(用户反馈的"新消息那行字往右跳一下再缩回")。 再随卡片变窄被省略号收回(用户反馈的"新消息那行字往右跳一下再缩回")。
--> -->
<span class="pill-inner"> <span class="pill-inner">
<span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }"> <span class="pill-icon" :class="{ urgent: isUrgent, call: isCall, homework: isHomework }">
<svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true"> <svg v-if="isHomework" viewBox="0 0 24 24" aria-hidden="true">
<path <path
fill="currentColor" fill="currentColor"
d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z" d="M6 3h9a3 3 0 0 1 3 3v13.5a.5.5 0 0 1-.75.43L15 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L10 18.5l-2.25 1.43a.5.5 0 0 1-.53 0L5 18.5V5a2 2 0 0 1 1-2Zm2 4v1.6h7V7H8Zm0 3.4V12h7v-1.6H8Z"
/> />
</svg> </svg>
<svg v-else-if="isCall" viewBox="0 0 24 24" aria-hidden="true"> <svg v-else-if="isCall" viewBox="0 0 24 24" aria-hidden="true">
<path <path
fill="currentColor" fill="currentColor"
d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z" d="M4 10v4a1 1 0 0 0 1 1h2l4 3.5a1 1 0 0 0 1.65-.76V5.26A1 1 0 0 0 11 4.5L7 8H5a1 1 0 0 0-1 1Zm12.5-2.9a1 1 0 0 1 1.4.1 8 8 0 0 1 0 9.6 1 1 0 1 1-1.5-1.3 6 6 0 0 0 0-7 1 1 0 0 1 .1-1.4Z"
/> />
</svg> </svg>
<svg v-else viewBox="0 0 24 24" aria-hidden="true"> <svg v-else viewBox="0 0 24 24" aria-hidden="true">
<path <path
fill="currentColor" fill="currentColor"
d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z" d="M12 2a6 6 0 0 0-6 6v3.1L4.6 14a1 1 0 0 0 .9 1.5h13a1 1 0 0 0 .9-1.5L18 11.1V8a6 6 0 0 0-6-6Zm0 20a3 3 0 0 0 3-2.6H9A3 3 0 0 0 12 22Z"
/> />
</svg> </svg>
</span> </span>
<span class="pill-text"> <span class="pill-text">
<span class="pill-title">{{ pillSummaryTitle }}</span> <span class="pill-title">{{ pillSummaryTitle }}</span>
<span class="pill-sub"> <span class="pill-sub">
<template v-if="pendingCount > 1">点击查看</template> <template v-if="pendingCount > 1">点击查看</template>
<template v-else>{{ notification?.teacherName || '老师' }} · 点击查看</template> <template v-else>{{ notification?.teacherName || '老师' }} · 点击查看</template>
</span>
</span>
<!-- 多类型时把类型也做成小圆点,收起状态一眼看出有什么 -->
<span v-if="pendingCount > 1 && pendingTypes.length > 1" class="pill-types" aria-hidden="true">
<span v-for="item in pendingTypes" :key="item" class="pill-type">{{ item }}</span>
</span> </span>
</span>
<!-- 多类型时把类型也做成小圆点,收起状态一眼看出有什么 -->
<span v-if="pendingCount > 1 && pendingTypes.length > 1" class="pill-types" aria-hidden="true">
<span v-for="item in pendingTypes" :key="item" class="pill-type">{{ item }}</span>
</span>
<span class="pill-chevron" aria-hidden="true"> <span class="pill-chevron" aria-hidden="true">
<svg viewBox="0 0 24 24"> <svg viewBox="0 0 24 24">
<path <path
+103 -21
View File
@@ -4,7 +4,12 @@ import fsp from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { BrowserWindow, app } from 'electron'; import { BrowserWindow, app } from 'electron';
import { CLIENT_UPDATE_IDLE_MS, type ClientUpdateInfoDto } from '@classhelper/shared'; import {
isNewerVersion,
CLIENT_UPDATE_IDLE_MS,
type ClientHeartbeatResponse,
type ClientUpdateInfoDto,
} from '@classhelper/shared';
import { getConfig, saveConfig } from './config.js'; import { getConfig, saveConfig } from './config.js';
import { island } from './island.js'; import { island } from './island.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -13,16 +18,58 @@ import { logger } from './logger.js';
* 服务端统一下发的自动更新(下载 + 空闲时静默安装)。 * 服务端统一下发的自动更新(下载 + 空闲时静默安装)。
* *
* 设计要点(都是"教室机器"这个使用场景逼出来的): * 设计要点(都是"教室机器"这个使用场景逼出来的):
* 1. **下载随时进行、安装只在完全空闲时**:教室机的带宽与注意力都有限,上课期间重启应用是灾难, * 1. **下载随时进行,按下发策略安装**:立即安装;放学后到指定时刻且不在上课时安装;
* 所以安装的三个必要条件缺一不可 —— 不在上课、主窗口已隐藏满 `CLIENT_UPDATE_IDLE_MS`(5 分钟)、 * 空闲安装要求不在上课且主窗口隐藏满五分钟;
* 本进程还没装过;
* 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行** * 2. **下完先校验 sha256**:服务端给的哈希对不上就删掉文件、记 error,**不做任何信任放行**
* (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里); * (宁可这次更新失败,也不能把一个来源不明的 exe 装到教室里);
* 3. **安装脚本脱离本进程**:`start /wait` 等安装器跑完后重新拉起应用, * 3. **安装脚本脱离本进程**:PowerShell 等旧进程退出、安装器完成后重新拉起应用,
* 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`, * 本进程随即走既有退出路径退出(重启应用这件事交给安装脚本,而不是自己 `relaunch`,
* 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。 * 否则会与"安装器正在覆盖自己"撞车 —— Windows 上正在运行的程序文件是删不掉的)。
*/ */
let policy: Pick<ClientHeartbeatResponse, 'updateTargetVersion' | 'updateMode' | 'updateNotBefore'> | null =
null;
function policyPath(): string {
return path.join(pendingUpdateDir(), 'policy.json');
}
function loadPolicy(): typeof policy {
if (policy) return policy;
try {
policy = JSON.parse(fs.readFileSync(policyPath(), 'utf8'));
} catch {
/* 尚无下发策略 */
}
return policy;
}
/** 更新包只来自专用目录;清理与执行均不得接受目录外的配置路径。 */
function isManagedInstaller(file: string): boolean {
return path.dirname(path.resolve(file)) === path.resolve(pendingUpdateDir()) && /\.exe$/i.test(file);
}
function removeManagedInstaller(file: string): void {
if (!file || !isManagedInstaller(file)) return;
try {
fs.rmSync(file, { force: true });
} catch (error) {
logger.warn(`清理旧更新包失败:${String(error)}`);
}
}
/** 每次成功心跳先同步策略;取消或更换目标后旧包不得自行安装。 */
export function syncUpdatePolicy(next: ClientHeartbeatResponse): void {
policy = {
updateTargetVersion: next.updateTargetVersion,
updateMode: next.updateMode ?? 'idle',
updateNotBefore: next.updateNotBefore ?? null,
};
fs.mkdirSync(pendingUpdateDir(), { recursive: true });
fs.writeFileSync(policyPath(), JSON.stringify(policy), 'utf8');
if (getConfig().pendingUpdateVersion !== (next.updateTargetVersion ?? '')) {
removeManagedInstaller(getConfig().pendingUpdateFile);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
}
}
/** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */ /** 单个安装包的下载超时:教室机网络慢,给足 10 分钟 */
const DOWNLOAD_TIMEOUT_MS = 10 * 60_000; const DOWNLOAD_TIMEOUT_MS = 10 * 60_000;
@@ -77,7 +124,7 @@ export function watchMainWindow(win: BrowserWindow): void {
mainWindowHiddenAt = null; mainWindowHiddenAt = null;
if (hookedWindow === win) hookedWindow = null; if (hookedWindow === win) hookedWindow = null;
}); });
if (win.isVisible()) mainWindowHiddenAt = null; mainWindowHiddenAt = win.isVisible() ? null : Date.now();
} }
/** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */ /** 主窗口是否已经隐藏满 `CLIENT_UPDATE_IDLE_MS` */
@@ -187,11 +234,15 @@ export async function considerUpdate(info: ClientUpdateInfoDto, targetVersion: s
await fsp.rm(target, { force: true }); await fsp.rm(target, { force: true });
await fsp.rename(temp, target); await fsp.rename(temp, target);
if (loadPolicy()?.updateTargetVersion !== version) {
removeManagedInstaller(target);
return;
}
saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target }); saveConfig({ pendingUpdateVersion: version, pendingUpdateFile: target });
logger.info( logger.info(
`更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round( `更新 ${version} 已下载并校验通过(${formatSize(fs.statSync(target).size)},耗时 ${Math.round(
(Date.now() - startedAt) / 1000, (Date.now() - startedAt) / 1000,
)} 秒),将在空闲时自动安装`, )} 秒),将按服务端指定的时机安装`,
); );
await maybeInstallPendingUpdate(); await maybeInstallPendingUpdate();
} catch (error) { } catch (error) {
@@ -230,9 +281,8 @@ function formatSize(bytes: number): string {
/** /**
* 如果条件全部满足就装更新。 * 如果条件全部满足就装更新。
* *
* 条件(缺一不可):非冒烟模式 / 配置里有待安装版本 / 文件还在 / 不在上课 / * 非冒烟模式、有有效目标与安装包且自动更新开启;其余条件按安装策略判断。
* 主窗口已隐藏满 5 分钟 / 本进程没装过。**每次心跳后与主窗口 hide 后都会调用**, * 每次心跳后与主窗口隐藏后都会调用。
* 所以"老师下课关掉窗口 → 5 分钟后自动装上"就是靠这里触发的。
*/ */
export async function maybeInstallPendingUpdate(): Promise<void> { export async function maybeInstallPendingUpdate(): Promise<void> {
if (installing) return; if (installing) return;
@@ -241,21 +291,40 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
const config = getConfig(); const config = getConfig();
const version = config.pendingUpdateVersion?.trim() ?? ''; const version = config.pendingUpdateVersion?.trim() ?? '';
const installer = config.pendingUpdateFile?.trim() ?? ''; const installer = config.pendingUpdateFile?.trim() ?? '';
if (!version || !installer) return; if (!version || !installer || config.autoUpdate === false || !config.token) return;
if (!isNewerVersion(app.getVersion(), version)) {
removeManagedInstaller(installer);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
const active = loadPolicy();
if (!active || active.updateTargetVersion !== version) return;
if (active.updateMode === 'after-school') {
const at = Date.parse(active.updateNotBefore ?? '');
if (!Number.isFinite(at) || Date.now() < at) return;
}
if (!fs.existsSync(installer)) { if (!fs.existsSync(installer)) {
logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`); logger.warn(`待安装的更新 ${version} 已不存在(${installer}),清除待安装记录`);
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' }); saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return; return;
} }
if (island.getState().inClass) { if (
!isManagedInstaller(installer) ||
path.dirname(fs.realpathSync(installer)) !== fs.realpathSync(pendingUpdateDir())
) {
logger.error('拒绝执行更新目录之外的安装文件');
saveConfig({ pendingUpdateVersion: '', pendingUpdateFile: '' });
return;
}
if (active.updateMode !== 'immediate' && island.getState().inClass) {
logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`); logger.info(`更新 ${version} 已就绪,但正在上课,暂不安装`);
return; return;
} }
if (mainWindowVisible()) { if (active.updateMode === 'idle' && mainWindowVisible()) {
logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`); logger.debug(`更新 ${version} 已就绪,但主窗口可见(人正在用),暂不安装`);
return; return;
} }
if (!mainWindowIdleLongEnough()) { if (active.updateMode === 'idle' && !mainWindowIdleLongEnough()) {
const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000); const waited = mainWindowHiddenAt === null ? 0 : Math.round((Date.now() - mainWindowHiddenAt) / 1000);
logger.debug( logger.debug(
`更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` + `更新 ${version} 已就绪,但主窗口隐藏仅 ${waited} 秒` +
@@ -276,14 +345,27 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
logger.info(`开始静默安装更新 ${version}:${installer}`); logger.info(`开始静默安装更新 ${version}:${installer}`);
try { try {
const exe = app.getPath('exe'); const exe = app.getPath('exe');
const command = `start "" /wait "${installer}" /S && start "" "${exe}"`; const quote = (value: string): string => "'" + value.replaceAll("'", "''") + "'";
const child = spawn('cmd.exe', ['/c', command], { const script =
detached: true, `$ErrorActionPreference = 'Stop'; Wait-Process -Id ${process.pid} -ErrorAction SilentlyContinue; ` +
stdio: 'ignore', `$installer = Start-Process -FilePath ${quote(installer)} -ArgumentList '/S','--updated','--force-run' -Wait -PassThru; ` +
windowsHide: true, `if ($installer.ExitCode -ne 0 -and (Test-Path -LiteralPath ${quote(exe)})) { Start-Process -FilePath ${quote(exe)} }`;
const encoded = Buffer.from(script, 'utf16le').toString('base64');
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-WindowStyle', 'Hidden', '-EncodedCommand', encoded],
{
detached: true,
stdio: 'ignore',
windowsHide: true,
},
);
await new Promise<void>((resolve, reject) => {
child.once('spawn', resolve);
child.once('error', reject);
}); });
child.unref(); child.unref();
// 安装脚本会等安装器结束后重新拉起应用;本进程随即退出(复用既有退出路径,保证配置已落盘) // 安装器从实际安装目录启动新版;免安装版的旧 exe 可能随退出消失,只在安装失败时尝试恢复它。
if (quitHandler) { if (quitHandler) {
quitHandler(); quitHandler();
} else { } else {
@@ -293,7 +375,7 @@ export async function maybeInstallPendingUpdate(): Promise<void> {
} catch (error) { } catch (error) {
installing = false; installing = false;
logger.error( logger.error(
`启动更新安装失败(将在下次空闲时重试):${error instanceof Error ? error.message : String(error)}`, `启动更新安装失败(将在下次满足安装条件时重试):${error instanceof Error ? error.message : String(error)}`,
); );
} }
} }
@@ -1,17 +1,20 @@
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import os from 'node:os'; import os from 'node:os';
import fs from 'node:fs';
import path from 'node:path';
import { BrowserWindow, app, dialog } from 'electron'; import { BrowserWindow, app, dialog } from 'electron';
import { import {
CLIENT_COMMAND_LABELS, CLIENT_COMMAND_LABELS,
CLIENT_HEARTBEAT_INTERVAL_MS, CLIENT_HEARTBEAT_INTERVAL_MS,
isNewerVersion, isNewerVersion,
type ClientCommandDto, type ClientCommandDto,
type ApiResponse,
type ClientCommandKind, type ClientCommandKind,
type ClientHeartbeatRequest, type ClientHeartbeatRequest,
type ClientHeartbeatResponse, type ClientHeartbeatResponse,
type ClientUpdateInfoDto, type ClientUpdateInfoDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { maybeInstallPendingUpdate, considerUpdate } from './auto-update.js'; import { maybeInstallPendingUpdate, considerUpdate, syncUpdatePolicy } from './auto-update.js';
import { getConfig } from './config.js'; import { getConfig } from './config.js';
import { island } from './island.js'; import { island } from './island.js';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -21,7 +24,7 @@ import { logger } from './logger.js';
* *
* **为什么是"客户端定时拉"而不是服务端推**:教室机的连接经常断(代理、休眠、重启), * **为什么是"客户端定时拉"而不是服务端推**:教室机的连接经常断(代理、休眠、重启),
* 而「这台机器是什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上 —— 所以统一用 * 而「这台机器是什么版本、要不要更新、有没有待执行命令」必须下次上线就能补上 —— 所以统一用
* 「服务端记状态 + 客户端定时上报」的范式(与 ClassIsland 插件的上报同一套路),不引入新的长连接握手。 * 「服务端记状态 + 客户端定时上报」的范式,不引入新的长连接握手。
* 每次上报的响应里同时带回「服务端指定的目标版本 + 待执行命令」。 * 每次上报的响应里同时带回「服务端指定的目标版本 + 待执行命令」。
* *
* 三条边界: * 三条边界:
@@ -111,7 +114,7 @@ function isAgentSuppressed(): boolean {
* 上报里的"这台机器现在在干什么"。 * 上报里的"这台机器现在在干什么"。
* *
* 取值就三种(服务端设备列表直接展示这段文字): * 取值就三种(服务端设备列表直接展示这段文字):
* - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(此时绝不安装更新); * - `上课中` —— 灵动岛按渲染进程上报的课表状态判定正在上课(延后策略此时不安装更新);
* - `使用中` —— 主窗口可见,人正在用; * - `使用中` —— 主窗口可见,人正在用;
* - `待机` —— 窗口隐藏(最小化到托盘),机器空闲。 * - `待机` —— 窗口隐藏(最小化到托盘),机器空闲。
*/ */
@@ -172,10 +175,12 @@ async function beat(): Promise<void> {
let backoff = false; let backoff = false;
try { try {
const body: ClientHeartbeatRequest = { const body: ClientHeartbeatRequest = {
agentProtocol: 2,
deviceKey: config.deviceKey, deviceKey: config.deviceKey,
name: os.hostname(), name: os.hostname(),
clientVersion: app.getVersion(), clientVersion: app.getVersion(),
platform: `${process.platform}-${process.arch}`, platform: `${process.platform}-${process.arch}`,
autoUpdate: config.autoUpdate !== false,
pendingVersion: pendingVersionOrNull(), pendingVersion: pendingVersionOrNull(),
stateNote: currentStateNote(), stateNote: currentStateNote(),
}; };
@@ -202,7 +207,9 @@ async function beat(): Promise<void> {
return; return;
} }
const payload = (await response.json()) as ClientHeartbeatResponse; const envelope = (await response.json()) as ApiResponse<ClientHeartbeatResponse>;
if (!envelope.success || !envelope.data?.deviceId) throw new Error('心跳响应格式不正确');
const payload = envelope.data;
state.lastHeartbeatAt = new Date().toISOString(); state.lastHeartbeatAt = new Date().toISOString();
state.lastError = ''; state.lastError = '';
state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? ''; state.updateTargetVersion = payload.updateTargetVersion?.trim() ?? '';
@@ -211,15 +218,19 @@ async function beat(): Promise<void> {
`${state.updateTargetVersion ? `,服务端要求升到 ${state.updateTargetVersion}` : ''})`, `${state.updateTargetVersion ? `,服务端要求升到 ${state.updateTargetVersion}` : ''})`,
); );
void handleCommand(payload.command); void handleCommand(payload.command).catch((error) => logger.error(`远控处理失败:${String(error)}`));
await handleUpdate(payload.update, payload.updateTargetVersion); // 下载不能阻塞心跳,否则慢速下载期间远控和在线状态都会失效。
syncUpdatePolicy(payload);
void handleUpdate(payload.update, payload.updateTargetVersion).catch((error) =>
logger.error(`更新处理失败:${String(error)}`),
);
} catch (error) { } catch (error) {
// 网络错误 / 超时 / JSON 解析失败:按原节奏重试 // 网络错误 / 超时 / JSON 解析失败:按原节奏重试
state.lastError = error instanceof Error ? error.message : String(error); state.lastError = error instanceof Error ? error.message : String(error);
logger.warn(`客户端上报失败(将于 ${HEARTBEAT_INTERVAL_TEXT} 后重试):${state.lastError}`); logger.warn(`客户端上报失败(将于 ${HEARTBEAT_INTERVAL_TEXT} 后重试):${state.lastError}`);
} finally { } finally {
state.beating = false; state.beating = false;
// 每次心跳(无论成败)都顺手看一下有没有下载好的更新该装了 —— 上课/窗口可见时会自动跳过 // 每次心跳后检查已下载的更新是否满足安装策略。
void maybeInstallPendingUpdate(); void maybeInstallPendingUpdate();
scheduleNext(backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS); scheduleNext(backoff ? UNAUTHORIZED_BACKOFF_MS : CLIENT_HEARTBEAT_INTERVAL_MS);
} }
@@ -233,14 +244,45 @@ const HEARTBEAT_INTERVAL_TEXT = `${Math.round(CLIENT_HEARTBEAT_INTERVAL_MS / 100
* 两道护栏缺一不可(用户要求「必须二次确认」): * 两道护栏缺一不可(用户要求「必须二次确认」):
* 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 —— * 1. **本机二次确认**:弹一个默认选中「取消」的对话框,60 秒不确认按取消处理 ——
* 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道; * 服务端那边虽然也确认过,但真正会被关掉的机器上必须有人当场知道;
* 2. **同一条命令只弹一次**:它随每次心跳重复下发,用内存集合去重,避免 30 秒弹一个框。 * 2. **同一条命令只执行一次**:等待确认时内存去重,处理结果落盘,重启后也只重试回执。
* *
* 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey): * 执行后**必须回执**(`POST /api/clients/commands/:id/ack`,body 带 deviceKey):
* 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存, * 否则服务端那条命令会一直挂在"待执行"里,Web 端看不到结果。回执失败时把结果暂存,
* 下一次心跳只重试回执、不重复弹窗。 * 下一次心跳只重试回执、不重复弹窗。
*/ */
const inFlightCommands = new Set<string>(); const inFlightCommands = new Set<string>();
const unackedResults = new Map<string, { ok: boolean; result: string }>(); type CommandOutcome = { ok: boolean; result: string };
const commandResults = new Map<string, CommandOutcome>();
let resultsLoaded = false;
function commandResultsPath(): string {
return path.join(app.getPath('userData'), 'command-results.json');
}
function readCommandResults(): void {
if (resultsLoaded) return;
try {
const entries: unknown = JSON.parse(fs.readFileSync(commandResultsPath(), 'utf8'));
if (Array.isArray(entries))
for (const entry of entries.slice(-200)) {
if (
Array.isArray(entry) &&
typeof entry[0] === 'string' &&
typeof entry[1]?.ok === 'boolean' &&
typeof entry[1]?.result === 'string'
)
commandResults.set(entry[0], entry[1]);
}
} catch {
/* 首次运行尚无记录 */
}
resultsLoaded = true;
}
function rememberCommand(commandId: string, outcome: CommandOutcome): void {
readCommandResults();
commandResults.set(commandId, outcome);
while (commandResults.size > 200) commandResults.delete(commandResults.keys().next().value!);
fs.mkdirSync(app.getPath('userData'), { recursive: true });
fs.writeFileSync(commandResultsPath(), JSON.stringify([...commandResults]), 'utf8');
}
async function handleCommand(command: ClientCommandDto | null): Promise<void> { async function handleCommand(command: ClientCommandDto | null): Promise<void> {
if (!command) return; if (!command) return;
@@ -255,15 +297,15 @@ async function handleCommand(command: ClientCommandDto | null): Promise<void> {
return; return;
} }
// 正在确认或执行时不能用预存的中间结果抢先回执。
if (inFlightCommands.has(command.id)) return;
// 处理过但回执没送达:只重试回执 // 处理过但回执没送达:只重试回执
const pending = unackedResults.get(command.id); readCommandResults();
const pending = commandResults.get(command.id);
if (pending) { if (pending) {
await ackCommand(command.id, pending.ok, pending.result); await ackCommand(command.id, pending.ok, pending.result);
return; return;
} }
// 正在等本机确认:不要再弹第二个框
if (inFlightCommands.has(command.id)) return;
inFlightCommands.add(command.id); inFlightCommands.add(command.id);
try { try {
const confirmed = await confirmCommand(label, command.payload); const confirmed = await confirmCommand(label, command.payload);
@@ -273,6 +315,11 @@ async function handleCommand(command: ClientCommandDto | null): Promise<void> {
await ackCommand(command.id, false, result); await ackCommand(command.id, false, result);
return; return;
} }
// 注销可能立刻结束本进程,先落盘,重启后不能再次执行同一条指令。
rememberCommand(command.id, {
ok: false,
result: '本机已确认并提交系统执行,最终结果未回传,请核对设备状态',
});
const outcome = await executeCommand(command.kind); const outcome = await executeCommand(command.kind);
logger.warn(`远控命令「${label}」执行${outcome.ok ? '成功' : '失败'}:${outcome.result}`); logger.warn(`远控命令「${label}」执行${outcome.ok ? '成功' : '失败'}:${outcome.result}`);
await ackCommand(command.id, outcome.ok, outcome.result); await ackCommand(command.id, outcome.ok, outcome.result);
@@ -283,8 +330,11 @@ async function handleCommand(command: ClientCommandDto | null): Promise<void> {
/** 本机二次确认对话框:默认「取消」,60 秒未操作按取消处理 */ /** 本机二次确认对话框:默认「取消」,60 秒未操作按取消处理 */
async function confirmCommand(label: string, note: string): Promise<boolean> { async function confirmCommand(label: string, note: string): Promise<boolean> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), COMMAND_CONFIRM_TIMEOUT_MS);
const answered = dialog const answered = dialog
.showMessageBox({ .showMessageBox({
signal: controller.signal,
type: 'warning', type: 'warning',
title: '来自管理员的远程指令', title: '来自管理员的远程指令',
message: `管理员要求本机「${label}」`, message: `管理员要求本机「${label}」`,
@@ -294,13 +344,14 @@ async function confirmCommand(label: string, note: string): Promise<boolean> {
cancelId: 0, cancelId: 0,
noLink: true, noLink: true,
}) })
.then((result) => result.response === 1) .then((result) => !controller.signal.aborted && result.response === 1)
.catch(() => false); .catch(() => false);
return Promise.race([ try {
answered, return await answered;
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), COMMAND_CONFIRM_TIMEOUT_MS)), } finally {
]); clearTimeout(timer);
}
} }
/** 执行系统命令:只支持 Windows(本项目客户端只发 Windows 版) */ /** 执行系统命令:只支持 Windows(本项目客户端只发 Windows 版) */
@@ -308,32 +359,31 @@ async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; r
if (process.platform !== 'win32') return { ok: false, result: '仅支持 Windows' }; if (process.platform !== 'win32') return { ok: false, result: '仅支持 Windows' };
const commands: Record<ClientCommandKind, { file: string; args: string[] }> = { const commands: Record<ClientCommandKind, { file: string; args: string[] }> = {
shutdown: { file: 'shutdown.exe', args: ['/s', '/t', '0'] }, shutdown: { file: 'shutdown.exe', args: ['/s', '/t', '5'] },
restart: { file: 'shutdown.exe', args: ['/r', '/t', '0'] }, restart: { file: 'shutdown.exe', args: ['/r', '/t', '5'] },
logoff: { file: 'shutdown.exe', args: ['/l'] }, logoff: { file: 'shutdown.exe', args: ['/l'] },
lock: { file: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] }, lock: { file: 'rundll32.exe', args: ['user32.dll,LockWorkStation'] },
}; };
const plan = commands[kind]; const plan = commands[kind];
try { try {
// detached + unref:不能等它退出(关机/注销会把本进程一起带走) // 关机/重启预留 5 秒用于回执;检查系统命令进程的真实退出码。
const child = spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true }); const child = spawn(plan.file, plan.args, { detached: true, stdio: 'ignore', windowsHide: true });
/** /**
* **必须监听 `error` 事件**:`spawn` 找不到可执行文件(或没权限)时不是抛异常,而是在 * **必须监听 `error` 事件**:`spawn` 找不到可执行文件(或没权限)时不是抛异常,而是在
* next tick 上发一个 `error` 事件 —— 只包 `try/catch` 的话,这种情况会一路走到 * next tick 上发一个 `error` 事件 —— 只包 `try/catch` 的话,这种情况会一路走到
* `return { ok: true }`,回执写着"已下达系统命令",Web 端看起来一切正常,机器却纹丝不动。 * `return { ok: true }`,回执写着"已下达系统命令",Web 端看起来一切正常,机器却纹丝不动。
* 等一个很短的窗口(200ms)确认没有立刻报错;真执行成功时子进程本来也要跑一会儿。 * 同时检查非零退出码,系统拒绝指令时不能回报成功。
*/ */
const failure = await new Promise<string | null>((resolve) => { return await new Promise((resolve) => {
child.once('error', (error) => resolve(error.message)); child.once('error', (error) => resolve({ ok: false, result: error.message }));
setTimeout(() => resolve(null), 200); child.once('exit', (code) =>
resolve({
ok: code === 0,
result: code === 0 ? '系统已接受指令' : `系统命令执行失败(退出码 ${code ?? '未知'})`,
}),
);
}); });
child.unref();
if (failure) {
logger.warn(`远控命令「${CLIENT_COMMAND_LABELS[kind] ?? kind}」无法执行:${failure}`);
return { ok: false, result: failure };
}
return { ok: true, result: '已下达系统命令' };
} catch (error) { } catch (error) {
return { ok: false, result: error instanceof Error ? error.message : String(error) }; return { ok: false, result: error instanceof Error ? error.message : String(error) };
} }
@@ -341,6 +391,7 @@ async function executeCommand(kind: ClientCommandKind): Promise<{ ok: boolean; r
/** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */ /** 回执:服务端用 body 里的 deviceKey 校验"这条命令是发给这台机器的" */
async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> { async function ackCommand(commandId: string, ok: boolean, result: string): Promise<void> {
rememberCommand(commandId, { ok, result });
const config = getConfig(); const config = getConfig();
if (!config.token) return; if (!config.token) return;
try { try {
@@ -352,16 +403,13 @@ async function ackCommand(commandId: string, ok: boolean, result: string): Promi
}); });
if (!response.ok) { if (!response.ok) {
logger.warn(`远控命令回执失败(HTTP ${response.status}),将在下次心跳重试`); logger.warn(`远控命令回执失败(HTTP ${response.status}),将在下次心跳重试`);
unackedResults.set(commandId, { ok, result });
return; return;
} }
unackedResults.delete(commandId);
logger.info(`远控命令回执已送达(${ok ? '成功' : '失败'}):${result}`); logger.info(`远控命令回执已送达(${ok ? '成功' : '失败'}):${result}`);
} catch (error) { } catch (error) {
logger.warn( logger.warn(
`远控命令回执失败(${error instanceof Error ? error.message : String(error)}),将在下次心跳重试`, `远控命令回执失败(${error instanceof Error ? error.message : String(error)}),将在下次心跳重试`,
); );
unackedResults.set(commandId, { ok, result });
} }
} }
+1 -21
View File
@@ -3,14 +3,11 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { app, safeStorage } from 'electron'; import { app, safeStorage } from 'electron';
import { import {
CLASSISLAND_NOTIFICATION_CHANNELS,
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
DEFAULT_ISLAND_APPEARANCE, DEFAULT_ISLAND_APPEARANCE,
DEFAULT_SERVER_URL, DEFAULT_SERVER_URL,
HOMEWORK_PHRASE_DEFAULTS, HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT, HOMEWORK_PHRASE_MAX_COUNT,
HOMEWORK_PHRASE_MAX_LENGTH, HOMEWORK_PHRASE_MAX_LENGTH,
type ClassIslandNotificationChannel,
type IslandAppearance, type IslandAppearance,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { logger } from './logger.js'; import { logger } from './logger.js';
@@ -33,8 +30,6 @@ interface PersistedConfig {
island?: Partial<IslandAppearance>; island?: Partial<IslandAppearance>;
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */ /** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
homeworkBoard?: Partial<HomeworkBoardSettings>; homeworkBoard?: Partial<HomeworkBoardSettings>;
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
notificationChannel?: ClassIslandNotificationChannel;
/** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */ /** 作业录入快捷短语(向后兼容:旧配置没有该字段时用默认那一组) */
homeworkPhrases?: string[]; homeworkPhrases?: string[];
/** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */ /** 是否已看过初次启动引导(向后兼容:旧配置没有该字段视为没看过,首启弹一次) */
@@ -68,7 +63,6 @@ const DEFAULT_CONFIG: PersistedConfig = {
tokenEncrypted: false, tokenEncrypted: false,
island: { ...DEFAULT_ISLAND_APPEARANCE }, island: { ...DEFAULT_ISLAND_APPEARANCE },
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD }, homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS], homeworkPhrases: [...HOMEWORK_PHRASE_DEFAULTS],
onboardingDone: false, onboardingDone: false,
theme: 'light', theme: 'light',
@@ -107,15 +101,6 @@ function normalizeHomeworkPhrases(input?: string[]): string[] {
return result; return result;
} }
/** 通知显示位置的白名单校验:非法值一律回落到默认值,避免坏配置把提醒"静默吞掉" */
function normalizeNotificationChannel(
input?: ClassIslandNotificationChannel,
): ClassIslandNotificationChannel {
return CLASSISLAND_NOTIFICATION_CHANNELS.includes(input as ClassIslandNotificationChannel)
? (input as ClassIslandNotificationChannel)
: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL;
}
/** 作业页偏好的合法化(模式白名单、字号夹紧) */ /** 作业页偏好的合法化(模式白名单、字号夹紧) */
function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings { function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings {
const mode = input?.mode === 'list' ? 'list' : 'board'; const mode = input?.mode === 'list' ? 'list' : 'board';
@@ -218,7 +203,6 @@ function readPersistedFromDisk(): PersistedConfig {
tokenEncrypted: parsed.tokenEncrypted === true, tokenEncrypted: parsed.tokenEncrypted === true,
island: normalizeIslandAppearance(parsed.island), island: normalizeIslandAppearance(parsed.island),
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard), homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases), homeworkPhrases: normalizeHomeworkPhrases(parsed.homeworkPhrases),
onboardingDone: parsed.onboardingDone === true, onboardingDone: parsed.onboardingDone === true,
theme: normalizeTheme(parsed.theme), theme: normalizeTheme(parsed.theme),
@@ -381,7 +365,6 @@ function toDesktopConfig(state: ConfigCache): DesktopStoredConfig {
token: state.token, token: state.token,
island: normalizeIslandAppearance(persisted.island), island: normalizeIslandAppearance(persisted.island),
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard), homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases), homeworkPhrases: normalizeHomeworkPhrases(persisted.homeworkPhrases),
onboardingDone: persisted.onboardingDone === true, onboardingDone: persisted.onboardingDone === true,
theme: normalizeTheme(persisted.theme), theme: normalizeTheme(persisted.theme),
@@ -463,9 +446,6 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.homeworkPhrases !== undefined) { if (patch.homeworkPhrases !== undefined) {
persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases); persisted.homeworkPhrases = normalizeHomeworkPhrases(patch.homeworkPhrases);
} }
if (patch.notificationChannel !== undefined) {
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
}
// 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态, // 引导只接受"已看过":重看入口不走这里(看过与否只有 true 与"没看过"两种状态,
// 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。 // 想 re-arm 只能清配置 clearConfig)。传 false 一律忽略,避免任何调用点误把用户退回首启状态。
if (patch.onboardingDone === true) { if (patch.onboardingDone === true) {
@@ -493,7 +473,7 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
if (patch.careMode !== undefined) { if (patch.careMode !== undefined) {
persisted.careMode = patch.careMode === true; persisted.careMode = patch.careMode === true;
} }
// 待安装的更新只由主进程的 auto-update 模块写入;渲染进程传进来也照收(退出登录/重置走 clearConfig) // 仅供主进程更新模块写入;IPC 层会剔除这两个字段。
if (typeof patch.pendingUpdateVersion === 'string') { if (typeof patch.pendingUpdateVersion === 'string') {
persisted.pendingUpdateVersion = patch.pendingUpdateVersion; persisted.pendingUpdateVersion = patch.pendingUpdateVersion;
} }
@@ -10,8 +10,7 @@ import { logger } from './logger.js';
* *
* 1. `render-process-gone` —— 渲染进程没了(崩溃 / 被杀),**静默重建窗口**; * 1. `render-process-gone` —— 渲染进程没了(崩溃 / 被杀),**静默重建窗口**;
* 2. `unresponsive` —— 界面卡住超过 10 秒还没缓过来,同样重建;期间恢复(`responsive`)就取消; * 2. `unresponsive` —— 界面卡住超过 10 秒还没缓过来,同样重建;期间恢复(`responsive`)就取消;
* 3. `uncaughtException` / `unhandledRejection` —— 主进程的未捕获错误, * 3. `uncaughtException` 只在本机空闲时重启;普通 `unhandledRejection` 仅记录堆栈。
* 记完整堆栈,并且**只有在本机确认空闲时**才重启自己。
* *
* 两条限流(照抄灵动岛 supervisor 的口径,避免"崩溃 → 重建 → 立刻又崩"死循环刷日志): * 两条限流(照抄灵动岛 supervisor 的口径,避免"崩溃 → 重建 → 立刻又崩"死循环刷日志):
* **10 分钟内最多 3 次**,超了只记日志、停下来等人工处理。 * **10 分钟内最多 3 次**,超了只记日志、停下来等人工处理。
@@ -27,7 +26,7 @@ const UNRESPONSIVE_GRACE_MS = 10_000;
export interface CrashGuardOptions { export interface CrashGuardOptions {
/** 销毁并重建主窗口(index.ts 里复用原有的"建窗 + 载入界面"逻辑) */ /** 销毁并重建主窗口(index.ts 里复用原有的"建窗 + 载入界面"逻辑) */
recoverMainWindow: () => void; recoverMainWindow: () => void | Promise<void>;
/** 本机是否空闲(不在上课且主窗口不可见)—— 决定主进程崩溃后能不能自动重启 */ /** 本机是否空闲(不在上课且主窗口不可见)—— 决定主进程崩溃后能不能自动重启 */
isIdle: () => boolean; isIdle: () => boolean;
/** 退出应用(复用既有的退出路径,保证配置落盘) */ /** 退出应用(复用既有的退出路径,保证配置落盘) */
@@ -56,7 +55,7 @@ export function installCrashGuard(guard: CrashGuardOptions): void {
logger.error( logger.error(
`主窗口渲染进程异常退出:reason=${details.reason} exitCode=${details.exitCode}(准备重建窗口)`, `主窗口渲染进程异常退出:reason=${details.reason} exitCode=${details.exitCode}(准备重建窗口)`,
); );
recoverWindow(`渲染进程退出(${details.reason})`); void recoverWindow(`渲染进程退出(${details.reason})`);
}); });
app.on('child-process-gone', (_event, details) => { app.on('child-process-gone', (_event, details) => {
@@ -72,7 +71,6 @@ export function installCrashGuard(guard: CrashGuardOptions): void {
process.on('unhandledRejection', (reason) => { process.on('unhandledRejection', (reason) => {
logger.error('主进程未处理的 Promise 拒绝:', reason); logger.error('主进程未处理的 Promise 拒绝:', reason);
restartIfIdle('unhandledRejection');
}); });
} }
@@ -107,18 +105,17 @@ function armUnresponsiveTimer(): void {
unresponsiveTimer = setTimeout(() => { unresponsiveTimer = setTimeout(() => {
unresponsiveTimer = null; unresponsiveTimer = null;
if (!mainWindow || mainWindow.isDestroyed()) return; if (!mainWindow || mainWindow.isDestroyed()) return;
recoverWindow('界面无响应超过 10 秒'); void recoverWindow('界面无响应超过 10 秒');
}, UNRESPONSIVE_GRACE_MS); }, UNRESPONSIVE_GRACE_MS);
} }
function isMainWindowContents(id: number): boolean { function isMainWindowContents(id: number): boolean {
// 主窗口引用还没建立(启动早期)时按"就是它"处理,避免漏掉重建 if (!mainWindow || mainWindow.isDestroyed()) return false;
if (!mainWindow || mainWindow.isDestroyed()) return true;
return mainWindow.webContents.id === id; return mainWindow.webContents.id === id;
} }
/** 静默重建主窗口(限流) */ /** 静默重建主窗口(限流) */
function recoverWindow(reason: string): void { async function recoverWindow(reason: string): Promise<void> {
const recover = options?.recoverMainWindow; const recover = options?.recoverMainWindow;
if (recovering) return; if (recovering) return;
if (!recover) { if (!recover) {
@@ -145,7 +142,7 @@ function recoverWindow(reason: string): void {
win.hide(); win.hide();
} }
logger.warn(`开始重建主窗口(${reason},本窗口期第 ${windowRecoverAt.length} 次)`); logger.warn(`开始重建主窗口(${reason},本窗口期第 ${windowRecoverAt.length} 次)`);
recover(); await recover();
} catch (error) { } catch (error) {
logger.error(`重建主窗口失败:${error instanceof Error ? error.message : String(error)}`); logger.error(`重建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
} finally { } finally {
+94 -85
View File
@@ -18,6 +18,7 @@ const currentDir = __dirname;
const devServerUrl = process.env.VITE_DEV_SERVER_URL; const devServerUrl = process.env.VITE_DEV_SERVER_URL;
const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1'; const isSmokeTest = process.env.ELECTRON_SMOKE_TEST === '1';
const isQuickSmoke = isSmokeTest && process.env.ELECTRON_SMOKE_MODE !== 'visual';
/** /**
* 这次启动是否需要"直接进托盘、不弹窗"——开机自启专用(见 `applyAutoLaunch()`)。 * 这次启动是否需要"直接进托盘、不弹窗"——开机自启专用(见 `applyAutoLaunch()`)。
@@ -73,6 +74,7 @@ function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
title: 'ClassHelper', title: 'ClassHelper',
// 冒烟验证时不弹窗,避免打扰使用者;开机自启(--hidden)时直接进托盘 // 冒烟验证时不弹窗,避免打扰使用者;开机自启(--hidden)时直接进托盘
show: !isSmokeTest && options.hidden !== true, show: !isSmokeTest && options.hidden !== true,
focusable: !isQuickSmoke,
autoHideMenuBar: true, autoHideMenuBar: true,
backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3', backgroundColor: theme === 'dark' ? '#161719' : '#f3f3f3',
webPreferences: { webPreferences: {
@@ -81,6 +83,8 @@ function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
nodeIntegration: false, nodeIntegration: false,
sandbox: true, sandbox: true,
webSecurity: true, webSecurity: true,
backgroundThrottling: false,
offscreen: isQuickSmoke,
}, },
}); });
@@ -99,6 +103,12 @@ function createWindow(options: { hidden?: boolean } = {}): BrowserWindow {
win.on('closed', () => { win.on('closed', () => {
if (mainWindow === win) mainWindow = null; if (mainWindow === win) mainWindow = null;
}); });
win.on('close', (event) => {
if (isQuitting) return;
event.preventDefault();
win.hide();
logger.info('主窗口已隐藏到系统托盘(后台继续运行)');
});
// 崩溃自愈与「空闲安装更新」都要盯着主窗口:窗口重建后换的是新实例,因此每次建窗都重挂一次 // 崩溃自愈与「空闲安装更新」都要盯着主窗口:窗口重建后换的是新实例,因此每次建窗都重挂一次
watchCrashGuardWindow(win); watchCrashGuardWindow(win);
@@ -117,7 +127,7 @@ function ensureMainWindow(options: { hidden?: boolean } = {}): BrowserWindow | n
if (mainWindow && !mainWindow.isDestroyed()) return mainWindow; if (mainWindow && !mainWindow.isDestroyed()) return mainWindow;
try { try {
mainWindow = createWindow(options); mainWindow = createWindow(options);
void loadRenderer(mainWindow); void loadRenderer(mainWindow).catch((error) => logger.error('重建主窗口加载失败', error));
} catch (error) { } catch (error) {
logger.error(`创建主窗口失败:${error instanceof Error ? error.message : String(error)}`); logger.error(`创建主窗口失败:${error instanceof Error ? error.message : String(error)}`);
mainWindow = null; mainWindow = null;
@@ -172,95 +182,95 @@ if (!gotLock) {
showMainWindow(); showMainWindow();
}); });
app.whenReady().then(async () => { app
// 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」) .whenReady()
logger.setVerbose(getConfig().developerMode === true); .then(async () => {
registerIpcHandlers(); // 日志级别跟开发者模式走:debug 只在开启时才输出(设置页「开发者模式」)
registerIslandIpc(); logger.setVerbose(getConfig().developerMode === true);
registerIpcHandlers();
registerIslandIpc();
// 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步) // 开机自启:登录项要与配置保持一致(用户在设置页改过之后由 IPC 立即同步)
applyAutoLaunch(); applyAutoLaunch();
mainWindow = createWindow({ hidden: startHidden }); mainWindow = createWindow({ hidden: startHidden });
await loadRenderer(mainWindow);
// 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启 // 崩溃自愈:主窗口渲染进程崩溃 / 界面无响应时静默重建;主进程崩溃且本机空闲时自动重启
installCrashGuard({ installCrashGuard({
recoverMainWindow: () => { recoverMainWindow: async () => {
const alive = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null; const alive = mainWindow && !mainWindow.isDestroyed() ? mainWindow : null;
if (alive) { if (alive) {
alive.hide(); alive.hide();
alive.destroy(); alive.destroy();
} }
mainWindow = ensureMainWindow(); mainWindow = createWindow({ hidden: true });
}, await loadRenderer(mainWindow);
isIdle: () => },
island.getState().inClass === false && isIdle: () =>
mainWindow?.isVisible() !== true && island.getState().inClass === false &&
!BrowserWindow.getAllWindows().some((win) => !win.isDestroyed() && win.isVisible()), mainWindow?.isVisible() !== true &&
quitApp: () => quitApp(), !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);
// 点击灵动岛里的"打开应用"时把主窗口拉到前台
island.setOpenAppHandler(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}); });
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心 await loadRenderer(mainWindow).catch((error) => logger.error('主窗口启动加载失败', error));
island.setMarkReadHandler((id) => { // 空闲安装更新:先把手动退出的回调交给 auto-update(它不能再 import 本文件,否则循环导入)
if (!mainWindow || mainWindow.isDestroyed()) return; setQuitHandler(() => quitApp());
mainWindow.webContents.send('island:mark-read', id);
});
// 多条通知时的"标为已读"是**整批**操作:一次把列表里的通知都标上
island.setMarkAllReadHandler((ids) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-all-read', ids);
});
} catch (error) {
logger.error('灵动岛初始化失败(不影响主功能)', error);
}
// 关闭主窗口 → 隐藏到系统托盘(需求 5),只有托盘"退出"才真正结束进程 // 灵动岛:独立的置顶透明小窗口(桌面中上方)
// 注意:必须在冒烟验证之前挂载,否则 close() 会真的销毁窗口
mainWindow?.on('close', (event) => {
if (isQuitting) return;
event.preventDefault();
mainWindow?.hide();
logger.info('主窗口已隐藏到系统托盘(后台继续运行,左键托盘图标可恢复)');
});
try {
createTray({
onShow: () => showMainWindow(),
onHide: () => mainWindow?.hide(),
onQuit: () => quitApp(),
});
} catch (error) {
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
}
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
startClientAgent();
if (isSmokeTest) {
try { try {
await runSmokeTest(mainWindow); if (!isQuickSmoke)
await island.init(devServerUrl ? `${devServerUrl.replace(/\/+$/, '')}/island.html` : null);
// 点击灵动岛里的"打开应用"时把主窗口拉到前台
island.setOpenAppHandler(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
});
// 灵动岛点"标为已读":转交主窗口渲染进程调用接口并刷新通知中心
island.setMarkReadHandler((id) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-read', id);
});
// 多条通知时的"标为已读"是**整批**操作:一次把列表里的通知都标上
island.setMarkAllReadHandler((ids) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
mainWindow.webContents.send('island:mark-all-read', ids);
});
} catch (error) { } catch (error) {
console.error('[SMOKE] 冒烟验证异常', error); logger.error('灵动岛初始化失败(不影响主功能)', error);
app.exit(1);
} }
}
}); try {
if (!isQuickSmoke)
createTray({
onShow: () => showMainWindow(),
onHide: () => mainWindow?.hide(),
onQuit: () => quitApp(),
});
} catch (error) {
logger.warn(`系统托盘初始化失败(不影响主功能):${error instanceof Error ? error.message : error}`);
}
// 启动后自动查一次更新(延迟几秒,不跟首屏抢带宽);冒烟模式下自动跳过,见 main/update.ts
scheduleStartupUpdateCheck(() => mainWindow);
// 服务端统一下发:定时上报本机状态并领取「待执行命令 / 统一更新」(冒烟默认不上报)
startClientAgent();
if (isSmokeTest) {
try {
await runSmokeTest(mainWindow);
} catch (error) {
console.error('[SMOKE] 冒烟验证异常', error);
app.exit(1);
}
}
})
.catch((error) => {
logger.error('应用初始化失败', error);
if (isSmokeTest) app.exit(1);
});
/** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动 / 灵动岛"打开应用") */ /** 显示主窗口(托盘左键 / 托盘菜单 / 二次启动 / 灵动岛"打开应用") */
function showMainWindow(): void { function showMainWindow(): void {
@@ -326,10 +336,9 @@ if (!gotLock) {
logger.info(`应用退出清理:${summary}`); logger.info(`应用退出清理:${summary}`);
}); });
app.on('activate', async () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
const win = ensureMainWindow(); ensureMainWindow();
if (win) await loadRenderer(win);
} }
}); });
} }
+5 -1
View File
@@ -17,7 +17,11 @@ export function registerIpcHandlers(): void {
ipcMain.handle( ipcMain.handle(
IPC_CHANNELS.saveConfig, IPC_CHANNELS.saveConfig,
(_event, patch: Partial<DesktopStoredConfig>): DesktopStoredConfig => { (_event, patch: Partial<DesktopStoredConfig>): DesktopStoredConfig => {
return saveConfig(patch ?? {}); // 更新安装记录只允许主进程内部写入,渲染进程不得借配置通道指定可执行文件。
const allowed = { ...patch };
delete allowed.pendingUpdateFile;
delete allowed.pendingUpdateVersion;
return saveConfig(allowed);
}, },
); );
+38 -18
View File
@@ -341,9 +341,7 @@ class IslandController {
if (this.previewId && this.state.active?.id === this.previewId) return; if (this.previewId && this.state.active?.id === this.previewId) return;
const sinceActivated = Date.now() - this.state.updatedAt; const sinceActivated = Date.now() - this.state.updatedAt;
if (this.isCursorOnIsland()) { if (this.isCursorOnIsland()) {
logger.info( logger.info(`灵动岛:忽略失焦(指针仍在岛上,判定为展开时的激活抖动)距上次状态=${sinceActivated}ms`);
`灵动岛:忽略失焦(指针仍在岛上,判定为展开时的激活抖动)距上次状态=${sinceActivated}ms`,
);
return; return;
} }
// 触摸屏上没有"指针"可依据(isCursorOnIsland 恒为 false),只能用更宽的宽限期覆盖 // 触摸屏上没有"指针"可依据(isCursorOnIsland 恒为 false),只能用更宽的宽限期覆盖
@@ -828,10 +826,13 @@ class IslandController {
*/ */
this.setState({ mode: 'pill', reason: 'closing' }, { skipWindow: true }); this.setState({ mode: 'pill', reason: 'closing' }, { skipWindow: true });
const speed = clamp(this.appearance.speed, 0.5, 2, 1); const speed = clamp(this.appearance.speed, 0.5, 2, 1);
this.closeTimer = setTimeout(() => { this.closeTimer = setTimeout(
this.closeTimer = null; () => {
this.finishClose(); this.closeTimer = null;
}, Math.round(CLOSE_ANIMATION_MS / speed)); this.finishClose();
},
Math.round(CLOSE_ANIMATION_MS / speed),
);
} }
/** /**
@@ -876,6 +877,13 @@ class IslandController {
this.win = null; this.win = null;
} }
/** 教师撤回通知后,同时移除正在展示和等待下课的副本。 */
removeNotification(id: string): void {
this.state.queued = this.state.queued.filter((item) => item.id !== id);
if (this.state.active?.id === id) this.dismissActive();
else this.setState({});
}
/* ------------------------------------------------------------ 内部实现 */ /* ------------------------------------------------------------ 内部实现 */
private dismissActive(): void { private dismissActive(): void {
@@ -908,7 +916,6 @@ class IslandController {
this.hide(); this.hide();
} }
private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void { private activate(notification: IslandNotification, reason: IslandState['reason'], mode: IslandMode): void {
this.clearTimers(); this.clearTimers();
// 正在展示的通知从队列中移出,queue 只保留"尚未看过"的 // 正在展示的通知从队列中移出,queue 只保留"尚未看过"的
@@ -1010,11 +1017,7 @@ class IslandController {
? 'right' ? 'right'
: 'center'; : 'center';
const hValue = const hValue =
hMode === 'left' hMode === 'left' ? area.x + marginX : hMode === 'right' ? area.x + area.width - marginX : center;
? area.x + marginX
: hMode === 'right'
? area.x + area.width - marginX
: center;
return { hMode, hValue, vMode, vValue }; return { hMode, hValue, vMode, vValue };
} }
@@ -1834,7 +1837,6 @@ const PREVIEW_DURATION_MS = 30_000;
*/ */
const MIN_CARD_HEIGHT = 120; const MIN_CARD_HEIGHT = 120;
/** /**
* 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`): * 渲染进程心跳(渲染进程每 5s 上报一次,间隔写死在 `src/island/IslandApp.vue`):
* 超过这个时间没上报就认为它已经卡死/不响应。 * 超过这个时间没上报就认为它已经卡死/不响应。
@@ -1866,6 +1868,9 @@ export const island = new IslandController();
/** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */ /** 注册灵动岛相关 IPC(主窗口渲染进程 -> 主进程;灵动岛渲染进程 -> 主进程) */
export function registerIslandIpc(): void { export function registerIslandIpc(): void {
ipcMain.on('island:remove-notification', (_event, id: string) => {
if (typeof id === 'string') island.removeNotification(id);
});
ipcMain.on( ipcMain.on(
'island:push', 'island:push',
(_event, payload: { notification: IslandNotification; context?: IslandPushContext }) => { (_event, payload: { notification: IslandNotification; context?: IslandPushContext }) => {
@@ -1884,13 +1889,28 @@ export function registerIslandIpc(): void {
island.noteRendererAlive(); island.noteRendererAlive();
}); });
ipcMain.handle('island:get-state', () => island.getState()); let appearancePatch: Partial<IslandAppearance> | null = null;
let appearanceTimer: ReturnType<typeof setTimeout> | null = null;
const flushAppearance = (): void => {
if (appearanceTimer) clearTimeout(appearanceTimer);
appearanceTimer = null;
if (appearancePatch) island.setAppearance(appearancePatch);
appearancePatch = null;
};
ipcMain.handle('island:get-state', () => {
flushAppearance();
return island.getState();
});
ipcMain.on('island:set-appearance', (_event, patch: Partial<IslandAppearance>) => { ipcMain.on('island:set-appearance', (_event, patch: Partial<IslandAppearance>) => {
if (patch) island.setAppearance(patch); if (!patch) return;
appearancePatch = { ...appearancePatch, ...patch };
if (!appearanceTimer) appearanceTimer = setTimeout(flushAppearance, 16);
}); });
ipcMain.handle('island:get-appearance', () => island.getAppearance()); ipcMain.handle('island:get-appearance', () => {
flushAppearance();
return island.getAppearance();
});
// 渲染进程命中测试结果 → **只用于立刻打开命中**(比 60ms 轮询更快)。 // 渲染进程命中测试结果 → **只用于立刻打开命中**(比 60ms 轮询更快)。
// 关闭命中一律由主进程按真实光标决定:两边都下发 true/false 会互相覆盖, // 关闭命中一律由主进程按真实光标决定:两边都下发 true/false 会互相覆盖,
@@ -0,0 +1,106 @@
import fs from 'node:fs';
import path from 'node:path';
import { app, BrowserWindow } from 'electron';
import { getConfig } from './config.js';
/** 后台冒烟只检启动、桥接、缓存与窗口生命周期;屏幕像素和动画由显式视觉验收覆盖。 */
export async function runQuickSmoke(win: BrowserWindow): Promise<void> {
const startedAt = Date.now();
const results: Array<{ name: string; ok: boolean; detail: string; durationMs: number }> = [];
async function check(name: string, action: () => Promise<boolean | string> | boolean): Promise<void> {
const start = Date.now();
try {
const result = await action();
results.push({
name,
ok: result !== false,
detail: typeof result === 'string' ? result : '',
durationMs: Date.now() - start,
});
} catch (error) {
results.push({ name, ok: false, detail: String(error), durationMs: Date.now() - start });
}
const last = results.at(-1)!;
console.log(`[${last.ok ? 'PASS' : 'FAIL'}] ${name}(${last.durationMs}ms)${last.detail}`);
}
await check('后台启动并挂载界面', async () => {
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
if (
await win.webContents.executeJavaScript(
`Boolean(window.__classhelperSmoke__ && document.querySelector('input, .el-menu-item'))`,
)
)
return true;
await new Promise((resolve) => setTimeout(resolve, 50));
}
return false;
});
await check('所有验证窗口保持隐藏且不获取焦点', () =>
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
);
await check('预加载桥接白名单', async () =>
win.webContents.executeJavaScript(`Boolean(window.desktop?.getConfig && !window.desktop.ipcRenderer)`),
);
await check('渲染进程不能改写安装记录或设备身份', async () => {
const before = getConfig();
const after = await win.webContents.executeJavaScript(
`window.desktop.saveConfig({ pendingUpdateFile: 'C:/unexpected.exe', pendingUpdateVersion: '999.0.0', deviceKey: 'unexpected' })`,
);
return (
after.pendingUpdateFile === before.pendingUpdateFile &&
after.pendingUpdateVersion === before.pendingUpdateVersion &&
after.deviceKey === before.deviceKey
);
});
for (const [name, method] of [
['IndexedDB 缓存读写', 'cacheSelfTest'],
['断网缓存回退', 'offlineScenario'],
['服务端与班级缓存隔离', 'cacheIsolationSelfTest'],
]) {
await check(name!, async () => {
const result = await win.webContents.executeJavaScript(`window.__classhelperSmoke__.${method}()`);
if (!result.ok) throw new Error(result.detail);
return result.detail;
});
}
if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
await check('后台登录与数据、实时通道集成', async () => {
const credentials = {
code: process.env.ELECTRON_SMOKE_CLASS_CODE ?? '',
password: process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '',
serverUrl: (process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api').replace(/\/api\/?$/, ''),
};
const result = await win.webContents.executeJavaScript(
`window.__classhelperSmoke__.onlineScenario(${JSON.stringify(credentials)})`,
);
if (!result.ok) throw new Error(result.detail);
await win.webContents.executeJavaScript('window.__classhelperSmoke__.sessionCleanup()');
return result.detail;
});
}
await check('检查结束时没有可见或聚焦的验证窗口', () =>
BrowserWindow.getAllWindows().every((item) => !item.isVisible() && !item.isFocused()),
);
await check('关闭窗口仍隐藏而不销毁', () => {
win.close();
return !win.isDestroyed() && !win.isVisible() && app.isReady();
});
const failed = results.filter((result) => !result.ok);
const result = {
passed: results.length - failed.length,
total: results.length,
ok: !failed.length,
mode: 'quick',
durationMs: Date.now() - startedAt,
results,
appVersion: app.getVersion(),
packaged: app.isPackaged,
};
if (process.env.ELECTRON_SMOKE_RESULT) {
fs.mkdirSync(path.dirname(process.env.ELECTRON_SMOKE_RESULT), { recursive: true });
fs.writeFileSync(process.env.ELECTRON_SMOKE_RESULT, JSON.stringify(result, null, 2));
}
console.log(`[SMOKE] 后台快速检查 ${result.passed}/${result.total} 项通过,${result.durationMs}ms`);
app.exit(failed.length ? 1 : 0);
}
+23 -64
View File
@@ -1,4 +1,5 @@
import fs from 'node:fs'; import fs from 'node:fs';
import { runQuickSmoke } from './smoke-quick.js';
import path from 'node:path'; import path from 'node:path';
import { app, BrowserWindow, desktopCapturer, screen } from 'electron'; import { app, BrowserWindow, desktopCapturer, screen } from 'electron';
import type { NativeImage } from 'electron'; import type { NativeImage } from 'electron';
@@ -1150,7 +1151,10 @@ async function runIslandChecks(
const shotAppearance: Parameters<typeof island.setAppearance>[0] = { const shotAppearance: Parameters<typeof island.setAppearance>[0] = {
accent: process.env.ELECTRON_SMOKE_ISLAND_ACCENT ?? '#e91e8c', accent: process.env.ELECTRON_SMOKE_ISLAND_ACCENT ?? '#e91e8c',
}; };
if (process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'black' || process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'glass') { if (
process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'black' ||
process.env.ELECTRON_SMOKE_ISLAND_STYLE === 'glass'
) {
shotAppearance.style = process.env.ELECTRON_SMOKE_ISLAND_STYLE; shotAppearance.style = process.env.ELECTRON_SMOKE_ISLAND_STYLE;
} else if (!process.env.ELECTRON_SMOKE_ISLAND_STYLE) { } else if (!process.env.ELECTRON_SMOKE_ISLAND_STYLE) {
shotAppearance.style = 'tinted'; shotAppearance.style = 'tinted';
@@ -4125,67 +4129,17 @@ async function runSettingsPageMarginCheck(
}; };
} }
async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: boolean; detail: string }> { async function runSettingsPageChannelCheck(win: BrowserWindow): Promise<{ ok: boolean; detail: string }> {
const result = (await win.webContents const text = await win.webContents.executeJavaScript(`(async () => {
.executeJavaScript( location.hash = '#/settings/reminder';
`(async () => { await new Promise((resolve) => setTimeout(resolve, 500));
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); return document.body.innerText;
const before = (await window.desktop.getConfig()).notificationChannel; })()`);
location.hash = '#/settings/reminder'; return {
ok: text.includes('通知提醒') && !text.includes('ClassIsland 联动'),
// 找到「通知显示位置」那张卡片里的单选组 detail: '通知统一由客户端提醒',
let group = null; };
for (let i = 0; i < 40 && !group; i += 1) {
await wait(100);
group = Array.from(document.querySelectorAll('.el-card')).find((card) =>
(card.querySelector('.el-card__header')?.textContent ?? '').includes('ClassIsland 联动'),
);
}
if (!group) return { ok: false, detail: '设置页没有「ClassIsland 联动」卡片' };
const radios = Array.from(group.querySelectorAll('.el-radio'));
// 注意:这段脚本本身是外层模板字符串,里面不能再出现反引号(会截断外层字符串)
if (radios.length < 3) return { ok: false, detail: '单选项只有 ' + radios.length + ' 个' };
// 点第二个(只在 ClassHelper 客户端弹),断言配置真的写下去了
radios[1].click();
await wait(600);
const afterClient = (await window.desktop.getConfig()).notificationChannel;
const clientChecked = radios[1].classList.contains('is-checked');
// 再点回第一个(两端都弹),避免影响后续用例
radios[0].click();
await wait(600);
const afterBoth = (await window.desktop.getConfig()).notificationChannel;
location.hash = '#/schedule';
return { ok: true, before, afterClient, afterBoth, clientChecked, labels: radios.map((n) => n.textContent.trim()) };
})()`,
)
.catch((error: unknown) => ({
ok: false as const,
detail: `执行失败:${error instanceof Error ? error.message : String(error)}`,
}))) as
| {
ok: true;
before: string;
afterClient: string;
afterBoth: string;
clientChecked: boolean;
labels: string[];
}
| { ok: false; detail: string }
| null;
if (result?.ok) {
return {
ok: result.afterClient === 'client' && result.clientChecked && result.afterBoth === 'both',
detail:
`单选项=[${result.labels.join(' / ')}],` +
`选择「只在 ClassHelper 客户端弹」后配置 ${result.before} → ${result.afterClient}(选中=${result.clientChecked}),` +
`切回后 ${result.afterBoth}`,
};
}
return { ok: false, detail: result?.detail ?? '未执行' };
} }
/** /**
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。 * Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退, * 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
@@ -4227,6 +4181,7 @@ async function runTrayAndShutdownChecks(mainWin: BrowserWindow, record: Recorder
); );
} }
export async function runSmokeTest(win: BrowserWindow): Promise<void> { export async function runSmokeTest(win: BrowserWindow): Promise<void> {
if (process.env.ELECTRON_SMOKE_MODE !== 'visual') return runQuickSmoke(win);
const results: SmokeResult[] = []; const results: SmokeResult[] = [];
const record = (name: string, ok: boolean, detail = ''): void => { const record = (name: string, ok: boolean, detail = ''): void => {
results.push({ name, ok, detail }); results.push({ name, ok, detail });
@@ -4395,10 +4350,14 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
// 班级账号凭据由 scripts/smoke.mjs 通过管理端接口准备(渲染进程读不到 process.env) // 班级账号凭据由 scripts/smoke.mjs 通过管理端接口准备(渲染进程读不到 process.env)
const classCode = process.env.ELECTRON_SMOKE_CLASS_CODE ?? ''; const classCode = process.env.ELECTRON_SMOKE_CLASS_CODE ?? '';
const classPassword = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? ''; const classPassword = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '';
const serverUrl = (process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api').replace(
/\/api\/?$/,
'',
);
const online = await win.webContents.executeJavaScript( const online = await win.webContents.executeJavaScript(
`(async () => { `(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' }; if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword })}); return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword, serverUrl })});
})()`, })()`,
); );
record( record(
@@ -4413,9 +4372,9 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
const settingsAppearance = await runSettingsPageAppearanceCheck(win); const settingsAppearance = await runSettingsPageAppearanceCheck(win);
record('设置页真实 UI:拖拽滑块即时改变灵动岛外观', settingsAppearance.ok, settingsAppearance.detail); record('设置页真实 UI:拖拽滑块即时改变灵动岛外观', settingsAppearance.ok, settingsAppearance.detail);
// 通知显示位置(客户端自己选):提醒弹在 ClassHelper 还是 ClassIsland // 提醒设置:仅保留本机通知与教师管理的成绩查询状态
const settingsChannel = await runSettingsPageChannelCheck(win); const settingsChannel = await runSettingsPageChannelCheck(win);
record('设置页真实 UI:切换「通知显示位置」写回本地配置', settingsChannel.ok, settingsChannel.detail); record('设置页真实 UI:提醒规则展示且无退役联动入口', settingsChannel.ok, settingsChannel.detail);
// 改密入口(2026-10-01 用户要求移除:班级密码只由管理员在 Web 端维护)——负断言防退化 // 改密入口(2026-10-01 用户要求移除:班级密码只由管理员在 Web 端维护)——负断言防退化
const settingsPassword = await runSettingsPagePasswordEntryCheck(win); const settingsPassword = await runSettingsPagePasswordEntryCheck(win);
@@ -50,6 +50,9 @@ const bridge: DesktopBridge = {
islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => { islandPush: (payload: { notification: IslandNotification; context?: IslandPushContext }): void => {
ipcRenderer.send('island:push', payload); ipcRenderer.send('island:push', payload);
}, },
islandRemoveNotification: (id: string): void => {
ipcRenderer.send('island:remove-notification', id);
},
islandSetClassState: (payload: IslandClassStatePayload): void => { islandSetClassState: (payload: IslandClassStatePayload): void => {
ipcRenderer.send('island:class-state', payload); ipcRenderer.send('island:class-state', payload);
}, },
@@ -102,14 +102,6 @@ onMounted(async () => {
bootText.value = '正在恢复登录状态...'; bootText.value = '正在恢复登录状态...';
const session = await auth.restore(); const session = await auth.restore();
// 通知显示位置:先用本地配置兜底(断网也要按上次的选择决定本机弹不弹),
// 登录态就绪后再向服务器对齐一次(服务端才是单一事实来源)
const storedConfig = await window.desktop?.getConfig?.();
if (storedConfig?.notificationChannel) {
appStore.applyLocalNotificationChannel(storedConfig.notificationChannel);
}
void appStore.loadNotificationChannel(auth.classId);
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据) // 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
void appStore.init(session.serverUrl); void appStore.init(session.serverUrl);
@@ -2,7 +2,6 @@ import {
API_PATHS, API_PATHS,
type ClassDetailDto, type ClassDetailDto,
type ClassDto, type ClassDto,
type ClassIslandNotificationChannel,
type CourseDto, type CourseDto,
type ExamDto, type ExamDto,
type ExamRankingDto, type ExamRankingDto,
@@ -47,36 +46,6 @@ export const classApi = {
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`), detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
}; };
/**
* 通知显示位置(both / client / classisland)。
*
* 由**这台教室机器自己**决定"提醒弹在 ClassHelper 还是 ClassIsland":
* 本地存一份用于本机的弹窗/上岛判断,同时写回班级记录,服务端据此决定要不要推 ClassIsland。
*/
export const classChannelApi = {
get: (classId: string): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
api.get(`${API_PATHS.classes}/${classId}/notification-channel`),
set: (
classId: string,
notificationChannel: ClassIslandNotificationChannel,
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
api.patch(`${API_PATHS.classes}/${classId}/notification-channel`, { notificationChannel }),
};
/** 本班 ClassIsland 联动状态(教室客户端「设置 → ClassIsland 联动」展示) */
export const classIslandStatusApi = {
get: (
classId: string,
): Promise<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
}> => api.get(`${API_PATHS.classes}/${classId}/classisland-status`),
};
export const scheduleApi = { export const scheduleApi = {
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> => list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
api.get(API_PATHS.schedules, params), api.get(API_PATHS.schedules, params),
@@ -151,13 +120,11 @@ export const gradeApi = {
/** /**
* ClassHelper 班级端设置:「是否允许按学号查询本班学生成绩明细」。 * ClassHelper 班级端设置:「是否允许按学号查询本班学生成绩明细」。
* 与通知显示位置同一套模式 —— 由教室机器自己决定,教师/管理员在 Web 端也能改。 * 教师/管理员在 Web 端控制,教室机器只读取当前状态。
*/ */
export const classGradeQueryApi = { export const classGradeQueryApi = {
get: (classId: string): Promise<{ studentGradeQueryEnabled: boolean }> => get: (classId: string): Promise<{ studentGradeQueryEnabled: boolean }> =>
api.get(`${API_PATHS.classes}/${classId}/student-grade-query`), api.get(`${API_PATHS.classes}/${classId}/student-grade-query`),
set: (classId: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
api.patch(`${API_PATHS.classes}/${classId}/student-grade-query`, { enabled }),
}; };
export const dashboardApi = { export const dashboardApi = {
+55 -12
View File
@@ -1,3 +1,5 @@
import { getCacheScope, scopedKey } from './scope.js';
/** /**
* IndexedDB 缓存层(无第三方依赖)。 * IndexedDB 缓存层(无第三方依赖)。
* *
@@ -37,7 +39,8 @@ let databasePromise: Promise<IDBDatabase> | null = null;
function openDatabase(): Promise<IDBDatabase> { function openDatabase(): Promise<IDBDatabase> {
if (databasePromise) return databasePromise; if (databasePromise) return databasePromise;
databasePromise = new Promise<IDBDatabase>((resolve, reject) => { let failed = false;
const opening = new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION); const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => { request.onupgradeneeded = () => {
@@ -49,12 +52,36 @@ function openDatabase(): Promise<IDBDatabase> {
} }
}; };
request.onsuccess = () => resolve(request.result); request.onsuccess = () => {
request.onerror = () => reject(request.error ?? new Error('IndexedDB 打开失败')); const db = request.result;
request.onblocked = () => reject(new Error('IndexedDB 被其它窗口占用')); if (failed) {
db.close();
return;
}
const reset = (): void => {
if (databasePromise === opening) databasePromise = null;
};
db.onversionchange = () => {
db.close();
reset();
};
db.onclose = reset;
resolve(db);
};
request.onerror = () => {
failed = true;
reject(request.error ?? new Error('IndexedDB 打开失败'));
};
request.onblocked = () => {
failed = true;
reject(new Error('IndexedDB 被其它窗口占用'));
};
}); });
databasePromise = opening;
return databasePromise; void opening.catch(() => {
if (databasePromise === opening) databasePromise = null;
});
return opening;
} }
function runRequest<T>( function runRequest<T>(
@@ -67,7 +94,8 @@ function runRequest<T>(
new Promise<T>((resolve, reject) => { new Promise<T>((resolve, reject) => {
const transaction = db.transaction(store, mode); const transaction = db.transaction(store, mode);
const request = action(transaction.objectStore(store)); const request = action(transaction.objectStore(store));
request.onsuccess = () => resolve(request.result as T); transaction.oncomplete = () => resolve(request.result as T);
transaction.onabort = () => reject(transaction.error ?? new Error('IndexedDB 事务已中止'));
request.onerror = () => reject(request.error ?? new Error('IndexedDB 操作失败')); request.onerror = () => reject(request.error ?? new Error('IndexedDB 操作失败'));
}), }),
); );
@@ -89,14 +117,28 @@ function toPlain<T>(value: T): T {
} }
} }
export async function cacheSet<T>(store: CacheStore, key: string, value: T): Promise<void> { export async function cacheSet<T>(
const record: CacheRecord<T> = { key, value: toPlain(value), updatedAt: Date.now() }; store: CacheStore,
key: string,
value: T,
scope = getCacheScope(),
): Promise<void> {
const record: CacheRecord<T> = {
key: store === 'profile' ? key : scopedKey(key, scope),
value: toPlain(value),
updatedAt: Date.now(),
};
await runRequest<IDBValidKey>(store, 'readwrite', (objectStore) => objectStore.put(record)); await runRequest<IDBValidKey>(store, 'readwrite', (objectStore) => objectStore.put(record));
} }
export function cacheGet<T>(store: CacheStore, key: string): Promise<CacheRecord<T> | null> { export function cacheGet<T>(
store: CacheStore,
key: string,
scope = getCacheScope(),
): Promise<CacheRecord<T> | null> {
const resolvedKey = store === 'profile' ? key : scopedKey(key, scope);
return runRequest<CacheRecord<T> | undefined>(store, 'readonly', (objectStore) => return runRequest<CacheRecord<T> | undefined>(store, 'readonly', (objectStore) =>
objectStore.get(key), objectStore.get(resolvedKey),
).then((record) => record ?? null); ).then((record) => record ?? null);
} }
@@ -105,7 +147,8 @@ export function cacheList<T>(store: CacheStore): Promise<Array<CacheRecord<T>>>
} }
export async function cacheRemove(store: CacheStore, key: string): Promise<void> { export async function cacheRemove(store: CacheStore, key: string): Promise<void> {
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.delete(key)); const resolvedKey = store === 'profile' ? key : scopedKey(key);
await runRequest<undefined>(store, 'readwrite', (objectStore) => objectStore.delete(resolvedKey));
} }
export async function cacheClearStore(store: CacheStore): Promise<void> { export async function cacheClearStore(store: CacheStore): Promise<void> {
+9 -2
View File
@@ -1,4 +1,5 @@
import { cacheGet, cacheSet, type CacheStore } from './db.js'; import { cacheGet, cacheSet, type CacheStore } from './db.js';
import { getCacheScope } from './scope.js';
export interface CachedFetchResult<T> { export interface CachedFetchResult<T> {
data: T; data: T;
@@ -23,13 +24,19 @@ export async function fetchWithCache<T>(
loader: () => Promise<T>, loader: () => Promise<T>,
fallback: T, fallback: T,
): Promise<CachedFetchResult<T>> { ): Promise<CachedFetchResult<T>> {
const scope = getCacheScope();
try { try {
const data = await loader(); const data = await loader();
await cacheSet(store, key, data); if (scope !== getCacheScope())
return { data: fallback, fromCache: false, updatedAt: null, error: '会话已切换' };
// 网络成功但本地磁盘不可用时,仍返回刚获取的数据。
await cacheSet(store, key, data, scope).catch(() => undefined);
return { data, fromCache: false, updatedAt: Date.now(), error: null }; return { data, fromCache: false, updatedAt: Date.now(), error: null };
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
const cached = await cacheGet<T>(store, key).catch(() => null); const cached = await cacheGet<T>(store, key, scope).catch(() => null);
if (scope !== getCacheScope())
return { data: fallback, fromCache: false, updatedAt: null, error: '会话已切换' };
if (cached) { if (cached) {
return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: message }; return { data: cached.value, fromCache: true, updatedAt: cached.updatedAt, error: message };
} }
+22
View File
@@ -0,0 +1,22 @@
let activeScope = 'anonymous';
const listeners = new Set<() => void>();
/** 同一台机器可切换服务端与班级,两者都必须纳入缓存命名空间。 */
export function setCacheScope(server: string, identity: string): void {
const next = `${encodeURIComponent(server.replace(/\/+$/, ''))}@${encodeURIComponent(identity)}`;
if (next === activeScope) return;
activeScope = next;
for (const listener of listeners) listener();
}
export function getCacheScope(): string {
return activeScope;
}
export function scopedKey(key: string, scope = activeScope): string {
return `${scope}::${key}`;
}
export function onCacheScopeChanged(listener: () => void): void {
listeners.add(listener);
}
export function profileCacheKey(server: string, username: string): string {
return `${encodeURIComponent(server.replace(/\/+$/, ''))}@${encodeURIComponent(username)}::profile`;
}
+31 -6
View File
@@ -1,5 +1,27 @@
import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js'; import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js';
import { fetchWithCache } from './index.js'; import { fetchWithCache } from './index.js';
import { setCacheScope } from './scope.js';
export async function cacheIsolationSelfTest(): Promise<SmokeCheckResult> {
const key = `scope-check-${Date.now()}`;
try {
setCacheScope('http://scope-a.invalid', 'class-a');
await cacheSet('grades', key, ['班级甲']);
setCacheScope('http://scope-a.invalid', 'class-b');
if (await cacheGet('grades', key)) return { ok: false, detail: '跨班级读到了缓存' };
setCacheScope('http://scope-b.invalid', 'class-a');
if (await cacheGet('grades', key)) return { ok: false, detail: '跨服务端读到了缓存' };
setCacheScope('http://scope-a.invalid', 'class-a');
const own = await cacheGet<string[]>('grades', key);
await cacheRemove('grades', key);
return { ok: own?.value[0] === '班级甲', detail: '换班和换服务端均隔离,原班缓存仍可读取' };
} finally {
setCacheScope('', 'anonymous');
}
}
/** 联网自检与造数、清理始终使用同一服务端。 */
let smokeServerUrl = 'http://127.0.0.1:4000';
export interface SmokeCheckResult { export interface SmokeCheckResult {
ok: boolean; ok: boolean;
@@ -208,7 +230,7 @@ async function seedSmokeContent(
need: { homework: boolean; notification: boolean }, need: { homework: boolean; notification: boolean },
): Promise<SmokeCheckResult> { ): Promise<SmokeCheckResult> {
if (!classId) return { ok: false, detail: '缺少班级,无法自建数据' }; if (!classId) return { ok: false, detail: '缺少班级,无法自建数据' };
const serverUrl = 'http://127.0.0.1:4000'; const serverUrl = smokeServerUrl;
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`; const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
try { try {
@@ -275,7 +297,7 @@ async function cleanupTemporaryContent(): Promise<string> {
if (!temporaryContent) return ''; if (!temporaryContent) return '';
const { token, homeworks, notifications } = temporaryContent; const { token, homeworks, notifications } = temporaryContent;
temporaryContent = null; temporaryContent = null;
const serverUrl = 'http://127.0.0.1:4000'; const serverUrl = smokeServerUrl;
const removals = [ const removals = [
...homeworks.map((id) => `/api/homeworks/${id}`), ...homeworks.map((id) => `/api/homeworks/${id}`),
...notifications.map((id) => `/api/notifications/${id}`), ...notifications.map((id) => `/api/notifications/${id}`),
@@ -298,6 +320,7 @@ async function cleanupTemporaryContent(): Promise<string> {
export async function onlineScenario(credentials?: { export async function onlineScenario(credentials?: {
code: string; code: string;
password: string; password: string;
serverUrl?: string;
}): Promise<SmokeCheckResult> { }): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([ const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
import('../stores/auth.js'), import('../stores/auth.js'),
@@ -311,7 +334,8 @@ export async function onlineScenario(credentials?: {
const appStore = useAppStore(); const appStore = useAppStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const serverUrl = 'http://127.0.0.1:4000'; const serverUrl = credentials?.serverUrl ?? smokeServerUrl;
smokeServerUrl = serverUrl;
const detail: string[] = []; const detail: string[] = [];
try { try {
@@ -441,7 +465,7 @@ export async function islandRealtimeScenario(): Promise<{
import('../island/bridge.js'), import('../island/bridge.js'),
]); ]);
const auth = useAuthStore(); const auth = useAuthStore();
const serverUrl = 'http://127.0.0.1:4000'; const serverUrl = smokeServerUrl;
const classId = auth.classId ?? null; const classId = auth.classId ?? null;
if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' }; if (!classId) return { ok: false, detail: '当前学生没有班级,无法投递通知' };
@@ -499,7 +523,7 @@ export async function islandRealtimeCleanup(
teacherToken: string, teacherToken: string,
): Promise<SmokeCheckResult> { ): Promise<SmokeCheckResult> {
try { try {
const response = await fetch(`http://127.0.0.1:4000/api/notifications/${notificationId}`, { const response = await fetch(`${smokeServerUrl}/api/notifications/${notificationId}`, {
method: 'DELETE', method: 'DELETE',
headers: { authorization: `Bearer ${teacherToken}` }, headers: { authorization: `Bearer ${teacherToken}` },
}); });
@@ -544,7 +568,7 @@ export async function scheduleTimelineSelfTest(): Promise<SmokeCheckResult> {
import('../router/index.js'), import('../router/index.js'),
]); ]);
const auth = useAuthStore(); const auth = useAuthStore();
const serverUrl = 'http://127.0.0.1:4000'; const serverUrl = smokeServerUrl;
const classId = auth.classId ?? null; const classId = auth.classId ?? null;
if (!classId) return { ok: false, detail: '当前学生没有班级,无法构造课表' }; if (!classId) return { ok: false, detail: '当前学生没有班级,无法构造课表' };
@@ -1172,6 +1196,7 @@ export async function aboutPageSelfTest(): Promise<SmokeCheckResult> {
export function registerSmokeHooks(): void { export function registerSmokeHooks(): void {
window.__classhelperSmoke__ = { window.__classhelperSmoke__ = {
cacheSelfTest, cacheSelfTest,
cacheIsolationSelfTest,
offlineScenario, offlineScenario,
layoutNavigationSelfTest, layoutNavigationSelfTest,
onlineScenario, onlineScenario,
@@ -102,7 +102,7 @@ function finish(): void {
} }
function onKeydown(event: KeyboardEvent): void { function onKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape') finish(); if (onboarding.visible && event.key === 'Escape') finish();
} }
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('keydown', onKeydown); window.addEventListener('keydown', onKeydown);
@@ -154,9 +154,7 @@ onUnmounted(() => {
</div> </div>
<div class="onboarding-actions"> <div class="onboarding-actions">
<el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button> <el-button v-if="!isFirst" data-action="prev" @click="goPrev">上一步</el-button>
<el-button v-if="!isLast" type="primary" data-action="next" @click="goNext"> <el-button v-if="!isLast" type="primary" data-action="next" @click="goNext">下一步</el-button>
下一步
</el-button>
<el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button> <el-button v-else type="primary" data-action="finish" @click="finish">开始使用</el-button>
</div> </div>
</div> </div>
@@ -250,7 +248,7 @@ onUnmounted(() => {
margin: 0 0 8px; margin: 0 0 8px;
font-size: 13px; font-size: 13px;
line-height: 1.8; line-height: 1.8;
color: var(--ch-text-regular, #606266); color: var(--ch-text-secondary);
} }
.onboarding-foot { .onboarding-foot {
@@ -0,0 +1,12 @@
import { ElMessageBox } from 'element-plus';
/** 取消是正常交互;真实异常继续交给调用方处理。 */
export async function confirmAction(...args: Parameters<typeof ElMessageBox.confirm>): Promise<boolean> {
try {
await ElMessageBox.confirm(...args);
return true;
} catch (reason) {
if (reason === 'cancel' || reason === 'close') return false;
throw reason;
}
}
@@ -1,3 +1,4 @@
import { watch, type WatchStopHandle } from 'vue';
import { import {
SOCKET_EVENTS, SOCKET_EVENTS,
resolveClassStatus, resolveClassStatus,
@@ -8,6 +9,7 @@ import {
} from '@classhelper/shared'; } from '@classhelper/shared';
import { notificationApi, scheduleApi } from '../api/index.js'; import { notificationApi, scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js'; import { fetchWithCache } from '../cache/index.js';
import { getCacheScope } from '../cache/scope.js';
import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js'; import { clearOfflineSince, readOfflineSince, useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
import { useNotificationStore } from '../stores/notifications.js'; import { useNotificationStore } from '../stores/notifications.js';
@@ -30,6 +32,9 @@ let currentPeriod: ClassPeriod | null = null;
let inClass = false; let inClass = false;
let tickTimer: ReturnType<typeof setInterval> | null = null; let tickTimer: ReturnType<typeof setInterval> | null = null;
let refreshTimer: ReturnType<typeof setInterval> | null = null; let refreshTimer: ReturnType<typeof setInterval> | null = null;
let stopWeekWatch: WatchStopHandle | null = null;
let scheduleRequest = 0;
let scheduleWeek = 1;
/** 供通知投递时读取:当前是否上课 + 本节课结束时间 */ /** 供通知投递时读取:当前是否上课 + 本节课结束时间 */
export function getIslandClassContext(): { inClass: boolean; currentPeriodEnd: string | null } { export function getIslandClassContext(): { inClass: boolean; currentPeriodEnd: string | null } {
@@ -45,21 +50,28 @@ export async function refreshIslandSchedules(): Promise<void> {
const appStore = useAppStore(); const appStore = useAppStore();
const auth = useAuthStore(); const auth = useAuthStore();
if (!auth.token) return; if (!auth.token) return;
const scope = getCacheScope();
const classId = auth.classId ?? undefined;
await appStore.refreshTerm(classId);
if (scope !== getCacheScope() || !auth.token) return;
const request = ++scheduleRequest;
const week = appStore.currentWeek; const week = appStore.currentWeek;
const result = await fetchWithCache<ScheduleDto[]>( const result = await fetchWithCache<ScheduleDto[]>(
'schedules', 'schedules',
`week:${week}`, `week:${week}`,
() => scheduleApi.list({ classId: auth.classId ?? undefined, week }), () => scheduleApi.list({ classId, week }),
[], [],
); );
if (scope !== getCacheScope() || request !== scheduleRequest) return;
if (result.error && !result.updatedAt) return;
schedules = result.data; schedules = result.data;
scheduleWeek = week;
recomputeClassState(true); recomputeClassState(true);
} }
function recomputeClassState(force = false): void { function recomputeClassState(force = false): void {
const appStore = useAppStore(); const appStore = useAppStore();
const status = resolveClassStatus(schedules, new Date(), appStore.currentWeek); const status = resolveClassStatus(schedules, new Date(), scheduleWeek);
const nextInClass = status.inClass; const nextInClass = status.inClass;
currentPeriod = status.current; currentPeriod = status.current;
@@ -313,6 +325,12 @@ function onScheduleUpdated(): void {
export function startIslandBridge(): void { export function startIslandBridge(): void {
if (tickTimer) return; if (tickTimer) return;
stopWeekWatch = watch(
() => useAppStore().currentWeek,
() => {
void refreshIslandSchedules();
},
);
void refreshIslandSchedules().catch(() => undefined); void refreshIslandSchedules().catch(() => undefined);
@@ -338,6 +356,9 @@ export function stopIslandBridge(): void {
if (refreshTimer) clearInterval(refreshTimer); if (refreshTimer) clearInterval(refreshTimer);
tickTimer = null; tickTimer = null;
refreshTimer = null; refreshTimer = null;
scheduleRequest += 1;
stopWeekWatch?.();
stopWeekWatch = null;
useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated); useRealtimeStore().off(SOCKET_EVENTS.scheduleUpdated, onScheduleUpdated);
schedules = []; schedules = [];
@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { confirmAction } from '../composables/confirm.js';
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { ElMessage, ElMessageBox, type MenuInstance } from 'element-plus'; import { ElMessage, type MenuInstance } from 'element-plus';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { startIslandBridge, stopIslandBridge } from '../island/bridge.js'; import { startIslandBridge, stopIslandBridge } from '../island/bridge.js';
import { useThemeReveal } from '../composables/motion.js'; import { useThemeReveal } from '../composables/motion.js';
@@ -354,7 +355,7 @@ const connectionText = computed(() => {
}); });
async function handleLogout(): Promise<void> { async function handleLogout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' }); if (!(await confirmAction('确认退出当前账号?离线缓存会保留。', '退出登录', { type: 'warning' }))) return;
realtime.disconnect(); realtime.disconnect();
await auth.logout(); await auth.logout();
ElMessage.success('已退出登录'); ElMessage.success('已退出登录');
@@ -370,6 +371,10 @@ async function handleLogout(): Promise<void> {
function onNotification(payload: unknown): void { function onNotification(payload: unknown): void {
void notifications.pushRealtime(payload as NotificationDto); void notifications.pushRealtime(payload as NotificationDto);
} }
function onNotificationDeleted(payload: unknown): void {
void notifications.removeRealtime((payload as { id: string }).id);
window.desktop?.islandRemoveNotification((payload as { id: string }).id);
}
/** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */ /** 服务器从不可达恢复为可达:自动同步一次(联网后自动同步) */
function onRecovered(): void { function onRecovered(): void {
@@ -378,9 +383,10 @@ function onRecovered(): void {
} }
onMounted(async () => { onMounted(async () => {
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
realtime.on(SOCKET_EVENTS.notificationDeleted, onNotificationDeleted);
await notifications.load().catch(() => undefined); await notifications.load().catch(() => undefined);
await notifications.refreshUnreadCount(); await notifications.refreshUnreadCount();
realtime.on(SOCKET_EVENTS.notificationNew, onNotification);
appStore.onServerRecovered(onRecovered); appStore.onServerRecovered(onRecovered);
// 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止, // 灵动岛桥接跟着"已登录布局"的生命周期走:这样退出登录会随布局卸载而停止,
// 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。 // 重新登录(或换班登录)时又会重新启动,不会拿旧课表算上课状态。
@@ -389,6 +395,7 @@ onMounted(async () => {
onUnmounted(() => { onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationNew, onNotification); realtime.off(SOCKET_EVENTS.notificationNew, onNotification);
realtime.off(SOCKET_EVENTS.notificationDeleted, onNotificationDeleted);
appStore.offServerRecovered(onRecovered); appStore.offServerRecovered(onRecovered);
stopIslandBridge(); stopIslandBridge();
}); });
@@ -410,7 +417,10 @@ onUnmounted(() => {
data-test="sidebar-toggle" data-test="sidebar-toggle"
@click="ui.toggleSidebar()" @click="ui.toggleSidebar()"
> >
<el-icon :size="16"><Expand v-if="ui.sidebarCollapsed" /><Fold v-else /></el-icon> <el-icon :size="16">
<Expand v-if="ui.sidebarCollapsed" />
<Fold v-else />
</el-icon>
</button> </button>
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出: <!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 --> v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
@@ -422,11 +432,7 @@ onUnmounted(() => {
把"跟着菜单滚出视口"的底片裁掉,不让它盖到品牌区/页脚上。 把"跟着菜单滚出视口"的底片裁掉,不让它盖到品牌区/页脚上。
两层都 pointer-events:none —— 底片盖在菜单项上,否则会把点击吃掉。 --> 两层都 pointer-events:none —— 底片盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator-clip" :style="clipStyle" aria-hidden="true"> <div class="nav-indicator-clip" :style="clipStyle" aria-hidden="true">
<div <div class="nav-indicator" :class="{ 'is-following': indicatorFollowing }" :style="indicatorStyle" />
class="nav-indicator"
:class="{ 'is-following': indicatorFollowing }"
:style="indicatorStyle"
/>
</div> </div>
<el-menu <el-menu
ref="menuRef" ref="menuRef"
@@ -495,7 +501,10 @@ onUnmounted(() => {
:aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'" :aria-label="ui.theme === 'dark' ? '切换到浅色模式' : '切换到深色模式'"
@click="handleThemeToggle" @click="handleThemeToggle"
> >
<el-icon><Sunny v-if="ui.theme === 'dark'" /><Moon v-else /></el-icon> <el-icon>
<Sunny v-if="ui.theme === 'dark'" />
<Moon v-else />
</el-icon>
</el-button> </el-button>
</el-tooltip> </el-tooltip>
<el-dropdown trigger="click"> <el-dropdown trigger="click">
@@ -1,15 +1,12 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
STORAGE_KEYS,
formatDate,
type ClassIslandNotificationChannel,
} from '@classhelper/shared';
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js'; import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
import { classChannelApi } from '../api/index.js';
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js'; import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js'; import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
import { dashboardApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js';
import { getCacheScope, onCacheScopeChanged } from '../cache/scope.js';
type RecoveryHandler = () => void; type RecoveryHandler = () => void;
@@ -70,13 +67,26 @@ export const useAppStore = defineStore('app', () => {
const currentWeek = ref(1); const currentWeek = ref(1);
const maxWeek = ref(20); const maxWeek = ref(20);
const initialized = ref(false); const initialized = ref(false);
/** let termRequest = 0;
* 通知显示位置:这台教室机器把提醒弹在哪个端。 onCacheScopeChanged(() => {
* currentWeek.value = 1;
* 单一事实来源是**班级记录**(服务端),本地这份只是缓存:断网时也要能按上次的选择 maxWeek.value = 20;
* 决定本机弹不弹,所以启动时先读本地配置、登录后再向服务器对齐一次。 termRequest += 1;
*/ clearOfflineSince();
const notificationChannel = ref<ClassIslandNotificationChannel>(DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL); });
/** 当前教学周的单一写入入口,课表页与灵动岛共用。 */
async function refreshTerm(classId?: string): Promise<void> {
const request = ++termRequest;
const scope = getCacheScope();
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(classId), {
currentWeek: 1,
maxWeek: 20,
});
if (request !== termRequest || scope !== getCacheScope() || (result.error && !result.updatedAt)) return;
currentWeek.value = result.data.currentWeek;
maxWeek.value = result.data.maxWeek;
}
const offline = computed(() => !serverReachable.value); const offline = computed(() => !serverReachable.value);
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步')); const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
@@ -178,53 +188,6 @@ export const useAppStore = defineStore('app', () => {
void refreshCacheStats(); void refreshCacheStats();
} }
/* ------------------------------------------------------------ 通知显示位置 */
/** 启动时用本地配置兜底(此时可能还没登录/断网) */
function applyLocalNotificationChannel(value: ClassIslandNotificationChannel): void {
notificationChannel.value = value;
}
/**
* 向服务器对齐显示位置。
* 登录后调用:以服务端为准(多台机器/老师改过之后,本地缓存可能已经过期)。
*/
async function loadNotificationChannel(classId: string | null): Promise<void> {
if (!classId) return;
try {
const result = await classChannelApi.get(classId);
notificationChannel.value = result.notificationChannel;
await window.desktop?.saveConfig({ notificationChannel: result.notificationChannel });
} catch {
// 离线/无权限:保留本地值,不影响展示
}
}
/**
* 改显示位置:先落本地(立刻生效),再写回班级记录。
* 服务端写失败时返回 false,由设置页提示"已保存在本机,联网后请重试"。
*/
async function setNotificationChannel(
classId: string | null,
value: ClassIslandNotificationChannel,
): Promise<boolean> {
notificationChannel.value = value;
await window.desktop?.saveConfig({ notificationChannel: value });
if (!classId) return false;
try {
const result = await classChannelApi.set(classId, value);
notificationChannel.value = result.notificationChannel;
return true;
} catch {
return false;
}
}
/** 本次提醒是否要在本机弹(弹窗 + 灵动岛);"只在 ClassIsland 上弹"时为 false */
function shouldPopupLocally(): boolean {
return notificationChannel.value !== 'classisland';
}
return { return {
serverUrl, serverUrl,
serverReachable, serverReachable,
@@ -236,17 +199,13 @@ export const useAppStore = defineStore('app', () => {
currentWeek, currentWeek,
maxWeek, maxWeek,
initialized, initialized,
notificationChannel,
init, init,
ping, ping,
applyServerUrl, applyServerUrl,
applyLocalNotificationChannel,
loadNotificationChannel,
setNotificationChannel,
shouldPopupLocally,
refreshCacheStats, refreshCacheStats,
clearCache, clearCache,
markSynced, markSynced,
refreshTerm,
onServerRecovered, onServerRecovered,
offServerRecovered, offServerRecovered,
stopHealthPolling, stopHealthPolling,
@@ -5,8 +5,9 @@ import { authApi } from '../api/index.js';
import { setApiBaseUrl, setTokenProvider } from '../api/http.js'; import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
import { cacheGet, cacheSet } from '../cache/db.js'; import { cacheGet, cacheSet } from '../cache/db.js';
import { normalizeServerUrl } from '../config.js'; import { normalizeServerUrl } from '../config.js';
import { profileCacheKey, setCacheScope } from '../cache/scope.js';
const PROFILE_KEY = 'me'; let profileKey = '';
let sessionServer = '';
/** /**
* 登录态。 * 登录态。
@@ -58,10 +59,13 @@ export const useAuthStore = defineStore('auth', () => {
loading.value = true; loading.value = true;
try { try {
const payload: LoginResponse = await authApi.classLogin({ code, password }); const payload: LoginResponse = await authApi.classLogin({ code, password });
profileKey = profileCacheKey(normalized, code);
sessionServer = normalized;
setCacheScope(normalized, payload.user.classId ?? payload.user.id);
applyToken(payload.token); applyToken(payload.token);
user.value = payload.user; user.value = payload.user;
offlineSession.value = false; offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, user.value); await cacheSet('profile', profileKey, user.value).catch(() => undefined);
await persistSession(normalized, code, payload.token); await persistSession(normalized, code, payload.token);
} finally { } finally {
loading.value = false; loading.value = false;
@@ -90,8 +94,11 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
const cachedProfile = await cacheGet<SessionUser>('profile', PROFILE_KEY).catch(() => null); profileKey = profileCacheKey(serverUrl, username);
sessionServer = serverUrl;
const cachedProfile = await cacheGet<SessionUser>('profile', profileKey).catch(() => null);
if (cachedProfile) user.value = cachedProfile.value; if (cachedProfile) user.value = cachedProfile.value;
setCacheScope(serverUrl, user.value?.classId ?? user.value?.id ?? `unresolved:${username}`);
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据 // 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
if (token.value) void refreshProfile(); if (token.value) void refreshProfile();
@@ -102,11 +109,14 @@ export const useAuthStore = defineStore('auth', () => {
/** 后台刷新个人资料;服务器不可达时保持缓存资料与离线会话标记 */ /** 后台刷新个人资料;服务器不可达时保持缓存资料与离线会话标记 */
async function refreshProfile(): Promise<void> { async function refreshProfile(): Promise<void> {
if (!token.value) return; if (!token.value) return;
const requestedToken = token.value;
try { try {
const profile = await authApi.me(); const profile = await authApi.me();
if (token.value !== requestedToken) return;
user.value = profile; user.value = profile;
setCacheScope(sessionServer, profile.classId ?? profile.id);
offlineSession.value = false; offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, profile); await cacheSet('profile', profileKey, profile).catch(() => undefined);
} catch { } catch {
offlineSession.value = Boolean(user.value); offlineSession.value = Boolean(user.value);
} }
@@ -120,6 +130,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
applyToken(null); applyToken(null);
user.value = null; user.value = null;
setCacheScope('', 'anonymous');
offlineSession.value = false; offlineSession.value = false;
if (window.desktop) await window.desktop.saveConfig({ token: null }); if (window.desktop) await window.desktop.saveConfig({ token: null });
} }
@@ -5,6 +5,7 @@ import { notificationApi } from '../api/index.js';
import { cacheSet } from '../cache/db.js'; import { cacheSet } from '../cache/db.js';
import { fetchWithCache } from '../cache/index.js'; import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from './app.js'; import { useAppStore } from './app.js';
import { getCacheScope, onCacheScopeChanged } from '../cache/scope.js';
/** /**
* 通知状态:列表 + 未读数。 * 通知状态:列表 + 未读数。
@@ -18,6 +19,23 @@ export const useNotificationStore = defineStore('notifications', () => {
const fromCache = ref(false); const fromCache = ref(false);
const updatedAt = ref<number | null>(null); const updatedAt = ref<number | null>(null);
const error = ref<string | null>(null); const error = ref<string | null>(null);
let requestId = 0;
let revision = 0;
const changes = new Map<string, { revision: number; value: NotificationDto | null }>();
onCacheScopeChanged(() => {
requestId += 1;
revision = 0;
changes.clear();
items.value = [];
unreadCount.value = 0;
loading.value = false;
fromCache.value = false;
updatedAt.value = null;
error.value = null;
});
function changed(id: string, value: NotificationDto | null): void {
changes.set(id, { revision: ++revision, value });
}
const unreadItems = computed(() => items.value.filter((item) => !item.read)); const unreadItems = computed(() => items.value.filter((item) => !item.read));
@@ -34,6 +52,9 @@ export const useNotificationStore = defineStore('notifications', () => {
} }
async function load(): Promise<void> { async function load(): Promise<void> {
const request = ++requestId;
const scope = getCacheScope();
const startedAt = revision;
loading.value = true; loading.value = true;
try { try {
const result = await fetchWithCache<NotificationDto[]>( const result = await fetchWithCache<NotificationDto[]>(
@@ -42,14 +63,23 @@ export const useNotificationStore = defineStore('notifications', () => {
() => notificationApi.list(), () => notificationApi.list(),
[], [],
); );
items.value = result.data; if (request !== requestId || scope !== getCacheScope()) return;
const merged = new Map(result.data.map((item) => [item.id, item]));
for (const [id, change] of changes) {
if (change.revision <= startedAt) continue;
if (change.value) merged.set(id, change.value);
else merged.delete(id);
}
items.value = [...merged.values()].sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
changes.clear();
fromCache.value = result.fromCache; fromCache.value = result.fromCache;
updatedAt.value = result.updatedAt; updatedAt.value = result.updatedAt;
error.value = result.error; error.value = result.error;
recount(); recount();
await persist();
if (!result.fromCache) useAppStore().markSynced(); if (!result.fromCache) useAppStore().markSynced();
} finally { } finally {
loading.value = false; if (request === requestId) loading.value = false;
} }
} }
@@ -57,6 +87,14 @@ export const useNotificationStore = defineStore('notifications', () => {
async function pushRealtime(payload: NotificationDto): Promise<void> { async function pushRealtime(payload: NotificationDto): Promise<void> {
if (items.value.some((item) => item.id === payload.id)) return; if (items.value.some((item) => item.id === payload.id)) return;
items.value = [{ ...payload, read: false }, ...items.value]; items.value = [{ ...payload, read: false }, ...items.value];
changed(payload.id, items.value[0]!);
recount();
await persist();
}
async function removeRealtime(id: string): Promise<void> {
items.value = items.value.filter((item) => item.id !== id);
changed(id, null);
recount(); recount();
await persist(); await persist();
} }
@@ -67,6 +105,7 @@ export const useNotificationStore = defineStore('notifications', () => {
if (target) { if (target) {
target.read = true; target.read = true;
target.readAt = new Date().toISOString(); target.readAt = new Date().toISOString();
changed(id, { ...target });
} }
recount(); recount();
await persist(); await persist();
@@ -76,6 +115,7 @@ export const useNotificationStore = defineStore('notifications', () => {
const result = await notificationApi.markAllRead(); const result = await notificationApi.markAllRead();
const now = new Date().toISOString(); const now = new Date().toISOString();
items.value = items.value.map((item) => ({ ...item, read: true, readAt: item.readAt ?? now })); items.value = items.value.map((item) => ({ ...item, read: true, readAt: item.readAt ?? now }));
for (const item of items.value) changed(item.id, item);
recount(); recount();
await persist(); await persist();
return result.marked; return result.marked;
@@ -100,6 +140,7 @@ export const useNotificationStore = defineStore('notifications', () => {
error, error,
load, load,
pushRealtime, pushRealtime,
removeRealtime,
markRead, markRead,
markAllRead, markAllRead,
refreshUnreadCount, refreshUnreadCount,
@@ -12,7 +12,6 @@ import {
} from '@classhelper/shared'; } from '@classhelper/shared';
import { socketUrlOf } from '../config.js'; import { socketUrlOf } from '../config.js';
import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js'; import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js';
import { useAppStore } from './app.js';
type RealtimeEventName = keyof ServerToClientEvents; type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void; type EventHandler = (payload: unknown) => void;
@@ -23,18 +22,6 @@ type EventHandler = (payload: unknown) => void;
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。 * 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
*/ */
export const useRealtimeStore = defineStore('realtime', () => { export const useRealtimeStore = defineStore('realtime', () => {
const app = useAppStore();
/**
* 本机要不要弹这次提醒。
*
* 教室在设置页选了「只在 ClassIsland 上弹」时,客户端仍然把通知收进通知中心(列表里有记录、
* 未读红点照常),但**不再弹窗、也不上岛** —— 否则老师会看到两个地方同时炸出来。
*/
function popupLocally(): boolean {
return app.shouldPopupLocally();
}
const connected = ref(false); const connected = ref(false);
const connecting = ref(false); const connecting = ref(false);
const lastEventName = ref<string | null>(null); const lastEventName = ref<string | null>(null);
@@ -76,18 +63,20 @@ export const useRealtimeStore = defineStore('realtime', () => {
dispatch('__connect_error__', error.message); dispatch('__connect_error__', error.message);
}); });
instance.on(SOCKET_EVENTS.notificationDeleted, (payload) => {
mark(SOCKET_EVENTS.notificationDeleted);
dispatch(SOCKET_EVENTS.notificationDeleted, payload);
});
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => { instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.notificationNew); mark(SOCKET_EVENTS.notificationNew);
if (popupLocally()) { ElMessage({
ElMessage({ message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`, type: payload.priority === 'URGENT' ? 'error' : 'info',
type: payload.priority === 'URGENT' ? 'error' : 'info', duration: 5000,
duration: 5000, showClose: true,
showClose: true, });
}); // 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出 pushNotificationToIsland(payload);
pushNotificationToIsland(payload);
}
dispatch(SOCKET_EVENTS.notificationNew, payload); dispatch(SOCKET_EVENTS.notificationNew, payload);
}); });
@@ -101,16 +90,14 @@ export const useRealtimeStore = defineStore('realtime', () => {
instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => { instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.callNew); mark(SOCKET_EVENTS.callNew);
if (popupLocally()) { ElMessage({
ElMessage({ message: payload.title,
message: payload.title, type: 'warning',
type: 'warning', duration: 8000,
duration: 8000, showClose: true,
showClose: true, });
}); // 叫人:无论是否上课都立即展开(老师正在等这位同学)
// 叫人:无论是否上课都立即展开(老师正在等这位同学) pushCallToIsland(payload);
pushCallToIsland(payload);
}
dispatch(SOCKET_EVENTS.callNew, payload); dispatch(SOCKET_EVENTS.callNew, payload);
}); });
@@ -384,7 +384,7 @@ onUnmounted(() => {
</el-table-column> </el-table-column>
<el-table-column label="等级" width="80"> <el-table-column label="等级" width="80">
<template #default="{ row }"> <template #default="{ row }">
{{ gradeLevel(gradePercent(row.score, row.totalScore)) }} {{ row.level || gradeLevel(gradePercent(row.score, row.totalScore)) }}
</template> </template>
</el-table-column> </el-table-column>
<el-table-column label="发布时间" width="170"> <el-table-column label="发布时间" width="170">
@@ -70,8 +70,6 @@ async function submit(): Promise<void> {
try { try {
await auth.login(form.serverUrl, form.code.trim(), form.password); await auth.login(form.serverUrl, form.code.trim(), form.password);
await appStore.init(form.serverUrl); await appStore.init(form.serverUrl);
// 登录后对齐"通知显示位置":这台机器上次选的是弹 ClassHelper 还是弹 ClassIsland
await appStore.loadNotificationChannel(auth.classId);
if (auth.token) realtime.connect(appStore.serverUrl, auth.token); if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
ElMessage.success(`已进入 ${auth.displayName}`); ElMessage.success(`已进入 ${auth.displayName}`);
await router.replace('/schedule'); await router.replace('/schedule');
@@ -12,7 +12,7 @@ import {
type ScheduleDto, type ScheduleDto,
type ScheduleWeekView, type ScheduleWeekView,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { dashboardApi, scheduleApi } from '../api/index.js'; import { scheduleApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js'; import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js'; import { useAuthStore } from '../stores/auth.js';
@@ -172,14 +172,9 @@ function remainText(minutes: number): string {
async function loadTerm(): Promise<void> { async function loadTerm(): Promise<void> {
// 带 classId:周次上限取本班"教学周数"(班主任可调,默认 20 周) // 带 classId:周次上限取本班"教学周数"(班主任可调,默认 20 周)
const result = await fetchWithCache('classes', 'term', () => dashboardApi.term(auth.classId ?? undefined), { await appStore.refreshTerm(auth.classId ?? undefined);
currentWeek: 1, weekOptions.value = buildWeekOptions(appStore.maxWeek);
maxWeek: 20, if (!week.value || week.value === 1) week.value = appStore.currentWeek;
});
appStore.currentWeek = result.data.currentWeek;
appStore.maxWeek = result.data.maxWeek;
weekOptions.value = buildWeekOptions(result.data.maxWeek);
if (!week.value || week.value === 1) week.value = result.data.currentWeek;
} }
async function loadSchedules(): Promise<void> { async function loadSchedules(): Promise<void> {
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { confirmAction } from '../../composables/confirm.js';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage } from 'element-plus';
import { useAuthStore } from '../../stores/auth.js'; import { useAuthStore } from '../../stores/auth.js';
import { useRealtimeStore } from '../../stores/realtime.js'; import { useRealtimeStore } from '../../stores/realtime.js';
@@ -13,7 +14,7 @@ const auth = useAuthStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
async function logout(): Promise<void> { async function logout(): Promise<void> {
await ElMessageBox.confirm('确认退出当前账号?', '退出登录', { type: 'warning' }); if (!(await confirmAction('确认退出当前账号?', '退出登录', { type: 'warning' }))) return;
realtime.disconnect(); realtime.disconnect();
await auth.logout(); await auth.logout();
ElMessage.success('已退出登录'); ElMessage.success('已退出登录');
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { confirmAction } from '../../composables/confirm.js';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage } from 'element-plus';
import { formatDate, relativeTime, type UpdateInfo } from '@classhelper/shared'; import { formatDate, relativeTime, type UpdateInfo } from '@classhelper/shared';
import type { ClientAgentStatus, DesktopAppInfo } from '../../../types/desktop.js'; import type { ClientAgentStatus, DesktopAppInfo } from '../../../types/desktop.js';
import { useAppStore } from '../../stores/app.js'; import { useAppStore } from '../../stores/app.js';
@@ -198,9 +199,12 @@ const cacheRows = computed(() =>
); );
async function clearCache(): Promise<void> { async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', { if (
type: 'warning', !(await confirmAction('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
}); type: 'warning',
}))
)
return;
await appStore.clearCache(); await appStore.clearCache();
ElMessage.success('离线缓存已清空'); ElMessage.success('离线缓存已清空');
} }
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { confirmAction } from '../../composables/confirm.js';
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'; import { computed, onBeforeUnmount, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage } from 'element-plus';
import { import {
HOMEWORK_PHRASE_DEFAULTS, HOMEWORK_PHRASE_DEFAULTS,
HOMEWORK_PHRASE_MAX_COUNT, HOMEWORK_PHRASE_MAX_COUNT,
@@ -93,9 +94,12 @@ async function manualSync(): Promise<void> {
} }
async function clearCache(): Promise<void> { async function clearCache(): Promise<void> {
await ElMessageBox.confirm('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', { if (
type: 'warning', !(await confirmAction('清空后离线时将无法查看历史数据,确认继续?', '清空离线缓存', {
}); type: 'warning',
}))
)
return;
await appStore.clearCache(); await appStore.clearCache();
ElMessage.success('离线缓存已清空'); ElMessage.success('离线缓存已清空');
} }
@@ -151,7 +155,7 @@ async function removePhrase(value: string): Promise<void> {
} }
async function resetPhrases(): Promise<void> { async function resetPhrases(): Promise<void> {
await ElMessageBox.confirm('恢复为默认快捷短语?', '恢复默认', { type: 'warning' }); if (!(await confirmAction('恢复为默认快捷短语?', '恢复默认', { type: 'warning' }))) return;
phrases.value = [...HOMEWORK_PHRASE_DEFAULTS]; phrases.value = [...HOMEWORK_PHRASE_DEFAULTS];
await savePhrases(); await savePhrases();
ElMessage.success('已恢复默认'); ElMessage.success('已恢复默认');
@@ -192,7 +196,7 @@ const updateTargetText = computed(() => {
const pendingUpdateText = computed(() => { const pendingUpdateText = computed(() => {
const pending = agent.value?.pendingUpdateVersion ?? ''; const pending = agent.value?.pendingUpdateVersion ?? '';
if (!pending) return '无'; if (!pending) return '无';
return `${pending}(已下载,空闲时自动安装)`; return `${pending}(已下载,等待按服务端指定的时机安装)`;
}); });
const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无'); const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { confirmAction } from '../../composables/confirm.js';
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus'; import { ElMessage } from 'element-plus';
import { import {
DEFAULT_ISLAND_APPEARANCE, DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES, ISLAND_APPEARANCE_RANGES as RANGES,
@@ -65,7 +66,7 @@ async function saveIslandAppearance(): Promise<void> {
} }
async function resetIslandAppearance(): Promise<void> { async function resetIslandAppearance(): Promise<void> {
await ElMessageBox.confirm('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' }); if (!(await confirmAction('恢复灵动岛的默认外观?', '恢复默认', { type: 'warning' }))) return;
applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE }); applyIslandAppearance({ ...DEFAULT_ISLAND_APPEARANCE });
await saveIslandAppearance(); await saveIslandAppearance();
} }
@@ -1,42 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { ElMessage } from 'element-plus'; import { classGradeQueryApi } from '../../api/index.js';
import {
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_CHANNELS,
formatDate,
type ClassIslandNotificationChannel,
} from '@classhelper/shared';
import { classGradeQueryApi, classIslandStatusApi } from '../../api/index.js';
import { useAppStore } from '../../stores/app.js';
import { useAuthStore } from '../../stores/auth.js'; import { useAuthStore } from '../../stores/auth.js';
/**
* 提醒设置:这台教室机器把提醒弹在哪个端(客户端 / ClassIsland / 两端都弹)。
* 值同时落本地配置与班级记录(服务端据此决定是否推送 ClassIsland)。
*/
const appStore = useAppStore();
const auth = useAuthStore(); const auth = useAuthStore();
const channelSaving = ref(false);
/**
* 本班有没有接入 ClassIsland 设备。
*
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
*/
const classIslandConnected = ref(false);
/** 本班 ClassIsland 设备的展示信息(设备名 / 最后上报),来自 /classes/:id/classisland-status */
const classIslandStatus = ref<{
connected: boolean;
deviceName: string | null;
deviceCount: number;
lastSeenAt: string | null;
pluginVersion: string | null;
classIslandVersion: string | null;
} | null>(null);
/* ------------------------------------------------------------ 班级端成绩查询状态(只读) /* ------------------------------------------------------------ 班级端成绩查询状态(只读)
* *
* 「是否允许班级端查看成绩」是**服务端口径**:管理员在 Web 端「班级管理」里开关, * 「是否允许班级端查看成绩」是**服务端口径**:管理员在 Web 端「班级管理」里开关,
@@ -56,43 +22,7 @@ async function loadGradeQuery(): Promise<void> {
} }
} }
const classIslandStatusText = computed(() => { onMounted(loadGradeQuery);
const status = classIslandStatus.value;
if (!status || !status.connected) return '未接入';
const seen = status.lastSeenAt ? `最后上报 ${formatDate(status.lastSeenAt, true)}` : '尚未上报过';
return `已接入 · ${status.deviceName ?? 'ClassIsland 设备'} · ${seen}`;
});
const channelOptions = computed(() =>
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
value,
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
disabled: value === 'classisland' && !classIslandConnected.value,
})),
);
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
channelSaving.value = true;
try {
const saved = await appStore.setNotificationChannel(auth.classId, value);
if (saved) ElMessage.success('提醒显示位置已更新');
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
} finally {
channelSaving.value = false;
}
}
onMounted(async () => {
await appStore.loadNotificationChannel(auth.classId);
// 本班 ClassIsland 联动状态:没接设备就别让选「只在 ClassIsland 上弹」
if (auth.classId) {
const status = await classIslandStatusApi.get(auth.classId).catch(() => null);
classIslandStatus.value = status;
classIslandConnected.value = status?.connected === true;
}
// 班级端成绩查询开关(存在班级上,与 Web 端共享同一个值)
await loadGradeQuery();
});
</script> </script>
<template> <template>
@@ -100,44 +30,14 @@ onMounted(async () => {
<div class="page-header"> <div class="page-header">
<div> <div>
<h2 class="page-title">提醒</h2> <h2 class="page-title">提醒</h2>
<p class="page-subtitle">老师发通知 / 叫人时,提醒弹在哪个端</p> <p class="page-subtitle">通知提醒与成绩查询状态</p>
</div> </div>
</div> </div>
<el-card shadow="never"> <el-card shadow="never">
<template #header><span>ClassIsland 联动</span></template> <template #header>通知提醒</template>
<el-descriptions :column="1" border size="small" class="mb-12">
<el-descriptions-item label="联动状态">{{ classIslandStatusText }}</el-descriptions-item>
<el-descriptions-item label="插件 / ClassIsland">
{{ classIslandStatus?.pluginVersion ?? '—' }} /
{{ classIslandStatus?.classIslandVersion ?? '—' }}
</el-descriptions-item>
</el-descriptions>
<p class="text-muted"> <p class="text-muted">
<strong>通知模式</strong> 通知与叫人通过 ClassHelper 客户端和灵动岛提醒。普通通知在上课期间暂存,下课后补弹;紧急通知立即提醒。
:老师在 ClassHelper 上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
</p>
<el-radio-group
:model-value="appStore.notificationChannel"
:disabled="channelSaving"
class="channel-group"
@change="
(value: string | number | boolean | undefined) =>
applyChannel(value as ClassIslandNotificationChannel)
"
>
<el-radio v-for="item in channelOptions" :key="item.value" :value="item.value" :disabled="item.disabled">
{{ item.label }}
</el-radio>
</el-radio-group>
<p class="text-muted channel-hint">
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
<template v-if="!classIslandConnected">
<br />
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassHelper 联动」里接入后才能选"只在
ClassIsland 上弹")。
</template>
</p> </p>
</el-card> </el-card>
@@ -150,9 +50,7 @@ onMounted(async () => {
打开后,可以在「成绩」页看到本班成绩与排名。学生本身没有账号、不能登录, 打开后,可以在「成绩」页看到本班成绩与排名。学生本身没有账号、不能登录,
查成绩就是由这台教室机器代做。关闭后本机查成绩会被服务端拒绝,教师端不受影响。 查成绩就是由这台教室机器代做。关闭后本机查成绩会被服务端拒绝,教师端不受影响。
</p> </p>
<p class="text-muted"> <p class="text-muted">本开关由管理员在服务端「班级管理」里统一设置,教室机器不提供本地开关。</p>
本开关由管理员在服务端「班级管理」和「客户端设备」里统一设置,教室机器不提供本地开关。
</p>
<el-tag :type="gradeQueryEnabled ? 'success' : 'info'" effect="plain"> <el-tag :type="gradeQueryEnabled ? 'success' : 'info'" effect="plain">
{{ gradeQueryEnabled ? '当前:已允许查看成绩与排名' : '当前:已由管理员关闭' }} {{ gradeQueryEnabled ? '当前:已允许查看成绩与排名' : '当前:已由管理员关闭' }}
</el-tag> </el-tag>
+3 -14
View File
@@ -2,13 +2,7 @@
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。 * 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
* 渲染进程只依赖这些最小 API,不接触 Node.js。 * 渲染进程只依赖这些最小 API,不接触 Node.js。
*/ */
import type { import type { IslandAppearance, IslandNotification, IslandState, UpdateInfo } from '@classhelper/shared';
ClassIslandNotificationChannel,
IslandAppearance,
IslandNotification,
IslandState,
UpdateInfo,
} from '@classhelper/shared';
import type { ClientAgentStatus } from '../main/client-agent.js'; import type { ClientAgentStatus } from '../main/client-agent.js';
/** 服务端联动状态:渲染进程要显示它,因此在这里转出(`desktop.d.ts` 是两端契约的单一入口) */ /** 服务端联动状态:渲染进程要显示它,因此在这里转出(`desktop.d.ts` 是两端契约的单一入口) */
@@ -45,13 +39,6 @@ export interface DesktopStoredConfig {
* 录入作业时点一下就追加到标题/内容里,少打字(例如 P、大本、背诵)。 * 录入作业时点一下就追加到标题/内容里,少打字(例如 P、大本、背诵)。
*/ */
homeworkPhrases: string[]; homeworkPhrases: string[];
/**
* 通知显示位置(both / client / classisland)。
*
* 这台教室机器"提醒到底弹在哪个端"由它决定:值同时存本地(决定本机要不要弹窗/上岛)
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
*/
notificationChannel: ClassIslandNotificationChannel;
/** /**
* 是否已看过「初次启动引导」。 * 是否已看过「初次启动引导」。
* *
@@ -170,6 +157,7 @@ export interface DesktopBridge {
/* 灵动岛 */ /* 灵动岛 */
/** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */ /** 把一条通知投递到灵动岛(payload 必须是纯数据,说明见 saveConfig) */
islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void; islandPush(payload: { notification: IslandNotification; context?: IslandPushContext }): void;
islandRemoveNotification(id: string): void;
/** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */ /** 同步上课状态:进入上课隐藏,下课后自动弹出暂存通知 */
islandSetClassState(payload: IslandClassStatePayload): void; islandSetClassState(payload: IslandClassStatePayload): void;
/** 读取灵动岛当前状态(设置页/冒烟验证用) */ /** 读取灵动岛当前状态(设置页/冒烟验证用) */
@@ -255,6 +243,7 @@ declare global {
/** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */ /** 冒烟验证钩子,仅在 ELECTRON_SMOKE_TEST=1 时由渲染进程注册 */
__classhelperSmoke__?: { __classhelperSmoke__?: {
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>; cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
cacheIsolationSelfTest(): Promise<{ ok: boolean; detail: string }>;
offlineScenario(): Promise<{ ok: boolean; detail: string }>; offlineScenario(): Promise<{ ok: boolean; detail: string }>;
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>; layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
onlineScenario(credentials?: { onlineScenario(credentials?: {
+3 -4
View File
@@ -1,6 +1,6 @@
{ {
"name": "@classhelper/server", "name": "@classhelper/server",
"version": "1.3.2", "version": "1.3.3",
"private": true, "private": true,
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问", "description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
"type": "module", "type": "module",
@@ -9,7 +9,7 @@
"dev": "tsx watch src/index.ts", "dev": "tsx watch src/index.ts",
"start": "node dist/index.js", "start": "node dist/index.js",
"start:tsx": "tsx src/index.ts", "start:tsx": "tsx src/index.ts",
"build": "tsc -p tsconfig.build.json", "build": "node scripts/build.mjs",
"typecheck": "tsc -p tsconfig.json", "typecheck": "tsc -p tsconfig.json",
"db:generate": "prisma generate", "db:generate": "prisma generate",
"db:migrate": "prisma migrate dev", "db:migrate": "prisma migrate dev",
@@ -18,8 +18,7 @@
"db:seed": "prisma db seed", "db:seed": "prisma db seed",
"db:reset": "prisma migrate reset --force", "db:reset": "prisma migrate reset --force",
"db:studio": "prisma studio", "db:studio": "prisma studio",
"verify:e2e": "node scripts/verify-e2e.mjs", "verify:e2e": "node scripts/verify-e2e.mjs"
"verify:classisland": "node scripts/verify-classisland.mjs"
}, },
"dependencies": { "dependencies": {
"@classhelper/shared": "workspace:*", "@classhelper/shared": "workspace:*",
@@ -0,0 +1,13 @@
-- 移除退役联动数据;业务课表保留并归为手工管理。
DROP TABLE IF EXISTS "ClassIslandPush";
DROP TABLE IF EXISTS "IntegrationDevice";
ALTER TABLE "Class" DROP COLUMN "notificationChannel";
UPDATE "Schedule" SET "source" = 'manual' WHERE "source" = 'classisland';
-- 默认组按角色绑定,改名也不会失去对应关系。
ALTER TABLE "PermissionGroup" ADD COLUMN "defaultRole" TEXT;
CREATE UNIQUE INDEX "PermissionGroup_defaultRole_key" ON "PermissionGroup"("defaultRole");
DELETE FROM "PermissionGroupItem" WHERE "key" IN ('device:manage', 'classisland:notify');
ALTER TABLE "ClientDevice" ADD COLUMN "updateMode" TEXT NOT NULL DEFAULT 'idle';
ALTER TABLE "ClientDevice" ADD COLUMN "updateNotBefore" DATETIME;
@@ -0,0 +1,6 @@
-- 未实现独立接口的权限不再展示;原有管理组继承对应只读能力。
DELETE FROM "PermissionGroupItem" WHERE "key" IN ('log:read', 'developer:mode');
INSERT OR IGNORE INTO "PermissionGroupItem" ("id", "groupId", "key")
SELECT "groupId" || ':roster:read', "groupId", 'roster:read' FROM "PermissionGroupItem" WHERE "key" = 'roster:manage';
INSERT OR IGNORE INTO "PermissionGroupItem" ("id", "groupId", "key")
SELECT "groupId" || ':teacher:read', "groupId", 'teacher:read' FROM "PermissionGroupItem" WHERE "key" = 'teacher:manage';
+6 -124
View File
@@ -219,11 +219,8 @@ model Class {
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它 /// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
termWeeks Int @default(20) termWeeks Int @default(20)
/// 本班学期开始日期(YYYY-MM-DD,本地日期);留空则用全局配置(.env 的 TERM_START_DATE)。 /// 本班学期开始日期(YYYY-MM-DD,本地日期);留空则用全局配置(.env 的 TERM_START_DATE)。
/// 管理员改它会重算「当前第几周」,并随镜像课表下发给教室的 ClassIsland。 /// 管理员改它会重算「当前第几周」,并广播课表更新。
termStartDate String? termStartDate String?
/// 通知显示位置(由 ClassHelper 班级端在设置页里选):
/// both=班级端与 ClassIsland 都弹 / client=只弹班级端 / classisland=只弹 ClassIsland
notificationChannel String @default("both")
/// 是否允许 ClassHelper 班级端按学号查询本班学生的成绩明细(默认开启)。 /// 是否允许 ClassHelper 班级端按学号查询本班学生的成绩明细(默认开启)。
/// 关闭后班级端查成绩一律 403;教师端不受影响。 /// 关闭后班级端查成绩一律 403;教师端不受影响。
studentGradeQueryEnabled Boolean @default(true) studentGradeQueryEnabled Boolean @default(true)
@@ -243,8 +240,6 @@ model Class {
grades Grade[] grades Grade[]
exams Exam[] exams Exam[]
timeLayouts TimeLayout[] timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
clientDevices ClientDevice[] clientDevices ClientDevice[]
@@index([teacherId]) @@index([teacherId])
@@ -295,9 +290,6 @@ model Schedule {
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射) /// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
weekParity String @default("ALL") weekParity String @default("ALL")
/// 该行的来源:manual=手工建立(Web 端录入 / ClassIsland JSON 导入) /// 该行的来源:manual=手工建立(Web 端录入 / ClassIsland JSON 导入)
/// / classisland=插件上报同步得到。
/// 插件每次全量上报时只清理「classisland 来源且本次未再上报」的行(解决"老师在 ClassIsland
/// 里删掉一节、服务端却永远留着"的幽灵课),老师手排的课一律不删。
source String @default("manual") source String @default("manual")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@ -436,120 +428,9 @@ model Grade {
@@index([courseId]) @@index([courseId])
} }
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班的 ClassHelper 班级端)
///
/// 说明:
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
/// 避免设备列表里堆满重复项;
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课";
/// 4) `lastHeartbeatAt` 每次上报都刷新(在线判定用它),`lastSeenAt` 只在**带运行状态**的
/// 上报时刷新 —— 这样"能连上但一直没上报状态"的设备不会被误判成完全正常。
model IntegrationDevice {
id String @id @default(cuid())
classId String
/// 设备显示名(默认取机器名)
name String
/// 设备唯一标识(插件上报的机器码)
deviceKey String
/// 联动方式:plugin=实时插件联动 / import=仅一次性导入
mode String @default("plugin")
/// 是否接入(关闭后插件上报与下发一律 403)
enabled Boolean @default(true)
/// 设备令牌的 sha256(明文只在创建/重置时返回一次)
tokenHash String
/// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
tokenHint String
/// 是否允许插件把 ClassIsland 的课表回传到本服务
///
/// **默认关闭**(2026-10-06 起):课表以服务端为准,由服务端**自动下发**到教室;
/// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
syncScheduleToServer Boolean @default(false)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。
///
/// **2026-10-11 起含义收窄为"允许下发"**:不再是"自动同步的主方向" —— 插件以前每 15 秒
/// 上报一次、每次上报完都顺手镜像一遍(`MirrorSchedule` 开着时),教室的课表被反复重写;
/// 现在镜像只由 `requestMirrorSchedule` 这个一次性待办触发,这里只表示"这台设备接不接受下发"。
mirrorScheduleToClassIsland Boolean @default(true)
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
requestScheduleReport Boolean @default(false)
/// 待办的「立即下发一次课表」请求:Web 端点「下发课表到教室」时置位,
/// 插件在下一次心跳看到它就去拉本班课表并**强制替换**写进 ClassIsland,
/// 拉取成功即由服务端清除标记(见 `pullClassPlanForDevice`)。
///
/// 为什么要一次性待办而不是"开关":教室机常常离线,一个实时下发的长连接握手不值得,
/// 而后台定时镜像会与老师在教室本地做的调整互相打架(2026-10-11 实测:
/// 插件每 15 秒重写一遍课表,服务端那边看起来就是课表被反复删改)。
requestMirrorSchedule Boolean @default(false)
classIslandVersion String?
pluginVersion String?
/// 最后一次**带运行状态**的上报时间
lastSeenAt DateTime?
/// 最后一次心跳时间(每次上报都刷新);「在线」看它
lastHeartbeatAt DateTime?
/// 以下为最后一次上报的快照
classPlanLoaded Boolean @default(false)
currentSubject String?
currentTimeState String?
currentPeriodStart String?
currentPeriodEnd String?
nextSubject String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
pushes ClassIslandPush[]
@@unique([classId, deviceKey])
@@index([classId])
@@index([tokenHash])
}
/// 下发给 ClassIsland 的提醒:教师发通知 → 学生机器全屏弹出。
/// 落库是为了「设备离线时通知不丢」——插件重连后拉取未确认的一条立即弹出。
model ClassIslandPush {
id String @id @default(cuid())
classId String
deviceId String?
title String
content String
/// 显示时长(秒):映射到 ClassIsland 的 NotificationContent.Duration
durationSeconds Int @default(8)
/// 语音朗读内容;null = 不朗读
speechContent String?
urgent Boolean @default(false)
/// 提醒类型:notification=通知类 / call=叫人(叫人算「主动通知」,上课时段也立刻弹)
kind String @default("notification")
/// 发起人的用户 id(教师/管理员)
createdBy String?
/// 同时落库到通知中心的通知 id(saveToNotifications 时写入)
notificationId String?
/// 插件确认弹出的时间(确认后不再重复下发)
ackedAt DateTime?
createdAt DateTime @default(now())
/// 过期时间:超过后不再补发(避免学生几天后突然弹出旧提醒)
expiresAt DateTime
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
device IntegrationDevice? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
@@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 { model PermissionGroup {
/// 角色默认组;自定义组为空,改名不影响对应角色。
defaultRole String? @unique
id String @id @default(cuid()) id String @id @default(cuid())
name String @unique name String @unique
description String @default("") description String @default("")
@@ -606,8 +487,7 @@ model AppSetting {
/// ///
/// 身份由客户端主进程生成并持久化的 `deviceKey`(UUID)标识,心跳每 30 秒一次: /// 身份由客户端主进程生成并持久化的 `deviceKey`(UUID)标识,心跳每 30 秒一次:
/// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控 /// 服务端由此知道**每台机器在哪个班、版本多少、是否在线**,并把更新与远控
/// 指令按「查库 + 心跳拉取」的范式下发(不新增长连接握手,照抄 /// 指令按「查库 + 心跳拉取」的范式下发,不新增长连接握手。
/// `IntegrationDevice.requestScheduleReport` 的做法)。
model ClientDevice { model ClientDevice {
id String @id @default(cuid()) id String @id @default(cuid())
/// 该设备当前登录的班级(未登录时为空) /// 该设备当前登录的班级(未登录时为空)
@@ -624,6 +504,8 @@ model ClientDevice {
autoUpdate Boolean @default(true) autoUpdate Boolean @default(true)
/// 服务端指定的目标版本('' = 不指定,按最新版走) /// 服务端指定的目标版本('' = 不指定,按最新版走)
updateTargetVersion String @default("") updateTargetVersion String @default("")
updateMode String @default("idle")
updateNotBefore DateTime?
/// 已下载待安装的版本('' = 无) /// 已下载待安装的版本('' = 无)
pendingVersion String @default("") pendingVersion String @default("")
/// 最近一次心跳时间(**在线判定看它**) /// 最近一次心跳时间(**在线判定看它**)
@@ -1,9 +1,8 @@
/** /**
* 维护脚本:给"已有数据库"补齐结构性新增列(不依赖安装程序升级)。 * 维护脚本:给"已有数据库"补齐结构性新增列(不依赖安装程序升级)。
* *
* 为什么需要它:安装版服务端只在**库为空**时执行随包迁移 SQL(首次启动建表); * 服务端启动时会自动应用完整迁移。本脚本仅用于排查旧库缺列,
* 用安装程序覆盖升级时,升级逻辑会自动补齐新迁移。若像本地联调那样**手工覆盖 dist** * 不能替代正式迁移(例如移除联动表、创建权限组索引)。
* 部署,就需要显式跑一次本脚本。
* *
* 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**, * 用法(用安装目录自带的 node 即可,无需 SDK):传入**已编译的 `lib/db.js`**,
* 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new): * 直接复用服务端自己配置好的 Prisma 客户端(Prisma 7 需要 driver adapter,不能裸 new):
@@ -56,12 +55,6 @@ const COLUMNS = [
sql: `ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT ''`, sql: `ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT ''`,
note: '成绩等级文本(percent 下由得分率换算,可手改)', note: '成绩等级文本(percent 下由得分率换算,可手改)',
}, },
{
table: 'IntegrationDevice',
column: 'lastHeartbeatAt',
sql: `ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME`,
note: 'ClassHelper 班级端最后一次心跳(在线判定看它)',
},
{ {
table: 'User', table: 'User',
column: 'phone', column: 'phone',
+20
View File
@@ -0,0 +1,20 @@
/** 先清理编译目录,确保已移除的模块不会残留在下一次交付物中。 */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { createRequire } from 'node:module';
import { spawnSync } from 'node:child_process';
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const require = createRequire(import.meta.url);
fs.rmSync(path.join(root, 'dist'), { recursive: true, force: true });
const result = spawnSync(
process.execPath,
[require.resolve('typescript/bin/tsc'), '-p', 'tsconfig.build.json'],
{
cwd: root,
stdio: 'inherit',
},
);
if (result.error) throw result.error;
process.exitCode = result.status ?? 1;
@@ -1,679 +0,0 @@
#!/usr/bin/env node
/**
* ClassIsland 联动验证(插件侧接口 + 下发链路)。
*
* 覆盖范围:
* 1) 设备令牌鉴权:无令牌 / 错令牌一律 401,令牌作废后立即失效
* 2) 插件上报 → 服务端写库(课表 + 节次时间 + 状态快照)幂等
* 3) 教师下发提醒 → 落库 → 插件 pending 拉到 → ack 后不再补发
* 4) 停用设备后插件上报被 403 拦下
* 5) 课表镜像拉取(mirrorScheduleToClassIsland)返回 ClassIsland 口径的数据
*
* 用法:
* 1) 另开终端启动后端:pnpm dev:server
* 2) 执行:node packages/server/scripts/verify-classisland.mjs
*
* 环境变量:
* VERIFY_BASE_URL 默认 http://127.0.0.1:4000(脚本直连,不受系统代理影响)
*
* 注意:本脚本只操作「新建设备 + 该设备对应班级」的数据,
* 结束时会把新建的设备删除;上报的课表会写进该班(与导入模块语义一致,可被覆盖)。
*/
const BASE_URL = process.env.VERIFY_BASE_URL ?? `http://127.0.0.1:${process.env.PORT ?? 4000}`;
const results = [];
function record(name, ok, detail = '') {
results.push({ name, ok, detail });
console.log(`${ok ? '\u2713' : '\u2717'} ${name}${detail ? ` (${detail})` : ''}`);
}
async function api(path, { method = 'GET', token, deviceToken, body } = {}) {
const headers = { 'Content-Type': 'application/json' };
if (token) headers.Authorization = `Bearer ${token}`;
if (deviceToken) headers['X-ClassIsland-Token'] = deviceToken;
const response = await fetch(`${BASE_URL}/api${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
});
const payload = await response.json().catch(() => null);
return { status: response.status, payload };
}
/** 造一份插件会上报的课表(周一/周二,含单双周) */
function sampleSchedule() {
return {
mode: 'merge',
entries: [
{
dayOfWeek: 1,
startTime: '08:00',
endTime: '08:45',
subject: '语文',
teacherName: '王老师',
weekParity: 'ALL',
weekCountDiv: 0,
weekCountDivTotal: 0,
planName: '周一课表',
},
{
dayOfWeek: 1,
startTime: '08:55',
endTime: '09:40',
subject: '数学',
teacherName: '李老师',
weekParity: 'ODD',
weekCountDiv: 1,
weekCountDivTotal: 2,
planName: '周一课表',
},
{
dayOfWeek: 2,
startTime: '08:00',
endTime: '08:45',
subject: '英语',
teacherName: '张老师',
weekParity: 'ALL',
weekCountDiv: 0,
weekCountDivTotal: 0,
planName: '周二课表',
},
],
};
}
function sampleTimeLayout() {
return {
name: 'ClassIsland 验证时间表',
mode: 'replace',
items: [
{ index: 1, name: '第 1 节', startTime: '08:00', endTime: '08:45', type: 'class', skipped: false },
{ index: 2, name: '课间', startTime: '08:45', endTime: '08:55', type: 'break', skipped: false },
{ index: 3, name: '第 2 节', startTime: '08:55', endTime: '09:40', type: 'class', skipped: false },
],
};
}
/** 按候选密码依次登录,返回可用 token(不同环境的默认密码可能不同) */
async function loginWithCandidates(username, passwords) {
for (const password of passwords) {
const result = await api('/auth/login', { method: 'POST', body: { username, password } });
const token = result.payload?.data?.token;
if (token) return token;
}
return null;
}
/**
* 反复「拉取待提醒 → 逐条确认」,直到该设备视角下没有积压。
*
* 为什么验证脚本要做这件事:`GET /integrations/classisland/pending` 是
* 「本班全部未确认且未过期的提醒,按时间升序取前 20 条」。而 `verify:desktop` /
* `verify:web` 等冒烟会**真的往演示班发通知**(AGENTS.md §5 第 38 条),
* 那些条如果没被确认就会一直堆着 —— 攒够 20 条之后,本脚本刚发的提醒会被挤到窗口之外,
* 表现为「待提醒里找不到刚发的那条」,与代码对不对完全无关(2026-10-05 实测踩过:
* 库里积了 45 条历次冒烟残条,本脚本从 42/42 掉到 35/40)。
*
* 这里做的事情与真实插件重连后的行为完全一致(补弹积压并逐条确认),
* 因此不会掩盖任何产品侧问题。可见范围也一致:只处理 `deviceId` 为空的班级广播
* 与本设备自己的推送,别的设备的目标推送取不到、也确认不了。
*/
async function drainPending(deviceToken) {
let total = 0;
for (let round = 0; round < 20; round += 1) {
const pending = await api('/integrations/classisland/pending', { deviceToken });
const items = pending.payload?.data?.notifications ?? [];
if (items.length === 0) break;
for (const item of items) {
await api('/integrations/classisland/ack', { method: 'POST', deviceToken, body: { id: item.id } });
total += 1;
}
}
return total;
}
async function main() {
console.log(`\n=== ClassIsland 联动验证(${BASE_URL})===\n`);
const health = await api('/health');
if (health.status !== 200) {
console.log('后端未启动,请先运行 pnpm dev:server\n');
process.exit(1);
}
const modules = health.payload?.data?.modules ?? [];
record('integrations 模块已挂载', modules.includes('integrations'), modules.join(', '));
if (!modules.includes('integrations')) process.exit(1);
// ---------------------------------------------------------------- 登录
// 演示库里的教师密码由 .env 的 DEFAULT_TEACHER_PASSWORD 决定(种子脚本用它建号),
// 因此这里按候选列表尝试,避免「换了环境就整脚本失败」。
const teacherToken = await loginWithCandidates('teacher1', ['teacher123', '123456']);
record('教师登录 teacher1', Boolean(teacherToken));
if (!teacherToken) process.exit(1);
// 管理员用于取班级码(只有管理员/班主任能看到班级码),学生隔离用例需要它
const adminToken = await loginWithCandidates('admin', ['admin123']);
const classes = await api('/classes', { token: teacherToken });
const target = (classes.payload?.data ?? [])[0];
record('取到可管理的班级', Boolean(target?.id), target ? `${target.name} (${target.id})` : '无');
if (!target?.id) process.exit(1);
// 叫人用例需要一位学生;班级账号用来验证"教室客户端自己选显示位置"
const students = await api(`/classes/${target.id}/students`, { token: teacherToken });
const firstStudent = (students.payload?.data ?? [])[0];
/** 用班级码 + 班级密码登录,拿班级账号 token(教室机器的身份) */
async function classLoginToken() {
if (adminToken) {
const detail = await api(`/classes/${target.id}`, { token: adminToken });
const code = detail.payload?.data?.code;
if (code) {
for (const password of ['123456', 'class123']) {
const result = await api('/auth/class-login', {
method: 'POST',
body: { code, password },
});
const token = result.payload?.data?.token;
if (token) return token;
}
}
}
return null;
}
// ---------------------------------------------------------------- 1. 新建设备
const created = await api('/integrations/devices', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, name: '验证用设备', syncScheduleToServer: true },
});
const deviceToken = created.payload?.data?.token;
const deviceId = created.payload?.data?.device?.id;
record(
'新建设备并返回设备令牌',
created.status === 201 && typeof deviceToken === 'string' && deviceToken.startsWith('chci_'),
`status=${created.status} hint=${created.payload?.data?.device?.tokenHint ?? '-'}`,
);
if (!deviceToken || !deviceId) process.exit(1);
// ---------------------------------------------------------------- 2. 令牌鉴权
const noToken = await api('/integrations/classisland/report', { method: 'POST', body: {} });
record('无令牌上报被拒(401)', noToken.status === 401, `status=${noToken.status}`);
const badToken = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken: 'chci_deadbeef',
body: {},
});
record('错误令牌上报被拒(401)', badToken.status === 401, `status=${badToken.status}`);
// ---------------------------------------------------------------- 3. 上报(状态 + 课表 + 节次)
const reportBody = {
pluginVersion: '1.1.2.0',
classIslandVersion: '2.1.0.0',
state: {
inClass: true,
subject: '语文',
nextSubject: '数学',
timeState: 'OnClass',
periodStart: '08:00',
periodEnd: '08:45',
week: 1,
classPlanLoaded: true,
clientTime: new Date().toISOString(),
},
schedule: sampleSchedule(),
timeLayout: sampleTimeLayout(),
};
const report1 = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: reportBody,
});
const r1 = report1.payload?.data;
record(
'插件上报课表写入成功',
report1.status === 200 && r1?.scheduleApplied === true && r1.scheduleCreated + r1.scheduleUpdated === 3,
`新增=${r1?.scheduleCreated} 更新=${r1?.scheduleUpdated} 周=${r1?.week}`,
);
record('插件上报节次时间写入成功', r1?.timeLayoutApplied === true, `applied=${r1?.timeLayoutApplied}`);
record('上报回传服务端周次与时间', typeof r1?.serverTime === 'string' && Number.isInteger(r1?.week));
// 幂等:重复上报不应产生重复行
const report2 = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: reportBody,
});
const r2 = report2.payload?.data;
record(
'重复上报幂等(不新增、只更新)',
r2?.scheduleCreated === 0 && r2?.scheduleUpdated === 3,
`新增=${r2?.scheduleCreated} 更新=${r2?.scheduleUpdated}`,
);
// 设备列表里的状态快照
const devices = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
const listed = (devices.payload?.data ?? []).find((item) => item.id === deviceId);
record(
'设备状态快照已更新(Web 端可展示)',
Boolean(listed?.lastSeenAt) &&
listed?.currentSubject === '语文' &&
listed?.currentTimeState === 'OnClass',
`科目=${listed?.currentSubject} 状态=${listed?.currentTimeState} ClassIsland=${listed?.classIslandVersion}`,
);
// ---------------------------------------------------------------- 4. 下发提醒
// 先确认掉本班的历史积压(历次冒烟留下的未确认提醒),否则 /pending 的前 20 条窗口
// 会被它们占满,下面「插件拉取到待提醒」等用例会拿不到刚发的这条(理由见 drainPending)
const drained = await drainPending(deviceToken);
if (drained > 0) {
console.log(`(已确认本班 ${drained} 条历史积压提醒,避免挤占待提醒窗口)\n`);
}
const notify = await api('/integrations/classisland/notify', {
method: 'POST',
token: teacherToken,
body: {
classId: target.id,
title: '请到讲台领资料',
content: '下课后来讲台领取期中复习资料',
durationSeconds: 12,
speech: true,
},
});
const n1 = notify.payload?.data;
record(
'教师下发提醒(落库 + 标记设备数)',
notify.status === 200 && n1?.targetCount >= 1 && Boolean(n1?.notificationId),
`设备数=${n1?.targetCount} 通知id=${n1?.notificationId ? '有' : '无'}`,
);
const pending = await api('/integrations/classisland/pending', { deviceToken });
const items = pending.payload?.data?.notifications ?? [];
const picked = items.find((item) => item.title === '请到讲台领资料');
record(
'插件拉取到待提醒(离线补齐链路可用)',
pending.status === 200 && Boolean(picked),
`待提醒数=${items.length} 时长=${picked?.durationSeconds}s 朗读=${Boolean(picked?.speechContent)}`,
);
if (picked) {
const ack = await api('/integrations/classisland/ack', {
method: 'POST',
deviceToken,
body: { id: picked.id },
});
record(
'插件确认提醒已弹出',
ack.status === 200 && ack.payload?.data?.acked === true,
`status=${ack.status}`,
);
const pending2 = await api('/integrations/classisland/pending', { deviceToken });
const again = (pending2.payload?.data?.notifications ?? []).some((item) => item.id === picked.id);
record('确认后不再补发(避免重复弹窗)', !again, `仍在待提醒中=${again}`);
}
// ---------------------------------------------------------------- 4.5 其他发布入口也会联动 ClassIsland
// 需求:"在其他页面发通知也可以联动 ClassIsland"。通知发布页 / 叫人走的是各自的接口,
// 这里逐个验证它们真的会生成待弹出提醒(而不是只有联动页那个专用接口才联动)。
const classChannel = await api(`/classes/${target.id}/notification-channel`, { token: teacherToken });
record(
'班级有"通知显示位置"(默认两端都弹)',
classChannel.status === 200 && classChannel.payload?.data?.notificationChannel === 'both',
`channel=${classChannel.payload?.data?.notificationChannel}`,
);
const published = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, title: '通知页联动验证', content: '从通知发布页发的通知', priority: 'NORMAL' },
});
const pendingAfterPublish = await api('/integrations/classisland/pending', { deviceToken });
const publishPush = (pendingAfterPublish.payload?.data?.notifications ?? []).find(
(item) => item.title === '通知页联动验证',
);
record(
'通知发布页发的通知也会推送到 ClassIsland',
published.status === 201 && Boolean(publishPush),
`status=${published.status} 待提醒=${(pendingAfterPublish.payload?.data?.notifications ?? []).length}`,
);
// kind 决定插件"上课时段弹不弹":通知类会被暂存,叫人属于主动通知要立刻弹
record(
'通知类推送的 kind=notification(上课时段插件会暂存)',
publishPush?.kind === 'notification',
`kind=${publishPush?.kind}`,
);
if (publishPush) {
await api('/integrations/classisland/ack', {
method: 'POST',
deviceToken,
body: { id: publishPush.id },
});
}
const call = await api('/calls', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, studentId: firstStudent?.id, message: '叫人联动验证' },
});
const pendingAfterCall = await api('/integrations/classisland/pending', { deviceToken });
const callPush = (pendingAfterCall.payload?.data?.notifications ?? []).find(
(item) => item.content === '叫人联动验证',
);
record(
'叫人也会推送到 ClassIsland',
Boolean(firstStudent) && call.status === 201 && Boolean(callPush),
firstStudent ? `status=${call.status}` : '班级里没有学生,跳过',
);
record(
'叫人推送的 kind=call(算主动通知,上课时段也立刻弹)',
callPush?.kind === 'call',
`kind=${callPush?.kind}`,
);
if (callPush) {
await api('/integrations/classisland/ack', {
method: 'POST',
deviceToken,
body: { id: callPush.id },
});
}
// 教室在客户端里选"只在 ClassHelper 客户端显示"后,任何入口都不该再打扰 ClassIsland
const setClient = await api(`/classes/${target.id}/notification-channel`, {
method: 'PATCH',
token: teacherToken,
body: { notificationChannel: 'client' },
});
record(
'可把"通知显示位置"改成只在客户端显示',
setClient.status === 200 && setClient.payload?.data?.notificationChannel === 'client',
`channel=${setClient.payload?.data?.notificationChannel}`,
);
const mutedPublish = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, title: '不该上 ClassIsland 的通知', content: '教室选择了只在客户端显示' },
});
const mutedNotify = await api('/integrations/classisland/notify', {
method: 'POST',
token: teacherToken,
body: { classId: target.id, title: '联动页也不该下发', content: '同样被教室的设置拦下' },
});
const pendingMuted = await api('/integrations/classisland/pending', { deviceToken });
const mutedItems = pendingMuted.payload?.data?.notifications ?? [];
record(
'显示位置=client 时通知不再推送 ClassIsland',
mutedPublish.status === 201 && !mutedItems.some((item) => item.title === '不该上 ClassIsland 的通知'),
`待提醒=${mutedItems.length}`,
);
record(
'显示位置=client 时联动页的下发也会被跳过(并说明原因)',
mutedNotify.status === 200 &&
mutedNotify.payload?.data?.skipped === 'channel-client' &&
String(mutedNotify.payload?.message ?? '').includes('只在 ClassHelper 客户端显示'),
`skipped=${mutedNotify.payload?.data?.skipped} message=${mutedNotify.payload?.message}`,
);
// 班级账号(教室机器)自己就能读写这项设置 —— 需求就是"由 ClassIsland 之外的客户端自己选"
const classSessionToken = await classLoginToken();
const readByClass = await api(`/classes/${target.id}/notification-channel`, {
token: classSessionToken,
});
record(
'班级账号可读取通知显示位置',
readByClass.status === 200 && readByClass.payload?.data?.notificationChannel === 'client',
`channel=${readByClass.payload?.data?.notificationChannel}`,
);
const setByClass = await api(`/classes/${target.id}/notification-channel`, {
method: 'PATCH',
token: classSessionToken,
body: { notificationChannel: 'both' },
});
record(
'班级账号可修改通知显示位置(由教室客户端自己选)',
setByClass.status === 200 && setByClass.payload?.data?.notificationChannel === 'both',
`channel=${setByClass.payload?.data?.notificationChannel}`,
);
const badChannel = await api(`/classes/${target.id}/notification-channel`, {
method: 'PATCH',
token: teacherToken,
body: { notificationChannel: 'nope' },
});
record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`);
// ---------------------------------------------------------------- 5. 课表镜像
//
// 同步方向在 2026-10-06 反转:**服务端 → 教室自动下发**成为主方向(默认开启),
// 「把教室课表传上来」改成 Web 端的一次性人工动作。因此这里先验证"默认就下发",
// 再验证"关掉之后确实不下发"。
const mirrorDefault = await api('/integrations/classisland/class-plan', { deviceToken });
record(
'课表默认由服务端自动下发(新方向:服务端 → 教室)',
mirrorDefault.status === 200 && mirrorDefault.payload?.data?.classPlan?.entries?.length >= 0,
`status=${mirrorDefault.status} data=${mirrorDefault.payload?.data === null ? 'null' : 'object'}`,
);
const disabled = await api(`/integrations/devices/${deviceId}`, {
method: 'PATCH',
token: teacherToken,
body: { mirrorScheduleToClassIsland: false },
});
record(
'关闭「镜像课表到 ClassIsland」',
disabled.status === 200 && disabled.payload?.data?.mirrorScheduleToClassIsland === false,
`status=${disabled.status}`,
);
const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
record(
'关掉镜像后不再下发课表',
mirrorOff.status === 200 &&
(mirrorOff.payload?.data === null || mirrorOff.payload?.data === undefined),
`data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`,
);
const enabled = await api(`/integrations/devices/${deviceId}`, {
method: 'PATCH',
token: teacherToken,
body: { mirrorScheduleToClassIsland: true },
});
record(
'重新打开「镜像课表到 ClassIsland」',
enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
`status=${enabled.status}`,
);
const mirror = await api('/integrations/classisland/class-plan', { deviceToken });
const plan = mirror.payload?.data?.classPlan;
const weekDays = (plan?.entries ?? []).map((item) => item.weekDay);
record(
'拉取课表为 ClassIsland 口径(0=周日 … 6=周六)',
mirror.status === 200 &&
Array.isArray(plan?.entries) &&
plan.entries.length >= 3 &&
weekDays.every((day) => day >= 0 && day <= 6),
`条目=${plan?.entries?.length} weekDay=[${weekDays.join(',')}] 档案名=${plan?.profileName}`,
);
record(
'单双周换算为 WeekCountDiv/Total',
(plan?.entries ?? []).some((item) => item.weekCountDivTotal === 2),
(plan?.entries ?? [])
.map((item) => `${item.subject}:${item.weekCountDiv}/${item.weekCountDivTotal}`)
.join(' '),
);
// ClassInfo.Index 与"第 i 个上课时间点"一一对应,因此镜像条目必须带起止时间,
// 否则插件只能按数组顺序硬排,一旦某天少一节整份课表就错位(这是最容易漏的一处契约)
const timeOk = (plan?.entries ?? []).every(
(item) => /^\d{2}:\d{2}$/.test(item.startTime ?? '') && /^\d{2}:\d{2}$/.test(item.endTime ?? ''),
);
record(
'镜像条目带起止时间(插件据此重建上课时间点)',
(plan?.entries ?? []).length > 0 && timeOk,
(plan?.entries ?? [])
.slice(0, 3)
.map((item) => `${item.subject} ${item.startTime}-${item.endTime}`)
.join(' | '),
);
const classPoints = (plan?.timeLayouts?.[0]?.layouts ?? []).filter((item) => item.timeType === 0);
record(
'镜像时间表含上课时间点(与课表条目对得上)',
classPoints.length > 0 && (plan?.entries ?? []).length >= classPoints.length / 2,
`上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
);
/* ---------------------------------------------------------------- 5.1 课表同步:幽灵行清理 + 机器码回填 */
// 老师在 Web 端手排的一节课(source=manual):插件同步时**绝不能**删它
const courseList = await api(`/courses?classId=${target.id}`, { token: teacherToken });
const courseId = (courseList.payload?.data ?? [])[0]?.id;
const manualCreated = await api('/schedules', {
method: 'POST',
token: teacherToken,
body: {
classId: target.id,
courseId,
dayOfWeek: 7,
startTime: '21:00',
endTime: '21:45',
weekStart: 1,
weekEnd: 20,
weekParity: 'ALL',
},
});
const manualScheduleId = manualCreated.payload?.data?.id;
record(
'(准备)Web 端手排一节课(source=manual)',
manualCreated.status === 201 && Boolean(manualScheduleId),
`status=${manualCreated.status}`,
);
const entry = (startTime, endTime) => ({
dayOfWeek: 6,
startTime,
endTime,
subject: '语文',
weekParity: 'ALL',
});
/** 该班课表里"周六 19:xx"的行(插件上报写入的那些) */
const listSaturday = async () => {
const list = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
return (list.payload?.data ?? []).filter(
(item) => item.dayOfWeek === 6 && item.startTime.startsWith('19:'),
);
};
// 第一次上报:两节(第二节随后会被"老师在 ClassIsland 里删掉")
const ghostReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: {
deviceKey: 'smoke-machine-01',
schedule: { mode: 'merge', entries: [entry('19:00', '19:45'), entry('19:50', '20:35')] },
},
});
record(
'插件上报两节课(其中一节随后会被删除)',
ghostReport.payload?.data?.scheduleApplied === true,
`status=${ghostReport.status}`,
);
const beforePrune = await listSaturday();
record('上报后两节都在库里', beforePrune.length === 2, `周六 19 点=${beforePrune.length} 节`);
// 第二次上报:只报第二节 → 第一节应当被清理。
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
// ClassHelper 班级端与 Web 端会一直显示一节实际上已经不上的课。
const pruneReport = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: {
deviceKey: 'smoke-machine-01',
schedule: { mode: 'merge', entries: [entry('19:50', '20:35')] },
},
});
const afterPrune = await listSaturday();
record(
'插件侧删除的课被同步清理(不再留幽灵课)',
pruneReport.payload?.data?.scheduleApplied === true &&
afterPrune.length === 1 &&
afterPrune[0]?.startTime === '19:50',
`周六 19 点=${afterPrune.map((item) => item.startTime).join(',') || '空'}`,
);
// 只清理"插件同步出来的行":老师手排的课必须活着
const scheduleList = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
const manualAlive = (scheduleList.payload?.data ?? []).some((item) => item.id === manualScheduleId);
record('Web 端手排的课不被插件同步删除', manualAlive, `手排行 id=${manualScheduleId ?? '-'}`);
// 机器码回填:设备列表里不再是 pending-…
const deviceList = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
const backfilledKey =
(deviceList.payload?.data ?? []).find((item) => item.id === deviceId)?.deviceKey ?? '';
record(
'插件上报的机器码已回填到设备记录',
backfilledKey === 'smoke-machine-01',
`deviceKey=${backfilledKey}`,
);
// 清理:把手排的课与本次上报写进去的课都删掉,避免污染演示数据
if (manualScheduleId) {
await api(`/schedules/${manualScheduleId}`, { method: 'DELETE', token: teacherToken });
}
for (const row of await listSaturday()) {
await api(`/schedules/${row.id}`, { method: 'DELETE', token: teacherToken });
}
record('清理:本次验证写入的课表与手排课已删除', (await listSaturday()).length === 0, '周六 19 点已清空');
// ---------------------------------------------------------------- 6. 停用设备
await api(`/integrations/devices/${deviceId}`, {
method: 'PATCH',
token: teacherToken,
body: { enabled: false },
});
const blocked = await api('/integrations/classisland/report', {
method: 'POST',
deviceToken,
body: { state: { inClass: false, clientTime: new Date().toISOString() } },
});
record('停用设备后插件上报被拒(403)', blocked.status === 403, `status=${blocked.status}`);
// ---------------------------------------------------------------- 7. 权限隔离
// 班级码只有管理员/班主任可见,班级密码也是默认值,因此这里按管理员信息补齐
let classCode = target.code ?? null;
if (!classCode && adminToken) {
const all = await api('/classes', { token: adminToken });
classCode = (all.payload?.data ?? []).find((item) => item.id === target.id)?.code ?? null;
}
const studentLogin = await api('/auth/class-login', {
method: 'POST',
body: { code: classCode ?? '', password: '123456' },
});
const studentToken = studentLogin.payload?.data?.token;
if (studentToken) {
const forbidden = await api('/integrations/devices', { token: studentToken });
record('学生无法查看联动设备列表(403)', forbidden.status === 403, `status=${forbidden.status}`);
} else {
record('班级账号登录(跳过学生隔离用例)', false, `status=${studentLogin.status}`);
}
// ---------------------------------------------------------------- 清理
const removed = await api(`/integrations/devices/${deviceId}`, { method: 'DELETE', token: teacherToken });
record('删除验证设备(清理)', removed.status === 200, `status=${removed.status}`);
const failed = results.filter((item) => !item.ok);
console.log(`\n=== 结果:${results.length - failed.length}/${results.length} 通过 ===`);
if (failed.length > 0) {
console.log('失败用例:');
for (const item of failed) console.log(` - ${item.name} (${item.detail})`);
process.exit(1);
}
console.log('ClassIsland 联动链路全部通过。\n');
}
main().catch((error) => {
console.error('验证脚本异常:', error);
process.exit(1);
});
+58 -85
View File
@@ -476,7 +476,11 @@ async function main() {
try { try {
const { payload, elapsed } = await notificationWait; const { payload, elapsed } = await notificationWait;
record('ClassHelper 班级端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`); record(
'ClassHelper 班级端 5 秒内收到 notification:new',
elapsed < TIME_LIMIT_MS,
`${elapsed}ms · ${payload?.title}`,
);
} catch (error) { } catch (error) {
record('ClassHelper 班级端 5 秒内收到 notification:new', false, error.message); record('ClassHelper 班级端 5 秒内收到 notification:new', false, error.message);
} }
@@ -534,7 +538,11 @@ async function main() {
try { try {
const { payload, elapsed } = await homeworkWait; const { payload, elapsed } = await homeworkWait;
record('ClassHelper 班级端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`); record(
'ClassHelper 班级端 5 秒内收到 homework:new',
elapsed < TIME_LIMIT_MS,
`${elapsed}ms · ${payload?.title}`,
);
} catch (error) { } catch (error) {
record('ClassHelper 班级端 5 秒内收到 homework:new', false, error.message); record('ClassHelper 班级端 5 秒内收到 homework:new', false, error.message);
} }
@@ -705,8 +713,8 @@ async function main() {
'管理员设置某班某科的任课老师(PUT /subject-teachers)', '管理员设置某班某科的任课老师(PUT /subject-teachers)',
adminAssignSubject.status === 200 && adminAssignSubject.status === 200 &&
(adminAssignSubject.payload?.data ?? []).some( (adminAssignSubject.payload?.data ?? []).some(
(item) => item.subjectName === probeSubject && (item) =>
item.teacherId === teacher2Login.payload?.data?.user?.id, item.subjectName === probeSubject && item.teacherId === teacher2Login.payload?.data?.user?.id,
), ),
`status=${adminAssignSubject.status}`, `status=${adminAssignSubject.status}`,
); );
@@ -1145,11 +1153,7 @@ async function main() {
token: adminToken, token: adminToken,
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] }, body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
}); });
record( record('管理员分配任课老师成功(200)', adminAssign.status === 200, `status=${adminAssign.status}`);
'管理员分配任课老师成功(200)',
adminAssign.status === 200,
`status=${adminAssign.status}`,
);
// 解除 = 把该科还回班主任(Course.teacherId 是必填列,不能留空) // 解除 = 把该科还回班主任(Course.teacherId 是必填列,不能留空)
const adminUnassign = await api(`/classes/${classId}/subject-teachers`, { const adminUnassign = await api(`/classes/${classId}/subject-teachers`, {
method: 'PUT', method: 'PUT',
@@ -1183,8 +1187,9 @@ async function main() {
// 新规则:**作业按科目授权**。 // 新规则:**作业按科目授权**。
// 科任老师只能布置自己任教科目(Course.teacherId)的作业; // 科任老师只能布置自己任教科目(Course.teacherId)的作业;
// 不指定科目的作业只有班主任与管理员能发 —— 两条都要验到。 // 不指定科目的作业只有班主任与管理员能发 —— 两条都要验到。
const subjectCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken })) const subjectCourse = (
.payload?.data?.find((item) => item.teacherId === teacher2Id); await api(`/courses?classId=${subjectClassId}`, { token: adminToken })
).payload?.data?.find((item) => item.teacherId === teacher2Id);
const subjectHomework = await api('/homeworks', { const subjectHomework = await api('/homeworks', {
method: 'POST', method: 'POST',
token: subjectClassId ? teacher2Token : teacherToken, token: subjectClassId ? teacher2Token : teacherToken,
@@ -1215,8 +1220,9 @@ async function main() {
); );
// 科任老师改**别人任教科目**的作业同样被拒 // 科任老师改**别人任教科目**的作业同样被拒
if (subjectCourse) { if (subjectCourse) {
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken })) const otherCourse = (
.payload?.data?.find((item) => item.teacherId !== teacher2Id); await api(`/courses?classId=${subjectClassId}`, { token: adminToken })
).payload?.data?.find((item) => item.teacherId !== teacher2Id);
// 先确认"别人任教的科目"这个前提存在:缺了它,下面那条用例会因为"没带科目" // 先确认"别人任教的科目"这个前提存在:缺了它,下面那条用例会因为"没带科目"
// 而被拒(403),看着是绿的,其实没验到"非任教科目"这条规则。 // 而被拒(403),看着是绿的,其实没验到"非任教科目"这条规则。
record( record(
@@ -1355,7 +1361,9 @@ async function main() {
); );
} }
const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, { token: teacherToken }); const badDay = await api(`/notifications/days?classId=${classId}&from=2026-13-45`, {
token: teacherToken,
});
record('通知归档接口拒绝非法日期(422)', badDay.status === 422, `status=${badDay.status}`); record('通知归档接口拒绝非法日期(422)', badDay.status === 422, `status=${badDay.status}`);
// 批量发布通知:一次提交多个班,返回逐班结果 // 批量发布通知:一次提交多个班,返回逐班结果
@@ -1545,25 +1553,32 @@ async function main() {
`含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`, `含 homework:write=${teacher2ResetPermissions.includes('homework:write')}`,
); );
// 管理员固定拥有全部权限,不接受分配 // 管理员可以分配权限组,但始终保留两个管理入口
const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, { const assignAdmin = await api(`/permissions/users/${adminMember?.id}/groups`, {
method: 'PUT', method: 'PUT',
token: adminToken, token: adminToken,
body: { groupIds: permissionGroupId ? [permissionGroupId] : [] }, body: { groupIds: permissionGroupId ? [permissionGroupId] : [] },
}); });
record( record(
'管理员不接受权限组分配(PUT /users/:id/groups → 400)', '管理员可以分配权限组(PUT /users/:id/groups → 200)',
Boolean(adminMember) && Boolean(adminMember) && assignAdmin.status === 200 && assignAdmin.payload?.data?.fixed === false,
assignAdmin.status === 400 &&
String(assignAdmin.payload?.message ?? '').includes('权限组'),
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`, `status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
); );
await api(`/permissions/users/${adminMember?.id}/groups`, {
method: 'PUT',
token: adminToken,
body: { groupIds: [] },
});
// 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败) // 内置权限组不可删除:先找一个 builtin 的(**演示库当前没有内置组**,此时只报事实、不算失败)
const groupsList = await api('/permissions/groups', { token: adminToken }); const groupsList = await api('/permissions/groups', { token: adminToken });
const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null; const builtinGroup = (groupsList.payload?.data ?? []).find((item) => item.builtin === true) ?? null;
if (builtinGroup) { if (builtinGroup) {
const deleteBuiltin = await api(`/permissions/groups/${builtinGroup.id}`, { method: 'DELETE', token: adminToken }); const deleteBuiltin = await api(`/permissions/groups/${builtinGroup.id}`, {
method: 'DELETE',
token: adminToken,
});
record( record(
'内置权限组不可删除(DELETE /permissions/groups/:id → 400)', '内置权限组不可删除(DELETE /permissions/groups/:id → 400)',
deleteBuiltin.status === 400, deleteBuiltin.status === 400,
@@ -1607,7 +1622,11 @@ async function main() {
// 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 JWT 一律 401 // 心跳不强求权限键(教室端会话权限集为空),但**必须登录**:没有 JWT 一律 401
const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase }); const heartbeatNoAuth = await api('/clients/heartbeat', { method: 'POST', body: heartbeatBase });
const heartbeat = await api('/clients/heartbeat', { method: 'POST', token: adminToken, body: heartbeatBase }); const heartbeat = await api('/clients/heartbeat', {
method: 'POST',
token: adminToken,
body: heartbeatBase,
});
const heartbeatData = heartbeat.payload?.data; const heartbeatData = heartbeat.payload?.data;
record( record(
'客户端心跳 POST /clients/heartbeat(未登录 401 / 登录后 200 且返回 deviceId 与 command 字段)', '客户端心跳 POST /clients/heartbeat(未登录 401 / 登录后 200 且返回 deviceId 与 command 字段)',
@@ -1644,7 +1663,11 @@ async function main() {
const command = await api('/clients/commands', { const command = await api('/clients/commands', {
method: 'POST', method: 'POST',
token: adminToken, token: adminToken,
body: { deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'], kind: 'lock', note: 'E2E 远控用例' }, body: {
deviceIds: [device?.id ?? heartbeatData?.deviceId ?? 'none'],
kind: 'lock',
note: 'E2E 远控用例',
},
}); });
const heartbeatWithCommand = await api('/clients/heartbeat', { const heartbeatWithCommand = await api('/clients/heartbeat', {
method: 'POST', method: 'POST',
@@ -1677,7 +1700,8 @@ async function main() {
); );
const devicesAfterAck = await api('/clients/devices', { token: adminToken }); const devicesAfterAck = await api('/clients/devices', { token: adminToken });
const ackedDevice = (devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null; const ackedDevice =
(devicesAfterAck.payload?.data ?? []).find((item) => item.deviceKey === deviceKey) ?? null;
record( record(
'回执后设备不再有待执行命令(pendingCommand 为空)', '回执后设备不再有待执行命令(pendingCommand 为空)',
Boolean(ackedDevice) && ackedDevice.pendingCommand === null, Boolean(ackedDevice) && ackedDevice.pendingCommand === null,
@@ -1803,7 +1827,9 @@ async function main() {
}); });
record( record(
'开启大模型助手并固定为离线 mock(PUT /ai/settings)', '开启大模型助手并固定为离线 mock(PUT /ai/settings)',
enableAi.status === 200 && enableAi.payload?.data?.enabled === true && enableAi.payload?.data?.provider === 'mock', 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}`, `status=${enableAi.status} enabled=${enableAi.payload?.data?.enabled} provider=${enableAi.payload?.data?.provider}`,
); );
@@ -1916,8 +1942,7 @@ async function main() {
const diagnosticsText = JSON.stringify(diagnostics.payload?.data ?? {}); const diagnosticsText = JSON.stringify(diagnostics.payload?.data ?? {});
record( record(
'运行诊断 GET /system/diagnostics(200 且不泄漏 apiKey/password/token/secret)', '运行诊断 GET /system/diagnostics(200 且不泄漏 apiKey/password/token/secret)',
diagnostics.status === 200 && diagnostics.status === 200 && !/apiKey|password|token|secret/i.test(diagnosticsText),
!/apiKey|password|token|secret/i.test(diagnosticsText),
`status=${diagnostics.status} 字段=${Object.keys(diagnostics.payload?.data ?? {}).join(',')}`, `status=${diagnostics.status} 字段=${Object.keys(diagnostics.payload?.data ?? {}).join(',')}`,
); );
} }
@@ -2930,8 +2955,8 @@ async function main() {
body: { username: 'smoke_denied_teacher', name: '越权教师', password: 'smoke123456' }, body: { username: 'smoke_denied_teacher', name: '越权教师', password: 'smoke123456' },
}); });
record( record(
'仅管理员可录入教师(教师读取/新建均 403)', '教师名单只读与管理分离(默认教师可读取,但不能新建)',
teacherSeesTeachers.status === 403 && teacherCreatesTeacher.status === 403, teacherSeesTeachers.status === 200 && teacherCreatesTeacher.status === 403,
`列表=${teacherSeesTeachers.status} 新建=${teacherCreatesTeacher.status} message=${teacherCreatesTeacher.payload?.message ?? ''}`, `列表=${teacherSeesTeachers.status} 新建=${teacherCreatesTeacher.status} message=${teacherCreatesTeacher.payload?.message ?? ''}`,
); );
@@ -3187,8 +3212,6 @@ async function main() {
'Notification', 'Notification',
'NotificationRead', 'NotificationRead',
'Grade', 'Grade',
'IntegrationDevice',
'ClassIslandPush',
'PermissionGroup', 'PermissionGroup',
'PermissionGroupItem', 'PermissionGroupItem',
'UserPermissionGroup', 'UserPermissionGroup',
@@ -3511,13 +3534,12 @@ async function main() {
// force 只对管理员生效:教师带 force=1 也必须命中缓存(否则任意登录用户都能刷爆匿名限流) // force 只对管理员生效:教师带 force=1 也必须命中缓存(否则任意登录用户都能刷爆匿名限流)
const updateTeacherForced = await api('/update/check?force=1', { token: teacherToken }); const updateTeacherForced = await api('/update/check?force=1', { token: teacherToken });
record( record(
'更新检查:教师可查(公开信息),但 force 仅管理员生效', '更新检查:默认教师可查,强制刷新需要 update:force',
updateTeacherForced.status === 200 && updateTeacherForced.status === 200 &&
updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt, updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt,
`status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`, `status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`,
); );
/* ---------------------------------------------------------------- 13. 学期周次(仅管理员可写) */ /* ---------------------------------------------------------------- 13. 学期周次(仅管理员可写) */
// 需求:「第几周的周次数据可以由 admin 修改,并对所有 ClassIsland 下发修改」。 // 需求:「第几周的周次数据可以由 admin 修改,并对所有 ClassIsland 下发修改」。
// 线性推算遇到调休/补课会整体错位,所以支持逐周指定日期区间。 // 线性推算遇到调休/补课会整体错位,所以支持逐周指定日期区间。
@@ -3624,62 +3646,13 @@ async function main() {
`configured=${termRestored.payload?.data?.configured}`, `configured=${termRestored.payload?.data?.configured}`,
); );
/* ---------------------------------------------------------------- 14. 课表同步方向反转 */ const retiredIntegration = await api('/integrations/devices', { token: adminToken });
// 自建一台临时设备(收尾删掉):不依赖演示库里有没有接入过设备
const tempDevice = await api('/integrations/devices', {
method: 'POST',
token: adminToken,
body: { classId, name: 'E2E 课表方向校验设备' },
});
const tempDeviceId = tempDevice.payload?.data?.device?.id;
record( record(
'课表方向:新建设备默认「服务端自动下发、不自动回传」', '退役联动接口不再提供服务',
tempDevice.status === 201 && retiredIntegration.status === 404,
tempDevice.payload?.data?.device?.syncScheduleToServer === false && `status=${retiredIntegration.status}`,
tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland === true,
`status=${tempDevice.status} syncScheduleToServer=${tempDevice.payload?.data?.device?.syncScheduleToServer} mirrorScheduleToClassIsland=${tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland}`,
); );
if (tempDeviceId) {
const pullRequest = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
method: 'POST',
token: adminToken,
});
record(
'从教室机器获取课表:管理员可发起(POST /request-schedule)',
pullRequest.status === 200 && pullRequest.payload?.data?.requested === true,
`status=${pullRequest.status} ${pullRequest.payload?.message ?? ''}`,
);
// 置位后再读设备列表应当看到待办(插件下一次心跳看到就会立刻把课表推上来)
const afterRequest = await api(`/integrations/devices?classId=${classId}`, { token: adminToken });
const requestedDevice = (afterRequest.payload?.data ?? []).find((item) => item.id === tempDeviceId);
record(
'从教室机器获取课表:待办标记已落库(插件下次心跳会照做)',
requestedDevice?.requestScheduleReport === true,
`requestScheduleReport=${requestedDevice?.requestScheduleReport}`,
);
// 教师对**别人班**的设备不能发起
const deniedPull = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
method: 'POST',
token: teacher2Token,
});
record(
'从教室机器获取课表:非本班教师被拒绝(403)',
deniedPull.status === 403,
`status=${deniedPull.status}`,
);
// 收尾:删掉临时设备
const removedDevice = await api(`/integrations/devices/${tempDeviceId}`, {
method: 'DELETE',
token: adminToken,
});
record('课表方向用例收尾:临时设备已删除', removedDevice.status === 200, `status=${removedDevice.status}`);
}
socket.close(); socket.close();
// ---------------------------------------------------------------- 汇总 // ---------------------------------------------------------------- 汇总
+3
View File
@@ -7,6 +7,7 @@ import { env } from './config/env.js';
import { disconnectPrisma, pingDatabase } from './lib/db.js'; import { disconnectPrisma, pingDatabase } from './lib/db.js';
import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js'; import { ensureInitialAdmin, ensureSchema } from './lib/db-bootstrap.js';
import { logger } from './lib/logger.js'; import { logger } from './lib/logger.js';
import { backfillNotificationDates } from './lib/backfill-notification-dates.js';
import { refreshDeveloperMode } from './lib/settings.js'; import { refreshDeveloperMode } from './lib/settings.js';
import { resolveWebDistDir } from './lib/web-static.js'; import { resolveWebDistDir } from './lib/web-static.js';
import { initRealtime } from './realtime/socket.js'; import { initRealtime } from './realtime/socket.js';
@@ -51,6 +52,8 @@ async function prepareDatabase(): Promise<void> {
} }
await prepareDatabase(); await prepareDatabase();
const backfilledDates = await backfillNotificationDates();
if (backfilledDates) logger.info(`已补齐 ${backfilledDates} 条历史通知的所属日期`);
// 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts) // 开发者模式的内存缓存:错误中间件每请求都要问一次,不可能为此查库(见 lib/settings.ts)
await refreshDeveloperMode(); await refreshDeveloperMode();
@@ -0,0 +1,24 @@
import { dayKeyLocal } from '@classhelper/shared';
import { prisma } from './db.js';
/** 旧叫人通知没有所属日期;用服务进程的本地日期口径幂等回填,兼容 SQLite 与 MySQL。 */
export async function backfillNotificationDates(): Promise<number> {
let updated = 0;
for (;;) {
const rows = await prisma.notification.findMany({
where: { assignDate: '' },
select: { id: true, createdAt: true },
take: 200,
});
if (!rows.length) return updated;
await prisma.$transaction(
rows.map((row) =>
prisma.notification.update({
where: { id: row.id },
data: { assignDate: dayKeyLocal(row.createdAt) },
}),
),
);
updated += rows.length;
}
}
+17
View File
@@ -90,6 +90,23 @@ async function loadAppliedMigrations(client: {
`CREATE TABLE IF NOT EXISTS "${MIGRATION_LEDGER}" ("name" TEXT NOT NULL PRIMARY KEY, "appliedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`, `CREATE TABLE IF NOT EXISTS "${MIGRATION_LEDGER}" ("name" TEXT NOT NULL PRIMARY KEY, "appliedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
); );
// 开发库/源码部署由 Prisma 管理迁移;覆盖安装时接纳其已完成记录,
// 不能重跑已被后续迁移改过的旧 SQL(例如已移除的 User.classId 索引)。
const prismaLedger = await prisma.$queryRawUnsafe<Array<{ name: string }>>(
`SELECT name FROM sqlite_master WHERE type = 'table' AND name = '_prisma_migrations'`,
);
if (prismaLedger.length > 0) {
const completed = await prisma.$queryRawUnsafe<Array<{ migration_name: string }>>(
`SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL`,
);
for (const row of completed) {
await client.execute({
sql: `INSERT OR IGNORE INTO "${MIGRATION_LEDGER}" ("name") VALUES (?)`,
args: [row.migration_name],
} as never);
}
}
const rows = await prisma.$queryRawUnsafe<Array<{ name: string }>>( const rows = await prisma.$queryRawUnsafe<Array<{ name: string }>>(
`SELECT name FROM "${MIGRATION_LEDGER}"`, `SELECT name FROM "${MIGRATION_LEDGER}"`,
); );
+8 -2
View File
@@ -2,8 +2,14 @@ import type { Response } from 'express';
import type { ApiResponse } from '@classhelper/shared'; import type { ApiResponse } from '@classhelper/shared';
/** 成功响应:{ success: true, data, message } */ /** 成功响应:{ success: true, data, message } */
export function sendOk<T>(res: Response, data: T, message = 'ok', status = 200): void { export function sendOk<T>(
const body: ApiResponse<T> = { success: true, data, message }; res: Response,
data: T,
message = 'ok',
status = 200,
legacyFields: object = {},
): void {
const body: ApiResponse<T> = { ...legacyFields, success: true, data, message };
res.status(status).json(body); res.status(status).json(body);
} }
-9
View File
@@ -3,7 +3,6 @@ import type {
ArchivedClassDto, ArchivedClassDto,
ArchivedYearDto, ArchivedYearDto,
ClassDto, ClassDto,
ClassIslandNotificationChannel,
ClassTeacherBrief, ClassTeacherBrief,
CourseBrief, CourseBrief,
CourseDto, CourseDto,
@@ -191,8 +190,6 @@ export interface ClassLike {
teacherId: string; teacherId: string;
/** 本学期教学周数(班主任可调,默认 20) */ /** 本学期教学周数(班主任可调,默认 20) */
termWeeks?: number; termWeeks?: number;
/** 通知显示位置:both / client / classisland(由 ClassHelper 班级端设置) */
notificationChannel?: string | null;
/** 班级端按学号查成绩的开关 */ /** 班级端按学号查成绩的开关 */
studentGradeQueryEnabled?: boolean | null; studentGradeQueryEnabled?: boolean | null;
/** 毕业归档所属届别;null = 在读 */ /** 毕业归档所属届别;null = 在读 */
@@ -209,11 +206,6 @@ export interface ClassLike {
}; };
} }
/** 通知显示位置:库里存字符串,出参收敛成联合类型(非法值一律按默认 both 处理) */
export function toNotificationChannel(value: string | null | undefined): ClassIslandNotificationChannel {
return value === 'client' || value === 'classisland' ? value : 'both';
}
export function toClassDto(item: ClassLike): ClassDto { export function toClassDto(item: ClassLike): ClassDto {
return { return {
id: item.id, id: item.id,
@@ -223,7 +215,6 @@ export function toClassDto(item: ClassLike): ClassDto {
classIndex: item.classIndex ?? null, classIndex: item.classIndex ?? null,
teacherId: item.teacherId, teacherId: item.teacherId,
termWeeks: item.termWeeks ?? 20, termWeeks: item.termWeeks ?? 20,
notificationChannel: toNotificationChannel(item.notificationChannel),
// 默认开启:老库补列时的默认值也是 true,两边保持一致 // 默认开启:老库补列时的默认值也是 true,两边保持一致
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true, studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
archivedYearId: item.archivedYearId ?? null, archivedYearId: item.archivedYearId ?? null,
Loaded 100 of 184 files, more files were not shown because too many files have changed in this diff. Show more