feat(rbac): 落地角色权限模型(需求 7)——班级/人员/名单/成绩收紧到管理员,班主任管本班课表,科任仅作业·叫人·通知

## 权限矩阵(前后端共用一份)

- 新增 `packages/shared/src/permissions.ts`:`ClassRole`(ADMIN/HEAD/SUBJECT/STUDENT/NONE)、
  `resolveClassRole()`、`canManageClasses/AssignTeachers/Roster/Schedule/Courses/PublishContent/ManageGrades/Import`
  与 `PERMISSION_MATRIX`(README 与验收脚本据此展开)。
- 后端 `lib/access.ts`:新增 `resolveUserClassRole()`(`Class.teacherId` → 班主任,`ClassTeacher` → 科任)
  与 `assertCanManageClasses/AssignTeachers/Roster/Schedule/PublishContent/ManageGrades`;
  各 service 在**服务层**断言(路由仅做角色过滤),越权统一 403。
- 收紧项:班级增删改、班主任/科任分配、学生名单与账号 → 仅 ADMIN;
  课表/课程增删改 → ADMIN 或本班班主任;成绩录入/修改/批量 → 仅 ADMIN(按需求清单);
  作业/通知/叫人 → ADMIN、班主任、科任皆可。
- 读权限保持不变:教师仍可查看自己班级的课表/作业/通知/成绩列表与统计。

## 前端按同一矩阵隐藏入口

- 侧边栏:教师端不再显示「班级管理 / 学生管理 / 成绩录入」(管理员仍是 7 项)。
- 班级页:新建/编辑/删除按钮仅管理员可见;课表页:增删改按钮仅"管理员或本班班主任"可见;
  成绩页:录入/批量/删除按钮仅管理员可见。
- **叫人入口调整**:教师需要叫人,而「学生管理」是管理员专属,因此教师入口移到
  「通知发布 → 叫人」(选班级 → 选学生 → 快捷短语/自定义消息;名单走 `/classes/:id/students`,
  不使用管理员专属的 `/students`);管理员在学生管理页仍保留逐行「叫人」。

## 顺手修掉的两个真实缺陷

- `NotificationsView.openCreate()`:班级列表异步加载期间点「发布通知」会导致「目标班级」为空、
  校验失败且**静默不发布**;现在打开弹窗前会确保班级已加载(冒烟同步等待下拉就绪)。
- `desktop-client/scripts/smoke.mjs`:上一次冒烟被强杀后配置目录仍被句柄占用,`rmSync` 报 ENOTEMPTY
  直接让冒烟启动失败;改为按进程号使用独立目录并容错清理。

## 验证

- `pnpm verify:e2e` **82/82** ✅:新增 14 项角色权限断言(班主任/科任建班被拒、班主任删班被拒、
  班主任/科任分配人员被拒、管理员分配与取消成功、科任管理课表被拒、科任可布置作业、
  科任/班主任录入成绩被拒、管理员录入成功),并把历史用例中"仅管理员可执行"的操作切到管理员令牌。
- `pnpm verify:web` **17/17** ✅:新增「教师端隐藏无权入口」断言(可见菜单=仪表盘/课表/作业/通知,
  越权入口=[]),叫人入口改到通知页后仍验证弹窗、8 条快捷短语、自定义输入与可选中态。
- `pnpm verify:desktop` **34/34** ✅;`lint` / `format:check` / `typecheck` 全绿。
- 数据清理:清掉历史遗留的测试班级与 4 条探针课表,演示数据恢复为 3 个班级。
This commit is contained in:
LoveChengke committed 2026-09-12 15:02:02 +08:00
1 parent 71f43042ca
commit 30e4c36809
25 files changed
+712 -180

No files matched your search

+47 -20
View File
@@ -1,4 +1,4 @@
# 班级小助手(Class Helper)
# 班级小助手(Class Helper)
班级信息管理系统,采用 pnpm monorepo,包含**后端服务**、**Web 管理端**(教师/管理员)与
**桌面客户端**(学生)三端。核心链路:
@@ -171,8 +171,8 @@ pnpm verify:desktop
```bash
# 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:68 项
pnpm verify:web # Web 管理端真实点击(含手机小屏适配、叫人入口、紧急通知全屏警告):16 项
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 叫人:82 项
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口、紧急通知):17 项
pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读/作业/叫人、真实链路、课表时间轴):34 项
```
@@ -203,7 +203,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
## 常用命令
| 命令 | 说明 |
| ------------------------------------------- | ---------------------------------------------------------------------------- |
| ------------------------------------------- | ------------------------------------------------------------------------- |
| `pnpm dev` | 并行启动 shared(tsc watch) + 后端 + Web 端 |
| `pnpm dev:server` / `pnpm dev:web` | 只启动后端 / 只启动 Web 端 |
| `pnpm dev:desktop` | 启动 EXE 客户端开发模式(Vite 5174 + Electron,热更新) |
@@ -214,8 +214,8 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
| `pnpm verify:e2e` | 后端端到端验收(68 项,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,16 项,含手机小屏适配与叫人入口) |
| `pnpm verify:e2e` | 后端端到端验收(82 项,含角色权限矩阵,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,17 项,含权限入口隐藏) |
| `pnpm verify:desktop` | EXE 客户端冒烟验证(34 项,含灵动岛动画/收回/已读/作业/叫人、课表时间轴) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
@@ -261,7 +261,7 @@ pnpm verify:desktop # Electron 客户端(含灵动岛动画/收回/已读
实现位置:`src/composables/useResponsive.ts`(断点 + resize 监听)、`src/layouts/AdminLayout.vue`(抽屉/顶栏)、
`src/styles/index.css`(全局响应式规则)、各视图表格卡片加 `.table-card`。
`pnpm verify:web` 的 16 项里,最后 8 项专门验证手机端与叫人入口(Electron 真实点击 + 真实尺寸窗口):
`pnpm verify:web` 的 17 项里,最后 9 项专门验证权限入口隐藏、手机端与叫人入口(Electron 真实点击):
| 校验项 | 实测结果 |
| -------------------------------------- | -------------------------------------------------------------------- |
@@ -561,20 +561,47 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
`class:{classId}`、`user:{userId}`、`role:{role}`、`students`、`teachers`。
客户端断线由 Socket.IO 自动重连(Web 端 store 与后续 EXE 端共用同一套事件名)。
## 权限模型(RBAC)
## 角色与权限模型(RBAC)
| 角色 | 可见范围 | 写权限 |
| --------- | ------------------------------------------------------------- | --------------------------------------- |
| `ADMIN` | 全部班级 | 全部 |
| `TEACHER` | 自己创建(`Class.teacherId`)或被分配(`ClassTeacher`)的班级 | 仅上述班级 |
| `STUDENT` | 仅自己所在班级(`User.classId` + `Enrollment`) | 无(仅能标记自己的作业状态 / 已读通知) |
班级内的角色由"账号角色 + 与该班级的关系"共同决定,前后端共用同一份矩阵
(`packages/shared/src/permissions.ts`,后端 `lib/access.ts` 复用,前端用它隐藏入口):
| 角色 | 判定方式 | 可见范围 |
| ------------------ | ------------------------------ | -------------------------- |
| `ADMIN` 管理员 | `User.role = ADMIN` | 全部班级 |
| 班主任 `HEAD` | `Class.teacherId === 当前用户` | 本班 |
| 科任老师 `SUBJECT` | `ClassTeacher` 中存在当前用户 | 被分配的班级 |
| 学生 `STUDENT` | `User.classId` / 班级账号 | 自己的班级(只读业务数据) |
**权限矩阵**(`PERMISSION_MATRIX`,验收脚本按此逐条断言):
| 操作 | 管理员 | 班主任 | 科任老师 |
| -------------------------------------- | :----: | :----------: | :------: |
| 班级创建 / 修改 / 删除 | ✅ | ❌ | ❌ |
| 分配班主任与科任老师 | ✅ | ❌ | ❌ |
| 学生名单管理(增删 / 重置密码 / 导入) | ✅ | ❌ | ❌ |
| 课表管理(增删改 / 时间配置导入) | ✅ | ✅(仅本班) | ❌ |
| 布置作业 | ✅ | ✅ | ✅ |
| 叫人 | ✅ | ✅ | ✅ |
| 发布通知 | ✅ | ✅ | ✅ |
| 成绩录入 / 修改 / 导入 | ✅ | ❌ | ❌ |
实现要点:
- `src/middleware/auth.ts`:JWT 校验后会**回查数据库**,班级/角色变更立即生效。
- `src/lib/access.ts`:集中实现 `assertClassAccess` / `assertClassWritable` / `resolveClassScope`,
列表接口通过 scope 收敛数据(`classId in [...]`),单条接口直接断言。
- 越权请求统一返回 `403`,未登录返回 `401`(验收脚本已覆盖 10 项越权场景)。
- `src/middleware/auth.ts`:JWT 校验后**回查数据库**,班级/角色变更立即生效。
- `src/lib/access.ts`:`resolveUserClassRole()` 解析班级内角色,`assertCanManageClasses` /
`assertCanAssignTeachers` / `assertCanManageRoster` / `assertCanManageSchedule` /
`assertCanPublishContent` / `assertCanManageGrades` 在**服务层**断言(路由只做角色过滤)。
- `resolveClassScope()` 收敛列表数据(`classId in [...]`),单条接口直接断言;越权一律 `403`。
- 前端按同一矩阵隐藏入口:教师端侧边栏不再显示「班级管理 / 学生管理 / 成绩录入」,
课表页的增删改按钮仅对"管理员或本班班主任"显示,成绩页的录入/删除按钮仅对管理员显示。
- **`叫人`入口位置调整**:班主任与科任老师都需要叫人,而「学生管理」页是管理员专属,
因此教师的叫人入口放在「通知发布 → 叫人」(选班级 → 选学生 → 快捷短语/自定义消息),
管理员在学生管理里仍保留逐行「叫人」。
> 行为变更提示:按需求"科任老师只有布置作业、叫人、通知权限""班主任另有本班课表管理权限",
> 成绩录入与班级/名单管理已收紧为管理员专属;如需把成绩下放给班主任或科任老师,
> 只需调整 `packages/shared/src/permissions.ts` 里的 `canManageGrades()` 并同步断言。
## 模块化设计(可随时增删模块)
@@ -650,9 +677,9 @@ pnpm db:migrate && pnpm db:seed
当前实测:
| 验证 | 结果 |
| ---------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **68/68** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 手机小屏适配 + 叫人入口 + 紧急通知 + PWA) | **16/16** ✅ |
| --------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **82/82** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + PWA) | **17/17** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + 灵动岛动画/收回/已读/作业/叫人 + 课表时间轴) | **34/34** ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
+9 -3
View File
@@ -26,9 +26,15 @@ if (missing.length > 0) {
console.log('[smoke] 启动 Electron 冒烟验证...\n');
// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出)
const profileDir = path.join(repoRoot, '.cache', 'desktop-smoke-profile');
fs.rmSync(profileDir, { recursive: true, force: true });
// 独立的 userData:避免与用户正在运行的客户端抢单实例锁(否则新进程会静默退出)。
// 目录按进程号区分:上一次冒烟若被强杀,目录可能仍被句柄占用(rmSync 会 ENOTEMPTY),
// 用独立目录可以彻底避免互相干扰。
const profileDir = path.join(repoRoot, '.cache', `desktop-smoke-profile-${process.pid}`);
try {
fs.rmSync(profileDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 200 });
} catch (error) {
console.warn(`[smoke] 复用/清理配置目录失败(忽略):${error?.message ?? error}`);
}
console.log(`[smoke] 独立配置目录:${profileDir}`);
// 未显式指定时自动探测后端:可达则启用联网集成 + 侧边栏点击测试
+171 -19
View File
@@ -1,4 +1,4 @@
#!/usr/bin/env node
#!/usr/bin/env node
/**
* 端到端联调验证(对应验收标准):
* 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到
@@ -76,6 +76,15 @@ async function main() {
record('教师登录 teacher1', Boolean(teacherToken), `status=${teacherLogin.status}`);
if (!teacherToken) process.exit(1);
// 管理员令牌:按新的角色权限模型,班级增删改/人员分配/学生名单/成绩均仅管理员可执行
const adminLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'admin', password: 'admin123' },
});
const adminToken = adminLogin.payload?.data?.token;
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
if (!adminToken) process.exit(1);
const studentLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'student01', password: 'student123' },
@@ -117,7 +126,7 @@ async function main() {
foreignClass ? `外部班级:${foreignClass.name}` : '未找到其它教师的班级',
);
const studentList = await api(`/students?classId=${classId}`, { token: teacherToken });
const studentList = await api(`/students?classId=${classId}`, { token: adminToken });
const classmates = studentList.payload?.data ?? [];
const targetStudent = classmates.find((item) => item.id === studentUser?.id) ?? classmates[0];
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
@@ -225,7 +234,7 @@ async function main() {
const gradeWait = waitForEvent(socket, 'grade:updated');
const createdGrade = await api('/grades', {
method: 'POST',
token: teacherToken,
token: adminToken,
body: {
classId,
courseId,
@@ -270,7 +279,7 @@ async function main() {
`学生 ${classDetail.payload?.data?.students?.length ?? 0} 人 · 课程 ${classDetail.payload?.data?.courses?.length ?? 0} 门`,
);
const teacherList = await api('/teachers', { token: teacherToken });
const teacherList = await api('/teachers', { token: adminToken });
record(
'教师列表 /teachers',
(teacherList.payload?.data ?? []).length >= 2,
@@ -305,20 +314,20 @@ async function main() {
const assigned = await api(`/classes/${classId}/teachers`, {
method: 'POST',
token: teacherToken,
token: adminToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
});
record('分配协作教师', assigned.status === 201, `status=${assigned.status}`);
const unassigned = await api(`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`, {
method: 'DELETE',
token: teacherToken,
token: adminToken,
});
record('取消协作教师', unassigned.status === 200, `status=${unassigned.status}`);
const tempUsername = `e2e_student_${Date.now()}`;
const addedStudent = await api(`/classes/${classId}/students`, {
method: 'POST',
token: teacherToken,
token: adminToken,
body: { username: tempUsername, name: '联调学生' },
});
const addedStudentId = addedStudent.payload?.data?.id;
@@ -334,14 +343,14 @@ async function main() {
const resetResult = await api(`/students/${addedStudentId}/reset-password`, {
method: 'POST',
token: teacherToken,
token: adminToken,
body: {},
});
record('重置学生密码', resetResult.status === 200, `status=${resetResult.status}`);
const renamed = await api(`/students/${addedStudentId}`, {
method: 'PATCH',
token: teacherToken,
token: adminToken,
body: { name: '联调学生(已改名)' },
});
record(
@@ -352,7 +361,7 @@ async function main() {
const removedStudent = await api(`/classes/${classId}/students/${addedStudentId}`, {
method: 'DELETE',
token: teacherToken,
token: adminToken,
});
record('班级移出学生', removedStudent.status === 200, `status=${removedStudent.status}`);
@@ -360,20 +369,13 @@ async function main() {
const teacherDelete = await api(`/students/${addedStudentId}`, { method: 'DELETE', token: teacherToken });
record('教师删除未分班学生被拒绝(403)', teacherDelete.status === 403, `status=${teacherDelete.status}`);
const adminLogin = await api('/auth/login', {
method: 'POST',
body: { username: 'admin', password: 'admin123' },
});
const adminToken = adminLogin.payload?.data?.token;
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
const adminDelete = await api(`/students/${addedStudentId}`, { method: 'DELETE', token: adminToken });
record('管理员删除学生账号', adminDelete.status === 200, `status=${adminDelete.status}`);
}
const bulkResult = await api('/grades/bulk', {
method: 'POST',
token: teacherToken,
token: adminToken,
body: {
classId,
courseId,
@@ -424,7 +426,7 @@ async function main() {
const updatedGrade = await api(`/grades/${createdGrade.payload?.data?.id}`, {
method: 'PATCH',
token: teacherToken,
token: adminToken,
body: { score: 95 },
});
record(
@@ -619,6 +621,156 @@ async function main() {
record('清理叫人测试数据', true, '已删除 3 条叫人通知');
}
// ---------------------------------------------------------------- 6.4 班级角色权限矩阵
// 依据需求:班主任/科任老师不能增删改班级;仅管理员可分配人员;
// 科任老师仅作业/叫人/通知;班主任另可管理本班课表;成绩录入仅管理员。
{
record('管理员登录(用于权限矩阵校验)', Boolean(adminToken), '已在上文登录');
// 科任老师:teacher2 在高二(3)班是协作(科任)老师
const subjectClass = teacher2Classes.find(
(item) => item.teacherId !== teacher2Login.payload?.data?.user?.id,
);
const subjectClassId = subjectClass?.id;
record(
'存在"科任老师"场景的班级',
Boolean(subjectClassId) && subjectClassId !== foreignClass?.id,
`classId=${subjectClassId ?? '-'} name=${subjectClass?.name ?? '-'}`,
);
if (adminToken) {
const headCreate = await api('/classes', {
method: 'POST',
token: teacherToken,
body: { name: '班主任越权班', grade: '高一' },
});
record('班主任创建班级被拒绝(403)', headCreate.status === 403, `status=${headCreate.status}`);
const subjectCreate = await api('/classes', {
method: 'POST',
token: teacher2Token,
body: { name: '科任越权班', grade: '高一' },
});
record('科任老师创建班级被拒绝(403)', subjectCreate.status === 403, `status=${subjectCreate.status}`);
const headDelete = await api(`/classes/${classId}`, { method: 'DELETE', token: teacherToken });
record('班主任删除班级被拒绝(403)', headDelete.status === 403, `status=${headDelete.status}`);
const headAssign = await api(`/classes/${classId}/teachers`, {
method: 'POST',
token: teacherToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
});
record('班主任分配科任老师被拒绝(403)', headAssign.status === 403, `status=${headAssign.status}`);
const subjectAssign = await api(`/classes/${classId}/teachers`, {
method: 'POST',
token: teacher2Token,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
});
record('科任老师分配人员被拒绝(403)', subjectAssign.status === 403, `status=${subjectAssign.status}`);
// 管理员可以分配(用"先加后删"验证)
const adminAssign = await api(`/classes/${classId}/teachers`, {
method: 'POST',
token: adminToken,
body: { teacherId: teacher2Login.payload?.data?.user?.id },
});
record(
'管理员分配科任老师成功(201/200)',
[200, 201].includes(adminAssign.status),
`status=${adminAssign.status}`,
);
const adminUnassign = await api(
`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`,
{
method: 'DELETE',
token: adminToken,
},
);
record('管理员取消科任老师成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
// 课表:班主任可管理本班,科任老师不可
if (subjectClassId) {
const subjectSchedule = await api('/schedules', {
method: 'POST',
token: teacher2Token,
body: {
classId: subjectClassId,
courseId: courses.payload?.data?.[0]?.id,
dayOfWeek: 3,
startTime: '13:00',
endTime: '13:45',
weekStart: 1,
weekEnd: 20,
},
});
record(
'科任老师管理课表被拒绝(403)',
subjectSchedule.status === 403 || subjectSchedule.status === 400,
`status=${subjectSchedule.status}`,
);
}
const subjectHomework = await api('/homeworks', {
method: 'POST',
token: subjectClassId ? teacher2Token : teacherToken,
body: {
classId: subjectClassId,
title: '科任老师布置的作业',
content: '权限矩阵校验用',
},
});
record('科任老师可布置作业(201)', subjectHomework.status === 201, `status=${subjectHomework.status}`);
if (subjectHomework.payload?.data?.id) {
await api(`/homeworks/${subjectHomework.payload.data.id}`, {
method: 'DELETE',
token: teacher2Token,
});
}
const subjectGrades = await api('/grades', {
method: 'POST',
token: teacher2Token,
body: { classId: classId, userId: studentUser?.id, examName: '越权成绩', score: 90, totalScore: 100 },
});
record('科任老师录入成绩被拒绝(403)', subjectGrades.status === 403, `status=${subjectGrades.status}`);
const headGrades = await api('/grades', {
method: 'POST',
token: teacherToken,
body: {
classId: classId,
userId: studentUser?.id,
examName: '班主任越权成绩',
score: 90,
totalScore: 100,
},
});
record(
'班主任录入成绩被拒绝(403,成绩仅管理员)',
headGrades.status === 403,
`status=${headGrades.status}`,
);
const adminGrades = await api('/grades', {
method: 'POST',
token: adminToken,
body: {
classId: classId,
userId: studentUser?.id,
examName: '管理员录入成绩',
score: 88,
totalScore: 100,
},
});
record('管理员录入成绩成功(201)', adminGrades.status === 201, `status=${adminGrades.status}`);
if (adminGrades.payload?.data?.id) {
await api(`/grades/${adminGrades.payload.data.id}`, { method: 'DELETE', token: adminToken });
}
}
}
// ---------------------------------------------------------------- 7. 权限隔离
if (foreignClass) {
const studentCrossClass = await api(`/classes/${foreignClass.id}`, { token: studentToken });
+85 -3
View File
@@ -1,12 +1,25 @@
import {
canManageGrades,
canManageSchedule,
canPublishContent,
canManageClasses,
canAssignTeachers,
canManageRoster,
resolveClassRole,
type ClassRole,
} from '@classhelper/shared';
import { prisma } from './db.js';
import { ApiError } from './http.js';
import type { TokenPayload } from './jwt.js';
/**
* 权限(RBAC)规则:
* - ADMIN :可访问全部班级
* - TEACHER :仅可访问自己创建(teacherId)或被分配(ClassTeacher)的班级
* - STUDENT :仅可查看自己所在班级的数据,且无任何写权限
* - ADMIN :全部班级;班级增删改、人员分配、成绩、课表
* - HEAD :班主任(Class.teacherId)—— 本班课表 + 作业/通知/叫人
* - SUBJECT :科任老师(ClassTeacher)—— 仅作业/通知/叫人
* - STUDENT :只读;班级账号(classSession)以班级为单位读写自己的数据
*
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
*/
export function isAdmin(user: TokenPayload): boolean {
@@ -82,6 +95,75 @@ export async function assertClassWritable(user: TokenPayload, classId: string):
await assertClassAccess(user, classId);
}
/* ---------------------------------------------------------------- 班级内角色 */
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / STUDENT / NONE) */
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
if (user.role === 'ADMIN') return 'ADMIN';
if (user.role === 'STUDENT') return canAccessClass(user, classId).then((ok) => (ok ? 'STUDENT' : 'NONE'));
const record = await prisma.class.findUnique({
where: { id: classId },
select: {
teacherId: true,
teachers: { where: { teacherId: user.sub }, select: { id: true } },
},
});
if (!record) return 'NONE';
return resolveClassRole({
role: user.role,
isHeadTeacher: record.teacherId === user.sub,
isSubjectTeacher: record.teachers.length > 0,
});
}
/** 班级本身(增删改)与人员分配:仅管理员 */
export function assertCanManageClasses(user: TokenPayload): void {
if (!canManageClasses(user.role)) {
throw ApiError.forbidden('只有管理员可以创建、修改或删除班级');
}
}
/** 班主任 / 科任老师分配:仅管理员 */
export function assertCanAssignTeachers(user: TokenPayload): void {
if (!canAssignTeachers(user.role)) {
throw ApiError.forbidden('只有管理员可以分配班主任与科任老师');
}
}
/** 学生名单管理:仅管理员 */
export function assertCanManageRoster(user: TokenPayload): void {
if (!canManageRoster(user.role)) {
throw ApiError.forbidden('只有管理员可以管理学生名单与账号');
}
}
/** 课表管理(含时间配置导入):管理员或本班班主任 */
export async function assertCanManageSchedule(user: TokenPayload, classId: string): Promise<void> {
const classRole = await resolveUserClassRole(user, classId);
if (!canManageSchedule(classRole)) {
throw ApiError.forbidden(
classRole === 'SUBJECT' ? '科任老师不能管理课表,请联系班主任或管理员' : '无权管理该班级的课表',
);
}
}
/** 作业 / 通知 / 叫人:管理员、班主任、科任老师 */
export async function assertCanPublishContent(user: TokenPayload, classId: string): Promise<void> {
await assertClassAccess(user, classId);
const classRole = await resolveUserClassRole(user, classId);
if (!canPublishContent(classRole)) {
throw ApiError.forbidden('当前账号没有发布作业/通知或叫人的权限');
}
}
/** 成绩录入 / 修改 / 导入:仅管理员 */
export function assertCanManageGrades(user: TokenPayload): void {
if (!canManageGrades(user.role)) {
throw ApiError.forbidden('只有管理员可以录入、修改或导入成绩');
}
}
/** 学生的主班级,未分配班级时抛 403 */
export function requireStudentClassId(user: TokenPayload): string {
if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任');
@@ -1,5 +1,5 @@
import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
import { assertClassWritable } from '../../lib/access.js';
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';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -18,7 +18,7 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
* "叫人"类型,无论是否在上课都立即展开显示。
*/
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
await assertClassWritable(user, input.classId);
await assertCanPublishContent(user, input.classId);
const student = await prisma.user.findUnique({
where: { id: input.studentId },
@@ -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';
@@ -38,7 +38,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
});
/** POST /api/classes - 创建班级 */
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createClassSchema }), async (req, res) => {
router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
});
@@ -1,8 +1,10 @@
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
import type { ClassDetailDto, ClassDto, StudentDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import {
assertClassAccess,
assertClassWritable,
assertCanAssignTeachers,
assertCanManageClasses,
assertCanManageRoster,
classScopeIdFilter,
resolveClassScope,
} from '../../lib/access.js';
@@ -75,6 +77,7 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi
/** 创建班级 */
export async function createClass(user: TokenPayload, input: CreateClassInput): Promise<ClassDto> {
assertCanManageClasses(user);
let teacherId = user.sub;
if (input.teacherId && user.role === 'ADMIN') {
const teacher = await prisma.user.findUnique({ where: { id: input.teacherId } });
@@ -101,7 +104,7 @@ export async function updateClass(
classId: string,
input: UpdateClassInput,
): Promise<ClassDto> {
await assertClassWritable(user, classId);
assertCanManageClasses(user);
const updated = await prisma.class.update({
where: { id: classId },
@@ -118,7 +121,7 @@ export async function updateClass(
/** 删除班级(级联删除课表/作业/通知/成绩) */
export async function deleteClass(user: TokenPayload, classId: string): Promise<void> {
await assertClassWritable(user, classId);
assertCanManageClasses(user);
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
await prisma.class.delete({ where: { id: classId } });
}
@@ -144,7 +147,7 @@ export async function addStudent(
classId: string,
input: AddStudentInput,
): Promise<StudentDto> {
await assertClassWritable(user, classId);
assertCanManageRoster(user);
const existing = await prisma.user.findUnique({ where: { username: input.username } });
let studentId: string;
@@ -190,7 +193,7 @@ export async function addStudent(
/** 将学生移出班级(保留账号) */
export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise<void> {
await assertClassWritable(user, classId);
assertCanManageRoster(user);
const student = await prisma.user.findUnique({ where: { id: studentId } });
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
@@ -206,7 +209,7 @@ export async function removeStudent(user: TokenPayload, classId: string, student
/** 分配协作教师 */
export async function assignTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
await assertClassWritable(user, classId);
assertCanAssignTeachers(user);
const teacher = await prisma.user.findUnique({ where: { id: teacherId } });
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
@@ -222,7 +225,7 @@ export async function assignTeacher(user: TokenPayload, classId: string, teacher
/** 取消协作教师(班主任不可被移除) */
export async function removeTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
await assertClassWritable(user, classId);
assertCanAssignTeachers(user);
const target = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
if (target?.teacherId === teacherId) throw ApiError.badRequest('班主任不能被移除,请先转移班级');
@@ -1,5 +1,5 @@
import type { CourseDto } from '@classhelper/shared';
import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import type { CourseDto } from '@classhelper/shared';
import { assertCanManageSchedule, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -19,7 +19,7 @@ export async function listCourses(user: TokenPayload, classId?: string): Promise
}
export async function createCourse(user: TokenPayload, input: CreateCourseInput): Promise<CourseDto> {
await assertClassWritable(user, input.classId);
await assertCanManageSchedule(user, input.classId);
const created = await prisma.course.create({
data: { name: input.name, classId: input.classId, teacherId: user.sub },
@@ -35,7 +35,7 @@ export async function updateCourse(
): Promise<CourseDto> {
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) throw ApiError.notFound('课程不存在');
await assertClassWritable(user, course.classId);
await assertCanManageSchedule(user, course.classId);
const updated = await prisma.course.update({
where: { id: courseId },
@@ -48,6 +48,6 @@ export async function updateCourse(
export async function deleteCourse(user: TokenPayload, courseId: string): Promise<void> {
const course = await prisma.course.findUnique({ where: { id: courseId } });
if (!course) throw ApiError.notFound('课程不存在');
await assertClassWritable(user, course.classId);
await assertCanManageSchedule(user, course.classId);
await prisma.course.delete({ where: { id: courseId } });
}
@@ -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';
@@ -52,7 +52,7 @@ router.get(
);
/** POST /api/grades - 录入单条成绩(广播 grade:updated) */
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
router.post('/', requireRole('ADMIN'), validate({ body: createGradeSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
@@ -62,21 +62,16 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc
});
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */
router.post(
'/bulk',
requireRole('ADMIN', 'TEACHER'),
validate({ body: bulkCreateGradeSchema }),
async (req, res) => {
router.post('/bulk', requireRole('ADMIN'), validate({ body: bulkCreateGradeSchema }), async (req, res) => {
const user = getAuthUser(req);
const grades = await gradeService.bulkCreateGrades(user, req.body as BulkCreateGradeInput);
sendCreated(res, { count: grades.length, items: grades }, `已录入 ${grades.length} 条成绩`);
},
);
});
/** PATCH /api/grades/:id - 修改成绩 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requireRole('ADMIN'),
validate({ params: idParamSchema, body: updateGradeSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -86,17 +81,12 @@ router.patch(
);
/** DELETE /api/grades/:id - 删除成绩 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await gradeService.deleteGrade(user, id);
sendOk(res, { id }, '成绩已删除');
},
);
});
export const gradesModule = defineModule({
name: 'grades',
@@ -1,4 +1,4 @@
import {
import {
SOCKET_EVENTS,
gradeLevel,
gradePercent,
@@ -6,7 +6,7 @@ import {
type GradeLevel,
type GradeStats,
} from '@classhelper/shared';
import { assertClassWritable, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { assertCanManageGrades, classScopeWhere, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -98,7 +98,7 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption
}
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
await assertClassWritable(user, input.classId);
assertCanManageGrades(user);
await assertStudentInClass(input.userId, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
@@ -119,7 +119,7 @@ export async function createGrade(user: TokenPayload, input: CreateGradeInput):
/** 批量录入:同一个考试一次提交整班成绩 */
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
await assertClassWritable(user, input.classId);
assertCanManageGrades(user);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
@@ -155,7 +155,7 @@ export async function updateGrade(
): Promise<GradeDto> {
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
if (!current) throw ApiError.notFound('成绩记录不存在');
await assertClassWritable(user, current.classId);
assertCanManageGrades(user);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
const updated = await prisma.grade.update({
@@ -181,7 +181,7 @@ export async function updateGrade(
export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> {
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
if (!current) throw ApiError.notFound('成绩记录不存在');
await assertClassWritable(user, current.classId);
assertCanManageGrades(user);
await prisma.grade.delete({ where: { id: gradeId } });
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
...toGradeDto({ ...current, course: null, student: null, class: null }),
@@ -1,7 +1,7 @@
import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared';
import { SOCKET_EVENTS, type HomeworkDto, type HomeworkStatusDto } from '@classhelper/shared';
import {
assertCanPublishContent,
assertClassAccess,
assertClassWritable,
classScopeWhere,
isStudent,
resolveClassScope,
@@ -82,7 +82,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
/** 发布作业并实时推送到班级房间 */
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
await assertClassWritable(user, input.classId);
await assertCanPublishContent(user, input.classId);
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
const created = await prisma.homework.create({
@@ -110,7 +110,7 @@ export async function updateHomework(
): Promise<HomeworkDto> {
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
if (!current) throw ApiError.notFound('作业不存在');
await assertClassWritable(user, current.classId);
await assertCanPublishContent(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
@@ -137,7 +137,7 @@ export async function deleteHomework(user: TokenPayload, homeworkId: string): Pr
include: { course: courseSelect, creator: creatorSelect },
});
if (!current) throw ApiError.notFound('作业不存在');
await assertClassWritable(user, current.classId);
await assertCanPublishContent(user, current.classId);
await prisma.homework.delete({ where: { id: homeworkId } });
emitToClass(current.classId, SOCKET_EVENTS.homeworkUpdated, {
@@ -1,7 +1,7 @@
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
import {
assertClassAccess,
assertClassWritable,
assertCanPublishContent,
classScopeWhere,
resolveClassScope,
} from '../../lib/access.js';
@@ -61,7 +61,7 @@ export async function createNotification(
user: TokenPayload,
input: CreateNotificationInput,
): Promise<NotificationDto> {
await assertClassWritable(user, input.classId);
await assertCanPublishContent(user, input.classId);
const priority = input.priority ?? 'NORMAL';
if (priority === 'URGENT' && input.confirmDuringClass !== true) {
@@ -99,7 +99,7 @@ export async function createNotification(
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
if (!current) throw ApiError.notFound('通知不存在');
await assertClassWritable(user, current.classId);
await assertCanPublishContent(user, current.classId);
await prisma.notification.delete({ where: { id: notificationId } });
}
@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import { resolveCurrentWeek } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { sendCreated, sendOk } from '../../lib/http.js';
@@ -1,4 +1,4 @@
import {
import {
SOCKET_EVENTS,
buildScheduleWeekView,
resolveClassStatus,
@@ -9,7 +9,7 @@ import {
} from '@classhelper/shared';
import {
assertClassAccess,
assertClassWritable,
assertCanManageSchedule,
classScopeWhere,
isStudent,
requireStudentClassId,
@@ -102,7 +102,7 @@ export async function getClassStatus(
}
export async function createSchedule(user: TokenPayload, input: CreateScheduleInput): Promise<ScheduleDto> {
await assertClassWritable(user, input.classId);
await assertCanManageSchedule(user, input.classId);
await assertCourseInClass(input.courseId, input.classId);
const created = await prisma.schedule.create({
@@ -135,7 +135,7 @@ export async function updateSchedule(
): Promise<ScheduleDto> {
const current = await prisma.schedule.findUnique({ where: { id: scheduleId } });
if (!current) throw ApiError.notFound('课表记录不存在');
await assertClassWritable(user, current.classId);
await assertCanManageSchedule(user, current.classId);
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
@@ -173,7 +173,7 @@ export async function updateSchedule(
export async function deleteSchedule(user: TokenPayload, scheduleId: string): Promise<void> {
const current = await prisma.schedule.findUnique({ where: { id: scheduleId } });
if (!current) throw ApiError.notFound('课表记录不存在');
await assertClassWritable(user, current.classId);
await assertCanManageSchedule(user, current.classId);
await prisma.schedule.delete({ where: { id: scheduleId } });
emitToClass(current.classId, SOCKET_EVENTS.scheduleUpdated, {
@@ -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';
@@ -19,36 +19,26 @@ const router = Router();
router.use(authenticate());
/** GET /api/students?classId=&keyword= - 学生名单(教师/管理员) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ query: listStudentsQuerySchema }),
async (req, res) => {
router.get('/', requireRole('ADMIN'), validate({ query: listStudentsQuerySchema }), async (req, res) => {
const user = getAuthUser(req);
const query = validatedQuery<{ classId?: string; keyword?: string }>(req);
sendOk(res, await studentService.listStudents(user, query), '获取学生列表成功');
},
);
});
/** POST /api/students - 新建学生账号 */
router.post(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ body: createStudentSchema }),
async (req, res) => {
router.post('/', requireRole('ADMIN'), validate({ body: createStudentSchema }), async (req, res) => {
const user = getAuthUser(req);
sendCreated(
res,
await studentService.createStudent(user, req.body as CreateStudentInput),
'学生账号创建成功',
);
},
);
});
/** PATCH /api/students/:id - 修改学生信息 / 调班 */
router.patch(
'/:id',
requireRole('ADMIN', 'TEACHER'),
requireRole('ADMIN'),
validate({ params: idParamSchema, body: updateStudentSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -64,7 +54,7 @@ router.patch(
/** POST /api/students/:id/reset-password - 重置密码 */
router.post(
'/:id/reset-password',
requireRole('ADMIN', 'TEACHER'),
requireRole('ADMIN'),
validate({ params: idParamSchema, body: resetPasswordSchema }),
async (req, res) => {
const user = getAuthUser(req);
@@ -75,17 +65,12 @@ router.post(
);
/** DELETE /api/students/:id - 删除学生账号 */
router.delete(
'/:id',
requireRole('ADMIN', 'TEACHER'),
validate({ params: idParamSchema }),
async (req, res) => {
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
await studentService.deleteStudent(user, id);
sendOk(res, { id }, '学生账号已删除');
},
);
});
export const studentsModule = defineModule({
name: 'students',
@@ -1,6 +1,6 @@
import type { StudentDto } from '@classhelper/shared';
import type { StudentDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { assertClassWritable, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js';
import { assertCanManageRoster, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import type { TokenPayload } from '../../lib/jwt.js';
@@ -37,7 +37,7 @@ export async function listStudents(user: TokenPayload, options: ListStudentOptio
/** 新建学生账号(可选直接分班) */
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
if (input.classId) await assertClassWritable(user, input.classId);
if (input.classId) assertCanManageRoster(user);
const passwordHash = await hashPassword(input.password ?? env.defaultStudentPassword);
const created = await prisma.user.create({
@@ -75,14 +75,14 @@ export async function updateStudent(
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
assertCanManageRoster(user);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以直接修改');
}
const nextClassId = input.classId === undefined ? student.classId : (input.classId ?? null);
if (nextClassId && nextClassId !== student.classId) {
await assertClassWritable(user, nextClassId);
assertCanManageRoster(user);
}
const updated = await prisma.user.update({
@@ -119,7 +119,7 @@ export async function deleteStudent(user: TokenPayload, studentId: string): Prom
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
assertCanManageRoster(user);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以删除');
}
@@ -137,7 +137,7 @@ export async function resetPassword(
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
if (student.classId) {
await assertClassWritable(user, student.classId);
assertCanManageRoster(user);
} else if (!isAdmin(user)) {
throw ApiError.forbidden('该学生尚未分班,仅管理员可以重置密码');
}
@@ -1,4 +1,4 @@
import { Router } from 'express';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedQuery } from '../../middleware/validate.js';
@@ -10,15 +10,10 @@ const router = Router();
router.use(authenticate());
/** GET /api/teachers?keyword= - 教师列表(分配协作教师时使用) */
router.get(
'/',
requireRole('ADMIN', 'TEACHER'),
validate({ query: listTeachersQuerySchema }),
async (req, res) => {
router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
},
);
});
/** POST /api/teachers - 新建教师账号(仅管理员) */
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => {
+1
View File
@@ -3,5 +3,6 @@
* 后端、Web 管理端、桌面客户端统一从这里导入类型与工具函数。
*/
export * from './constants.js';
export * from './permissions.js';
export * from './utils.js';
export type * from './types.js';
+93
View File
@@ -0,0 +1,93 @@
import type { UserRole } from './types.js';
/**
* 班级内的角色(相对"某一个班级"而言)。
*
* - `ADMIN` 系统管理员:班级增删改、人员分配、全部数据
* - `HEAD` 班主任(`Class.teacherId`):本班课表 + 作业/通知/叫人
* - `SUBJECT` 科任老师(`ClassTeacher`):仅作业/通知/叫人
* - `STUDENT` 学生(个人账号或班级账号):只读
* - `NONE` 与该班级无关
*/
export type ClassRole = 'ADMIN' | 'HEAD' | 'SUBJECT' | 'STUDENT' | 'NONE';
export interface ClassRoleContext {
role: UserRole;
/** 是否是该班班主任(Class.teacherId === 当前用户) */
isHeadTeacher?: boolean;
/** 是否被分配为该班科任/协作老师(ClassTeacher 中存在当前用户) */
isSubjectTeacher?: boolean;
}
/** 依据账号角色与该班归属关系,解析出班级内角色 */
export function resolveClassRole(context: ClassRoleContext): ClassRole {
if (context.role === 'ADMIN') return 'ADMIN';
if (context.role === 'TEACHER') {
if (context.isHeadTeacher) return 'HEAD';
if (context.isSubjectTeacher) return 'SUBJECT';
return 'NONE';
}
if (context.role === 'STUDENT') return 'STUDENT';
return 'NONE';
}
/** 班级本身(创建/修改/删除)与人员分配:仅管理员 */
export function canManageClasses(role: UserRole): boolean {
return role === 'ADMIN';
}
/** 班主任与科任老师名单的编辑:仅管理员 */
export function canAssignTeachers(role: UserRole): boolean {
return role === 'ADMIN';
}
/** 学生名单(增删学生、重置密码、导入名单):仅管理员 */
export function canManageRoster(role: UserRole): boolean {
return role === 'ADMIN';
}
/** 课表管理:管理员或本班班主任(科任老师不可以) */
export function canManageSchedule(classRole: ClassRole): boolean {
return classRole === 'ADMIN' || classRole === 'HEAD';
}
/** 课程(课表的前置数据):与课表同权限 */
export function canManageCourses(classRole: ClassRole): boolean {
return canManageSchedule(classRole);
}
/** 布置作业 / 叫人 / 发通知:管理员、班主任、科任老师皆可(学生不可) */
export function canPublishContent(classRole: ClassRole): boolean {
return classRole === 'ADMIN' || classRole === 'HEAD' || classRole === 'SUBJECT';
}
/** 成绩录入/修改/导入:仅管理员(按需求 7 的权限清单) */
export function canManageGrades(role: UserRole): boolean {
return role === 'ADMIN';
}
/** 可导入的数据类型 */
export type ImportKind = 'grades' | 'students' | 'scheduleTimes';
/** 导入权限:成绩=管理员;名单=管理员;课表时间=管理员或本班班主任 */
export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind): boolean {
if (kind === 'scheduleTimes') return canManageSchedule(classRole);
return canManageGrades(role);
}
/** 人类可读的权限矩阵(README / 设置页展示,单一事实来源) */
export const PERMISSION_MATRIX: readonly {
action: string;
admin: boolean;
head: boolean;
subject: boolean;
}[] = [
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
{ action: '分配班主任与科任老师', admin: true, head: false, subject: false },
{ action: '学生名单管理(增删 / 重置密码 / 导入)', admin: true, head: false, subject: false },
{ action: '课表管理(增删改 / 时间配置导入)', admin: true, head: true, subject: false },
{ action: '布置作业', admin: true, head: true, subject: true },
{ action: '叫人', admin: true, head: true, subject: true },
{ action: '发布通知', admin: true, head: true, subject: true },
{ action: '成绩录入 / 修改 / 导入', admin: true, head: false, subject: false },
];
+21 -9
View File
@@ -14,16 +14,28 @@ const auth = useAuthStore();
const realtime = useRealtimeStore();
const { isMobile } = useResponsive();
/**
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
* - 班级管理 / 学生管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
* - 课表管理:管理员 + 班主任(页面内再按"是否本班班主任"控制增删改按钮)
* - 作业 / 通知:所有教师
* - 成绩录入:仅管理员(成绩写入仅管理员;教师仍可在仪表盘查看汇总)
*/
const menuItems = [
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer' },
{ path: '/classes', title: '班级管理', icon: 'School' },
{ path: '/students', title: '学生管理', icon: 'User' },
{ path: '/schedules', title: '课表管理', icon: 'Calendar' },
{ path: '/homeworks', title: '作业发布', icon: 'Notebook' },
{ path: '/notifications', title: '通知发布', icon: 'Bell' },
{ path: '/grades', title: '成绩录入', icon: 'Trophy' },
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
{ path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] },
{ path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] },
{ path: '/schedules', title: '课表管理', icon: 'Calendar', roles: ['ADMIN', 'TEACHER'] },
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN'] },
];
/** 当前账号可见的菜单(教师看不到班级/学生/成绩录入等无权入口) */
const visibleMenuItems = computed(() =>
menuItems.filter((item) => (auth.role ? item.roles.includes(auth.role) : false)),
);
/** 高亮当前菜单:直接比较路由路径,避免依赖路由名 */
const activeMenu = computed(() => route.path);
@@ -129,7 +141,7 @@ async function submitPassword(): Promise<void> {
<span class="brand-text">班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-menu-item v-for="item in visibleMenuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
@@ -217,7 +229,7 @@ async function submitPassword(): Promise<void> {
<span class="brand-text">班级小助手</span>
</div>
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
<el-menu-item v-for="item in menuItems" :key="item.path" :index="item.path">
<el-menu-item v-for="item in visibleMenuItems" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
+9 -4
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
@@ -11,12 +11,17 @@ import {
type UserDto,
} from '@classhelper/shared';
import { classApi, courseApi, teacherApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { useResponsive } from '@/composables/useResponsive';
const auth = useAuthStore();
const realtime = useRealtimeStore();
const { isMobile } = useResponsive();
/** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */
const isAdmin = computed(() => auth.role === 'ADMIN');
/** 小屏下详情描述改为单列,避免文字被压成竖排 */
const detailColumns = computed(() => (isMobile.value ? 1 : 3));
@@ -239,7 +244,7 @@ onUnmounted(() => {
@clear="loadClasses"
/>
<el-button :icon="'Search'" @click="loadClasses">查询</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
</div>
</div>
@@ -268,8 +273,8 @@ onUnmounted(() => {
<el-table-column label="操作" width="230" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="danger" @click="removeClass(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>
</el-table-column>
</el-table>
+9 -4
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref, watch } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import * as echarts from 'echarts';
@@ -14,8 +14,13 @@ import {
type StudentDto,
} from '@classhelper/shared';
import { classApi, courseApi, gradeApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
const auth = useAuthStore();
/** 成绩写入仅管理员(教师仍可查看列表与统计,符合需求 7 的权限清单) */
const isAdmin = computed(() => auth.role === 'ADMIN');
const realtime = useRealtimeStore();
const loading = ref(false);
@@ -273,8 +278,8 @@ onUnmounted(() => {
<el-select v-model="filter.examName" placeholder="全部考试" clearable style="width: 160px">
<el-option v-for="item in examOptions" :key="item" :label="item" :value="item" />
</el-select>
<el-button type="primary" :icon="'Plus'" @click="openSingle">单条录入</el-button>
<el-button type="success" :icon="'Upload'" @click="openBulk">批量录入</el-button>
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openSingle">单条录入</el-button>
<el-button v-if="isAdmin" type="success" :icon="'Upload'" @click="openBulk">批量录入</el-button>
</div>
</div>
@@ -335,7 +340,7 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="danger" @click="removeGrade(row)">删除</el-button>
<el-button v-if="isAdmin" link type="danger" @click="removeGrade(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
@@ -1,7 +1,8 @@
<script setup lang="ts">
<script setup lang="ts">
import { onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
CALL_QUICK_PHRASES,
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
PRIORITY_TAG_TYPES,
@@ -13,8 +14,9 @@ import {
type ClassPeriod,
type NotificationDto,
type NotificationPriority,
type StudentDto,
} from '@classhelper/shared';
import { classApi, notificationApi, scheduleApi } from '@/api';
import { callApi, classApi, notificationApi, scheduleApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
@@ -49,6 +51,68 @@ async function loadNotifications(): Promise<void> {
}
}
/* ------------------------------------------------------------ 叫人(教师可用) */
/**
* 叫人入口放在通知页:班主任与科任老师都能用(学生管理页是管理员专属)。
* 学生名单走 `/classes/:id/students`(班级详情接口,教师有权访问),
* 不使用管理员专属的 `/students`。
*/
const callVisible = ref(false);
const callSending = ref(false);
const callClassId = ref('');
const callStudentId = ref('');
const callStudents = ref<StudentDto[]>([]);
const callQuickPhrase = ref<string>(CALL_QUICK_PHRASES[0] ?? '');
const callMessage = ref('');
async function openCall(): Promise<void> {
callClassId.value = filter.classId || classes.value[0]?.id || '';
callStudentId.value = '';
callMessage.value = '';
callQuickPhrase.value = CALL_QUICK_PHRASES[0] ?? '';
callStudents.value = [];
callVisible.value = true;
if (callClassId.value) await loadCallStudents();
}
async function loadCallStudents(): Promise<void> {
callStudentId.value = '';
callStudents.value = callClassId.value ? await classApi.students(callClassId.value) : [];
}
function pickCallPhrase(phrase: string): void {
callQuickPhrase.value = callQuickPhrase.value === phrase ? '' : phrase;
}
async function submitCall(): Promise<void> {
if (!callClassId.value) {
ElMessage.warning('请选择班级');
return;
}
if (!callStudentId.value) {
ElMessage.warning('请选择学生');
return;
}
if (!callMessage.value.trim() && !callQuickPhrase.value.trim()) {
ElMessage.warning('请选择快捷短语或填写自定义消息');
return;
}
callSending.value = true;
try {
const created = await callApi.create({
classId: callClassId.value,
studentId: callStudentId.value,
...(callMessage.value.trim() ? { message: callMessage.value.trim() } : {}),
...(callQuickPhrase.value.trim() ? { quickPhrase: callQuickPhrase.value.trim() } : {}),
});
ElMessage.success(`已通知:${created.title}`);
callVisible.value = false;
} finally {
callSending.value = false;
}
}
/* ------------------------------------------------------------ 发布 */
const formVisible = ref(false);
@@ -73,7 +137,9 @@ const urgentWarning = reactive({
title: '',
});
function openCreate(): void {
async function openCreate(): Promise<void> {
// 班级列表可能还没加载完(进入页面后立刻点按钮):先补一次,避免「目标班级」为空导致校验失败
if (classes.value.length === 0) await loadClasses().catch(() => undefined);
form.classId = filter.classId || classes.value[0]?.id || '';
form.title = '';
form.content = '';
@@ -202,6 +268,7 @@ onUnmounted(() => {
@keyup.enter="loadNotifications"
@clear="loadNotifications"
/>
<el-button type="warning" :icon="'Bell'" @click="openCall">叫人</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">发布通知</el-button>
</div>
</div>
@@ -273,6 +340,59 @@ onUnmounted(() => {
</template>
</el-dialog>
<!-- 叫人:选班级 → 选学生 → 快捷短语 / 自定义消息 → 学生端灵动岛立即弹出 -->
<el-dialog v-model="callVisible" title="叫人" width="520px">
<el-form label-width="76px">
<el-form-item label="班级">
<el-select v-model="callClassId" style="width: 100%" @change="loadCallStudents">
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="学生">
<el-select v-model="callStudentId" filterable placeholder="选择学生" style="width: 100%">
<el-option
v-for="item in callStudents"
:key="item.id"
:label="`${item.name}(${item.username})`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="快捷短语">
<div class="call-phrases">
<el-tag
v-for="phrase in CALL_QUICK_PHRASES"
:key="phrase"
class="call-phrase"
:effect="callQuickPhrase === phrase ? 'dark' : 'plain'"
:type="callQuickPhrase === phrase ? 'warning' : 'info'"
@click="pickCallPhrase(phrase)"
>
{{ phrase }}
</el-tag>
</div>
</el-form-item>
<el-form-item label="自定义">
<el-input
v-model="callMessage"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="可选;填写后优先于快捷短语"
/>
</el-form-item>
</el-form>
<el-alert
type="warning"
:closable="false"
title="学生端桌面会立即浮出「请 XXX 同学找 XXX 老师」,上课时段也照常弹出"
/>
<template #footer>
<el-button @click="callVisible = false">取消</el-button>
<el-button type="warning" :loading="callSending" @click="submitCall">发送叫人</el-button>
</template>
</el-dialog>
<!-- 上课时段发布紧急通知的全屏二次确认(3 秒倒计时) -->
<UrgentClassWarning
:visible="urgentWarning.visible"
+29 -4
View File
@@ -1,4 +1,4 @@
<script setup lang="ts">
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
@@ -14,10 +14,23 @@ import {
type ScheduleWeekView,
} from '@classhelper/shared';
import { classApi, courseApi, dashboardApi, scheduleApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
const auth = useAuthStore();
const realtime = useRealtimeStore();
/**
* 课表管理权限:管理员,或"所选班级的班主任"(Class.teacherId === 当前用户)。
* 与后端 assertCanManageSchedule 使用同一权限矩阵;科任老师不显示写入口。
*/
const canManageSchedule = computed(() => {
if (auth.role === 'ADMIN') return true;
if (auth.role !== 'TEACHER') return false;
const current = classes.value.find((item) => item.id === query.classId);
return Boolean(current && current.teacherId === auth.user?.id);
});
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const courses = ref<CourseDto[]>([]);
@@ -212,7 +225,9 @@ onUnmounted(() => {
<el-option v-for="week in weekOptions" :key="week" :label="`第 ${week} 周`" :value="week" />
</el-select>
<el-button :icon="'Refresh'" @click="loadSchedules">刷新</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新增课表</el-button>
<el-button v-if="canManageSchedule" type="primary" :icon="'Plus'" @click="openCreate">
新增课表
</el-button>
</div>
</div>
@@ -241,8 +256,18 @@ onUnmounted(() => {
<div class="timetable-meta">{{ item.location ?? '未填地点' }}</div>
<div class="timetable-meta">{{ formatWeekRange(item.weekStart, item.weekEnd) }}</div>
<div>
<el-button link type="primary" size="small" @click="openEdit(item)">编辑</el-button>
<el-button link type="danger" size="small" @click="removeSchedule(item)">删除</el-button>
<el-button v-if="canManageSchedule" link type="primary" size="small" @click="openEdit(item)">
编辑
</el-button>
<el-button
v-if="canManageSchedule"
link
type="danger"
size="small"
@click="removeSchedule(item)"
>
删除
</el-button>
</div>
</div>
</template>
+47 -16
View File
@@ -1,4 +1,4 @@
/**
/**
* Web 管理端 UI 冒烟测试(Electron 驱动真实浏览器内核)。
*
* 覆盖:
@@ -29,17 +29,21 @@ const USERNAME = process.env.UI_SMOKE_USER ?? 'teacher1';
const PASSWORD = process.env.UI_SMOKE_PASS ?? 'teacher123';
const RESULT_FILE = process.env.UI_SMOKE_RESULT ?? '';
/** 侧边栏菜单:显示文案 -> 期望路由 */
/**
* 侧边栏菜单(教师账号 teacher1 可见的部分)。
* 按新的角色权限模型:班级管理 / 学生管理 / 成绩录入 仅管理员可见,
* 教师端不应出现这些入口(后端同时强校验),因此期望值只有 4 项。
*/
const MENU_ITEMS = [
{ label: '仪表盘', path: '/dashboard' },
{ label: '班级管理', path: '/classes' },
{ label: '学生管理', path: '/students' },
{ label: '课表管理', path: '/schedules' },
{ label: '作业发布', path: '/homeworks' },
{ label: '通知发布', path: '/notifications' },
{ label: '成绩录入', path: '/grades' },
];
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
const HIDDEN_MENU_LABELS = ['班级管理', '学生管理', '成绩录入'];
const results = [];
function record(name, ok, detail = '') {
results.push({ name, ok, detail });
@@ -218,8 +222,15 @@ async function main() {
if (!openButton) return { ok: false, detail: '未找到"发布通知"按钮' };
openButton.click();
const dialog = await waitFor(() => document.querySelector('.el-dialog'));
// 页面里同时存在「发布通知」与「叫人」两个弹窗,按标题精确定位
const dialog = await waitFor(() =>
Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('发布通知'),
),
);
if (!dialog) return { ok: false, detail: '发布通知弹窗未打开' };
// 等目标班级下拉选好值(班级列表是异步加载的,避免空值导致校验失败)
await waitFor(() => (dialog.querySelector('.el-select__selected-item')?.textContent ?? '').trim().length > 0, 5000);
const titleItem = formItem(dialog, '标题');
const contentItem = formItem(dialog, '内容');
@@ -240,8 +251,12 @@ async function main() {
if (!publish) return { ok: false, detail: '未找到"立即发布"按钮' };
publish.click();
const mask = await waitFor(() => document.querySelector('.urgent-mask'), 10000);
if (!mask) return { ok: false, detail: '上课时段发布紧急通知未弹出全屏警告' };
const mask = await waitFor(() => document.querySelector('.urgent-mask'), 15000);
if (!mask) {
const errors = Array.from(dialog.querySelectorAll('.el-form-item__error')).map((n) => n.textContent.trim()).join('|');
const tips = Array.from(document.querySelectorAll('.el-message')).map((n) => n.textContent.trim()).join('|');
return { ok: false, detail: '未弹出全屏警告|表单错误=' + (errors || '无') + '|页面提示=' + (tips || '无') + '|班级=' + ((dialog.querySelector('.el-select__selected-item')?.textContent ?? '').trim()) };
}
const maskText = (mask.textContent ?? '').replace(/\\s+/g, ' ').trim();
const periodText = (mask.querySelector('.urgent-period .period-value')?.textContent ?? '').trim();
@@ -331,25 +346,41 @@ async function main() {
})()`);
record('Service Worker 已注册(浏览器可安装为应用)', Boolean(swInfo?.ok), String(swInfo?.detail ?? ''));
// 7.5 "叫人"入口(学生管理 → 叫人 → 快捷短语/自定义消息)
// 7.5 权限入口隐藏(教师端不应出现班级/学生/成绩录入入口)
const hiddenCheck = await win.webContents.executeJavaScript(`(() => {
const labels = Array.from(document.querySelectorAll('.el-menu-item')).map((node) =>
(node.textContent ?? '').trim(),
);
const leaked = ${JSON.stringify(HIDDEN_MENU_LABELS)}.filter((item) => labels.some((label) => label.startsWith(item)));
return { labels, leaked };
})()`);
record(
'教师端隐藏无权入口(班级管理/学生管理/成绩录入)',
(hiddenCheck?.leaked ?? ['?']).length === 0,
`可见菜单=[${(hiddenCheck?.labels ?? []).join(',')}] 越权入口=[${(hiddenCheck?.leaked ?? []).join(',')}]`,
);
// 7.6 "叫人"入口(通知发布 → 叫人 → 快捷短语/自定义消息)
const callDialog = await win.webContents.executeJavaScript(`(async () => {
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
(node.textContent ?? '').trim().startsWith('学生管理'),
(node.textContent ?? '').trim().startsWith('通知发布'),
);
if (!menu) return { ok: false, reason: '未找到学生管理菜单' };
if (!menu) return { ok: false, reason: '未找到通知发布菜单' };
menu.click();
const deadline = Date.now() + 6000;
while (Date.now() < deadline && location.pathname !== '/students') {
while (Date.now() < deadline && location.pathname !== '/notifications') {
await new Promise((resolve) => setTimeout(resolve, 80));
}
await new Promise((resolve) => setTimeout(resolve, 600));
const callButton = Array.from(document.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').trim() === '叫人',
);
if (!callButton) return { ok: false, reason: '学生列表里没有"叫人"按钮' };
if (!callButton) return { ok: false, reason: '通知页没有"叫人"按钮' };
callButton.click();
await new Promise((resolve) => setTimeout(resolve, 500));
const dialog = document.querySelector('.el-dialog');
await new Promise((resolve) => setTimeout(resolve, 700));
const dialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('叫人'),
);
if (!dialog) return { ok: false, reason: '叫人弹窗未打开' };
const phrases = Array.from(dialog.querySelectorAll('.call-phrase')).map((node) => node.textContent.trim());
const hasTextarea = Boolean(dialog.querySelector('textarea'));
@@ -474,7 +505,7 @@ async function runMobileChecks(win) {
await captureMobileShot(win, 'mobile-1-drawer');
record(
'手机端抽屉菜单(点汉堡滑出,菜单项完整)',
drawerInfo?.open === true && drawerInfo?.items >= 7 && !/enter-from/.test(drawerInfo?.overlayClass ?? ''),
drawerInfo?.open === true && drawerInfo?.items >= 4 && !/enter-from/.test(drawerInfo?.overlayClass ?? ''),
`菜单项=${drawerInfo?.items} left=${drawerInfo?.left} aria-expanded=${drawerInfo?.expandAttr} ` +
`transform=${drawerInfo?.transform} 遮罩类=${drawerInfo?.overlayClass}`,
);