From d0830f683064b38b64676cedf9f809e47cccc5a0 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Sun, 27 Sep 2026 14:59:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(integrations,classisland,server,web,deskto?= =?UTF-8?q?p):=20ClassIsland=20=E8=81=94=E5=8A=A8=E6=8F=92=E4=BB=B6?= =?UTF-8?q?=E2=80=94=E2=80=94=E4=B8=8A=E6=8A=A5=E8=AF=BE=E8=A1=A8=E4=B8=8E?= =?UTF-8?q?=E4=B8=8A=E8=AF=BE=E7=8A=B6=E6=80=81=E3=80=81=E4=B8=8B=E5=8F=91?= =?UTF-8?q?=E6=8F=90=E9=86=92=E5=B9=B6=E5=9B=9E=E6=89=A7=E3=80=81=E6=95=99?= =?UTF-8?q?=E5=AE=A4=E7=AB=AF=E6=8E=A8=E9=80=81=E9=80=9A=E9=81=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 教室机器上的 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)。 --- AGENTS.md | 160 +++- README.md | 152 +++- .../classisland/01-urgent-mask.png | Bin 0 -> 319535 bytes .../classisland/02-normal-mask.png | Bin 0 -> 319880 bytes .../classisland/03-from-notify-page.png | Bin 0 -> 370259 bytes eslint.config.mjs | 2 + package.json | 6 +- packages/classisland-plugin/.gitignore | 4 + .../ClassHelper.ClassIslandPlugin.csproj | 45 ++ .../Properties/launchSettings.json | 10 + packages/classisland-plugin/README.md | 148 ++++ packages/classisland-plugin/icon.png | Bin 0 -> 3263 bytes packages/classisland-plugin/manifest.yml | 19 + packages/classisland-plugin/scripts/build.mjs | 174 +++++ .../classisland-plugin/scripts/verify.mjs | 296 ++++++++ .../src/Interop/ClassHelperClient.cs | 423 +++++++++++ .../src/Models/PluginSettings.cs | 158 ++++ packages/classisland-plugin/src/Plugin.cs | 157 ++++ .../src/Services/BridgeService.cs | 537 ++++++++++++++ .../ClassHelperNotificationProvider.cs | 170 +++++ .../src/Services/ClassPlanWriter.cs | 252 +++++++ .../src/Services/ScheduleMapper.cs | 249 +++++++ .../src/Views/BridgeSettingsPage.axaml | 167 +++++ .../src/Views/BridgeSettingsPage.axaml.cs | 198 +++++ packages/desktop-client/src/main/config.ts | 26 +- packages/desktop-client/src/main/smoke.ts | 67 ++ packages/desktop-client/src/renderer/App.vue | 8 + .../desktop-client/src/renderer/api/index.ts | 17 + .../desktop-client/src/renderer/stores/app.ts | 67 +- .../src/renderer/stores/realtime.ts | 49 +- .../src/renderer/views/LoginView.vue | 2 + .../src/renderer/views/SettingsView.vue | 92 +++ .../desktop-client/src/types/desktop.d.ts | 14 +- packages/server/package.json | 3 +- .../migration.sql | 58 ++ .../migration.sql | 3 + packages/server/prisma/schema.prisma | 82 +++ .../server/scripts/verify-classisland.mjs | 507 +++++++++++++ packages/server/src/lib/mappers.ts | 9 + .../server/src/modules/calls/calls.service.ts | 19 + .../src/modules/classes/classes.module.ts | 45 +- .../src/modules/classes/classes.schemas.ts | 13 + .../src/modules/classes/classes.service.ts | 74 +- .../src/modules/imports/class-plan.service.ts | 11 +- .../src/modules/integrations/device-auth.ts | 100 +++ .../integrations/integrations.module.ts | 172 +++++ .../integrations/integrations.schemas.ts | 124 ++++ .../integrations/integrations.service.ts | 694 ++++++++++++++++++ .../integrations/write-back.service.ts | 222 ++++++ .../notifications/notifications.service.ts | 49 ++ packages/server/src/modules/registry.ts | 2 + packages/server/src/types/express.d.ts | 9 + packages/shared/src/constants.ts | 64 ++ packages/shared/src/types.ts | 289 +++++++- packages/shared/src/utils.ts | 31 + packages/web-admin/src/api/index.ts | 31 + .../web-admin/src/layouts/AdminLayout.vue | 2 + packages/web-admin/src/router/index.ts | 7 + packages/web-admin/src/stores/realtime.ts | 21 + .../web-admin/src/views/IntegrationsView.vue | 652 ++++++++++++++++ scripts/ui-smoke/main.cjs | 157 +++- 61 files changed, 7026 insertions(+), 93 deletions(-) create mode 100644 docs/screenshots/classisland/01-urgent-mask.png create mode 100644 docs/screenshots/classisland/02-normal-mask.png create mode 100644 docs/screenshots/classisland/03-from-notify-page.png create mode 100644 packages/classisland-plugin/.gitignore create mode 100644 packages/classisland-plugin/ClassHelper.ClassIslandPlugin.csproj create mode 100644 packages/classisland-plugin/Properties/launchSettings.json create mode 100644 packages/classisland-plugin/README.md create mode 100644 packages/classisland-plugin/icon.png create mode 100644 packages/classisland-plugin/manifest.yml create mode 100644 packages/classisland-plugin/scripts/build.mjs create mode 100644 packages/classisland-plugin/scripts/verify.mjs create mode 100644 packages/classisland-plugin/src/Interop/ClassHelperClient.cs create mode 100644 packages/classisland-plugin/src/Models/PluginSettings.cs create mode 100644 packages/classisland-plugin/src/Plugin.cs create mode 100644 packages/classisland-plugin/src/Services/BridgeService.cs create mode 100644 packages/classisland-plugin/src/Services/ClassHelperNotificationProvider.cs create mode 100644 packages/classisland-plugin/src/Services/ClassPlanWriter.cs create mode 100644 packages/classisland-plugin/src/Services/ScheduleMapper.cs create mode 100644 packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml create mode 100644 packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml.cs create mode 100644 packages/server/prisma/migrations/20260927120000_add_classisland_integration/migration.sql create mode 100644 packages/server/prisma/migrations/20260927140000_add_class_notification_channel/migration.sql create mode 100644 packages/server/scripts/verify-classisland.mjs create mode 100644 packages/server/src/modules/integrations/device-auth.ts create mode 100644 packages/server/src/modules/integrations/integrations.module.ts create mode 100644 packages/server/src/modules/integrations/integrations.schemas.ts create mode 100644 packages/server/src/modules/integrations/integrations.service.ts create mode 100644 packages/server/src/modules/integrations/write-back.service.ts create mode 100644 packages/web-admin/src/views/IntegrationsView.vue diff --git a/AGENTS.md b/AGENTS.md index 0ab855a..b53b113 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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\`),因此插件可以真跑起来验证 | ### 从零重建环境 @@ -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 @@ -365,6 +421,30 @@ onMounted(async () => { title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效" /> + + +

+ 老师在班级小助手上发布通知 / 叫人时,提醒要弹在哪里由这台教室机器决定。选择会同步到班级, + 服务端据此决定是否一并推送到教室的 ClassIsland。 +

+ + + {{ item.label }} + + +

+ {{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }} + +

+
+ @@ -425,6 +505,18 @@ onMounted(async () => { diff --git a/scripts/ui-smoke/main.cjs b/scripts/ui-smoke/main.cjs index 1f90b17..1e806f6 100644 --- a/scripts/ui-smoke/main.cjs +++ b/scripts/ui-smoke/main.cjs @@ -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);