Files
ClassHelper/README.md
T
LoveChengke 3a9f8812fc feat: 客户端灵动岛 + 上课时段紧急通知二次确认,并修复打包/校验脚本在 Electron 宿主下的多个缺陷
## 客户端灵动岛(动态岛)

- 新增独立透明置顶窗口:桌面中上方常驻、不占任务栏、不抢焦点(268x44 胶囊 / 404x316 详情 / 424x344 紧急)
- 三种形态与动画:隐藏 ↔ 胶囊 ↔ 详情,尺寸用 easeOutCubic 260ms 逐帧缓动、透明度单独淡入淡出
  (尺寸与淡入使用各自动画令牌,避免互相打断导致窗口卡在胶囊尺寸)
- 行为:普通通知变「新消息 · 共 N 条」胶囊,点击展开详情;上课时段自动隐藏并进入待发队列,
  下课后自动弹出;紧急通知无论是否上课立即展开(红色呼吸光晕 + 紧急角标,无需点击)
- 上课状态来自 GET /api/schedules/current(10s 心跳 + 下课时长定时器),
  新增 src/main/island.ts(控制器/IPC)、src/preload/island.ts、src/island/IslandApp.vue、
  src/renderer/island/bridge.ts、island.html 与多页 Vite 构建

## 上课时段发布紧急通知的二次确认

- 服务端:priority=URGENT 且当前处于上课时间段且未带 confirmDuringClass 时返回
  409 URGENT_DURING_CLASS(携带 current/next/week/serverTime),绕过界面调接口同样被拦截
- 新增 GET /api/schedules/current(inClass/current/next,at 参数用于诊断与测试)
- Web 管理端:全屏警告遮罩(UrgentClassWarning.vue),显示正在上的课时段与待发布标题,
  确认按钮带 3 秒倒计时(圆环进度,倒计时结束前禁用),取消即不发;并发命中 409 同样兜底弹窗

## 打包与验证脚本修复(Electron 宿主环境)

- scripts/lib/node-runtime.mjs:定位真正的 node.exe。此前经 pnpm 运行时 process.execPath 指向
  Electron,dist:server 会把 Electron 复制成安装包内的 node.exe,dist:win 会让 electron-builder 报
  Unknown argument: .../cli.js(改为显式使用真实 Node)
- scripts/lib/electron-env.mjs:启动 Electron 前清理继承的 ELECTRON_RUN_AS_NODE(否则 Electron 退化为
  纯 Node,require('electron') 直接 MODULE_NOT_FOUND),并从 path.txt 定位 electron.exe
  (process.versions.electron 存在时 import 'electron' 返回的是 API 对象而非路径)
- scripts/dist-server.mjs:新增构建产物新鲜度校验(曾出现 server 已重建但打进安装包的 shared/dist 是旧的,
  安装后服务启动即报 does not provide an export named MAX_TERM_WEEK)
- scripts/ui-smoke:新增 live-probe.mjs 真实制造/清理上课时段,Electron 驱动真实点击断言全屏警告文案、
  按钮禁用与 3 秒倒计时时长(实测 2966ms),测试后自动删除探针课表与测试通知

## 验证结果

- pnpm verify:e2e 62/62(新增上课时段拦截、二次确认后发布、普通通知不受限等 8 项)
- pnpm verify:web 8/8(新增上课时段紧急通知全屏警告 + 倒计时)
- pnpm verify:desktop 18/18(新增灵动岛窗口/上课隐藏/下课弹出/紧急立即展开/胶囊点击展开 + 截图)
- 打包后 EXE 冒烟 15/15(packaged: true,退出码 0)
- 服务端安装程序升级安装:.env 与数据库保持不变,/api/schedules/current 正常
- pnpm lint / format:check / typecheck 全部通过
2026-09-12 08:41:08 +08:00

624 lines
53 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 班级小助手(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 离线缓存 | ✅ 已完成(冒烟验证 18/18) |
| 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 客户端(含灵动岛与联网集成):18 项
```
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截**等 62 项,
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
(登录 + 四类数据 + Socket.IO),实测 18/18 通过。
## 默认账号与种子数据
`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 客户端冒烟验证(18 项,含灵动岛、侧边栏点击与离线回退) |
| `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 逐帧缓动(胶囊 ↔ 详情 形变),透明度单独淡入淡出;
两套动画使用**独立令牌**,互不打断(早前"淡入取消形变导致窗口卡在胶囊尺寸"的问题已修复)。
- 事件流:`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`(上课状态轮询与通知转发)。
### 离线缓存与自动同步
```
┌────────────── 在线 ──────────────┐
请求 → 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 断言与截图) |
| 联网集成(`ELECTRON_SMOKE_ONLINE=1`) | ✅ 登录王小明 / 1 班级 / 6 作业 / 9 通知 / 8 成绩 / 第1周30节课 / Socket.IO 已连接 |
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
已实测 `packaged: true`、18/18 通过、退出码 0)。灵动岛各状态的窗口截图会写到
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`),便于人工确认视觉效果。
## 上课时段策略(紧急通知二次确认)
"上课时段"由课表实时判定:`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 <token>`。
| 方法 | 路径 | 权限 | 说明 |
| --------------------------- | -------------------------------------------------------- | --------------- | -------------------------------------------------------------------------------- |
| 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/<name>/`(`*.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 | ✅ 18/18,`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`(客户端冒烟 + 灵动岛四态 + 侧边栏点击 + 离线回退) | **18/18** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
## 常见问题(本机环境已知坑)
1. **在 DSH 桌面端(Electron 宿主)里执行 pnpm 时,依赖的 `.bin` 不会被注入 PATH**
现象:`pnpm run <script>` 报 `'prisma' 不是内部或外部命令`。
原因:本机 pnpm 由 Electron 进程承载,bin 目录位于
`node_modules/.pnpm/node_modules/.bin`,未注入子进程 PATH。
规避:直接用 Node 调用 CLI 入口,例如
`node packages/server/node_modules/prisma/build/index.js generate`、
`node packages/web-admin/node_modules/vite/bin/vite.js build`。
在普通终端(非 Electron 宿主)中 `pnpm <script>` 一切正常。
1.5 **脚本里启动 Electron 时必须清掉 `ELECTRON_RUN_AS_NODE`,并且不要 `import 'electron'` 取路径**
现象(两种,都只在本机这种"宿主终端本身是 Electron"的环境里出现):
① `node scripts/smoke.mjs` 正常,但 `pnpm verify:desktop` 报
`Cannot find module 'electron'`(Electron 退化成纯 Node 运行);
② `pnpm dist:win` 报 `Unknown argument: .../electron-builder/out/cli/cli.js`。
原因:本机 `pnpm` 运行在 Electron 内置 Node 上,会给子进程带上 `ELECTRON_RUN_AS_NODE=1`;
同时 `process.versions.electron` 存在时,`require('electron')` 返回的是 **API 对象而非可执行文件路径**,
`spawn(process.execPath, [cli.js, …])` 会被 Electron 当成"启动应用"而多出一个位置参数。
规避(已内置到代码里):
- `scripts/lib/electron-env.mjs`:`resolveElectronEnv()` 清理变量、`resolveElectronExecutable()` 从
`node_modules/electron/path.txt` 定位 electron.exe;
- `scripts/lib/node-runtime.mjs`:`resolveNodeRuntime()` 明确找一个**真正的 node.exe**,
供 electron-builder CLI 与"内置 Node 运行时"打包使用。
2. **不要用 PowerShell 管道调用 `start.cmd`**
现象:`& "$install\start.cmd" | Select-Object -First 20` 会一直不返回。
原因:脚本内启动的 node 进程在该上下文里继承了管道句柄。
规避:双击运行,或用 `Start-Process cmd.exe "/c start.cmd"`(脚本自身 4 秒内退出)。
3. **原生模块会按 Electron ABI 构建**
同一原因会让 `node-gyp` / `prebuild-install` 以 `runtime=electron` 为目标,
装出的 `.node` 在 Node 进程里无法加载。本项目因此改用 **libSQL 适配器**
(`@libsql/win32-x64-msvc` 为 npm 预编译包,无需本地编译),彻底规避该问题。
4. **本机 TLS 中间人证书导致依赖二进制下载失败**
现象:`prebuild-install warn install unable to verify the first certificate`。
规避:安装时设置 `NODE_OPTIONS=--use-system-ca`(Node ≥ 22.15 支持),
并确保 `npm_config_cache` 指向可写目录(如仓库内 `.cache/npm-cache`)。
5. **受限沙箱下 Vite 构建报 `spawn EPERM`**
Vite 在 Windows 上通过 `execFile` 解析真实路径,需要放开子进程管道限制后再构建。
6. **Prisma 7 与旧版本差异**
`prisma` 的 npm `latest` 标签当前指向 `8.0.0-rc`,本项目**显式锁定 7.10.0**;
生成器为 `prisma-client`(输出到 `src/generated/prisma`,已 gitignore),
连接串在 `prisma.config.ts` 中配置,且必须通过 driver adapter 接入数据库。
7. **Electron 二进制下载慢或失败**
`electron` 的 postinstall 会从 GitHub 拉取约 151MB 发行包,本机实测 10 分钟仍未完成。
规避:安装前设置镜像与缓存目录(本项目已把 electron 缓存指向 `.cache/electron`):
`ELECTRON_MIRROR=https://cdn.npmmirror.com/binaries/electron/`。
打包阶段 `scripts/dist-win.mjs` 已内置该镜像,并优先复用本地已解压的 Electron。
8. **electron-builder 的构建工具(NSIS / winCodeSign)下载慢**
通过 `ELECTRON_BUILDER_BINARIES_MIRROR=https://npmmirror.com/mirrors/electron-builder-binaries/`
解决(已内置到 `dist-win.mjs`),实测 nsis-3.0.4.1 与 nsis-resources 正常下载。
`scripts/dist-server.mjs` 会直接复用 electron-builder 缓存里的 `makensis`。
9. **应用图标**
`pnpm icons` 会用 Electron 渲染 SVG 生成 `packages/desktop-client/build/icon.ico`(多尺寸)、
PWA 所需的 PNG 与 `favicon.svg`。electron-builder 与 NSIS 安装程序都会自动使用该图标。
10. **服务端安装包内的 `.cmd` 脚本为什么是英文提示?**
cmd.exe 按 OEM 代码页(GBK)解析脚本文件,UTF-8 中文会产生乱码甚至语法错误,
因此脚本提示统一用 ASCII,中文说明放在安装向导与 `README.txt` 中。
## 交付清单(阶段 7 · 生产化)
| 路径 | 说明 |
| --------------------------------------------------- | ---------------------------------------------------------------- |
| `scripts/dist-server.mjs` | 服务端 + Web 端打包(内置 Node、依赖、迁移、启停脚本、安装程序) |
| `scripts/nsis/server-installer.nsi` | NSIS 安装程序模板(安装/快捷方式/开机自启/卸载保留数据) |
| `scripts/generate-icons.mjs` + `scripts/icons/` | 图标生成(Electron 渲染 SVG → PNG/ICO) |
| `scripts/ui-smoke/` | Web 管理端 UI 真实点击回归测试 |
| `packages/server/src/middleware/security.ts` | helmet + 限流(通用/登录)+ 请求耗时日志 |
| `packages/server/src/lib/web-static.ts` | Web 管理端静态托管 + SPA 回退 + 缓存策略 |
| `packages/server/src/lib/db-bootstrap.ts` | 首启动自动迁移 + 自动创建管理员 |
| `packages/web-admin/public/{manifest,sw.js}` | PWA:可安装为应用 + 离线外壳 |
| `deploy/{Dockerfile,docker-compose.yml,nginx.conf}` | 云部署与 HTTPS 反代样例 |
| `docs/production.md` | 生产部署指南(三种形态 + 运维 + 安全清单 + 故障排查) |
阶段 8(灵动岛 + 上课时段策略)新增文件:
| 路径 | 说明 |
| ------------------------------------------------------------ | -------------------------------------------------------------------- |
| `packages/desktop-client/src/main/island.ts` | 灵动岛控制器(窗口/状态机/动画/超时/IPC) |
| `packages/desktop-client/src/preload/island.ts` | 灵动岛 preload 桥(`islandGetState/islandPush/islandSetClassState`) |
| `packages/desktop-client/src/island/IslandApp.vue` | 灵动岛界面(胶囊 / 详情 / 紧急三种形态 + 动画) |
| `packages/desktop-client/src/renderer/island/bridge.ts` | 上课状态轮询(10s)与通知转发 |
| `packages/server/src/modules/schedules/schedules.service.ts` | `computeClassStatus` / `getClassStatus`(上课时段判定) |
| `packages/web-admin/src/components/UrgentClassWarning.vue` | 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) |
| `scripts/ui-smoke/live-probe.mjs` | UI 回归测试的"真实上课时段"探针(自建课表 + 用后清理) |
| `scripts/lib/{electron-env,node-runtime}.mjs` | Electron/Node 运行时定位与宿主环境兼容处理 |
## 后续可选增强
1. 代码签名证书(消除 SmartScreen 提示)。
2. 客户端"提交类操作离线队列"(当前离线为只读,联网后自动同步读取的数据)。
3. Socket.IO 多实例广播(引入 `@socket.io/redis-adapter`)与 CI 流水线。
4. 灵动岛位置/尺寸可配置(多显示器时跟随当前活动屏幕,而非固定主屏)。