feat(integrations,classisland,server,web,desktop): ClassIsland 联动插件——上报课表与上课状态、下发提醒并回执、教室端推送通道
教室机器上的 ClassIsland 现在可以直接和班级小助手互通:插件走 /api/integrations/classisland/*,用设备令牌(X-ClassIsland-Token,库里只存 sha256)独立鉴权,不占用 JWT 通道。 - 服务端:新增 integrations 模块(上报 / 待弹出 / 回执 / 课表镜像 / 回写),两个迁移(设备令牌表、Class.notificationChannel);通知、叫人、联动页下发统一复用 pushToClassIsland,避免"某个页面发的通知不联动" - 插件:packages/classisland-plugin(.NET 8,独立于 pnpm workspace)——BridgeService 上报与提醒轮询、ScheduleMapper/ClassPlanWriter 课表镜像、Avalonia 设置页、ClassHelperNotificationProvider 在 ClassIsland 上弹出提醒;本地/内网地址强制直连,躲开系统代理返回的假 404 - 客户端:设置页可选提醒通道(both / client / classisland),并订阅相应实时事件 - Web 端:新增「ClassIsland 联动」页(设备管理与提醒下发) - 校验与打包:verify:classisland(需后端)、verify:classisland-plugin(静态契约 54 项)、build/dist:classisland-plugin 产出 .cipx - 文档:README 与 AGENTS.md 同步新增联动说明,docs/screenshots/classisland 留三张真机截图 门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 54/54 通过。运行期仍需在装了 ClassIsland 的机器上实测(步骤见 AGENTS.md §8)。
This commit is contained in:
1 parent
fe41ebb7d6
commit
d0830f6830
61 files changed
+7026
-93
No files matched your search
@@ -8,12 +8,13 @@
|
||||
|
||||
班级信息管理系统,**pnpm monorepo**,一份代码产出三个交付物:
|
||||
|
||||
| 交付物 | 位置 | 形态 |
|
||||
| ---------- | ------------------------- | -------------------------------------------- |
|
||||
| 后端服务 | `packages/server` | Express + Prisma + Socket.IO,`/api` 前缀 |
|
||||
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用) |
|
||||
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
|
||||
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
|
||||
| 交付物 | 位置 | 形态 |
|
||||
| -------------------- | ----------------------------- | ------------------------------------------------------------------------------ |
|
||||
| 后端服务 | `packages/server` | Express + Prisma + Socket.IO,`/api` 前缀 |
|
||||
| Web 管理端 | `packages/web-admin` | Vue 3 + Vite + Element Plus(教师/管理员用) |
|
||||
| 桌面客户端 | `packages/desktop-client` | Electron + Vue 3(学生用,含「灵动岛」浮窗) |
|
||||
| 共享契约 | `packages/shared` | 三端共用的类型 / 常量 / 权限 / 工具函数 |
|
||||
| ClassIsland 联动插件 | `packages/classisland-plugin` | .NET 8 / C#(装在教室的 ClassIsland 上:上报课表与上课状态、弹出老师发的提醒) |
|
||||
|
||||
核心链路:
|
||||
|
||||
@@ -25,6 +26,10 @@
|
||||
│ 写库 + 按班级房间广播
|
||||
▼
|
||||
Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI
|
||||
|
||||
ClassIsland(教室机器)──► 班级小助手联动插件 ──► /api/integrations/classisland/*(设备令牌鉴权)
|
||||
▲ │
|
||||
└──── 老师下发的提醒(上报返回值里顺带带回)◄┘
|
||||
```
|
||||
|
||||
- 远端:`LoveChengke/ClassHelper`(仓库名大小写不敏感,本地 remote 存的是小写 `classhelper.git`)
|
||||
@@ -55,6 +60,14 @@ packages/server/
|
||||
src/modules/ 功能模块 + registry.ts(模块注册表)
|
||||
scripts/verify-e2e.mjs 后端端到端验收(163 项)
|
||||
packages/web-admin/src/ api / stores / router / layouts / views / styles / components
|
||||
packages/classisland-plugin/ .NET 8 插件(独立于 pnpm workspace,不参与 pnpm install)
|
||||
manifest.yml 插件清单:id / apiVersion / entranceAssembly / version
|
||||
src/Plugin.cs 入口:读 Settings.json → 注册提醒提供方 / 设置页 / BridgeService
|
||||
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(静态契约校验)
|
||||
packages/desktop-client/
|
||||
src/main/ 主进程:窗口 / 单实例 / IPC 配置 / 托盘 / 灵动岛 / smoke
|
||||
src/preload/ contextBridge 白名单桥(**不暴露 ipcRenderer 本体**)
|
||||
@@ -72,16 +85,18 @@ README.md 完整产品与交付说明(改动交付形态时
|
||||
|
||||
## 3. 本机环境(已配置好,可直接开工)
|
||||
|
||||
| 项 | 状态 |
|
||||
| --------------- | -------------------------------------------------------------------------------------------- |
|
||||
| Node | v22.15.0(要求 ≥ 20.19,Prisma 7 硬性要求) |
|
||||
| pnpm | 11.19.0(`packageManager` 锁定 11.8.0) |
|
||||
| npm registry | `https://registry.npmmirror.com/`(全局 pnpm 配置;仓库内无 `.npmrc`) |
|
||||
| 依赖 | `pnpm install` 已完成(707 包) |
|
||||
| 后端 `.env` | 已由 `.env.example` 生成 `packages/server/.env`(**gitignored**),`JWT_SECRET` 为本机随机值 |
|
||||
| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
|
||||
| 数据库 | SQLite `packages/server/prisma/dev.db`,5 个迁移已应用、种子已写入 |
|
||||
| Electron 二进制 | 已下载(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`) |
|
||||
| 项 | 状态 |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Node | v22.15.0(要求 ≥ 20.19,Prisma 7 硬性要求) |
|
||||
| pnpm | 11.19.0(`packageManager` 锁定 11.8.0) |
|
||||
| npm registry | `https://registry.npmmirror.com/`(全局 pnpm 配置;仓库内无 `.npmrc`) |
|
||||
| 依赖 | `pnpm install` 已完成(707 包) |
|
||||
| 后端 `.env` | 已由 `.env.example` 生成 `packages/server/.env`(**gitignored**),`JWT_SECRET` 为本机随机值 |
|
||||
| Prisma Client | 已生成到 `packages/server/src/generated/prisma`(gitignored) |
|
||||
| 数据库 | SQLite `packages/server/prisma/dev.db`,5 个迁移已应用、种子已写入 |
|
||||
| Electron 二进制 | 已下载(`node_modules/.pnpm/electron@44.3.0/node_modules/electron/dist/electron.exe`) |
|
||||
| .NET SDK | 8.0.303(构建 ClassIsland 插件用;插件与 pnpm workspace 无关,不参与 `pnpm install`) |
|
||||
| ClassIsland 本体 | `F:\classisland\ClassIsland.Desktop.exe`(2.1.0.1,与本插件目标版本一致);**数据根目录是 `F:\data`**(配置 `F:\data\Config`、档案 `F:\data\Profiles`、日志 `F:\data\Logs`、插件 `F:\data\Plugins\<id>`),因此插件可以真跑起来验证 |
|
||||
|
||||
### 从零重建环境
|
||||
|
||||
@@ -120,28 +135,32 @@ node install.js # 工作目录:node_modules/.pnpm/electron@44.3.0/node_modul
|
||||
|
||||
## 4. 常用命令
|
||||
|
||||
| 命令 | 说明 |
|
||||
| --------------------------------------------------------- | ------------------------------------------------------------- |
|
||||
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
|
||||
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
||||
| `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||
| `pnpm build` | 构建 shared + 后端 + Web 端 + 客户端 |
|
||||
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
|
||||
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||
| `pnpm lint` / `pnpm lint:fix` | ESLint |
|
||||
| `pnpm format` / `pnpm format:check` | Prettier |
|
||||
| `pnpm db:generate` | 生成 Prisma Client |
|
||||
| `pnpm db:deploy` | 应用已有迁移(**本机推荐**) |
|
||||
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
|
||||
| `pnpm db:studio` | Prisma Studio |
|
||||
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider(配合 `docs/mysql.md`) |
|
||||
| `pnpm verify:e2e` | 后端端到端验收 163 项(**需后端已启动**) |
|
||||
| `pnpm verify:web` | Web 管理端真实点击回归 27 项(需后端已启动且 Web 产物已构建) |
|
||||
| `pnpm verify:desktop` | 客户端冒烟 77+ 项(Electron,无人工点击) |
|
||||
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟 |
|
||||
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 两者 |
|
||||
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) |
|
||||
| 命令 | 说明 |
|
||||
| --------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端(4000) + Web 端(5173) |
|
||||
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
|
||||
| `pnpm dev:desktop` | 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||
| `pnpm build` | 构建 shared + 后端 + Web 端 + 客户端 |
|
||||
| `pnpm build:shared` | 只构建 shared(改完 `packages/shared` 必须跑) |
|
||||
| `pnpm build:desktop` | 只构建客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
||||
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8;缓存/临时目录自动指到 `.cache/`) |
|
||||
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/` |
|
||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||
| `pnpm lint` / `pnpm lint:fix` | ESLint |
|
||||
| `pnpm format` / `pnpm format:check` | Prettier |
|
||||
| `pnpm db:generate` | 生成 Prisma Client |
|
||||
| `pnpm db:deploy` | 应用已有迁移(**本机推荐**) |
|
||||
| `pnpm db:seed` / `pnpm db:reset` | 写种子 / 重置并重播种子 |
|
||||
| `pnpm db:studio` | Prisma Studio |
|
||||
| `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider(配合 `docs/mysql.md`) |
|
||||
| `pnpm verify:e2e` | 后端端到端验收 163 项(**需后端已启动**) |
|
||||
| `pnpm verify:web` | Web 管理端真实点击回归 27 项(需后端已启动且 Web 产物已构建) |
|
||||
| `pnpm verify:desktop` | 客户端冒烟 77+ 项(Electron,无人工点击) |
|
||||
| `pnpm verify:packaged` | 对**打包后/已安装**的客户端 EXE 跑同一套冒烟 |
|
||||
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 上报 / 提醒下发与回执 / 镜像契约,需后端已启动) |
|
||||
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由) |
|
||||
| `pnpm dist:server` / `dist:win` / `dist:dir` / `dist:all` | 打包服务端安装程序 / 客户端安装包 / 免安装目录 / 两者 |
|
||||
| `pnpm icons` | 生成应用图标(Electron 渲染 SVG → PNG/ICO) |
|
||||
|
||||
### 端口
|
||||
|
||||
@@ -162,6 +181,9 @@ releases/
|
||||
班级小助手-<版本>-x64-portable.exe 学生端单文件版
|
||||
server/ classhelper-server/ 免安装目录(内置 node.exe,双击 start.cmd 即用)
|
||||
班级小助手服务端-<版本>-x64-setup.exe 服务端安装程序(含 Web 管理端)
|
||||
classisland-plugin/
|
||||
ClassHelper.ClassIslandPlugin.cipx ClassIsland 插件包(从文件安装)
|
||||
ClassHelper.ClassIslandPlugin/ 免安装目录(直接丢进 ClassIsland 的 Plugins)
|
||||
```
|
||||
|
||||
```bash
|
||||
@@ -171,6 +193,9 @@ node packages/desktop-client/scripts/dist-win.mjs -c.directories.output=<绝对
|
||||
|
||||
# 服务端:脚本输出路径写死在 release-server/,打完需要自己移进 releases/server/
|
||||
pnpm dist:server # 首次会跑 46s 左右的 npm install;重试可加 --reuse-deps 跳过
|
||||
|
||||
# ClassIsland 插件:构建脚本自己归集到 releases/classisland-plugin/
|
||||
pnpm dist:classisland-plugin
|
||||
```
|
||||
|
||||
---
|
||||
@@ -212,9 +237,24 @@ pnpm dist:server # 首次会跑 46s 左右的 npm install;重试可
|
||||
11. **Web 端设计令牌集中。** 圆角/投影写在 `packages/web-admin/src/styles/index.css` 的 `:root`
|
||||
(`--ch-radius-*` / `--ch-shadow-*`),组件里**不要写死数值**;Element Plus 通过覆盖它的 CSS 变量对齐。
|
||||
手机小屏(≤768px)用抽屉导航 + 卡片内横向滚动,新增页面沿用 `useResponsive.ts` + `.table-card` 约定。
|
||||
12. **ClassIsland 联动是独立鉴权通道,不要塞进 JWT。** 插件用设备令牌(`X-ClassIsland-Token`,库里只存 sha256)
|
||||
调 `/api/integrations/classisland/*`;Web 端管理设备仍走 JWT + `requireRole`。新增插件接口时,
|
||||
路由挂 `integrations.module.ts`,并在 `integrations.schemas.ts` 里写 zod —— 插件侧的 C# DTO
|
||||
必须**逐字段对齐**(`pnpm verify:classisland-plugin` 会校验字段名与路由)。
|
||||
13. **课表镜像契约必须带 `startTime`/`endTime`。** ClassIsland 的 `ClassPlan.Classes[i]` 强制与时间表里
|
||||
**第 i 个 `TimeType == 0`(上课)的时间点**对齐:镜像下发少一个时间点,整份课表就会错位,
|
||||
而且表现为"课表看着有内容、每节课都对不上",极难排查。
|
||||
|
||||
---
|
||||
|
||||
14. **所有发布入口共用同一份 ClassIsland 推送实现**(`integrations.service.ts` 的 `pushToClassIsland`):
|
||||
通知发布 / 叫人 / 联动页的下发都走它。新增「发通知」类入口时**必须**复用,不要再各写一套 ——
|
||||
否则又会出现「某个页面发的通知不联动 ClassIsland」。
|
||||
15. **提醒弹在哪个端由教室客户端选**,值存在 `Class.notificationChannel`(`both` / `client` / `classisland`):
|
||||
客户端设置页写它(班级账号也有写权限,见 `PATCH /api/classes/:id/notification-channel`),
|
||||
服务端据此决定是否推 ClassIsland(`shouldPushToClassIsland`)。改动这条链路要同时动三处:
|
||||
客户端设置页与本地配置、`lib/mappers` 的 DTO、服务端推送判断。
|
||||
|
||||
## 6. 代码风格
|
||||
|
||||
- **TypeScript ESM + NodeNext**:`package.json` 全是 `"type": "module"`,相对导入**必须带 `.js` 后缀**
|
||||
@@ -237,6 +277,10 @@ pnpm dist:server # 首次会跑 46s 左右的 npm install;重试可
|
||||
`Invoke-WebRequest http://127.0.0.1:4000/api/health` 会返回**别人的 404**(响应带 `Alt-Svc: h3=…`、空 body),
|
||||
看起来像后端路由没挂上。实际是走了代理。自测请加 `-NoProxy`(`curl.exe` 用 `--noproxy '*'`)。
|
||||
项目内的 Node 脚本(`verify:*`)直连,不受影响。
|
||||
**同一个坑对 ClassIsland 插件同样成立**:.NET 的 `HttpClient` 默认跟随系统代理,
|
||||
教室机器上表现为"填好令牌后立刻 HTTP 404"(其实是代理的错误页)。插件里
|
||||
`ClassHelperClient` 用 `LocalAwareProxy` 对本地/内网地址强制直连,
|
||||
`verify:classisland-plugin` 有 4 项断言盯着这段逻辑,别删。
|
||||
2. **`pnpm` 由 Electron 宿主承载**(`pnpm config list` 里 `userAgent` 是 `node/v24.19.0`,而 `node -v` 是 v22.15.0)。
|
||||
个别情况下依赖的 `.bin` 不会进子进程 PATH,`pnpm run <script>` 报「不是内部或外部命令」。
|
||||
规避:直接用 Node 调入口,例如 `node packages/server/node_modules/prisma/build/index.js generate`
|
||||
@@ -263,6 +307,16 @@ pnpm dist:server # 首次会跑 46s 左右的 npm install;重试可
|
||||
`$env:TEMP='F:\ClassHelper\.cache\tmp'; $env:TMP=$env:TEMP; node scripts/dist-server.mjs --reuse-deps`。
|
||||
12. **仓库根 `.gitignore` 是 GBK 编码**(含一段乱码注释),`apply_patch` 与 Prettier 都读不了它。
|
||||
要补忽略规则请写 `.git/info/exclude`(本地生效、不入库),别试图整文件重写。
|
||||
(`packages/classisland-plugin/.gitignore` 是本仓库里唯一能正常编辑的 .gitignore,用来忽略 bin/obj/cipx。)
|
||||
13. **`dotnet` 会把 NuGet 缓存与临时文件写进 C 盘**(`%USERPROFILE%\.nuget`、`%TEMP%`),
|
||||
C 盘紧张时直接构建失败。`pnpm build:classisland-plugin` 已把 `NUGET_PACKAGES/TEMP/TMP/DOTNET_CLI_HOME`
|
||||
指到仓库内 `.cache/`;手敲 `dotnet build` 时请自己带上这几个变量。
|
||||
14. **`.cipx` 打包最后一步要 PowerShell**:SDK 的 `CreateCipx` 目标默认调 `pwsh`,
|
||||
本机 PATH 里没有 pwsh 时会失败。构建脚本会探测并把结果透传给 MSBuild
|
||||
(`-p:PowershellBinaryName=…`),直接跑 `dotnet build -p:CreateCipx=true` 则要自己加。
|
||||
15. **`releases/`(复数)必须在 ESLint 忽略列表里**:本地跑过 `pnpm dist:*` 之后,
|
||||
这个目录里是构建后的压缩 JS,不忽略的话 `pnpm lint` 会报出几千条与源码无关的 error
|
||||
(`eslint.config.mjs` 里已补 `**/releases/**`,别再删掉)。
|
||||
|
||||
### 已知脆弱用例(不是环境问题,别去"修环境")
|
||||
|
||||
@@ -274,6 +328,18 @@ pnpm dist:server # 首次会跑 46s 左右的 npm install;重试可
|
||||
|
||||
---
|
||||
|
||||
16. **改完插件必须更新 `F:\data\Plugins\classhelper.classisland.bridge\` 里的 DLL 并重启 ClassIsland**,
|
||||
否则跑的还是旧副本(ClassIsland 运行时会锁住 DLL,必须先退出再覆盖)。
|
||||
**别把该目录下的 `Settings.json` 覆盖掉** —— 那里面是这台机器的服务器地址与设备令牌。
|
||||
17. **ClassIsland 的提醒链路有三个"上机才暴露"的硬约束**(都已在插件里处理,并写进 `verify:classisland-plugin` 的静态断言,别删):
|
||||
- 提醒内容**必须**在 UI 线程构造 + 显示,否则 `LucideIconSource → AvaloniaObject` 构造时抛
|
||||
`Call from invalid thread`,表现为"ClassHelper 客户端收到了、ClassIsland 毫无反应";
|
||||
- 服务端"回执才算送达",因此插件必须对**播放中的提醒按 id 去重**,否则轮询间隔 < 提醒时长时会反复弹;
|
||||
- `Subject.Fallback` 的名字就是 `???` 占位符,上报前要归一化成"没有科目"。
|
||||
|
||||
18. **服务端改了代码要重启才生效**(`tsx src/index.ts` 不热载,`pnpm dev:server` 才有 watch)。
|
||||
验证脚本报「某个断言突然不对」时,先确认后端跑的是不是最新代码 —— 这个坑踩过一次。
|
||||
|
||||
## 8. 交付前检查
|
||||
|
||||
```bash
|
||||
@@ -282,10 +348,26 @@ pnpm typecheck # 全仓库类型检查
|
||||
|
||||
pnpm dev:server # 另开终端
|
||||
pnpm verify:e2e # 后端 163 项
|
||||
pnpm verify:web # Web 27 项
|
||||
pnpm verify:web # Web 29 项(其中 1 项是"已知脆弱用例",见上表)
|
||||
pnpm verify:classisland # ClassIsland 联动链路 24 项
|
||||
pnpm verify:classisland-plugin # 插件静态契约 54 项(不需要后端)
|
||||
pnpm build:desktop && pnpm verify:desktop # 客户端冒烟
|
||||
```
|
||||
|
||||
改动到 ClassIsland 插件时,额外跑 `pnpm dist:classisland-plugin` 确认能打出 `.cipx`;
|
||||
并**在真机上过一遍**(本机就有 ClassIsland,见 §3):
|
||||
|
||||
```powershell
|
||||
# 1) 退出 ClassIsland(DLL 被占用时无法覆盖)→ 2) 覆盖插件 → 3) 重新启动
|
||||
Get-Process ClassIsland.Desktop | Stop-Process -Force
|
||||
Copy-Item packages\classisland-plugin\bin\Release\ClassHelper.ClassIslandPlugin.dll F:\data\Plugins\classhelper.classisland.bridge\ -Force
|
||||
Start-Process F:\classisland\ClassIsland.Desktop.exe -WorkingDirectory F:\classisland
|
||||
# 4) 在 Web 端「ClassIsland 联动」发一条提醒,看启动时控制台/日志里依次出现:
|
||||
# 取到待弹出提醒 → 已弹出提醒 →(播完)已确认提醒
|
||||
```
|
||||
|
||||
交付时要说明"编译 / 打包 / 静态契约已过,运行期需在装了 ClassIsland 的机器上实测"。
|
||||
|
||||
`pnpm format:check` **在 master 基线上本来就是失败的**(175 个文件未过 Prettier,含 `tsconfig.base.json`
|
||||
与 `scripts/*`),不要拿它当门禁、也不要为了让它变绿去批量格式化。只检查你改过的文件:
|
||||
`pnpm exec prettier --check <file>`。
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
|
||||
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
|
||||
| 8 | 客户端灵动岛(通知浮窗,上课隐藏 / 下课弹出 / 紧急立即展开)与上课时段紧急通知二次确认 | ✅ 已完成(见"上课时段策略") |
|
||||
| 12 | **ClassIsland 联动**:教室机器装插件 → 课表/上课状态上报、老师在 Web 端发提醒 → ClassIsland 全屏弹出 | ✅ 已完成(见"ClassIsland 联动") |
|
||||
|
||||
> **与原始提示词的两处偏差(已与你确认)**
|
||||
>
|
||||
@@ -34,14 +35,15 @@
|
||||
> 2. 数据库 **先用 SQLite(libSQL 内嵌)跑通 MVP,并预留 MySQL 切换**:本机没有可用 MySQL
|
||||
> 实例;`docs/mysql.md` 给出云数据库切换的完整步骤,`deploy/` 提供 MySQL 版 Compose。
|
||||
|
||||
## 三套交付产物
|
||||
## 四套交付产物
|
||||
|
||||
| 产物 | 文件 | 用途 |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------- |
|
||||
| **服务端 + Web 管理端安装程序** | `release-server/班级小助手服务端-0.1.0-x64-setup.exe`(34 MB) | 装到教师电脑/校服务器即完整系统,**内置 Node 运行时**,双击安装、开机自启、自动建库建号 |
|
||||
| **学生客户端安装程序** | `packages/desktop-client/release/班级小助手-0.1.0-x64-setup.exe`(107 MB) | 学生机安装(NSIS 安装包) |
|
||||
| **学生客户端单文件版** | `packages/desktop-client/release/班级小助手-0.1.0-x64-portable.exe`(106.9 MB) | 免安装直接运行(U 盘分发) |
|
||||
| Web 管理端(PWA) | 由服务端在 `/` 直接托管 | 浏览器打开即用,可在 Edge/Chrome 中「安装为应用」;**已适配手机小屏**(1Panel 风格抽屉导航) |
|
||||
| 产物 | 文件 | 用途 |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- |
|
||||
| **服务端 + Web 管理端安装程序** | `release-server/班级小助手服务端-0.1.0-x64-setup.exe`(34 MB) | 装到教师电脑/校服务器即完整系统,**内置 Node 运行时**,双击安装、开机自启、自动建库建号 |
|
||||
| **学生客户端安装程序** | `packages/desktop-client/release/班级小助手-0.1.0-x64-setup.exe`(107 MB) | 学生机安装(NSIS 安装包) |
|
||||
| **学生客户端单文件版** | `packages/desktop-client/release/班级小助手-0.1.0-x64-portable.exe`(106.9 MB) | 免安装直接运行(U 盘分发) |
|
||||
| Web 管理端(PWA) | 由服务端在 `/` 直接托管 | 浏览器打开即用,可在 Edge/Chrome 中「安装为应用」;**已适配手机小屏**(1Panel 风格抽屉导航) |
|
||||
| **ClassIsland 联动插件** | `releases/classisland-plugin/ClassHelper.ClassIslandPlugin.cipx` | 装在教室机器的 ClassIsland 上(也可直接把 `ClassHelper.ClassIslandPlugin/` 目录丢进 Plugins) |
|
||||
|
||||
生产部署请看 **[docs/production.md](docs/production.md)**,四种形态按场景选:
|
||||
|
||||
@@ -93,6 +95,14 @@ class-helper/
|
||||
│ ├── middleware/ # auth / error / validate / security(helmet+限流+耗时日志)
|
||||
│ ├── realtime/ # socket.ts + bus.ts(事件总线)
|
||||
│ └── modules/ # 功能模块 + registry.ts
|
||||
├── classisland-plugin/ # ClassIsland 联动插件(.NET 8 / C#,独立于 pnpm workspace)
|
||||
│ ├── src/Plugin.cs # 插件入口(读配置 → 注册提醒提供方/设置页/联动服务)
|
||||
│ ├── src/Services/ # BridgeService(上报/接收/镜像)/ ScheduleMapper / ClassPlanWriter
|
||||
│ ├── src/Services/ClassHelperNotificationProvider.cs # 把老师发的提醒显示到 ClassIsland 上
|
||||
│ ├── src/Views/BridgeSettingsPage.axaml(.cs) # 「班级小助手联动」设置页(Avalonia)
|
||||
│ ├── src/Interop/ClassHelperClient.cs # 后端 HTTP 客户端 + 与服务端对齐的 DTO
|
||||
│ ├── manifest.yml # ClassIsland 插件清单(id / apiVersion / entranceAssembly)
|
||||
│ └── scripts/{build,verify}.mjs # 构建打包(.cipx)与静态契约校验(54 项)
|
||||
├── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus + PWA)
|
||||
│ ├── public/ # manifest / sw.js / 图标(由 pnpm icons 生成)
|
||||
│ └── src/{api,stores,router,layouts,views,styles}
|
||||
@@ -183,9 +193,14 @@ pnpm verify:desktop
|
||||
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 单双周课表/ClassIsland 课程表导入 + 班级账号(个人学生登录停用):163 项
|
||||
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、多班批量发布、未交名单、课程表导入弹窗、手机端课表横向滚动、教师录入、统一科目):27 项
|
||||
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/联合通知回归/胶囊类型汇总、未读红点位置、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、作业看板全屏自适应、个性化与设置页链路、托盘/退出无残留):77 项
|
||||
pnpm verify:classisland # ClassIsland 联动链路(设备令牌鉴权、课表/节次上报、提醒下发与回执、课表镜像契约):24 项
|
||||
pnpm verify:classisland-plugin # 插件静态契约校验(清单一致性、注册完整性、C# DTO ↔ 服务端 zod/路由、提醒链路硬约束):54 项
|
||||
pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(证明 packaged=true,默认 release/win-unpacked)
|
||||
```
|
||||
|
||||
> 插件要真正跑起来需要 ClassIsland 本体,本机没有(见"ClassIsland 联动"一节),
|
||||
> 因此 `verify:classisland-plugin` 是**静态**契约校验:它盯的是"装不上/对不上"这一类不需要运行就能发现的问题。
|
||||
|
||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权、单双周课表与 ClassIsland 课程表导入、个人学生登录停用**等 163 项,
|
||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||
@@ -227,6 +242,8 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
|
||||
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
|
||||
| `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 |
|
||||
| `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) |
|
||||
| `pnpm build:classisland-plugin` | 构建 ClassIsland 联动插件(.NET 8,产物在 `packages/classisland-plugin/bin/Release`) |
|
||||
| `pnpm dist:classisland-plugin` | 打包插件为 `.cipx` 并归集到 `releases/classisland-plugin/`(含免安装目录) |
|
||||
| `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) |
|
||||
| **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) |
|
||||
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
|
||||
@@ -236,6 +253,8 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
|
||||
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,27 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
|
||||
| `pnpm verify:desktop` | EXE 客户端冒烟验证(77 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、作业看板全屏自适应、个性化全参数) |
|
||||
| `pnpm verify:packaged` | 对打包后/已安装的客户端 EXE 跑同一套冒烟(`--exe` 指定路径,实测 `packaged: true`) |
|
||||
| `pnpm verify:classisland` | ClassIsland 联动链路(设备令牌 / 课表上报 / 提醒下发与回执 / 镜像契约,需服务端已启动) |
|
||||
| `pnpm verify:classisland-plugin` | 插件静态契约校验(清单一致性 / 注册完整性 / C# DTO ↔ 服务端 zod 与路由) |
|
||||
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
|
||||
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
|
||||
| `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 |
|
||||
@@ -517,6 +536,8 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
|
||||
|
||||
### 个性化设置与系统托盘
|
||||
|
||||
**设置 → 通知显示位置**:提醒弹在 ClassHelper 还是 ClassIsland(见 [ClassIsland 联动](#classisland-联动))。
|
||||
|
||||
**设置 → 灵动岛 · 个性化**(改动即实时生效,保存后写入本地配置,重启仍生效):
|
||||
|
||||
| 设置项 | 区间/取值 | 说明 |
|
||||
@@ -846,6 +867,75 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
|
||||
> (会幂等补齐 `Schedule.weekParity` 与 `Class.termWeeks`;安装程序覆盖升级会自动补迁移,
|
||||
> 仅空库首次启动会执行随包迁移 SQL)。
|
||||
|
||||
## ClassIsland 联动
|
||||
|
||||
课堂里真正"知道现在第几节、在上什么课"的是 [ClassIsland](https://classisland.tech)。
|
||||
本项目通过一个**ClassIsland 插件**把两边接起来(插件源码在 `packages/classisland-plugin/`),两个方向各解决一件事:
|
||||
|
||||
| 方向 | 做什么 | 老师在哪看 |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- |
|
||||
| ClassIsland → 班级小助手 | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节的起止时间),写入本班课表 | Web 端「ClassIsland 联动」实时状态卡片 |
|
||||
| 班级小助手 → ClassIsland | 老师在 Web 端发一条**提醒** → 落库 → 教室机器的插件取回 → ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不会重复弹 | Web 端「ClassIsland 联动 → 下发提醒」 |
|
||||
| (可选)班级 → ClassIsland | 把班级小助手上排好的课表**镜像**回 ClassIsland:新建一份 `班级小助手-<班级名>` 档案课表,不动老师原有的课表 | 同上页面的「开镜像」开关 |
|
||||
|
||||
### 接入流程(三步)
|
||||
|
||||
1. **Web 端签发设备令牌**:管理员 / 本班班主任在「ClassIsland 联动」页点「接入新设备」,
|
||||
选择班级并生成令牌(形如 `chci_xxxxxxxx…`,**明文只显示这一次**,服务端只存 sha256);
|
||||
2. **教室机器填令牌**:在 ClassIsland 的「设置 → 班级小助手联动」里填入后端地址 + 令牌,点「立即上报」;
|
||||
3. **验证**:页面顶部出现设备卡片与「在线」标记,说明链路通了 —— 之后课表与上课状态会自动同步,
|
||||
老师发的提醒也会在这台机器上弹出来。
|
||||
|
||||
### 通知从哪些入口发都会联动
|
||||
|
||||
三个发布入口共用同一份推送实现(服务端 `pushToClassIsland`),因此不存在「只有某个页面才联动」:
|
||||
|
||||
| 入口 | 说明 |
|
||||
| ------------------------------------ | ---------------------------------------------------- |
|
||||
| 「通知发布」页 | 与班级通知中心同一条记录,同时推给教室的 ClassIsland |
|
||||
| 「叫人」 | 老师点名让某位同学过去,教室大屏也会播报一条 |
|
||||
| 「ClassIsland 联动」页的「下发提醒」 | 专门发给教室大屏的提醒(支持时长 / 语音 / 优先级) |
|
||||
|
||||
### 提醒弹在哪个端:由教室客户端自己选
|
||||
|
||||
教室机器的 ClassHelper 客户端「设置 → 通知显示位置」里选(选择会同步到班级记录):
|
||||
|
||||
| 选择 | ClassHelper 客户端 | ClassIsland |
|
||||
| ----------------------------------------- | ------------------------------ | ----------- |
|
||||
| `ClassHelper 与 ClassIsland 都弹`(默认) | 弹窗 + 灵动岛 | 全屏提醒 |
|
||||
| `只在 ClassHelper 客户端弹` | 弹窗 + 灵动岛 | 不推送 |
|
||||
| `只在 ClassIsland 上弹` | 只进通知中心(不弹窗、不上岛) | 全屏提醒 |
|
||||
|
||||
服务端据此决定要不要推 ClassIsland —— 规则只有一条,**任何入口都遵守**(包括联动页的「下发提醒」,
|
||||
此时会回一句「该班教室已设置为只在 ClassHelper 客户端显示」)。Web 端「ClassIsland 联动」页会展示每个班的当前选择;
|
||||
教师 / 管理员也可以通过 `PATCH /api/classes/:id/notification-channel` 改,但默认由教室机器自己定。
|
||||
|
||||
> 与插件侧的开关是「与」关系:教室客户端要推 + 插件要开着「接收班级小助手提醒」,提醒才会在 ClassIsland 上出现。
|
||||
|
||||
### 插件为什么这样设计
|
||||
|
||||
- **不用 IPC,而是走 HTTP 上报**:ClassIsland 的跨进程 IPC 缓存的数据质量不足以还原课表,
|
||||
插件内部直接读 `ILessonsService`(课程状态)与 `IProfileService`(档案课表)最准;
|
||||
与后端之间用"上报即拉取"(每次上报的返回值里顺带带一条待弹出的提醒),
|
||||
插件因此不需要 Socket/WebSocket 依赖,故障面更小 —— 上报间隔本身就是心跳。
|
||||
- **单双周按 ClassIsland 的口径换算**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
|
||||
走 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity`(服务端与插件共用同一份语义);
|
||||
3 周以上的轮换无法用单双周表达,会降级为"每周"并在日志里给出提示。
|
||||
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里**第 i 个上课点**对齐,
|
||||
因此镜像接口会带上每条课目的 `startTime`/`endTime`,插件据此重建时间表;
|
||||
少一个时间点整份课表就会错位,这一条是插件契约里最容易踩的坑。
|
||||
|
||||
### 本机限制(重要)
|
||||
|
||||
本机**没有安装 ClassIsland 本体**,因此插件的 C# 代码只做到"编译 + 打包 + 静态契约校验":
|
||||
|
||||
- `pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin`:编译并打包成 `.cipx`(需要 .NET 8 SDK);
|
||||
- `pnpm verify:classisland-plugin`:54 项静态校验(清单三件套一致性、注册完整性、C# DTO ↔ 服务端 zod/路由契约、提醒链路的 UI 线程/去重等硬约束);
|
||||
- 端到端链路(设备令牌鉴权 / 上报 / 提醒下发与回执 / 镜像契约)由 `pnpm verify:classisland` 用后端真实验证(24 项);
|
||||
- **提醒链路已在真机 ClassIsland 2.1.0.1 上实测通过**(老师发提醒 → 教室全屏弹出 → 播完回执),
|
||||
留档截图见 [`docs/screenshots/classisland/`](docs/screenshots/classisland/)。
|
||||
- 提醒是**轮询取回**的(插件不持有长连接):默认最多 10 秒延迟,设置页「提醒轮询间隔」可调到 5 秒。
|
||||
|
||||
## REST API 一览
|
||||
|
||||
统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。
|
||||
@@ -895,23 +985,32 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
|
||||
| POST | `/imports/table/commit` | 管理员 | 按字段映射与写入模式导入(成绩 / 学生名单),返回新增/更新/跳过/失败与错误行号 |
|
||||
| POST | `/imports/time-layout/preview` | 管理员/本班班主任 | 解析 ClassIsland 时间配置 JSON(只解析不落库) |
|
||||
| GET / POST / DELETE | `/imports/time-layout` | 管理员/本班班主任 | 时间配置列表 / 导入(`replace` 覆盖、`merge` 合并)/ 删除 |
|
||||
| GET / POST / PATCH / DELETE | `/integrations/devices[/:id]` | 管理员/教师 | ClassIsland 联动设备管理(令牌只存 sha256,令牌前缀用于人眼识别) |
|
||||
| POST | `/integrations/devices/:id/token` | 管理员/教师 | 重置设备令牌(旧令牌立即失效,明文只返回一次) |
|
||||
| POST | `/integrations/classisland/notify` | 管理员/教师 | 下发提醒到该班 ClassIsland 设备(广播 `classisland:notification`) |
|
||||
| POST | `/integrations/classisland/report` | **设备令牌** | 插件上报状态 + 课表 + 节次时间(`X-ClassIsland-Token`,非 JWT) |
|
||||
| GET | `/integrations/classisland/pending` | **设备令牌** | 插件拉取尚未确认的提醒(离线期间老师发的通知,重连后补齐) |
|
||||
| POST | `/integrations/classisland/ack` | **设备令牌** | 插件确认提醒已弹出(确认后不再补发) |
|
||||
| GET | `/integrations/classisland/class-plan` | **设备令牌** | 插件拉取本班课表(开启镜像时)用于写回 ClassIsland |
|
||||
| GET | `/health` | 公开 | 健康检查(含已挂载模块列表) |
|
||||
|
||||
## WebSocket 事件
|
||||
|
||||
连接方式:`io(url, { auth: { token } })`,握手阶段用 JWT 鉴权。
|
||||
|
||||
| 事件 | 方向 | 载荷 |
|
||||
| ------------------------------------- | --------------- | ------------------------------------- |
|
||||
| `connected` | 服务端 → 客户端 | `{ userId, role, rooms }` |
|
||||
| `notification:new` | 服务端 → 客户端 | `NotificationDto` |
|
||||
| `homework:new` | 服务端 → 客户端 | `HomeworkDto` |
|
||||
| `homework:updated` | 服务端 → 客户端 | `HomeworkDto & { deleted?: boolean }` |
|
||||
| `homework:status` | 服务端 → 客户端 | `HomeworkStatusDto & { classId }` |
|
||||
| `grade:updated` | 服务端 → 客户端 | `GradeDto` |
|
||||
| `schedule:updated` | 服务端 → 客户端 | `{ classId, action, schedule? }` |
|
||||
| `class:updated` | 服务端 → 客户端 | `{ classId, action }` |
|
||||
| `class:join` / `class:leave` / `ping` | 客户端 → 服务端 | 手动订阅班级(服务端二次校验权限) |
|
||||
| 事件 | 方向 | 载荷 |
|
||||
| ------------------------------------- | --------------- | -------------------------------------------- |
|
||||
| `connected` | 服务端 → 客户端 | `{ userId, role, rooms }` |
|
||||
| `notification:new` | 服务端 → 客户端 | `NotificationDto` |
|
||||
| `homework:new` | 服务端 → 客户端 | `HomeworkDto` |
|
||||
| `homework:updated` | 服务端 → 客户端 | `HomeworkDto & { deleted?: boolean }` |
|
||||
| `homework:status` | 服务端 → 客户端 | `HomeworkStatusDto & { classId }` |
|
||||
| `grade:updated` | 服务端 → 客户端 | `GradeDto` |
|
||||
| `schedule:updated` | 服务端 → 客户端 | `{ classId, action, schedule? }` |
|
||||
| `class:updated` | 服务端 → 客户端 | `{ classId, action }` |
|
||||
| `classisland:state` | 服务端 → 客户端 | `ClassIslandStateEvent`(教室现在上什么课) |
|
||||
| `classisland:notification` | 服务端 → 客户端 | `ClassIslandNotificationEvent`(提醒已下发) |
|
||||
| `class:join` / `class:leave` / `ping` | 客户端 → 服务端 | 手动订阅班级(服务端二次校验权限) |
|
||||
|
||||
房间规则(`@classhelper/shared` 的 `SOCKET_ROOMS`):
|
||||
`class:{classId}`、`user:{userId}`、`role:{role}`、`students`、`teachers`。
|
||||
@@ -1052,6 +1151,10 @@ pnpm db:migrate && pnpm db:seed
|
||||
| **创建班级仅管理员 + 设置/更改班主任** | ✅ | `verify:e2e`:教师建班 403;管理员建班可带 `teacherId`、`PATCH /classes/:id/head-teacher` 落库生效;`verify:web`:教师直接访问 `/teachers` 被挡回仪表盘 |
|
||||
| **授课科目统一(18 科固定目录)** | ✅ | `verify:web`:新增课表的科目下拉出现「统一科目(选中后自动建课)」,选中 → 自动建课 → 写入课表成功(收尾删除该条课表) |
|
||||
| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文/占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` |
|
||||
| **其他页面发通知也会联动 ClassIsland** | ✅ | `verify:classisland`:「通知发布页发的通知也会推送到 ClassIsland」「叫人也会推送到 ClassIsland」;真机实测截图 [03-from-notify-page](docs/screenshots/classisland/03-from-notify-page.png) |
|
||||
| **提醒弹在哪个端由客户端自行选择** | ✅ | 客户端「设置 → 通知显示位置」三选一(`verify:desktop`:设置页真实点击 → 写回本地配置,在线 82/82);服务端按班级设置决定是否推送(`verify:classisland`:client 时通知与联动页下发都被跳过) |
|
||||
| **班级小助手能读取 ClassIsland 的课表** | ✅ | `verify:classisland`:插件上报课表 → 本班课表新增/更新(重复上报幂等)、节次时间写入、设备状态快照可在 Web 端展示;`verify:web`:教师在「ClassIsland 联动」页真实签发设备令牌 |
|
||||
| **用户可选择通知是否在 ClassIsland 上显示** | ✅ | 插件设置页开关「接收班级小助手提醒」(本机总闸)+ Web 端「下发提醒」(标题/内容/时长/优先级/语音朗读/是否同步通知中心);`verify:classisland`:提醒落库 → 待提醒 → 回执 → 确认后不补发 |
|
||||
| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) |
|
||||
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 |
|
||||
|
||||
@@ -1219,6 +1322,19 @@ pnpm db:migrate && pnpm db:seed
|
||||
| `packages/web-admin/src/views/ClassesView.vue`(班级账号列与弹窗) | 管理员设置班级码 / 重置班级密码 |
|
||||
| `packages/desktop-client/scripts/smoke.mjs`(班级账号准备) | 冒烟前通过管理端接口准备可用班级凭据,兼容全新安装与升级安装 |
|
||||
|
||||
阶段 12(ClassIsland 联动插件)新增文件:
|
||||
|
||||
| 路径 | 说明 |
|
||||
| ------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| `packages/server/src/modules/integrations/` | 联动模块:设备令牌鉴权(sha256)/ 上报写库 / 提醒下发与回执 / 课表镜像(复用导入模块口径) |
|
||||
| `packages/server/prisma/migrations/20260927120000_add_classisland_integration/` | `IntegrationDevice` + `ClassIslandPush` 迁移(跨库通用,只用基础类型) |
|
||||
| `packages/server/scripts/verify-classisland.mjs` | 联动链路端到端验收(24 项,需服务端已启动) |
|
||||
| `packages/classisland-plugin/` | ClassIsland 插件(.NET 8):入口 / 联动主循环 / 提醒提供方 / 设置页 / 合同 DTO |
|
||||
| `packages/classisland-plugin/scripts/{build,verify}.mjs` | 插件编译打包(`.cipx` 归集到 `releases/`)与静态契约校验(54 项) |
|
||||
| `docs/screenshots/classisland/*.png` | 真机联调留档:普通/紧急提醒在 ClassIsland 上的弹出效果 |
|
||||
| `packages/web-admin/src/views/IntegrationsView.vue` | Web 端「ClassIsland 联动」页:设备卡片与列表、令牌签发/重置、下发提醒表单 |
|
||||
| `packages/shared/src/{types,constants,utils}.ts` 的联动契约 | `IntegrationDeviceDto` / `ClassIslandReportRequest` / `weekParityFromDiv` ⇄ `weekDivFromParity` |
|
||||
|
||||
## 后续可选增强
|
||||
|
||||
1. 代码签名证书(消除 SmartScreen 提示)。
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 312 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 312 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 362 KiB |
@@ -26,6 +26,8 @@ export default tseslint.config(
|
||||
'**/dist-electron/**',
|
||||
'**/release/**',
|
||||
'**/release-server/**',
|
||||
// 交付产物归集目录(pnpm dist:* 的输出,见 AGENTS.md §4):里面是构建后的压缩 JS,不该进 lint
|
||||
'**/releases/**',
|
||||
'**/build/**',
|
||||
'**/coverage/**',
|
||||
'**/src/generated/**',
|
||||
|
||||
+5
-1
@@ -15,6 +15,7 @@
|
||||
"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:desktop": "pnpm build:shared && pnpm --filter @classhelper/desktop-client build",
|
||||
"build:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs",
|
||||
"typecheck": "pnpm build:shared && pnpm -r --no-bail typecheck",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
@@ -31,11 +32,14 @@
|
||||
"verify:desktop": "pnpm --filter @classhelper/desktop-client smoke",
|
||||
"verify:web": "node scripts/ui-smoke/run.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",
|
||||
"dist:win": "pnpm --filter @classhelper/desktop-client dist:win",
|
||||
"dist:dir": "pnpm --filter @classhelper/desktop-client dist:dir",
|
||||
"dist:server": "pnpm build:shared && pnpm --filter @classhelper/server build && pnpm --filter @classhelper/web-admin build && node scripts/dist-server.mjs",
|
||||
"dist:all": "pnpm dist:server && pnpm dist:win"
|
||||
"dist:classisland-plugin": "node packages/classisland-plugin/scripts/build.mjs --cipx",
|
||||
"dist:all": "pnpm dist:server && pnpm dist:win && pnpm dist:classisland-plugin"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.5",
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
# .NET 构建产物(与仓库根的 .gitignore 分开:根 .gitignore 是 GBK 编码,apply_patch 读不了)
|
||||
bin/
|
||||
obj/
|
||||
cipx/
|
||||
@@ -0,0 +1,45 @@
|
||||
<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>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"profiles": {
|
||||
"ClassIsland 插件": {
|
||||
"commandName": "Executable",
|
||||
"executablePath": "$(ClassIsland_DebugBinaryFile)",
|
||||
"commandLineArgs": "-epp $(TargetDir)",
|
||||
"workingDirectory": "$(ClassIsland_DebugBinaryDirectory)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# 班级小助手联动插件
|
||||
|
||||
把 [ClassIsland](https://classisland.tech) 与 [班级小助手](https://github.com/LoveChengke/ClassHelper) 连起来:
|
||||
让班级小助手**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
|
||||
|
||||
## 它能做什么
|
||||
|
||||
| 方向 | 说明 |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| ClassIsland → 班级小助手 | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
|
||||
| 班级小助手 → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
|
||||
| 班级小助手 → ClassIsland(可选) | 把班级小助手上排好的课表**镜像**回 ClassIsland(新建 `班级小助手-<班级名>` 档案课表,不改动老师原有的课表) |
|
||||
|
||||
## 安装
|
||||
|
||||
1. 把插件放进 ClassIsland 的插件目录,二选一:
|
||||
- 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」;
|
||||
- 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录
|
||||
(Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。
|
||||
2. 重启 ClassIsland,在「设置 → 插件」里确认「班级小助手联动」已启用。
|
||||
3. 打开「设置 → 班级小助手联动」,填入:
|
||||
- **服务器地址**:班级小助手后端地址,例如 `http://127.0.0.1:4000`;
|
||||
- **设备令牌**:在班级小助手 Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
|
||||
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
|
||||
|
||||
## 配置项(全部在「设置 → 班级小助手联动」里)
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 服务器地址 | 班级小助手后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
|
||||
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
|
||||
| 上报课表到班级小助手 | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
|
||||
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
|
||||
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
|
||||
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
|
||||
| 接收班级小助手提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
|
||||
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
|
||||
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
|
||||
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
|
||||
| 显示调试信息 | 展开设备标识、与服务端的时钟差、配置目录等,排查「为什么同步不上」时打开 |
|
||||
|
||||
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
|
||||
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
|
||||
|
||||
> **提醒到底会不会弹,取决于两个开关:**
|
||||
>
|
||||
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
|
||||
> 服务端根本不会推过来(此时本插件收不到东西,属正常);
|
||||
> 2. 本插件的「接收班级小助手提醒」关掉时,即使推过来也不弹。
|
||||
>
|
||||
> 两者都开着,提醒才会在 ClassIsland 上出现。
|
||||
|
||||
## 工作原理(为什么这么写)
|
||||
|
||||
- **不用跨进程 IPC,插件内直接读课程服务**:ClassIsland 的 IPC 暴露的是缓存数据,不足以还原课表;
|
||||
插件直接读 `ILessonsService`(当前科目 / 状态 / 时间点)与 `IProfileService`(档案里的课表、时间表、科目),
|
||||
拿到的才是「教室此刻真实的状态」。
|
||||
- **「上报即拉取」,不引入 Socket 依赖**:每次上报的返回值里会顺带带一条「尚未确认的提醒」,
|
||||
插件收到就交给提醒提供方显示;应用启动时额外拉一次 `pending`,
|
||||
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
|
||||
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
|
||||
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
|
||||
「班级小助手提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
|
||||
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
|
||||
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
|
||||
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
|
||||
**第 i 个 `TimeType == 0`(上课)的时间点**一一对应,
|
||||
因此镜像接口为每条课目带上 `startTime` / `endTime`,插件据此重建时间表
|
||||
(课间点从服务端的时间表里按起始时刻对齐)。少一个时间点,整份课表就会错位。
|
||||
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
|
||||
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
|
||||
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
|
||||
- **非破坏性**:镜像只创建 / 更新名字以 `班级小助手-` 开头的课表与时间表;
|
||||
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
|
||||
|
||||
## 上机联调记录(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`(响应不是班级小助手的标准格式)**
|
||||
这台机器上的**系统代理拦了内网请求**。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 小时后过期,避免教室机器一开机弹出一堆旧通知。
|
||||
|
||||
**课表上报了但班级小助手上没变化**
|
||||
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
|
||||
|
||||
**镜像开关打开了,但 ClassIsland 的课表没变**
|
||||
镜像需要 Web 端该设备开启「镜像课表」且班级里已有课表;插件只在**上报时**顺路拉取镜像,
|
||||
所以打开开关后点一下「立即上报」或等一个上报间隔即可。镜像不会覆盖老师原有的课表。
|
||||
|
||||
## 许可
|
||||
|
||||
与班级小助手仓库一致。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,19 @@
|
||||
# 班级小助手 ClassIsland 联动插件 —— 清单文件
|
||||
#
|
||||
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
|
||||
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
|
||||
|
||||
id: classhelper.classisland.bridge
|
||||
name: 班级小助手联动
|
||||
description: |-
|
||||
把 ClassIsland 的课表与上课状态同步到班级小助手,并在 ClassIsland 上显示老师下发的提醒。
|
||||
功能:
|
||||
1. 上报当前课表与节次时间(班级小助手 Web 端与桌面客户端据此显示课程);
|
||||
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
|
||||
3. 接收老师在班级小助手上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
|
||||
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
|
||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||
url: https://github.com/LoveChengke/ClassHelper
|
||||
version: 0.1.0.0
|
||||
apiVersion: 2.0.0.0
|
||||
author: 班级小助手
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* 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(ClassIsland 插件包)并归集到 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 releasesDir = 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/ 不入库)
|
||||
fs.rmSync(releasesDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(releasesDir, { recursive: true });
|
||||
fs.copyFileSync(cipxFile, path.join(releasesDir, path.basename(cipxFile)));
|
||||
|
||||
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
|
||||
const portableDir = path.join(releasesDir, 'ClassHelper.ClassIslandPlugin');
|
||||
fs.mkdirSync(portableDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(outputDir)) {
|
||||
const from = path.join(outputDir, name);
|
||||
if (!fs.statSync(from).isFile()) continue;
|
||||
// 依赖清单与调试符号对加载没影响,但会让老师误以为"要装一堆东西",因此只带最小集合
|
||||
if (!['ClassHelper.ClassIslandPlugin.dll', 'manifest.yml', 'icon.png', 'README.md'].includes(name)) {
|
||||
continue;
|
||||
}
|
||||
fs.copyFileSync(from, path.join(portableDir, name));
|
||||
}
|
||||
|
||||
for (const file of fs.readdirSync(cipxDir)) {
|
||||
fs.copyFileSync(path.join(cipxDir, file), path.join(releasesDir, file));
|
||||
}
|
||||
|
||||
log(`插件包已归集到 ${path.relative(repoRoot, releasesDir)}`);
|
||||
for (const file of fs.readdirSync(releasesDir)) {
|
||||
log(` ${file}`);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* 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 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) && /"班级小助手联动"/.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.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(
|
||||
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
||||
settingsSource.includes('NotificationPollSeconds') &&
|
||||
bridgeSource.includes('_notifyTimer') &&
|
||||
bridgeSource.includes('NotificationPollSeconds'),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------ 结果 */
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
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; } = "";
|
||||
[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; }
|
||||
}
|
||||
|
||||
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; }
|
||||
[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; } = "";
|
||||
}
|
||||
|
||||
public class PullResultDto
|
||||
{
|
||||
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
|
||||
[JsonPropertyName("week")] public int Week { get; set; }
|
||||
[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>调用班级小助手后端的结果(不抛异常,便于设置页展示原因)。</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>
|
||||
/// 班级小助手后端客户端。
|
||||
///
|
||||
/// 只用 <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);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 与班级小助手通信的 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,
|
||||
};
|
||||
|
||||
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})——" +
|
||||
"响应不是班级小助手的标准格式,请检查服务器地址是否正确、" +
|
||||
"或该系统代理是否拦截了内网请求"
|
||||
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
|
||||
}
|
||||
LastError = message!;
|
||||
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
|
||||
}
|
||||
|
||||
LastError = "";
|
||||
LastSuccessAt = DateTimeOffset.Now;
|
||||
return envelope;
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
LastError = "请求超时(请检查服务器地址与网络)";
|
||||
return null;
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
LastError = $"无法连接班级小助手服务:{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();
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
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;
|
||||
private bool _uploadSchedule = true;
|
||||
private bool _mirrorSchedule = false;
|
||||
private bool _receiveNotifications = true;
|
||||
private int _notificationDurationSeconds = 8;
|
||||
private int _notificationPollSeconds = 10;
|
||||
private bool _speechEnabled = false;
|
||||
private bool _showDebugInfo = false;
|
||||
|
||||
/// <summary>
|
||||
/// 班级小助手后端地址,例如 <c>http://127.0.0.1:4000</c>。
|
||||
/// </summary>
|
||||
public string ServerUrl
|
||||
{
|
||||
get => _serverUrl;
|
||||
set => SetProperty(ref _serverUrl, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设备令牌(形如 <c>chci_...</c>):在班级小助手 Web 端「ClassIsland 联动」页面生成。
|
||||
/// </summary>
|
||||
public string DeviceToken
|
||||
{
|
||||
get => _deviceToken;
|
||||
set => SetProperty(ref _deviceToken, value);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// 是否把 ClassIsland 的课表同步到班级小助手。
|
||||
/// 关闭后仍会上报"当前上什么课"的状态,只是不再写课表。
|
||||
/// </summary>
|
||||
public bool UploadSchedule
|
||||
{
|
||||
get => _uploadSchedule;
|
||||
set => SetProperty(ref _uploadSchedule, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否把班级小助手上排好的课表镜像回 ClassIsland
|
||||
/// (新建一份名为「班级小助手-<班级名>」的档案课表,不覆盖老师原有的课表)。
|
||||
/// </summary>
|
||||
public bool MirrorSchedule
|
||||
{
|
||||
get => _mirrorSchedule;
|
||||
set => SetProperty(ref _mirrorSchedule, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否接收老师在班级小助手上发布的提醒,并在 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 =>
|
||||
Uri.TryCreate(ServerUrl, UriKind.Absolute, out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
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("班级小助手联动:拿不到 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("班级小助手联动:拿不到课程服务,联动已停用");
|
||||
return;
|
||||
}
|
||||
|
||||
var bridge = IAppHost.TryGetService<BridgeService>();
|
||||
if (bridge is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到联动服务,联动已停用");
|
||||
return;
|
||||
}
|
||||
|
||||
_bridgeStarted = true;
|
||||
bridge.Attach(lessons);
|
||||
bridge.RestartTimer();
|
||||
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
|
||||
// 不这么做的话,最长要等一个上报间隔才会弹出来
|
||||
_ = bridge.PullPendingAsync();
|
||||
Logger?.LogInformation("班级小助手联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 联动失败绝不能把 ClassIsland 本体拖下水
|
||||
Logger?.LogError(exception, "班级小助手联动:初始化联动服务失败");
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
"班级小助手联动:读取设置失败,已回退到默认设置({Path})", path);
|
||||
}
|
||||
|
||||
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
|
||||
settings.Normalize();
|
||||
if (needGenerateKey)
|
||||
{
|
||||
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车
|
||||
var machine = Environment.MachineName.ToLowerInvariant();
|
||||
settings.DeviceKey = $"{machine}-{Guid.NewGuid():N}"[..Math.Min(24, machine.Length + 33)];
|
||||
}
|
||||
|
||||
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);
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(settings, JsonOptions));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||
"班级小助手联动:保存设置失败({Path})", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
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 秒,
|
||||
/// 把"当前上什么课"与(可选的)全量课表推给班级小助手;
|
||||
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
|
||||
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
|
||||
/// 3. <b>镜像</b>:开启镜像开关时,把班级小助手上排好的课表写回 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;
|
||||
|
||||
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("班级小助手联动:已订阅课程事件");
|
||||
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>发起一次上报(事件驱动:已有上报在跑时直接丢弃本次,避免事件风暴打出并发请求)。</summary>
|
||||
public void RequestReport(string reason)
|
||||
{
|
||||
_ = ReportAsync(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行一次上报并返回是否真的发出去了。
|
||||
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
|
||||
/// </summary>
|
||||
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true)
|
||||
{
|
||||
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(),
|
||||
State = BuildState(lessons),
|
||||
};
|
||||
|
||||
var entryCount = 0;
|
||||
var layoutCount = 0;
|
||||
if (includeSchedule && _settings.UploadSchedule && profile is not null)
|
||||
{
|
||||
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
|
||||
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning);
|
||||
entryCount = entries.Count;
|
||||
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
|
||||
// 补上 endTime 之外的字段由服务端按需处理;课表为空时不发,免得把服务端已有课表"合并"没了
|
||||
if (entries.Count == 0) request.Schedule = null;
|
||||
|
||||
var layout = ResolvePrimaryTimeLayout(profile);
|
||||
if (layout is not null)
|
||||
{
|
||||
var items = ScheduleMapper.MapTimeLayout(layout);
|
||||
layoutCount = items.Count;
|
||||
request.TimeLayout = new TimeLayoutPayloadDto
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name,
|
||||
Mode = "replace",
|
||||
Items = items,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var (result, data) = await _client.ReportAsync(_settings, request, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
if (!result.Ok || data is null)
|
||||
{
|
||||
_logger.LogWarning("班级小助手联动上报失败({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(
|
||||
"班级小助手联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
||||
data.ScheduleCreated, data.ScheduleUpdated);
|
||||
}
|
||||
|
||||
var detail = entryCount > 0
|
||||
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
||||
: "仅状态";
|
||||
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);
|
||||
}
|
||||
|
||||
if (_settings.MirrorSchedule)
|
||||
{
|
||||
await MirrorScheduleAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
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("班级小助手联动:拉取待提醒失败({Message})", result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:取到待弹出提醒「{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("班级小助手联动:已确认提醒 {Id}", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
|
||||
_logger.LogWarning("班级小助手联动:确认提醒失败 {Message}", result.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把班级小助手上排好的课表写回 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("班级小助手联动:跳过课表镜像({Message})", result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var applied = ClassPlanWriter.Apply(profile, plan);
|
||||
if (!applied.Applied)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
|
||||
applied.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
|
||||
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
|
||||
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
|
||||
|
||||
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
|
||||
string.Join("、", applied.PlanNames.Select(name => $"「{name}」")));
|
||||
foreach (var warning in applied.Warnings)
|
||||
{
|
||||
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "班级小助手联动:课表镜像失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 内部实现 */
|
||||
|
||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||
private const string PluginVersion = "0.1.0.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, "班级小助手联动:读取课程状态失败,本次只上报心跳");
|
||||
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优先用"当前正在生效"的课表对应的时间表;取不到再退回档案里的第一个。
|
||||
/// 有多个时间表时选错会把节次时间报错,所以先按当前课表找。
|
||||
/// </summary>
|
||||
private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout(
|
||||
ClassIsland.Shared.Models.Profile.Profile profile)
|
||||
{
|
||||
if (profile.TimeLayouts.Count == 0) return null;
|
||||
|
||||
var current = profile.SelectedClassPlanGroupId != Guid.Empty
|
||||
? CurrentClassPlan(profile)
|
||||
: null;
|
||||
if (current is not null)
|
||||
{
|
||||
var layout = ScheduleMapper.ResolveTimeLayout(current, profile);
|
||||
if (layout is not null) return layout;
|
||||
}
|
||||
|
||||
// 退而求其次:档案里被激活的时间表,最后才是任意一个
|
||||
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, "班级小助手联动:读取档案失败");
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Avalonia.Threading;
|
||||
using ClassHelper.ClassIslandPlugin.Interop;
|
||||
using ClassHelper.ClassIslandPlugin.Models;
|
||||
using ClassIsland.Core.Abstractions.Services.NotificationProviders;
|
||||
using ClassIsland.Core.Attributes;
|
||||
using ClassIsland.Core.Models.Notification;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 提醒提供方:把老师在班级小助手上发布的提醒显示到 ClassIsland 上。
|
||||
///
|
||||
/// 与 <see cref="BridgeService"/> 的分工:
|
||||
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
|
||||
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
|
||||
/// 全屏特效 / 语音 / 音效那一整套流程。
|
||||
///
|
||||
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「班级小助手联动」设置页里,
|
||||
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
|
||||
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
|
||||
/// </summary>
|
||||
// 第 3 个参数是 Fluent 图标字体里的字形字符(ClassIsland 内置提醒提供方用的也是字形字符,
|
||||
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
|
||||
[NotificationProviderInfo(
|
||||
ProviderGuidValue,
|
||||
"班级小助手提醒",
|
||||
"\uE8BD",
|
||||
"显示老师从班级小助手下发的提醒。")]
|
||||
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();
|
||||
|
||||
public ClassHelperNotificationProvider(
|
||||
BridgeService bridge,
|
||||
PluginSettings settings,
|
||||
ILogger<ClassHelperNotificationProvider> logger)
|
||||
{
|
||||
_bridge = bridge;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_bridge.NotificationReceived += OnNotificationReceived;
|
||||
}
|
||||
|
||||
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("班级小助手联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 必须切回 UI 线程:提醒内容里的图标、以及提醒主机本身都是 Avalonia 的 UI 对象,
|
||||
// 而本方法是从上报回调(线程池)里被调用的 —— 在线程池上直接显示会静默失败/抛异常。
|
||||
Dispatcher.UIThread.Post(() => ShowOnUiThread(notification));
|
||||
}
|
||||
|
||||
private void ShowOnUiThread(PushNotificationDto notification)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = BuildRequest(notification);
|
||||
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
|
||||
AckWhenFinished(request, notification);
|
||||
ShowNotification(request);
|
||||
_logger.LogInformation("班级小助手联动:已弹出提醒「{Title}」({Seconds} 秒)",
|
||||
notification.Title, notification.DurationSeconds);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
|
||||
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
|
||||
lock (_playing) _playing.Remove(notification.Id);
|
||||
_logger.LogError(exception, "班级小助手联动:显示提醒「{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) ? "班级小助手" : 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>提醒播完 / 被关掉后回执给班级小助手,服务端不再补发。</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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClassHelper.ClassIslandPlugin.Interop;
|
||||
using ClassIsland.Shared.Models.Profile;
|
||||
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 把班级小助手拉回来的课表写进 ClassIsland 档案。
|
||||
///
|
||||
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
|
||||
/// (即一个星期几 + 一个单双周相位),而班级小助手的课表同时覆盖 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 = "班级小助手-";
|
||||
|
||||
/// <summary>时间表名字后缀。</summary>
|
||||
public const string TimeLayoutNameSuffix = "(班级小助手)";
|
||||
|
||||
/// <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>
|
||||
public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source)
|
||||
{
|
||||
var result = new ApplyResult();
|
||||
if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0)
|
||||
{
|
||||
result.Warnings.Add("服务端返回的课表为空,未改动 ClassIsland 档案");
|
||||
return result;
|
||||
}
|
||||
|
||||
var layout = source.TimeLayouts[0];
|
||||
// 课间 / 分割线 / 行动点:按起始时间索引,供"每条课目后接上对应课间"使用
|
||||
var breakPoints = layout.Layouts
|
||||
.Where(item => item.TimeType != 0)
|
||||
.GroupBy(item => item.StartTime)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
|
||||
// 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);
|
||||
var planId = FindPlanId(profile, planName);
|
||||
var (timeLayoutId, timeLayout) = BuildTimeLayout(
|
||||
profile, planName, group.Entries, breakPoints);
|
||||
|
||||
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 group.Entries)
|
||||
{
|
||||
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);
|
||||
|
||||
// 冲突提示:同一星期几 + 同一单双周相位下老师还开着别的课表
|
||||
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)
|
||||
.Select(pair => pair.Value.Name)
|
||||
.ToList();
|
||||
if (conflicts.Count > 0)
|
||||
{
|
||||
result.Warnings.Add(
|
||||
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
|
||||
"两者重叠时以班级小助手同步的课表为准");
|
||||
}
|
||||
}
|
||||
|
||||
// 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>
|
||||
/// 按分组内的条目重建时间表:每条课目前后按需插入服务端的课间点。
|
||||
/// 返回写入的时间表 GUID。
|
||||
/// </summary>
|
||||
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
|
||||
Profile profile,
|
||||
string planName,
|
||||
IReadOnlyList<ClassPlanEntryMirrorDto> entries,
|
||||
IReadOnlyDictionary<string, TimeLayoutItemMirrorDto> breakPoints)
|
||||
{
|
||||
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 };
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
layout.Layouts.Add(new TimeLayoutItem
|
||||
{
|
||||
TimeType = 0, // 上课
|
||||
StartTime = ParseTime(entry.StartTime),
|
||||
EndTime = ParseTime(entry.EndTime),
|
||||
});
|
||||
// 该课目结束后紧接着的课间(服务端时间表里有同起始时刻的课间点才插入)
|
||||
if (breakPoints.TryGetValue(entry.EndTime, out var brk))
|
||||
{
|
||||
layout.Layouts.Add(new TimeLayoutItem
|
||||
{
|
||||
TimeType = brk.TimeType,
|
||||
StartTime = ParseTime(brk.StartTime),
|
||||
EndTime = ParseTime(brk.EndTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
profile.TimeLayouts[id] = layout;
|
||||
return (id, layout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 课表名:单组时用「班级小助手-<班级名>」;多组时补上星期与单双周以免互相覆盖。
|
||||
/// </summary>
|
||||
private static string BuildPlanName(
|
||||
string profileName,
|
||||
int weekDay,
|
||||
int weekCountDiv,
|
||||
int weekCountDivTotal,
|
||||
int groupCount)
|
||||
{
|
||||
var baseName = string.IsNullOrWhiteSpace(profileName) ? "班级小助手课表" : 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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 口径 ↔ 班级小助手口径"的转换,保证只有一处需要跟着
|
||||
/// 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(上课)的点**,
|
||||
/// 课间 / 分割线 / 行动不占 Classes 的位置。因此这里必须按 TimeType 过滤后再配对,
|
||||
/// 否则整张课表会错位。
|
||||
/// </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 validPoints = plan.ValidTimeLayoutItems.Count > 0
|
||||
? plan.ValidTimeLayoutItems.Where(item => item.TimeType == 0).ToList()
|
||||
: classPoints;
|
||||
|
||||
var weekDay = ToServerDayOfWeek(plan.TimeRule.WeekDay);
|
||||
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
||||
if (plan.TimeRule.WeekCountDivTotal > 2)
|
||||
{
|
||||
warning = "存在 3 周以上的轮换课表,班级小助手只支持单双周,已按「每周」处理";
|
||||
}
|
||||
|
||||
var classes = plan.Classes;
|
||||
var count = Math.Min(classes.Count, validPoints.Count);
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var info = classes[index];
|
||||
var point = validPoints[index];
|
||||
if (info is null) 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),
|
||||
Subject = subject,
|
||||
TeacherName = ResolveSubjectTeacher(info.SubjectId, profile),
|
||||
WeekParity = parity,
|
||||
WeekCountDiv = plan.TimeRule.WeekCountDiv,
|
||||
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
|
||||
PlanName = plan.Name,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <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,
|
||||
Name = BuildTimePointName(point, ref classPointIndex),
|
||||
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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<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="连接班级小助手"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="填入班级小助手后端的地址与设备令牌。设备令牌在班级小助手 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}" />
|
||||
|
||||
<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 Settings.MirrorSchedule}" />
|
||||
</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="课表以「合并」方式写入班级小助手:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
||||
|
||||
<fa:SettingsExpander Header="上报课表到班级小助手"
|
||||
Description="把 ClassIsland 里启用的课表同步给班级小助手,Web 端与桌面客户端即可看到课程表。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
<fa:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding Settings.UploadSchedule, Mode=TwoWay}" />
|
||||
</fa:SettingsExpander.Footer>
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
|
||||
Description="反方向同步:把班级小助手上排好的课表写回 ClassIsland,会新建一份以「班级小助手-」开头的档案课表,不会修改老师原有的课表。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
<fa:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding Settings.MirrorSchedule, Mode=TwoWay}" />
|
||||
</fa:SettingsExpander.Footer>
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<fa:SettingsExpander Header="自动上报"
|
||||
Description="应用启动、课表变化以及每隔一段时间各上报一次;关闭后只能用上面的「立即上报」手动触发。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</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="老师在班级小助手上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
|
||||
|
||||
<fa:SettingsExpander Header="接收班级小助手提醒"
|
||||
Description="关闭后本机会忽略老师在班级小助手上发布的提醒(提醒仍会留在班级通知中心)。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</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="" />
|
||||
</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="" />
|
||||
</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="提示:班级小助手 Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</controls:SettingsPageBase>
|
||||
@@ -0,0 +1,198 @@
|
||||
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>
|
||||
/// 「班级小助手联动」设置页。
|
||||
///
|
||||
/// 页面上的绑定源就是本页自己(<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",
|
||||
"班级小助手联动",
|
||||
"\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,7 +1,14 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { app, safeStorage } from 'electron';
|
||||
import { DEFAULT_ISLAND_APPEARANCE, DEFAULT_SERVER_URL, type IslandAppearance } from '@classhelper/shared';
|
||||
import {
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
DEFAULT_SERVER_URL,
|
||||
type ClassIslandNotificationChannel,
|
||||
type IslandAppearance,
|
||||
} from '@classhelper/shared';
|
||||
import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js';
|
||||
|
||||
/** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */
|
||||
@@ -22,6 +29,8 @@ interface PersistedConfig {
|
||||
island?: Partial<IslandAppearance>;
|
||||
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
|
||||
homeworkBoard?: Partial<HomeworkBoardSettings>;
|
||||
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
|
||||
notificationChannel?: ClassIslandNotificationChannel;
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: PersistedConfig = {
|
||||
@@ -31,8 +40,18 @@ const DEFAULT_CONFIG: PersistedConfig = {
|
||||
tokenEncrypted: false,
|
||||
island: { ...DEFAULT_ISLAND_APPEARANCE },
|
||||
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
|
||||
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||||
};
|
||||
|
||||
/** 通知显示位置的白名单校验:非法值一律回落到默认值,避免坏配置把提醒"静默吞掉" */
|
||||
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 {
|
||||
const mode = input?.mode === 'list' ? 'list' : 'board';
|
||||
@@ -126,6 +145,7 @@ function readPersisted(): PersistedConfig {
|
||||
tokenEncrypted: parsed.tokenEncrypted === true,
|
||||
island: normalizeIslandAppearance(parsed.island),
|
||||
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
|
||||
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
|
||||
};
|
||||
} catch {
|
||||
return { ...DEFAULT_CONFIG };
|
||||
@@ -147,6 +167,7 @@ export function getConfig(): DesktopStoredConfig {
|
||||
token: decryptToken(persisted),
|
||||
island: normalizeIslandAppearance(persisted.island),
|
||||
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
|
||||
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -169,6 +190,9 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
|
||||
...patch.homeworkBoard,
|
||||
});
|
||||
}
|
||||
if (patch.notificationChannel !== undefined) {
|
||||
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
|
||||
}
|
||||
if (patch.token !== undefined) {
|
||||
const encrypted = encryptToken(patch.token);
|
||||
persisted.token = encrypted.token;
|
||||
|
||||
@@ -2536,6 +2536,69 @@ async function runSettingsPageAppearanceCheck(
|
||||
return { ok: false, detail: result?.detail ?? '未执行' };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置页「通知显示位置」自检(需求:提醒弹在 ClassHelper 还是 ClassIsland,由客户端自己选)。
|
||||
*
|
||||
* 走真实 UI:切到设置页 → 点单选项 → 断言「本地配置已写入」,再切回去。
|
||||
* 服务端同步(写回班级记录)由 verify:classisland 覆盖,这里只管客户端这一半。
|
||||
*/
|
||||
async function runSettingsPageChannelCheck(
|
||||
win: BrowserWindow,
|
||||
): Promise<{ ok: boolean; detail: string }> {
|
||||
const result = (await win.webContents
|
||||
.executeJavaScript(
|
||||
`(async () => {
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const before = (await window.desktop.getConfig()).notificationChannel;
|
||||
location.hash = '#/settings';
|
||||
|
||||
// 找到「通知显示位置」那张卡片里的单选组
|
||||
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('通知显示位置'),
|
||||
);
|
||||
}
|
||||
if (!group) return { ok: false, detail: '设置页没有「通知显示位置」卡片' };
|
||||
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 时触发,跑完自动退出)。
|
||||
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
|
||||
@@ -2732,6 +2795,10 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
const settingsAppearance = await runSettingsPageAppearanceCheck(win);
|
||||
record('设置页真实 UI:拖拽滑块即时改变灵动岛外观', settingsAppearance.ok, settingsAppearance.detail);
|
||||
|
||||
// 通知显示位置(客户端自己选):提醒弹在 ClassHelper 还是 ClassIsland
|
||||
const settingsChannel = await runSettingsPageChannelCheck(win);
|
||||
record('设置页真实 UI:切换「通知显示位置」写回本地配置', settingsChannel.ok, settingsChannel.detail);
|
||||
|
||||
// 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊;
|
||||
// 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读")
|
||||
const realtime = await runIslandRealtimeCheck(win);
|
||||
|
||||
@@ -25,6 +25,14 @@ onMounted(async () => {
|
||||
bootText.value = '正在恢复登录状态...';
|
||||
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);
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
API_PATHS,
|
||||
type ClassDetailDto,
|
||||
type ClassDto,
|
||||
type ClassIslandNotificationChannel,
|
||||
type DashboardSummary,
|
||||
type GradeDto,
|
||||
type HomeworkDto,
|
||||
@@ -32,6 +33,22 @@ export const classApi = {
|
||||
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 }),
|
||||
};
|
||||
|
||||
export const scheduleApi = {
|
||||
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
|
||||
api.get(API_PATHS.schedules, params),
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
|
||||
import {
|
||||
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
|
||||
STORAGE_KEYS,
|
||||
formatDate,
|
||||
type ClassIslandNotificationChannel,
|
||||
} from '@classhelper/shared';
|
||||
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
|
||||
import { classChannelApi } from '../api/index.js';
|
||||
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
|
||||
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
|
||||
|
||||
@@ -19,6 +25,13 @@ export const useAppStore = defineStore('app', () => {
|
||||
const currentWeek = ref(1);
|
||||
const maxWeek = ref(20);
|
||||
const initialized = ref(false);
|
||||
/**
|
||||
* 通知显示位置:这台教室机器把提醒弹在哪个端。
|
||||
*
|
||||
* 单一事实来源是**班级记录**(服务端),本地这份只是缓存:断网时也要能按上次的选择
|
||||
* 决定本机弹不弹,所以启动时先读本地配置、登录后再向服务器对齐一次。
|
||||
*/
|
||||
const notificationChannel = ref<ClassIslandNotificationChannel>(DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL);
|
||||
|
||||
const offline = computed(() => !serverReachable.value);
|
||||
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
|
||||
@@ -108,6 +121,53 @@ export const useAppStore = defineStore('app', () => {
|
||||
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 {
|
||||
serverUrl,
|
||||
serverReachable,
|
||||
@@ -119,9 +179,14 @@ export const useAppStore = defineStore('app', () => {
|
||||
currentWeek,
|
||||
maxWeek,
|
||||
initialized,
|
||||
notificationChannel,
|
||||
init,
|
||||
ping,
|
||||
applyServerUrl,
|
||||
applyLocalNotificationChannel,
|
||||
loadNotificationChannel,
|
||||
setNotificationChannel,
|
||||
shouldPopupLocally,
|
||||
refreshCacheStats,
|
||||
clearCache,
|
||||
markSynced,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '@classhelper/shared';
|
||||
import { socketUrlOf } from '../config.js';
|
||||
import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js';
|
||||
import { useAppStore } from './app.js';
|
||||
|
||||
type RealtimeEventName = keyof ServerToClientEvents;
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
@@ -22,6 +23,18 @@ type EventHandler = (payload: unknown) => void;
|
||||
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
|
||||
*/
|
||||
export const useRealtimeStore = defineStore('realtime', () => {
|
||||
const app = useAppStore();
|
||||
|
||||
/**
|
||||
* 本机要不要弹这次提醒。
|
||||
*
|
||||
* 教室在设置页选了「只在 ClassIsland 上弹」时,客户端仍然把通知收进通知中心(列表里有记录、
|
||||
* 未读红点照常),但**不再弹窗、也不上岛** —— 否则老师会看到两个地方同时炸出来。
|
||||
*/
|
||||
function popupLocally(): boolean {
|
||||
return app.shouldPopupLocally();
|
||||
}
|
||||
|
||||
const connected = ref(false);
|
||||
const connecting = ref(false);
|
||||
const lastEventName = ref<string | null>(null);
|
||||
@@ -65,14 +78,16 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
||||
|
||||
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
|
||||
mark(SOCKET_EVENTS.notificationNew);
|
||||
ElMessage({
|
||||
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
|
||||
type: payload.priority === 'URGENT' ? 'error' : 'info',
|
||||
duration: 5000,
|
||||
showClose: true,
|
||||
});
|
||||
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
|
||||
pushNotificationToIsland(payload);
|
||||
if (popupLocally()) {
|
||||
ElMessage({
|
||||
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
|
||||
type: payload.priority === 'URGENT' ? 'error' : 'info',
|
||||
duration: 5000,
|
||||
showClose: true,
|
||||
});
|
||||
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
|
||||
pushNotificationToIsland(payload);
|
||||
}
|
||||
dispatch(SOCKET_EVENTS.notificationNew, payload);
|
||||
});
|
||||
|
||||
@@ -86,14 +101,16 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
||||
|
||||
instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => {
|
||||
mark(SOCKET_EVENTS.callNew);
|
||||
ElMessage({
|
||||
message: payload.title,
|
||||
type: 'warning',
|
||||
duration: 8000,
|
||||
showClose: true,
|
||||
});
|
||||
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
|
||||
pushCallToIsland(payload);
|
||||
if (popupLocally()) {
|
||||
ElMessage({
|
||||
message: payload.title,
|
||||
type: 'warning',
|
||||
duration: 8000,
|
||||
showClose: true,
|
||||
});
|
||||
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
|
||||
pushCallToIsland(payload);
|
||||
}
|
||||
dispatch(SOCKET_EVENTS.callNew, payload);
|
||||
});
|
||||
|
||||
|
||||
@@ -68,6 +68,8 @@ async function submit(): Promise<void> {
|
||||
try {
|
||||
await auth.login(form.serverUrl, form.code.trim(), form.password);
|
||||
await appStore.init(form.serverUrl);
|
||||
// 登录后对齐"通知显示位置":这台机器上次选的是弹 ClassHelper 还是弹 ClassIsland
|
||||
await appStore.loadNotificationChannel(auth.classId);
|
||||
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
||||
ElMessage.success(`已进入 ${auth.displayName}`);
|
||||
await router.replace('/schedule');
|
||||
|
||||
@@ -3,6 +3,9 @@ import { computed, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS,
|
||||
DEFAULT_ISLAND_APPEARANCE,
|
||||
ISLAND_APPEARANCE_RANGES as RANGES,
|
||||
ISLAND_POSITION_LABELS,
|
||||
@@ -10,11 +13,13 @@ import {
|
||||
ISLAND_STYLES,
|
||||
ISLAND_POSITIONS,
|
||||
formatDate,
|
||||
type ClassIslandNotificationChannel,
|
||||
type IslandAppearance,
|
||||
type IslandPosition,
|
||||
type IslandStyle,
|
||||
} from '@classhelper/shared';
|
||||
import type { DesktopAppInfo } from '../../types/desktop.js';
|
||||
import { classApi } from '../api/index.js';
|
||||
import { pingHealth, setApiBaseUrl } from '../api/http.js';
|
||||
import { normalizeServerUrl } from '../config.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
@@ -113,6 +118,51 @@ async function logout(): Promise<void> {
|
||||
await router.replace('/login');
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 通知显示位置 */
|
||||
|
||||
/**
|
||||
* 通知显示位置:这台教室机器把提醒弹在哪个端。
|
||||
*
|
||||
* - both:ClassHelper 客户端(弹窗 + 灵动岛)与 ClassIsland 都弹(默认)
|
||||
* - client:只弹 ClassHelper 客户端
|
||||
* - classisland:只弹 ClassIsland(客户端只进通知中心,不弹窗、不上岛)
|
||||
*
|
||||
* 值会落本地配置(本机立刻生效)并写回班级记录(服务端据此决定要不要推 ClassIsland)。
|
||||
*/
|
||||
const channelSaving = ref(false);
|
||||
/**
|
||||
* 本班有没有接入 ClassIsland 设备。
|
||||
*
|
||||
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
|
||||
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
|
||||
*/
|
||||
const classIslandConnected = ref(false);
|
||||
|
||||
const channelOptions = computed(() =>
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
|
||||
value,
|
||||
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
|
||||
disabled: value === 'classisland' && !classIslandConnected.value,
|
||||
})),
|
||||
);
|
||||
|
||||
async function loadClassChannel(): Promise<void> {
|
||||
const classId = auth.classId;
|
||||
if (!classId) return;
|
||||
await appStore.loadNotificationChannel(classId);
|
||||
}
|
||||
|
||||
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
|
||||
channelSaving.value = true;
|
||||
try {
|
||||
const saved = await appStore.setNotificationChannel(auth.classId, value);
|
||||
if (saved) ElMessage.success('提醒显示位置已更新');
|
||||
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
|
||||
} finally {
|
||||
channelSaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 灵动岛个性化 */
|
||||
|
||||
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
|
||||
@@ -184,6 +234,12 @@ onMounted(async () => {
|
||||
await loadAppInfo();
|
||||
await appStore.refreshCacheStats();
|
||||
await loadIslandAppearance();
|
||||
await loadClassChannel();
|
||||
// 本班有没有 ClassIsland 设备:没接就禁用"弹在 ClassIsland"这一项,避免选了却没效果
|
||||
const classList = await classApi.list().catch(() => []);
|
||||
classIslandConnected.value = classList.some(
|
||||
(item) => item.id === auth.classId && item.classIslandConnected === true,
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -365,6 +421,30 @@ onMounted(async () => {
|
||||
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
|
||||
/>
|
||||
</el-card>
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>通知显示位置</span></template>
|
||||
<p class="text-muted">
|
||||
老师在班级小助手上发布通知 / 叫人时,提醒要弹在哪里由这台教室机器决定。选择会同步到班级,
|
||||
服务端据此决定是否一并推送到教室的 ClassIsland。
|
||||
</p>
|
||||
<el-radio-group
|
||||
:model-value="appStore.notificationChannel"
|
||||
:disabled="channelSaving"
|
||||
class="channel-group"
|
||||
@change="(value: string | number | boolean | undefined) => applyChannel(value as ClassIslandNotificationChannel)"
|
||||
>
|
||||
<el-radio v-for="item in channelOptions" :key="item.value" :value="item.value" :disabled="item.disabled">
|
||||
{{ item.label }}
|
||||
</el-radio>
|
||||
</el-radio-group>
|
||||
<p class="text-muted channel-hint">
|
||||
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
|
||||
<template v-if="!classIslandConnected">
|
||||
<br />提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在 ClassIsland 上弹")。
|
||||
</template>
|
||||
</p>
|
||||
</el-card>
|
||||
|
||||
<el-card shadow="never" class="mt-12">
|
||||
<template #header><span>账号信息</span></template>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
@@ -425,6 +505,18 @@ onMounted(async () => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.channel-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.channel-hint {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
+13
-1
@@ -2,7 +2,12 @@
|
||||
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
|
||||
* 渲染进程只依赖这些最小 API,不接触 Node.js。
|
||||
*/
|
||||
import type { IslandAppearance, IslandNotification, IslandState } from '@classhelper/shared';
|
||||
import type {
|
||||
ClassIslandNotificationChannel,
|
||||
IslandAppearance,
|
||||
IslandNotification,
|
||||
IslandState,
|
||||
} from '@classhelper/shared';
|
||||
|
||||
/** 作业页展示偏好(看板 / 列表,看板外观) */
|
||||
export interface HomeworkBoardSettings {
|
||||
@@ -27,6 +32,13 @@ export interface DesktopStoredConfig {
|
||||
island: IslandAppearance;
|
||||
/** 作业页展示偏好(看板/列表、显示时间、看板字号) */
|
||||
homeworkBoard: HomeworkBoardSettings;
|
||||
/**
|
||||
* 通知显示位置(both / client / classisland)。
|
||||
*
|
||||
* 这台教室机器"提醒到底弹在哪个端"由它决定:值同时存本地(决定本机要不要弹窗/上岛)
|
||||
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
|
||||
*/
|
||||
notificationChannel: ClassIslandNotificationChannel;
|
||||
}
|
||||
|
||||
export interface DesktopAppInfo {
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"db:seed": "prisma db seed",
|
||||
"db:reset": "prisma migrate reset --force",
|
||||
"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": {
|
||||
"@classhelper/shared": "workspace:*",
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
-- ClassIsland 联动:设备接入 + 提醒下发
|
||||
--
|
||||
-- 说明:
|
||||
-- 1) IntegrationDevice 保存「班级 ↔ 一台装有联动插件的机器」的绑定关系,
|
||||
-- 令牌只存 sha256 哈希,明文仅在创建/重置接口返回一次。
|
||||
-- 2) 同一班级内 deviceKey 唯一:插件重装后带同一机器码会重绑原记录,避免设备列表堆重复项。
|
||||
-- 3) ClassIslandPush 落库是为了「设备离线时通知不丢」:插件重连后拉取未确认的一条立即弹出。
|
||||
-- 4) 本文件重复执行是安全的(逐条执行、忽略"已存在"错误)。
|
||||
|
||||
CREATE TABLE "IntegrationDevice" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"deviceKey" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL DEFAULT 'plugin',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"tokenHint" TEXT NOT NULL,
|
||||
"syncScheduleToServer" BOOLEAN NOT NULL DEFAULT true,
|
||||
"mirrorScheduleToClassIsland" BOOLEAN NOT NULL DEFAULT false,
|
||||
"classIslandVersion" TEXT,
|
||||
"pluginVersion" TEXT,
|
||||
"lastSeenAt" DATETIME,
|
||||
"classPlanLoaded" BOOLEAN NOT NULL DEFAULT false,
|
||||
"currentSubject" TEXT,
|
||||
"currentTimeState" TEXT,
|
||||
"currentPeriodStart" TEXT,
|
||||
"currentPeriodEnd" TEXT,
|
||||
"nextSubject" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "IntegrationDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "IntegrationDevice_classId_deviceKey_key" ON "IntegrationDevice"("classId", "deviceKey");
|
||||
CREATE INDEX "IntegrationDevice_classId_idx" ON "IntegrationDevice"("classId");
|
||||
CREATE INDEX "IntegrationDevice_tokenHash_idx" ON "IntegrationDevice"("tokenHash");
|
||||
|
||||
CREATE TABLE "ClassIslandPush" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"deviceId" TEXT,
|
||||
"title" TEXT NOT NULL,
|
||||
"content" TEXT NOT NULL,
|
||||
"durationSeconds" INTEGER NOT NULL DEFAULT 8,
|
||||
"speechContent" TEXT,
|
||||
"urgent" BOOLEAN NOT NULL DEFAULT false,
|
||||
"createdBy" TEXT,
|
||||
"notificationId" TEXT,
|
||||
"ackedAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "ClassIslandPush_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "ClassIslandPush_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "IntegrationDevice" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX "ClassIslandPush_classId_createdAt_idx" ON "ClassIslandPush"("classId", "createdAt");
|
||||
CREATE INDEX "ClassIslandPush_deviceId_ackedAt_idx" ON "ClassIslandPush"("deviceId", "ackedAt");
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
-- 通知显示位置(由教室的班级客户端设置):both / client / classisland
|
||||
-- 旧数据默认 both:行为与之前一致(客户端照常收,同时推给 ClassIsland)
|
||||
ALTER TABLE "Class" ADD COLUMN "notificationChannel" TEXT NOT NULL DEFAULT 'both';
|
||||
@@ -60,6 +60,9 @@ model Class {
|
||||
passwordHash String?
|
||||
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
|
||||
termWeeks Int @default(20)
|
||||
/// 通知显示位置(由教室的班级客户端在设置页里选):
|
||||
/// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland
|
||||
notificationChannel String @default("both")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@ -73,6 +76,8 @@ model Class {
|
||||
grades Grade[]
|
||||
enrollments Enrollment[]
|
||||
timeLayouts TimeLayout[]
|
||||
integrationDevices IntegrationDevice[]
|
||||
classIslandPushes ClassIslandPush[]
|
||||
|
||||
@@index([teacherId])
|
||||
}
|
||||
@@ -262,3 +267,80 @@ model Grade {
|
||||
@@index([classId, examName])
|
||||
@@index([courseId])
|
||||
}
|
||||
|
||||
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端)
|
||||
///
|
||||
/// 说明:
|
||||
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
|
||||
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
|
||||
/// 避免设备列表里堆满重复项;
|
||||
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。
|
||||
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 的课表回传到本服务
|
||||
syncScheduleToServer Boolean @default(true)
|
||||
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)
|
||||
mirrorScheduleToClassIsland Boolean @default(false)
|
||||
classIslandVersion String?
|
||||
pluginVersion String?
|
||||
lastSeenAt 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)
|
||||
/// 发起人的用户 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])
|
||||
}
|
||||
@@ -0,0 +1,507 @@
|
||||
#!/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;
|
||||
}
|
||||
|
||||
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: '0.1.0.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. 下发提醒
|
||||
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}`,
|
||||
);
|
||||
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}` : '班级里没有学生,跳过',
|
||||
);
|
||||
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. 课表镜像(默认关闭 → 打开 → 拉取)
|
||||
const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
|
||||
record(
|
||||
'镜像开关关闭时不下发课表',
|
||||
mirrorOff.status === 200 && mirrorOff.payload?.data === null,
|
||||
`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}`,
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------- 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);
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ClassDto,
|
||||
ClassIslandNotificationChannel,
|
||||
ClassTeacherBrief,
|
||||
CourseBrief,
|
||||
CourseDto,
|
||||
@@ -95,6 +96,8 @@ export interface ClassLike {
|
||||
teacherId: string;
|
||||
/** 本学期教学周数(班主任可调,默认 20) */
|
||||
termWeeks?: number;
|
||||
/** 通知显示位置:both / client / classisland(由教室的班级客户端设置) */
|
||||
notificationChannel?: string | null;
|
||||
createdAt: Date;
|
||||
teacher?: TeacherBriefLike | null;
|
||||
_count?: {
|
||||
@@ -106,6 +109,11 @@ 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 {
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -113,6 +121,7 @@ export function toClassDto(item: ClassLike): ClassDto {
|
||||
grade: item.grade,
|
||||
teacherId: item.teacherId,
|
||||
termWeeks: item.termWeeks ?? 20,
|
||||
notificationChannel: toNotificationChannel(item.notificationChannel),
|
||||
createdAt: toIso(item.createdAt),
|
||||
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
||||
studentCount: item._count?.students ?? undefined,
|
||||
|
||||
@@ -6,6 +6,7 @@ import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { toNotificationDto } from '../../lib/mappers.js';
|
||||
import { emitToClass, emitToUser } from '../../realtime/bus.js';
|
||||
import { pushToClassIsland, shouldPushToClassIsland } from '../integrations/integrations.service.js';
|
||||
import type { CreateCallInput } from './calls.schemas.js';
|
||||
|
||||
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
|
||||
@@ -63,6 +64,24 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
|
||||
// 因此"叫人"消息在班级设备上也会立即展开(学生姓名在标题里,全班都能看到叫谁)
|
||||
emitToUser(created.classId, SOCKET_EVENTS.callNew, dto);
|
||||
|
||||
// 叫人同样推到教室的 ClassIsland:老师点名时,教室大屏也弹一条(学生姓名在标题里)。
|
||||
// 显示位置由教室客户端决定;推送失败不影响叫人本身。
|
||||
try {
|
||||
if (await shouldPushToClassIsland(input.classId)) {
|
||||
await pushToClassIsland({
|
||||
classId: input.classId,
|
||||
title: created.title,
|
||||
content: created.content,
|
||||
urgent: input.urgent === true,
|
||||
createdBy: user.sub,
|
||||
notificationId: created.id,
|
||||
teacherName,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn(`叫人推送 ClassIsland 失败(叫人本身已发出):${String(error)}`);
|
||||
}
|
||||
|
||||
logger.info(`${input.urgent ? '紧急叫人' : '叫人'}:${teacherName} → ${studentName}(${message})`);
|
||||
return dto;
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import {
|
||||
validate,
|
||||
validatedBody,
|
||||
validatedParams,
|
||||
validatedQuery,
|
||||
} from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
addStudentSchema,
|
||||
@@ -14,10 +19,12 @@ import {
|
||||
listClassesQuerySchema,
|
||||
updateClassAccountSchema,
|
||||
updateClassSchema,
|
||||
updateNotificationChannelSchema,
|
||||
type AddStudentInput,
|
||||
type CreateClassInput,
|
||||
type UpdateClassAccountInput,
|
||||
type UpdateClassInput,
|
||||
type UpdateNotificationChannelInput,
|
||||
} from './classes.schemas.js';
|
||||
import * as classService from './classes.service.js';
|
||||
|
||||
@@ -106,6 +113,42 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/classes/:id/notification-channel - 读取"通知显示到哪个端"
|
||||
*
|
||||
* 教室的班级客户端登录后会拉这个值,据此决定自己是弹窗/上岛还是静默;
|
||||
* 教师/管理员也能读(Web 端展示用)。
|
||||
*/
|
||||
router.get('/:id/notification-channel', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await classService.getNotificationChannel(user, id), '获取通知显示位置成功');
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/classes/:id/notification-channel - 设置"通知显示到哪个端"
|
||||
*
|
||||
* **由教室的班级客户端自己选**(班级账号可改),教师/管理员也可改;
|
||||
* 普通学生账号被拒(见 service 内的断言)。
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/notification-channel',
|
||||
validate({ params: idParamSchema, body: updateNotificationChannelSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await classService.updateNotificationChannel(
|
||||
user,
|
||||
id,
|
||||
validatedBody<UpdateNotificationChannelInput>(req),
|
||||
),
|
||||
'通知显示位置已更新',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/classes/:id/students - 班级学生名单 */
|
||||
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { CLASSISLAND_NOTIFICATION_CHANNELS } from '@classhelper/shared';
|
||||
import { keywordSchema } from '../../lib/schemas.js';
|
||||
|
||||
export const createClassSchema = z.object({
|
||||
@@ -54,6 +55,17 @@ export const assignHeadTeacherSchema = z.object({
|
||||
teacherId: z.string().min(1, '请选择班主任'),
|
||||
});
|
||||
|
||||
/**
|
||||
* 通知显示位置(both / client / classisland)。
|
||||
* 由**教室的班级客户端**在设置页里改,教师/管理员也可以在 Web 端改。
|
||||
*/
|
||||
export const updateNotificationChannelSchema = z.object({
|
||||
notificationChannel: z.enum(
|
||||
CLASSISLAND_NOTIFICATION_CHANNELS as unknown as [string, ...string[]],
|
||||
{ message: '显示位置只能是 both / client / classisland' },
|
||||
),
|
||||
});
|
||||
|
||||
export const classStudentParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
@@ -67,4 +79,5 @@ export const classTeacherParamSchema = z.object({
|
||||
export type CreateClassInput = z.infer<typeof createClassSchema>;
|
||||
export type UpdateClassInput = z.infer<typeof updateClassSchema>;
|
||||
export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
|
||||
export type UpdateNotificationChannelInput = z.infer<typeof updateNotificationChannelSchema>;
|
||||
export type AddStudentInput = z.infer<typeof addStudentSchema>;
|
||||
@@ -1,4 +1,9 @@
|
||||
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
|
||||
import type {
|
||||
ClassDetailDto,
|
||||
ClassDto,
|
||||
ClassIslandNotificationChannel,
|
||||
StudentDto,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import {
|
||||
assertClassAccess,
|
||||
@@ -6,6 +11,7 @@ import {
|
||||
assertCanManageClasses,
|
||||
assertCanManageRoster,
|
||||
classScopeIdFilter,
|
||||
isStaff,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import {
|
||||
@@ -15,11 +21,25 @@ import {
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toClassDto, toCourseDto, toStudentDto, toTeacherBrief } from '../../lib/mappers.js';
|
||||
import {
|
||||
toClassDto,
|
||||
toCourseDto,
|
||||
toNotificationChannel,
|
||||
toStudentDto,
|
||||
toTeacherBrief,
|
||||
} from '../../lib/mappers.js';
|
||||
import { hashPassword } from '../../lib/password.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||
import type { AddStudentInput, CreateClassInput, UpdateClassInput } from './classes.schemas.js';
|
||||
import type {
|
||||
AddStudentInput,
|
||||
CreateClassInput,
|
||||
UpdateClassInput,
|
||||
UpdateNotificationChannelInput,
|
||||
} from './classes.schemas.js';
|
||||
import { attachIntegrationFlag } from '../integrations/integrations.service.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { isClassSession } from '../../lib/session.js';
|
||||
|
||||
const countSelect = {
|
||||
students: true,
|
||||
@@ -76,7 +96,8 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
|
||||
orderBy: [{ grade: 'asc' }, { name: 'asc' }],
|
||||
});
|
||||
|
||||
return classes.map((item) => toClassDtoWithAccount(user, item));
|
||||
// 附上「是否已接入 ClassIsland」徽标:Web 端班级列表/课表页据此提示
|
||||
return attachIntegrationFlag(classes.map((item) => toClassDtoWithAccount(user, item)));
|
||||
}
|
||||
|
||||
/** 班级详情:学生名单 + 课程 + 协作教师 */
|
||||
@@ -333,3 +354,48 @@ export async function removeTeacher(user: TokenPayload, classId: string, teacher
|
||||
|
||||
await prisma.classTeacher.deleteMany({ where: { classId, teacherId } });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 通知显示位置 */
|
||||
|
||||
/**
|
||||
* 读取班级的通知显示位置(both / client / classisland)。
|
||||
*
|
||||
* 这是**教室客户端**要读的值:客户端登录后拉一次,把自己的弹窗行为对齐到同一个选择。
|
||||
* 学生也能读自己班的(只读),便于排查"为什么教室没弹"。
|
||||
*/
|
||||
export async function getNotificationChannel(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> {
|
||||
await assertClassAccess(user, classId);
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { notificationChannel: true },
|
||||
});
|
||||
return { notificationChannel: toNotificationChannel(record?.notificationChannel) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置班级的通知显示位置。
|
||||
*
|
||||
* 允许两类人改:**该班的班级账号**(教室机器的客户端 —— 需求就是"由客户端自己选")
|
||||
* 与教师 / 管理员(Web 端也能改,便于老师统一要求)。
|
||||
* 普通学生账号不允许:否则"教室里到底弹哪个端"会变成任意学生都能改的开关。
|
||||
*/
|
||||
export async function updateNotificationChannel(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
input: UpdateNotificationChannelInput,
|
||||
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> {
|
||||
await assertClassAccess(user, classId);
|
||||
if (!isClassSession(user) && !isStaff(user)) {
|
||||
throw ApiError.forbidden('只有班级账号或教师/管理员可以修改通知显示位置');
|
||||
}
|
||||
|
||||
const channel = toNotificationChannel(input.notificationChannel);
|
||||
await prisma.class.update({ where: { id: classId }, data: { notificationChannel: channel } });
|
||||
// 广播出去:其他端(Web 列表 / 同班客户端)能立刻看到这个选择变了
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
logger.info(`班级通知显示位置已更新:classId=${classId} channel=${channel} by=${user.sub}`);
|
||||
return { notificationChannel: channel };
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { assertCanManageSchedule } from '../../lib/access.js';
|
||||
import { weekParityFromDiv } from '@classhelper/shared';
|
||||
import type { ClassPlanImportInput } from './imports.schemas.js';
|
||||
|
||||
/**
|
||||
@@ -96,14 +97,10 @@ function normalizeTime(value: unknown): string | null {
|
||||
|
||||
/**
|
||||
* `WeekCountDiv` / `WeekCountDivTotal` → 我们课表的 weekParity。
|
||||
* 只支持 2 周循环(=单双周);3 周及以上循环无法用单双周表达,记 warning 并按"每周"处理。
|
||||
* 口径集中在 `@classhelper/shared` 的 `weekParityFromDiv`(插件上报走同一份实现),
|
||||
* 这里只是本模块内的别名,保证"手动导入 JSON"与"插件实时上报"落到库里完全一致。
|
||||
*/
|
||||
function resolveParity(div: number, total: number): 'ALL' | 'ODD' | 'EVEN' {
|
||||
if (div <= 0 || total <= 1) return 'ALL';
|
||||
const position = ((div - 1) % total) + 1;
|
||||
if (total === 2) return position === 1 ? 'ODD' : 'EVEN';
|
||||
return 'ALL';
|
||||
}
|
||||
const resolveParity = weekParityFromDiv;
|
||||
|
||||
/** 解析 ClassIsland 档案 JSON(或只含 ClassPlans 的片段)里的课程表 */
|
||||
export function parseClassIslandClassPlan(input: unknown): ClassPlanParseResult {
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, RequestHandler } from 'express';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
|
||||
/**
|
||||
* 联动设备的令牌工具。
|
||||
*
|
||||
* 为什么不用 JWT:
|
||||
* - 令牌是「设备级长期凭证」,插件装在教室机器上,长时间无人值守,
|
||||
* 需要一个"管理员在 Web 端点了重置才失效"的凭证,而不是有固定过期时间的会话;
|
||||
* - 明文只在创建/重置接口返回一次,服务端只存 sha256 —— 库被读走也无法反推出可用令牌。
|
||||
*/
|
||||
export function generateDeviceToken(): string {
|
||||
// 32 字节随机 -> 64 位十六进制,前缀便于在日志/设置页里一眼认出
|
||||
return `chci_${crypto.randomBytes(32).toString('hex')}`;
|
||||
}
|
||||
|
||||
export function hashDeviceToken(token: string): string {
|
||||
return crypto.createHash('sha256').update(token, 'utf8').digest('hex');
|
||||
}
|
||||
|
||||
/** 令牌前缀提示(chci_ + 前 8 位),用于设备列表里人眼识别 */
|
||||
export function tokenHintOf(token: string): string {
|
||||
return `${token.slice(0, 13)}…`;
|
||||
}
|
||||
|
||||
/** 从 \`X-ClassIsland-Token\` 或 \`Authorization: Bearer\` 中取出设备令牌 */
|
||||
export function extractDeviceToken(headerValue: string | undefined, custom: unknown): string | null {
|
||||
if (typeof custom === 'string' && custom.trim()) return custom.trim();
|
||||
if (!headerValue) return null;
|
||||
const [scheme, value] = headerValue.split(' ');
|
||||
if (!value || scheme.toLowerCase() !== 'bearer') return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
/** 设备上下文(与 Express.Request.device 的结构一致) */
|
||||
export interface DeviceContext {
|
||||
deviceId: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
}
|
||||
|
||||
/** 插件侧路由统一挂这个中间件:解析设备令牌并回查设备记录 */
|
||||
export function authenticateDevice(): RequestHandler {
|
||||
return (req, _res, next) => {
|
||||
void (async () => {
|
||||
try {
|
||||
const token = extractDeviceToken(req.headers.authorization, req.headers['x-classisland-token']);
|
||||
if (!token) throw ApiError.unauthorized('缺少设备令牌(X-ClassIsland-Token)');
|
||||
|
||||
const record = await prisma.integrationDevice.findFirst({
|
||||
where: { tokenHash: hashDeviceToken(token) },
|
||||
select: {
|
||||
id: true,
|
||||
classId: true,
|
||||
name: true,
|
||||
enabled: true,
|
||||
syncScheduleToServer: true,
|
||||
mirrorScheduleToClassIsland: true,
|
||||
},
|
||||
});
|
||||
if (!record) throw ApiError.unauthorized('设备令牌无效,请在「ClassIsland 联动」里重新生成');
|
||||
|
||||
const context: DeviceContext = {
|
||||
deviceId: record.id,
|
||||
classId: record.classId,
|
||||
name: record.name,
|
||||
enabled: record.enabled,
|
||||
syncScheduleToServer: record.syncScheduleToServer,
|
||||
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
||||
};
|
||||
req.device = context;
|
||||
next();
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
})();
|
||||
};
|
||||
}
|
||||
|
||||
/** 取出当前设备上下文,未鉴权时抛 401 */
|
||||
export function getDevice(req: Request): DeviceContext {
|
||||
const device = req.device;
|
||||
if (!device) throw ApiError.unauthorized('缺少设备上下文');
|
||||
return device;
|
||||
}
|
||||
|
||||
/** 设备被管理员停用:插件上报与下发一律拒绝(403 而不是 401,便于插件提示"请管理员开启") */
|
||||
export function assertDeviceEnabled(device: DeviceContext): void {
|
||||
if (!device.enabled) {
|
||||
throw ApiError.forbidden('该设备已在「ClassIsland 联动」中被停用,请联系管理员开启');
|
||||
}
|
||||
}
|
||||
|
||||
/** 生成的令牌字符串的字面量前缀,供测试与文档引用 */
|
||||
export const DEVICE_TOKEN_PREFIX = 'chci_';
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import { authenticateDevice, assertDeviceEnabled, getDevice } from './device-auth.js';
|
||||
import {
|
||||
ackNotificationSchema,
|
||||
classIslandReportSchema,
|
||||
createDeviceSchema,
|
||||
deviceIdParamSchema,
|
||||
listDevicesQuerySchema,
|
||||
sendNotificationSchema,
|
||||
updateDeviceSchema,
|
||||
type ClassIslandReportInput,
|
||||
type CreateDeviceInput,
|
||||
type SendNotificationInput,
|
||||
type UpdateDeviceInput,
|
||||
} from './integrations.schemas.js';
|
||||
import * as integrationService from './integrations.service.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/* ================================================================== 插件侧
|
||||
* 用**设备令牌**鉴权(X-ClassIsland-Token / Authorization: Bearer),不是 JWT 会话。
|
||||
* 路由放在文件最前面,避免被下面的 authenticate() 拦住;每个路由单独挂 authenticateDevice()。
|
||||
*/
|
||||
|
||||
/** POST /api/integrations/classisland/report - 插件上报当前状态 +(可选)课表 */
|
||||
router.post(
|
||||
'/classisland/report',
|
||||
authenticateDevice(),
|
||||
validate({ body: classIslandReportSchema }),
|
||||
async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
const result = await integrationService.applyReport(device, validatedBody<ClassIslandReportInput>(req));
|
||||
sendOk(res, result, '上报成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/integrations/classisland/pending - 插件拉取尚未确认的下发提醒(离线补齐) */
|
||||
router.get('/classisland/pending', authenticateDevice(), async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
sendOk(res, await integrationService.listPendingNotifications(device), '获取待提醒成功');
|
||||
});
|
||||
|
||||
/** POST /api/integrations/classisland/ack - 插件确认提醒已弹出(之后不再补发) */
|
||||
router.post(
|
||||
'/classisland/ack',
|
||||
authenticateDevice(),
|
||||
validate({ body: ackNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
await integrationService.ackNotification(device, validatedBody<{ id: string }>(req).id);
|
||||
sendOk(res, { acked: true }, '已确认');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/integrations/classisland/class-plan - 插件拉取本班课表(镜像回 ClassIsland) */
|
||||
router.get('/classisland/class-plan', authenticateDevice(), async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
if (!device.mirrorScheduleToClassIsland) {
|
||||
sendOk(res, null, '该设备未开启「把班级课表镜像到 ClassIsland」');
|
||||
return;
|
||||
}
|
||||
sendOk(res, await integrationService.pullClassPlanForDevice(device), '获取课表成功');
|
||||
});
|
||||
|
||||
/* ================================================================== Web 管理端
|
||||
* 用 JWT 鉴权;权限与"课表管理 / 发通知"保持一致,由 service 内二次校验班级维度角色。
|
||||
*/
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* GET /api/integrations/devices?classId= - 联动设备列表(含最后一次上报的状态快照)
|
||||
*
|
||||
* 仅管理员/教师可见:设备列表属于管理面,学生也不需要知道教室机器接了哪台设备;
|
||||
* 教师只能看到自己可访问班级的设备(service 内按 classScope 过滤)。
|
||||
*/
|
||||
router.get(
|
||||
'/devices',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ query: listDevicesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(res, await integrationService.listDevices(getAuthUser(req), classId), '获取设备列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
|
||||
router.post(
|
||||
'/devices',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ body: createDeviceSchema }),
|
||||
async (req, res) => {
|
||||
const result = await integrationService.createDevice(
|
||||
getAuthUser(req),
|
||||
validatedBody<CreateDeviceInput>(req),
|
||||
);
|
||||
sendCreated(res, result, '设备已创建,请把令牌填入插件设置(只显示这一次)');
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/integrations/devices/:id - 改名 / 启停 / 调整同步开关 */
|
||||
router.patch(
|
||||
'/devices/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: deviceIdParamSchema, body: updateDeviceSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await integrationService.updateDevice(getAuthUser(req), id, validatedBody<UpdateDeviceInput>(req)),
|
||||
'设备已更新',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
|
||||
router.post(
|
||||
'/devices/:id/token',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await integrationService.resetDeviceToken(getAuthUser(req), id), '令牌已重置');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/integrations/devices/:id - 删除设备 */
|
||||
router.delete(
|
||||
'/devices/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await integrationService.deleteDevice(getAuthUser(req), id);
|
||||
sendOk(res, { id }, '设备已删除');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/classisland/notify - 把提醒下发到该班的 ClassIsland 设备 */
|
||||
router.post(
|
||||
'/classisland/notify',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ body: sendNotificationSchema }),
|
||||
async (req, res) => {
|
||||
const result = await integrationService.sendNotification(
|
||||
getAuthUser(req),
|
||||
validatedBody<SendNotificationInput>(req),
|
||||
);
|
||||
sendOk(
|
||||
res,
|
||||
result,
|
||||
result.skipped === 'channel-client'
|
||||
? '该班教室已设置为「只在 ClassHelper 客户端显示」,本次未推送到 ClassIsland'
|
||||
: result.targetCount === 0
|
||||
? '已保存提醒,但该班级还没有已接入的 ClassIsland 设备'
|
||||
: `已下发到 ${result.delivered} 台 ClassIsland 设备`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export const integrationsModule = defineModule({
|
||||
name: 'integrations',
|
||||
basePath: '/integrations',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
WEEK_PARITY_VALUES,
|
||||
CLASSISLAND_NOTIFICATION_MAX_DURATION,
|
||||
CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
|
||||
} from '@classhelper/shared';
|
||||
import { booleanFlagSchema, timeSchema } from '../../lib/schemas.js';
|
||||
|
||||
/** 1=周一 … 7=周日 */
|
||||
const dayOfWeekSchema = z.coerce.number().int().min(1).max(7);
|
||||
|
||||
export const integrationModeSchema = z.enum(['plugin', 'import']);
|
||||
export const weekParitySchema = z.enum(WEEK_PARITY_VALUES as unknown as [string, ...string[]]);
|
||||
|
||||
/* ------------------------------------------------------------------ Web 管理端 */
|
||||
|
||||
export const listDevicesQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const createDeviceSchema = z.object({
|
||||
classId: z.string().min(1, '请选择班级'),
|
||||
name: z.string().trim().max(40).optional(),
|
||||
mode: integrationModeSchema.optional(),
|
||||
/** 是否允许插件把 ClassIsland 的课表回传到本服务 */
|
||||
syncScheduleToServer: z.boolean().optional(),
|
||||
/** 是否允许把本班课表镜像回 ClassIsland */
|
||||
mirrorScheduleToClassIsland: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const updateDeviceSchema = z.object({
|
||||
name: z.string().trim().max(40).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
mode: integrationModeSchema.optional(),
|
||||
syncScheduleToServer: z.boolean().optional(),
|
||||
mirrorScheduleToClassIsland: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const deviceIdParamSchema = z.object({ id: z.string().min(1, '缺少设备 id') });
|
||||
|
||||
export const sendNotificationSchema = z.object({
|
||||
classId: z.string().min(1, '请选择班级'),
|
||||
title: z.string().trim().min(1, '请输入提醒标题').max(120),
|
||||
content: z.string().trim().min(1, '请输入提醒内容').max(2000),
|
||||
durationSeconds: z.coerce.number().int().min(1).max(CLASSISLAND_NOTIFICATION_MAX_DURATION).optional(),
|
||||
speech: booleanFlagSchema,
|
||||
speechContent: z.string().trim().max(500).optional(),
|
||||
saveToNotifications: z.boolean().optional(),
|
||||
priority: z.enum(['LOW', 'NORMAL', 'HIGH', 'URGENT']).optional(),
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ 插件侧(设备令牌鉴权) */
|
||||
|
||||
export const reportScheduleEntrySchema = z.object({
|
||||
dayOfWeek: dayOfWeekSchema,
|
||||
startTime: timeSchema,
|
||||
endTime: timeSchema,
|
||||
subject: z.string().trim().min(1, '科目不能为空').max(40),
|
||||
teacherName: z.string().trim().max(40).nullish(),
|
||||
weekParity: weekParitySchema.optional(),
|
||||
weekCountDiv: z.coerce.number().int().min(0).max(30).optional(),
|
||||
weekCountDivTotal: z.coerce.number().int().min(0).max(30).optional(),
|
||||
planName: z.string().trim().max(60).optional(),
|
||||
});
|
||||
|
||||
export const reportTimeLayoutItemSchema = z.object({
|
||||
index: z.coerce.number().int().min(0).max(200).optional(),
|
||||
name: z.string().trim().max(40).optional(),
|
||||
startTime: timeSchema,
|
||||
endTime: timeSchema,
|
||||
type: z.enum(['class', 'break', 'divider', 'action']).optional(),
|
||||
skipped: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const classIslandReportSchema = z.object({
|
||||
pluginVersion: z.string().trim().max(40).optional(),
|
||||
classIslandVersion: z.string().trim().max(40).optional(),
|
||||
state: z
|
||||
.object({
|
||||
inClass: z.boolean().optional(),
|
||||
subject: z.string().trim().max(40).nullish(),
|
||||
nextSubject: z.string().trim().max(40).nullish(),
|
||||
timeState: z.string().trim().max(40).nullish(),
|
||||
periodStart: timeSchema.nullish(),
|
||||
periodEnd: timeSchema.nullish(),
|
||||
week: z.coerce.number().int().min(1).max(60).nullish(),
|
||||
classPlanLoaded: z.boolean().optional(),
|
||||
clientTime: z.string().trim().max(40).optional(),
|
||||
})
|
||||
.optional(),
|
||||
schedule: z
|
||||
.object({
|
||||
mode: z.enum(['replace', 'merge']).optional(),
|
||||
entries: z.array(reportScheduleEntrySchema).max(500, '单次上报的课程条目过多'),
|
||||
})
|
||||
.nullish(),
|
||||
timeLayout: z
|
||||
.object({
|
||||
name: z.string().trim().max(60).optional(),
|
||||
mode: z.enum(['replace', 'merge']).optional(),
|
||||
items: z.array(reportTimeLayoutItemSchema).max(200, '单次上报的节次过多'),
|
||||
})
|
||||
.nullish(),
|
||||
});
|
||||
|
||||
/** 插件确认某条提醒已弹出(避免重连后重复补发) */
|
||||
export const ackNotificationSchema = z.object({
|
||||
id: z.string().min(1, '缺少提醒 id'),
|
||||
});
|
||||
|
||||
/** 插件上报的默认值集中在这里,避免散落在 service 里 */
|
||||
export const REPORT_DEFAULTS = {
|
||||
scheduleMode: 'merge',
|
||||
timeLayoutMode: 'merge',
|
||||
timeLayoutName: 'ClassIsland 时间表',
|
||||
notificationDuration: CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
|
||||
} as const;
|
||||
|
||||
export type CreateDeviceInput = z.infer<typeof createDeviceSchema>;
|
||||
export type UpdateDeviceInput = z.infer<typeof updateDeviceSchema>;
|
||||
export type SendNotificationInput = z.infer<typeof sendNotificationSchema>;
|
||||
export type ClassIslandReportInput = z.infer<typeof classIslandReportSchema>;
|
||||
export type ReportScheduleEntry = z.infer<typeof reportScheduleEntrySchema>;
|
||||
export type ReportTimeLayoutItem = z.infer<typeof reportTimeLayoutItemSchema>;
|
||||
@@ -0,0 +1,694 @@
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
resolveCurrentWeek,
|
||||
weekDivFromParity,
|
||||
type ClassDto,
|
||||
type ClassIslandPendingResult,
|
||||
type ClassIslandPushNotification,
|
||||
type ClassIslandReportResult,
|
||||
type ClassIslandScheduleEntry,
|
||||
type IntegrationDeviceDto,
|
||||
type IntegrationDeviceTokenDto,
|
||||
type SendClassIslandNotificationResult,
|
||||
type TimeLayoutEntry,
|
||||
type NotificationDto,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { assertCanManageSchedule, assertCanPublishContent, resolveClassScope } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { toNotificationChannel, toNotificationDto } from '../../lib/mappers.js';
|
||||
import { emitToClass, emitToRooms } from '../../realtime/bus.js';
|
||||
import { SOCKET_ROOMS } from '@classhelper/shared';
|
||||
import type { DeviceContext } from './device-auth.js';
|
||||
import { generateDeviceToken, hashDeviceToken, tokenHintOf } from './device-auth.js';
|
||||
import type {
|
||||
ClassIslandReportInput,
|
||||
CreateDeviceInput,
|
||||
SendNotificationInput,
|
||||
UpdateDeviceInput,
|
||||
} from './integrations.schemas.js';
|
||||
import { REPORT_DEFAULTS } from './integrations.schemas.js';
|
||||
import {
|
||||
applyReportedSchedule,
|
||||
applyReportedTimeLayout,
|
||||
toClassIslandWeekDay,
|
||||
type MirrorClassPlan,
|
||||
} from './write-back.service.js';
|
||||
|
||||
/**
|
||||
* ClassIsland 联动服务。
|
||||
*
|
||||
* 两条链路:
|
||||
* 1) **ClassHelper → ClassIsland**:教师发通知 → 落库 + Socket.IO 广播 →
|
||||
* 教室机器上的插件弹出全屏提醒(离线时先落库,插件重连后拉取补发);
|
||||
* 2) **ClassIsland → ClassHelper**:插件把"当前上什么课 + 全量课表"上报 →
|
||||
* 本服务按设备令牌解析出所属班级并写库 → 广播 `classisland:state` 给 Web 端实时展示。
|
||||
*
|
||||
* 权限口径:
|
||||
* - 设备管理(创建/重置令牌/启停):与"课表管理"一致(管理员或本班班主任);
|
||||
* - 下发提醒:与"发通知"一致(管理员 / 班主任 / 科任老师)。
|
||||
*/
|
||||
|
||||
/* ------------------------------------------------------------------ 设备管理(Web 端,JWT 鉴权) */
|
||||
|
||||
/** 设备记录 -> DTO(tokenHash 永不出库) */
|
||||
function toDeviceDto(record: {
|
||||
id: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
deviceKey: string;
|
||||
mode: string;
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
classIslandVersion: string | null;
|
||||
pluginVersion: string | null;
|
||||
lastSeenAt: Date | null;
|
||||
classPlanLoaded: boolean;
|
||||
currentSubject: string | null;
|
||||
currentTimeState: string | null;
|
||||
currentPeriodStart: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
nextSubject: string | null;
|
||||
tokenHint: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
class?: { name: string } | null;
|
||||
}): IntegrationDeviceDto {
|
||||
return {
|
||||
id: record.id,
|
||||
classId: record.classId,
|
||||
className: record.class?.name ?? null,
|
||||
name: record.name,
|
||||
deviceKey: record.deviceKey,
|
||||
mode: record.mode === 'import' ? 'import' : 'plugin',
|
||||
enabled: record.enabled,
|
||||
syncScheduleToServer: record.syncScheduleToServer,
|
||||
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
||||
classIslandVersion: record.classIslandVersion,
|
||||
pluginVersion: record.pluginVersion,
|
||||
lastSeenAt: record.lastSeenAt ? record.lastSeenAt.toISOString() : null,
|
||||
classPlanLoaded: record.classPlanLoaded,
|
||||
currentSubject: record.currentSubject,
|
||||
currentTimeState: record.currentTimeState,
|
||||
currentPeriodStart: record.currentPeriodStart,
|
||||
currentPeriodEnd: record.currentPeriodEnd,
|
||||
nextSubject: record.nextSubject,
|
||||
tokenHint: record.tokenHint,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDevices(user: TokenPayload, classId?: string): Promise<IntegrationDeviceDto[]> {
|
||||
// 管理员不传 classId 时看全部;教师只看自己可访问的班级
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
const records = await prisma.integrationDevice.findMany({
|
||||
where: scope.mode === 'all' ? {} : { classId: { in: scope.classIds } },
|
||||
include: { class: { select: { name: true } } },
|
||||
orderBy: [{ classId: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return records.map(toDeviceDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 新建设备:一次性返回明文令牌。
|
||||
* deviceKey 由插件上报的机器码决定,但创建时还不知道机器码,
|
||||
* 因此这里用随机值占位,插件首次上报时按 token 找到记录并回填真实机器码。
|
||||
*/
|
||||
export async function createDevice(
|
||||
user: TokenPayload,
|
||||
input: CreateDeviceInput,
|
||||
): Promise<IntegrationDeviceTokenDto> {
|
||||
await assertCanManageSchedule(user, input.classId);
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: input.classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const token = generateDeviceToken();
|
||||
const created = await prisma.integrationDevice.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
name: input.name?.trim() || `${classRecord.name} ClassIsland 设备`,
|
||||
deviceKey: `pending-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
mode: input.mode ?? 'plugin',
|
||||
tokenHash: hashDeviceToken(token),
|
||||
tokenHint: tokenHintOf(token),
|
||||
syncScheduleToServer: input.syncScheduleToServer !== false,
|
||||
mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland === true,
|
||||
},
|
||||
include: { class: { select: { name: true } } },
|
||||
});
|
||||
|
||||
logger.info(`ClassIsland 联动设备已创建:班级=${classRecord.name} 设备=${created.name}`);
|
||||
return { device: toDeviceDto(created), token };
|
||||
}
|
||||
|
||||
async function findDeviceOrFail(id: string): Promise<{
|
||||
id: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
}> {
|
||||
const record = await prisma.integrationDevice.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, classId: true, name: true },
|
||||
});
|
||||
if (!record) throw ApiError.notFound('设备不存在或已被删除');
|
||||
return record;
|
||||
}
|
||||
|
||||
export async function updateDevice(
|
||||
user: TokenPayload,
|
||||
deviceId: string,
|
||||
input: UpdateDeviceInput,
|
||||
): Promise<IntegrationDeviceDto> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
|
||||
const updated = await prisma.integrationDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: {
|
||||
...(input.name !== undefined ? { name: input.name.trim() || current.name } : {}),
|
||||
...(input.enabled !== undefined ? { enabled: input.enabled } : {}),
|
||||
...(input.mode !== undefined ? { mode: input.mode } : {}),
|
||||
...(input.syncScheduleToServer !== undefined
|
||||
? { syncScheduleToServer: input.syncScheduleToServer }
|
||||
: {}),
|
||||
...(input.mirrorScheduleToClassIsland !== undefined
|
||||
? { mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland }
|
||||
: {}),
|
||||
},
|
||||
include: { class: { select: { name: true } } },
|
||||
});
|
||||
logger.info(`ClassIsland 联动设备已更新:${updated.name} enabled=${updated.enabled}`);
|
||||
return toDeviceDto(updated);
|
||||
}
|
||||
|
||||
/** 重置令牌:旧令牌立即失效(插件侧需要重新填写) */
|
||||
export async function resetDeviceToken(
|
||||
user: TokenPayload,
|
||||
deviceId: string,
|
||||
): Promise<IntegrationDeviceTokenDto> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
|
||||
const token = generateDeviceToken();
|
||||
const updated = await prisma.integrationDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: { tokenHash: hashDeviceToken(token), tokenHint: tokenHintOf(token) },
|
||||
include: { class: { select: { name: true } } },
|
||||
});
|
||||
logger.warn(`ClassIsland 联动设备令牌已重置:${updated.name}(旧令牌立即失效)`);
|
||||
return { device: toDeviceDto(updated), token };
|
||||
}
|
||||
|
||||
export async function deleteDevice(user: TokenPayload, deviceId: string): Promise<void> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
await prisma.integrationDevice.delete({ where: { id: deviceId } });
|
||||
logger.info(`ClassIsland 联动设备已删除:${current.name}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 提醒下发(Web 端 → ClassIsland) */
|
||||
|
||||
function toPushDto(
|
||||
record: {
|
||||
id: string;
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
durationSeconds: number;
|
||||
speechContent: string | null;
|
||||
urgent: boolean;
|
||||
createdAt: Date;
|
||||
},
|
||||
extras: { className?: string | null; teacherName?: string | null } = {},
|
||||
): ClassIslandPushNotification {
|
||||
return {
|
||||
id: record.id,
|
||||
title: record.title,
|
||||
content: record.content,
|
||||
durationSeconds: record.durationSeconds,
|
||||
speechContent: record.speechContent,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
urgent: record.urgent,
|
||||
classId: record.classId,
|
||||
className: extras.className ?? null,
|
||||
teacherName: extras.teacherName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 下发一条提醒到该班级的 ClassIsland 设备。
|
||||
*
|
||||
* - 落库(ClassIslandPush)→ 在线设备通过 Socket.IO 立即收到;离线设备重连后走
|
||||
* `GET /api/integrations/classisland/pending` 补齐,保证"老师按了发送就一定送到";
|
||||
* - `saveToNotifications` 默认 true:同时写一条班级通知,学生端通知中心/灵动岛也能看到,
|
||||
* 避免"只在 ClassIsland 上弹了一下,事后查无此事"。
|
||||
*/
|
||||
export async function sendNotification(
|
||||
user: TokenPayload,
|
||||
input: SendNotificationInput,
|
||||
): Promise<SendClassIslandNotificationResult> {
|
||||
await assertCanPublishContent(user, input.classId);
|
||||
|
||||
const teacher = await prisma.user.findUnique({
|
||||
where: { id: user.sub },
|
||||
select: { name: true, username: true },
|
||||
});
|
||||
const teacherName = teacher?.name ?? teacher?.username ?? null;
|
||||
|
||||
const priority = input.priority ?? 'NORMAL';
|
||||
let notificationId: string | null = null;
|
||||
|
||||
// 教室在客户端里选了"只在 ClassHelper 客户端显示"时,**任何入口**都不再推 ClassIsland
|
||||
// (包括这个专门发给 ClassIsland 的入口)—— 规则只有一条,老师不用记"哪个页面有例外"。
|
||||
// 仍然把通知落库,并把跳过原因回给调用方,由 Web 端提示"不是没送达,是教室没要"。
|
||||
const allowed = await shouldPushToClassIsland(input.classId);
|
||||
|
||||
if (input.saveToNotifications !== false) {
|
||||
const created = await prisma.notification.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
priority,
|
||||
createdBy: user.sub,
|
||||
},
|
||||
include: { creator: { select: { id: true, name: true, username: true } }, reads: true },
|
||||
});
|
||||
notificationId = created.id;
|
||||
const dto: NotificationDto = toNotificationDto(created, { userId: user.sub, withStatus: true });
|
||||
// 学生端(桌面客户端 / 班级设备)照旧收到通知中心的实时推送
|
||||
emitToClass(input.classId, SOCKET_EVENTS.notificationNew, dto);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
logger.info(`ClassIsland 提醒被跳过:班级=${input.classId} 已选择"只在 ClassHelper 客户端显示"`);
|
||||
return { delivered: 0, targetCount: 0, notificationId, skipped: 'channel-client' };
|
||||
}
|
||||
|
||||
const result = await pushToClassIsland({
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
durationSeconds: input.durationSeconds,
|
||||
speechContent: input.speech
|
||||
? input.speechContent?.trim() || `${input.title}。${input.content}`
|
||||
: null,
|
||||
urgent: priority === 'URGENT',
|
||||
createdBy: user.sub,
|
||||
notificationId,
|
||||
teacherName,
|
||||
});
|
||||
|
||||
return { ...result, notificationId, skipped: null };
|
||||
}
|
||||
|
||||
/** 推送所需的入参:三个发布入口(联动页 / 通知发布 / 叫人)共用同一套语义 */
|
||||
export interface PushToClassIslandInput {
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
/** 显示时长(秒),留空用默认值 */
|
||||
durationSeconds?: number;
|
||||
/** 语音朗读内容;null = 不朗读 */
|
||||
speechContent?: string | null;
|
||||
urgent?: boolean;
|
||||
createdBy?: string | null;
|
||||
/** 若这次提醒也落了通知中心的库,把通知 id 带上,便于事后追溯 */
|
||||
notificationId?: string | null;
|
||||
/** 覆盖落库/广播时附带的教师名(三个入口的取法不同) */
|
||||
teacherName?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 把一条提醒推给某个班的 ClassIsland 设备(落库 + 广播)。
|
||||
*
|
||||
* 这是**三个发布入口的唯一共用实现**:
|
||||
* 1. 联动页的「下发提醒」(sendNotification);
|
||||
* 2. 通知发布页的 POST /notifications;
|
||||
* 3. 叫人 POST /calls。
|
||||
* 因此"换个页面发通知就联动不到 ClassIsland"这类不一致不会再出现。
|
||||
*
|
||||
* 调用方负责权限校验与(必要时)显示位置开关判断。
|
||||
*/
|
||||
export async function pushToClassIsland(
|
||||
input: PushToClassIslandInput,
|
||||
): Promise<{ delivered: number; targetCount: number }> {
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: input.classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const devices = await prisma.integrationDevice.findMany({
|
||||
where: { classId: input.classId, enabled: true, mode: 'plugin' },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
// 一个已接入的设备都没有时**不落推送记录**:
|
||||
// 否则任何"发通知"的动作(通知发布 / 叫人 / 冒烟脚本)都会给该班堆一条待提醒,
|
||||
// 等这台教室真的装上插件时被一次性补发一堆过期内容。
|
||||
// 教室机器只是离线(设备存在但没上报)的情况不受影响 —— 那正是"离线补齐"要覆盖的场景。
|
||||
if (devices.length === 0) {
|
||||
logger.info(`跳过 ClassIsland 推送:班级=${classRecord.name} 还没有已接入的设备`);
|
||||
return { delivered: 0, targetCount: 0 };
|
||||
}
|
||||
|
||||
const push = await prisma.classIslandPush.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
durationSeconds: input.durationSeconds ?? REPORT_DEFAULTS.notificationDuration,
|
||||
speechContent: input.speechContent ?? null,
|
||||
urgent: input.urgent === true,
|
||||
createdBy: input.createdBy ?? null,
|
||||
notificationId: input.notificationId ?? null,
|
||||
// 24 小时未送达/未确认的提醒不再补发,避免学生过几天突然看到旧提醒
|
||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||
},
|
||||
});
|
||||
|
||||
emitToClass(input.classId, SOCKET_EVENTS.classislandNotification, {
|
||||
...toPushDto(push, { className: classRecord.name, teacherName: input.teacherName ?? null }),
|
||||
targetCount: devices.length,
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`ClassIsland 提醒下发:班级=${classRecord.name} 设备数=${devices.length} 标题="${input.title}"`,
|
||||
);
|
||||
return { delivered: devices.length, targetCount: devices.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级的通知显示位置(由教室的班级客户端在设置页里选)。
|
||||
*
|
||||
* - `client`:教室里选了"只在 ClassHelper 客户端弹",因此**不要**推 ClassIsland;
|
||||
* - 其余(both / classisland / 非法值)都推 —— 非法值按默认 both 处理,宁可多送一次也不要静默丢提醒。
|
||||
*/
|
||||
export async function shouldPushToClassIsland(classId: string): Promise<boolean> {
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { notificationChannel: true },
|
||||
});
|
||||
return toNotificationChannel(record?.notificationChannel) !== 'client';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 插件侧(设备令牌鉴权) */
|
||||
|
||||
/**
|
||||
* 插件上报:状态 + 可选的全量课表/节次时间。
|
||||
*
|
||||
* 关键点:**班级由设备令牌决定**,插件无法指定 classId,
|
||||
* 因此一台被绑定到 A 班的机器不可能把课表写进 B 班。
|
||||
*/
|
||||
export async function applyReport(
|
||||
device: DeviceContext,
|
||||
input: ClassIslandReportInput,
|
||||
): Promise<ClassIslandReportResult> {
|
||||
const now = new Date();
|
||||
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||
|
||||
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
|
||||
const state = input.state;
|
||||
await prisma.integrationDevice.update({
|
||||
where: { id: device.deviceId },
|
||||
data: {
|
||||
lastSeenAt: now,
|
||||
...(input.pluginVersion ? { pluginVersion: input.pluginVersion } : {}),
|
||||
...(input.classIslandVersion ? { classIslandVersion: input.classIslandVersion } : {}),
|
||||
...(state
|
||||
? {
|
||||
classPlanLoaded: state.classPlanLoaded === true,
|
||||
currentSubject: state.subject ?? null,
|
||||
currentTimeState: state.timeState ?? null,
|
||||
currentPeriodStart: state.periodStart ?? null,
|
||||
currentPeriodEnd: state.periodEnd ?? null,
|
||||
nextSubject: state.nextSubject ?? null,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (state) {
|
||||
emitToRooms([SOCKET_ROOMS.class(device.classId), SOCKET_ROOMS.teachers], SOCKET_EVENTS.classislandState, {
|
||||
classId: device.classId,
|
||||
deviceId: device.deviceId,
|
||||
deviceName: device.name,
|
||||
inClass: state.inClass === true,
|
||||
subject: state.subject ?? null,
|
||||
nextSubject: state.nextSubject ?? null,
|
||||
timeState: state.timeState ?? null,
|
||||
periodStart: state.periodStart ?? null,
|
||||
periodEnd: state.periodEnd ?? null,
|
||||
week: state.week ?? week,
|
||||
classPlanLoaded: state.classPlanLoaded === true,
|
||||
at: now.toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
// 2) 课表写入(受设备开关与班级设置双重约束)
|
||||
let scheduleApplied = false;
|
||||
let scheduleCreated = 0;
|
||||
let scheduleUpdated = 0;
|
||||
let courses: string[] = [];
|
||||
if (input.schedule && input.schedule.entries.length > 0) {
|
||||
if (!device.syncScheduleToServer) {
|
||||
logger.warn(`ClassIsland 上报课表被忽略:设备「${device.name}」的"回传课表到服务端"开关已关闭`);
|
||||
} else {
|
||||
const result = await applyReportedSchedule(
|
||||
device.classId,
|
||||
input.schedule.mode ?? REPORT_DEFAULTS.scheduleMode,
|
||||
input.schedule.entries,
|
||||
);
|
||||
scheduleApplied = result.applied;
|
||||
scheduleCreated = result.created;
|
||||
scheduleUpdated = result.updated;
|
||||
courses = result.courses;
|
||||
if (result.applied) {
|
||||
emitToClass(device.classId, SOCKET_EVENTS.scheduleUpdated, {
|
||||
classId: device.classId,
|
||||
action: 'imported',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 节次时间表写入
|
||||
let timeLayoutApplied = false;
|
||||
if (input.timeLayout && input.timeLayout.items.length > 0) {
|
||||
const result = await applyReportedTimeLayout(
|
||||
device.classId,
|
||||
input.timeLayout.name?.trim() || REPORT_DEFAULTS.timeLayoutName,
|
||||
input.timeLayout.items,
|
||||
);
|
||||
timeLayoutApplied = result.applied;
|
||||
}
|
||||
|
||||
// 4) 取出尚未确认的下发提醒(插件刚启动时把漏掉的提醒立刻弹出来)
|
||||
const pending = await pendingNotificationFor(device.deviceId, device.classId);
|
||||
|
||||
return {
|
||||
scheduleApplied,
|
||||
scheduleCreated,
|
||||
scheduleUpdated,
|
||||
courses,
|
||||
timeLayoutApplied,
|
||||
week,
|
||||
serverTime: now.toISOString(),
|
||||
settings: {
|
||||
mirrorScheduleToClassIsland: device.mirrorScheduleToClassIsland,
|
||||
enabled: device.enabled,
|
||||
},
|
||||
pendingNotification: pending,
|
||||
};
|
||||
}
|
||||
|
||||
/** 该设备/该班级最近一条尚未确认、未过期的下发提醒 */
|
||||
async function pendingNotificationFor(
|
||||
deviceId: string,
|
||||
classId: string,
|
||||
): Promise<ClassIslandPushNotification | null> {
|
||||
const record = await prisma.classIslandPush.findFirst({
|
||||
where: {
|
||||
classId,
|
||||
ackedAt: null,
|
||||
expiresAt: { gt: new Date() },
|
||||
OR: [{ deviceId: null }, { deviceId }],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (!record) return null;
|
||||
return toPushDto(record);
|
||||
}
|
||||
|
||||
/** 插件重连/定时轮询:拉取本班所有未确认的提醒 */
|
||||
export async function listPendingNotifications(device: DeviceContext): Promise<ClassIslandPendingResult> {
|
||||
const now = new Date();
|
||||
const records = await prisma.classIslandPush.findMany({
|
||||
where: {
|
||||
classId: device.classId,
|
||||
ackedAt: null,
|
||||
expiresAt: { gt: now },
|
||||
OR: [{ deviceId: null }, { deviceId: device.deviceId }],
|
||||
},
|
||||
orderBy: { createdAt: 'asc' },
|
||||
take: 20,
|
||||
});
|
||||
return {
|
||||
notifications: records.map((item) => toPushDto(item)),
|
||||
serverTime: now.toISOString(),
|
||||
week: resolveCurrentWeek(env.termStartDate, now),
|
||||
};
|
||||
}
|
||||
|
||||
/** 插件确认提醒已弹出(之后不再补发) */
|
||||
export async function ackNotification(device: DeviceContext, id: string): Promise<void> {
|
||||
const record = await prisma.classIslandPush.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, classId: true, deviceId: true },
|
||||
});
|
||||
if (!record) throw ApiError.notFound('提醒不存在');
|
||||
if (record.classId !== device.classId) throw ApiError.forbidden('该提醒不属于本设备所在班级');
|
||||
await prisma.classIslandPush.update({
|
||||
where: { id },
|
||||
data: { ackedAt: new Date(), ...(record.deviceId ? {} : { deviceId: device.deviceId }) },
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 插件拉取本班课表(mirrorScheduleToClassIsland 打开时,
|
||||
* 插件把它转换后覆盖写入 ClassIsland,实现"教师在 Web 端排课 → 教室课表跟着变")。
|
||||
*/
|
||||
export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
|
||||
classPlan: MirrorClassPlan;
|
||||
week: number;
|
||||
serverTime: string;
|
||||
}> {
|
||||
const now = new Date();
|
||||
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: device.classId },
|
||||
select: { id: true, name: true, termWeeks: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const [schedules, timeLayouts] = await Promise.all([
|
||||
prisma.schedule.findMany({
|
||||
where: { classId: device.classId },
|
||||
include: { course: { select: { id: true, name: true } } },
|
||||
orderBy: [{ dayOfWeek: 'asc' }, { startTime: 'asc' }],
|
||||
}),
|
||||
prisma.timeLayout.findMany({
|
||||
where: { classId: device.classId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
]);
|
||||
|
||||
// 节次时间表:优先用班级里已导入的 ClassIsland 时间表,其次按课表的开始时间合成
|
||||
const layoutId = `ch-${device.classId}`;
|
||||
const layoutName = timeLayouts[0]?.name ?? '班级小助手时间表';
|
||||
let layouts: { startTime: string; endTime: string; timeType: number }[] = [];
|
||||
if (timeLayouts.length > 0 && timeLayouts[0]) {
|
||||
layouts = parseStoredTimeLayout(timeLayouts[0].items).map((item) => ({
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
// 本系统的 class->0(上课)、break->1(课间)、divider->2(分割线)
|
||||
timeType: item.type === 'break' ? 1 : item.type === 'divider' ? 2 : 0,
|
||||
}));
|
||||
} else {
|
||||
layouts = synthesizeTimeLayout(schedules);
|
||||
}
|
||||
|
||||
const entries = schedules.map((item) => {
|
||||
const parity = weekDivFromParity((item.weekParity as 'ALL' | 'ODD' | 'EVEN' | null) ?? 'ALL');
|
||||
return {
|
||||
weekDay: toClassIslandWeekDay(item.dayOfWeek),
|
||||
weekCountDiv: parity.weekCountDiv,
|
||||
weekCountDivTotal: parity.weekCountDivTotal,
|
||||
subject: item.course?.name ?? '未命名科目',
|
||||
teacherName: null,
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
timeLayoutId: layoutId,
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
classPlan: {
|
||||
entries,
|
||||
timeLayouts: [{ id: layoutId, name: layoutName, layouts }],
|
||||
profileName: `班级小助手-${classRecord.name}`,
|
||||
termStartDate: env.termStartDate,
|
||||
},
|
||||
week,
|
||||
serverTime: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/** 解析库里存的节次 JSON(容错:坏数据不抛异常,退化为空) */
|
||||
function parseStoredTimeLayout(raw: string): { startTime: string; endTime: string; type: string }[] {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed
|
||||
.map((item) => item as Partial<TimeLayoutEntry>)
|
||||
.filter(
|
||||
(item): item is Partial<TimeLayoutEntry> & { startTime: string; endTime: string } =>
|
||||
typeof item.startTime === 'string' && typeof item.endTime === 'string',
|
||||
)
|
||||
.map((item) => ({
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
type: typeof item.type === 'string' ? item.type : 'class',
|
||||
}));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** 没有导入节次时间表时,用当天课表里出现过的起止时间合成一份(去重并按时间排序) */
|
||||
function synthesizeTimeLayout(
|
||||
schedules: { startTime: string; endTime: string }[],
|
||||
): { startTime: string; endTime: string; timeType: number }[] {
|
||||
const seen = new Map<string, string>();
|
||||
for (const item of schedules) {
|
||||
if (!seen.has(item.startTime)) seen.set(item.startTime, item.endTime);
|
||||
}
|
||||
return [...seen.entries()]
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.map(([startTime, endTime]) => ({ startTime, endTime, timeType: 0 }));
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 供仪表盘/统计使用 */
|
||||
|
||||
/** 某个班级当前是否已有在线设备(用于 Web 端提示"该班尚未接入 ClassIsland") */
|
||||
export async function countEnabledDevices(classIds: string[]): Promise<number> {
|
||||
if (classIds.length === 0) return 0;
|
||||
return prisma.integrationDevice.count({
|
||||
where: { classId: { in: classIds }, enabled: true, mode: 'plugin' },
|
||||
});
|
||||
}
|
||||
|
||||
/** 供 ClassDto 附加"是否已接入 ClassIsland"(Web 端班级列表里显示徽标) */
|
||||
export async function attachIntegrationFlag(classes: ClassDto[]): Promise<ClassDto[]> {
|
||||
if (classes.length === 0) return classes;
|
||||
const ids = classes.map((item) => item.id);
|
||||
const devices = await prisma.integrationDevice.findMany({
|
||||
where: { classId: { in: ids }, enabled: true },
|
||||
select: { classId: true },
|
||||
});
|
||||
const connected = new Set(devices.map((item) => item.classId));
|
||||
return classes.map((item) => ({ ...item, classIslandConnected: connected.has(item.id) }));
|
||||
}
|
||||
|
||||
export type { ClassIslandScheduleEntry };
|
||||
@@ -0,0 +1,222 @@
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { weekParityFromDiv } from '@classhelper/shared';
|
||||
import type { ReportScheduleEntry, ReportTimeLayoutItem } from './integrations.schemas.js';
|
||||
|
||||
/**
|
||||
* 插件上报的课表 / 节次时间 → 写入本服务。
|
||||
*
|
||||
* 设计要点:
|
||||
* 1) **复用导入模块的口径**:与 `modules/imports/class-plan.service.ts`、
|
||||
* `time-layout.service.ts` 完全一致 —— 缺失的科目自动补建课程(任课老师取班主任)、
|
||||
* merge 按 `星期 + 开始时间 + 单双周` 去重、replace 先清空再写入。
|
||||
* 这样"插件实时上报"和"手动导入 JSON"落到库里的结果是一样的,不会出现两套语义。
|
||||
* 2) **幂等**:插件每次上报(启动、课表变化、定时)都会带全量课表,
|
||||
* 因此 merge 必须是"存在则更新、不存在则新增",重复上报不会产生重复行。
|
||||
* 3) 整个写入放在一个事务里:解析/校验失败的批次不会留下半截数据。
|
||||
*/
|
||||
|
||||
export interface ScheduleWriteResult {
|
||||
applied: boolean;
|
||||
created: number;
|
||||
updated: number;
|
||||
courses: string[];
|
||||
weekStart: number;
|
||||
weekEnd: number;
|
||||
}
|
||||
|
||||
/** 单双周口径:优先用插件显式给的 weekParity,否则从 WeekCountDiv/Total 推导 */
|
||||
function resolveParity(entry: ReportScheduleEntry): 'ALL' | 'ODD' | 'EVEN' {
|
||||
if (entry.weekParity === 'ALL' || entry.weekParity === 'ODD' || entry.weekParity === 'EVEN') {
|
||||
return entry.weekParity;
|
||||
}
|
||||
const { weekCountDiv = 0, weekCountDivTotal = 0 } = entry;
|
||||
return weekParityFromDiv(weekCountDiv, weekCountDivTotal);
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入课表。classId 由**设备令牌**决定(不接受插件指定),因此插件无法越权写别的班级。
|
||||
*/
|
||||
export async function applyReportedSchedule(
|
||||
classId: string,
|
||||
mode: 'replace' | 'merge',
|
||||
entries: ReportScheduleEntry[],
|
||||
): Promise<ScheduleWriteResult> {
|
||||
const empty: ScheduleWriteResult = {
|
||||
applied: false,
|
||||
created: 0,
|
||||
updated: 0,
|
||||
courses: [],
|
||||
weekStart: 1,
|
||||
weekEnd: 20,
|
||||
};
|
||||
if (entries.length === 0) return empty;
|
||||
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { id: true, teacherId: true, termWeeks: true },
|
||||
});
|
||||
if (!classRecord) {
|
||||
logger.warn(`ClassIsland 上报:班级不存在,已丢弃本批课表(classId=${classId})`);
|
||||
return empty;
|
||||
}
|
||||
|
||||
// 起止周次:与导入模块保持一致(weekStart 由班级进度决定,weekEnd 取班级教学周数)
|
||||
const weekStart = 1;
|
||||
const weekEnd = classRecord.termWeeks > 0 ? classRecord.termWeeks : 20;
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
// 1) 补齐缺失的课程(任课老师先用班主任占位,老师可在课表管理里改)
|
||||
const existingCourses = await tx.course.findMany({
|
||||
where: { classId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
const courseIdByName = new Map(existingCourses.map((item) => [item.name, item.id]));
|
||||
const createdCourses: string[] = [];
|
||||
for (const name of new Set(entries.map((item) => item.subject))) {
|
||||
if (courseIdByName.has(name)) continue;
|
||||
const created = await tx.course.create({
|
||||
data: { classId, name, teacherId: classRecord.teacherId },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
courseIdByName.set(created.name, created.id);
|
||||
createdCourses.push(created.name);
|
||||
}
|
||||
|
||||
// 2) replace:先清空该班课表(与导入模块的 replace 语义一致)
|
||||
if (mode === 'replace') {
|
||||
await tx.schedule.deleteMany({ where: { classId } });
|
||||
}
|
||||
|
||||
// 3) 逐条 upsert:merge 的去重键是「星期 + 开始时间 + 单双周」
|
||||
let created = 0;
|
||||
let updated = 0;
|
||||
for (const entry of entries) {
|
||||
const courseId = courseIdByName.get(entry.subject);
|
||||
if (!courseId) continue;
|
||||
const weekParity = resolveParity(entry);
|
||||
const existing = await tx.schedule.findFirst({
|
||||
where: { classId, dayOfWeek: entry.dayOfWeek, startTime: entry.startTime, weekParity },
|
||||
select: { id: true },
|
||||
});
|
||||
if (existing) {
|
||||
await tx.schedule.update({
|
||||
where: { id: existing.id },
|
||||
data: { courseId, endTime: entry.endTime, weekStart, weekEnd },
|
||||
});
|
||||
updated += 1;
|
||||
} else {
|
||||
await tx.schedule.create({
|
||||
data: {
|
||||
classId,
|
||||
courseId,
|
||||
dayOfWeek: entry.dayOfWeek,
|
||||
startTime: entry.startTime,
|
||||
endTime: entry.endTime,
|
||||
location: null,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
weekParity,
|
||||
},
|
||||
});
|
||||
created += 1;
|
||||
}
|
||||
}
|
||||
return { created, updated, createdCourses, allCourses: [...courseIdByName.keys()] };
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`ClassIsland 上报课表:班级=${classId} 模式=${mode} 新增=${result.created} 更新=${result.updated} ` +
|
||||
`补建科目=[${result.createdCourses.join(',')}]`,
|
||||
);
|
||||
return {
|
||||
applied: true,
|
||||
created: result.created,
|
||||
updated: result.updated,
|
||||
courses: result.allCourses,
|
||||
weekStart,
|
||||
weekEnd,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 写入节次时间表(TimeLayout)。
|
||||
* 与 `modules/imports/time-layout.service.ts` 的口径保持一致:
|
||||
* 同一班级同名配置整体替换;没有则新建。
|
||||
* 注意:`action`(ClassIsland TimeType=3,行动)在本系统里没有对应节次类型,
|
||||
* 写入时降级为 `divider`(不占课时,只做视觉分隔),避免类型校验失败。
|
||||
*/
|
||||
export async function applyReportedTimeLayout(
|
||||
classId: string,
|
||||
name: string,
|
||||
items: ReportTimeLayoutItem[],
|
||||
): Promise<{ applied: boolean; count: number }> {
|
||||
if (items.length === 0) return { applied: false, count: 0 };
|
||||
|
||||
const normalized = items.map((item, index) => ({
|
||||
index: item.index && item.index > 0 ? item.index : index + 1,
|
||||
name: item.name?.trim() || `第 ${index + 1} 节`,
|
||||
startTime: item.startTime,
|
||||
endTime: item.endTime,
|
||||
type: item.type === 'action' ? 'divider' : (item.type ?? 'class'),
|
||||
skipped: item.skipped === true,
|
||||
}));
|
||||
|
||||
const current = await prisma.timeLayout.findFirst({
|
||||
where: { classId, name },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (current) {
|
||||
await prisma.timeLayout.update({
|
||||
where: { id: current.id },
|
||||
data: { items: JSON.stringify(normalized), source: 'classisland' },
|
||||
});
|
||||
} else {
|
||||
await prisma.timeLayout.create({
|
||||
data: { classId, name, source: 'classisland', items: JSON.stringify(normalized) },
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(`ClassIsland 上报节次时间表:班级=${classId} 名称=${name} 共 ${normalized.length} 节`);
|
||||
return { applied: true, count: normalized.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* 把本服务的课表转换成 ClassIsland 的档案口径(供插件拉回去镜像)。
|
||||
*
|
||||
* 关键换算:
|
||||
* - 星期:本系统 1=周一…7=周日 → ClassIsland 0=周日、1=周一…6=周六;
|
||||
* - 节次:ClassIsland 的 Classes[i] 对应 Layouts 里第 i 个 TimeType=0(上课)的点,
|
||||
* 因此这里必须**按节次顺序**生成 layouts,并把每个 Schedule 归到"开始时间最接近且不晚于它"的节次上;
|
||||
* - 单双周:ALL→(0,0);ODD→(1,2);EVEN→(2,2),与导入模块的解析互逆。
|
||||
*/
|
||||
export interface MirrorClassPlan {
|
||||
entries: {
|
||||
weekDay: number;
|
||||
weekCountDiv: number;
|
||||
weekCountDivTotal: number;
|
||||
subject: string;
|
||||
teacherName: string | null;
|
||||
/**
|
||||
* 该课目的起止时间(HH:mm)。插件靠它重建 ClassIsland 的时间表:
|
||||
* `ClassPlan.Classes[i]` 必须与时间表里第 i 个上课点一一对应,缺了时间就整份错位。
|
||||
*/
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
timeLayoutId: string;
|
||||
}[];
|
||||
timeLayouts: {
|
||||
id: string;
|
||||
name: string;
|
||||
layouts: { startTime: string; endTime: string; timeType: number }[];
|
||||
}[];
|
||||
profileName: string;
|
||||
termStartDate: string;
|
||||
}
|
||||
|
||||
/** 本系统 dayOfWeek(1=周一…7=周日)→ ClassIsland WeekDay(0=周日…6=周六) */
|
||||
export function toClassIslandWeekDay(dayOfWeek: number): number {
|
||||
return dayOfWeek % 7;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { logger } from '../../lib/logger.js';
|
||||
import { toNotificationDto } from '../../lib/mappers.js';
|
||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { pushToClassIsland, shouldPushToClassIsland } from '../integrations/integrations.service.js';
|
||||
import { computeClassStatus } from '../schedules/schedules.service.js';
|
||||
import type { CreateNotificationInput } from './notifications.schemas.js';
|
||||
|
||||
@@ -97,9 +98,57 @@ export async function createNotification(
|
||||
|
||||
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true });
|
||||
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
|
||||
|
||||
// 同一条通知也推到教室的 ClassIsland 上("老师发通知 → 教室大屏弹出"不该只在联动页生效)。
|
||||
// 是否真的推送由**教室客户端**在设置页里选的显示位置决定(见 Class.notificationChannel):
|
||||
// 选了"只弹 ClassHelper 客户端"就不打扰 ClassIsland。
|
||||
await pushToClassIslandIfEnabled({
|
||||
classId: created.classId,
|
||||
title: created.title,
|
||||
content: created.content,
|
||||
priority,
|
||||
createdBy: user.sub,
|
||||
notificationId: created.id,
|
||||
teacherName: created.creator?.name ?? created.creator?.username ?? null,
|
||||
});
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
/**
|
||||
* 通知类发布入口的公共尾巴:按班级的显示位置决定要不要推 ClassIsland。
|
||||
*
|
||||
* 推失败**不能**影响通知本身已经发布成功这件事 —— 教室机器离线、插件没装都不该让老师收到 500,
|
||||
* 因此这里把异常收敛成日志告警。
|
||||
*/
|
||||
async function pushToClassIslandIfEnabled(input: {
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
priority: string;
|
||||
createdBy: string;
|
||||
notificationId: string;
|
||||
teacherName: string | null;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
if (!(await shouldPushToClassIsland(input.classId))) {
|
||||
logger.info(`通知未推送 ClassIsland:班级=${input.classId} 已选择"只在 ClassHelper 客户端显示"`);
|
||||
return;
|
||||
}
|
||||
await pushToClassIsland({
|
||||
classId: input.classId,
|
||||
title: input.title,
|
||||
content: input.content,
|
||||
urgent: input.priority === 'URGENT',
|
||||
createdBy: input.createdBy,
|
||||
notificationId: input.notificationId,
|
||||
teacherName: input.teacherName,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.warn(`通知推送 ClassIsland 失败(通知本身已发布):${String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
|
||||
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||
if (!current) throw ApiError.notFound('通知不存在');
|
||||
|
||||
@@ -6,6 +6,7 @@ import { dashboardModule } from './dashboard/dashboard.module.js';
|
||||
import { gradesModule } from './grades/grades.module.js';
|
||||
import { homeworksModule } from './homeworks/homeworks.module.js';
|
||||
import { importsModule } from './imports/imports.module.js';
|
||||
import { integrationsModule } from './integrations/integrations.module.js';
|
||||
import { notificationsModule } from './notifications/notifications.module.js';
|
||||
import { schedulesModule } from './schedules/schedules.module.js';
|
||||
import { studentsModule } from './students/students.module.js';
|
||||
@@ -28,6 +29,7 @@ export const apiModules: ApiModule[] = [
|
||||
notificationsModule,
|
||||
callsModule,
|
||||
importsModule,
|
||||
integrationsModule,
|
||||
gradesModule,
|
||||
studentsModule,
|
||||
teachersModule,
|
||||
|
||||
+9
@@ -15,6 +15,15 @@ declare global {
|
||||
};
|
||||
validatedQuery?: unknown;
|
||||
validatedParams?: unknown;
|
||||
/** ClassIsland 联动插件上报时解析出的设备上下文(见 modules/integrations/device-auth.ts) */
|
||||
device?: {
|
||||
deviceId: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,8 @@ export const API_PATHS = {
|
||||
teachers: '/teachers',
|
||||
/** 导入:模板下载 / 表格导入 / ClassIsland 课表时间配置 */
|
||||
imports: '/imports',
|
||||
/** ClassIsland 联动:设备接入、状态上报、通知下发 */
|
||||
integrations: '/integrations',
|
||||
dashboard: '/dashboard/summary',
|
||||
} as const;
|
||||
|
||||
@@ -214,9 +216,71 @@ export const SOCKET_EVENTS = {
|
||||
classUpdated: 'class:updated',
|
||||
/** "叫人":老师点名让某位同学去找他(定向到 user:{studentId} 房间) */
|
||||
callNew: 'call:new',
|
||||
/** ClassIsland 联动:设备状态上报(Web 端实时展示当前课程) */
|
||||
classislandState: 'classisland:state',
|
||||
/** ClassIsland 联动:教师发起的提醒已下发(插件据此全屏弹出) */
|
||||
classislandNotification: 'classisland:notification',
|
||||
connected: 'connected',
|
||||
} as const;
|
||||
|
||||
/** ClassIsland 联动:设备接入令牌的前缀(便于识别与日志排查) */
|
||||
export const CLASSISLAND_DEVICE_TOKEN_PREFIX = 'chci_';
|
||||
|
||||
/** ClassIsland 联动:ClassIsland 的时间点类型(与 TimeLayoutItem.TimeType 一致) */
|
||||
export const CLASSISLAND_TIME_TYPES = {
|
||||
class: 0,
|
||||
break: 1,
|
||||
divider: 2,
|
||||
action: 3,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* ClassIsland 的时间点类型 -> 本系统的节次类型。
|
||||
* 3(行动)在本系统里按"分割线"处理:它不占课时,只做视觉分隔。
|
||||
*/
|
||||
export const CLASSISLAND_TIME_TYPE_LABELS: Record<number, string> = {
|
||||
0: '上课',
|
||||
1: '课间',
|
||||
2: '分割线',
|
||||
3: '行动',
|
||||
};
|
||||
|
||||
/** ClassIsland TimeState 字符串 -> 中文(插件上报 → Web 端展示) */
|
||||
export const CLASSISLAND_TIME_STATE_LABELS: Record<string, string> = {
|
||||
None: '空闲',
|
||||
OnClass: '上课中',
|
||||
PrepareOnClass: '预备铃',
|
||||
Breaking: '课间休息',
|
||||
AfterSchool: '已放学',
|
||||
};
|
||||
|
||||
/**
|
||||
* 通知的显示位置(由**教室的班级客户端**在设置页里选,存在班级上)。
|
||||
*
|
||||
* - `both`:ClassHelper 客户端弹(弹窗 + 灵动岛),同时推给教室的 ClassIsland;
|
||||
* - `client`:只在 ClassHelper 客户端弹,不打扰 ClassIsland;
|
||||
* - `classisland`:只在 ClassIsland 上弹(客户端只进通知中心,不弹窗、不上岛)。
|
||||
*/
|
||||
export const CLASSISLAND_NOTIFICATION_CHANNELS = ['both', 'client', 'classisland'] as const;
|
||||
|
||||
export const CLASSISLAND_NOTIFICATION_CHANNEL_LABELS: Record<string, string> = {
|
||||
both: 'ClassHelper 与 ClassIsland 都弹',
|
||||
client: '只在 ClassHelper 客户端弹',
|
||||
classisland: '只在 ClassIsland 上弹',
|
||||
};
|
||||
|
||||
export const CLASSISLAND_NOTIFICATION_CHANNEL_HINTS: Record<string, string> = {
|
||||
both: '教室的 ClassHelper 客户端与 ClassIsland 都会提醒(默认)',
|
||||
client: '提醒只在 ClassHelper 客户端显示,不推送到 ClassIsland',
|
||||
classisland: '提醒只推送到 ClassIsland,ClassHelper 客户端只留记录、不弹窗',
|
||||
};
|
||||
|
||||
/** 默认显示位置:两端都弹 */
|
||||
export const DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL = 'both';
|
||||
/** 默认提醒显示时长(秒),后端与插件共用同一口径 */
|
||||
export const CLASSISLAND_NOTIFICATION_DEFAULT_DURATION = 8;
|
||||
export const CLASSISLAND_NOTIFICATION_MAX_DURATION = 120;
|
||||
|
||||
/**
|
||||
* "叫人"快捷短语(Web 管理端一键选择,学生端灵动岛同步展示)。
|
||||
* 自定义消息会替换/补全这些短语;`message` 为空时用短语本身。
|
||||
|
||||
@@ -106,6 +106,10 @@ export interface ClassDto {
|
||||
hasPassword?: boolean;
|
||||
/** 本学期教学周数(班主任可调,默认 20):课表周次选择与默认 weekEnd 都用它 */
|
||||
termWeeks: number;
|
||||
/** 是否已有启用中的 ClassIsland 联动设备(班级列表/课表页显示「已接入」徽标) */
|
||||
classIslandConnected?: boolean;
|
||||
/** 通知显示位置(由教室的班级客户端设置,见 CLASSISLAND_NOTIFICATION_CHANNELS) */
|
||||
notificationChannel?: ClassIslandNotificationChannel;
|
||||
}
|
||||
|
||||
export interface ClassTeacherBrief {
|
||||
@@ -592,10 +596,15 @@ export interface ServerToClientEvents {
|
||||
'grade:updated': (payload: GradeDto) => void;
|
||||
'schedule:updated': (payload: {
|
||||
classId: string;
|
||||
action: 'created' | 'updated' | 'deleted';
|
||||
/** imported = 由 ClassIsland 插件上报后整体刷新课表 */
|
||||
action: 'created' | 'updated' | 'deleted' | 'imported';
|
||||
schedule?: ScheduleDto;
|
||||
}) => void;
|
||||
'class:updated': (payload: { classId: string; action: 'created' | 'updated' | 'deleted' }) => void;
|
||||
/** ClassIsland 联动:设备上报了当前上课状态(Web 端实时展示「现在上什么课」) */
|
||||
'classisland:state': (payload: ClassIslandStateEvent) => void;
|
||||
/** ClassIsland 联动:老师发的提醒已下发给该班设备(Web 端据此提示「已送达」) */
|
||||
'classisland:notification': (payload: ClassIslandNotificationEvent) => void;
|
||||
connected: (payload: { userId: string; role: UserRole; rooms: string[] }) => void;
|
||||
}
|
||||
|
||||
@@ -670,6 +679,284 @@ export interface TimeLayoutImportResult {
|
||||
replaced: number;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ ClassIsland 联动(集成) */
|
||||
|
||||
/**
|
||||
* 通知的显示位置(存在班级上,由教室的班级客户端在设置页里选)。
|
||||
* 服务端据此决定「发通知时要不要同时推给教室的 ClassIsland」。
|
||||
*/
|
||||
export type ClassIslandNotificationChannel = 'both' | 'client' | 'classisland';
|
||||
|
||||
/**
|
||||
* 联动方式:
|
||||
* - `plugin`:安装「班级小助手联动插件」,由插件把 ClassIsland 的当前状态与课表推给本服务;
|
||||
* - `import`:只把 ClassIsland 导出的档案 JSON 导入本服务(一次性,无实时联动)。
|
||||
*/
|
||||
export type IntegrationMode = 'plugin' | 'import';
|
||||
|
||||
/** 设备(一台安装了 ClassIsland 插件的机器)与班级的绑定关系 */
|
||||
export interface IntegrationDeviceDto {
|
||||
id: string;
|
||||
classId: string;
|
||||
className?: string | null;
|
||||
/** 设备显示名(默认取机器名) */
|
||||
name: string;
|
||||
/** 设备唯一标识(插件上报的机器码) */
|
||||
deviceKey: string;
|
||||
/** ClassIsland 版本,例如 2.1.0.0 */
|
||||
classIslandVersion: string | null;
|
||||
/** 插件版本 */
|
||||
pluginVersion: string | null;
|
||||
mode: IntegrationMode;
|
||||
/** 是否接入:接入后插件才能上报状态/课表并接收通知 */
|
||||
enabled: boolean;
|
||||
/** 是否允许插件把 ClassIsland 的课表回传到本服务(覆盖/合并本班课表) */
|
||||
syncScheduleToServer: boolean;
|
||||
/** 是否允许把班级课表镜像回 ClassIsland(由插件执行) */
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
/** 最后一次成功上报的时间 */
|
||||
lastSeenAt: string | null;
|
||||
/** 最后一次上报里 ClassIsland 是否已加载课表 */
|
||||
classPlanLoaded: boolean;
|
||||
/** 最后一次上报里的当前科目 */
|
||||
currentSubject: string | null;
|
||||
/** 最后一次上报里的当前时间点状态(OnClass / Breaking / AfterSchool / None / PrepareOnClass) */
|
||||
currentTimeState: string | null;
|
||||
/** 最后一次上报里的当前节次起止时间(HH:mm) */
|
||||
currentPeriodStart: string | null;
|
||||
currentPeriodEnd: string | null;
|
||||
/** 最后一次上报里的下一节课科目 */
|
||||
nextSubject: string | null;
|
||||
/** 令牌前缀提示(chci_xxxxxxxx…):设备列表里用于区分多台设备各自用的令牌 */
|
||||
tokenHint?: string;
|
||||
/** 创建/重置接口一次性下发的纯文本设备令牌(其他接口永不返回) */
|
||||
token?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
/** 创建/重置设备令牌的返回(token 只在这一次返回) */
|
||||
export interface IntegrationDeviceTokenDto {
|
||||
device: IntegrationDeviceDto;
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface CreateIntegrationDeviceRequest {
|
||||
classId: string;
|
||||
name?: string;
|
||||
mode?: IntegrationMode;
|
||||
syncScheduleToServer?: boolean;
|
||||
mirrorScheduleToClassIsland?: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateIntegrationDeviceRequest {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
syncScheduleToServer?: boolean;
|
||||
mirrorScheduleToClassIsland?: boolean;
|
||||
mode?: IntegrationMode;
|
||||
}
|
||||
|
||||
/** 通知下发:把一条通知推给某班级的 ClassIsland 设备(在 ClassIsland 上全屏提醒) */
|
||||
export interface SendClassIslandNotificationRequest {
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
/** 显示时长(秒,1~120,默认 8):映射到 ClassIsland 的 NotificationContent.Duration */
|
||||
durationSeconds?: number;
|
||||
/** 是否语音朗读(默认 false) */
|
||||
speech?: boolean;
|
||||
/** 语音朗读内容(留空则朗读 title + content) */
|
||||
speechContent?: string;
|
||||
/** 是否同时落库到班级通知中心(默认 true) */
|
||||
saveToNotifications?: boolean;
|
||||
/** 优先级(落库时使用,默认 NORMAL) */
|
||||
priority?: NotificationPriority;
|
||||
}
|
||||
|
||||
export interface SendClassIslandNotificationResult {
|
||||
/** 已下发(在线)的设备数 */
|
||||
delivered: number;
|
||||
/** 目标设备总数 */
|
||||
targetCount: number;
|
||||
/** 一并落库的通知 id(saveToNotifications=false 时为 null) */
|
||||
notificationId: string | null;
|
||||
/**
|
||||
* 本次被跳过的原因(没有跳过时为 null/undefined)。
|
||||
* `channel-client` = 该班教室在客户端里选了"只在 ClassHelper 客户端显示",
|
||||
* 因此没有推送到 ClassIsland —— 让老师知道"不是没送达,是教室没要"。
|
||||
*/
|
||||
skipped?: 'channel-client' | null;
|
||||
}
|
||||
|
||||
/* ------------------ 插件上报(设备令牌鉴权,不是 JWT 会话) ------------------ */
|
||||
|
||||
/** 插件上报的课表条目(已按本系统口径归一化) */
|
||||
export interface ClassIslandScheduleEntry {
|
||||
/** 1=周一 … 7=周日(插件已从 ClassIsland 的 0=周日 转换) */
|
||||
dayOfWeek: number;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
subject: string;
|
||||
teacherName?: string | null;
|
||||
weekParity: WeekParity;
|
||||
weekCountDiv: number;
|
||||
weekCountDivTotal: number;
|
||||
planName: string;
|
||||
}
|
||||
|
||||
/** 插件上报的节次时间(时间表) */
|
||||
export interface ClassIslandTimeLayoutEntry {
|
||||
index: number;
|
||||
name: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
type: 'class' | 'break' | 'divider' | 'action';
|
||||
skipped: boolean;
|
||||
}
|
||||
|
||||
/** POST /api/integrations/classisland/report —— 插件的一次上报 */
|
||||
export interface ClassIslandReportRequest {
|
||||
/** 插件/应用版本,便于排查 */
|
||||
pluginVersion?: string;
|
||||
classIslandVersion?: string;
|
||||
/** 本机运行状态 */
|
||||
state?: {
|
||||
inClass: boolean;
|
||||
subject: string | null;
|
||||
nextSubject: string | null;
|
||||
/** 当前时间点状态字符串(ClassIsland TimeState) */
|
||||
timeState: string | null;
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
week: number | null;
|
||||
classPlanLoaded: boolean;
|
||||
/** 客户端本机时间,用于诊断时钟偏差 */
|
||||
clientTime: string;
|
||||
};
|
||||
/** 课表(仅在 syncScheduleToServer 打开时有意义;不传表示本次不上报课表) */
|
||||
schedule?: {
|
||||
/** replace 覆盖本班课表 / merge 合并(默认) */
|
||||
mode: 'replace' | 'merge';
|
||||
entries: ClassIslandScheduleEntry[];
|
||||
} | null;
|
||||
/** 节次时间表 */
|
||||
timeLayout?: {
|
||||
name?: string;
|
||||
mode: 'replace' | 'merge';
|
||||
items: ClassIslandTimeLayoutEntry[];
|
||||
} | null;
|
||||
}
|
||||
|
||||
/** POST /api/integrations/classisland/report 的返回 */
|
||||
export interface ClassIslandReportResult {
|
||||
/** 服务端是否接受并应用了课表 */
|
||||
scheduleApplied: boolean;
|
||||
scheduleCreated: number;
|
||||
scheduleUpdated: number;
|
||||
/** 自动补建/命中的课程名 */
|
||||
courses: string[];
|
||||
timeLayoutApplied: boolean;
|
||||
/** 服务端当前教学周(插件可用来校正"第几周") */
|
||||
week: number;
|
||||
/** 服务端时间(插件可用来校正时钟) */
|
||||
serverTime: string;
|
||||
/** 回传给插件的外观/行为设置:本轮需要下发的通知已随实时事件推送 */
|
||||
settings: {
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
enabled: boolean;
|
||||
};
|
||||
/** 尚未在 ClassIsland 上确认过的一条提醒(插件可立即弹出) */
|
||||
pendingNotification?: ClassIslandPushNotification | null;
|
||||
}
|
||||
|
||||
/** 下发给 ClassIsland 的提醒(教师发通知 → 学生机器全屏弹出) */
|
||||
export interface ClassIslandPushNotification {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
/** 显示时长(秒) */
|
||||
durationSeconds: number;
|
||||
/** 语音朗读内容;null = 不朗读 */
|
||||
speechContent: string | null;
|
||||
createdAt: string;
|
||||
/** 是否紧急(紧急时插件用更强的遮罩与更长的时长) */
|
||||
urgent: boolean;
|
||||
classId: string;
|
||||
className?: string | null;
|
||||
teacherName?: string | null;
|
||||
}
|
||||
|
||||
/** GET /api/integrations/classisland/pending 的返回(插件重连后补齐漏掉的通知) */
|
||||
export interface ClassIslandPendingResult {
|
||||
notifications: ClassIslandPushNotification[];
|
||||
serverTime: string;
|
||||
week: number;
|
||||
}
|
||||
|
||||
/** 插件拉取本班的整份课表(mirrorScheduleToClassIsland 打开时,插件把课表写回 ClassIsland) */
|
||||
export interface ClassIslandClassPlanPull {
|
||||
/** ClassIsland 的口径:0=周日、1=周一 … 6=周六 */
|
||||
classPlan: {
|
||||
entries: {
|
||||
/** ClassIsland 的 WeekDay:0=周日 … 6=周六 */
|
||||
weekDay: number;
|
||||
weekCountDiv: number;
|
||||
weekCountDivTotal: number;
|
||||
/** 科目名 */
|
||||
subject: string;
|
||||
teacherName: string | null;
|
||||
/**
|
||||
* 该课目的起止时间(HH:mm)。
|
||||
*
|
||||
* ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里第 i 个上课点对齐,
|
||||
* 因此插件要按这些时间重建时间表 —— 缺了它们整份镜像课表都会错位。
|
||||
*/
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
timeLayoutId: string;
|
||||
}[];
|
||||
timeLayouts: {
|
||||
id: string;
|
||||
name: string;
|
||||
layouts: {
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
/** ClassIsland TimeType:0=上课、1=课间、2=分割线、3=行动 */
|
||||
timeType: number;
|
||||
}[];
|
||||
}[];
|
||||
/** 建议的档案名 */
|
||||
profileName: string;
|
||||
/** 教学周起始日期(用于插件设置 ClassIsland 的周次口径) */
|
||||
termStartDate: string;
|
||||
};
|
||||
week: number;
|
||||
serverTime: string;
|
||||
}
|
||||
|
||||
/** Socket.IO 事件:ClassIsland 设备状态变化(Web 管理端实时展示) */
|
||||
export interface ClassIslandStateEvent {
|
||||
classId: string;
|
||||
deviceId: string;
|
||||
deviceName: string;
|
||||
inClass: boolean;
|
||||
subject: string | null;
|
||||
nextSubject: string | null;
|
||||
timeState: string | null;
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
week: number | null;
|
||||
classPlanLoaded: boolean;
|
||||
at: string;
|
||||
}
|
||||
|
||||
/** Socket.IO 事件:有新提醒下发到 ClassIsland(插件与 Web 端都可据此刷新) */
|
||||
export interface ClassIslandNotificationEvent extends ClassIslandPushNotification {
|
||||
/** 是下发给本班级设备,还是仅通知 Web 端展示 */
|
||||
targetCount: number;
|
||||
}
|
||||
|
||||
/** 客户端 -> 服务端事件名 */
|
||||
export interface ClientToServerEvents {
|
||||
'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void;
|
||||
|
||||
@@ -65,6 +65,37 @@ export function weekParityOf(week: number): Exclude<WeekParity, 'ALL'> {
|
||||
return week % 2 === 0 ? 'EVEN' : 'ODD';
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassIsland 的 `WeekCountDiv` / `WeekCountDivTotal` → 本系统的单双周。
|
||||
*
|
||||
* 语义(与 ClassIsland 源码一致):`WeekCountDivTotal = n` 时,第 `WeekCountDiv` 周启用(1-based);
|
||||
* `WeekCountDiv <= 0` 或 `Total <= 1` 表示每周都上。
|
||||
*
|
||||
* 注意:本系统只用单双周(2 周循环),3 周及以上的循环**无法表达**,此时降级为「每周」
|
||||
* 并由调用方向用户提示(导入模块记 warning)。这里集中为一处,避免导入与插件上报各写一套。
|
||||
*/
|
||||
export function weekParityFromDiv(weekCountDiv: number, weekCountDivTotal: number): WeekParity {
|
||||
if (weekCountDiv <= 0 || weekCountDivTotal <= 1) return 'ALL';
|
||||
const position = ((weekCountDiv - 1) % weekCountDivTotal) + 1;
|
||||
if (weekCountDivTotal === 2) return position === 1 ? 'ODD' : 'EVEN';
|
||||
return 'ALL';
|
||||
}
|
||||
|
||||
/** `weekParityFromDiv` 的逆运算:本系统的单双周 → ClassIsland 的 Div/Total */
|
||||
export function weekDivFromParity(parity: WeekParity | null | undefined): {
|
||||
weekCountDiv: number;
|
||||
weekCountDivTotal: number;
|
||||
} {
|
||||
if (parity === 'ODD') return { weekCountDiv: 1, weekCountDivTotal: 2 };
|
||||
if (parity === 'EVEN') return { weekCountDiv: 2, weekCountDivTotal: 2 };
|
||||
return { weekCountDiv: 0, weekCountDivTotal: 0 };
|
||||
}
|
||||
|
||||
/** 多周循环(3 周及以上)无法用单双周表达:告知调用方是否被降级 */
|
||||
export function isWeekCycleDegraded(weekCountDiv: number, weekCountDivTotal: number): boolean {
|
||||
return weekCountDivTotal > 2;
|
||||
}
|
||||
|
||||
/** 判断某个课表条目在第 week 周是否上课(周次区间 + 单双周) */
|
||||
export function isScheduleActiveInWeek(
|
||||
schedule: Pick<ScheduleDto, 'weekStart' | 'weekEnd'> & { weekParity?: WeekParity | null },
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
type CreateCourseRequest,
|
||||
type CreateGradeRequest,
|
||||
type CreateHomeworkRequest,
|
||||
type CreateIntegrationDeviceRequest,
|
||||
type CreateNotificationRequest,
|
||||
type CreateScheduleRequest,
|
||||
type DashboardSummary,
|
||||
@@ -18,11 +19,15 @@ import {
|
||||
type GradeStats,
|
||||
type HomeworkDto,
|
||||
type HomeworkSubmissionsDto,
|
||||
type IntegrationDeviceDto,
|
||||
type IntegrationDeviceTokenDto,
|
||||
type LoginRequest,
|
||||
type LoginResponse,
|
||||
type NotificationDto,
|
||||
type ScheduleDto,
|
||||
type ScheduleWeekView,
|
||||
type SendClassIslandNotificationRequest,
|
||||
type SendClassIslandNotificationResult,
|
||||
type StudentDto,
|
||||
type TableImportPreview,
|
||||
type TableImportResult,
|
||||
@@ -31,6 +36,7 @@ import {
|
||||
type TimeLayoutParsePreview,
|
||||
type UpdateClassAccountRequest,
|
||||
type UpdateClassRequest,
|
||||
type UpdateIntegrationDeviceRequest,
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
import { api } from './http';
|
||||
@@ -292,3 +298,28 @@ export const importApi = {
|
||||
payload: unknown;
|
||||
}): Promise<ClassPlanImportResultDto> => api.post(`${API_PATHS.imports}/class-plan`, payload),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ ClassIsland 联动 */
|
||||
|
||||
/**
|
||||
* 联动设备管理 + 提醒下发。
|
||||
*
|
||||
* 设备令牌(chci_...)只在**创建**与**重置**两个接口的返回值里出现一次,
|
||||
* 其余接口永远只回 tokenHint(前缀提示),因此拿到 token 后要立刻提示老师复制保存。
|
||||
*/
|
||||
export const integrationApi = {
|
||||
listDevices: (classId?: string): Promise<IntegrationDeviceDto[]> =>
|
||||
api.get(`${API_PATHS.integrations}/devices`, classId ? { classId } : undefined),
|
||||
createDevice: (payload: CreateIntegrationDeviceRequest): Promise<IntegrationDeviceTokenDto> =>
|
||||
api.post(`${API_PATHS.integrations}/devices`, payload),
|
||||
updateDevice: (id: string, payload: UpdateIntegrationDeviceRequest): Promise<IntegrationDeviceDto> =>
|
||||
api.patch(`${API_PATHS.integrations}/devices/${id}`, payload),
|
||||
/** 重置令牌:旧令牌立即失效,插件需要重新填写 */
|
||||
resetToken: (id: string): Promise<IntegrationDeviceTokenDto> =>
|
||||
api.post(`${API_PATHS.integrations}/devices/${id}/token`, {}),
|
||||
removeDevice: (id: string): Promise<{ id: string }> =>
|
||||
api.delete(`${API_PATHS.integrations}/devices/${id}`),
|
||||
/** 把一条提醒下发到该班的 ClassIsland 设备(ClassIsland 上全屏弹出) */
|
||||
notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> =>
|
||||
api.post(`${API_PATHS.integrations}/classisland/notify`, payload),
|
||||
};
|
||||
@@ -21,6 +21,7 @@ const { isMobile } = useResponsive();
|
||||
* - 成绩录入:管理员 + 教师;页面内只对"管理员或所选班级的班主任"显示写入/导入按钮
|
||||
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
|
||||
* - 作业 / 通知:所有教师
|
||||
* - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮)
|
||||
*/
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
|
||||
@@ -31,6 +32,7 @@ const menuItems = [
|
||||
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
];
|
||||
|
||||
/** 当前账号可见的菜单(班级/学生管理仅管理员可见) */
|
||||
|
||||
@@ -61,6 +61,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/GradesView.vue'),
|
||||
meta: { title: '成绩录入', icon: 'Trophy' },
|
||||
},
|
||||
{
|
||||
path: 'integrations',
|
||||
name: 'integrations',
|
||||
component: () => import('@/views/IntegrationsView.vue'),
|
||||
// 与后端 requireRole('ADMIN','TEACHER') 对齐:学生角色不该看到这一页
|
||||
meta: { title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@ import { io, type Socket } from 'socket.io-client';
|
||||
import {
|
||||
PRIORITY_LABELS,
|
||||
SOCKET_EVENTS,
|
||||
type ClassIslandNotificationEvent,
|
||||
type ClassIslandStateEvent,
|
||||
type GradeDto,
|
||||
type HomeworkDto,
|
||||
type NotificationDto,
|
||||
@@ -23,6 +25,8 @@ const EVENT_LABELS: Record<string, string> = {
|
||||
[SOCKET_EVENTS.gradeUpdated]: '成绩更新',
|
||||
[SOCKET_EVENTS.scheduleUpdated]: '课表变更',
|
||||
[SOCKET_EVENTS.homeworkStatus]: '作业状态',
|
||||
[SOCKET_EVENTS.classislandState]: 'ClassIsland 状态',
|
||||
[SOCKET_EVENTS.classislandNotification]: 'ClassIsland 提醒',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -116,6 +120,23 @@ export const useRealtimeStore = defineStore('realtime', () => {
|
||||
dispatch(SOCKET_EVENTS.scheduleUpdated, payload);
|
||||
});
|
||||
|
||||
instance.on(SOCKET_EVENTS.classislandState, (payload: ClassIslandStateEvent) => {
|
||||
markEvent(SOCKET_EVENTS.classislandState);
|
||||
// 状态是"心跳"级事件(每次上报都会来),弹通知会刷屏,只静默分发给页面
|
||||
dispatch(SOCKET_EVENTS.classislandState, payload);
|
||||
});
|
||||
|
||||
instance.on(SOCKET_EVENTS.classislandNotification, (payload: ClassIslandNotificationEvent) => {
|
||||
markEvent(SOCKET_EVENTS.classislandNotification);
|
||||
if (payload.targetCount > 0) {
|
||||
notify('success', '提醒已下发到 ClassIsland', payload.title);
|
||||
} else {
|
||||
// 没有已接入的设备时提醒只落库,老师需要知道"没送到教室"
|
||||
notify('warning', '该班级还没有已接入的 ClassIsland 设备', payload.title);
|
||||
}
|
||||
dispatch(SOCKET_EVENTS.classislandNotification, payload);
|
||||
});
|
||||
|
||||
instance.on(SOCKET_EVENTS.connected, (payload: { rooms: string[] }) => {
|
||||
markEvent(SOCKET_EVENTS.connected);
|
||||
console.info('[realtime] 已加入房间', payload.rooms);
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
|
||||
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
|
||||
import {
|
||||
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
|
||||
CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
|
||||
CLASSISLAND_NOTIFICATION_MAX_DURATION,
|
||||
CLASSISLAND_TIME_STATE_LABELS,
|
||||
NOTIFICATION_PRIORITIES,
|
||||
PRIORITY_LABELS,
|
||||
SOCKET_EVENTS,
|
||||
formatDate,
|
||||
relativeTime,
|
||||
type ClassDto,
|
||||
type ClassIslandNotificationChannel,
|
||||
type ClassIslandStateEvent,
|
||||
type IntegrationDeviceDto,
|
||||
type NotificationPriority,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, integrationApi } from '@/api';
|
||||
import { useResponsive } from '@/composables/useResponsive';
|
||||
import { useRealtimeStore } from '@/stores/realtime';
|
||||
|
||||
/**
|
||||
* ClassIsland 联动页。
|
||||
*
|
||||
* 两块内容:
|
||||
* 1. **设备接入**:为每个班签发设备令牌(`chci_...`),老师把令牌填进教室机器的
|
||||
* ClassIsland 插件设置,插件就会把该机课表/上课状态上报上来,并接收本页下发的提醒;
|
||||
* 2. **下发提醒**:把一条提醒推到该班的 ClassIsland 上全屏弹出(学生端通知中心同时留档)。
|
||||
*
|
||||
* 权限与后端对齐:本页只对 ADMIN / TEACHER 开放(路由 meta.roles),
|
||||
* 具体到"能不能管这个班"由后端 assertCanManageSchedule / assertCanPublishContent 判定。
|
||||
*/
|
||||
|
||||
const realtime = useRealtimeStore();
|
||||
const { isMobile } = useResponsive();
|
||||
|
||||
const loading = ref(false);
|
||||
const classes = ref<ClassDto[]>([]);
|
||||
const devices = ref<IntegrationDeviceDto[]>([]);
|
||||
const filter = reactive({ classId: '' });
|
||||
|
||||
/** deviceId → 最近一次 socket 推送的实时状态(页面停留期间最新) */
|
||||
const liveStates = ref<Record<string, ClassIslandStateEvent>>({});
|
||||
|
||||
/** 设备在线判定窗口:插件默认 60 秒上报一次,给到 3 分钟容差(网络抖动/息屏唤醒) */
|
||||
const ONLINE_WINDOW_MS = 3 * 60 * 1000;
|
||||
const nowTick = ref(Date.now());
|
||||
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const filteredDevices = computed(() =>
|
||||
filter.classId ? devices.value.filter((item) => item.classId === filter.classId) : devices.value,
|
||||
);
|
||||
|
||||
/** 当前选中班级下在线(最近有上报)的设备数 */
|
||||
const onlineCount = computed(() => filteredDevices.value.filter((item) => isOnline(item)).length);
|
||||
|
||||
function isOnline(device: IntegrationDeviceDto): boolean {
|
||||
if (!device.lastSeenAt) return false;
|
||||
return nowTick.value - new Date(device.lastSeenAt).getTime() < ONLINE_WINDOW_MS;
|
||||
}
|
||||
|
||||
/** 班级名(列表未加载完时退回 classId,避免出现空白) */
|
||||
function classNameOf(classId: string): string {
|
||||
return classes.value.find((item) => item.id === classId)?.name ?? classId;
|
||||
}
|
||||
|
||||
/** 该班的通知显示位置(由教室的 ClassHelper 客户端在设置页里选) */
|
||||
function channelOf(classId: string): ClassIslandNotificationChannel {
|
||||
return classes.value.find((item) => item.id === classId)?.notificationChannel ?? 'both';
|
||||
}
|
||||
|
||||
function channelLabelOf(classId: string): string {
|
||||
return CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[channelOf(classId)] ?? '两端都弹';
|
||||
}
|
||||
|
||||
/** 下发提醒弹窗里选中班级的显示位置:为 client 时提示"本次不会到 ClassIsland" */
|
||||
const notifyChannel = computed(() => channelOf(notifyForm.classId));
|
||||
|
||||
/**
|
||||
* 一台设备当前展示用的状态。
|
||||
*
|
||||
* 优先用 socket 推来的实时值(页面停留期间的更新),没有就用设备记录里
|
||||
* 最后一次上报的快照 —— 这样"刚进页面"也能立刻看到教室在上什么课。
|
||||
*/
|
||||
function liveOf(device: IntegrationDeviceDto): {
|
||||
subject: string | null;
|
||||
nextSubject: string | null;
|
||||
timeState: string | null;
|
||||
period: string;
|
||||
classPlanLoaded: boolean;
|
||||
} {
|
||||
const live = liveStates.value[device.id];
|
||||
const subject = live?.subject ?? device.currentSubject ?? null;
|
||||
const nextSubject = live?.nextSubject ?? device.nextSubject ?? null;
|
||||
const timeState = live?.timeState ?? device.currentTimeState ?? null;
|
||||
const start = live?.periodStart ?? device.currentPeriodStart ?? null;
|
||||
const end = live?.periodEnd ?? device.currentPeriodEnd ?? null;
|
||||
return {
|
||||
subject,
|
||||
nextSubject,
|
||||
timeState,
|
||||
period: start && end ? `${start} - ${end}` : '—',
|
||||
classPlanLoaded: live?.classPlanLoaded ?? device.classPlanLoaded,
|
||||
};
|
||||
}
|
||||
|
||||
/** 实时状态的中文说明(映射表来自 shared,三端共用一份口径) */
|
||||
function timeStateLabel(timeState: string | null): string {
|
||||
if (!timeState) return '未知';
|
||||
return CLASSISLAND_TIME_STATE_LABELS[timeState] ?? timeState;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 数据加载 */
|
||||
|
||||
async function loadClasses(): Promise<void> {
|
||||
classes.value = await classApi.list();
|
||||
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
|
||||
}
|
||||
|
||||
async function loadDevices(): Promise<void> {
|
||||
devices.value = await integrationApi.listDevices(filter.classId || undefined);
|
||||
}
|
||||
|
||||
async function reload(): Promise<void> {
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadClasses();
|
||||
await loadDevices();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onClassChange(): void {
|
||||
// 切班级要重新拉一次:设备列表按班级过滤,缓存下来的旧列表会串班
|
||||
void loadDevices();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 设备管理 */
|
||||
|
||||
const createVisible = ref(false);
|
||||
const createRef = ref<FormInstance>();
|
||||
const createForm = reactive<{
|
||||
classId: string;
|
||||
name: string;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
}>({
|
||||
classId: '',
|
||||
name: '',
|
||||
syncScheduleToServer: true,
|
||||
mirrorScheduleToClassIsland: false,
|
||||
});
|
||||
|
||||
const createRules: FormRules = {
|
||||
classId: [{ required: true, message: '请选择班级', trigger: 'change' }],
|
||||
};
|
||||
|
||||
/** 一次性令牌弹窗:明文只在创建/重置接口返回一次,必须让老师当场复制走 */
|
||||
const tokenVisible = ref(false);
|
||||
const tokenValue = ref('');
|
||||
const tokenHint = ref('');
|
||||
|
||||
function openCreate(): void {
|
||||
createForm.classId = filter.classId || classes.value[0]?.id || '';
|
||||
createForm.name = '';
|
||||
createForm.syncScheduleToServer = true;
|
||||
createForm.mirrorScheduleToClassIsland = false;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitCreate(): Promise<void> {
|
||||
const valid = await createRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
const result = await integrationApi.createDevice({
|
||||
classId: createForm.classId,
|
||||
name: createForm.name.trim() || undefined,
|
||||
syncScheduleToServer: createForm.syncScheduleToServer,
|
||||
mirrorScheduleToClassIsland: createForm.mirrorScheduleToClassIsland,
|
||||
});
|
||||
createVisible.value = false;
|
||||
showToken(result.token, `设备「${result.device.name}」`);
|
||||
ElMessage.success('设备已创建,请把令牌填入教室机器的插件设置');
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
function showToken(token: string, hint: string): void {
|
||||
tokenValue.value = token;
|
||||
tokenHint.value = hint;
|
||||
tokenVisible.value = true;
|
||||
}
|
||||
|
||||
async function copyToken(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(tokenValue.value);
|
||||
ElMessage.success('令牌已复制');
|
||||
} catch {
|
||||
// 非 HTTPS / 浏览器拒绝剪贴板权限时的兜底:提示老师手动全选复制
|
||||
ElMessage.warning('浏览器拒绝了剪贴板访问,请手动选中复制');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleEnabled(device: IntegrationDeviceDto): Promise<void> {
|
||||
const next = !device.enabled;
|
||||
await integrationApi.updateDevice(device.id, { enabled: next });
|
||||
ElMessage.success(next ? '设备已启用' : '设备已停用(插件上报与下发会被拒绝)');
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
async function toggleMirror(device: IntegrationDeviceDto): Promise<void> {
|
||||
const next = !device.mirrorScheduleToClassIsland;
|
||||
await integrationApi.updateDevice(device.id, { mirrorScheduleToClassIsland: next });
|
||||
ElMessage.success(next ? '已开启:插件会把本班课表镜像回 ClassIsland' : '已关闭课表镜像');
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
async function resetToken(device: IntegrationDeviceDto): Promise<void> {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`重置后「${device.name}」的旧令牌立即失效,教室机器需要重新填写新令牌。确定继续?`,
|
||||
'重置设备令牌',
|
||||
{ type: 'warning', confirmButtonText: '重置令牌', cancelButtonText: '取消' },
|
||||
).catch(() => false);
|
||||
if (!confirmed) return;
|
||||
|
||||
const result = await integrationApi.resetToken(device.id);
|
||||
showToken(result.token, `设备「${result.device.name}」`);
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
async function removeDevice(device: IntegrationDeviceDto): Promise<void> {
|
||||
const confirmed = await ElMessageBox.confirm(
|
||||
`删除后「${device.name}」将无法再上报课表或接收提醒(历史提醒记录保留)。确定删除?`,
|
||||
'删除设备',
|
||||
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
|
||||
).catch(() => false);
|
||||
if (!confirmed) return;
|
||||
|
||||
await integrationApi.removeDevice(device.id);
|
||||
ElMessage.success('设备已删除');
|
||||
await loadDevices();
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 下发提醒 */
|
||||
|
||||
const notifyVisible = ref(false);
|
||||
const notifyRef = ref<FormInstance>();
|
||||
const notifyForm = reactive<{
|
||||
classId: string;
|
||||
title: string;
|
||||
content: string;
|
||||
durationSeconds: number;
|
||||
speech: boolean;
|
||||
speechContent: string;
|
||||
saveToNotifications: boolean;
|
||||
priority: NotificationPriority;
|
||||
}>({
|
||||
classId: '',
|
||||
title: '',
|
||||
content: '',
|
||||
durationSeconds: CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
|
||||
speech: false,
|
||||
speechContent: '',
|
||||
saveToNotifications: true,
|
||||
priority: 'NORMAL',
|
||||
});
|
||||
|
||||
const notifyRules: FormRules = {
|
||||
classId: [{ required: true, message: '请选择目标班级', trigger: 'change' }],
|
||||
title: [{ required: true, message: '请输入提醒标题', trigger: 'blur' }],
|
||||
content: [{ required: true, message: '请输入提醒内容', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
const submitting = ref(false);
|
||||
|
||||
function openNotify(): void {
|
||||
notifyForm.classId = filter.classId || classes.value[0]?.id || '';
|
||||
notifyForm.title = '';
|
||||
notifyForm.content = '';
|
||||
notifyForm.durationSeconds = CLASSISLAND_NOTIFICATION_DEFAULT_DURATION;
|
||||
notifyForm.speech = false;
|
||||
notifyForm.speechContent = '';
|
||||
notifyForm.saveToNotifications = true;
|
||||
notifyForm.priority = 'NORMAL';
|
||||
notifyVisible.value = true;
|
||||
}
|
||||
|
||||
async function submitNotify(): Promise<void> {
|
||||
const valid = await notifyRef.value?.validate().catch(() => false);
|
||||
if (!valid) return;
|
||||
|
||||
submitting.value = true;
|
||||
try {
|
||||
const result = await integrationApi.notify({
|
||||
classId: notifyForm.classId,
|
||||
title: notifyForm.title.trim(),
|
||||
content: notifyForm.content.trim(),
|
||||
durationSeconds: notifyForm.durationSeconds,
|
||||
speech: notifyForm.speech,
|
||||
speechContent: notifyForm.speechContent.trim() || undefined,
|
||||
saveToNotifications: notifyForm.saveToNotifications,
|
||||
priority: notifyForm.priority,
|
||||
});
|
||||
|
||||
if (result.targetCount === 0) {
|
||||
ElMessage.warning('提醒已保存,但该班级还没有已接入的 ClassIsland 设备');
|
||||
} else {
|
||||
ElMessage.success(`已下发到 ${result.delivered}/${result.targetCount} 台 ClassIsland 设备`);
|
||||
}
|
||||
notifyVisible.value = false;
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 实时通道 */
|
||||
|
||||
function handleState(payload: unknown): void {
|
||||
const state = payload as ClassIslandStateEvent;
|
||||
if (!state?.deviceId) return;
|
||||
liveStates.value = { ...liveStates.value, [state.deviceId]: state };
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await reload().catch(() => undefined);
|
||||
realtime.on(SOCKET_EVENTS.classislandState, handleState);
|
||||
// 在线状态是"距今多久"的判断,需要定时重算,否则页面开着时间不会变
|
||||
tickTimer = setInterval(() => {
|
||||
nowTick.value = Date.now();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
realtime.off(SOCKET_EVENTS.classislandState, handleState);
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">ClassIsland 联动</h2>
|
||||
<p class="page-subtitle">
|
||||
教室机器装上「班级小助手联动」插件后:课表与上课状态会自动上报到这里,这里下发的提醒会在 ClassIsland
|
||||
上全屏弹出
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-select
|
||||
v-model="filter.classId"
|
||||
placeholder="全部班级"
|
||||
clearable
|
||||
style="width: 170px"
|
||||
@change="onClassChange"
|
||||
>
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
<el-tag v-if="filteredDevices.length > 0" :type="onlineCount > 0 ? 'success' : 'info'" effect="light">
|
||||
在线 {{ onlineCount }} / {{ filteredDevices.length }}
|
||||
</el-tag>
|
||||
<el-button :icon="'Refresh'" :loading="loading" @click="reload">刷新</el-button>
|
||||
<el-button :icon="'Bell'" type="warning" @click="openNotify">下发提醒</el-button>
|
||||
<el-button :icon="'Plus'" type="primary" @click="openCreate">接入新设备</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<el-alert
|
||||
v-if="filteredDevices.length === 0"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="还没有接入 ClassIsland 设备"
|
||||
description="在教室机器的 ClassIsland 上安装「班级小助手联动」插件并启用,然后点右上角「接入新设备」生成设备令牌,填入插件的「设置 → 班级小助手联动」即可。"
|
||||
/>
|
||||
|
||||
<template v-else>
|
||||
<div class="device-grid">
|
||||
<el-card v-for="device in filteredDevices" :key="device.id" shadow="never">
|
||||
<div class="device-head">
|
||||
<div>
|
||||
<div class="device-name">{{ device.name }}</div>
|
||||
<div class="device-sub">{{ classNameOf(device.classId) }}</div>
|
||||
</div>
|
||||
<el-tag :type="isOnline(device) ? 'success' : 'info'" effect="light">
|
||||
{{ isOnline(device) ? '在线' : '离线' }}
|
||||
</el-tag>
|
||||
</div>
|
||||
<el-descriptions :column="1" size="small" border>
|
||||
<el-descriptions-item label="当前状态">
|
||||
{{ timeStateLabel(liveOf(device).timeState) }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="正在上">
|
||||
{{ liveOf(device).subject ?? (liveOf(device).classPlanLoaded ? '空课' : '未加载课表') }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="下一节">
|
||||
{{ liveOf(device).nextSubject ?? '—' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="当前节次">
|
||||
{{ liveOf(device).period }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="最后上报">
|
||||
{{ device.lastSeenAt ? relativeTime(device.lastSeenAt) : '从未上报' }}
|
||||
</el-descriptions-item>
|
||||
<el-descriptions-item label="提醒显示">
|
||||
{{ channelLabelOf(device.classId) }}
|
||||
</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</el-card>
|
||||
</div>
|
||||
|
||||
<el-card shadow="never" class="table-card">
|
||||
<el-table v-loading="loading" :data="filteredDevices" empty-text="暂无联动设备">
|
||||
<el-table-column label="设备" min-width="180">
|
||||
<template #default="{ row }">
|
||||
<div>{{ row.name }}</div>
|
||||
<div class="cell-sub">{{ classNameOf(row.classId) }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.enabled ? 'success' : 'danger'" effect="light">
|
||||
{{ row.enabled ? '已接入' : '已停用' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="版本" width="170">
|
||||
<template #default="{ row }">
|
||||
<div class="cell-sub">插件 {{ row.pluginVersion ?? '—' }}</div>
|
||||
<div class="cell-sub">ClassIsland {{ row.classIslandVersion ?? '—' }}</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="回传课表" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.syncScheduleToServer ? 'success' : 'info'" effect="plain">
|
||||
{{ row.syncScheduleToServer ? '允许' : '禁止' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="镜像课表" width="110">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.mirrorScheduleToClassIsland ? 'success' : 'info'" effect="plain">
|
||||
{{ row.mirrorScheduleToClassIsland ? '开启' : '关闭' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="tokenHint" label="令牌" width="150" />
|
||||
<el-table-column label="最后上报" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ row.lastSeenAt ? relativeTime(row.lastSeenAt) : '从未' }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="创建时间" width="170">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="290" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="toggleEnabled(row)">
|
||||
{{ row.enabled ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
<el-button link type="primary" @click="toggleMirror(row)">
|
||||
{{ row.mirrorScheduleToClassIsland ? '关镜像' : '开镜像' }}
|
||||
</el-button>
|
||||
<el-button link type="warning" @click="resetToken(row)">重置令牌</el-button>
|
||||
<el-button link type="danger" @click="removeDevice(row)">删除</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
</template>
|
||||
|
||||
<!-- 接入新设备 -->
|
||||
<el-dialog
|
||||
v-model="createVisible"
|
||||
title="接入新设备"
|
||||
:width="isMobile ? '92%' : '520px'"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-form ref="createRef" :model="createForm" :rules="createRules" label-width="110px">
|
||||
<el-form-item label="班级" prop="classId">
|
||||
<el-select v-model="createForm.classId" placeholder="选择班级" style="width: 100%">
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="设备名称">
|
||||
<el-input v-model="createForm.name" placeholder="留空则用「班级名 + ClassIsland 设备」" />
|
||||
</el-form-item>
|
||||
<el-form-item label="回传课表">
|
||||
<el-switch v-model="createForm.syncScheduleToServer" />
|
||||
<span class="form-hint">允许插件把 ClassIsland 的课表同步到本系统</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="镜像课表">
|
||||
<el-switch v-model="createForm.mirrorScheduleToClassIsland" />
|
||||
<span class="form-hint">允许把本班课表写回 ClassIsland(新建档案课表,不改老师原有课表)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="createVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="submitCreate">生成设备令牌</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 一次性令牌 -->
|
||||
<el-dialog v-model="tokenVisible" title="设备令牌(只显示这一次)" :width="isMobile ? '92%' : '560px'">
|
||||
<el-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="请立即复制并填入插件设置"
|
||||
description="服务端只保存令牌的哈希,关闭本窗口后无法再查看明文;只能重置(旧令牌立即失效)。"
|
||||
/>
|
||||
<el-input v-model="tokenValue" readonly type="textarea" :rows="3" class="token-box" />
|
||||
<p class="form-hint">{{ tokenHint }} 的令牌</p>
|
||||
<template #footer>
|
||||
<el-button @click="tokenVisible = false">关闭</el-button>
|
||||
<el-button type="primary" :icon="'CopyDocument'" @click="copyToken">复制令牌</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<!-- 下发提醒 -->
|
||||
<el-dialog
|
||||
v-model="notifyVisible"
|
||||
title="下发提醒到 ClassIsland"
|
||||
:width="isMobile ? '92%' : '600px'"
|
||||
destroy-on-close
|
||||
>
|
||||
<el-alert
|
||||
v-if="notifyChannel === 'client'"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
class="notify-channel-alert"
|
||||
title="该班教室已设置为「只在 ClassHelper 客户端显示」"
|
||||
description="本次提醒不会推送到 ClassIsland。如需改变,请在教室机器的 ClassHelper 客户端「设置 → 通知显示位置」里调整。"
|
||||
/>
|
||||
<el-form ref="notifyRef" :model="notifyForm" :rules="notifyRules" label-width="110px">
|
||||
<el-form-item label="目标班级" prop="classId">
|
||||
<el-select v-model="notifyForm.classId" placeholder="选择班级" style="width: 100%">
|
||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="标题" prop="title">
|
||||
<el-input
|
||||
v-model="notifyForm.title"
|
||||
maxlength="120"
|
||||
show-word-limit
|
||||
placeholder="例如:交作业提醒"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="内容" prop="content">
|
||||
<el-input
|
||||
v-model="notifyForm.content"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
placeholder="会显示在 ClassIsland 的提醒正文里"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="显示时长">
|
||||
<el-input-number
|
||||
v-model="notifyForm.durationSeconds"
|
||||
:min="1"
|
||||
:max="CLASSISLAND_NOTIFICATION_MAX_DURATION"
|
||||
:step="1"
|
||||
/>
|
||||
<span class="form-hint">秒(1 ~ {{ CLASSISLAND_NOTIFICATION_MAX_DURATION }})</span>
|
||||
</el-form-item>
|
||||
<el-form-item label="优先级">
|
||||
<el-select v-model="notifyForm.priority" style="width: 100%">
|
||||
<el-option
|
||||
v-for="item in NOTIFICATION_PRIORITIES"
|
||||
:key="item"
|
||||
:label="PRIORITY_LABELS[item]"
|
||||
:value="item"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="语音朗读">
|
||||
<el-switch v-model="notifyForm.speech" />
|
||||
<span class="form-hint">按教室机器的 ClassIsland 语音设置朗读</span>
|
||||
</el-form-item>
|
||||
<el-form-item v-if="notifyForm.speech" label="朗读内容">
|
||||
<el-input
|
||||
v-model="notifyForm.speechContent"
|
||||
maxlength="500"
|
||||
placeholder="留空则朗读「标题 + 内容」"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="同步通知中心">
|
||||
<el-switch v-model="notifyForm.saveToNotifications" />
|
||||
<span class="form-hint">同时在班级通知中心留一条(学生端也能事后查看)</span>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="notifyVisible = false">取消</el-button>
|
||||
<el-button type="primary" :loading="submitting" @click="submitNotify">立即下发</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.device-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 14px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.device-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.device-sub,
|
||||
.cell-sub {
|
||||
opacity: 0.65;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
margin-left: 10px;
|
||||
opacity: 0.65;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.token-box {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.notify-channel-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
/* 手机小屏:卡片内的描述表在窄屏下会自动换行,这里只把间距收紧 */
|
||||
@media (max-width: 768px) {
|
||||
.device-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+155
-2
@@ -31,8 +31,8 @@ const RESULT_FILE = process.env.UI_SMOKE_RESULT ?? '';
|
||||
|
||||
/**
|
||||
* 侧边栏菜单(教师账号 teacher1 可见的部分)。
|
||||
* 按新的角色权限模型:班级管理 / 学生管理 / 成绩录入 仅管理员可见,
|
||||
* 教师端不应出现这些入口(后端同时强校验),因此期望值只有 4 项。
|
||||
* 按角色权限模型:班级管理 / 学生管理 / 教师管理 仅管理员可见,
|
||||
* 教师端不应出现这些入口(后端同时强校验),因此这里的期望值就是教师可见的全部菜单。
|
||||
*/
|
||||
const MENU_ITEMS = [
|
||||
{ label: '仪表盘', path: '/dashboard' },
|
||||
@@ -40,6 +40,7 @@ const MENU_ITEMS = [
|
||||
{ label: '作业发布', path: '/homeworks' },
|
||||
{ label: '通知发布', path: '/notifications' },
|
||||
{ label: '成绩录入', path: '/grades' },
|
||||
{ label: 'ClassIsland 联动', path: '/integrations' },
|
||||
];
|
||||
|
||||
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
|
||||
@@ -894,6 +895,158 @@ async function main() {
|
||||
: `原因=${removedUnified?.reason ?? '-'}`,
|
||||
);
|
||||
|
||||
// 7.85 ClassIsland 联动页:班主任为本班签发设备令牌 → 页面下发提醒表单可用 → 收尾删除设备
|
||||
// (这条链路是"老师在 Web 端发提醒 → 教室里 ClassIsland 全屏弹出"的入口,必须真实点得动)
|
||||
const integrationUi = await win.webContents.executeJavaScript(`(async () => {
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
|
||||
(node.textContent ?? '').trim().startsWith('ClassIsland 联动'),
|
||||
);
|
||||
if (!menu) return { ok: false, reason: '教师端看不到「ClassIsland 联动」入口' };
|
||||
menu.click();
|
||||
const deadline = Date.now() + 6000;
|
||||
while (Date.now() < deadline && location.pathname !== '/integrations') await sleep(80);
|
||||
if (location.pathname !== '/integrations') return { ok: false, reason: '点击后没有跳到 /integrations' };
|
||||
await sleep(900);
|
||||
|
||||
const buttons = Array.from(document.querySelectorAll('button')).map((node) => (node.textContent ?? '').trim());
|
||||
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
|
||||
|
||||
// 打开「下发提醒」弹窗:表单字段齐不齐
|
||||
const notifyButton = Array.from(document.querySelectorAll('button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '下发提醒',
|
||||
);
|
||||
if (!notifyButton) return { ok: false, title, reason: '页面没有「下发提醒」按钮' };
|
||||
notifyButton.click();
|
||||
await sleep(700);
|
||||
const notifyDialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
|
||||
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('下发提醒到 ClassIsland'),
|
||||
);
|
||||
if (!notifyDialog) return { ok: false, title, reason: '下发提醒弹窗未打开' };
|
||||
const labels = Array.from(notifyDialog.querySelectorAll('.el-form-item__label')).map((node) =>
|
||||
(node.textContent ?? '').trim(),
|
||||
);
|
||||
const submit = Array.from(notifyDialog.querySelectorAll('.el-dialog__footer button')).some(
|
||||
(node) => (node.textContent ?? '').trim() === '立即下发',
|
||||
);
|
||||
const cancel = Array.from(document.querySelectorAll('.el-dialog__footer button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '取消',
|
||||
);
|
||||
if (cancel) cancel.click();
|
||||
await sleep(400);
|
||||
|
||||
// 打开「接入新设备」:生成令牌 → 弹窗必须给出 chci_ 明文
|
||||
// 收尾要删掉本次新增的设备,因此先记下当前已有的 id,之后按"集合差"清理
|
||||
const authHeaders = { authorization: 'Bearer ' + (localStorage.getItem('classhelper.token') ?? '') };
|
||||
const before = (await (await fetch('/api/integrations/devices', { headers: authHeaders })).json()).data ?? [];
|
||||
const beforeIds = new Set(before.map((item) => item.id));
|
||||
|
||||
const createButton = Array.from(document.querySelectorAll('button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '接入新设备',
|
||||
);
|
||||
if (!createButton) return { ok: false, title, reason: '页面没有「接入新设备」按钮' };
|
||||
createButton.click();
|
||||
await sleep(600);
|
||||
const createDialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
|
||||
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('接入新设备'),
|
||||
);
|
||||
if (!createDialog) return { ok: false, title, reason: '接入新设备弹窗未打开' };
|
||||
const classSelected = Boolean((createDialog.querySelector('.el-select')?.textContent ?? '').trim());
|
||||
|
||||
// 「设备名称」是 el-input,且同一弹窗里 el-select 也渲染 input,
|
||||
// 所以必须按表单项标签定位,否则会写进班级选择框里(这正是第一次写这个用例时踩的坑)
|
||||
const nameItem = Array.from(createDialog.querySelectorAll('.el-form-item')).find(
|
||||
(node) => (node.querySelector('.el-form-item__label')?.textContent ?? '').trim() === '设备名称',
|
||||
);
|
||||
const nameInput = nameItem?.querySelector('input');
|
||||
if (!nameInput) return { ok: false, title, reason: '「设备名称」输入框未找到' };
|
||||
nameInput.value = 'UI 冒烟设备';
|
||||
nameInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
await sleep(200);
|
||||
const generate = Array.from(createDialog.querySelectorAll('.el-dialog__footer button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '生成设备令牌',
|
||||
);
|
||||
if (!generate) return { ok: false, title, reason: '弹窗没有「生成设备令牌」按钮' };
|
||||
generate.click();
|
||||
|
||||
const tokenDeadline = Date.now() + 8000;
|
||||
let tokenText = '';
|
||||
while (Date.now() < tokenDeadline) {
|
||||
const box = document.querySelector('.el-dialog textarea');
|
||||
tokenText = (box?.value ?? '').trim();
|
||||
if (tokenText.startsWith('chci_')) break;
|
||||
await sleep(150);
|
||||
}
|
||||
const hasCopy = Array.from(document.querySelectorAll('.el-dialog__footer button')).some((node) =>
|
||||
(node.textContent ?? '').trim() === '复制令牌',
|
||||
);
|
||||
const closeToken = Array.from(document.querySelectorAll('.el-dialog__footer button')).find((node) =>
|
||||
(node.textContent ?? '').trim() === '关闭',
|
||||
);
|
||||
if (closeToken) closeToken.click();
|
||||
await sleep(400);
|
||||
|
||||
// 用接口再确认一次"确实新建了一台设备"(界面上的令牌弹窗也可能是假象)
|
||||
const after = (await (await fetch('/api/integrations/devices', { headers: authHeaders })).json()).data ?? [];
|
||||
const created = after.filter((item) => !beforeIds.has(item.id));
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
title,
|
||||
hasNotify: buttons.includes('下发提醒'),
|
||||
hasCreate: buttons.includes('接入新设备'),
|
||||
labels,
|
||||
submit,
|
||||
classSelected,
|
||||
tokenPrefixOk: tokenText.startsWith('chci_'),
|
||||
tokenLength: tokenText.length,
|
||||
hasCopy,
|
||||
path: location.pathname,
|
||||
createdCount: created.length,
|
||||
createdName: created[0]?.name ?? '',
|
||||
createdIds: created.map((item) => item.id),
|
||||
};
|
||||
})()`);
|
||||
record(
|
||||
'ClassIsland 联动页(教师为本班签发设备令牌 + 下发提醒表单)',
|
||||
Boolean(integrationUi?.ok) &&
|
||||
integrationUi?.title === 'ClassIsland 联动' &&
|
||||
integrationUi?.hasNotify === true &&
|
||||
integrationUi?.hasCreate === true &&
|
||||
integrationUi?.submit === true &&
|
||||
integrationUi?.classSelected === true &&
|
||||
integrationUi?.tokenPrefixOk === true &&
|
||||
integrationUi?.tokenLength > 40 &&
|
||||
integrationUi?.hasCopy === true &&
|
||||
integrationUi?.createdCount === 1 &&
|
||||
integrationUi?.createdName === 'UI 冒烟设备' &&
|
||||
['目标班级', '标题', '内容', '显示时长', '优先级', '语音朗读', '同步通知中心'].every((label) =>
|
||||
(integrationUi?.labels ?? []).includes(label),
|
||||
),
|
||||
`path=${integrationUi?.path ?? '-'} 标题=${integrationUi?.title ?? '-'} 表单字段=[${(integrationUi?.labels ?? []).join('/')}] ` +
|
||||
`令牌前缀=${integrationUi?.tokenPrefixOk ? 'chci_' : '(异常)'}(${integrationUi?.tokenLength ?? 0} 字符) ` +
|
||||
`新建设备=${integrationUi?.createdCount ?? 0}台(${integrationUi?.createdName ?? '-'})` +
|
||||
`${integrationUi?.reason ? ` 原因=${integrationUi.reason}` : ''}`,
|
||||
);
|
||||
|
||||
// 收尾:把本次新建的设备按 id 删掉(演示数据里不该留下"UI 冒烟设备")
|
||||
const integrationCleanup = await win.webContents.executeJavaScript(`(async () => {
|
||||
const token = localStorage.getItem('classhelper.token') ?? '';
|
||||
const headers = { authorization: 'Bearer ' + token };
|
||||
const ids = ${JSON.stringify(integrationUi?.createdIds ?? [])};
|
||||
for (const id of ids) {
|
||||
await fetch('/api/integrations/devices/' + id, { method: 'DELETE', headers });
|
||||
}
|
||||
const list = (await (await fetch('/api/integrations/devices', { headers })).json()).data ?? [];
|
||||
return { ok: true, removed: ids.length, left: list.filter((item) => ids.includes(item.id)).length };
|
||||
})()`);
|
||||
record(
|
||||
'ClassIsland 联动用例收尾:冒烟设备已删除(不污染演示数据)',
|
||||
Boolean(integrationCleanup?.ok) && integrationCleanup?.left === 0,
|
||||
`删除=${integrationCleanup?.removed ?? 0} 残留=${integrationCleanup?.left ?? '-'}` +
|
||||
(integrationCleanup?.reason ? ` 原因=${integrationCleanup.reason}` : ''),
|
||||
);
|
||||
|
||||
// 7.9 管理员专属:教师录入(本轮新增「教师管理」页,仅管理员可见可用)
|
||||
await runAdminTeacherChecks(win);
|
||||
|
||||
|
||||
Reference in new issue
Block a user