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:
1 parent
8b85079d81
commit
1073436ad6
38 files changed
+1263
-131
No files matched your search
@@ -163,7 +163,7 @@ pnpm build: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`),
|
||||
客户端会在顶部提示「当前处于离线状态」,页面继续显示本地缓存数据;重新启动后端后会自动同步。
|
||||
|
||||
@@ -171,15 +171,15 @@ pnpm verify:desktop
|
||||
|
||||
```bash
|
||||
# 另开一个终端先启动后端: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:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴、个性化与托盘):40 项
|
||||
```
|
||||
|
||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚**等 107 项,
|
||||
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作**等 125 项,
|
||||
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
|
||||
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
|
||||
(登录 + 四类数据 + Socket.IO),实测 26/26 通过。
|
||||
(**班级账号登录** + 四类数据 + Socket.IO),实测 40/40 通过。
|
||||
|
||||
## 默认账号与种子数据
|
||||
|
||||
@@ -187,7 +187,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
||||
15 课程 / 90 条课表 / 15 份作业 / 12 条通知 / 90 条成绩)。
|
||||
|
||||
| 角色 | 用户名 | 密码 | 说明 |
|
||||
| ------ | ------------------------- | ------------ | ------------------------------------------ |
|
||||
| ------ | ------------------------- | ------------ | ------------------------------------------- |
|
||||
| 管理员 | `admin` | `admin123` | 可访问全部班级 |
|
||||
| 教师 | `teacher1` | `teacher123` | 张老师:高一(1)班、高二(3)班班主任 |
|
||||
| 教师 | `teacher2` | `teacher123` | 李老师:高一(2)班班主任,高二(3)班协作教师 |
|
||||
@@ -195,9 +195,15 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
|
||||
| 学生 | `student02` … `student05` | `student123` | 高一(1)班 |
|
||||
| 学生 | `student06` … `student10` | `student123` | 高一(2)班 |
|
||||
| 学生 | `student11` … `student15` | `student123` | 高二(3)班 |
|
||||
| 班级 | 班级码 `G101` | `123456` | 高一(1)班(**学生端班级登录**,非个人账号) |
|
||||
| 班级 | 班级码 `G102` | `123456` | 高一(2)班 |
|
||||
| 班级 | 班级码 `G203` | `123456` | 高二(3)班 |
|
||||
|
||||
- 学生初始密码可用 `DEFAULT_STUDENT_PASSWORD` 配置(默认 `123456`),
|
||||
教师也可在「学生管理」里一键重置为默认密码。
|
||||
- **班级账号**(学生端主入口)用班级码 + 班级密码登录,默认密码由 `DEFAULT_CLASS_PASSWORD` 配置(默认 `123456`);
|
||||
管理员可在「班级管理 → 班级账号」里改班级码或重置密码,详见
|
||||
[学生端班级账号](#学生端班级账号学生端主体--班级)。
|
||||
- 教学周由 `TERM_START_DATE`(第 1 教学周的周一)换算,当前周次用于课表默认视图。
|
||||
|
||||
## 常用命令
|
||||
@@ -531,6 +537,49 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
|
||||
回归测试:`verify:e2e` 覆盖 409 拦截 / 二次确认后 201 / 普通通知不受限(用 `at` 固定时刻,结果可复现);
|
||||
`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 时间配置)
|
||||
|
||||
三条导入链路都在服务端完成解析、校验与去重,前端只负责收集文件与展示结果,
|
||||
@@ -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` | 登录 | 当前用户(学生附带班级/年级) |
|
||||
| PATCH | `/auth/password` | 登录 | 修改自己的密码 |
|
||||
| POST | `/auth/logout` | 登录 | 退出(无状态,客户端丢弃 token) |
|
||||
| GET | `/classes` | 登录 | 班级列表(按权限收敛) |
|
||||
| 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` | 班级可见 / 可写 | 学生名单 / 添加学生(已存在账号直接转入) |
|
||||
| DELETE | `/classes/:id/students/:userId` | 教师/管理员 | 移出学生 |
|
||||
| POST / DELETE | `/classes/:id/teachers[/:teacherId]` | 教师/管理员 | 分配 / 取消协作教师 |
|
||||
@@ -732,7 +783,7 @@ pnpm db:migrate && pnpm db:seed
|
||||
|
||||
数据库表:`User` / `Class` / `ClassTeacher` / `Enrollment` / `Course` / `Schedule` /
|
||||
`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** |
|
||||
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
|
||||
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
|
||||
@@ -764,6 +815,9 @@ pnpm db:migrate && pnpm db:seed
|
||||
| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 |
|
||||
| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 |
|
||||
| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 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`(见下表) |
|
||||
| 提供完整 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:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **40/40** ✅ |
|
||||
| `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/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 提示)。
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
"helmet": "^8.3.0",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"xlsx": "0.18.5",
|
||||
"zod": "^4.6.2"
|
||||
}
|
||||
}
|
||||
@@ -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, ['.'], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
@@ -64,6 +115,8 @@ const child = spawn(electronPath, ['.'], {
|
||||
ELECTRON_SMOKE_TEST: '1',
|
||||
ELECTRON_SMOKE_ONLINE: online,
|
||||
ELECTRON_SMOKE_PROFILE: profileDir,
|
||||
ELECTRON_SMOKE_CLASS_CODE: classCredentials?.code ?? '',
|
||||
ELECTRON_SMOKE_CLASS_PASSWORD: classCredentials?.password ?? '',
|
||||
}),
|
||||
});
|
||||
|
||||
|
||||
@@ -908,10 +908,13 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
|
||||
// 可选:对真实后端做联网集成自检(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(
|
||||
`(async () => {
|
||||
if (!window.__classhelperSmoke__) return { ok: false, detail: '渲染进程未注册冒烟钩子' };
|
||||
return await window.__classhelperSmoke__.onlineScenario();
|
||||
return await window.__classhelperSmoke__.onlineScenario(${JSON.stringify({ code: classCode, password: classPassword })});
|
||||
})()`,
|
||||
);
|
||||
record(
|
||||
|
||||
@@ -17,7 +17,11 @@ import { api } from './http.js';
|
||||
|
||||
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
|
||||
export const authApi = {
|
||||
/** 个人账号登录(教师/管理员/个人学生;客户端界面已不再展示,保留以兼容旧流程与排障) */
|
||||
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, {}),
|
||||
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
|
||||
};
|
||||
|
||||
+12
-3
@@ -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';
|
||||
|
||||
export interface SmokeCheckResult {
|
||||
@@ -127,7 +127,10 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
|
||||
* 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
|
||||
* 覆盖渲染进程里实际使用的 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([
|
||||
import('../stores/auth.js'),
|
||||
import('../stores/app.js'),
|
||||
@@ -144,8 +147,14 @@ export async function onlineScenario(): Promise<SmokeCheckResult> {
|
||||
const detail: string[] = [];
|
||||
|
||||
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(`classSession=${auth.isClassSession}`);
|
||||
|
||||
// 进入主布局(后续的侧边栏点击测试依赖布局已挂载)
|
||||
const { router } = await import('../router/index.js');
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed, ref } from 'vue';
|
||||
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 { setApiBaseUrl, setTokenProvider } from '../api/http.js';
|
||||
import { cacheGet, cacheSet } from '../cache/db.js';
|
||||
@@ -15,13 +15,17 @@ const PROFILE_KEY = 'me';
|
||||
*/
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const token = ref<string | null>(null);
|
||||
const user = ref<StudentDto | null>(null);
|
||||
const user = ref<SessionUser | null>(null);
|
||||
const loading = ref(false);
|
||||
const offlineSession = ref(false);
|
||||
|
||||
const isAuthenticated = computed(() => Boolean(token.value));
|
||||
const displayName = computed(() => user.value?.name ?? '未登录');
|
||||
const classId = computed(() => user.value?.classId ?? null);
|
||||
/** true 表示当前是「班级账号(班级设备)」会话:个人数据按全班读写 */
|
||||
const isClassSession = computed(() => user.value?.classSession === true);
|
||||
/** 班级码(登录账号,便于设置页展示) */
|
||||
const classCode = computed(() => user.value?.classCode ?? '');
|
||||
|
||||
setTokenProvider(() => token.value);
|
||||
|
||||
@@ -39,20 +43,26 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
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);
|
||||
if (!normalized) throw new Error('服务器地址格式不正确,例如 http://127.0.0.1:4000');
|
||||
|
||||
setApiBaseUrl(normalized);
|
||||
loading.value = true;
|
||||
try {
|
||||
const payload: LoginResponse = await authApi.login({ username, password });
|
||||
const payload: LoginResponse = await authApi.classLogin({ code, password });
|
||||
applyToken(payload.token);
|
||||
user.value = payload.user as StudentDto;
|
||||
user.value = payload.user;
|
||||
offlineSession.value = false;
|
||||
await cacheSet('profile', PROFILE_KEY, user.value);
|
||||
await persistSession(normalized, username, payload.token);
|
||||
await persistSession(normalized, code, payload.token);
|
||||
} finally {
|
||||
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;
|
||||
|
||||
// 服务器刷新放到后台,绝不阻塞启动:离线时也能立刻进入界面看缓存数据
|
||||
@@ -122,6 +132,8 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
isAuthenticated,
|
||||
displayName,
|
||||
classId,
|
||||
isClassSession,
|
||||
classCode,
|
||||
login,
|
||||
restore,
|
||||
refreshProfile,
|
||||
|
||||
@@ -12,9 +12,12 @@ import {
|
||||
import { gradeApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
import { useAppStore } from '../stores/app.js';
|
||||
import { useAuthStore } from '../stores/auth.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
|
||||
const appStore = useAppStore();
|
||||
/** 班级账号(班级设备)看到的是全班成绩总览,标题与副标题随会话类型变化 */
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
const loading = ref(false);
|
||||
@@ -130,9 +133,10 @@ onUnmounted(() => {
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">我的成绩</h2>
|
||||
<h2 class="page-title">{{ auth.isClassSession ? '本班成绩' : '我的成绩' }}</h2>
|
||||
<p class="page-subtitle">
|
||||
共 {{ grades.length }} 条记录 · 平均得分率 {{ averagePercent }}%
|
||||
<span v-if="auth.isClassSession">· 班级账号:显示全班成绩总览</span>
|
||||
<el-tag v-if="fromCache" size="small" type="warning" effect="plain">离线缓存</el-tag>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,15 @@ async function toggleComplete(item: HomeworkDto | null): Promise<void> {
|
||||
completed: next,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
ElMessage.success(next ? '已标记为完成' : '已取消完成标记');
|
||||
ElMessage.success(
|
||||
auth.isClassSession
|
||||
? next
|
||||
? '已按全班标记为完成'
|
||||
: '已取消全班的完成标记'
|
||||
: next
|
||||
? '已标记为完成'
|
||||
: '已取消完成标记',
|
||||
);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '提交失败');
|
||||
} finally {
|
||||
@@ -170,7 +178,15 @@ onUnmounted(() => {
|
||||
:loading="submitting"
|
||||
@click.stop="toggleComplete(row)"
|
||||
>
|
||||
{{ row.completed ? '取消完成' : '标记完成' }}
|
||||
{{
|
||||
row.completed
|
||||
? auth.isClassSession
|
||||
? '取消全班完成'
|
||||
: '取消完成'
|
||||
: auth.isClassSession
|
||||
? '全班标记完成'
|
||||
: '标记完成'
|
||||
}}
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
@@ -213,7 +229,9 @@ onUnmounted(() => {
|
||||
:disabled="appStore.offline"
|
||||
@click="toggleComplete(current)"
|
||||
>
|
||||
{{ current.completed ? '取消完成标记' : '标记为已完成' }}
|
||||
{{
|
||||
current.completed ? '取消完成标记' : auth.isClassSession ? '全班标记为已完成' : '标记为已完成'
|
||||
}}
|
||||
</el-button>
|
||||
<span v-if="appStore.offline" class="text-muted" style="margin-left: 10px">离线状态下不可提交</span>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@ const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
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 testResult = ref<'ok' | 'fail' | null>(null);
|
||||
|
||||
@@ -29,7 +29,7 @@ const rules: FormRules = {
|
||||
trigger: 'blur',
|
||||
},
|
||||
],
|
||||
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入班级码', trigger: 'blur' }],
|
||||
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
|
||||
};
|
||||
|
||||
@@ -38,7 +38,7 @@ onMounted(async () => {
|
||||
if (window.desktop) {
|
||||
const config = await window.desktop.getConfig();
|
||||
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;
|
||||
|
||||
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);
|
||||
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
|
||||
ElMessage.success(`欢迎回来,${auth.displayName}`);
|
||||
ElMessage.success(`已进入 ${auth.displayName}`);
|
||||
await router.replace('/schedule');
|
||||
} catch (error) {
|
||||
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>
|
||||
<div>
|
||||
<h1 class="login-title">班级小助手</h1>
|
||||
<p class="login-sub">学生客户端 · 登录后实时接收课表、作业、通知与成绩</p>
|
||||
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -105,19 +105,24 @@ async function enterOffline(): Promise<void> {
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="用户名" prop="username">
|
||||
<el-input v-model="form.username" placeholder="学号 / 用户名" clearable @keyup.enter="submit">
|
||||
<el-form-item label="班级码" prop="code">
|
||||
<el-input
|
||||
v-model="form.code"
|
||||
placeholder="例如 G101(由老师在班级管理中提供)"
|
||||
clearable
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
<template #prefix>
|
||||
<el-icon><User /></el-icon>
|
||||
<el-icon><School /></el-icon>
|
||||
</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
|
||||
<el-form-item label="密码" prop="password">
|
||||
<el-form-item label="班级密码" prop="password">
|
||||
<el-input
|
||||
v-model="form.password"
|
||||
type="password"
|
||||
placeholder="密码"
|
||||
placeholder="班级密码"
|
||||
show-password
|
||||
@keyup.enter="submit"
|
||||
>
|
||||
@@ -156,9 +161,9 @@ async function enterOffline(): Promise<void> {
|
||||
</el-button>
|
||||
|
||||
<p class="login-tip">
|
||||
演示账号:student01 / student123(高一(1)班)
|
||||
班级码与班级密码由管理员在 Web 管理端「班级管理 → 班级账号」中设置或重置。
|
||||
<br />
|
||||
数据来源:教师或管理员在 Web 管理端发布的内容。
|
||||
登录后本机即代表整个班级:作业完成、通知已读都会按全班记录。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+4
-1
@@ -86,7 +86,10 @@ declare global {
|
||||
cacheSelfTest(): Promise<{ ok: boolean; detail: string }>;
|
||||
offlineScenario(): 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 → 灵动岛 */
|
||||
islandRealtimeScenario(): Promise<{
|
||||
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");
|
||||
@@ -54,6 +54,10 @@ model Class {
|
||||
name String
|
||||
grade String
|
||||
teacherId String
|
||||
/// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写)
|
||||
code String @unique
|
||||
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录
|
||||
passwordHash String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
|
||||
@@ -84,10 +84,12 @@ async function main(): Promise<void> {
|
||||
const random = createRandom(20260901);
|
||||
|
||||
// 同一角色共用密码,只需各哈希一次
|
||||
const [adminHash, teacherHash, studentHash] = await Promise.all([
|
||||
const [adminHash, teacherHash, studentHash, classPasswordHash] = await Promise.all([
|
||||
hashPassword('admin123'),
|
||||
hashPassword('teacher123'),
|
||||
hashPassword('student123'),
|
||||
// 班级账号默认密码(学生端「班级登录」)
|
||||
hashPassword('123456'),
|
||||
]);
|
||||
|
||||
const admin = await prisma.user.create({
|
||||
@@ -101,16 +103,23 @@ async function main(): Promise<void> {
|
||||
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
|
||||
});
|
||||
|
||||
// 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
|
||||
const classSeeds = [
|
||||
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id },
|
||||
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id },
|
||||
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id },
|
||||
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' },
|
||||
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' },
|
||||
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' },
|
||||
];
|
||||
|
||||
const classes = [];
|
||||
for (const [index, seed] of classSeeds.entries()) {
|
||||
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' });
|
||||
}
|
||||
|
||||
@@ -1234,6 +1234,246 @@ async function main() {
|
||||
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();
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
@@ -28,6 +28,8 @@ const envSchema = z.object({
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
|
||||
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
|
||||
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
|
||||
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
|
||||
TERM_START_DATE: z.string().default(''),
|
||||
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
|
||||
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
|
||||
@@ -139,6 +141,8 @@ export const env = {
|
||||
bcryptRounds: raw.BCRYPT_ROUNDS,
|
||||
/** 新增学生时未指定密码时的初始密码 */
|
||||
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
|
||||
/** 新建班级时的默认班级密码(班级账号登录) */
|
||||
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
|
||||
termStartDate: raw.TERM_START_DATE,
|
||||
logLevel: raw.LOG_LEVEL,
|
||||
trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
|
||||
|
||||
@@ -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) },
|
||||
});
|
||||
}
|
||||
@@ -28,6 +28,8 @@ export type ApiErrorCode =
|
||||
| 'IMPORT_FORMAT_INVALID'
|
||||
| 'IMPORT_INVALID'
|
||||
| 'IMPORT_EMPTY'
|
||||
/** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */
|
||||
| 'CLASS_PASSWORD_NOT_SET'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
||||
|
||||
@@ -11,6 +11,10 @@ export interface TokenPayload {
|
||||
name: string;
|
||||
role: UserRole;
|
||||
classId: string | null;
|
||||
/** true 表示"班级账号(班级设备)"登录:sub 是班级 id,而非某个学生账号 */
|
||||
classSession?: boolean;
|
||||
/** 班级账号的班级码(便于日志/审计) */
|
||||
classCode?: string;
|
||||
}
|
||||
|
||||
export function signToken(payload: TokenPayload): string {
|
||||
@@ -37,6 +41,8 @@ export function verifyToken(token: string): TokenPayload {
|
||||
name: String(record.name ?? ''),
|
||||
role,
|
||||
classId: record.classId ? String(record.classId) : null,
|
||||
classSession: record.classSession === true,
|
||||
classCode: record.classCode ? String(record.classCode) : undefined,
|
||||
};
|
||||
} catch {
|
||||
throw ApiError.unauthorized('登录状态已失效,请重新登录');
|
||||
|
||||
@@ -25,6 +25,16 @@ import type {
|
||||
const toIso = (value: Date): string => value.toISOString();
|
||||
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 {
|
||||
id: string;
|
||||
username: string;
|
||||
@@ -200,14 +210,16 @@ export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto
|
||||
|
||||
/**
|
||||
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
|
||||
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找完成状态
|
||||
* @param options.withStatus 是否统计完成人数(教师视角)
|
||||
*/
|
||||
export function toHomeworkDto(
|
||||
item: HomeworkLike,
|
||||
options: { userId?: string | null; withStatus?: boolean } = {},
|
||||
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
|
||||
): HomeworkDto {
|
||||
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 {
|
||||
id: item.id,
|
||||
@@ -248,12 +260,18 @@ export interface NotificationLike {
|
||||
reads?: NotificationReadLike[];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param options.userId 指定当前用户(普通学生账号)
|
||||
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找已读状态
|
||||
* @param options.withStatus 是否统计已读人数(教师视角)
|
||||
*/
|
||||
export function toNotificationDto(
|
||||
item: NotificationLike,
|
||||
options: { userId?: string | null; withStatus?: boolean } = {},
|
||||
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
|
||||
): NotificationDto {
|
||||
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 {
|
||||
id: item.id,
|
||||
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -15,6 +15,31 @@ export function authenticate(): RequestHandler {
|
||||
if (!token) throw ApiError.unauthorized('缺少 Authorization 头');
|
||||
|
||||
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({
|
||||
where: { id: payload.sub },
|
||||
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 {
|
||||
changePasswordSchema,
|
||||
classLoginSchema,
|
||||
loginSchema,
|
||||
type ChangePasswordInput,
|
||||
type ClassLoginInput,
|
||||
type LoginInput,
|
||||
} from './auth.schemas.js';
|
||||
import * as authService from './auth.service.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
/** POST /api/auth/login - 登录(公开) */
|
||||
/** POST /api/auth/login - 登录(公开):教师 / 管理员 / 个人学生账号 */
|
||||
router.post('/login', validate({ body: loginSchema }), async (req, res) => {
|
||||
const result: LoginResponse = await authService.login(req.body as LoginInput);
|
||||
sendOk(res, result, '登录成功');
|
||||
});
|
||||
|
||||
/** GET /api/auth/me - 当前用户信息 */
|
||||
router.get('/me', authenticate(), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await authService.getProfile(user.sub), '获取当前用户成功');
|
||||
/**
|
||||
* POST /api/auth/class-login - 班级账号登录(公开,学生端主入口)
|
||||
*
|
||||
* 班级码 + 班级密码 → 以「班级」为主体的 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) => {
|
||||
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 }, '密码修改成功,请重新登录');
|
||||
});
|
||||
|
||||
|
||||
@@ -5,10 +5,17 @@ export const loginSchema = z.object({
|
||||
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({
|
||||
currentPassword: z.string().min(1, '请输入当前密码'),
|
||||
newPassword: z.string().min(6, '新密码至少 6 位').max(128),
|
||||
});
|
||||
|
||||
export type LoginInput = z.infer<typeof loginSchema>;
|
||||
export type ClassLoginInput = z.infer<typeof classLoginSchema>;
|
||||
export type ChangePasswordInput = z.infer<typeof changePasswordSchema>;
|
||||
@@ -1,12 +1,14 @@
|
||||
import type { LoginResponse, StudentDto, UserRole } from '@classhelper/shared';
|
||||
import { changeClassPassword, findClassForLogin, verifyClassPassword } from '../../lib/class-account.js';
|
||||
import { prisma } from '../../lib/db.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 { 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> {
|
||||
const user = await prisma.user.findUnique({ where: { username: input.username } });
|
||||
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);
|
||||
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
|
||||
|
||||
const token = signToken({
|
||||
const payload: TokenPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: user.classId,
|
||||
});
|
||||
};
|
||||
const token = signToken(payload);
|
||||
|
||||
return { token, user: toUserDto(user) };
|
||||
}
|
||||
|
||||
/** 当前用户信息(学生额外带班级名与年级) */
|
||||
export async function getProfile(userId: string): Promise<StudentDto> {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
});
|
||||
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
|
||||
return toStudentDto(user);
|
||||
/**
|
||||
* 班级账号登录(学生端):班级码 + 班级密码。
|
||||
*
|
||||
* 签发的 JWT 以**班级**为主体:`sub` 与 `classId` 都是班级 id,并带 `classSession: true`,
|
||||
* 后续个人数据(作业完成、通知已读)由服务端按"全班"范围读写,个人学生不再是登录主体。
|
||||
*/
|
||||
export async function classLogin(input: ClassLoginInput): Promise<LoginResponse> {
|
||||
const record = await findClassForLogin(input.code);
|
||||
|
||||
if (!(await verifyClassPassword(input.password, record.passwordHash ?? ''))) {
|
||||
throw ApiError.unauthorized('班级码或班级密码错误');
|
||||
}
|
||||
|
||||
/** 修改自己的密码 */
|
||||
export async function changePassword(userId: string, input: ChangePasswordInput): Promise<void> {
|
||||
const user = await prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user) 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);
|
||||
|
||||
const matched = await verifyPassword(input.currentPassword, user.passwordHash);
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户信息(学生额外带班级名与年级)。
|
||||
* 班级账号返回班级信息(id 即班级 id)。
|
||||
*/
|
||||
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 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('当前密码不正确');
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
where: { id: user.sub },
|
||||
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 { prisma } from '../../lib/db.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 },
|
||||
});
|
||||
|
||||
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);
|
||||
// 定向广播给被叫的学生:客户端灵动岛立即展开
|
||||
// 定向广播给被叫的学生:个人学生端灵动岛立即展开
|
||||
emitToUser(student.id, SOCKET_EVENTS.callNew, dto);
|
||||
// 同时广播到 classId 对应的 user 房间:班级账号(班级设备)以此房间登录,
|
||||
// 因此"叫人"消息在班级设备上也会立即展开(学生姓名在标题里,全班都能看到叫谁)
|
||||
emitToUser(created.classId, SOCKET_EVENTS.callNew, dto);
|
||||
|
||||
logger.info(`叫人:${teacherName} → ${studentName}(${message})`);
|
||||
return dto;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Router } from 'express';
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
@@ -11,9 +11,11 @@ import {
|
||||
classTeacherParamSchema,
|
||||
createClassSchema,
|
||||
listClassesQuerySchema,
|
||||
updateClassAccountSchema,
|
||||
updateClassSchema,
|
||||
type AddStudentInput,
|
||||
type CreateClassInput,
|
||||
type UpdateClassAccountInput,
|
||||
type UpdateClassInput,
|
||||
} from './classes.schemas.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 - 班级学生名单 */
|
||||
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -6,6 +6,12 @@ export const createClassSchema = z.object({
|
||||
grade: z.string().trim().min(1, '请输入年级').max(32),
|
||||
/** 仅管理员可指定班主任,教师创建时固定为自己 */
|
||||
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({
|
||||
@@ -13,6 +19,20 @@ export const updateClassSchema = z.object({
|
||||
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({
|
||||
keyword: keywordSchema,
|
||||
});
|
||||
@@ -39,4 +59,5 @@ export const classTeacherParamSchema = z.object({
|
||||
|
||||
export type CreateClassInput = z.infer<typeof createClassSchema>;
|
||||
export type UpdateClassInput = z.infer<typeof updateClassSchema>;
|
||||
export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
|
||||
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 {
|
||||
assertClassAccess,
|
||||
@@ -8,6 +8,10 @@ import {
|
||||
classScopeIdFilter,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import {
|
||||
buildClassAccountCreateData,
|
||||
updateClassAccount as updateClassAccountRecord,
|
||||
} from '../../lib/class-account.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
@@ -24,6 +28,36 @@ const countSelect = {
|
||||
notifications: true,
|
||||
} 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[]> {
|
||||
const scope = await resolveClassScope(user);
|
||||
@@ -42,7 +76,7 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
|
||||
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('班级不存在');
|
||||
|
||||
return {
|
||||
...toClassDto(item),
|
||||
...toClassDtoWithAccount(user, item),
|
||||
students: item.students.map((student) => toStudentDto(student)),
|
||||
courses: item.courses.map(toCourseDto),
|
||||
teachers: item.teachers.map((assignment) => toTeacherBrief(assignment.teacher)),
|
||||
@@ -87,15 +121,47 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
|
||||
teacherId = teacher.id;
|
||||
}
|
||||
|
||||
// 班级账号:自动生成班级码(也可由管理员指定)+ 默认班级密码
|
||||
const account = await buildClassAccountCreateData(env.defaultClassPassword, input.code);
|
||||
|
||||
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: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
_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 type { TokenPayload } from '../../lib/jwt.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 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 classWhere = classScopeIdFilter(scope);
|
||||
const now = new Date();
|
||||
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
const personalWhere = personalIdWhere(personalIds);
|
||||
|
||||
const [
|
||||
classCount,
|
||||
@@ -47,15 +51,15 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
prisma.homework.count({ where }),
|
||||
prisma.notification.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
|
||||
? 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.notification.findMany({
|
||||
where,
|
||||
include: { creator: creatorSelect, reads: { where: { userId: user.sub } } },
|
||||
include: { creator: creatorSelect, reads: { where: personalWhere } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
}),
|
||||
@@ -64,7 +68,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
include: {
|
||||
course: courseSelect,
|
||||
creator: creatorSelect,
|
||||
statuses: student ? { where: { userId: user.sub } } : true,
|
||||
statuses: student ? { where: personalWhere } : true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
@@ -74,7 +78,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
include: {
|
||||
course: courseSelect,
|
||||
creator: creatorSelect,
|
||||
statuses: student ? { where: { userId: user.sub } } : true,
|
||||
statuses: student ? { where: personalWhere } : true,
|
||||
},
|
||||
orderBy: { dueAt: 'asc' },
|
||||
take: 5,
|
||||
@@ -92,13 +96,13 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
unreadNotificationCount,
|
||||
pendingHomeworkCount,
|
||||
recentNotifications: recentNotifications.map((item): NotificationDto =>
|
||||
toNotificationDto(item, { userId: user.sub }),
|
||||
toNotificationDto(item, { userIds: personalIds }),
|
||||
),
|
||||
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 =>
|
||||
toHomeworkDto(item, { userId: student ? user.sub : null, withStatus: !student }),
|
||||
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
gradeLevel,
|
||||
gradePercent,
|
||||
@@ -12,6 +12,7 @@ import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toGradeDto } from '../../lib/mappers.js';
|
||||
import { parseOptionalDate } from '../../lib/schemas.js';
|
||||
import { resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass, emitToUser } from '../../realtime/bus.js';
|
||||
import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js';
|
||||
|
||||
@@ -27,13 +28,20 @@ export interface ListGradeOptions {
|
||||
examName?: string;
|
||||
}
|
||||
|
||||
/** 学生查看自己的成绩 */
|
||||
/**
|
||||
* 学生查看成绩。
|
||||
* - 普通学生账号:只看自己的成绩;
|
||||
* - 班级账号(班级设备):返回**本班全部学生的成绩总览**(个人学生不再是登录主体,
|
||||
* 班级设备上一屏展示全班成绩,便于张贴/核对)。
|
||||
*/
|
||||
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
|
||||
const grades = await prisma.grade.findMany({
|
||||
where: { userId: user.sub },
|
||||
where: { userId: { in: personalIds } },
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }],
|
||||
take: 300,
|
||||
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
||||
take: 500,
|
||||
});
|
||||
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 {
|
||||
assertCanPublishContent,
|
||||
assertClassAccess,
|
||||
@@ -11,6 +11,7 @@ import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
|
||||
import { parseOptionalDate } from '../../lib/schemas.js';
|
||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import type {
|
||||
CreateHomeworkInput,
|
||||
@@ -39,6 +40,9 @@ export async function listHomeworks(
|
||||
): Promise<HomeworkDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const student = isStudent(user);
|
||||
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
|
||||
const personalIds = student ? await resolvePersonalIds(user) : [];
|
||||
const personalWhere = personalIdWhere(personalIds);
|
||||
|
||||
const homeworks = await prisma.homework.findMany({
|
||||
where: {
|
||||
@@ -48,36 +52,41 @@ export async function listHomeworks(
|
||||
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
|
||||
: {}),
|
||||
...(student && options.pendingOnly
|
||||
? { statuses: { none: { userId: user.sub, completed: true } } }
|
||||
? { statuses: { none: { ...personalWhere, completed: true } } }
|
||||
: {}),
|
||||
},
|
||||
include: {
|
||||
course: courseSelect,
|
||||
creator: creatorSelect,
|
||||
statuses: student ? { where: { userId: user.sub } } : true,
|
||||
statuses: student ? { where: personalWhere } : true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
|
||||
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> {
|
||||
const student = isStudent(user);
|
||||
const personalIds = student ? await resolvePersonalIds(user) : [];
|
||||
const homework = await prisma.homework.findUnique({
|
||||
where: { id: homeworkId },
|
||||
include: {
|
||||
course: courseSelect,
|
||||
creator: creatorSelect,
|
||||
statuses: isStudent(user) ? { where: { userId: user.sub } } : true,
|
||||
statuses: student ? { where: personalIdWhere(personalIds) } : true,
|
||||
},
|
||||
});
|
||||
if (!homework) throw ApiError.notFound('作业不存在');
|
||||
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(
|
||||
user: TokenPayload,
|
||||
homeworkId: string,
|
||||
@@ -155,10 +169,36 @@ export async function updateHomeworkStatus(
|
||||
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
|
||||
if (!homework) throw ApiError.notFound('作业不存在');
|
||||
|
||||
const targetUserId = isStudent(user) ? user.sub : (input.userId ?? user.sub);
|
||||
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 } });
|
||||
if (!target || target.classId !== homework.classId) {
|
||||
throw ApiError.badRequest('目标学生不属于该作业所在班级');
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
||||
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
||||
import {
|
||||
assertClassAccess,
|
||||
assertCanPublishContent,
|
||||
@@ -10,6 +10,7 @@ import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { toNotificationDto } from '../../lib/mappers.js';
|
||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { computeClassStatus } from '../schedules/schedules.service.js';
|
||||
import type { CreateNotificationInput } from './notifications.schemas.js';
|
||||
@@ -29,6 +30,9 @@ export async function listNotifications(
|
||||
): Promise<NotificationDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
|
||||
// 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
const personalWhere = personalIdWhere(personalIds);
|
||||
|
||||
const notifications = await prisma.notification.findMany({
|
||||
where: {
|
||||
@@ -37,17 +41,17 @@ export async function listNotifications(
|
||||
...(options.keyword
|
||||
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
|
||||
: {}),
|
||||
...(options.unreadOnly ? { reads: { none: { userId: user.sub } } } : {}),
|
||||
...(options.unreadOnly ? { reads: { none: personalWhere } } : {}),
|
||||
},
|
||||
include: {
|
||||
creator: creatorSelect,
|
||||
reads: isStaff ? true : { where: { userId: user.sub } },
|
||||
reads: isStaff ? true : { where: personalWhere },
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }],
|
||||
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 } });
|
||||
}
|
||||
|
||||
/** 标记单条通知已读 */
|
||||
/**
|
||||
* 标记单条通知已读。
|
||||
* 班级账号:为全班学生写入已读记录(班级设备代全班操作),教师端的已读人数随之更新。
|
||||
*/
|
||||
export async function markAsRead(
|
||||
user: TokenPayload,
|
||||
notificationId: string,
|
||||
): Promise<{ notificationId: string; readAt: string }> {
|
||||
): Promise<{ notificationId: string; readAt: string; marked: number }> {
|
||||
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||
if (!notification) throw ApiError.notFound('通知不存在');
|
||||
await assertClassAccess(user, notification.classId);
|
||||
|
||||
const read = await prisma.notificationRead.upsert({
|
||||
where: { notificationId_userId: { notificationId, userId: user.sub } },
|
||||
create: { notificationId, userId: user.sub },
|
||||
update: {},
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
// 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates,
|
||||
// 直接 createMany 遇到重复会整体失败。
|
||||
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 }> {
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
const personalWhere = personalIdWhere(personalIds);
|
||||
|
||||
const pending = await prisma.notification.findMany({
|
||||
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } },
|
||||
where: { ...classScopeWhere(scope), reads: { none: personalWhere } },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
let marked = 0;
|
||||
for (const item of pending) {
|
||||
await prisma.notificationRead.upsert({
|
||||
where: { notificationId_userId: { notificationId: item.id, userId: user.sub } },
|
||||
create: { notificationId: item.id, userId: user.sub },
|
||||
update: {},
|
||||
const existing = await prisma.notificationRead.findMany({
|
||||
where: { notificationId: item.id, userId: { in: personalIds } },
|
||||
select: { userId: true },
|
||||
});
|
||||
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> {
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
return prisma.notification.count({
|
||||
where: { ...classScopeWhere(scope), reads: { none: { userId: user.sub } } },
|
||||
where: { ...classScopeWhere(scope), reads: { none: personalIdWhere(personalIds) } },
|
||||
});
|
||||
}
|
||||
@@ -52,9 +52,11 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
return;
|
||||
}
|
||||
const payload = verifyToken(token);
|
||||
// 班级账号(班级设备):socket.data.user.id 用班级 id,据此加入 user:{classId} 房间,
|
||||
// "叫人"等定向消息因此能直达班级设备(见 calls.service)。
|
||||
socket.data.user = {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub,
|
||||
username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username,
|
||||
name: payload.name,
|
||||
role: payload.role,
|
||||
classId: payload.classId,
|
||||
|
||||
@@ -64,9 +64,27 @@ export interface LoginRequest {
|
||||
password: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录会话主体。
|
||||
* - 普通账号(教师/管理员/个人学生):就是用户本身;
|
||||
* - 班级账号(班级设备):`classSession = true`,此时 `id` 与 `classId` 都是班级 id,
|
||||
* `name` 是班级名,个人数据由服务端按"全班"范围读写。
|
||||
*/
|
||||
export interface SessionUser extends StudentDto {
|
||||
classSession?: boolean;
|
||||
/** 班级账号的班级码(仅班级会话返回) */
|
||||
classCode?: string;
|
||||
}
|
||||
|
||||
export interface LoginResponse {
|
||||
token: string;
|
||||
user: UserDto;
|
||||
user: SessionUser;
|
||||
}
|
||||
|
||||
/** 班级账号登录(学生端):班级码 + 班级密码 */
|
||||
export interface ClassLoginRequest {
|
||||
code: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 班级 */
|
||||
@@ -82,6 +100,10 @@ export interface ClassDto {
|
||||
courseCount?: number;
|
||||
homeworkCount?: number;
|
||||
notificationCount?: number;
|
||||
/** 班级码(学生端班级账号登录用);仅对有管理权限的角色返回 */
|
||||
code?: string;
|
||||
/** 是否已设置班级密码(哈希永不外泄) */
|
||||
hasPassword?: boolean;
|
||||
}
|
||||
|
||||
export interface ClassTeacherBrief {
|
||||
@@ -99,6 +121,8 @@ export interface ClassDetailDto extends ClassDto {
|
||||
export interface CreateClassRequest {
|
||||
name: string;
|
||||
grade: string;
|
||||
/** 可选:自定义班级码,留空自动生成 */
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface UpdateClassRequest {
|
||||
@@ -106,6 +130,14 @@ export interface UpdateClassRequest {
|
||||
grade?: string;
|
||||
}
|
||||
|
||||
/** 管理员设置/重置班级账号(班级码 + 班级密码) */
|
||||
export interface UpdateClassAccountRequest {
|
||||
/** 新的班级码(4~16 位字母数字,留空表示不改) */
|
||||
code?: string;
|
||||
/** 新的班级密码(留空表示不改) */
|
||||
password?: string;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 课程 */
|
||||
|
||||
export interface CourseDto {
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
type TimeLayoutDto,
|
||||
type TimeLayoutImportResult,
|
||||
type TimeLayoutParsePreview,
|
||||
type UpdateClassAccountRequest,
|
||||
type UpdateClassRequest,
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
@@ -63,6 +64,9 @@ export const classApi = {
|
||||
api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }),
|
||||
removeTeacher: (id: string, teacherId: string): Promise<unknown> =>
|
||||
api.delete(`${API_PATHS.classes}/${id}/teachers/${teacherId}`),
|
||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||
updateAccount: (id: string, payload: UpdateClassAccountRequest): Promise<ClassDto> =>
|
||||
api.patch(`${API_PATHS.classes}/${id}/class-account`, payload),
|
||||
};
|
||||
|
||||
/* ------------------------------------------------------------------ 课程 */
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type ClassDto,
|
||||
type CourseDto,
|
||||
type StudentDto,
|
||||
type UpdateClassAccountRequest,
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
import { classApi, courseApi, teacherApi } from '@/api';
|
||||
@@ -43,7 +44,7 @@ async function loadClasses(): Promise<void> {
|
||||
const formVisible = ref(false);
|
||||
const formRef = ref<FormInstance>();
|
||||
const editingId = ref<string | null>(null);
|
||||
const form = reactive({ name: '', grade: '' });
|
||||
const form = reactive({ name: '', grade: '', code: '' });
|
||||
const rules: FormRules = {
|
||||
name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
|
||||
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
|
||||
@@ -53,6 +54,7 @@ function openCreate(): void {
|
||||
editingId.value = null;
|
||||
form.name = '';
|
||||
form.grade = '';
|
||||
form.code = '';
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -60,6 +62,7 @@ function openEdit(row: ClassDto): void {
|
||||
editingId.value = row.id;
|
||||
form.name = row.name;
|
||||
form.grade = row.grade;
|
||||
form.code = '';
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -71,13 +74,69 @@ async function submitForm(): Promise<void> {
|
||||
await classApi.update(editingId.value, { name: form.name, grade: form.grade });
|
||||
ElMessage.success('班级已更新');
|
||||
} 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('班级创建成功');
|
||||
}
|
||||
formVisible.value = false;
|
||||
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> {
|
||||
await ElMessageBox.confirm(
|
||||
`删除「${row.name}」将同时删除其课表、作业、通知与成绩,确认继续?`,
|
||||
@@ -267,12 +326,24 @@ onUnmounted(() => {
|
||||
<el-table-column label="通知" width="80">
|
||||
<template #default="{ row }">{{ row.notificationCount ?? 0 }}</template>
|
||||
</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">
|
||||
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right">
|
||||
<el-table-column label="操作" width="310" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<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="danger" @click="removeClass(row)">删除</el-button>
|
||||
</template>
|
||||
@@ -280,6 +351,38 @@ onUnmounted(() => {
|
||||
</el-table>
|
||||
</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-form ref="formRef" :model="form" :rules="rules" label-width="80px">
|
||||
@@ -289,6 +392,9 @@ onUnmounted(() => {
|
||||
<el-form-item label="年级" prop="grade">
|
||||
<el-input v-model="form.grade" placeholder="例如 高一" />
|
||||
</el-form-item>
|
||||
<el-form-item v-if="!editingId" label="班级码">
|
||||
<el-input v-model="form.code" placeholder="留空自动生成(4~16 位字母数字)" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="formVisible = false">取消</el-button>
|
||||
@@ -393,3 +499,15 @@ onUnmounted(() => {
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.account-tag {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -375,7 +375,8 @@ function writeReadme() {
|
||||
二、默认账号(首次安装后请立即修改密码)
|
||||
管理员 admin / admin123
|
||||
教师 teacher1 / teacher123、teacher2 / teacher123
|
||||
学生 student01 ~ student15 / student123(桌面客户端使用)
|
||||
班级 班级码 G101/G102/G203 + 班级密码 123456(学生端桌面客户端使用)
|
||||
个人 student01 ~ student15 / student123(个人学生账号,接口向后兼容)
|
||||
|
||||
三、目录说明
|
||||
server\\ 后端程序(server\\dist\\index.js 为入口)
|
||||
|
||||
Reference in new issue
Block a user