# 班级小助手(Class Helper) 班级信息管理系统,采用 pnpm monorepo,包含**后端服务**、**Web 管理端**(教师/管理员)与 **桌面客户端**(学生)三端。核心链路: ``` 教师在 Web 端发布内容 │ REST API(JWT 鉴权) ▼ 后端服务(Express + Prisma) │ 写入数据库 + 按班级房间广播 ▼ Socket.IO ──► class:{classId} 房间 ──► 学生桌面客户端实时更新 UI ``` ## 当前交付范围 | 阶段 | 内容 | 状态 | | ---- | ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | | 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 | | 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 | | 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 | | 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 19/19) | | 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) | | 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) | | 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) | | 8 | 客户端灵动岛(通知浮窗,上课隐藏 / 下课弹出 / 紧急立即展开)与上课时段紧急通知二次确认 | ✅ 已完成(见"上课时段策略") | > **与原始提示词的两处偏差(已与你确认)** > > 1. 桌面客户端采用 **Electron + Vue 3**(而非 Avalonia/FluentAvalonia):正文 90% 的要求 > (Socket.IO Client、Pinia、IndexedDB 离线缓存、electron-builder)都基于 Electron, > 且本机没有 .NET SDK。 > 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`(107 MB) | 免安装直接运行(U 盘分发) | | Web 管理端(PWA) | 由服务端在 `/` 直接托管 | 浏览器打开即用,可在 Edge/Chrome 中「安装为应用」 | 生产部署(Windows 安装包 / Docker + MySQL / 手动部署)请看 **[docs/production.md](docs/production.md)**。 ## 目录结构 ``` class-helper/ ├── package.json # 根脚本(dev / build / db:* / verify:* / dist:* / icons) ├── pnpm-workspace.yaml # workspace 定义 + allowBuilds(pnpm 11 依赖构建白名单) ├── tsconfig.base.json # 共享 TS 基础配置 ├── eslint.config.mjs # ESLint 扁平配置(TS + Vue) ├── .dockerignore # 容器构建上下文排除项 ├── build/icon.{ico,png} # 应用图标(由 pnpm icons 生成) ├── scripts/ │ ├── use-database.mjs # SQLite ⇄ MySQL provider 切换助手 │ ├── generate-icons.mjs # 用 Electron 渲染 SVG 生成 PNG/ICO 图标 │ ├── dist-server.mjs # 服务端 + Web 管理端 打包(免安装目录 + NSIS 安装程序) │ ├── lib/electron-env.mjs # 启动 Electron 的公共处理(清理 RUN_AS_NODE + 定位可执行文件) │ ├── nsis/server-installer.nsi# 安装程序脚本模板 │ └── ui-smoke/ # Web 管理端 UI 真实点击回归测试(Electron 驱动 + 上课时段探针) ├── deploy/ # 生产部署:Dockerfile / docker-compose.yml / nginx.conf ├── docs/ │ ├── production.md # 生产部署指南(三种形态 + 运维 + 安全清单) │ └── mysql.md # MySQL 切换指南 └── packages/ ├── shared/ # 三端共享:类型契约、常量、工具函数 │ └── src/{types,constants,utils,index}.ts ├── server/ # 后端服务 │ ├── prisma/schema.prisma # 数据模型 │ ├── prisma/migrations/ # 迁移历史(已生成并应用) │ ├── prisma/seed.ts # 种子数据 │ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串) │ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(62 项) │ └── src/ │ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载) │ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出) │ ├── config/env.ts # 环境变量校验(zod)+ 生产配置自检 │ ├── lib/ # db / db-bootstrap / web-static / http / jwt / access(RBAC) / mappers │ ├── middleware/ # auth / error / validate / security(helmet+限流+耗时日志) │ ├── realtime/ # socket.ts + bus.ts(事件总线) │ └── modules/ # 功能模块 + registry.ts ├── web-admin/ # Web 管理端(Vue 3 + Vite + Element Plus + PWA) │ ├── public/ # manifest / sw.js / 图标(由 pnpm icons 生成) │ └── src/{api,stores,router,layouts,views,styles} └── desktop-client/ # EXE 客户端(Electron + Vue 3) ├── electron-builder.yml # 打包配置(nsis 安装包 + portable 单文件) ├── build/icon.ico # 应用图标(electron-builder 自动使用) ├── vite.config.mts # 渲染进程构建(base: './',hash 路由) ├── scripts/{build-main,dev,smoke,dist-win}.mjs └── src/ ├── main/ # 主进程:窗口/单实例/配置持久化(IPC)/冒烟验证/灵动岛 ├── preload/ # contextBridge 安全桥(不暴露 ipcRenderer 本体) ├── island/ # 灵动岛渲染进程(独立透明置顶窗口) ├── types/desktop.d.ts # 主进程 <-> 渲染进程契约 └── renderer/ # 渲染进程:api / stores / cache / router / layouts / views ``` ## 技术栈 | 层 | 选型 | 版本 | | ---------- | ---------------------------------------------------------------------- | ----------------------------------------------------- | | 包管理 | pnpm workspace | 11.8.0 | | 语言 | TypeScript | 5.9.3(`typescript-eslint` 要求 < 6.1,因此未用 7.x) | | 后端 | Node.js + Express | 24.18 / 5.2 | | ORM | Prisma + driver adapter | 7.10(+ `@prisma/adapter-libsql`) | | 实时 | Socket.IO | 4.8 | | 认证 | jsonwebtoken + bcryptjs | 9.0 / 3.0 | | 校验 | zod | 4.6 | | Web 端 | Vue 3 + Vite + Element Plus + Pinia + Vue Router + Axios + ECharts | 3.5 / 8.3 / 2.14 / 4.0 / 5.3 / 1.20 / 6.1 | | EXE 客户端 | Electron + Vue 3 + Element Plus + Pinia + Socket.IO Client + IndexedDB | 44.3 / 3.5 / 2.14 / 4.0 / 4.8 | | 打包 | electron-builder(nsis / portable) | 26.15 | > 运行环境要求:**Node.js ≥ 20.19**(Prisma 7 硬性要求,推荐 22/24)。 ## 快速开始 ```bash # 1. 安装依赖(Node >= 20.19,pnpm >= 10) pnpm install # 2. 准备环境变量(后端) cp packages/server/.env.example packages/server/.env # → 至少修改 JWT_SECRET;本地开发可直接使用仓库内已有的 .env # 3. 生成 Prisma Client 并建库、写入种子数据 pnpm db:generate pnpm db:migrate pnpm db:seed # 4. 启动(后端 4000 + Web 端 5173 一起启动) pnpm dev ``` 启动后访问: | 服务 | 地址 | | ------------------------------------------ | -------------------------------- | | Web 管理端(开发) | http://127.0.0.1:5173 | | 后端服务首页(浏览器可读,列出已挂载模块) | http://127.0.0.1:4000 | | 后端 API | http://127.0.0.1:4000/api | | 健康检查 | http://127.0.0.1:4000/api/health | > 后端只提供 REST API 与 WebSocket,没有业务网页界面。直接访问 > `http://127.0.0.1:4000/` 会返回服务首页(带 `Accept: application/json` 时返回结构化信息); > 其它未匹配路径统一返回 `{ "success": false, "code": "NOT_FOUND" }`。 > Web 管理端地址是 **5173** 端口,请勿访问 80 端口(会连接被拒绝)。 也可以分开启动:`pnpm dev:server`(仅后端)、`pnpm dev:web`(仅 Web 端)。 ### 启动 EXE 客户端(学生端) ```bash # 开发模式:自动构建主进程 + 启动 Vite 渲染进程(5174) + 打开 Electron 窗口(支持热更新) pnpm dev:desktop # 已构建产物的冒烟验证(不弹窗,跑完自动退出) pnpm build:desktop pnpm verify:desktop ``` 客户端首次启动会带上默认服务器地址 `http://127.0.0.1:4000`,可用学生账号 `student01 / student123` 登录。 若要验证"断网可查看缓存",登录并浏览过程序后关闭后端(`Ctrl+C` 停掉 `pnpm dev:server`), 客户端会在顶部提示「当前处于离线状态」,页面继续显示本地缓存数据;重新启动后端后会自动同步。 ### 端到端验收脚本 ```bash # 另开一个终端先启动后端:pnpm dev:server pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截:62 项 pnpm verify:web # Web 管理端真实点击(含上课时段紧急通知全屏警告):8 项 pnpm verify:desktop # Electron 客户端(含灵动岛与联网集成):19 项 ``` 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项, 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 (登录 + 四类数据 + Socket.IO),实测 19/19 通过。 ## 默认账号与种子数据 `pnpm db:seed` 会清空业务表并写入一套完整演示数据(1 管理员 / 2 教师 / 3 班级 / 15 学生 / 15 课程 / 90 条课表 / 15 份作业 / 12 条通知 / 90 条成绩)。 | 角色 | 用户名 | 密码 | 说明 | | ------ | ------------------------- | ------------ | ------------------------------------------ | | 管理员 | `admin` | `admin123` | 可访问全部班级 | | 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 | | 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 | | 学生 | `student01` | `student123` | 高一(1)班 | | 学生 | `student02` … `student05` | `student123` | 高一(1)班 | | 学生 | `student06` … `student10` | `student123` | 高一(2)班 | | 学生 | `student11` … `student15` | `student123` | 高二(3)班 | - 学生初始密码可用 `DEFAULT_STUDENT_PASSWORD` 配置(默认 `123456`), 教师也可在「学生管理」里一键重置为默认密码。 - 教学周由 `TERM_START_DATE`(第 1 教学周的周一)换算,当前周次用于课表默认视图。 ## 常用命令 | 命令 | 说明 | | ------------------------------------------- | ------------------------------------------------------------------------- | | `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 | | `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 | | `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) | | `pnpm build` | 构建 shared + 后端 + Web 端 + EXE 客户端 | | `pnpm build:desktop` | 仅构建 EXE 客户端(esbuild 主进程/preload + Vite 渲染进程) | | `pnpm icons` | 生成应用图标(PNG/ICO,用 Electron 渲染 SVG) | | **`pnpm dist:server`** | **打包服务端 + Web 管理端**(免安装目录 + NSIS 安装程序,内置 Node) | | `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) | | `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE | | `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 | | `pnpm verify:e2e` | 后端端到端验收(62 项,需服务端已启动) | | `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,8 项) | | `pnpm verify:desktop` | EXE 客户端冒烟验证(19 项,含灵动岛像素级截图校验、侧边栏点击与离线回退) | | `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) | | `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 | | `pnpm format` / `pnpm format:check` | Prettier 格式化 / 检查 | | `pnpm db:generate` | 生成 Prisma Client(输出到 `packages/server/src/generated/prisma`) | | `pnpm db:migrate` | 创建并应用迁移 | | `pnpm db:seed` | 写入种子数据 | | `pnpm db:reset` | 重置数据库并重新执行 seed | | `pnpm db:studio` | 打开 Prisma Studio | | `pnpm db:switch:mysql` / `db:switch:sqlite` | 切换数据库 provider | ## EXE 客户端(学生端) ### 进程结构与安全设置 | 进程 | 文件 | 职责 | | -------- | ------------------------------------------------ | -------------------------------------------------------------------------------------------------------------- | | 主进程 | `src/main/*` → `dist/main/index.js` | 窗口/单实例锁、配置持久化(`safeStorage` 加密 token)、IPC、冒烟验证 | | 预加载 | `src/preload/index.ts` → `dist/preload/index.js` | 通过 `contextBridge` 暴露最小 API(`getConfig/saveConfig/getAppInfo/openExternal`),不暴露 `ipcRenderer` 本体 | | 渲染进程 | `src/renderer/**` → `dist/renderer/*` | Vue 3 界面、Axios 请求、Socket.IO 实时、IndexedDB 离线缓存 | 安全基线:`contextIsolation: true`、`nodeIntegration: false`、`sandbox: true`、`webSecurity: true`, 外部链接一律交给系统浏览器打开,禁止应用内导航到非本地地址。 ### 功能页面 | 页面 | 功能 | | ---- | ----------------------------------------------------------------------------------------- | | 登录 | 服务器地址 + 用户名 + 密码,附带「测试连接」;已登录但服务器不可达时可「离线进入」 | | 课表 | 按周展示 7 列课表,支持上一周/下一周/下拉选择周次,高亮"今天",显示周次范围与地点 | | 作业 | 列表(全部/未完成/已完成过滤)、详情抽屉、标记完成/取消完成、附件链接、逾期高亮 | | 通知 | 未读红点(侧边栏徽标)、优先级标签、点击自动标为已读、全部已读、未读过滤、详情抽屉 | | 成绩 | 表格(分数/得分率进度条/等级)+ ECharts 柱状图(各次考试平均得分率)+ 等级分布 + 最近更新 | | 设置 | 服务器地址(保存并测试)、账号信息、离线缓存条目统计与清空、客户端版本信息、退出登录 | > 通知到达时还会由**灵动岛**在桌面中上方浮出提醒(见上一节),上课时段自动隐藏、紧急通知立即展开。 ### 灵动岛(桌面通知浮窗) 客户端有一个独立于主窗口的**灵动岛**:一个无边框、透明、置顶、不占任务栏的窄条窗口, 常驻桌面**中上方**(屏幕工作区顶部居中,`y = workArea.y + 8`)。它是"消息到达"的第一现场: | 场景 | 灵动岛行为 | | -------------------------- | ------------------------------------------------------------------------------------------------------ | | 收到通知(非上课时段) | 从隐藏态淡入为**胶囊**:`新消息 · 共 N 条` + 发送人 + `点击查看`,15 秒后自动收起 | | 点击胶囊 | 展开为**详情卡**:标题、内容、时间、`打开应用 / 标为已读 / 知道了`,20 秒后收起 | | 上课时间段收到普通通知 | **自动隐藏**(不打扰课堂),进入待发队列;下课后自动在桌面中上方弹出详情(14 秒)→ 收起为胶囊(25 秒) | | 上课时间段收到**紧急**通知 | **无论是否上课立刻展开**显示详情(带红色呼吸光晕与"紧急"角标,无需点击),45 秒后收起 | | 多条约谈 | 队列按时间排序,紧急通知优先插播;卡片底部提示"还有 N 条通知" | | 退出/断开 | 主窗口退出时灵动岛一并关闭;上课状态来自 `GET /api/schedules/current`(10 秒心跳 + 下课时长定时器) | - 动画:窗口尺寸用 `easeOutCubic` 260ms 逐帧缓动(胶囊 ↔ 详情 形变),透明度单独淡入淡出; 卡片内部再用 `cubic-bezier(0.34, 1.56, 0.64, 1)`(弹簧回弹)做入场与点击反馈, 紧急形态的红色光晕由独立的 `.glow` 层以 `urgent-breath` 无限呼吸,未读点用 `dot-pulse`。 两套窗口动画使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。 - 事件流:`notification:new` → `renderer/stores/realtime.ts` → `pushNotificationToIsland()` → 主进程 `IslandController`(决定隐藏/胶囊/详情)→ 灵动岛渲染进程 `src/island/IslandApp.vue`。 - 相关文件:`src/main/island.ts`(控制器 + IPC)、`src/preload/island.ts`、`src/island/IslandApp.vue`、 `src/renderer/island/bridge.ts`(上课状态轮询与通知转发)。 各形态实拍(由 `pnpm verify:desktop` 自动截取并归档到 `docs/screenshots/island/`,并做像素级校验): | 形态 | 截图 | 尺寸(逻辑 / 截图) | 说明 | | ---------------------- | ------------------------------------------------------------------- | ------------------- | ----------------------------------------- | | 下课后自动弹出详情 | ![下课后自动弹出](docs/screenshots/island/island-2-after-class.png) | 404×316 / 808×632 | 上课期间暂存的通知,下课后自动弹出 | | 上课期间紧急通知 | ![紧急通知立即展开](docs/screenshots/island/island-3-urgent.png) | 424×344 / 848×688 | 红色光晕 + 紧急角标,无需点击 | | 非上课时段"新消息"胶囊 | ![新消息胶囊](docs/screenshots/island/island-4-pill.png) | 268×44 / 536×88 | 点击后展开详情 | | 点击胶囊后的详情 | ![点击后展开](docs/screenshots/island/island-5-clicked.png) | 404×316 / 808×632 | 标题/内容/时间 + 打开应用/标为已读/知道了 | > 截图路径可用 `ISLAND_SHOTS_DIR` 覆盖(默认 `.cache/island-shots/`); > 归档目录里的图就是打包后 EXE 跑自动化验证时产出的同一份文件。 ### 离线缓存与自动同步 ``` ┌────────────── 在线 ──────────────┐ 请求 → fetchWithCache(store, key, loader, fallback) └── 失败 → 读取 IndexedDB 缓存 → 返回旧数据(UI 标记"离线缓存") 实时推送(notification:new / grade:updated ...) → 更新内存状态 + 写回 IndexedDB 服务器不可达 → 可达(health 轮询 20s 或任意请求成功)→ 触发 onServerRecovered → 各页自动重新拉取 ``` - 缓存按业务域分为 6 个 object store:`profile` / `classes` / `schedules` / `homeworks` / `notifications` / `grades`。 - 缓存写入前统一做 JSON 纯化(Vue 响应式对象是 Proxy,直接写入 IndexedDB 会抛 `could not be cloned`)。 - 登录令牌由主进程用 Electron `safeStorage`(Windows 下为 DPAPI)加密后落盘,因此重启客户端可离线进入。 - 令牌缺失/失效时保持缓存资料可见,恢复网络后自动刷新个人资料。 ### 打包 Windows EXE ```bash pnpm dist:dir # 免安装目录:packages/desktop-client/release/win-unpacked/班级小助手.exe pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS 工具,可用镜像) ``` 打包脚本 `packages/desktop-client/scripts/dist-win.mjs` 做了三件事,规避常见网络问题: 1. **复用本地已解压的 Electron**:检测到 `node_modules/electron/dist/electron.exe` 时通过 `--config.electronDist` 直接使用,不再重复下载 150MB+ 发行包; 2. 默认走国内镜像(可用环境变量覆盖): `ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/`、 `ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/`; 3. 构建缓存固定到仓库内 `.cache/`,不污染全局目录。 产物说明:`release/win-unpacked/班级小助手.exe`(约 235MB,Electron 运行时)+ `resources/app.asar` (约 2.7MB,仅含 `dist/` 与 `package.json`,运行时不需要 node_modules)。 默认使用 Electron 内置图标(未提供 `build/icon.ico`)。 ### 客户端冒烟验证(无需人工点击) `pnpm verify:desktop` 以 `ELECTRON_SMOKE_TEST=1` 启动应用(不弹窗),主进程依次校验后自动退出: | 校验项 | 实测结果 | | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | 渲染进程挂载 + 窗口标题 | ✅ `children=1` / `title=班级小助手` | | 登录页渲染(三个输入框) | ✅ 服务器地址 / 用户名 / 密码 | | preload contextBridge 注入 | ✅ 9 个方法(含灵动岛 3 个) | | IPC 往返(getAppInfo/saveConfig) | ✅ Electron 44.3.0 | | 配置文件写入用户目录 | ✅ `%APPDATA%\@classhelper\desktop-client\config.json` | | IndexedDB 缓存读写 | ✅ 读写/统计/删除 + 6 个 store | | 断网时回退本地缓存 | ✅ `fromCache=true`(请求不可达端口) | | 灵动岛窗口创建(置顶/透明/不占任务栏) | ✅ `ready=true` | | 上课期间普通通知自动隐藏(暂存) | ✅ `mode=hidden queued=1` | | 下课后自动弹出暂存通知详情 | ✅ `mode=expanded reason=after-class` | | 上课期间紧急通知立即展开(无需点击) | ✅ `mode=expanded reason=urgent` | | 胶囊 → 点击 → 展开详情 | ✅ `新消息 · 共 2 条` → `expanded`(含真实 DOM 断言与截图) | | **灵动岛截图留档 + 像素级校验** | ✅ 4 张(808×632 / 848×688 / 536×88 / 808×632);宽高比与状态机配置偏差 **0.000**;胶囊态偏红 205 px、紧急态偏红 **20473 px**(红色光晕);形态尺寸递增 pill < expanded < urgent | | 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 6 作业 / 9 通知 / 8 成绩 / 第1周30节课 / Socket.IO 已连接 | 该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=` 写出 JSON 结果, 已实测 `packaged: true`、16/16 通过、退出码 0)。灵动岛各状态的窗口截图会写到 `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), 并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)与紧急态红色像素做断言。 ## 上课时段策略(紧急通知二次确认) "上课时段"由课表实时判定:`GET /api/schedules/current?classId=` 返回 `{ inClass, current, next, week, serverTime }`(`at` 参数可覆盖判定时刻,便于联调与测试)。 **服务端强制拦截**(不是只靠前端提醒): ``` POST /api/notifications { classId, title, content, priority: "URGENT" } → 409 URGENT_DURING_CLASS POST /api/notifications { …, priority: "URGENT", confirmDuringClass: true } → 201 POST /api/notifications { …, priority: "NORMAL" }(上课时段) → 201(不受限) ``` 409 响应体携带 `details: { classId, current, next, week, serverTime }`,前端据此渲染"正在上的课"。 这样即使有人绕过界面直接调接口,也一定会被拦下来。 **Web 管理端**:教师在通知发布弹窗选择"紧急"并提交时,先查询班级上课状态,命中则弹出**全屏二次确认** (`packages/web-admin/src/components/UrgentClassWarning.vue`): - 全屏遮罩 + 强警示配色,文案"现在为上课时间段……" + 正在上的课时段 + 待发布标题; - 确认按钮带 **3 秒倒计时**(圆环进度 + 每秒钟数提示),倒计时结束前不可点击,防止误触; - 取消则不发;确认后带 `confirmDuringClass: true` 重新提交并成功发布; - 兜底:并发场景(提交瞬间刚好打铃)仍会收到 409,此时同样弹出该警告。 **EXE 客户端**:上课时段非紧急通知自动隐藏、下课后自动弹出;紧急通知无论是否上课都立即展开(见"灵动岛"一节)。 回归测试:`verify:e2e` 覆盖 409 拦截 / 二次确认后 201 / 普通通知不受限(用 `at` 固定时刻,结果可复现); `verify:web` 用真实点击构造"正在上课"场景并断言全屏警告文案、按钮禁用与倒计时时长(实测 ~2.96s)。 ## REST API 一览 统一响应体:`{ "success": true, "data": {}, "message": "" }`(错误为 `success:false` + `code`)。 所有接口前缀 `/api`,除登录外均需 `Authorization: Bearer `。 | 方法 | 路径 | 权限 | 说明 | | --------------------------- | -------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------- | | POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 | | GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | | PATCH | `/auth/password` | 登录 | 修改自己的密码 | | POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | | GET | `/classes` | 登录 | 班级列表(按权限收敛) | | GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) | | POST / PATCH / DELETE | `/classes` `/classes/:id` | 教师/管理员 | 班级增删改 | | GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) | | DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 | | POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 | | GET / POST / PATCH / DELETE | `/courses` | 登录 / 教师 | 课程管理 | | GET | `/schedules?classId=&week=&dayOfWeek=` | 登录 | 课表列表(week 过滤周次范围) | | GET | `/schedules/grid?classId=&week=` | 登录 | 周视图(7 列结构,供客户端直接渲染) | | GET | `/schedules/current?classId=&at=` | 登录 | 当前上课状态(`inClass` / `current` / `next`;`at` 为诊断用时间覆盖) | | POST / PATCH / DELETE | `/schedules` | 教师/管理员 | 课表增删改(广播 `schedule:updated`) | | GET | `/homeworks?classId=&courseId=&pendingOnly=&keyword=` | 登录 | 作业列表(学生带完成状态,教师带完成人数) | | GET | `/homeworks/:id` | 班级可见 | 作业详情 | | POST / PATCH / DELETE | `/homeworks` | 教师/管理员 | 发布/修改/删除(广播 `homework:new` / `homework:updated`) | | PATCH | `/homeworks/:id/status` | 登录 | 标记完成/取消(广播 `homework:status`) | | GET | `/notifications?classId=&priority=&unreadOnly=&keyword=` | 登录 | 通知列表(带已读状态) | | GET | `/notifications/unread-count` | 登录 | 未读数(红点) | | POST | `/notifications` | 教师/管理员 | 发布通知(广播 `notification:new`);上课时段发布紧急通知需 `confirmDuringClass` | | POST | `/notifications/:id/read`、`/notifications/read-all` | 登录 | 标记已读 | | DELETE | `/notifications/:id` | 教师/管理员 | 删除通知 | | GET | `/grades/my` | 登录 | 个人成绩 | | GET | `/grades?classId=&courseId=&userId=&examName=` | 教师/管理员 | 班级成绩 | | GET | `/grades/stats?classId=&courseId=&examName=` | 教师/管理员 | 等级分布 + 各课程平均得分率 | | POST | `/grades`、`/grades/bulk` | 教师/管理员 | 单条 / 批量录入(广播 `grade:updated`) | | PATCH / DELETE | `/grades/:id` | 教师/管理员 | 修改 / 删除成绩 | | GET | `/students?classId=&keyword=` | 教师/管理员 | 学生名单 | | POST / PATCH / DELETE | `/students` | 教师/管理员 | 学生账号增删改 | | POST | `/students/:id/reset-password` | 教师/管理员 | 重置密码 | | GET | `/teachers?keyword=` | 教师/管理员 | 教师列表(分配协作教师用) | | POST | `/teachers` | 管理员 | 新建教师账号 | | GET | `/dashboard/summary` / `/dashboard/term` | 登录 | 仪表盘汇总 / 学期周次 | | 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` | 客户端 → 服务端 | 手动订阅班级(服务端二次校验权限) | 房间规则(`@classhelper/shared` 的 `SOCKET_ROOMS`): `class:{classId}`、`user:{userId}`、`role:{role}`、`students`、`teachers`。 客户端断线由 Socket.IO 自动重连(Web 端 store 与后续 EXE 端共用同一套事件名)。 ## 权限模型(RBAC) | 角色 | 可见范围 | 写权限 | | --------- | ------------------------------------------------------------- | --------------------------------------- | | `ADMIN` | 全部班级 | 全部 | | `TEACHER` | 自己创建(`Class.teacherId`)或被分配(`ClassTeacher`)的班级 | 仅上述班级 | | `STUDENT` | 仅自己所在班级(`User.classId` + `Enrollment`) | 无(仅能标记自己的作业状态 / 已读通知) | 实现要点: - `src/middleware/auth.ts`:JWT 校验后会**回查数据库**,班级/角色变更立即生效。 - `src/lib/access.ts`:集中实现 `assertClassAccess` / `assertClassWritable` / `resolveClassScope`, 列表接口通过 scope 收敛数据(`classId in [...]`),单条接口直接断言。 - 越权请求统一返回 `403`,未登录返回 `401`(验收脚本已覆盖 10 项越权场景)。 ## 模块化设计(可随时增删模块) 后端每个功能是一个独立目录,统一契约 `ApiModule = { name, basePath, router, enabled? }`: ```ts // packages/server/src/modules/registry.ts export const apiModules: ApiModule[] = [ authModule, classesModule, coursesModule, schedulesModule, homeworksModule, notificationsModule, gradesModule, studentsModule, teachersModule, // ← 新增模块只需在这里加一行 dashboardModule, ].filter((module) => module.enabled !== false); ``` - **新增模块**:新建 `modules//`(`*.schemas.ts` 校验、`*.service.ts` 业务、`*.module.ts` 路由), 在 registry 里加一行即可,`app.ts` 无需改动。 - **移除/下线模块**:删掉对应行,或把 `enabled` 置为 `false`,其接口立即从路由表消失。 - **实时推送解耦**:业务模块只调用 `realtime/bus.ts` 的 `emitToClass/emitToUser`, 不直接依赖 Socket.IO 实例,因此「移除实时通道」不影响业务代码。 - **共享契约**:所有 DTO 与类型集中在 `@classhelper/shared`,三端共用,避免契约漂移。 ## 数据库 - 默认:SQLite(Prisma 7 的 libSQL driver adapter,数据文件 `packages/server/prisma/dev.db`)。 - 模型设计为跨库通用(无 enum、无 `@db.*` 原生类型),**切换到 MySQL 只需改 provider + 连接串**。 - 完整步骤见 [`docs/mysql.md`](docs/mysql.md): ```bash pnpm db:switch:mysql pnpm --filter @classhelper/server add @prisma/adapter-mariadb # 修改 packages/server/.env 的 DATABASE_PROVIDER / DATABASE_URL pnpm db:migrate && pnpm db:seed ``` 数据库表:`User` / `Class` / `ClassTeacher` / `Enrollment` / `Course` / `Schedule` / `Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `Grade`(共 11 张)。 ## 验收标准对照 | 验收项 | 结果 | 证据 | | ------------------------------------------------ | ---- | -------------------------------------------------------------------------------------------- | | 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** | | 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | | 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 | | 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` | | 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 | | 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | | 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | | 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 四种状态断言 + 截图(`.cache/island-shots/`) | | 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) | | 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | 打包产物验证(对最终 EXE 实测,非仅开发产物): | 产物 | 大小 | 冒烟结果 | | -------------------------------------- | ------- | ------------------------------------------------------------------------------------------------------ | | 服务端安装程序(内置 Node + Web 端) | 34MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 → **对安装实例跑 62/62 e2e + 8/8 UI** | | `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 16/16,`packaged: true`,退出码 0 | | `release/…-x64-portable.exe`(单文件) | ~107MB | ✅(含联网集成:登录王小明 / Socket.IO 已连接 / 四类数据) | | `release/…-x64-setup.exe`(客户端) | ~107MB | ✅ 构建成功,已嵌入自定义图标;包含灵动岛 | 当前实测: | 验证 | 结果 | | ---------------------------------------------------------------------------- | ----------------------------------- | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **62/62** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 上课时段紧急通知 + PWA) | **8/8** ✅ | | `pnpm verify:desktop`(客户端冒烟 + 灵动岛四态 + 像素级截图校验 + 离线回退) | **19/19** ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | | Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | ## 常见问题(本机环境已知坑) 1. **在 DSH 桌面端(Electron 宿主)里执行 pnpm 时,依赖的 `.bin` 不会被注入 PATH** 现象:`pnpm run