feat(class-account): 学生端主体改为班级(班级码 + 班级密码)+ 数据按班级隔离

服务端:
- Class 新增 code(唯一)/passwordHash 与迁移(老数据回填 C00001 形式, 未设密码禁止登录)
- POST /auth/class-login: 班级码 + 班级密码 -> classSession 会话(sub=classId)
- PATCH /classes/:id/class-account: 管理员改班级码/重置密码(唯一性、格式校验)
- auth 中间件/socket 握手支持班级会话(sub 为 classId, 回查 Class 表, 加入 user:{classId} 房间)
- lib/session.ts: 班级设备代全班操作(通知已读/作业完成/成绩总览按全班读写), 普通学生账号行为不变
- 叫人消息同时广播到 user:{classId}, 班级设备立即上岛
- 班级列表/详情按权限返回班级码与密码状态(哈希不外泄)

客户端:
- 登录页改为班级码 + 班级密码(个人学生入口移除, /auth/login 接口保留兼容)
- 作业按钮/成绩标题按班级会话显示(全班标记完成 / 本班成绩)
- 冒烟脚本自动准备班级账号凭据(兼容全新与升级安装)

Web 管理端:
- 班级管理新增「班级账号」列与设置弹窗(改班级码/重置密码/随机生成)
- 新建班级可选自定义班级码, 默认由服务端生成

验证: verify:e2e 125/125(新增班级账号 18 项) verify:web 18/18 verify:desktop 40/40
      (login=高一(1)班 classSession=true)
文档: README 新增「学生端班级账号」章节、API/迁移/验收/默认账号更新; 安装包 README 同步
This commit is contained in:
LoveChengke committed 2026-09-12 15:51:08 +08:00
1 parent 8b85079d81
commit 1073436ad6
38 files changed
+1263 -131

No files matched your search

+75 -10
View File
@@ -163,7 +163,7 @@ pnpm build:desktop
pnpm verify:desktop pnpm verify:desktop
``` ```
客户端首次启动会带上默认服务器地址 `http://127.0.0.1:4000`,可用学生账号 `student01 / student123` 登录。 客户端首次启动会带上默认服务器地址 `http://127.0.0.1:4000`,输入**班级码 + 班级密码**(种子数据:`G101` / `123456`)即可进入班级。
若要验证"断网可查看缓存",登录并浏览过程序后关闭后端(`Ctrl+C` 停掉 `pnpm dev:server`), 若要验证"断网可查看缓存",登录并浏览过程序后关闭后端(`Ctrl+C` 停掉 `pnpm dev:server`),
客户端会在顶部提示「当前处于离线状态」,页面继续显示本地缓存数据;重新启动后端后会自动同步。 客户端会在顶部提示「当前处于离线状态」,页面继续显示本地缓存数据;重新启动后端后会自动同步。
@@ -171,15 +171,15 @@ pnpm verify:desktop
```bash ```bash
# 另开一个终端先启动后端:pnpm dev:server # 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入:107 项 pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人 + 导入 + 班级账号:125 项
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 导入弹窗):18 项 pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知 + 导入弹窗):18 项
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化与托盘):40 项 pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化与托盘):40 项
``` ```
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚**等 107 项, 后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作**等 125 项,
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过; 实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成 客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
(登录 + 四类数据 + Socket.IO),实测 26/26 通过。 (**班级账号登录** + 四类数据 + Socket.IO),实测 40/40 通过。
## 默认账号与种子数据 ## 默认账号与种子数据
@@ -187,7 +187,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
15 课程 / 90 条课表 / 15 份作业 / 12 条通知 / 90 条成绩)。 15 课程 / 90 条课表 / 15 份作业 / 12 条通知 / 90 条成绩)。
| 角色 | 用户名 | 密码 | 说明 | | 角色 | 用户名 | 密码 | 说明 |
| ------ | ------------------------- | ------------ | ------------------------------------------ | | ------ | ------------------------- | ------------ | ------------------------------------------- |
| 管理员 | `admin` | `admin123` | 可访问全部班级 | | 管理员 | `admin` | `admin123` | 可访问全部班级 |
| 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 | | 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 |
| 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 | | 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 |
@@ -195,9 +195,15 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
| 学生 | `student02` … `student05` | `student123` | 高一(1)班 | | 学生 | `student02` … `student05` | `student123` | 高一(1)班 |
| 学生 | `student06` … `student10` | `student123` | 高一(2)班 | | 学生 | `student06` … `student10` | `student123` | 高一(2)班 |
| 学生 | `student11` … `student15` | `student123` | 高二(3)班 | | 学生 | `student11` … `student15` | `student123` | 高二(3)班 |
| 班级 | 班级码 `G101` | `123456` | 高一(1)班(**学生端班级登录**,非个人账号) |
| 班级 | 班级码 `G102` | `123456` | 高一(2)班 |
| 班级 | 班级码 `G203` | `123456` | 高二(3)班 |
- 学生初始密码可用 `DEFAULT_STUDENT_PASSWORD` 配置(默认 `123456`), - 学生初始密码可用 `DEFAULT_STUDENT_PASSWORD` 配置(默认 `123456`),
教师也可在「学生管理」里一键重置为默认密码。 教师也可在「学生管理」里一键重置为默认密码。
- **班级账号**(学生端主入口)用班级码 + 班级密码登录,默认密码由 `DEFAULT_CLASS_PASSWORD` 配置(默认 `123456`);
管理员可在「班级管理 → 班级账号」里改班级码或重置密码,详见
[学生端班级账号](#学生端班级账号学生端主体--班级)。
- 教学周由 `TERM_START_DATE`(第 1 教学周的周一)换算,当前周次用于课表默认视图。 - 教学周由 `TERM_START_DATE`(第 1 教学周的周一)换算,当前周次用于课表默认视图。
## 常用命令 ## 常用命令
@@ -531,6 +537,49 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
回归测试:`verify:e2e` 覆盖 409 拦截 / 二次确认后 201 / 普通通知不受限(用 `at` 固定时刻,结果可复现); 回归测试:`verify:e2e` 覆盖 409 拦截 / 二次确认后 201 / 普通通知不受限(用 `at` 固定时刻,结果可复现);
`verify:web` 用真实点击构造"正在上课"场景并断言全屏警告文案、按钮禁用与倒计时时长(实测 ~2.96s)。 `verify:web` 用真实点击构造"正在上课"场景并断言全屏警告文案、按钮禁用与倒计时时长(实测 ~2.96s)。
## 学生端班级账号(学生端主体 = 班级)
需求:学生端不再以"个人学生"为登录主体,改为**班级账号(班级设备)**登录,数据按班级隔离。
### 账号模型
| 项目 | 说明 |
| -------- | ------------------------------------------------------------------------------------ |
| 登录凭据 | 班级码(`Class.code`,唯一、4~16 位字母数字、大小写不敏感)+ 班级密码(bcrypt 哈希) |
| 会话形态 | JWT `{ sub: <classId>, classId: <classId>, role: 'STUDENT', classSession: true }` |
| 未设密码 | `passwordHash = null` → 班级登录被拒(403 `CLASS_PASSWORD_NOT_SET`),提示管理员设置 |
| 管理入口 | Web 管理端「班级管理」→ 行内「班级账号」按钮(改班级码 / 重置密码),仅管理员 |
| 向后兼容 | 个人学生账号(`student01`…)与 `/auth/login` 完全保留,接口层不受影响 |
### 数据按班级隔离
- `GET /classes`:班级会话只返回自己所在班级(跨班请求一律 403);
- 作业 / 通知 / 课表 / 成绩:全部按班级收敛,班级会话无法访问其他班级;
- 发布类接口(通知、作业、成绩、课表、叫人):班级会话是 `STUDENT`,一律 403;
- 成绩:`GET /grades/my` 对班级会话返回**全班成绩总览**(客户端标题自动变为「本班成绩」)。
### 班级设备代全班操作(个人数据语义)
班级设备代表整个班级,因此"个人记账"类操作由服务端一次性写入**全班学生**:
| 操作 | 行为 | 教师端看到的结果 |
| ----------------------------- | ------------------------------------------------- | --------------------- |
| 通知「标为已读」/「全部已读」 | 为全班学生写入 `NotificationRead`(已存在的跳过) | 已读人数 = 班级学生数 |
| 作业「全班标记完成」 | 为全班学生 upsert `HomeworkStatus.completed` | 完成人数 = 班级学生数 |
| 叫人「收到」 | 复用通知已读机制(同上) | 该生已读 |
读状态判定统一使用 `userId in 全班学生`(`packages/server/src/lib/session.ts` 的 `resolvePersonalIds()`),
因此普通学生账号仍然只影响自己,行为与改造前完全一致。
### 实现位置
- `packages/server/src/lib/class-account.ts`:班级码校验/生成、密码设置与重置、登录校验、自助改密码;
- `packages/server/src/lib/session.ts`:`isClassSession` / `resolvePersonalIds` / `personalIdWhere`;
- `packages/server/src/middleware/auth.ts`:班级会话回查 `Class` 表(而不是 `User` 表);
- `packages/server/src/realtime/socket.ts`:班级会话以班级 id 加入 `user:{classId}` 房间,叫人消息直达班级设备;
- `packages/web-admin/src/views/ClassesView.vue`:班级账号列 + 设置/重置弹窗;
- `packages/desktop-client/src/renderer/views/LoginView.vue`:班级码 + 班级密码登录页(不再展示个人账号入口)。
## 导入(成绩 / 名单表格 + ClassIsland 时间配置) ## 导入(成绩 / 名单表格 + ClassIsland 时间配置)
三条导入链路都在服务端完成解析、校验与去重,前端只负责收集文件与展示结果, 三条导入链路都在服务端完成解析、校验与去重,前端只负责收集文件与展示结果,
@@ -586,13 +635,15 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
| 方法 | 路径 | 权限 | 说明 | | 方法 | 路径 | 权限 | 说明 |
| --------------------------- | -------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------- | | --------------------------- | -------------------------------------------------------- | ----------------- | -------------------------------------------------------------------------------- |
| POST | `/auth/login` | 公开 | 登录,返回 token + 用户信息 | | POST | `/auth/login` | 公开 | 登录(教师 / 管理员 / 个人学生),返回 token + 用户信息 |
| POST | `/auth/class-login` | 公开 | **班级账号登录**:班级码 + 班级密码 → `classSession` 会话 |
| GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) | | GET | `/auth/me` | 登录 | 当前用户(学生附带班级/年级) |
| PATCH | `/auth/password` | 登录 | 修改自己的密码 | | PATCH | `/auth/password` | 登录 | 修改自己的密码 |
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) | | POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
| GET | `/classes` | 登录 | 班级列表(按权限收敛) | | GET | `/classes` | 登录 | 班级列表(按权限收敛) |
| GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) | | GET | `/classes/:id` | 班级可见 | 班级详情(学生/课程/协作教师) |
| POST / PATCH / DELETE | `/classes` `/classes/:id` | 教师/管理员 | 班级增删改 | | POST / PATCH / DELETE | `/classes` `/classes/:id` | 管理员 | 班级增删改(创建时自动生成班级码 = 班级账号) |
| PATCH | `/classes/:id/class-account` | 管理员 | 设置 / 重置班级账号(班级码 + 班级密码) |
| GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) | | GET / POST | `/classes/:id/students` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) |
| DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 | | DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 |
| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 | | POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 |
@@ -732,7 +783,7 @@ pnpm db:migrate && pnpm db:seed
数据库表:`User` / `Class` / `ClassTeacher` / `Enrollment` / `Course` / `Schedule` / 数据库表:`User` / `Class` / `ClassTeacher` / `Enrollment` / `Course` / `Schedule` /
`Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `Grade` / `Homework` / `HomeworkStatus` / `Notification` / `NotificationRead` / `Grade` /
`TimeLayout`(共 12 张)。 `TimeLayout`(共 12 张);`Class.code` / `Class.passwordHash` 是班级账号字段。
### 启动时迁移(安装版自动升级) ### 启动时迁移(安装版自动升级)
@@ -748,7 +799,7 @@ pnpm db:migrate && pnpm db:seed
## 验收标准对照 ## 验收标准对照
| 验收项 | 结果 | 证据 | | 验收项 | 结果 | 证据 |
| ------------------------------------------------ | ---- | ---------------------------------------------------------------------------------------------- | | ------------------------------------------------ | ---- | -------------------------------------------------------------------------------------------------- |
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** | | 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** |
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` | | 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 | | 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
@@ -764,6 +815,9 @@ pnpm db:migrate && pnpm db:seed
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 | | 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 |
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 | | ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 |
| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 2 个迁移文件,跳过 34 个已存在对象`,`TimeLayout` 自动建表 | | 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 2 个迁移文件,跳过 34 个已存在对象`,`TimeLayout` 自动建表 |
| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 18 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 |
| 班级设备代全班操作(已读 · 完成 · 成绩总览) | ✅ | 标记已读写入 6 条(全班 6 人)、教师端 `readCount=6`、`completedCount=6`、`/grades/my` 全班成绩 |
| 客户端只保留班级登录入口 | ✅ | `verify:desktop`:`login=高一(1)班 classSession=true`,导航「成绩」标题变为「本班成绩」 |
| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) | | 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) |
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | | 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 |
@@ -780,7 +834,7 @@ pnpm db:migrate && pnpm db:seed
| 验证 | 结果 | | 验证 | 结果 |
| ----------------------------------------------------------------------------------------- | ----------------------------------- | | ----------------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **107/107** ✅ | | `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **125/125** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 导入弹窗 + PWA) | **18/18** ✅ | | `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 导入弹窗 + PWA) | **18/18** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ | | `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | | `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
@@ -897,6 +951,17 @@ pnpm db:migrate && pnpm db:seed
| `packages/web-admin/src/components/TimeLayoutImportDialog.vue` | ClassIsland 时间配置导入弹窗(粘贴/选文件、解析预览、覆盖/合并、已存列表) | | `packages/web-admin/src/components/TimeLayoutImportDialog.vue` | ClassIsland 时间配置导入弹窗(粘贴/选文件、解析预览、覆盖/合并、已存列表) |
| `packages/shared/src/types.ts` 的导入 DTO | `TableImportPreview` / `TableImportResult` / `TimeLayoutDto` 等前后端共用 | | `packages/shared/src/types.ts` 的导入 DTO | `TableImportPreview` / `TableImportResult` / `TimeLayoutDto` 等前后端共用 |
阶段 10(学生端班级账号)新增文件:
| 路径 | 说明 |
| --------------------------------------------------------------------- | ----------------------------------------------------------------------- |
| `packages/server/src/lib/class-account.ts` | 班级码校验/生成、班级密码设置与重置、登录校验、班级账号自助改密码 |
| `packages/server/src/lib/session.ts` | `isClassSession` / `resolvePersonalIds` / `personalIdWhere`(全班范围) |
| `packages/server/prisma/migrations/20260912130000_add_class_account/` | `Class.code` / `Class.passwordHash` 迁移(老数据回填 `C00001` 形式) |
| `packages/desktop-client/src/renderer/views/LoginView.vue` | 班级码 + 班级密码登录页(不再展示个人学生账号入口) |
| `packages/web-admin/src/views/ClassesView.vue`(班级账号列与弹窗) | 管理员设置班级码 / 重置班级密码 |
| `packages/desktop-client/scripts/smoke.mjs`(班级账号准备) | 冒烟前通过管理端接口准备可用班级凭据,兼容全新安装与升级安装 |
## 后续可选增强 ## 后续可选增强
1. 代码签名证书(消除 SmartScreen 提示)。 1. 代码签名证书(消除 SmartScreen 提示)。
+1
View File
@@ -17,6 +17,7 @@
"helmet": "^8.3.0", "helmet": "^8.3.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"xlsx": "0.18.5",
"zod": "^4.6.2" "zod": "^4.6.2"
} }
} }
+53
View File
@@ -57,6 +57,57 @@ if (online === undefined) {
); );
} }
/**
* 准备一个已知可用的班级账号(学生端已改为班级码 + 班级密码登录):
* 用管理员账号登录 → 取第一个班级 → 重置成冒烟专用密码(必要时补一个班级码)。
* 这样无论被测实例是全新安装还是升级安装,冒烟都能拿到可用凭据。
*/
async function provisionClassAccount() {
const base = process.env.ELECTRON_SMOKE_API ?? 'http://127.0.0.1:4000/api';
const username = process.env.ELECTRON_SMOKE_ADMIN ?? 'admin';
const adminPassword = process.env.ELECTRON_SMOKE_ADMIN_PASSWORD ?? 'admin123';
const password = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? 'smoke123456';
try {
const loginResponse = await fetch(`${base}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password: adminPassword }),
});
const login = await loginResponse.json();
const token = login?.data?.token;
if (!token) return null;
const authHeaders = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const classesResponse = await fetch(`${base}/classes`, { headers: authHeaders });
const classes = (await classesResponse.json())?.data ?? [];
const target = classes[0];
if (!target) return null;
const code = target.code || `SMOKE${Math.floor(Math.random() * 9000 + 1000)}`;
const patchResponse = await fetch(`${base}/classes/${target.id}/class-account`, {
method: 'PATCH',
headers: authHeaders,
body: JSON.stringify({ code, password }),
});
if (!patchResponse.ok) return null;
const patched = (await patchResponse.json())?.data;
return { code: patched?.code ?? code, password, className: target.name };
} catch {
return null;
}
}
let classCredentials = null;
if (online === '1') {
classCredentials = await provisionClassAccount();
console.log(
classCredentials
? `[smoke] 班级账号就绪:${classCredentials.code}(${classCredentials.className})`
: '[smoke] 未能准备班级账号,联网集成将使用种子默认凭据',
);
}
const child = spawn(electronPath, ['.'], { const child = spawn(electronPath, ['.'], {
cwd: root, cwd: root,
stdio: 'inherit', stdio: 'inherit',
@@ -64,6 +115,8 @@ const child = spawn(electronPath, ['.'], {
ELECTRON_SMOKE_TEST: '1', ELECTRON_SMOKE_TEST: '1',
ELECTRON_SMOKE_ONLINE: online, ELECTRON_SMOKE_ONLINE: online,
ELECTRON_SMOKE_PROFILE: profileDir, ELECTRON_SMOKE_PROFILE: profileDir,
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
}), }),
}); });
+4 -1
View File
@@ -908,10 +908,13 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
// 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用) // 可选:对真实后端做联网集成自检(ELECTRON_SMOKE_ONLINE=1 时启用)
if (process.env.ELECTRON_SMOKE_ONLINE === '1') { if (process.env.ELECTRON_SMOKE_ONLINE === '1') {
// 班级账号凭据由 scripts/smoke.mjs 通过管理端接口准备(渲染进程读不到 process.env)
const classCode = process.env.ELECTRON_SMOKE_CLASS_CODE ?? '';
const classPassword = process.env.ELECTRON_SMOKE_CLASS_PASSWORD ?? '';
const online = await win.webContents.executeJavaScript( const online = await win.webContents.executeJavaScript(
`(async () => { `(async () => {
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' }; if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
return await window.__classhelperSmoke__.onlineScenario(); return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword })});
})()`, })()`,
); );
record( record(
@@ -17,7 +17,11 @@ import { api } from './http.js';
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */ /** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
export const authApi = { export const authApi = {
/** 个人账号登录(教师/管理员/个人学生;客户端界面已不再展示,保留以兼容旧流程与排障) */
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload), login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
/** 班级账号登录(学生端主入口):班级码 + 班级密码 */
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
api.post('/auth/class-login', payload),
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}), logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me), me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
}; };
+12 -3
View File
@@ -1,4 +1,4 @@
import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js'; import { cacheGet, cacheRemove, cacheSet, cacheStats } from './db.js';
import { fetchWithCache } from './index.js'; import { fetchWithCache } from './index.js';
export interface SmokeCheckResult { export interface SmokeCheckResult {
@@ -127,7 +127,10 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
* 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。 * 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。 * 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
*/ */
export async function onlineScenario(): Promise<SmokeCheckResult> { export async function onlineScenario(credentials?: {
code: string;
password: string;
}): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([ const [{ useAuthStore }, { useAppStore }, { useRealtimeStore }, api, cache] = await Promise.all([
import('../stores/auth.js'), import('../stores/auth.js'),
import('../stores/app.js'), import('../stores/app.js'),
@@ -144,8 +147,14 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
const detail: string[] = []; const detail: string[] = [];
try { try {
await auth.login(serverUrl, 'student01', 'student123'); // 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入
const code = credentials?.code ?? 'G101';
const password = credentials?.password ?? '123456';
if (!credentials?.code) detail.push('未收到班级凭据,回退到种子班级码 G101');
await auth.login(serverUrl, code, password);
detail.push(`login=${auth.user?.name ?? '-'}`); detail.push(`login=${auth.user?.name ?? '-'}`);
detail.push(`classSession=${auth.isClassSession}`);
// 进入主布局(后续的侧边栏点击测试依赖布局已挂载) // 进入主布局(后续的侧边栏点击测试依赖布局已挂载)
const { router } = await import('../router/index.js'); const { router } = await import('../router/index.js');
@@ -1,6 +1,6 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { STORAGE_KEYS, type LoginResponse, type StudentDto } from '@classhelper/shared'; import { STORAGE_KEYS, type LoginResponse, type SessionUser } from '@classhelper/shared';
import { authApi } from '../api/index.js'; import { authApi } from '../api/index.js';
import { setApiBaseUrl, setTokenProvider } from '../api/http.js'; import { setApiBaseUrl, setTokenProvider } from '../api/http.js';
import { cacheGet, cacheSet } from '../cache/db.js'; import { cacheGet, cacheSet } from '../cache/db.js';
@@ -15,13 +15,17 @@ const PROFILE_KEY = 'me';
*/ */
export const useAuthStore = defineStore('auth', () => { export const useAuthStore = defineStore('auth', () => {
const token = ref<string | null>(null); const token = ref<string | null>(null);
const user = ref<StudentDto | null>(null); const user = ref<SessionUser | null>(null);
const loading = ref(false); const loading = ref(false);
const offlineSession = ref(false); const offlineSession = ref(false);
const isAuthenticated = computed(() => Boolean(token.value)); const isAuthenticated = computed(() => Boolean(token.value));
const displayName = computed(() => user.value?.name ?? '未登录'); const displayName = computed(() => user.value?.name ?? '未登录');
const classId = computed(() => user.value?.classId ?? null); const classId = computed(() => user.value?.classId ?? null);
/** true 表示当前是「班级账号(班级设备)」会话:个人数据按全班读写 */
const isClassSession = computed(() => user.value?.classSession === true);
/** 班级码(登录账号,便于设置页展示) */
const classCode = computed(() => user.value?.classCode ?? '');
setTokenProvider(() => token.value); setTokenProvider(() => token.value);
@@ -39,20 +43,26 @@ export const useAuthStore = defineStore('auth', () => {
await window.desktop.saveConfig({ serverUrl, username, token: nextToken }); await window.desktop.saveConfig({ serverUrl, username, token: nextToken });
} }
/** 登录:写主进程配置 + 缓存资料 */ /**
async function login(serverUrl: string, username: string, password: string): Promise<void> { * 班级账号登录(学生端主入口):班级码 + 班级密码。
*
* 需求 1:学生端以「班级」为主体,个人学生不再是登录主体;
* 服务端据此把作业完成 / 通知已读等个人数据按"全班"范围读写。
* 保存的 username 字段记录班级码,便于下次预填。
*/
async function login(serverUrl: string, code: string, password: string): Promise<void> {
const normalized = normalizeServerUrl(serverUrl); const normalized = normalizeServerUrl(serverUrl);
if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000'); if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
setApiBaseUrl(normalized); setApiBaseUrl(normalized);
loading.value = true; loading.value = true;
try { try {
const payload: LoginResponse = await authApi.login({ username, password }); const payload: LoginResponse = await authApi.classLogin({ code, password });
applyToken(payload.token); applyToken(payload.token);
user.value = payload.user as StudentDto; user.value = payload.user;
offlineSession.value = false; offlineSession.value = false;
await cacheSet('profile', PROFILE_KEY, user.value); await cacheSet('profile', PROFILE_KEY, user.value);
await persistSession(normalized, username, payload.token); await persistSession(normalized, code, payload.token);
} finally { } finally {
loading.value = false; loading.value = false;
} }
@@ -80,7 +90,7 @@ export const useAuthStore = defineStore('auth', () => {
} }
} }
const cachedProfile = await cacheGet<StudentDto>('profile', PROFILE_KEY).catch(() => null); const cachedProfile = await cacheGet<SessionUser>('profile', PROFILE_KEY).catch(() => null);
if (cachedProfile) user.value = cachedProfile.value; if (cachedProfile) user.value = cachedProfile.value;
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据 // 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
@@ -122,6 +132,8 @@ export const useAuthStore = defineStore('auth', () => {
isAuthenticated, isAuthenticated,
displayName, displayName,
classId, classId,
isClassSession,
classCode,
login, login,
restore, restore,
refreshProfile, refreshProfile,
@@ -12,9 +12,12 @@ import {
import { gradeApi } from '../api/index.js'; import { gradeApi } from '../api/index.js';
import { fetchWithCache } from '../cache/index.js'; import { fetchWithCache } from '../cache/index.js';
import { useAppStore } from '../stores/app.js'; import { useAppStore } from '../stores/app.js';
import { useAuthStore } from '../stores/auth.js';
import { useRealtimeStore } from '../stores/realtime.js'; import { useRealtimeStore } from '../stores/realtime.js';
const appStore = useAppStore(); const appStore = useAppStore();
/** 班级账号(班级设备)看到的是全班成绩总览,标题与副标题随会话类型变化 */
const auth = useAuthStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const loading = ref(false); const loading = ref(false);
@@ -130,9 +133,10 @@ onUnmounted(() => {
<div class="page"> <div class="page">
<div class="page-header"> <div class="page-header">
<div> <div>
<h2 class="page-title">我的成绩</h2> <h2 class="page-title">{{ auth.isClassSession ? '本班成绩' : '我的成绩' }}</h2>
<p class="page-subtitle"> <p class="page-subtitle">
共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}% 共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}%
<span v-if="auth.isClassSession">· 班级账号:显示全班成绩总览</span>
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag> <el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
</p> </p>
</div> </div>
@@ -77,7 +77,15 @@ async function toggleComplete(item: HomeworkDto | null): Promise<void> {
completed: next, completed: next,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}; };
ElMessage.success(next ? '已标记为完成' : '已取消完成标记'); ElMessage.success(
auth.isClassSession
? next
? '已按全班标记为完成'
: '已取消全班的完成标记'
: next
? '已标记为完成'
: '已取消完成标记',
);
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '提交失败'); ElMessage.error(error instanceof Error ? error.message : '提交失败');
} finally { } finally {
@@ -170,7 +178,15 @@ onUnmounted(() => {
:loading="submitting" :loading="submitting"
@click.stop="toggleComplete(row)" @click.stop="toggleComplete(row)"
> >
{{ row.completed ? '取消完成' : '标记完成' }} {{
row.completed
? auth.isClassSession
? '取消全班完成'
: '取消完成'
: auth.isClassSession
? '全班标记完成'
: '标记完成'
}}
</el-button> </el-button>
</template> </template>
</el-table-column> </el-table-column>
@@ -213,7 +229,9 @@ onUnmounted(() => {
:disabled="appStore.offline" :disabled="appStore.offline"
@click="toggleComplete(current)" @click="toggleComplete(current)"
> >
{{ current.completed ? '取消完成标记' : '标记为已完成' }} {{
current.completed ? '取消完成标记' : auth.isClassSession ? '全班标记为已完成' : '标记为已完成'
}}
</el-button> </el-button>
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span> <span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
</div> </div>
@@ -14,7 +14,7 @@ const auth = useAuthStore();
const realtime = useRealtimeStore(); const realtime = useRealtimeStore();
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const form = reactive({ serverUrl: DEFAULT_SERVER, username: '', password: '' }); const form = reactive({ serverUrl: DEFAULT_SERVER, code: '', password: '' });
const testing = ref(false); const testing = ref(false);
const testResult = ref<'ok' | 'fail' | null>(null); const testResult = ref<'ok' | 'fail' | null>(null);
@@ -29,7 +29,7 @@ const rules: FormRules = {
trigger: 'blur', trigger: 'blur',
}, },
], ],
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }], code: [{ required: true, message: '请输入班级码', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }], password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
}; };
@@ -38,7 +38,7 @@ onMounted(async () => {
if (window.desktop) { if (window.desktop) {
const config = await window.desktop.getConfig(); const config = await window.desktop.getConfig();
if (config.serverUrl) form.serverUrl = config.serverUrl; if (config.serverUrl) form.serverUrl = config.serverUrl;
if (config.username) form.username = config.username; if (config.username) form.code = config.username;
} }
}); });
@@ -66,10 +66,10 @@ async function submit(): Promise<void> {
if (!valid) return; if (!valid) return;
try { try {
await auth.login(form.serverUrl, form.username.trim(), form.password); await auth.login(form.serverUrl, form.code.trim(), form.password);
await appStore.init(form.serverUrl); await appStore.init(form.serverUrl);
if (auth.token) realtime.connect(appStore.serverUrl, auth.token); if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
ElMessage.success(`欢迎回来,${auth.displayName}`); ElMessage.success(`已进入 ${auth.displayName}`);
await router.replace('/schedule'); await router.replace('/schedule');
} catch (error) { } catch (error) {
ElMessage.error(error instanceof Error ? error.message : '登录失败'); ElMessage.error(error instanceof Error ? error.message : '登录失败');
@@ -92,7 +92,7 @@ async function enterOffline(): Promise<void> {
<el-icon :size="30" color="#409eff"><School /></el-icon> <el-icon :size="30" color="#409eff"><School /></el-icon>
<div> <div>
<h1 class="login-title">班级小助手</h1> <h1 class="login-title">班级小助手</h1>
<p class="login-sub">学生客户端 · 登录后实时接收课表、作业、通知与成绩</p> <p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
</div> </div>
</div> </div>
@@ -105,19 +105,24 @@ async function enterOffline(): Promise<void> {
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="用户名" prop="username"> <el-form-item label="班级码" prop="code">
<el-input v-model="form.username" placeholder="学号 / 用户名" clearable @keyup.enter="submit"> <el-input
v-model="form.code"
placeholder="例如 G101(由老师在班级管理中提供)"
clearable
@keyup.enter="submit"
>
<template #prefix> <template #prefix>
<el-icon><User /></el-icon> <el-icon><School /></el-icon>
</template> </template>
</el-input> </el-input>
</el-form-item> </el-form-item>
<el-form-item label="密码" prop="password"> <el-form-item label="班级密码" prop="password">
<el-input <el-input
v-model="form.password" v-model="form.password"
type="password" type="password"
placeholder="密码" placeholder="班级密码"
show-password show-password
@keyup.enter="submit" @keyup.enter="submit"
> >
@@ -156,9 +161,9 @@ async function enterOffline(): Promise<void> {
</el-button> </el-button>
<p class="login-tip"> <p class="login-tip">
演示账号:student01 / student123(高一(1)班) 班级码与班级密码由管理员在 Web 管理端「班级管理 → 班级账号」中设置或重置。
<br /> <br />
数据来源:教师或管理员在 Web 管理端发布的内容。 登录后本机即代表整个班级:作业完成、通知已读都会按全班记录。
</p> </p>
</div> </div>
</div> </div>
+4 -1
View File
@@ -86,7 +86,10 @@ declare global {
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>; cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
offlineScenario(): Promise<{ ok: boolean; detail: string }>; offlineScenario(): Promise<{ ok: boolean; detail: string }>;
layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>; layoutNavigationSelfTest(): Promise<{ ok: boolean; detail: string }>;
onlineScenario(): Promise<{ ok: boolean; detail: string }>; onlineScenario(credentials?: {
code: string;
password: string;
}): Promise<{ ok: boolean; detail: string }>;
/** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */ /** 真实通知链路:教师发通知给当前学生班级,验证 Socket.IO → 灵动岛 */
islandRealtimeScenario(): Promise<{ islandRealtimeScenario(): Promise<{
ok: boolean; ok: boolean;
@@ -0,0 +1,20 @@
-- 班级账号(班级码 + 班级密码):学生端以「班级」为主体登录
--
-- 说明:
-- 1) code 是新增的必填唯一列。SQLite 无法直接给已有表加 "NOT NULL 且无默认值" 的列,
-- 因此先带默认值 '' 加上,再用 rowid 回填一版可用且唯一的班级码,最后建唯一索引。
-- 2) passwordHash 允许为 NULL:尚未设置班级密码的班级不允许班级登录,
-- 管理员可在「班级管理」里设置或重置(bcrypt 哈希由服务端生成)。
-- 3) 迁移逐条执行且忽略"已存在"错误,因此本文件重复执行是安全的。
-- AlterTable
ALTER TABLE "Class" ADD COLUMN "code" TEXT NOT NULL DEFAULT '';
-- AlterTable
ALTER TABLE "Class" ADD COLUMN "passwordHash" TEXT;
-- Backfill:老数据按 rowid 生成 C00001 形式的班级码(管理员可随时改)
UPDATE "Class" SET "code" = 'C' || substr('00000' || CAST(rowid AS TEXT), -5) WHERE "code" = '';
-- CreateIndex
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
+4
View File
@@ -54,6 +54,10 @@ model Class {
name String name String
grade String grade String
teacherId String teacherId String
/// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写)
code String @unique
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录
passwordHash String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
+14 -5
View File
@@ -84,10 +84,12 @@ async function main(): Promise<void> {
const random = createRandom(20260901); const random = createRandom(20260901);
// 同一角色共用密码,只需各哈希一次 // 同一角色共用密码,只需各哈希一次
const [adminHash, teacherHash, studentHash] = await Promise.all([ const [adminHash, teacherHash, studentHash, classPasswordHash] = await Promise.all([
hashPassword('admin123'), hashPassword('admin123'),
hashPassword('teacher123'), hashPassword('teacher123'),
hashPassword('student123'), hashPassword('student123'),
// 班级账号默认密码(学生端「班级登录」)
hashPassword('123456'),
]); ]);
const admin = await prisma.user.create({ const admin = await prisma.user.create({
@@ -101,16 +103,23 @@ async function main(): Promise<void> {
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash }, data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
}); });
// 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
const classSeeds = [ const classSeeds = [
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id }, { name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' },
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id }, { name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' },
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id }, { name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' },
]; ];
const classes = []; const classes = [];
for (const [index, seed] of classSeeds.entries()) { for (const [index, seed] of classSeeds.entries()) {
const created = await prisma.class.create({ const created = await prisma.class.create({
data: { name: seed.name, grade: seed.grade, teacherId: seed.teacherId }, data: {
name: seed.name,
grade: seed.grade,
teacherId: seed.teacherId,
code: seed.code,
passwordHash: classPasswordHash,
},
}); });
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' }); classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
} }
+240
View File
@@ -1234,6 +1234,246 @@ async function main() {
record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`); record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`);
} }
// ---------------------------------------------------------------- 9. 班级账号(学生端主体 = 班级)
// 设计:班级码 + 班级密码 → classSession 的 JWT;个人数据(作业完成 / 通知已读 / 成绩)
// 由服务端按"全班"范围读写,个人学生不再是登录主体(个人账号接口仍向后兼容)。
const classCode = `E2E${Date.now().toString(36).slice(-4).toUpperCase()}`;
const setAccount = await api(`/classes/${classId}/class-account`, {
method: 'PATCH',
token: adminToken,
body: { code: classCode, password: 'class123' },
});
record(
'管理员设置班级账号(班级码 + 班级密码)',
setAccount.status === 200 && setAccount.payload?.data?.code === classCode,
`status=${setAccount.status} code=${setAccount.payload?.data?.code}`,
);
const classLogin = await api('/auth/class-login', {
method: 'POST',
body: { code: classCode, password: 'class123' },
});
const classToken = classLogin.payload?.data?.token;
const classUser = classLogin.payload?.data?.user;
record(
'班级账号登录成功(主体为班级)',
classLogin.status === 200 &&
Boolean(classToken) &&
classUser?.classSession === true &&
classUser?.id === classId &&
classUser?.classId === classId,
`status=${classLogin.status} classSession=${classUser?.classSession} name=${classUser?.name}`,
);
const lowerLogin = await api('/auth/class-login', {
method: 'POST',
body: { code: classCode.toLowerCase(), password: 'class123' },
});
record('班级码大小写不敏感', lowerLogin.status === 200, `status=${lowerLogin.status}`);
const wrongClassPassword = await api('/auth/class-login', {
method: 'POST',
body: { code: classCode, password: 'bad-class-password' },
});
record(
'班级密码错误被拒绝(401)',
wrongClassPassword.status === 401,
`status=${wrongClassPassword.status}`,
);
const unknownCode = await api('/auth/class-login', {
method: 'POST',
body: { code: 'NOSUCHCODE', password: 'class123' },
});
record('未知班级码被拒绝(401)', unknownCode.status === 401, `status=${unknownCode.status}`);
const classMe = await api('/auth/me', { token: classToken });
record(
'班级会话 /auth/me 返回班级信息',
classMe.status === 200 &&
classMe.payload?.data?.classSession === true &&
classMe.payload?.data?.id === classId,
`status=${classMe.status} name=${classMe.payload?.data?.name}`,
);
const classClasses = await api('/classes', { token: classToken });
const classVisible = classClasses.payload?.data ?? [];
record(
'班级账号只能看到自己班级',
classVisible.length === 1 && classVisible[0]?.id === classId,
`共 ${classVisible.length} 个(${classVisible.map((item) => item.name).join(',')})`,
);
if (foreignClass) {
const classCrossHomework = await api(`/homeworks?classId=${foreignClass.id}`, { token: classToken });
record(
'班级账号跨班查询被拒绝(403)',
classCrossHomework.status === 403,
`status=${classCrossHomework.status}`,
);
}
const classPublish = await api('/notifications', {
method: 'POST',
token: classToken,
body: { classId, title: '班级账号越权发布', content: '不应成功' },
});
record('班级账号发布通知被拒绝(403)', classPublish.status === 403, `status=${classPublish.status}`);
const classGradesWrite = await api('/grades', {
method: 'POST',
token: classToken,
body: { classId, userId: studentUser?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
});
record(
'班级账号录入成绩被拒绝(403)',
classGradesWrite.status === 403,
`status=${classGradesWrite.status}`,
);
const classScheduleWrite = await api('/schedules', {
method: 'POST',
token: classToken,
body: { classId, courseId, dayOfWeek: 4, startTime: '14:00', endTime: '14:45' },
});
record(
'班级账号修改课表被拒绝(403)',
classScheduleWrite.status === 403,
`status=${classScheduleWrite.status}`,
);
// 班级设备代全班操作:通知已读
const classNameNotice = await api('/notifications', {
method: 'POST',
token: teacherToken,
body: { classId, title: '班级账号已读回归', content: '验证班级设备代全班标记已读' },
});
const classNameNoticeId = classNameNotice.payload?.data?.id;
const beforeUnread = await api(`/notifications/unread-count?classId=${classId}`, { token: classToken });
const classRead = await api(`/notifications/${classNameNoticeId}/read`, {
method: 'POST',
token: classToken,
});
const afterUnread = await api(`/notifications/unread-count?classId=${classId}`, { token: classToken });
const unreadBefore = beforeUnread.payload?.data?.count ?? 0;
const unreadAfter = afterUnread.payload?.data?.count ?? -1;
record(
'班级设备标记已读 = 全班已读(未读数 -1)',
classRead.status === 200 &&
(classRead.payload?.data?.marked ?? 0) === classmates.length &&
unreadBefore > 0 &&
unreadAfter === unreadBefore - 1,
`写入 ${classRead.payload?.data?.marked} 条(班级 ${classmates.length} 人)未读 ${unreadBefore} → ${unreadAfter}`,
);
const classReadAll = await api('/notifications/read-all', {
method: 'POST',
token: classToken,
body: { classId },
});
const unreadAfterAll = await api(`/notifications/unread-count?classId=${classId}`, { token: classToken });
record(
'班级设备全部已读 = 全班全部已读(未读归零)',
classReadAll.status === 200 && (unreadAfterAll.payload?.data?.count ?? -1) === 0,
`标记 ${classReadAll.payload?.data?.marked} 条,剩余未读 ${unreadAfterAll.payload?.data?.count}`,
);
const staffNotice = await api(
`/notifications?classId=${classId}&keyword=${encodeURIComponent('班级账号已读回归')}`,
{ token: teacherToken },
);
const staffNoticeView = (staffNotice.payload?.data ?? [])[0];
record(
'教师端看到的已读人数与全班一致',
staffNoticeView?.readCount === classmates.length,
`readCount=${staffNoticeView?.readCount} / 学生数=${classmates.length}`,
);
// 班级设备代全班操作:作业完成
const classHomework = await api('/homeworks', {
method: 'POST',
token: teacherToken,
body: { classId, title: '班级账号完成回归', content: '验证班级设备代全班标记完成' },
});
const classHomeworkId = classHomework.payload?.data?.id;
const classDone = await api(`/homeworks/${classHomeworkId}/status`, {
method: 'PATCH',
token: classToken,
body: { completed: true },
});
const teacherHomeworkView = await api(`/homeworks/${classHomeworkId}`, { token: teacherToken });
const classPending = await api(`/homeworks?classId=${classId}&pendingOnly=true`, { token: classToken });
const stillPending = (classPending.payload?.data ?? []).some((item) => item.id === classHomeworkId);
record(
'班级设备标记完成 = 全班完成(教师端完成人数一致)',
classDone.status === 200 &&
teacherHomeworkView.payload?.data?.completedCount === classmates.length &&
!stillPending,
`completedCount=${teacherHomeworkView.payload?.data?.completedCount} / 学生数=${classmates.length} 仍在待完成=${stillPending}`,
);
// 班级账号的成绩视图 = 全班总览
const classNameGrade = await api('/grades', {
method: 'POST',
token: adminToken,
body: { classId, userId: studentUser?.id, examName: '班级总览回归', score: 77, totalScore: 100 },
});
const classMyGrades = await api('/grades/my', { token: classToken });
const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.userId));
record(
'班级账号查看成绩 = 全班总览',
classMyGrades.status === 200 &&
(classMyGrades.payload?.data ?? []).some((item) => item.id === classNameGrade.payload?.data?.id),
`成绩条数=${(classMyGrades.payload?.data ?? []).length} 涉及学生=${gradeStudentIds.size}`,
);
// 管理员重置班级密码后:新密码可用、旧密码失效
const resetClassPassword = await api(`/classes/${classId}/class-account`, {
method: 'PATCH',
token: adminToken,
body: { password: 'class456' },
});
const newPasswordLogin = await api('/auth/class-login', {
method: 'POST',
body: { code: classCode, password: 'class456' },
});
const oldPasswordLogin = await api('/auth/class-login', {
method: 'POST',
body: { code: classCode, password: 'class123' },
});
record(
'重置班级密码后旧密码失效 / 新密码可用',
resetClassPassword.status === 200 && newPasswordLogin.status === 200 && oldPasswordLogin.status === 401,
`重置=${resetClassPassword.status} 新密码=${newPasswordLogin.status} 旧密码=${oldPasswordLogin.status}`,
);
const occupiedCode = await api(`/classes/${classId}/class-account`, {
method: 'PATCH',
token: adminToken,
body: { code: classes[1]?.code ?? 'G102' },
});
record(
'班级码重复被拒绝(400)',
occupiedCode.status === 400,
`status=${occupiedCode.status} message=${occupiedCode.payload?.message ?? ''}`,
);
// 清理:删除回归用的通知 / 作业 / 成绩,并把班级密码恢复为默认
if (classNameNoticeId) {
await api(`/notifications/${classNameNoticeId}`, { method: 'DELETE', token: teacherToken });
}
if (classHomeworkId) {
await api(`/homeworks/${classHomeworkId}`, { method: 'DELETE', token: teacherToken });
}
if (classNameGrade.payload?.data?.id) {
await api(`/grades/${classNameGrade.payload.data.id}`, { method: 'DELETE', token: adminToken });
}
await api(`/classes/${classId}/class-account`, {
method: 'PATCH',
token: adminToken,
body: { password: '123456' },
});
socket.close(); socket.close();
// ---------------------------------------------------------------- 汇总 // ---------------------------------------------------------------- 汇总
+4
View File
@@ -28,6 +28,8 @@ const envSchema = z.object({
CORS_ORIGIN: z.string().default('*'), CORS_ORIGIN: z.string().default('*'),
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10), BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'), DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
TERM_START_DATE: z.string().default(''), TERM_START_DATE: z.string().default(''),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'), LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */ /** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
@@ -139,6 +141,8 @@ export const env = {
bcryptRounds: raw.BCRYPT_ROUNDS, bcryptRounds: raw.BCRYPT_ROUNDS,
/** 新增学生时未指定密码时的初始密码 */ /** 新增学生时未指定密码时的初始密码 */
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD, defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
/** 新建班级时的默认班级密码(班级账号登录) */
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
termStartDate: raw.TERM_START_DATE, termStartDate: raw.TERM_START_DATE,
logLevel: raw.LOG_LEVEL, logLevel: raw.LOG_LEVEL,
trustProxy: resolveTrustProxy(raw.TRUST_PROXY), trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
+134
View File
@@ -0,0 +1,134 @@
import { prisma } from './db.js';
import { ApiError } from './http.js';
import { hashPassword, verifyPassword } from './password.js';
/**
* 班级账号(学生端「班级」主体):
* - 每个班级有一个**班级码**(唯一、大写字母数字)和**班级密码**(bcrypt);
* - 学生端班级设备用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT;
* - 管理员可在「班级管理」里修改班级码、重置班级密码。
*
* 兼容性:个人学生账号(User.username)完全不受影响,仍然是独立账号。
*/
/** 班级码:去掉易混淆字符(0/O/1/I),4~16 位大写字母数字 */
const CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const CODE_PATTERN = /^[A-Za-z0-9]{4,16}$/;
/** 归一化班级码:去空格 + 转大写(登录时用户输入大小写都可) */
export function normalizeClassCode(input: string): string {
return input.trim().toUpperCase();
}
export function isValidClassCode(input: string): boolean {
return CODE_PATTERN.test(input.trim());
}
/** 随机生成一个未被占用的班级码 */
export async function generateClassCode(prefix = ''): Promise<string> {
for (let attempt = 0; attempt < 40; attempt += 1) {
const length = Math.max(4, 6 - prefix.length);
let body = '';
for (let index = 0; index < length; index += 1) {
body += CODE_ALPHABET[Math.floor(Math.random() * CODE_ALPHABET.length)];
}
const code = `${prefix}${body}`.toUpperCase();
const exists = await prisma.class.findUnique({ where: { code }, select: { id: true } });
if (!exists) return code;
}
throw new ApiError(500, 'INTERNAL_ERROR', '班级码生成失败,请重试或手动指定班级码');
}
/** 创建班级时调用:生成班级码并写入默认班级密码 */
export async function buildClassAccountCreateData(
defaultPassword: string,
requestedCode?: string,
): Promise<{ code: string; passwordHash: string }> {
const code = requestedCode ? normalizeClassCode(requestedCode) : await generateClassCode();
if (!isValidClassCode(code)) throw ApiError.badRequest('班级码需为 4~16 位字母或数字');
const taken = await prisma.class.findUnique({ where: { code }, select: { id: true } });
if (taken) throw ApiError.badRequest(`班级码 ${code} 已被占用,请更换`);
return { code, passwordHash: await hashPassword(defaultPassword) };
}
/** 管理员:修改班级码 / 重置班级密码 */
export async function updateClassAccount(
classId: string,
input: { code?: string; password?: string },
): Promise<{ code: string; hasPassword: boolean }> {
const current = await prisma.class.findUnique({ where: { id: classId }, select: { id: true, code: true } });
if (!current) throw ApiError.notFound('班级不存在');
const data: { code?: string; passwordHash?: string } = {};
if (input.code !== undefined) {
const code = normalizeClassCode(input.code);
if (!isValidClassCode(code)) throw ApiError.badRequest('班级码需为 4~16 位字母或数字');
if (code !== current.code) {
const taken = await prisma.class.findUnique({ where: { code }, select: { id: true } });
if (taken) throw ApiError.badRequest(`班级码 ${code} 已被其他班级使用`);
data.code = code;
}
}
if (input.password !== undefined && input.password !== '') {
if (input.password.length < 6) throw ApiError.badRequest('班级密码至少 6 位');
data.passwordHash = await hashPassword(input.password);
}
if (Object.keys(data).length === 0) {
throw ApiError.badRequest('没有需要更新的内容(班级码或班级密码)');
}
const updated = await prisma.class.update({
where: { id: classId },
data,
select: { code: true, passwordHash: true },
});
return { code: updated.code, hasPassword: Boolean(updated.passwordHash) };
}
/** 班级账号登录:返回班级记录(密码校验在 service 层完成) */
export async function findClassForLogin(code: string): Promise<{
id: string;
name: string;
grade: string;
code: string;
passwordHash: string | null;
}> {
const normalized = normalizeClassCode(code);
const record = await prisma.class.findUnique({
where: { code: normalized },
select: { id: true, name: true, grade: true, code: true, passwordHash: true },
});
if (!record) throw ApiError.unauthorized('班级码或班级密码错误');
if (!record.passwordHash) {
throw new ApiError(
403,
'CLASS_PASSWORD_NOT_SET',
'该班级尚未设置班级密码,请联系管理员在「班级管理」中设置',
);
}
return record;
}
export async function verifyClassPassword(plain: string, passwordHash: string): Promise<boolean> {
return verifyPassword(plain, passwordHash);
}
/** 班级账号自助修改班级密码 */
export async function changeClassPassword(
classId: string,
currentPassword: string,
newPassword: string,
): Promise<void> {
const record = await prisma.class.findUnique({ where: { id: classId }, select: { passwordHash: true } });
if (!record) throw ApiError.notFound('班级不存在');
if (record.passwordHash && !(await verifyPassword(currentPassword, record.passwordHash))) {
throw ApiError.badRequest('当前班级密码不正确');
}
await prisma.class.update({
where: { id: classId },
data: { passwordHash: await hashPassword(newPassword) },
});
}
+2
View File
@@ -28,6 +28,8 @@ export type ApiErrorCode =
| 'IMPORT_FORMAT_INVALID' | 'IMPORT_FORMAT_INVALID'
| 'IMPORT_INVALID' | 'IMPORT_INVALID'
| 'IMPORT_EMPTY' | 'IMPORT_EMPTY'
/** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */
| 'CLASS_PASSWORD_NOT_SET'
| 'INTERNAL_ERROR'; | 'INTERNAL_ERROR';
/** 业务异常:中间件会把它转换成统一错误响应体 */ /** 业务异常:中间件会把它转换成统一错误响应体 */
+6
View File
@@ -11,6 +11,10 @@ export interface TokenPayload {
name: string; name: string;
role: UserRole; role: UserRole;
classId: string | null; classId: string | null;
/** true 表示"班级账号(班级设备)"登录:sub 是班级 id,而非某个学生账号 */
classSession?: boolean;
/** 班级账号的班级码(便于日志/审计) */
classCode?: string;
} }
export function signToken(payload: TokenPayload): string { export function signToken(payload: TokenPayload): string {
@@ -37,6 +41,8 @@ export function verifyToken(token: string): TokenPayload {
name: String(record.name ?? ''), name: String(record.name ?? ''),
role, role,
classId: record.classId ? String(record.classId) : null, classId: record.classId ? String(record.classId) : null,
classSession: record.classSession === true,
classCode: record.classCode ? String(record.classCode) : undefined,
}; };
} catch { } catch {
throw ApiError.unauthorized('登录状态已失效,请重新登录'); throw ApiError.unauthorized('登录状态已失效,请重新登录');
+22 -4
View File
@@ -25,6 +25,16 @@ import type {
const toIso = (value: Date): string => value.toISOString(); const toIso = (value: Date): string => value.toISOString();
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null); const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
/**
* "个人记录"归属范围解析:
* - 传入 userIds(班级账号 = 全班学生)时优先使用;
* - 否则退化为单个 userId(普通学生账号 = 自己)。
*/
function resolvePool(options: { userId?: string | null; userIds?: string[] }): string[] {
if (options.userIds && options.userIds.length > 0) return options.userIds;
return options.userId ? [options.userId] : [];
}
export interface UserLike { export interface UserLike {
id: string; id: string;
username: string; username: string;
@@ -200,14 +210,16 @@ export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto
/** /**
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus * @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找完成状态
* @param options.withStatus 是否统计完成人数(教师视角) * @param options.withStatus 是否统计完成人数(教师视角)
*/ */
export function toHomeworkDto( export function toHomeworkDto(
item: HomeworkLike, item: HomeworkLike,
options: { userId?: string | null; withStatus?: boolean } = {}, options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
): HomeworkDto { ): HomeworkDto {
const statuses = item.statuses ?? []; const statuses = item.statuses ?? [];
const own = options.userId ? statuses.find((status) => status.userId === options.userId) : undefined; const pool = resolvePool(options);
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.userId)) : undefined;
return { return {
id: item.id, id: item.id,
@@ -248,12 +260,18 @@ export interface NotificationLike {
reads?: NotificationReadLike[]; reads?: NotificationReadLike[];
} }
/**
* @param options.userId 指定当前用户(普通学生账号)
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找已读状态
* @param options.withStatus 是否统计已读人数(教师视角)
*/
export function toNotificationDto( export function toNotificationDto(
item: NotificationLike, item: NotificationLike,
options: { userId?: string | null; withStatus?: boolean } = {}, options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
): NotificationDto { ): NotificationDto {
const reads = item.reads ?? []; const reads = item.reads ?? [];
const own = options.userId ? reads.find((read) => read.userId === options.userId) : undefined; const pool = resolvePool(options);
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.userId)) : undefined;
return { return {
id: item.id, id: item.id,
+53
View File
@@ -0,0 +1,53 @@
import { prisma } from './db.js';
import type { TokenPayload } from './jwt.js';
/**
* 会话主体解析:普通账号 vs 班级账号(班级设备)。
*
* 背景(需求 1):学生端以「班级」为主体登录,个人学生不再作为登录入口。
* 班级账号的 JWT 形如:
* { sub: <classId>, username: <班级码>, name: <班级名>, role: 'STUDENT',
* classId: <classId>, classSession: true }
*
* 语义(由用户确认):
* - **班级设备代全班操作**:勾选作业完成、标记通知已读,都会写到该班全体学生的记录上,
* 因此教师端的"完成人数/已读人数"统计依然准确;
* - 读状态判定使用 `userId in 全班学生`:任一条记录存在即视为该班设备读过/完成过;
* - 个人学生账号(student01…)保持完全兼容,此时"全班"范围退化为"只有自己"。
*
* 这样所有个人数据的读写都收敛到 `resolvePersonalIds()` 一个入口,
* 既不需要改数据库结构,也不会让旧的个人账号行为发生变化。
*/
export function isClassSession(user: TokenPayload): boolean {
return user.role === 'STUDENT' && user.classSession === true;
}
/** 班级账号对应的班级 id */
export function classSessionClassId(user: TokenPayload): string {
return user.classId ?? user.sub;
}
/** 该班级的全部学生 id(班级账号的"全班"范围) */
export async function listClassStudentIds(classId: string): Promise<string[]> {
const students = await prisma.user.findMany({
where: { classId, role: 'STUDENT' },
select: { id: true },
});
return students.map((item) => item.id);
}
/**
* 当前会话的"个人记录主体"列表:
* - 普通学生:自己
* - 班级账号:全班学生
* - 教师/管理员:自己(教师侧的完成情况统计走 withStatus,不依赖这个列表)
*/
export async function resolvePersonalIds(user: TokenPayload): Promise<string[]> {
if (isClassSession(user)) return listClassStudentIds(classSessionClassId(user));
return [user.sub];
}
/** Prisma 过滤片段:`{ userId: { in: ids } }` */
export function personalIdWhere(ids: string[]): { userId: { in: string[] } } {
return { userId: { in: ids } };
}
+25
View File
@@ -15,6 +15,31 @@ export function authenticate(): RequestHandler {
if (!token) throw ApiError.unauthorized('缺少 Authorization 头'); if (!token) throw ApiError.unauthorized('缺少 Authorization 头');
const payload = verifyToken(token); const payload = verifyToken(token);
// 班级账号(班级设备):主体是班级而不是某个学生账号,回查 Class 表
if (payload.classSession) {
const record = await prisma.class.findUnique({
where: { id: payload.classId ?? payload.sub },
select: { id: true, name: true, code: true },
});
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
req.auth = {
token,
user: {
sub: record.id,
username: record.code,
name: record.name,
role: 'STUDENT',
classId: record.id,
classSession: true,
classCode: record.code,
},
};
next();
return;
}
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: payload.sub }, where: { id: payload.sub },
select: { id: true, username: true, name: true, role: true, classId: true }, select: { id: true, username: true, name: true, role: true, classId: true },
@@ -6,30 +6,43 @@ import { validate } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js'; import { defineModule } from '../module.types.js';
import { import {
changePasswordSchema, changePasswordSchema,
classLoginSchema,
loginSchema, loginSchema,
type ChangePasswordInput, type ChangePasswordInput,
type ClassLoginInput,
type LoginInput, type LoginInput,
} from './auth.schemas.js'; } from './auth.schemas.js';
import * as authService from './auth.service.js'; import * as authService from './auth.service.js';
const router = Router(); const router = Router();
/** POST /api/auth/login - 登录(公开) */ /** POST /api/auth/login - 登录(公开):教师 / 管理员 / 个人学生账号 */
router.post('/login', validate({ body: loginSchema }), async (req, res) => { router.post('/login', validate({ body: loginSchema }), async (req, res) => {
const result: LoginResponse = await authService.login(req.body as LoginInput); const result: LoginResponse = await authService.login(req.body as LoginInput);
sendOk(res, result, '登录成功'); sendOk(res, result, '登录成功');
}); });
/** GET /api/auth/me - 当前用户信息 */ /**
router.get('/me', authenticate(), async (req, res) => { * POST /api/auth/class-login - 班级账号登录(公开,学生端主入口)
const user = getAuthUser(req); *
sendOk(res, await authService.getProfile(user.sub), '获取当前用户成功'); * 班级码 + 班级密码 → 以「班级」为主体的 JWT(个人数据按全班读写)。
* 个人学生账号仍可用 /auth/login 登录(向后兼容,客户端已不再展示该入口)。
*/
router.post('/class-login', validate({ body: classLoginSchema }), async (req, res) => {
const result: LoginResponse = await authService.classLogin(req.body as ClassLoginInput);
sendOk(res, result, '班级登录成功');
}); });
/** PATCH /api/auth/password - 修改密码 */ /** GET /api/auth/me - 当前用户信息(班级账号返回班级信息) */
router.get('/me', authenticate(), async (req, res) => {
const user = getAuthUser(req);
sendOk(res, await authService.getProfile(user), '获取当前用户成功');
});
/** PATCH /api/auth/password - 修改密码(班级账号修改的是班级密码) */
router.patch('/password', authenticate(), validate({ body: changePasswordSchema }), async (req, res) => { router.patch('/password', authenticate(), validate({ body: changePasswordSchema }), async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
await authService.changePassword(user.sub, req.body as ChangePasswordInput); await authService.changePassword(user, req.body as ChangePasswordInput);
sendOk(res, { changed: true }, '密码修改成功,请重新登录'); sendOk(res, { changed: true }, '密码修改成功,请重新登录');
}); });
@@ -5,10 +5,17 @@ export const loginSchema = z.object({
password: z.string().min(1, '请输入密码').max(128), password: z.string().min(1, '请输入密码').max(128),
}); });
/** 班级账号登录(学生端):班级码 + 班级密码 */
export const classLoginSchema = z.object({
code: z.string().trim().min(4, '请输入班级码').max(16),
password: z.string().min(1, '请输入班级密码').max(128),
});
export const changePasswordSchema = z.object({ export const changePasswordSchema = z.object({
currentPassword: z.string().min(1, '请输入当前密码'), currentPassword: z.string().min(1, '请输入当前密码'),
newPassword: z.string().min(6, '新密码至少 6 位').max(128), newPassword: z.string().min(6, '新密码至少 6 位').max(128),
}); });
export type LoginInput = z.infer<typeof loginSchema>; export type LoginInput = z.infer<typeof loginSchema>;
export type ClassLoginInput = z.infer<typeof classLoginSchema>;
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>; export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
@@ -1,12 +1,14 @@
import type { LoginResponse, StudentDto, UserRole } from '@classhelper/shared'; import type { LoginResponse, StudentDto, UserRole } from '@classhelper/shared';
import { changeClassPassword, findClassForLogin, verifyClassPassword } from '../../lib/class-account.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import { signToken } from '../../lib/jwt.js'; import { signToken, type TokenPayload } from '../../lib/jwt.js';
import { toStudentDto, toUserDto } from '../../lib/mappers.js'; import { toStudentDto, toUserDto } from '../../lib/mappers.js';
import { hashPassword, verifyPassword } from '../../lib/password.js'; import { hashPassword, verifyPassword } from '../../lib/password.js';
import type { ChangePasswordInput, LoginInput } from './auth.schemas.js'; import { isClassSession } from '../../lib/session.js';
import type { ChangePasswordInput, ClassLoginInput, LoginInput } from './auth.schemas.js';
/** 登录:校验用户名密码并签发 JWT */ /** 登录:校验用户名密码并签发 JWT(教师/管理员/个人学生账号) */
export async function login(input: LoginInput): Promise<LoginResponse> { export async function login(input: LoginInput): Promise<LoginResponse> {
const user = await prisma.user.findUnique({ where: { username: input.username } }); const user = await prisma.user.findUnique({ where: { username: input.username } });
if (!user) throw ApiError.unauthorized('用户名或密码错误'); if (!user) throw ApiError.unauthorized('用户名或密码错误');
@@ -14,37 +16,107 @@ export async function login(input: LoginInput): Promise<LoginResponse> {
const passwordMatched = await verifyPassword(input.password, user.passwordHash); const passwordMatched = await verifyPassword(input.password, user.passwordHash);
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误'); if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
const token = signToken({ const payload: TokenPayload = {
sub: user.id, sub: user.id,
username: user.username, username: user.username,
name: user.name, name: user.name,
role: user.role as UserRole, role: user.role as UserRole,
classId: user.classId, classId: user.classId,
}); };
const token = signToken(payload);
return { token, user: toUserDto(user) }; return { token, user: toUserDto(user) };
} }
/** 当前用户信息(学生额外带班级名与年级) */ /**
export async function getProfile(userId: string): Promise<StudentDto> { * 班级账号登录(学生端):班级码 + 班级密码。
const user = await prisma.user.findUnique({ *
where: { id: userId }, * 签发的 JWT 以**班级**为主体:`sub` 与 `classId` 都是班级 id,并带 `classSession: true`,
include: { class: { select: { name: true, grade: true } } }, * 后续个人数据(作业完成、通知已读)由服务端按"全班"范围读写,个人学生不再是登录主体。
}); */
if (!user) throw ApiError.unauthorized('账号不存在或已被删除'); export async function classLogin(input: ClassLoginInput): Promise<LoginResponse> {
return toStudentDto(user); const record = await findClassForLogin(input.code);
if (!(await verifyClassPassword(input.password, record.passwordHash ?? ''))) {
throw ApiError.unauthorized('班级码或班级密码错误');
}
const payload: TokenPayload = {
sub: record.id,
username: record.code,
name: record.name,
role: 'STUDENT',
classId: record.id,
classSession: true,
classCode: record.code,
};
const token = signToken(payload);
return {
token,
user: {
id: record.id,
username: record.code,
name: record.name,
role: 'STUDENT',
classId: record.id,
createdAt: new Date().toISOString(),
className: record.name,
grade: record.grade,
classSession: true,
classCode: record.code,
},
};
} }
/** 修改自己的密码 */ /**
export async function changePassword(userId: string, input: ChangePasswordInput): Promise<void> { * 当前用户信息(学生额外带班级名与年级)。
const user = await prisma.user.findUnique({ where: { id: userId } }); * 班级账号返回班级信息(id 即班级 id)。
if (!user) throw ApiError.unauthorized('账号不存在或已被删除'); */
export async function getProfile(user: TokenPayload): Promise<StudentDto> {
if (isClassSession(user)) {
const record = await prisma.class.findUnique({
where: { id: user.classId ?? user.sub },
select: { id: true, name: true, grade: true, code: true, createdAt: true },
});
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
return {
id: record.id,
username: record.code,
name: record.name,
role: 'STUDENT',
classId: record.id,
createdAt: record.createdAt.toISOString(),
className: record.name,
grade: record.grade,
classSession: true,
classCode: record.code,
} as StudentDto & { classSession: boolean; classCode: string };
}
const matched = await verifyPassword(input.currentPassword, user.passwordHash); const user_ = await prisma.user.findUnique({
where: { id: user.sub },
include: { class: { select: { name: true, grade: true } } },
});
if (!user_) throw ApiError.unauthorized('账号不存在或已被删除');
return toStudentDto(user_);
}
/** 修改自己的密码;班级账号修改的是「班级密码」 */
export async function changePassword(user: TokenPayload, input: ChangePasswordInput): Promise<void> {
if (isClassSession(user)) {
await changeClassPassword(user.classId ?? user.sub, input.currentPassword, input.newPassword);
return;
}
const record = await prisma.user.findUnique({ where: { id: user.sub } });
if (!record) throw ApiError.unauthorized('账号不存在或已被删除');
const matched = await verifyPassword(input.currentPassword, record.passwordHash);
if (!matched) throw ApiError.badRequest('当前密码不正确'); if (!matched) throw ApiError.badRequest('当前密码不正确');
await prisma.user.update({ await prisma.user.update({
where: { id: userId }, where: { id: user.sub },
data: { passwordHash: await hashPassword(input.newPassword) }, data: { passwordHash: await hashPassword(input.newPassword) },
}); });
} }
@@ -1,4 +1,4 @@
import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
import { assertCanPublishContent } from '../../lib/access.js'; import { assertCanPublishContent } from '../../lib/access.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
@@ -49,11 +49,14 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
include: { creator: creatorSelect, reads: true }, include: { creator: creatorSelect, reads: true },
}); });
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true }); const dto = toNotificationDto(created, { userIds: [student.id], withStatus: true });
// 班级房间广播:通知中心/其他端都能看到 // 班级房间广播:通知中心/其他端都能看到
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto); emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
// 定向广播给被叫的学生:客户端灵动岛立即展开 // 定向广播给被叫的学生:个人学生端灵动岛立即展开
emitToUser(student.id, SOCKET_EVENTS.callNew, dto); emitToUser(student.id, SOCKET_EVENTS.callNew, dto);
// 同时广播到 classId 对应的 user 房间:班级账号(班级设备)以此房间登录,
// 因此"叫人"消息在班级设备上也会立即展开(学生姓名在标题里,全班都能看到叫谁)
emitToUser(created.classId, SOCKET_EVENTS.callNew, dto);
logger.info(`叫人:${teacherName} → ${studentName}(${message})`); logger.info(`叫人:${teacherName} → ${studentName}(${message})`);
return dto; return dto;
@@ -1,4 +1,4 @@
import { Router } from 'express'; import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js'; import { sendCreated, sendOk } from '../../lib/http.js';
import { idParamSchema } from '../../lib/schemas.js'; import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js'; import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
@@ -11,9 +11,11 @@ import {
classTeacherParamSchema, classTeacherParamSchema,
createClassSchema, createClassSchema,
listClassesQuerySchema, listClassesQuerySchema,
updateClassAccountSchema,
updateClassSchema, updateClassSchema,
type AddStudentInput, type AddStudentInput,
type CreateClassInput, type CreateClassInput,
type UpdateClassAccountInput,
type UpdateClassInput, type UpdateClassInput,
} from './classes.schemas.js'; } from './classes.schemas.js';
import * as classService from './classes.service.js'; import * as classService from './classes.service.js';
@@ -68,6 +70,25 @@ router.delete(
}, },
); );
/**
* PATCH /api/classes/:id/class-account - 设置 / 重置班级账号(班级码 + 班级密码)
* 仅管理员:学生端「班级登录」用的就是这个班级码
*/
router.patch(
'/:id/class-account',
requireRole('ADMIN'),
validate({ params: idParamSchema, body: updateClassAccountSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
sendOk(
res,
await classService.updateClassAccount(user, id, req.body as UpdateClassAccountInput),
'班级账号已更新',
);
},
);
/** GET /api/classes/:id/students - 班级学生名单 */ /** GET /api/classes/:id/students - 班级学生名单 */
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => { router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req); const user = getAuthUser(req);
@@ -6,6 +6,12 @@ export const createClassSchema = z.object({
grade: z.string().trim().min(1, '请输入年级').max(32), grade: z.string().trim().min(1, '请输入年级').max(32),
/** 仅管理员可指定班主任,教师创建时固定为自己 */ /** 仅管理员可指定班主任,教师创建时固定为自己 */
teacherId: z.string().min(1).optional(), teacherId: z.string().min(1).optional(),
/** 可选:自定义班级码(学生端班级账号登录用),留空自动生成 */
code: z
.string()
.trim()
.regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字')
.optional(),
}); });
export const updateClassSchema = z.object({ export const updateClassSchema = z.object({
@@ -13,6 +19,20 @@ export const updateClassSchema = z.object({
grade: z.string().trim().min(1).max(32).optional(), grade: z.string().trim().min(1).max(32).optional(),
}); });
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
export const updateClassAccountSchema = z
.object({
code: z
.string()
.trim()
.regex(/^[A-Za-z0-9]{4,16}$/, '班级码需为 4~16 位字母或数字')
.optional(),
password: z.string().min(6, '班级密码至少 6 位').max(64).optional(),
})
.refine((value) => value.code !== undefined || value.password !== undefined, {
message: '请至少填写班级码或班级密码中的一项',
});
export const listClassesQuerySchema = z.object({ export const listClassesQuerySchema = z.object({
keyword: keywordSchema, keyword: keywordSchema,
}); });
@@ -39,4 +59,5 @@ export const classTeacherParamSchema = z.object({
export type CreateClassInput = z.infer<typeof createClassSchema>; export type CreateClassInput = z.infer<typeof createClassSchema>;
export type UpdateClassInput = z.infer<typeof updateClassSchema>; export type UpdateClassInput = z.infer<typeof updateClassSchema>;
export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
export type AddStudentInput = z.infer<typeof addStudentSchema>; export type AddStudentInput = z.infer<typeof addStudentSchema>;
@@ -1,4 +1,4 @@
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared'; import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
import { env } from '../../config/env.js'; import { env } from '../../config/env.js';
import { import {
assertClassAccess, assertClassAccess,
@@ -8,6 +8,10 @@ import {
classScopeIdFilter, classScopeIdFilter,
resolveClassScope, resolveClassScope,
} from '../../lib/access.js'; } from '../../lib/access.js';
import {
buildClassAccountCreateData,
updateClassAccount as updateClassAccountRecord,
} from '../../lib/class-account.js';
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js'; import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
@@ -24,6 +28,36 @@ const countSelect = {
notifications: true, notifications: true,
} as const; } as const;
/**
* 说明:班级码 code 与班级密码哈希 passwordHash 是 Class 的标量字段,
* 使用 include 查询时会默认返回,无需(也不能)写进 include 里。
* 是否把它们放进 DTO 由 canSeeClassAccount() 决定,哈希永不外泄。
*/
/**
* 是否可以看到该班级的班级账号信息:
* 管理员(管理全部班级)或该班级的班主任(要发给学生用,只读展示)。
* 科任老师与学生都看不到班级码。
*/
function canSeeClassAccount(user: TokenPayload, item: { teacherId: string }): boolean {
if (user.role === 'ADMIN') return true;
return user.role === 'TEACHER' && item.teacherId === user.sub;
}
/** 把班级记录转换为 DTO,并按权限附带班级码 */
function toClassDtoWithAccount(
user: TokenPayload,
item: Parameters<typeof toClassDto>[0] & { code?: string; passwordHash?: string | null },
): ClassDto {
const dto = toClassDto(item);
if (!canSeeClassAccount(user, item)) return dto;
return {
...dto,
code: item.code,
hasPassword: Boolean(item.passwordHash),
};
}
/** 班级列表(按权限过滤) */ /** 班级列表(按权限过滤) */
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> { export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
const scope = await resolveClassScope(user); const scope = await resolveClassScope(user);
@@ -42,7 +76,7 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
orderBy: [{ grade: 'asc' }, { name: 'asc' }], orderBy: [{ grade: 'asc' }, { name: 'asc' }],
}); });
return classes.map(toClassDto); return classes.map((item) => toClassDtoWithAccount(user, item));
} }
/** 班级详情:学生名单 + 课程 + 协作教师 */ /** 班级详情:学生名单 + 课程 + 协作教师 */
@@ -68,7 +102,7 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi
if (!item) throw ApiError.notFound('班级不存在'); if (!item) throw ApiError.notFound('班级不存在');
return { return {
...toClassDto(item), ...toClassDtoWithAccount(user, item),
students: item.students.map((student) => toStudentDto(student)), students: item.students.map((student) => toStudentDto(student)),
courses: item.courses.map(toCourseDto), courses: item.courses.map(toCourseDto),
teachers: item.teachers.map((assignment) => toTeacherBrief(assignment.teacher)), teachers: item.teachers.map((assignment) => toTeacherBrief(assignment.teacher)),
@@ -87,15 +121,47 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
teacherId = teacher.id; teacherId = teacher.id;
} }
// 班级账号:自动生成班级码(也可由管理员指定)+ 默认班级密码
const account = await buildClassAccountCreateData(env.defaultClassPassword, input.code);
const created = await prisma.class.create({ const created = await prisma.class.create({
data: { name: input.name, grade: input.grade, teacherId }, data: {
name: input.name,
grade: input.grade,
teacherId,
code: account.code,
passwordHash: account.passwordHash,
},
include: { include: {
teacher: { select: { id: true, name: true, username: true } }, teacher: { select: { id: true, name: true, username: true } },
_count: { select: countSelect }, _count: { select: countSelect },
}, },
}); });
return toClassDto(created); return toClassDtoWithAccount(user, created);
}
/**
* 设置 / 重置班级账号(班级码 + 班级密码):仅管理员。
* 班级码即学生端"班级登录"的账号;密码留空表示不修改。
*/
export async function updateClassAccount(
user: TokenPayload,
classId: string,
input: { code?: string; password?: string },
): Promise<ClassDto> {
assertCanManageClasses(user);
await updateClassAccountRecord(classId, input);
const updated = await prisma.class.findUnique({
where: { id: classId },
include: {
teacher: { select: { id: true, name: true, username: true } },
_count: { select: countSelect },
},
});
if (!updated) throw ApiError.notFound('班级不存在');
return toClassDtoWithAccount(user, updated);
} }
/** 编辑班级 */ /** 编辑班级 */
@@ -9,6 +9,7 @@ import {
import { prisma } from '../../lib/db.js'; import { prisma } from '../../lib/db.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js'; import { toHomeworkDto, toNotificationDto } from '../../lib/mappers.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
const courseSelect = { select: { id: true, name: true } } as const; const courseSelect = { select: { id: true, name: true } } as const;
const creatorSelect = { select: { id: true, name: true, username: true } } as const; const creatorSelect = { select: { id: true, name: true, username: true } } as const;
@@ -25,6 +26,9 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
const where = classScopeWhere(scope); const where = classScopeWhere(scope);
const classWhere = classScopeIdFilter(scope); const classWhere = classScopeIdFilter(scope);
const now = new Date(); const now = new Date();
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
const [ const [
classCount, classCount,
@@ -47,15 +51,15 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
prisma.homework.count({ where }), prisma.homework.count({ where }),
prisma.notification.count({ where }), prisma.notification.count({ where }),
prisma.grade.count({ where }), prisma.grade.count({ where }),
prisma.notification.count({ where: { ...where, reads: { none: { userId: user.sub } } } }), prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
student student
? prisma.homework.count({ ? prisma.homework.count({
where: { ...where, statuses: { none: { userId: user.sub, completed: true } } }, where: { ...where, statuses: { none: { ...personalWhere, completed: true } } },
}) })
: prisma.homework.count({ where: { ...where, dueAt: { gte: now } } }), : prisma.homework.count({ where: { ...where, dueAt: { gte: now } } }),
prisma.notification.findMany({ prisma.notification.findMany({
where, where,
include: { creator: creatorSelect, reads: { where: { userId: user.sub } } }, include: { creator: creatorSelect, reads: { where: personalWhere } },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 5, take: 5,
}), }),
@@ -64,7 +68,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
include: { include: {
course: courseSelect, course: courseSelect,
creator: creatorSelect, creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true, statuses: student ? { where: personalWhere } : true,
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 5, take: 5,
@@ -74,7 +78,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
include: { include: {
course: courseSelect, course: courseSelect,
creator: creatorSelect, creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true, statuses: student ? { where: personalWhere } : true,
}, },
orderBy: { dueAt: 'asc' }, orderBy: { dueAt: 'asc' },
take: 5, take: 5,
@@ -92,13 +96,13 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
unreadNotificationCount, unreadNotificationCount,
pendingHomeworkCount, pendingHomeworkCount,
recentNotifications: recentNotifications.map((item): NotificationDto => recentNotifications: recentNotifications.map((item): NotificationDto =>
toNotificationDto(item, { userId: user.sub }), toNotificationDto(item, { userIds: personalIds }),
), ),
recentHomeworks: recentHomeworks.map((item): HomeworkDto => recentHomeworks: recentHomeworks.map((item): HomeworkDto =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }), toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
), ),
upcomingDeadlines: upcoming.map((item): HomeworkDto => upcomingDeadlines: upcoming.map((item): HomeworkDto =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }), toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
), ),
}; };
} }
@@ -1,4 +1,4 @@
import { import {
SOCKET_EVENTS, SOCKET_EVENTS,
gradeLevel, gradeLevel,
gradePercent, gradePercent,
@@ -12,6 +12,7 @@ import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { toGradeDto } from '../../lib/mappers.js'; import { toGradeDto } from '../../lib/mappers.js';
import { parseOptionalDate } from '../../lib/schemas.js'; import { parseOptionalDate } from '../../lib/schemas.js';
import { resolvePersonalIds } from '../../lib/session.js';
import { emitToClass, emitToUser } from '../../realtime/bus.js'; import { emitToClass, emitToUser } from '../../realtime/bus.js';
import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js'; import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js';
@@ -27,13 +28,20 @@ export interface ListGradeOptions {
examName?: string; examName?: string;
} }
/** 学生查看自己的成绩 */ /**
* 学生查看成绩。
* - 普通学生账号:只看自己的成绩;
* - 班级账号(班级设备):返回**本班全部学生的成绩总览**(个人学生不再是登录主体,
* 班级设备上一屏展示全班成绩,便于张贴/核对)。
*/
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> { export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
const personalIds = await resolvePersonalIds(user);
const grades = await prisma.grade.findMany({ const grades = await prisma.grade.findMany({
where: { userId: user.sub }, where: { userId: { in: personalIds } },
include, include,
orderBy: [{ publishedAt: 'desc' }], orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
take: 300, take: 500,
}); });
return grades.map(toGradeDto); return grades.map(toGradeDto);
} }
@@ -1,4 +1,4 @@
import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared'; import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared';
import { import {
assertCanPublishContent, assertCanPublishContent,
assertClassAccess, assertClassAccess,
@@ -11,6 +11,7 @@ import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js'; import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
import { parseOptionalDate } from '../../lib/schemas.js'; import { parseOptionalDate } from '../../lib/schemas.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import { emitToClass } from '../../realtime/bus.js'; import { emitToClass } from '../../realtime/bus.js';
import type { import type {
CreateHomeworkInput, CreateHomeworkInput,
@@ -39,6 +40,9 @@ export async function listHomeworks(
): Promise<HomeworkDto[]> { ): Promise<HomeworkDto[]> {
const scope = await resolveClassScope(user, options.classId); const scope = await resolveClassScope(user, options.classId);
const student = isStudent(user); const student = isStudent(user);
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
const personalIds = student ? await resolvePersonalIds(user) : [];
const personalWhere = personalIdWhere(personalIds);
const homeworks = await prisma.homework.findMany({ const homeworks = await prisma.homework.findMany({
where: { where: {
@@ -48,36 +52,41 @@ export async function listHomeworks(
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] } ? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}), : {}),
...(student && options.pendingOnly ...(student && options.pendingOnly
? { statuses: { none: { userId: user.sub, completed: true } } } ? { statuses: { none: { ...personalWhere, completed: true } } }
: {}), : {}),
}, },
include: { include: {
course: courseSelect, course: courseSelect,
creator: creatorSelect, creator: creatorSelect,
statuses: student ? { where: { userId: user.sub } } : true, statuses: student ? { where: personalWhere } : true,
}, },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 200, take: 200,
}); });
return homeworks.map((item) => return homeworks.map((item) =>
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }), toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
); );
} }
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> { export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
const student = isStudent(user);
const personalIds = student ? await resolvePersonalIds(user) : [];
const homework = await prisma.homework.findUnique({ const homework = await prisma.homework.findUnique({
where: { id: homeworkId }, where: { id: homeworkId },
include: { include: {
course: courseSelect, course: courseSelect,
creator: creatorSelect, creator: creatorSelect,
statuses: isStudent(user) ? { where: { userId: user.sub } } : true, statuses: student ? { where: personalIdWhere(personalIds) } : true,
}, },
}); });
if (!homework) throw ApiError.notFound('作业不存在'); if (!homework) throw ApiError.notFound('作业不存在');
await assertClassAccess(user, homework.classId); await assertClassAccess(user, homework.classId);
return toHomeworkDto(homework, { userId: isStudent(user) ? user.sub : null, withStatus: !isStudent(user) }); return toHomeworkDto(homework, {
userIds: student ? personalIds : [],
withStatus: !student,
});
} }
/** 发布作业并实时推送到班级房间 */ /** 发布作业并实时推送到班级房间 */
@@ -146,7 +155,12 @@ export async function deleteHomework(user: TokenPayload, homeworkId: string): Pr
}); });
} }
/** 标记完成 / 取消完成(学生标记自己;教师可代标记) */ /**
* 标记完成 / 取消完成。
* - 普通学生:标记自己;
* - 班级账号(班级设备):为全班学生写入完成状态(代全班操作);
* - 教师 / 管理员:可代指定学生标记。
*/
export async function updateHomeworkStatus( export async function updateHomeworkStatus(
user: TokenPayload, user: TokenPayload,
homeworkId: string, homeworkId: string,
@@ -155,10 +169,36 @@ export async function updateHomeworkStatus(
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } }); const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!homework) throw ApiError.notFound('作业不存在'); if (!homework) throw ApiError.notFound('作业不存在');
const targetUserId = isStudent(user) ? user.sub : (input.userId ?? user.sub);
await assertClassAccess(user, homework.classId); await assertClassAccess(user, homework.classId);
if (!isStudent(user) && input.userId) { if (isStudent(user)) {
const personalIds = await resolvePersonalIds(user);
let result: {
id: string;
homeworkId: string;
userId: string;
completed: boolean;
updatedAt: Date;
} | null = null;
for (const userId of personalIds) {
const status = await prisma.homeworkStatus.upsert({
where: { homeworkId_userId: { homeworkId, userId } },
create: { homeworkId, userId, completed: input.completed },
update: { completed: input.completed },
});
result ??= status;
}
if (!result) throw ApiError.forbidden('当前班级还没有学生账号,无法标记作业完成状态');
const dto = toHomeworkStatusDto(result);
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, { ...dto, classId: homework.classId });
return dto;
}
const targetUserId = input.userId ?? user.sub;
if (input.userId) {
const target = await prisma.user.findUnique({ where: { id: targetUserId }, select: { classId: true } }); const target = await prisma.user.findUnique({ where: { id: targetUserId }, select: { classId: true } });
if (!target || target.classId !== homework.classId) { if (!target || target.classId !== homework.classId) {
throw ApiError.badRequest('目标学生不属于该作业所在班级'); throw ApiError.badRequest('目标学生不属于该作业所在班级');
@@ -1,4 +1,4 @@
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared'; import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { import {
assertClassAccess, assertClassAccess,
assertCanPublishContent, assertCanPublishContent,
@@ -10,6 +10,7 @@ import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js'; import type { TokenPayload } from '../../lib/jwt.js';
import { logger } from '../../lib/logger.js'; import { logger } from '../../lib/logger.js';
import { toNotificationDto } from '../../lib/mappers.js'; import { toNotificationDto } from '../../lib/mappers.js';
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
import { emitToClass } from '../../realtime/bus.js'; import { emitToClass } from '../../realtime/bus.js';
import { computeClassStatus } from '../schedules/schedules.service.js'; import { computeClassStatus } from '../schedules/schedules.service.js';
import type { CreateNotificationInput } from './notifications.schemas.js'; import type { CreateNotificationInput } from './notifications.schemas.js';
@@ -29,6 +30,9 @@ export async function listNotifications(
): Promise<NotificationDto[]> { ): Promise<NotificationDto[]> {
const scope = await resolveClassScope(user, options.classId); const scope = await resolveClassScope(user, options.classId);
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER'; const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
// 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
const notifications = await prisma.notification.findMany({ const notifications = await prisma.notification.findMany({
where: { where: {
@@ -37,17 +41,17 @@ export async function listNotifications(
...(options.keyword ...(options.keyword
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] } ? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
: {}), : {}),
...(options.unreadOnly ? { reads: { none: { userId: user.sub } } } : {}), ...(options.unreadOnly ? { reads: { none: personalWhere } } : {}),
}, },
include: { include: {
creator: creatorSelect, creator: creatorSelect,
reads: isStaff ? true : { where: { userId: user.sub } }, reads: isStaff ? true : { where: personalWhere },
}, },
orderBy: [{ createdAt: 'desc' }], orderBy: [{ createdAt: 'desc' }],
take: 200, take: 200,
}); });
return notifications.map((item) => toNotificationDto(item, { userId: user.sub, withStatus: isStaff })); return notifications.map((item) => toNotificationDto(item, { userIds: personalIds, withStatus: isStaff }));
} }
/** /**
@@ -103,48 +107,72 @@ export async function deleteNotification(user: TokenPayload, notificationId: str
await prisma.notification.delete({ where: { id: notificationId } }); await prisma.notification.delete({ where: { id: notificationId } });
} }
/** 标记单条通知已读 */ /**
* 标记单条通知已读。
* 班级账号:为全班学生写入已读记录(班级设备代全班操作),教师端的已读人数随之更新。
*/
export async function markAsRead( export async function markAsRead(
user: TokenPayload, user: TokenPayload,
notificationId: string, notificationId: string,
): Promise<{ notificationId: string; readAt: string }> { ): Promise<{ notificationId: string; readAt: string; marked: number }> {
const notification = await prisma.notification.findUnique({ where: { id: notificationId } }); const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!notification) throw ApiError.notFound('通知不存在'); if (!notification) throw ApiError.notFound('通知不存在');
await assertClassAccess(user, notification.classId); await assertClassAccess(user, notification.classId);
const read = await prisma.notificationRead.upsert({ const personalIds = await resolvePersonalIds(user);
where: { notificationId_userId: { notificationId, userId: user.sub } }, // 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates,
create: { notificationId, userId: user.sub }, // 直接 createMany 遇到重复会整体失败。
update: {}, const existing = await prisma.notificationRead.findMany({
where: { notificationId, userId: { in: personalIds } },
select: { userId: true },
}); });
const have = new Set(existing.map((item) => item.userId));
const missing = personalIds.filter((userId) => !have.has(userId));
return { notificationId, readAt: read.readAt.toISOString() }; if (missing.length > 0) {
await prisma.notificationRead.createMany({
data: missing.map((userId) => ({ notificationId, userId })),
});
}
return { notificationId, readAt: new Date().toISOString(), marked: missing.length };
} }
/** 全部标为已读(可按班级限定) */ /** 全部标为已读(可按班级限定) */
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> { export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
const scope = await resolveClassScope(user, classId); const scope = await resolveClassScope(user, classId);
const personalIds = await resolvePersonalIds(user);
const personalWhere = personalIdWhere(personalIds);
const pending = await prisma.notification.findMany({ const pending = await prisma.notification.findMany({
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } }, where: { ...classScopeWhere(scope), reads: { none: personalWhere } },
select: { id: true }, select: { id: true },
}); });
let marked = 0;
for (const item of pending) { for (const item of pending) {
await prisma.notificationRead.upsert({ const existing = await prisma.notificationRead.findMany({
where: { notificationId_userId: { notificationId: item.id, userId: user.sub } }, where: { notificationId: item.id, userId: { in: personalIds } },
create: { notificationId: item.id, userId: user.sub }, select: { userId: true },
update: {},
}); });
const have = new Set(existing.map((row) => row.userId));
const missing = personalIds.filter((userId) => !have.has(userId));
if (missing.length === 0) continue;
await prisma.notificationRead.createMany({
data: missing.map((userId) => ({ notificationId: item.id, userId })),
});
marked += missing.length;
} }
return { marked: pending.length }; return { marked };
} }
/** 未读数量(客户端红点) */ /** 未读数量(客户端红点) */
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> { export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
const scope = await resolveClassScope(user, classId); const scope = await resolveClassScope(user, classId);
const personalIds = await resolvePersonalIds(user);
return prisma.notification.count({ return prisma.notification.count({
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } }, where: { ...classScopeWhere(scope), reads: { none: personalIdWhere(personalIds) } },
}); });
} }
+4 -2
View File
@@ -52,9 +52,11 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
return; return;
} }
const payload = verifyToken(token); const payload = verifyToken(token);
// 班级账号(班级设备):socket.data.user.id 用班级 id,据此加入 user:{classId} 房间,
// "叫人"等定向消息因此能直达班级设备(见 calls.service)。
socket.data.user = { socket.data.user = {
id: payload.sub, id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub,
username: payload.username, username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username,
name: payload.name, name: payload.name,
role: payload.role, role: payload.role,
classId: payload.classId, classId: payload.classId,
+33 -1
View File
@@ -64,9 +64,27 @@ export interface LoginRequest {
password: string; password: string;
} }
/**
* 登录会话主体。
* - 普通账号(教师/管理员/个人学生):就是用户本身;
* - 班级账号(班级设备):`classSession = true`,此时 `id` 与 `classId` 都是班级 id,
* `name` 是班级名,个人数据由服务端按"全班"范围读写。
*/
export interface SessionUser extends StudentDto {
classSession?: boolean;
/** 班级账号的班级码(仅班级会话返回) */
classCode?: string;
}
export interface LoginResponse { export interface LoginResponse {
token: string; token: string;
user: UserDto; user: SessionUser;
}
/** 班级账号登录(学生端):班级码 + 班级密码 */
export interface ClassLoginRequest {
code: string;
password: string;
} }
/* ------------------------------------------------------------------ 班级 */ /* ------------------------------------------------------------------ 班级 */
@@ -82,6 +100,10 @@ export interface ClassDto {
courseCount?: number; courseCount?: number;
homeworkCount?: number; homeworkCount?: number;
notificationCount?: number; notificationCount?: number;
/** 班级码(学生端班级账号登录用);仅对有管理权限的角色返回 */
code?: string;
/** 是否已设置班级密码(哈希永不外泄) */
hasPassword?: boolean;
} }
export interface ClassTeacherBrief { export interface ClassTeacherBrief {
@@ -99,6 +121,8 @@ export interface ClassDetailDto extends ClassDto {
export interface CreateClassRequest { export interface CreateClassRequest {
name: string; name: string;
grade: string; grade: string;
/** 可选:自定义班级码,留空自动生成 */
code?: string;
} }
export interface UpdateClassRequest { export interface UpdateClassRequest {
@@ -106,6 +130,14 @@ export interface UpdateClassRequest {
grade?: string; grade?: string;
} }
/** 管理员设置/重置班级账号(班级码 + 班级密码) */
export interface UpdateClassAccountRequest {
/** 新的班级码(4~16 位字母数字,留空表示不改) */
code?: string;
/** 新的班级密码(留空表示不改) */
password?: string;
}
/* ------------------------------------------------------------------ 课程 */ /* ------------------------------------------------------------------ 课程 */
export interface CourseDto { export interface CourseDto {
+4
View File
@@ -26,6 +26,7 @@ import {
type TimeLayoutDto, type TimeLayoutDto,
type TimeLayoutImportResult, type TimeLayoutImportResult,
type TimeLayoutParsePreview, type TimeLayoutParsePreview,
type UpdateClassAccountRequest,
type UpdateClassRequest, type UpdateClassRequest,
type UserDto, type UserDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
@@ -63,6 +64,9 @@ export const classApi = {
api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }), api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }),
removeTeacher: (id: string, teacherId: string): Promise<unknown> => removeTeacher: (id: string, teacherId: string): Promise<unknown> =>
api.delete(`${API_PATHS.classes}/${id}/teachers/${teacherId}`), api.delete(`${API_PATHS.classes}/${id}/teachers/${teacherId}`),
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
updateAccount: (id: string, payload: UpdateClassAccountRequest): Promise<ClassDto> =>
api.patch(`${API_PATHS.classes}/${id}/class-account`, payload),
}; };
/* ------------------------------------------------------------------ 课程 */ /* ------------------------------------------------------------------ 课程 */
+121 -3
View File
@@ -8,6 +8,7 @@ import {
type ClassDto, type ClassDto,
type CourseDto, type CourseDto,
type StudentDto, type StudentDto,
type UpdateClassAccountRequest,
type UserDto, type UserDto,
} from '@classhelper/shared'; } from '@classhelper/shared';
import { classApi, courseApi, teacherApi } from '@/api'; import { classApi, courseApi, teacherApi } from '@/api';
@@ -43,7 +44,7 @@ async function loadClasses(): Promise<void> {
const formVisible = ref(false); const formVisible = ref(false);
const formRef = ref<FormInstance>(); const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null); const editingId = ref<string | null>(null);
const form = reactive({ name: '', grade: '' }); const form = reactive({ name: '', grade: '', code: '' });
const rules: FormRules = { const rules: FormRules = {
name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }], name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }], grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
@@ -53,6 +54,7 @@ function openCreate(): void {
editingId.value = null; editingId.value = null;
form.name = ''; form.name = '';
form.grade = ''; form.grade = '';
form.code = '';
formVisible.value = true; formVisible.value = true;
} }
@@ -60,6 +62,7 @@ function openEdit(row: ClassDto): void {
editingId.value = row.id; editingId.value = row.id;
form.name = row.name; form.name = row.name;
form.grade = row.grade; form.grade = row.grade;
form.code = '';
formVisible.value = true; formVisible.value = true;
} }
@@ -71,13 +74,69 @@ async function submitForm(): Promise<void> {
await classApi.update(editingId.value, { name: form.name, grade: form.grade }); await classApi.update(editingId.value, { name: form.name, grade: form.grade });
ElMessage.success('班级已更新'); ElMessage.success('班级已更新');
} else { } else {
await classApi.create({ name: form.name, grade: form.grade }); await classApi.create({
name: form.name,
grade: form.grade,
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
});
ElMessage.success('班级创建成功'); ElMessage.success('班级创建成功');
} }
formVisible.value = false; formVisible.value = false;
await loadClasses(); await loadClasses();
} }
/* ------------------------------------------------------------ 班级账号(班级码 + 班级密码) */
const accountVisible = ref(false);
const accountSaving = ref(false);
const accountTarget = ref<ClassDto | null>(null);
const accountForm = reactive({ code: '', password: '' });
/** 打开班级账号设置:班级码即学生端「班级登录」的账号 */
function openAccount(row: ClassDto): void {
accountTarget.value = row;
accountForm.code = row.code ?? '';
accountForm.password = '';
accountVisible.value = true;
}
/** 一键生成一个易读的班级码(避开 0/O/1/I 等易混字符) */
function suggestCode(): void {
const alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
let body = '';
for (let index = 0; index < 6; index += 1) {
body += alphabet[Math.floor(Math.random() * alphabet.length)];
}
accountForm.code = body;
}
async function submitAccount(): Promise<void> {
const target = accountTarget.value;
if (!target) return;
if (!accountForm.code.trim() && !accountForm.password) {
ElMessage.warning('请填写新的班级码或班级密码');
return;
}
accountSaving.value = true;
try {
const payload: UpdateClassAccountRequest = {};
if (accountForm.code.trim() && accountForm.code.trim() !== target.code) {
payload.code = accountForm.code.trim().toUpperCase();
}
if (accountForm.password) payload.password = accountForm.password;
const updated = await classApi.updateAccount(target.id, payload);
ElMessage.success(
`班级账号已更新:班级码 ${updated.code ?? '-'}${accountForm.password ? ',班级密码已重置' : ''}`,
);
accountVisible.value = false;
await loadClasses();
} finally {
accountSaving.value = false;
}
}
async function removeClass(row: ClassDto): Promise<void> { async function removeClass(row: ClassDto): Promise<void> {
await ElMessageBox.confirm( await ElMessageBox.confirm(
`删除「${row.name}」将同时删除其课表、作业、通知与成绩,确认继续?`, `删除「${row.name}」将同时删除其课表、作业、通知与成绩,确认继续?`,
@@ -267,12 +326,24 @@ onUnmounted(() => {
<el-table-column label="通知" width="80"> <el-table-column label="通知" width="80">
<template #default="{ row }">{{ row.notificationCount ?? 0 }}</template> <template #default="{ row }">{{ row.notificationCount ?? 0 }}</template>
</el-table-column> </el-table-column>
<el-table-column label="班级账号(学生端登录)" width="220">
<template #default="{ row }">
<template v-if="row.code">
<el-tag type="info" effect="plain">{{ row.code }}</el-tag>
<el-tag :type="row.hasPassword ? 'success' : 'warning'" size="small" class="account-tag">
{{ row.hasPassword ? '已设密码' : '未设密码' }}
</el-tag>
</template>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="创建时间" width="170"> <el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template> <template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column> </el-table-column>
<el-table-column label="操作" width="230" fixed="right"> <el-table-column label="操作" width="310" fixed="right">
<template #default="{ row }"> <template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button> <el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button v-if="isAdmin" link type="warning" @click="openAccount(row)">班级账号</el-button>
<el-button v-if="isAdmin" link type="primary" @click="openEdit(row)">编辑</el-button> <el-button v-if="isAdmin" link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button v-if="isAdmin" link type="danger" @click="removeClass(row)">删除</el-button> <el-button v-if="isAdmin" link type="danger" @click="removeClass(row)">删除</el-button>
</template> </template>
@@ -280,6 +351,38 @@ onUnmounted(() => {
</el-table> </el-table>
</el-card> </el-card>
<!-- 班级账号:班级码 + 班级密码(学生端「班级登录」凭据) -->
<el-dialog v-model="accountVisible" title="班级账号(学生端登录)" width="460px">
<el-alert type="info" :closable="false" show-icon class="mb-12">
<template #title>
学生端以「班级」为主体登录:班级码 + 班级密码。登录后本机代表整个班级,
作业完成、通知已读都会按全班记录。
</template>
</el-alert>
<el-form label-width="90px">
<el-form-item label="班级">
<el-input :model-value="accountTarget?.name ?? ''" disabled />
</el-form-item>
<el-form-item label="班级码">
<el-input v-model="accountForm.code" placeholder="4~16 位字母或数字" style="width: 220px" />
<el-button link type="primary" class="ml-8" @click="suggestCode">随机生成</el-button>
</el-form-item>
<el-form-item label="新密码">
<el-input
v-model="accountForm.password"
type="password"
show-password
placeholder="留空表示不修改(至少 6 位)"
style="width: 220px"
/>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="accountVisible = false">取消</el-button>
<el-button type="primary" :loading="accountSaving" @click="submitAccount">保存</el-button>
</template>
</el-dialog>
<!-- 新建 / 编辑 --> <!-- 新建 / 编辑 -->
<el-dialog v-model="formVisible" :title="editingId ? '编辑班级' : '新建班级'" width="420px"> <el-dialog v-model="formVisible" :title="editingId ? '编辑班级' : '新建班级'" width="420px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="80px"> <el-form ref="formRef" :model="form" :rules="rules" label-width="80px">
@@ -289,6 +392,9 @@ onUnmounted(() => {
<el-form-item label="年级" prop="grade"> <el-form-item label="年级" prop="grade">
<el-input v-model="form.grade" placeholder="例如 高一" /> <el-input v-model="form.grade" placeholder="例如 高一" />
</el-form-item> </el-form-item>
<el-form-item v-if="!editingId" label="班级码">
<el-input v-model="form.code" placeholder="留空自动生成(4~16 位字母数字)" />
</el-form-item>
</el-form> </el-form>
<template #footer> <template #footer>
<el-button @click="formVisible = false">取消</el-button> <el-button @click="formVisible = false">取消</el-button>
@@ -393,3 +499,15 @@ onUnmounted(() => {
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
<style scoped>
.account-tag {
margin-left: 6px;
}
.mb-12 {
margin-bottom: 12px;
}
.ml-8 {
margin-left: 8px;
}
</style>
+2 -1
View File
@@ -375,7 +375,8 @@ function writeReadme() {
二、默认账号(首次安装后请立即修改密码) 二、默认账号(首次安装后请立即修改密码)
管理员 admin / admin123 管理员 admin / admin123
教师 teacher1 / teacher123、teacher2 / teacher123 教师 teacher1 / teacher123、teacher2 / teacher123
学生 student01 ~ student15 / student123(桌面客户端使用) 班级 班级码 G101/G102/G203 + 班级密码 123456(学生端桌面客户端使用)
个人 student01 ~ student15 / student123(个人学生账号,接口向后兼容)
三、目录说明 三、目录说明
server\\ 后端程序(server\\dist\\index.js 为入口) server\\ 后端程序(server\\dist\\index.js 为入口)