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);