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