feat(teachers,classes,schedules,web): 教师录入(仅管理员)+ 班主任可设置/更改 + 授课科目全校统一 + 登录页去示例

① 支持录入教师(与学生录入对齐),权限只有管理员
- 新增 Web「教师管理」页(`Views/TeachersView.vue`):单个录入(姓名/用户名/初始密码/角色)、
  导入名单(xlsx/csv:用户名、姓名、初始密码、角色)、编辑(改名/改角色)、重置密码、删除
- 服务端 `teachers` 模块补齐:GET / POST / PATCH /:id / POST /:id/reset-password / DELETE /:id,
  全部 `requireRole('ADMIN')`;表格导入新增 kind=teachers(`IMPORT_FIELDS.teachers`、`commitTeachers`、
  教师名单模板),教师调用预览/提交一律 403
- 删除护栏:还是班主任(`Class.teacherId`)或持有课程/班级分配的账号拒绝删除(409,提示先更换班主任)——
  教师是数据创建者,级联删除会连带删掉班级数据
- 前端路由加 `meta.roles`:`/classes`、`/students`、`/teachers` 仅 ADMIN,教师直接输网址会被挡回仪表盘
  (菜单隐藏只是"看不见",这次补上真正的守卫);新增环境变量 `DEFAULT_TEACHER_PASSWORD`

② 创建班级仅管理员 + 设置 / 更改班主任
- 新建班级时可直接指定班主任(不填则创建者本人),班级详情顶部「更换班主任」下拉随时改:
  新增 `PATCH /api/classes/:id/head-teacher`(仅管理员,校验目标账号是教师/管理员,广播 class:updated)
- 原班主任保留在协作教师名单里,避免换人后丢掉历史协作关系

③ 授课科目全校统一(用户澄清:"统一"指的是授课科目固定,不是各班课表相同)
- `SUBJECT_CATALOG` 改为用户给定的 18 科:语文/数学/英语/政治/历史/物理/化学/地理/生物/信息技术/
  体育/美术/音乐/班会/早读/晚读/通用技术/听力
- 课表「新增课表」与成绩「单条/批量录入」的科目下拉直接列出统一目录(该班还没有的进
  「统一科目(选中后自动建课)」分组),选中后自动为该班建同名课程再落库 —— 不必按班级一个个录科目
- 各班课表仍然各自独立(各班的课表本来就不一样)

④ 登录页去掉所有示例内容
- Web 登录页删除「演示账号(点击填充)」标签列表与"种子数据见 README"提示;客户端班级码占位符
  由「例如 G101(由老师在班级管理中提供)」改为「请输入班级码」
- `verify:web` 与 `verify:desktop` 各自断言登录页正文/占位符不含 `演示/示例/admin123/teacher123/G101`

⑤ 验证脚本不再依赖演示数据(并修掉自身残留)
- 清空服务端作业/通知后,联网自检改为**自建**一条作业/通知并在收尾删除(`seedSmokeContent`);
  权限用例需要"其它教师的班级/科任班级"时现建临时班级并在收尾删除
- e2e 新增清理:实时推送用例造的通知/作业、上课时段用例造的两条通知、教师名单/临时班级
  (此前这几条会留在库里);班级详情相关断言不再依赖种子班级布局
- ui-smoke 新增 7 项:登录页无示例、教师端访问 /teachers 被挡回、统一科目下拉 + 自动建课 + 收尾删除
  课表与课程、管理员真人录入教师 → 列表出现 → 删除
- 冒烟里"岛就绪"与"截图留档"改为轮询等待(偶发丢一张留档图会误判"形态尺寸递增")

实测:verify:e2e 163/163(安装实例)、verify:web 27/27、verify:desktop 77/77(离线复跑 69/69)、
verify:packaged 对 release/win-unpacked 与 D:\class\@classhelperdesktop-client 均 77/77 且 packaged=true;
typecheck / lint / format:check 全绿。服务端安装实例与客户端安装目录已就地更新。
This commit is contained in:
LoveChengke committed 2026-09-13 10:34:46 +08:00
1 parent 0ff2dc228b
commit 845fbdd863
27 files changed
+1627 -172

No files matched your search

+53 -21
View File
@@ -20,7 +20,7 @@
| 1 | pnpm monorepo 脚手架、TypeScript / ESLint / Prettier / 环境变量 | ✅ 已完成 |
| 2 | 后端:Prisma schema + 迁移 + 种子数据 + JWT 认证 + RBAC + 模块化 REST API + Socket.IO | ✅ 已完成 |
| 3 | Web 管理端:登录、主布局、仪表盘、班级/学生/课表/作业/通知/成绩页面、Axios 封装、实时提示 | ✅ 已完成 |
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 76/76) |
| 4 | EXE 客户端:Electron 主进程/preload/渲染进程、登录、课表/作业/通知/成绩/设置、实时推送、IndexedDB 离线缓存 | ✅ 已完成(冒烟验证 77/77) |
| 5 | 三端联调脚本、打包命令、完整 README | ✅ 已完成(三套安装包 + 两套 UI 回归测试) |
| 6 | 测试账号与种子数据说明 | ✅ 已完成(见下文) |
| 7 | 生产化:服务端安装程序(内置 Node)、Web 端 PWA 可安装、Docker + Nginx 部署、生产加固与运维文档 | ✅ 已完成(见 [`docs/production.md`](docs/production.md)) |
@@ -75,7 +75,7 @@ class-helper/
│ ├── prisma/migrations/ # 迁移历史(已生成并应用)
│ ├── prisma/seed.ts # 种子数据
│ ├── prisma.config.ts # Prisma 7 配置(迁移/种子/连接串)
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(149 项)
│ ├── scripts/verify-e2e.mjs # 后端端到端验收脚本(163 项)
│ └── src/
│ ├── app.ts # Express 装配(静态托管 + 探针 + 限流 + 模块挂载)
│ ├── index.ts # 启动入口(自检/初始化 + HTTP + Socket.IO + 优雅退出)
@@ -171,16 +171,16 @@ pnpm verify:desktop
```bash
# 另开一个终端先启动后端:pnpm dev:server
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 单双周课表/ClassIsland 课程表导入 + 班级账号(个人学生登录停用):149 项
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、多班批量发布、未交名单、课程表导入弹窗、手机端课表横向滚动):20 项
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/联合通知回归/胶囊类型汇总、未读红点位置、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、作业看板全屏自适应、个性化与设置页链路、托盘/退出无残留):76 项
pnpm verify:e2e # 后端 + REST + Socket.IO + RBAC + 上课时段拦截 + 紧急/普通叫人 + 单双周课表/ClassIsland 课程表导入 + 班级账号(个人学生登录停用):163 项
pnpm verify:web # Web 管理端真实点击(含权限入口隐藏、手机适配、叫人入口 + 紧急/普通级别、多班批量发布、未交名单、课程表导入弹窗、手机端课表横向滚动、教师录入、统一科目):27 项
pnpm verify:desktop # Electron 客户端(含灵动岛收起常驻/命中兜底/展开收起再展开/联合通知回归/胶囊类型汇总、未读红点位置、圆角四角一致、开合不震动、紧急与普通叫人、真实链路、课表时间轴、作业看板全屏自适应、个性化与设置页链路、托盘/退出无残留):77 项
pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一套冒烟(证明 packaged=true,默认 release/win-unpacked)
```
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权、单双周课表与 ClassIsland 课程表导入、个人学生登录停用**等 149 项,
后端脚本验证**实时推送时延、作业完成、成绩下发、权限隔离、上课时段紧急通知拦截、导入与失败回滚、班级账号代全班操作、老师端导入不越权、单双周课表与 ClassIsland 课程表导入、个人学生登录停用**等 163 项,
实测通知 37ms、作业 26ms、成绩 25ms 到达(要求 < 5 秒),紧急通知 409 拦截与二次确认后发布均通过;
客户端脚本验证 preload 桥接、渲染进程、IndexedDB 读写、断网回退、**灵动岛四种状态切换**与联网集成
(**班级账号登录** + 四类数据 + Socket.IO),实测 76/76 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 68/68)。
(**班级账号登录** + 四类数据 + Socket.IO),实测 77/77 通过;退出后再启动一次校验单实例锁与文件锁已释放(离线复跑 69/69)。
`pnpm verify:packaged --exe "<客户端路径>"` 对**打包后/已安装**的 EXE 跑同一套用例(`packaged: true`),
用于确认交付给学生的那个副本真的带上了本次修复 —— 开发产物通过**不等于**用户机器上的副本通过。
@@ -223,9 +223,9 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
| `pnpm dist:dir` | 打包客户端免安装目录 `release/win-unpacked`(含可执行文件,最快) |
| `pnpm dist:win` | 打包客户端 nsis 安装包 + portable 单文件 EXE |
| `pnpm dist:all` | 服务端安装程序 + 客户端安装程序一起打 |
| `pnpm verify:e2e` | 后端端到端验收(149 项,含角色权限矩阵、单双周课表与 ClassIsland 课程表导入、导入与班级账号,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,20 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
| `pnpm verify:desktop` | EXE 客户端冒烟验证(76 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、作业看板全屏自适应、个性化全参数) |
| `pnpm verify:e2e` | 后端端到端验收(163 项,含角色权限矩阵、单双周课表与 ClassIsland 课程表导入、导入与班级账号,需服务端已启动) |
| `pnpm verify:web` | Web 管理端 UI 真实点击测试(Electron 驱动,27 项,含权限入口隐藏、叫人紧急/普通与导入弹窗) |
| `pnpm verify:desktop` | EXE 客户端冒烟验证(77 项,含灵动岛圆角/收起常驻/命中兜底/展开收起再展开/开合不震动/已读/作业/紧急与普通叫人、课表时间轴、作业看板全屏自适应、个性化全参数) |
| `pnpm verify:packaged` | 对打包后/已安装的客户端 EXE 跑同一套冒烟(`--exe` 指定路径,实测 `packaged: true`) |
| `pnpm typecheck` | 全仓库类型检查(含 `vue-tsc`) |
| `pnpm lint` / `pnpm lint:fix` | ESLint 检查 / 自动修复 |
@@ -289,7 +289,34 @@ pnpm verify:packaged # 对**打包后/已安装**的客户端 EXE 跑同一
实现位置:`src/composables/useResponsive.ts`(断点 + resize 监听)、`src/layouts/AdminLayout.vue`(抽屉/顶栏)、
`src/styles/index.css`(全局响应式规则)、各视图表格卡片加 `.table-card`。
`pnpm verify:web` 的 20 项里,后半段专门验证权限入口隐藏、手机端、叫人入口(含紧急/普通两级)、成绩导入入口与 ClassIsland 时间配置导入弹窗(Electron 真实点击):
### 人员与班级(仅管理员)
| 能力 | 说明 |
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **教师管理**(新页面) | 侧边栏「教师管理」:**单个录入**(姓名 / 用户名 / 初始密码 / 角色)+**导入名单**(xlsx/csv,列:用户名、姓名、初始密码、角色)+编辑(改名/改角色)+重置密码+删除 |
| 录入教师仅管理员 | 页面只对 `ADMIN` 显示,路由 `meta.roles=['ADMIN']` 直接输网址也会被挡回仪表盘;服务端 `/api/teachers` 全量接口 `requireRole('ADMIN')`(教师调用 403,e2e 有断言) |
| 删除教师有护栏 | 还是**班主任**(`Class.teacherId`)或持有课程/班级分配的账号拒绝删除(409,提示先更换班主任)——教师是数据创建者,级联删除会连带删掉班级数据 |
| 创建班级仅管理员 | `POST /api/classes` = `requireRole('ADMIN')`,教师(含班主任)403;前端「新建班级」也只在管理员可见 |
| **设置 / 更改班主任** | 新建班级时可直接选班主任(默认自己);班级详情顶部「更换班主任」下拉随时改(`PATCH /api/classes/:id/head-teacher`,仅管理员),原班主任仍保留在协作教师名单 |
| 学生录入(原有) | 「学生管理」:单个新建(登录名自动生成)+导入名单;学生端主体是班级账号 |
### 授课科目统一(不再按班级一个个录)
全校固定科目目录定义在 `@classhelper/shared` 的 `SUBJECT_CATALOG`:
**语文 / 数学 / 英语 / 政治 / 历史 / 物理 / 化学 / 地理 / 生物 / 信息技术 / 体育 / 美术 / 音乐 / 班会 / 早读 / 晚读 / 通用技术 / 听力**。
- 课表「新增课表」与成绩「单条/批量录入」的**科目下拉直接列出这个统一目录**(该班还没有的科目显示在「统一科目(选中后自动建课)」分组里);
- 选中后由前端自动为该班 `POST /api/courses` 建同名课程(已存在则复用),再写入课表/成绩 —— 科任与班主任都不需要先去"班级 → 课程"里把科目建一遍;
- ClassIsland 课程表导入本来就会按导入内容自动补建课程,两者口径一致;
- `pnpm verify:web` 会在真实浏览器里打开「新增课表」,断言统一科目分组存在、选中后课表写入成功,并在收尾删掉这条课表(不留副作用)。
### 登录页
管理端与客户端登录页都**不含任何示例/演示内容**(没有"演示账号点击填充"、没有示例班级码占位符、
不提示种子账号);`pnpm verify:web` 与 `pnpm verify:desktop` 各自断言登录页正文与占位符里不出现
`演示 / 示例 / admin123 / teacher123 / G101` 等字样,防止以后又被加回去。
`pnpm verify:web` 的 27 项里,后半段专门验证权限入口隐藏、手机端、叫人入口(含紧急/普通两级)、成绩导入入口与 ClassIsland 时间配置导入弹窗(Electron 真实点击):
| 校验项 | 实测结果 |
| -------------------------------------- | -------------------------------------------------------------------- |
@@ -620,7 +647,7 @@ pnpm dist:win # nsis 安装包 + portable 单文件(需联网下载 NSIS
| **退出后无残留(文件锁 + 二次启动)** | ✅ 配置目录可删除(句柄已释放)→ 用同一配置目录二次启动成功并再次全绿 |
该验证对**开发产物与打包后的 EXE 都适用**(打包后用 `ELECTRON_SMOKE_RESULT=<file>` 写出 JSON 结果,
已实测 `packaged: true`、76/76 通过、退出码 0)。灵动岛各状态的窗口截图会写到
已实测 `packaged: true`、77/77 通过、退出码 0)。灵动岛各状态的窗口截图会写到
`ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`),
并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。
冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。
@@ -991,7 +1018,7 @@ pnpm db:migrate && pnpm db:seed
## 验收标准对照
| 验收项 | 结果 | 证据 |
| ------------------------------------------------ | ---- | -------------------------------------------------------------------------------------------------------------------------- |
| ------------------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 教师 Web 端发布通知,学生端 5 秒内收到 | ✅ | `verify:e2e`:`notification:new` **37–44ms** |
| 教师发布作业,学生能查看并标记完成 | ✅ | `homework:new` **26–40ms**,`PATCH /homeworks/:id/status` 200 且列表回显 `completed=true` |
| 教师录入成绩,学生能查看个人成绩 | ✅ | `grade:updated` **21–25ms**,`/grades/my` 返回记录;批量录入与统计接口通过 |
@@ -1011,6 +1038,11 @@ pnpm db:migrate && pnpm db:seed
| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `verify:e2e` 班级账号 19 项:登录 / 错误密码 401 / 跨班 403 / 发布 403 / 班级码重复 400 / 密码重置 / 班级码用后还原 | `n | 成绩与表格导入:老师端可用且不越权 | ✅ | 班主任预览/导入本班成绩 200、跨班 403、科任 403、名单导入与模板 403;Web 端班主任可见导入按钮并打开弹窗 |
| 班级设备代全班操作(已读 · 完成 · 成绩总览) | ✅ | 标记已读写入 6 条(全班 6 人)、教师端 `readCount=6`、`completedCount=6`、`/grades/my` 全班成绩 |
| 客户端只保留班级登录入口 | ✅ | `verify:desktop`:`login=高一(1)班 classSession=true`,导航「成绩」标题变为「本班成绩」 |
| **教师录入(仅管理员)** | ✅ | `verify:e2e`:教师读/建教师 403、管理员建 201 → 编辑 → 重置密码(新密码 200/旧密码 401)→ 删除(有职责 409 / 无职责 200);`verify:web`:管理员真人点「教师管理 → 新建教师 → 保存 → 列表出现 → 删除」 |
| **教师名单表格导入(仅管理员)** | ✅ | `verify:e2e`:教师预览 403、管理员预览/提交 200(`inserted=1`)、导入账号可登录;模板列=用户名/姓名/初始密码/角色 |
| **创建班级仅管理员 + 设置/更改班主任** | ✅ | `verify:e2e`:教师建班 403;管理员建班可带 `teacherId`、`PATCH /classes/:id/head-teacher` 落库生效;`verify:web`:教师直接访问 `/teachers` 被挡回仪表盘 |
| **授课科目统一(18 科固定目录)** | ✅ | `verify:web`:新增课表的科目下拉出现「统一科目(选中后自动建课)」,选中 → 自动建课 → 写入课表成功(收尾删除该条课表) |
| **登录页无示例内容** | ✅ | `verify:web`(管理端)与 `verify:desktop`(客户端)都断言登录页正文/占位符不含 `演示 / 示例 / admin123 / teacher123 / G101` |
| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) |
| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 |
@@ -1018,9 +1050,9 @@ pnpm db:migrate && pnpm db:seed
| 产物 | 大小 | 冒烟结果 |
| -------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- |
| 服务端安装程序(内置 Node + Web 端) | 34MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 → **对安装实例跑 149/149 e2e + 20/20 UI** |
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 打包版冒烟 **76/76**(离线复跑 68/68),`packaged: true`,退出码 0(含灵动岛动画/作业看板全屏自适应/叫人/时间轴) |
| `release/…-x64-portable.exe`(单文件) | 107.1MB | ✅ 打包后冒烟 **76/76**,`packaged: true`,退出码 0(自解压单文件同样通过) |
| 服务端安装程序(内置 Node + Web 端) | 34MB | ✅ 静默安装(升级保留 `.env` 与数据库)→ 自动建库建号 → 服务就绪 → **对安装实例跑 163/163 e2e + 27/27 UI** |
| `release/win-unpacked/班级小助手.exe` | 234.7MB | ✅ 打包版冒烟 **77/77**(离线复跑 69/69),`packaged: true`,退出码 0(含灵动岛动画/作业看板全屏自适应/叫人/时间轴) |
| `release/…-x64-portable.exe`(单文件) | 107.1MB | ✅ 打包后冒烟 **77/77**,`packaged: true`,退出码 0(自解压单文件同样通过) |
| `release/…-x64-setup.exe`(客户端) | 107.4MB | ✅ 构建成功,已嵌入自定义图标;含灵动岛与本次行为修正 |
> **把修复交付到"已安装的客户端"**:给学生的 EXE 安装在 `D:\class\@classhelperdesktop-client`(桌面/开始菜单快捷方式指向它),
@@ -1039,11 +1071,11 @@ pnpm db:migrate && pnpm db:seed
当前实测:
| 验证 | 结果 |
| ---------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **149/149** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + PWA) | **20/20** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 作业看板全屏自适应 + 个性化全参数 + 托盘与退出无残留) | **76/76** ✅(离线复跑 68/68) |
| `pnpm verify:packaged`(对 `release/win-unpacked` 与 `D:\class\@classhelperdesktop-client` 实测) | **76/76**,`packaged: true` ✅ |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- |
| `pnpm verify:e2e`(开发环境与**安装后的生产实例**各跑一次) | **163/163** ✅ |
| `pnpm verify:web`(Web 管理端真实点击 + 权限入口隐藏 + 手机适配 + 叫人 + 成绩/时间配置导入 + 教师录入 + 统一科目 + PWA) | **27/27** ✅ |
| `pnpm verify:desktop`(客户端冒烟 + WinIsland 架构/连续圆角/收起常驻 + 命中兜底与"展开收起再展开" + 作业看板全屏自适应 + 登录页无示例 + 个性化全参数 + 托盘与退出无残留) | **77/77** ✅(离线复跑 69/69) |
| `pnpm verify:packaged`(对 `release/win-unpacked` 与 `D:\class\@classhelperdesktop-client` 实测) | **77/77**,`packaged: true` ✅ |
| `pnpm typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ |
| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ |
| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ |
+33 -3
View File
@@ -126,7 +126,13 @@ async function captureIsland(
// 截图统计总是执行(像素断言不依赖环境变量);ISLAND_SHOTS_DIR 只控制是否落盘留档
const dir = process.env.ISLAND_SHOTS_DIR ?? '';
const win = island.getWindow();
if (!win || win.isDestroyed() || !win.isVisible()) return null;
if (!win || win.isDestroyed()) return null;
// 窗口可能正处在"隐藏 → 重新显示"的过渡里(例如下课后自动弹出):
// 先等它可见,避免偶发丢掉一张留档图导致"形态尺寸递增"这类断言缺数据。
for (let attempt = 0; attempt < 15 && !win.isVisible(); attempt += 1) {
await sleep(100);
}
if (!win.isVisible()) return null;
try {
// 透明窗口直接 capturePage 会得到空白图:把它临时放到不透明背景上再截图
await win.webContents.executeJavaScript(
@@ -1475,11 +1481,19 @@ async function runIslandChecks(
const urgentReddish = urgentShot?.reddish ?? 0;
const outsideReddish = urgentShot?.outsideReddish ?? 0;
const pillShot = shotDetails.find((shot) => shot.name.includes('pill'));
// "形态尺寸递增":胶囊 < 某个展开形态 < 紧急形态。
// 展开形态优先取 after-class 留档,缺图时退回其它展开态(避免一张可选留档缺失就误判)。
const expandedShot = [
shotDetails.find((shot) => shot.name.includes('after-class')),
shotDetails.find((shot) => shot.name.includes('clicked')),
shotDetails.find((shot) => shot.name.includes('homework')),
].find(Boolean);
const sizesOrdered = Boolean(
pillShot &&
expandedShot &&
urgentShot &&
pillShot.height < (shotDetails.find((shot) => shot.name.includes('after-class'))?.height ?? 0) &&
(shotDetails.find((shot) => shot.name.includes('after-class'))?.height ?? 0) < urgentShot.height,
pillShot.height < expandedShot.height &&
expandedShot.height < urgentShot.height,
);
record(
@@ -2403,6 +2417,22 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
`text=${dom?.text ?? ''}`,
);
// 需求:"删掉登录页所有示例内容" —— 占位符/提示里不允许出现示例班级码或示例账号
const loginExampleLeak = await win.webContents.executeJavaScript(
`(() => {
const text = (document.body.innerText || '') + ' ' + Array.from(document.querySelectorAll('input'))
.map((node) => node.getAttribute('placeholder') || '')
.join(' ');
const tokens = ['例如 G101', 'G101', '例如G101', 'admin123', 'teacher123', '演示', '示例'];
return { leaked: tokens.filter((item) => text.includes(item)) };
})()`,
);
record(
'登录页不含示例内容(无示例班级码/演示账号)',
(loginExampleLeak?.leaked ?? ['?']).length === 0,
`越界文案=[${(loginExampleLeak?.leaked ?? []).join(',')}]`,
);
// preload 桥接
const bridge = await win.webContents.executeJavaScript(
`(() => ({
+110 -3
View File
@@ -123,6 +123,93 @@ export async function layoutNavigationSelfTest(): Promise<SmokeCheckResult> {
};
}
/** 自检临时造的作业/通知(收尾必须删掉,避免污染用户的库) */
let temporaryContent: { token: string; homeworks: string[]; notifications: string[] } | null = null;
/**
* 服务端业务数据可能被管理员清空(用户明确要求过"删掉所有作业和通知"),
* 因此联网自检在列表为空时**自建**一条作业/通知来验证链路,收尾(sessionCleanup)删除。
*/
async function seedSmokeContent(
classId: string | null,
need: { homework: boolean; notification: boolean },
): Promise<SmokeCheckResult> {
if (!classId) return { ok: false, detail: '缺少班级,无法自建数据' };
const serverUrl = 'http://127.0.0.1:4000';
const stamp = `自检 ${new Date().toLocaleTimeString('zh-CN')}`;
try {
const login = await fetch(`${serverUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: 'teacher1', password: 'teacher123' }),
}).then((response) => response.json());
const token: string | undefined = login?.data?.token;
if (!token) return { ok: false, detail: `教师登录失败:${JSON.stringify(login).slice(0, 120)}` };
const homeworks: string[] = [];
const notifications: string[] = [];
if (need.homework) {
const created = await fetch(`${serverUrl}/api/homeworks`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
classId,
courseId: null,
title: `自动化验证作业 ${stamp}`,
content: '服务端没有作业数据时由冒烟自检临时创建,验证结束后立即删除,不会留在库里。',
}),
}).then((response) => response.json());
const id: string | undefined = created?.data?.id;
if (!id) return { ok: false, detail: `自建作业失败:${JSON.stringify(created).slice(0, 160)}` };
homeworks.push(id);
}
if (need.notification) {
const created = await fetch(`${serverUrl}/api/notifications`, {
method: 'POST',
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
body: JSON.stringify({
classId,
title: `自动化验证通知 ${stamp}`,
content: '服务端没有通知数据时由冒烟自检临时创建,验证结束后立即删除,不会留在库里。',
priority: 'NORMAL',
}),
}).then((response) => response.json());
const id: string | undefined = created?.data?.id;
if (!id) return { ok: false, detail: `自建通知失败:${JSON.stringify(created).slice(0, 160)}` };
notifications.push(id);
}
temporaryContent = { token, homeworks, notifications };
return { ok: true, detail: `作业+${homeworks.length} 通知+${notifications.length}` };
} catch (error) {
return { ok: false, detail: `自建数据异常:${error instanceof Error ? error.message : String(error)}` };
}
}
/** 删除自检临时造的作业/通知 */
async function cleanupTemporaryContent(): Promise<string> {
if (!temporaryContent) return '';
const { token, homeworks, notifications } = temporaryContent;
temporaryContent = null;
const serverUrl = 'http://127.0.0.1:4000';
const removals = [
...homeworks.map((id) => `/api/homeworks/${id}`),
...notifications.map((id) => `/api/notifications/${id}`),
];
let failed = 0;
for (const path of removals) {
const response = await fetch(`${serverUrl}${path}`, {
method: 'DELETE',
headers: { authorization: `Bearer ${token}` },
}).catch(() => null);
if (!response?.ok) failed += 1;
}
return `自检临时数据已清理 ${removals.length - failed}/${removals.length} 条`;
}
/**
* 冒烟自检 4(需后端在线):真实登录 + 拉取四类数据 + Socket.IO 连接。
* 覆盖渲染进程里实际使用的 API 封装、JWT 注入、实时通道与缓存写入。
@@ -165,12 +252,31 @@ export async function onlineScenario(credentials?: {
await appStore.init(serverUrl);
detail.push(`reachable=${appStore.serverReachable}`);
const [classes, homeworks, notifications, grades] = await Promise.all([
const [classes, initialHomeworks, initialNotifications, grades] = await Promise.all([
api.classApi.list(),
api.homeworkApi.list(),
api.notificationApi.list(),
api.gradeApi.my(),
]);
/**
* 自检**不允许依赖演示种子数据**:管理员随时可能清空业务数据
* (例如"删掉服务端所有作业和通知"),此时这几条联网断言不能变成"数据被清空就失败"。
* 因此列表为空时用教师账号造一条**本次自检专用**的作业/通知,收尾在 sessionCleanup 里删除。
*/
let homeworks = initialHomeworks;
let notifications = initialNotifications;
if (homeworks.length === 0 || notifications.length === 0) {
const seeded = await seedSmokeContent(auth.classId ?? null, {
homework: homeworks.length === 0,
notification: notifications.length === 0,
});
detail.push(`自建数据(${seeded.detail})`);
if (seeded.ok) {
[homeworks, notifications] = await Promise.all([api.homeworkApi.list(), api.notificationApi.list()]);
}
}
const term = await api.dashboardApi.term();
const schedules = await api.scheduleApi.list({ week: term.currentWeek });
@@ -582,16 +688,17 @@ export async function homeworkBoardSelfTest(): Promise<SmokeCheckResult> {
}
}
/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */
/** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */
export async function sessionCleanup(): Promise<SmokeCheckResult> {
const [{ useAuthStore }, { useRealtimeStore }] = await Promise.all([
import('../stores/auth.js'),
import('../stores/realtime.js'),
]);
try {
const cleaned = await cleanupTemporaryContent();
useRealtimeStore().disconnect();
await useAuthStore().logout();
return { ok: true, detail: '已断开实时通道并清理登录态' };
return { ok: true, detail: `已断开实时通道并清理登录态${cleaned ? `;${cleaned}` : ''}` };
} catch (error) {
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
}
@@ -106,12 +106,7 @@ async function enterOffline(): Promise<void> {
</el-form-item>
<el-form-item label="班级码" prop="code">
<el-input
v-model="form.code"
placeholder="例如 G101(由老师在班级管理中提供)"
clearable
@keyup.enter="submit"
>
<el-input v-model="form.code" placeholder="请输入班级码" clearable @keyup.enter="submit">
<template #prefix>
<el-icon><School /></el-icon>
</template>
+309 -9
View File
@@ -73,6 +73,7 @@ async function main() {
body: { username: 'teacher1', password: 'teacher123' },
});
const teacherToken = teacherLogin.payload?.data?.token;
const teacher1Id = teacherLogin.payload?.data?.user?.id;
record('教师登录 teacher1', Boolean(teacherToken), `status=${teacherLogin.status}`);
if (!teacherToken) process.exit(1);
@@ -144,19 +145,38 @@ async function main() {
process.exit(1);
}
// teacher2 名下但 teacher1 无权访问的班级,用于验证教师间权限隔离
// teacher2 名下但 teacher1 无权访问的班级,用于验证教师间权限隔离。
// 注意:演示库里可能被人工改过(管理员自己建班、给别的班加协作教师),
// 因此这里**不允许依赖种子数据的班级布局**:没有现成的就现建一个临时班级,收尾删除。
const teacher2Login = await api('/auth/login', {
method: 'POST',
body: { username: 'teacher2', password: 'teacher123' },
});
const teacher2Token = teacher2Login.payload?.data?.token;
const teacher2Id = teacher2Login.payload?.data?.user?.id;
const teacher1ClassIds = new Set(classes.map((item) => item.id));
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
const foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
const probeClassIds = [];
let foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
if (!foreignClass) {
const created = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
name: `权限校验班 ${Date.now()}`,
grade: '高一',
code: `PV${Date.now().toString().slice(-6)}`,
...(teacher2Id ? { teacherId: teacher2Id } : {}),
},
});
foreignClass = created.payload?.data ?? null;
if (foreignClass?.id) probeClassIds.push(foreignClass.id);
}
record(
'教师权限隔离测试数据就绪',
'教师权限隔离测试数据就绪(无现成班级时自建临时班级)',
Boolean(foreignClass),
foreignClass ? `外部班级:${foreignClass.name}` : '未找到其它教师的班级',
foreignClass ? `外部班级:${foreignClass.name}` : '未能准备其它教师的班级',
);
const studentList = await api(`/students?classId=${classId}`, { token: adminToken });
@@ -835,6 +855,22 @@ async function main() {
record('清理探测课表', removedProbe.status === 200, `status=${removedProbe.status}`);
}
// 上课时段用例造的两条通知:立即清理,避免在用户的库里留下"验证通知"
const duringClassNoticeIds = [
confirmedUrgent.payload?.data?.id,
normalDuringClass.payload?.data?.id,
].filter(Boolean);
for (const noticeId of duringClassNoticeIds) {
await api(`/notifications/${noticeId}`, { method: 'DELETE', token: teacherToken });
}
const duringClassNoticesLeft =
(await api(`/notifications?classId=${classId}`, { token: teacherToken })).payload?.data ?? [];
record(
'清理:上课时段用例的通知已删除(不在库里留验证通知)',
!duringClassNoticesLeft.some((item) => String(item.title).includes('上课时段')),
`剩余含"上课时段"的通知=${duringClassNoticesLeft.filter((item) => String(item.title).includes('上课时段')).length}`,
);
// ---------------------------------------------------------------- 6.3 叫人
if (classId && studentUser) {
const callByTeacher = await api('/calls', {
@@ -935,13 +971,35 @@ async function main() {
{
record('管理员登录(用于权限矩阵校验)', Boolean(adminToken), '已在上文登录');
// 科任老师:teacher2 在高二(3)班是协作(科任)老师
const subjectClass = teacher2Classes.find(
(item) => item.teacherId !== teacher2Login.payload?.data?.user?.id,
);
// 科任老师:teacher2 是某班的协作(科任)老师。
// 同样不依赖演示数据:推断出来的班级不可用(被人工改过权限 / 与外部班级同一个)时现建一个:
// teacher1 当班主任 + teacher2 协作,收尾删除。
let subjectClass = teacher2Classes.find((item) => item.teacherId !== teacher2Id) ?? null;
if (!subjectClass || subjectClass.id === foreignClass?.id) {
const created = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
name: `科任校验班 ${Date.now()}`,
grade: '高一',
code: `SJ${Date.now().toString().slice(-6)}`,
teacherId: teacher1Id,
},
});
const createdId = created.payload?.data?.id;
if (createdId) {
await api(`/classes/${createdId}/teachers`, {
method: 'POST',
token: adminToken,
body: { teacherId: teacher2Id },
});
subjectClass = { id: createdId, name: created.payload?.data?.name ?? '科任校验班' };
probeClassIds.push(createdId);
}
}
const subjectClassId = subjectClass?.id;
record(
'存在"科任老师"场景的班级',
'存在"科任老师"场景的班级(无现成班级时自建)',
Boolean(subjectClassId) && subjectClassId !== foreignClass?.id,
`classId=${subjectClassId ?? '-'} name=${subjectClass?.name ?? '-'}`,
);
@@ -1958,6 +2016,248 @@ async function main() {
`原值=${originalClassAccount.code || '(未设置)'} 还原后=${restoreAccount.payload?.data?.code ?? '-'} 登录=${restoredLogin.status}`,
);
// ---------------------------------------------------------------- 教师录入与班主任(仅管理员)
// 需求:"支持录入学生也要支持录入老师,录入老师的权限只有 admin;
// 创建班级只有 admin 有权限,并且支持设置或更改班主任"
const adminTeachers = await api('/teachers', { token: adminToken });
const staffList = adminTeachers.payload?.data ?? [];
const teacher1 = staffList.find((item) => item.username === 'teacher1');
const teacher2 = staffList.find((item) => item.username === 'teacher2');
record(
'教师列表(管理员)可用',
adminTeachers.status === 200 && staffList.length >= 2,
`status=${adminTeachers.status} 教师数=${staffList.length}`,
);
const teacherSeesTeachers = await api('/teachers', { token: teacherToken });
const teacherCreatesTeacher = await api('/teachers', {
method: 'POST',
token: teacherToken,
body: { username: 'smoke_denied_teacher', name: '越权教师', password: 'smoke123456' },
});
record(
'仅管理员可录入教师(教师读取/新建均 403)',
teacherSeesTeachers.status === 403 && teacherCreatesTeacher.status === 403,
`列表=${teacherSeesTeachers.status} 新建=${teacherCreatesTeacher.status} message=${teacherCreatesTeacher.payload?.message ?? ''}`,
);
// 录入一个教师账号 → 编辑 → 重置密码 → 新密码可登录
const smokeTeacherUsername = `smoke_teacher_${Date.now()}`;
const createdTeacher = await api('/teachers', {
method: 'POST',
token: adminToken,
body: { username: smokeTeacherUsername, name: '冒烟教师', password: 'smoke123456', role: 'TEACHER' },
});
const smokeTeacherId = createdTeacher.payload?.data?.id;
record(
'管理员录入教师账号(POST /teachers → 201)',
createdTeacher.status === 201 &&
Boolean(smokeTeacherId) &&
createdTeacher.payload?.data?.role === 'TEACHER',
`status=${createdTeacher.status} id=${smokeTeacherId ?? '-'} 角色=${createdTeacher.payload?.data?.role ?? '-'}`,
);
const teacherLoginBefore = await api('/auth/login', {
method: 'POST',
body: { username: smokeTeacherUsername, password: 'smoke123456' },
});
record(
'新教师账号可登录(初始密码生效)',
teacherLoginBefore.status === 200 && Boolean(teacherLoginBefore.payload?.data?.token),
`status=${teacherLoginBefore.status} 角色=${teacherLoginBefore.payload?.data?.user?.role ?? '-'}`,
);
const updatedTeacher = await api(`/teachers/${smokeTeacherId}`, {
method: 'PATCH',
token: adminToken,
body: { name: '冒烟教师(改名)', role: 'ADMIN' },
});
record(
'编辑教师(姓名 + 角色升为管理员)',
updatedTeacher.status === 200 &&
updatedTeacher.payload?.data?.name === '冒烟教师(改名)' &&
updatedTeacher.payload?.data?.role === 'ADMIN',
`status=${updatedTeacher.status} 姓名=${updatedTeacher.payload?.data?.name ?? '-'} 角色=${updatedTeacher.payload?.data?.role ?? '-'}`,
);
const resetTeacherPassword = await api(`/teachers/${smokeTeacherId}/reset-password`, {
method: 'POST',
token: adminToken,
body: { newPassword: 'teach999999' },
});
const teacherLoginNew = await api('/auth/login', {
method: 'POST',
body: { username: smokeTeacherUsername, password: 'teach999999' },
});
const teacherLoginOld = await api('/auth/login', {
method: 'POST',
body: { username: smokeTeacherUsername, password: 'smoke123456' },
});
record(
'重置教师密码(新密码可用 / 旧密码失效)',
resetTeacherPassword.status === 200 && teacherLoginNew.status === 200 && teacherLoginOld.status === 401,
`重置=${resetTeacherPassword.status} 新密码=${teacherLoginNew.status} 旧密码=${teacherLoginOld.status}`,
);
// 教师名单表格导入:教师预览 403;管理员预览 + 提交(教师与班级无关,classId 可省略)
const teacherCsv =
'\ufeff用户名,姓名,初始密码,角色\n' +
`imp_t_${Date.now().toString().slice(-8)},冒烟导入教师,import123456,教师\n`;
const teacherCsvBase64 = Buffer.from(teacherCsv, 'utf8').toString('base64');
const importedTeacherUsername = teacherCsv.split('\n')[1]?.split(',')[0] ?? '';
const teacherPreviewDenied = await api('/imports/table/preview', {
method: 'POST',
token: teacherToken,
body: { kind: 'teachers', fileName: 'teachers.csv', contentBase64: teacherCsvBase64 },
});
const adminTeacherPreview = await api('/imports/table/preview', {
method: 'POST',
token: adminToken,
body: { kind: 'teachers', fileName: 'teachers.csv', contentBase64: teacherCsvBase64 },
});
const adminTeacherCommit = await api('/imports/table/commit', {
method: 'POST',
token: adminToken,
body: {
kind: 'teachers',
fileName: 'teachers.csv',
contentBase64: teacherCsvBase64,
mapping: { username: '用户名', name: '姓名', password: '初始密码', role: '角色' },
mode: 'upsert',
},
});
const importedTeacherLogin = await api('/auth/login', {
method: 'POST',
body: { username: importedTeacherUsername, password: 'import123456' },
});
record(
'教师名单表格导入(教师 403 / 管理员预览+导入成功,且导入账号可登录)',
teacherPreviewDenied.status === 403 &&
adminTeacherPreview.status === 200 &&
Boolean(adminTeacherPreview.payload?.data?.suggestedMapping?.username) &&
adminTeacherCommit.status === 200 &&
adminTeacherCommit.payload?.data?.inserted === 1 &&
importedTeacherLogin.status === 200,
`教师预览=${teacherPreviewDenied.status} 管理员预览=${adminTeacherPreview.status} ` +
`建议映射=${JSON.stringify(adminTeacherPreview.payload?.data?.suggestedMapping ?? {})} ` +
`导入新增=${adminTeacherCommit.payload?.data?.inserted ?? '-'} 登录=${importedTeacherLogin.status}`,
);
// 删除护栏:还是班主任的账号不允许删除;没有班级职责的可以删
const deleteHeadTeacher = teacher1
? await api(`/teachers/${teacher1.id}`, { method: 'DELETE', token: adminToken })
: { status: 0, payload: null };
const deleteSmokeTeacher = await api(`/teachers/${smokeTeacherId}`, {
method: 'DELETE',
token: adminToken,
});
record(
'删除教师(有班级职责拒绝 409 / 无职责可删除)',
deleteHeadTeacher.status === 409 && deleteSmokeTeacher.status === 200,
`班主任账号=${deleteHeadTeacher.status}(${deleteHeadTeacher.payload?.message ?? ''}) 冒烟账号=${deleteSmokeTeacher.status}`,
);
// 创建班级仅管理员 + 设置/更改班主任
const teacherCreateClass = await api('/classes', {
method: 'POST',
token: teacherToken,
body: { name: '越权班级', grade: '高一' },
});
record(
'仅管理员可创建班级(教师 403)',
teacherCreateClass.status === 403,
`status=${teacherCreateClass.status} message=${teacherCreateClass.payload?.message ?? ''}`,
);
const tempClassCode = `SM${Date.now().toString().slice(-6)}`;
const createdClassWithHead = await api('/classes', {
method: 'POST',
token: adminToken,
body: {
name: `冒烟班级 ${Date.now()}`,
grade: '高一',
code: tempClassCode,
...(teacher2 ? { teacherId: teacher2.id } : {}),
},
});
const tempClassId = createdClassWithHead.payload?.data?.id;
record(
'创建班级时可指定班主任',
createdClassWithHead.status === 201 &&
Boolean(tempClassId) &&
(!teacher2 || createdClassWithHead.payload?.data?.teacher?.id === teacher2.id),
`status=${createdClassWithHead.status} 班主任=${createdClassWithHead.payload?.data?.teacher?.name ?? '-'}`,
);
const teacherChangeHead = await api(`/classes/${tempClassId}/head-teacher`, {
method: 'PATCH',
token: teacherToken,
body: { teacherId: teacher1?.id ?? '' },
});
const changedHead = await api(`/classes/${tempClassId}/head-teacher`, {
method: 'PATCH',
token: adminToken,
body: { teacherId: teacher1?.id ?? '' },
});
const changedDetail = await api(`/classes/${tempClassId}`, { token: adminToken });
record(
'设置 / 更改班主任(教师 403 / 管理员生效并落库)',
teacherChangeHead.status === 403 &&
changedHead.status === 200 &&
changedDetail.payload?.data?.teacher?.id === teacher1?.id,
`教师改=${teacherChangeHead.status} 管理员改=${changedHead.status} 详情班主任=${changedDetail.payload?.data?.teacher?.name ?? '-'}`,
);
// 清理:删除实时推送用例造的通知/作业、冒烟班级、临时权限校验班级与冒烟教师账号
const realtimeNoticeId = createdNotification.payload?.data?.id;
if (realtimeNoticeId) {
await api(`/notifications/${realtimeNoticeId}`, { method: 'DELETE', token: teacherToken });
}
if (homeworkId) {
await api(`/homeworks/${homeworkId}`, { method: 'DELETE', token: teacherToken });
}
const realtimeLeftovers = await api(`/notifications?classId=${classId}`, { token: teacherToken });
const homeworkLeftovers = await api(`/homeworks?classId=${classId}`, { token: teacherToken });
record(
'清理:实时推送用例的通知/作业已删除',
!(realtimeLeftovers.payload?.data ?? []).some((item) => String(item.title).includes('联调验证')) &&
!(homeworkLeftovers.payload?.data ?? []).some((item) => String(item.title).includes('联调验证')),
`残留通知=${(realtimeLeftovers.payload?.data ?? []).filter((item) => String(item.title).includes('联调验证')).length} ` +
`残留作业=${(homeworkLeftovers.payload?.data ?? []).filter((item) => String(item.title).includes('联调验证')).length}`,
);
if (tempClassId) await api(`/classes/${tempClassId}`, { method: 'DELETE', token: adminToken });
for (const probeClassId of probeClassIds) {
if (probeClassId && probeClassId !== tempClassId) {
await api(`/classes/${probeClassId}`, { method: 'DELETE', token: adminToken });
}
}
const importedTeacherRow = (await api('/teachers', { token: adminToken })).payload?.data?.find(
(item) => item.username === importedTeacherUsername,
);
if (importedTeacherRow) {
await api(`/teachers/${importedTeacherRow.id}`, { method: 'DELETE', token: adminToken });
}
const isProbeAccount = (username) =>
String(username).startsWith('smoke_') || String(username).startsWith('imp_t_');
const teachersAfterCleanup = await api('/teachers', { token: adminToken });
const classesAfterCleanup = await api('/classes', { token: adminToken });
const leftoverClasses = (classesAfterCleanup.payload?.data ?? []).filter(
(item) => item.name?.includes('校验班') || item.name?.includes('冒烟'),
);
record(
'清理:临时班级与冒烟教师账号已删除(不污染演示数据)',
!(teachersAfterCleanup.payload?.data ?? []).some((item) => isProbeAccount(item.username)) &&
leftoverClasses.length === 0,
`剩余教师=${(teachersAfterCleanup.payload?.data ?? []).length} 冒烟残留=${
(teachersAfterCleanup.payload?.data ?? [])
.filter((item) => isProbeAccount(item.username))
.map((item) => item.username)
.join(',') || '无'
} 残留班级=${leftoverClasses.map((item) => item.name).join(',') || '无'}`,
);
socket.close();
// ---------------------------------------------------------------- 汇总
+4
View File
@@ -30,6 +30,8 @@ const envSchema = z.object({
DEFAULT_STUDENT_PASSWORD: z.string().min(6).default('123456'),
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
DEFAULT_TEACHER_PASSWORD: z.string().min(6).default('123456'),
TERM_START_DATE: z.string().default(''),
LOG_LEVEL: z.enum(['debug', 'info', 'warn', 'error']).default('info'),
/** 反向代理层数(nginx / 容器场景一般填 1),用于正确识别客户端 IP 与协议 */
@@ -143,6 +145,8 @@ export const env = {
defaultStudentPassword: raw.DEFAULT_STUDENT_PASSWORD,
/** 新建班级时的默认班级密码(班级账号登录) */
defaultClassPassword: raw.DEFAULT_CLASS_PASSWORD,
/** 新建/重置教师账号时的初始密码 */
defaultTeacherPassword: raw.DEFAULT_TEACHER_PASSWORD,
termStartDate: raw.TERM_START_DATE,
logLevel: raw.LOG_LEVEL,
trustProxy: resolveTrustProxy(raw.TRUST_PROXY),
@@ -6,6 +6,7 @@ import { validate, validatedParams, validatedQuery } from '../../middleware/vali
import { defineModule } from '../module.types.js';
import {
addStudentSchema,
assignHeadTeacherSchema,
assignTeacherSchema,
classStudentParamSchema,
classTeacherParamSchema,
@@ -89,6 +90,22 @@ router.patch(
},
);
/**
* PATCH /api/classes/:id/head-teacher - 设置 / 更改班主任
* 仅管理员:班主任决定谁能管这个班的课表与成绩,属于人员分配权限
*/
router.patch(
'/:id/head-teacher',
requireRole('ADMIN'),
validate({ params: idParamSchema, body: assignHeadTeacherSchema }),
async (req, res) => {
const user = getAuthUser(req);
const { id } = validatedParams<{ id: string }>(req);
const { teacherId } = req.body as { teacherId: string };
sendOk(res, await classService.updateHeadTeacher(user, id, teacherId), '班主任已更新');
},
);
/** GET /api/classes/:id/students - 班级学生名单 */
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
const user = getAuthUser(req);
@@ -49,6 +49,11 @@ export const assignTeacherSchema = z.object({
teacherId: z.string().min(1, '请选择教师'),
});
/** 设置 / 更改班主任(仅管理员) */
export const assignHeadTeacherSchema = z.object({
teacherId: z.string().min(1, '请选择班主任'),
});
export const classStudentParamSchema = z.object({
id: z.string().min(1),
userId: z.string().min(1),
@@ -142,7 +142,37 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
}
/**
* 设置 / 重置班级账号(班级码 + 班级密码):仅管理员。
* 设置 / 更改班主任(仅管理员)。
*
* 班主任决定了"谁能管这个班的课表与成绩",所以更换时要保证目标账号确实是教师/管理员;
* 原班主任保留在协作教师名单里(`ClassTeacher`)不变,避免换人后丢掉历史协作关系。
*/
export async function updateHeadTeacher(
user: TokenPayload,
classId: string,
teacherId: string,
): Promise<ClassDto> {
assertCanAssignTeachers(user);
const target = await prisma.user.findUnique({ where: { id: teacherId } });
if (!target || (target.role !== 'TEACHER' && target.role !== 'ADMIN')) {
throw ApiError.badRequest('指定的班主任不存在或角色不是教师');
}
const updated = await prisma.class.update({
where: { id: classId },
data: { teacherId: target.id },
include: {
teacher: { select: { id: true, name: true, username: true } },
_count: { select: countSelect },
},
});
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'head-teacher-changed' });
return toClassDtoWithAccount(user, updated);
}
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员。
* 班级码即学生端"班级登录"的账号;密码留空表示不修改。
*/
export async function updateClassAccount(
@@ -4,6 +4,7 @@ import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import {
IMPORT_KINDS,
tableCommitSchema,
tableFileSchema,
timeLayoutImportSchema,
@@ -26,15 +27,29 @@ import {
const router = Router();
router.use(authenticate());
/** 名单类导入(学生/教师账号)只有管理员能做;成绩类按班级权限判定 */
function assertImportKindAllowed(kind: (typeof IMPORT_KINDS)[number], role: string): void {
if ((kind === 'students' || kind === 'teachers') && role !== 'ADMIN') {
throw ApiError.forbidden(
kind === 'teachers' ? '只有管理员可以录入或导入教师账号' : '只有管理员可以导入学生名单',
);
}
}
/** 查询参数里的 kind 收敛为合法值(默认 grades,避免非法值被当成成绩悄悄写入) */
function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
return IMPORT_KINDS.includes(value as (typeof IMPORT_KINDS)[number])
? (value as (typeof IMPORT_KINDS)[number])
: 'grades';
}
/**
* GET /api/imports/template?kind=grades|students&format=csv|xlsx - 下载导入模板
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);名单模板:仅管理员
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员
*/
router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
if (req.query.kind === 'students' && getAuthUser(req).role !== 'ADMIN') {
throw ApiError.forbidden('只有管理员可以导入学生名单');
}
const kind = req.query.kind === 'students' ? 'students' : 'grades';
const kind = resolveImportKind(req.query.kind);
assertImportKindAllowed(kind, getAuthUser(req).role);
const format = req.query.format === 'xlsx' ? 'xlsx' : 'csv';
if (format === 'xlsx') {
const base64 = buildTemplateXlsx(kind);
@@ -52,7 +67,7 @@ router.get('/template', requireRole('ADMIN', 'TEACHER'), (req, res) => {
/**
* POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
* 成绩:管理员或班主任;学生名单:仅管理员(与写入权限保持一致)
* 成绩:管理员或班主任;学生/教师名单:仅管理员(与写入权限保持一致)
*/
router.post(
'/table/preview',
@@ -60,9 +75,7 @@ router.post(
validate({ body: tableFileSchema }),
(req, res) => {
const input = validatedBody<TableFileInput>(req);
if (input.kind === 'students' && getAuthUser(req).role !== 'ADMIN') {
throw ApiError.forbidden('只有管理员可以导入学生名单');
}
assertImportKindAllowed(input.kind, getAuthUser(req).role);
sendOk(res, previewTable(input), '表格解析成功');
},
);
@@ -78,7 +91,13 @@ router.post(
requireRole('ADMIN', 'TEACHER'),
validate({ body: tableCommitSchema }),
async (req, res) => {
const result = await commitTable(getAuthUser(req), validatedBody<TableCommitInput>(req));
const input = validatedBody<TableCommitInput>(req);
assertImportKindAllowed(input.kind, getAuthUser(req).role);
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
if (input.kind !== 'teachers' && !input.classId) {
throw ApiError.badRequest('请先选择要导入的班级');
}
const result = await commitTable(getAuthUser(req), input);
sendOk(
res,
result,
@@ -1,7 +1,7 @@
import { z } from 'zod';
/** 导入类型:成绩 / 学生名单 / 课表时间配置 */
export const IMPORT_KINDS = ['grades', 'students'] as const;
/** 导入类型:成绩 / 学生名单 / 教师名单(仅管理员) */
export const IMPORT_KINDS = ['grades', 'students', 'teachers'] as const;
/** 表格导入:上传的文件(base64)+ 类型 */
export const tableFileSchema = z.object({
@@ -17,7 +17,8 @@ export const tableFileSchema = z.object({
/** 表格提交:在预览基础上带字段映射、目标班级与写入模式 */
export const tableCommitSchema = tableFileSchema.extend({
classId: z.string().min(1, '请选择班级'),
/** 目标班级:成绩/学生名单必填;教师名单与班级无关(可省略) */
classId: z.string().min(1, '请选择班级').optional(),
/** 列映射:规范字段 → 文件列名(或列索引字符串) */
mapping: z.record(z.string(), z.string().min(1)),
/** append = 仅新增;upsert = 已存在则更新(成绩按 学生+考试+课程,名单按用户名) */
@@ -50,6 +50,17 @@ export const IMPORT_FIELDS: Record<
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
],
teachers: [
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '工号', '账号'] },
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '教师', '教师姓名'] },
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
{
key: 'role',
label: '角色',
required: false,
synonyms: ['角色', 'role', '身份', '权限', '管理员'],
},
],
};
export interface TablePreview {
@@ -133,6 +144,8 @@ export function buildTemplateCsv(kind: ImportKind): string {
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', '92', '100', '数学']
: kind === 'teachers'
? ['teacher3', '王老师', 'teacher123', '教师']
: ['student01', '王小明', 'student123'];
const sampleLine = fields
.map((field) => sample[fields.findIndex((item) => item.key === field.key)] ?? '')
@@ -147,10 +160,13 @@ export function buildTemplateXlsx(kind: ImportKind): string {
const sample =
kind === 'grades'
? ['student01', '王小明', '期中考试', 92, 100, '数学']
: kind === 'teachers'
? ['teacher3', '王老师', 'teacher123', '教师']
: ['student01', '王小明', 'student123'];
const sheet = XLSX.utils.aoa_to_sheet([header, sample]);
const workbook = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(workbook, sheet, kind === 'grades' ? '成绩' : '学生名单');
const sheetName = kind === 'grades' ? '成绩' : kind === 'teachers' ? '教师名单' : '学生名单';
XLSX.utils.book_append_sheet(workbook, sheet, sheetName);
return XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
}
@@ -221,19 +237,21 @@ function cellValue(row: string[], columns: string[], mapping: Record<string, str
/** 导入成绩:学生按用户名/姓名匹配;重复(同班级+学生+考试+课程)按 mode 处理 */
export async function commitGrades(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
const classId = input.classId;
if (!classId) throw ApiError.badRequest('导入成绩需要先选择班级');
// 成绩导入:管理员或本班班主任(需求 6);科任 403
await assertCanManageGrades(user, input.classId);
await assertCanManageGrades(user, classId);
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const students = await prisma.user.findMany({
where: { classId: input.classId, role: 'STUDENT' },
where: { classId, role: 'STUDENT' },
select: { id: true, username: true, name: true },
});
const byUsername = new Map(students.map((item) => [item.username.toLowerCase(), item]));
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
const courses = await prisma.course.findMany({
where: { classId: input.classId },
where: { classId },
select: { id: true, name: true },
});
@@ -287,7 +305,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
warnings.push(`第 ${rowNumber} 行:课程「${courseName}」不存在,已按"不指定课程"导入`);
const existing = await prisma.grade.findFirst({
where: { classId: input.classId, userId: student.id, examName, courseId: course?.id ?? null },
where: { classId: classId, userId: student.id, examName, courseId: course?.id ?? null },
select: { id: true },
});
@@ -304,7 +322,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
} else {
await prisma.grade.create({
data: {
classId: input.classId,
classId: classId,
userId: student.id,
courseId: course?.id ?? null,
examName,
@@ -327,7 +345,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
warnings,
};
logger.info(
`成绩导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
`成绩导入:班级=${classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
@@ -335,10 +353,12 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
/** 导入学生名单:按用户名去重(append 跳过 / upsert 更新姓名与班级),仅管理员可用 */
export async function commitStudents(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
const classId = input.classId;
if (!classId) throw ApiError.badRequest('导入学生名单需要先选择班级');
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const cls = await prisma.class.findUnique({ where: { id: input.classId }, select: { id: true } });
const cls = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
if (!cls) throw ApiError.notFound('班级不存在');
const errors: ImportRowError[] = [];
@@ -372,7 +392,7 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
skipped += 1;
continue;
}
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: input.classId } });
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: classId } });
updated += 1;
} else {
const initialPassword = password || env.defaultStudentPassword;
@@ -381,7 +401,7 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
username,
name,
role: 'STUDENT',
classId: input.classId,
classId: classId,
passwordHash: await bcrypt.hash(initialPassword, 10),
},
});
@@ -400,11 +420,97 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
warnings,
};
logger.info(
`学生名单导入:班级=${input.classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
`学生名单导入:班级=${classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
return input.kind === 'grades' ? commitGrades(user, input) : commitStudents(user, input);
/**
* 导入教师名单:按用户名去重(append 跳过 / upsert 更新姓名与角色),**仅管理员**可用。
*
* 与班级无关(教师不属于班级),因此不需要 classId,模板里也没有班级列。
* 角色列可选:填「管理员 / ADMIN / admin」建管理员账号,其余一律建教师账号。
*/
export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
assertCanManageRoster(user);
const buffer = decodeBase64(input.contentBase64);
const parsed = parseTable(buffer, input.fileName);
const errors: ImportRowError[] = [];
const warnings: string[] = [];
let inserted = 0;
let updated = 0;
let skipped = 0;
for (const [index, row] of parsed.rows.entries()) {
const rowNumber = index + 2;
const username = cellValue(row, parsed.columns, input.mapping, 'username');
const name = cellValue(row, parsed.columns, input.mapping, 'name');
const password = cellValue(row, parsed.columns, input.mapping, 'password');
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
if (!username || !name) {
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
continue;
}
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
continue;
}
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
if (existing) {
if (existing.role === 'STUDENT') {
errors.push({ row: rowNumber, message: `用户名「${username}」已被学生账号占用` });
continue;
}
if (input.mode === 'append') {
skipped += 1;
continue;
}
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
await prisma.user.update({ where: { id: existing.id }, data: { name, role: nextRole } });
updated += 1;
} else {
const initialPassword = password || env.defaultTeacherPassword;
await prisma.user.create({
data: {
username,
name,
role: parseTeacherRole(roleCell) ?? 'TEACHER',
passwordHash: await bcrypt.hash(initialPassword, 10),
},
});
inserted += 1;
}
}
const result: ImportResult = {
kind: 'teachers',
total: parsed.rows.length,
inserted,
updated,
skipped,
failed: errors.length,
errors,
warnings,
};
logger.info(
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
);
return result;
}
/** 角色列解析:只认「管理员」类词;其余(含空)返回 null 交给调用方取默认值 */
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
const value = cell.trim().toUpperCase();
if (!value) return null;
if (value === 'ADMIN' || cell.includes('管理员') || value === '管理员') return 'ADMIN';
return 'TEACHER';
}
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
if (input.kind === 'teachers') return commitTeachers(user, input);
if (input.kind === 'grades') return commitGrades(user, input);
return commitStudents(user, input);
}
@@ -1,26 +1,70 @@
import { Router } from 'express';
import { Router } from 'express';
import { sendCreated, sendOk } from '../../lib/http.js';
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
import { validate, validatedQuery } from '../../middleware/validate.js';
import { idParamSchema } from '../../lib/schemas.js';
import { authenticate, requireRole } from '../../middleware/auth.js';
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
import { defineModule } from '../module.types.js';
import { createTeacherSchema, listTeachersQuerySchema, type CreateTeacherInput } from './teachers.schemas.js';
import {
createTeacherSchema,
listTeachersQuerySchema,
resetTeacherPasswordSchema,
updateTeacherSchema,
type CreateTeacherInput,
type ResetTeacherPasswordInput,
type UpdateTeacherInput,
} from './teachers.schemas.js';
import * as teacherService from './teachers.service.js';
const router = Router();
router.use(authenticate());
/** GET /api/teachers?keyword= - 教师列表(分配协作教师时使用) */
/**
* 教师账号管理:**全部接口仅管理员**。
* 学生端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
* 因此录入/修改/重置密码/删除都只开放给 ADMIN(路由层 + 前端菜单双重收敛)。
*/
/** GET /api/teachers?keyword= - 教师列表(教师管理页 + 分配协作教师时使用) */
router.get('/', requireRole('ADMIN'), validate({ query: listTeachersQuerySchema }), async (req, res) => {
const { keyword } = validatedQuery<{ keyword?: string }>(req);
sendOk(res, await teacherService.listTeachers(keyword), '获取教师列表成功');
});
/** POST /api/teachers - 新建教师账号(仅管理员) */
/** POST /api/teachers - 录入教师账号(仅管理员) */
router.post('/', requireRole('ADMIN'), validate({ body: createTeacherSchema }), async (req, res) => {
getAuthUser(req);
sendCreated(res, await teacherService.createTeacher(req.body as CreateTeacherInput), '教师账号创建成功');
});
/** PATCH /api/teachers/:id - 编辑教师(姓名 / 用户名 / 角色) */
router.patch(
'/:id',
requireRole('ADMIN'),
validate({ params: idParamSchema, body: updateTeacherSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
sendOk(res, await teacherService.updateTeacher(id, req.body as UpdateTeacherInput), '教师信息更新成功');
},
);
/** POST /api/teachers/:id/reset-password - 重置教师密码 */
router.post(
'/:id/reset-password',
requireRole('ADMIN'),
validate({ params: idParamSchema, body: resetTeacherPasswordSchema }),
async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.resetTeacherPassword(id, req.body as ResetTeacherPasswordInput);
sendOk(res, { id }, '密码已重置');
},
);
/** DELETE /api/teachers/:id - 删除教师账号(有班级职责/课程时拒绝,见 service) */
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
const { id } = validatedParams<{ id: string }>(req);
await teacherService.deleteTeacher(id);
sendOk(res, { id }, '教师账号已删除');
});
export const teachersModule = defineModule({
name: 'teachers',
basePath: '/teachers',
@@ -8,8 +8,22 @@ export const listTeachersQuerySchema = z.object({
export const createTeacherSchema = z.object({
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
name: z.string().trim().min(1, '请输入姓名').max(32),
password: z.string().min(6, '密码至少 6 位').max(128),
/** 留空则用默认初始密码(DEFAULT_TEACHER_PASSWORD) */
password: z.string().min(6, '密码至少 6 位').max(128).optional(),
role: z.enum(['TEACHER', 'ADMIN']).optional(),
});
/** 编辑教师:姓名 / 用户名 / 角色(角色可升为管理员,也可降回教师) */
export const updateTeacherSchema = z.object({
username: z.string().trim().min(3).max(32).optional(),
name: z.string().trim().min(1).max(32).optional(),
role: z.enum(['TEACHER', 'ADMIN']).optional(),
});
export const resetTeacherPasswordSchema = z.object({
newPassword: z.string().min(6, '新密码至少 6 位').max(128).optional(),
});
export type CreateTeacherInput = z.infer<typeof createTeacherSchema>;
export type UpdateTeacherInput = z.infer<typeof updateTeacherSchema>;
export type ResetTeacherPasswordInput = z.infer<typeof resetTeacherPasswordSchema>;
@@ -1,13 +1,22 @@
import type { UserDto } from '@classhelper/shared';
import { env } from '../../config/env.js';
import { prisma } from '../../lib/db.js';
import { ApiError } from '../../lib/http.js';
import { toUserDto } from '../../lib/mappers.js';
import { hashPassword } from '../../lib/password.js';
import type { CreateTeacherInput } from './teachers.schemas.js';
import type {
CreateTeacherInput,
ResetTeacherPasswordInput,
UpdateTeacherInput,
} from './teachers.schemas.js';
/**
* 教师列表:用于"分配协作教师"等需要选择教师的场景。
* 仅返回教师/管理员账号,字段与 UserDto 对齐。
* 教师管理(**仅管理员**,路由层已用 requireRole('ADMIN') 兜住):
* 录入教师是管理员专属操作,班主任与科任老师都没有该权限。
* 学生名单在 students 模块,两个模块结构保持一致(列表 / 新建 / 编辑 / 重置密码 / 删除)。
*/
/** 教师列表(教师 + 管理员账号) */
export async function listTeachers(keyword?: string): Promise<UserDto[]> {
const teachers = await prisma.user.findMany({
where: {
@@ -15,7 +24,7 @@ export async function listTeachers(keyword?: string): Promise<UserDto[]> {
...(keyword ? { OR: [{ name: { contains: keyword } }, { username: { contains: keyword } }] } : {}),
},
orderBy: [{ role: 'asc' }, { username: 'asc' }],
take: 200,
take: 500,
});
return teachers.map(toUserDto);
}
@@ -26,8 +35,74 @@ export async function createTeacher(input: CreateTeacherInput): Promise<UserDto>
username: input.username,
name: input.name,
role: input.role ?? 'TEACHER',
passwordHash: await hashPassword(input.password),
passwordHash: await hashPassword(input.password ?? env.defaultTeacherPassword),
},
});
return toUserDto(created);
}
/** 取教师账号(不存在或不是教师/管理员一律 404,避免越权探测) */
async function findTeacherOrThrow(teacherId: string): Promise<{ id: string; role: string; name: string }> {
const teacher = await prisma.user.findUnique({
where: { id: teacherId },
select: { id: true, role: true, name: true },
});
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
throw ApiError.notFound('教师账号不存在');
}
return teacher;
}
export async function updateTeacher(teacherId: string, input: UpdateTeacherInput): Promise<UserDto> {
const teacher = await findTeacherOrThrow(teacherId);
const updated = await prisma.user.update({
where: { id: teacher.id },
data: {
...(input.name ? { name: input.name } : {}),
...(input.username ? { username: input.username } : {}),
...(input.role ? { role: input.role } : {}),
},
});
return toUserDto(updated);
}
/** 重置教师密码(未指定时用默认初始密码) */
export async function resetTeacherPassword(
teacherId: string,
input: ResetTeacherPasswordInput,
): Promise<void> {
const teacher = await findTeacherOrThrow(teacherId);
await prisma.user.update({
where: { id: teacher.id },
data: { passwordHash: await hashPassword(input.newPassword ?? env.defaultTeacherPassword) },
});
}
/**
* 删除教师账号。
*
* 安全护栏:教师是数据的创建者,`Class.teacherId` / `Course.teacherId` / 作业通知的 createdBy
* 都是级联删除 —— 直接删班主任会连带删掉整个班级的数据。因此只有"没有任何班级职责、
* 也不持有课程"的账号才允许删除(实践中就是刚录入错的那条),否则返回 409 让管理员先转移。
*/
export async function deleteTeacher(userId: string): Promise<void> {
const teacher = await findTeacherOrThrow(userId);
const [ownedClasses, assignedClasses, courses] = await Promise.all([
prisma.class.count({ where: { teacherId: teacher.id } }),
prisma.classTeacher.count({ where: { teacherId: teacher.id } }),
prisma.course.count({ where: { teacherId: teacher.id } }),
]);
if (ownedClasses > 0) {
throw ApiError.conflict(
`「${teacher.name}」还是 ${ownedClasses} 个班级的班主任,请先在班级管理里更换班主任再删除`,
);
}
if (assignedClasses > 0 || courses > 0) {
throw ApiError.conflict(
`「${teacher.name}」还有 ${assignedClasses} 条班级分配 / ${courses} 门课程,请先解除后再删除`,
);
}
await prisma.user.delete({ where: { id: teacher.id } });
}
+12 -9
View File
@@ -67,26 +67,29 @@ export const WEEKDAY_LABELS: Record<number, string> = {
export const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7];
/**
* 常用科目目录:作业/课表里"新建课程"时可直接选(也用于种子数据与导入时的科目建议)。
* 顺序按中学常见排课习惯,前 9 个是文化课,后面是技术与体艺类。
* 统一授课科目目录(**全校固定**):作业、课表、成绩的"科目"都从这里选,
* 不需要按班级一个个建课 —— 选中后由前端自动为该班建好同名课程(已存在则复用)。
* 顺序按用户给定的作息习惯:文化课 → 技术 → 体艺 → 班会与早晚读 → 通用技术 → 听力。
*/
export const SUBJECT_CATALOG: readonly string[] = [
'语文',
'数学',
'英语',
'物理',
'化学',
'生物',
'政治',
'历史',
'物理',
'化学',
'地理',
'生物',
'信息技术',
'通用技术',
'体育',
'音乐',
'美术',
'心理健康',
'劳动',
'音乐',
'班会',
'早读',
'晚读',
'通用技术',
'听力',
] as const;
/**
+4 -3
View File
@@ -74,11 +74,11 @@ export function canManageGrades(classRole: ClassRole): boolean {
}
/** 可导入的数据类型 */
export type ImportKind = 'grades' | 'students' | 'scheduleTimes';
export type ImportKind = 'grades' | 'students' | 'teachers' | 'scheduleTimes';
/** 导入权限:成绩=管理员或本班班主任;名单=仅管理员;课表时间=管理员或本班班主任 */
/** 导入权限:成绩=管理员或本班班主任;学生/教师名单=仅管理员;课表时间=管理员或本班班主任 */
export function canImport(role: UserRole, classRole: ClassRole, kind: ImportKind): boolean {
if (kind === 'students') return canManageRoster(role);
if (kind === 'students' || kind === 'teachers') return canManageRoster(role);
if (kind === 'scheduleTimes') return canManageSchedule(classRole);
return canManageGrades(classRole);
}
@@ -93,6 +93,7 @@ export const PERMISSION_MATRIX: readonly {
{ 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: false, subject: false },
{ action: '课表管理(增删改 / 时间配置导入)', admin: true, head: true, subject: false },
{ action: '布置作业', admin: true, head: true, subject: true },
{ action: '叫人', admin: true, head: true, subject: true },
+17 -7
View File
@@ -65,6 +65,9 @@ export const classApi = {
api.delete(`${API_PATHS.classes}/${id}/students/${userId}`),
assignTeacher: (id: string, teacherId: string): Promise<unknown> =>
api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }),
/** 设置 / 更改班主任:仅管理员 */
assignHeadTeacher: (id: string, teacherId: string): Promise<ClassDto> =>
api.patch(`${API_PATHS.classes}/${id}/head-teacher`, { teacherId }),
removeTeacher: (id: string, teacherId: string): Promise<unknown> =>
api.delete(`${API_PATHS.classes}/${id}/teachers/${teacherId}`),
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
@@ -200,7 +203,7 @@ export const dashboardApi = {
api.get('/dashboard/term', classId ? { classId } : undefined),
};
/* ------------------------------------------------------------------ 协作教师 */
/* ------------------------------------------------------------------ 教师管理(仅管理员) */
export const teacherApi = {
list: (keyword?: string): Promise<UserDto[]> =>
@@ -208,9 +211,16 @@ export const teacherApi = {
create: (payload: {
username: string;
name: string;
password: string;
password?: string;
role?: 'TEACHER' | 'ADMIN';
}): Promise<UserDto> => api.post(API_PATHS.teachers, payload),
update: (
id: string,
payload: { username?: string; name?: string; role?: 'TEACHER' | 'ADMIN' },
): Promise<UserDto> => api.patch(`${API_PATHS.teachers}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.teachers}/${id}`),
resetPassword: (id: string, newPassword?: string): Promise<unknown> =>
api.post(`${API_PATHS.teachers}/${id}/reset-password`, newPassword ? { newPassword } : {}),
/** 班级详情中的 teachers 字段即为已分配的协作教师 */
fromClass: (classId: string): Promise<ClassDetailDto> => classApi.detail(classId),
};
@@ -220,22 +230,22 @@ export const teacherApi = {
export const importApi = {
/** 模板下载地址(CSV 走 JSON,XLSX 走二进制) */
template: (
kind: 'grades' | 'students',
kind: 'grades' | 'students' | 'teachers',
format: 'csv' | 'xlsx' = 'csv',
): Promise<{ kind: string; format: string; fileName: string; content: string }> =>
api.get(`${API_PATHS.imports}/template`, { kind, format }),
/** 上传表格并预览(解析 + 必填列校验 + 建议映射,不写库) */
previewTable: (payload: {
kind: 'grades' | 'students';
kind: 'grades' | 'students' | 'teachers';
fileName: string;
contentBase64: string;
}): Promise<TableImportPreview> => api.post(`${API_PATHS.imports}/table/preview`, payload),
/** 确认字段映射与写入模式后执行导入 */
/** 确认字段映射与写入模式后执行导入(教师名单与班级无关,classId 可省略) */
commitTable: (payload: {
kind: 'grades' | 'students';
classId: string;
kind: 'grades' | 'students' | 'teachers';
classId?: string;
fileName: string;
contentBase64: string;
mapping: Record<string, string>;
@@ -21,7 +21,7 @@ import {
import { API_BASE_URL } from '@/config';
import { importApi } from '@/api';
type Kind = 'grades' | 'students';
type Kind = 'grades' | 'students' | 'teachers';
interface FieldDef {
key: string;
@@ -44,12 +44,19 @@ const FIELDS: Record<Kind, FieldDef[]> = {
{ key: 'name', label: '姓名', required: true },
{ key: 'password', label: '初始密码', required: false },
],
teachers: [
{ key: 'username', label: '用户名', required: true },
{ key: 'name', label: '姓名', required: true },
{ key: 'password', label: '初始密码', required: false },
{ key: 'role', label: '角色', required: false },
],
};
const props = defineProps<{
modelValue: boolean;
kind: Kind;
classId: string;
/** 教师名单与班级无关,可以不传 */
classId?: string;
}>();
const emit = defineEmits<{
@@ -73,7 +80,12 @@ const result = ref<TableImportResult | null>(null);
const fileInputRef = ref<HTMLInputElement>();
const fields = computed(() => FIELDS[props.kind]);
const title = computed(() => (props.kind === 'grades' ? '导入成绩表格' : '导入学生名单'));
const title = computed(() => {
if (props.kind === 'grades') return '导入成绩表格';
return props.kind === 'teachers' ? '导入教师名单' : '导入学生名单';
});
/** 教师名单不需要班级(教师不属于任何班级) */
const needsClass = computed(() => props.kind !== 'teachers');
watch(visible, (value) => {
if (value) reset();
@@ -178,7 +190,7 @@ const mappedRequired = computed(() =>
);
async function submit(): Promise<void> {
if (!props.classId) {
if (needsClass.value && !props.classId) {
ElMessage.warning('请先在页面上选择班级');
return;
}
@@ -195,7 +207,7 @@ async function submit(): Promise<void> {
try {
const data = await importApi.commitTable({
kind: props.kind,
classId: props.classId,
...(needsClass.value && props.classId ? { classId: props.classId } : {}),
fileName: fileName.value,
contentBase64: contentBase64.value,
mapping: mapping.value,
@@ -16,7 +16,7 @@ const { isMobile } = useResponsive();
/**
* 菜单项与可见角色(与后端 `@classhelper/shared/permissions` 矩阵保持一致):
* - 班级管理 / 学生管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
* - 班级管理 / 学生管理 / 教师管理:仅管理员(班主任与科任老师都没有班级增删改与人员分配权限)
* - 课表管理:管理员 + 班主任(页面内再按"是否本班班主任"控制增删改按钮)
* - 成绩录入:管理员 + 教师;页面内只对"管理员或所选班级的班主任"显示写入/导入按钮
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
@@ -26,6 +26,7 @@ const menuItems = [
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
{ path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] },
{ path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] },
{ path: '/teachers', title: '教师管理', icon: 'UserFilled', roles: ['ADMIN'] },
{ path: '/schedules', title: '课表管理', icon: 'Calendar', roles: ['ADMIN', 'TEACHER'] },
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
+29 -2
View File
@@ -23,13 +23,19 @@ const routes: RouteRecordRaw[] = [
path: 'classes',
name: 'classes',
component: () => import('@/views/ClassesView.vue'),
meta: { title: '班级管理', icon: 'School' },
meta: { title: '班级管理', icon: 'School', roles: ['ADMIN'] },
},
{
path: 'students',
name: 'students',
component: () => import('@/views/StudentsView.vue'),
meta: { title: '学生管理', icon: 'User' },
meta: { title: '学生管理', icon: 'User', roles: ['ADMIN'] },
},
{
path: 'teachers',
name: 'teachers',
component: () => import('@/views/TeachersView.vue'),
meta: { title: '教师管理', icon: 'UserFilled', roles: ['ADMIN'] },
},
{
path: 'schedules',
@@ -70,6 +76,18 @@ export const router = createRouter({
routes,
});
/** 读取已保存的登录用户角色(路由守卫用;未登录/解析失败返回 null) */
function storedRole(): string | null {
try {
const raw = localStorage.getItem(STORAGE_KEYS.user);
if (!raw) return null;
const parsed = JSON.parse(raw) as { role?: string };
return parsed?.role ?? null;
} catch {
return null;
}
}
router.beforeEach((to) => {
const token = localStorage.getItem(STORAGE_KEYS.token);
if (to.meta.public) {
@@ -79,6 +97,15 @@ router.beforeEach((to) => {
if (!token) {
return { name: 'login', query: { redirect: to.fullPath } };
}
// 仅管理员可见的页面(班级/学生/教师管理):直接输网址也要挡回去,
// 不能依赖"菜单隐藏"——菜单隐藏只是看不见,不是权限。
const roles = to.meta.roles;
if (Array.isArray(roles) && roles.length > 0) {
const role = storedRole();
if (!role || !(roles as string[]).includes(role)) {
return { name: 'dashboard' };
}
}
return true;
});
+72 -1
View File
@@ -44,18 +44,29 @@ async function loadClasses(): Promise<void> {
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({ name: '', grade: '', code: '', termWeeks: 20 });
const form = reactive({ name: '', grade: '', code: '', termWeeks: 20, headTeacherId: '' });
const rules: FormRules = {
name: [{ required: true, message: '请输入班级名称', trigger: 'blur' }],
grade: [{ required: true, message: '请输入年级', trigger: 'blur' }],
};
/** 管理员可选班主任(教师 + 管理员账号);「新建班级」才设置,「编辑」走单独的"更换班主任" */
const staffOptions = ref<UserDto[]>([]);
async function loadStaffOptions(): Promise<void> {
if (!isAdmin.value) return;
if (staffOptions.value.length > 0) return;
staffOptions.value = await teacherApi.list().catch(() => []);
}
function openCreate(): void {
editingId.value = null;
form.name = '';
form.grade = '';
form.code = '';
form.termWeeks = 20;
form.headTeacherId = auth.user?.id ?? '';
void loadStaffOptions();
formVisible.value = true;
}
@@ -65,6 +76,7 @@ function openEdit(row: ClassDto): void {
form.grade = row.grade;
form.code = '';
form.termWeeks = row.termWeeks ?? 20;
void loadStaffOptions();
formVisible.value = true;
}
@@ -78,12 +90,20 @@ async function submitForm(): Promise<void> {
grade: form.grade,
termWeeks: form.termWeeks,
});
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
if (isAdmin.value && form.headTeacherId) {
const target = classes.value.find((item) => item.id === editingId.value);
if (target && target.teacher?.id !== form.headTeacherId) {
await classApi.assignHeadTeacher(editingId.value, form.headTeacherId);
}
}
ElMessage.success('班级已更新');
} else {
await classApi.create({
name: form.name,
grade: form.grade,
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
});
ElMessage.success('班级创建成功');
}
@@ -91,6 +111,23 @@ async function submitForm(): Promise<void> {
await loadClasses();
}
/* ------------------------------------------------------------ 更换班主任(仅管理员) */
const headTeacherSaving = ref(false);
/** 详情页更换班主任:写 PATCH /api/classes/:id/head-teacher */
async function changeHeadTeacher(classId: string, teacherId: string): Promise<void> {
if (!teacherId) return;
headTeacherSaving.value = true;
try {
await classApi.assignHeadTeacher(classId, teacherId);
ElMessage.success('班主任已更换');
await Promise.all([loadClasses(), refreshDetail()]);
} finally {
headTeacherSaving.value = false;
}
}
/* ------------------------------------------------------------ 班级账号(班级码 + 班级密码) */
const accountVisible = ref(false);
@@ -401,6 +438,19 @@ onUnmounted(() => {
<el-form-item v-if="!editingId" label="班级码">
<el-input v-model="form.code" placeholder="留空自动生成(4~16 位字母数字)" />
</el-form-item>
<el-form-item v-if="isAdmin" label="班主任">
<el-select v-model="form.headTeacherId" placeholder="选择班主任" style="width: 100%" filterable>
<el-option
v-for="item in staffOptions"
:key="item.id"
:label="`${item.name}(${item.username})`"
:value="item.id"
/>
</el-select>
<span class="text-muted" style="width: 100%">
班主任可以管理本班课表与成绩;留空则默认由创建者本人担任
</span>
</el-form-item>
<el-form-item label="学期周数">
<el-input-number v-model="form.termWeeks" :min="1" :max="40" :step="1" />
<span class="text-muted ml-8">
@@ -423,6 +473,27 @@ onUnmounted(() => {
<el-descriptions-item label="学生数">{{ detail.students.length }}</el-descriptions-item>
</el-descriptions>
<!-- 更换班主任(仅管理员):班主任决定谁能管这个班的课表与成绩 -->
<div v-if="isAdmin && detail" class="toolbar mt-12">
<span class="text-muted">更换班主任</span>
<el-select
:model-value="detail.teacher?.id ?? ''"
placeholder="选择教师"
style="width: 240px"
filterable
:loading="headTeacherSaving"
@change="(value: string) => changeHeadTeacher(detail!.id, value)"
>
<el-option
v-for="item in staffOptions"
:key="item.id"
:label="`${item.name}(${item.username})`"
:value="item.id"
/>
</el-select>
<span class="text-muted">切换后立即生效:原班主任仍保留在下方「协作教师」名单里</span>
</div>
<el-tabs v-model="activeTab" class="mt-12">
<el-tab-pane label="学生名单" name="students">
<div class="toolbar mt-12">
+47 -6
View File
@@ -4,6 +4,7 @@ import { ElMessage, ElMessageBox } from 'element-plus';
import * as echarts from 'echarts';
import {
SOCKET_EVENTS,
SUBJECT_CATALOG,
formatDate,
gradeLevel,
gradePercent,
@@ -138,6 +139,30 @@ function handleResize(): void {
courseChart.value?.resize();
}
/* ------------------------------------------------------------ 统一科目
*
* 与课表一致:"科目"是全校统一的固定目录(shared 的 SUBJECT_CATALOG),
* 下拉里直接列出该班还没有的科目,选中后自动建课 —— 不需要先按班级单独录入课程。
*/
const SUBJECT_PREFIX = 'subject:';
const subjectOptions = computed(() => {
const existing = new Set(courses.value.map((item) => item.name));
return SUBJECT_CATALOG.filter((name) => !existing.has(name));
});
/** 把下拉值解析成真实 courseId:`subject:语文` 这类伪值先建课(已存在则复用) */
async function resolveCourseId(value: string): Promise<string | null> {
if (!value.startsWith(SUBJECT_PREFIX)) return value || null;
const name = value.slice(SUBJECT_PREFIX.length);
const existing = courses.value.find((item) => item.name === name);
if (existing) return existing.id;
const created = await courseApi.create({ classId: filter.classId, name });
courses.value = [...courses.value, created];
return created.id;
}
/* ------------------------------------------------------------ 单条录入 */
const singleVisible = ref(false);
@@ -163,7 +188,7 @@ async function submitSingle(): Promise<void> {
}
await gradeApi.create({
classId: filter.classId,
courseId: singleForm.courseId || null,
courseId: await resolveCourseId(singleForm.courseId),
userId: singleForm.userId,
examName: singleForm.examName.trim(),
score: singleForm.score,
@@ -213,7 +238,7 @@ async function submitBulk(): Promise<void> {
const result = await gradeApi.bulkCreate({
classId: filter.classId,
courseId: bulkForm.courseId || null,
courseId: await resolveCourseId(bulkForm.courseId),
examName: bulkForm.examName.trim(),
totalScore: bulkForm.totalScore,
items,
@@ -391,9 +416,17 @@ onUnmounted(() => {
/>
</el-select>
</el-form-item>
<el-form-item label="课程">
<el-select v-model="singleForm.courseId" clearable style="width: 100%">
<el-form-item label="科目">
<el-select v-model="singleForm.courseId" clearable filterable style="width: 100%">
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
<el-option-group v-if="subjectOptions.length" label="统一科目(自动建课)">
<el-option
v-for="name in subjectOptions"
:key="name"
:label="name"
:value="`${SUBJECT_PREFIX}${name}`"
/>
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="考试名称">
@@ -414,14 +447,22 @@ onUnmounted(() => {
<!-- 批量录入 -->
<el-dialog v-model="bulkVisible" title="批量录入成绩" width="620px">
<el-form :model="bulkForm" label-width="90px" inline>
<el-form-item label="课程">
<el-select v-model="bulkForm.courseId" clearable style="width: 150px">
<el-form-item label="科目">
<el-select v-model="bulkForm.courseId" clearable filterable style="width: 150px">
<el-option
v-for="item in teacherCourseSuggestions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
<el-option-group v-if="subjectOptions.length" label="统一科目(自动建课)">
<el-option
v-for="name in subjectOptions"
:key="name"
:label="name"
:value="`${SUBJECT_PREFIX}${name}`"
/>
</el-option-group>
</el-select>
</el-form-item>
<el-form-item label="考试">
@@ -15,17 +15,6 @@ const rules: FormRules = {
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
};
const demoAccounts = [
{ role: '管理员', username: 'admin', password: 'admin123' },
{ role: '教师', username: 'teacher1', password: 'teacher123' },
{ role: '教师', username: 'teacher2', password: 'teacher123' },
];
function fill(username: string, password: string): void {
form.username = username;
form.password = password;
}
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
@@ -70,20 +59,6 @@ async function submit(): Promise<void> {
登录
</el-button>
</el-form>
<el-divider>演示账号(点击填充)</el-divider>
<div class="demo-list">
<el-tag
v-for="item in demoAccounts"
:key="item.username"
class="demo-tag"
effect="plain"
@click="fill(item.username, item.password)"
>
{{ item.role }}:{{ item.username }} / {{ item.password }}
</el-tag>
</div>
<p class="login-tip">学生请使用班级小助手桌面客户端登录;种子数据见 README。</p>
</div>
</div>
</template>
@@ -121,11 +96,6 @@ async function submit(): Promise<void> {
.login-brand {
margin-bottom: 16px;
}
.demo-tag {
width: 100%;
justify-content: center;
}
}
.login-brand {
@@ -149,22 +119,4 @@ async function submit(): Promise<void> {
.login-button {
width: 100%;
}
.demo-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.demo-tag {
cursor: pointer;
justify-content: flex-start;
}
.login-tip {
margin: 16px 0 0;
font-size: 12px;
color: #c0c4cc;
line-height: 1.6;
}
</style>
+53 -5
View File
@@ -3,6 +3,7 @@ import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
SOCKET_EVENTS,
SUBJECT_CATALOG,
WEEKDAYS,
WEEKDAY_LABELS,
WEEK_PARITY_LABELS,
@@ -99,6 +100,33 @@ async function loadCourses(): Promise<void> {
courses.value = query.classId ? await courseApi.list(query.classId) : [];
}
/**
* 统一授课科目(需求:"课表统一而不是单个班级录入")。
*
* 学校固定的科目目录来自 shared 的 `SUBJECT_CATALOG`(语文/数学/…/听力),
* 与班级无关:**该班还没有的科目也直接出现在下拉里**,选中后自动为该班建好同名课程,
* 因此不需要为了排课先去"班级 → 课程"里一个个录一遍。
* 下拉只有一个 v-model,统一科目用 `subject:语文` 这种带前缀的伪值表达。
*/
const SUBJECT_PREFIX = 'subject:';
const subjectOptions = computed(() => {
const existing = new Set(courses.value.map((item) => item.name));
return SUBJECT_CATALOG.filter((name) => !existing.has(name));
});
/** 把表单里的选中值解析成真实 courseId:伪值先建课(已存在则复用) */
async function resolveCourseId(): Promise<string | null> {
const value = form.courseId;
if (!value.startsWith(SUBJECT_PREFIX)) return value || null;
const name = value.slice(SUBJECT_PREFIX.length);
const existing = courses.value.find((item) => item.name === name);
if (existing) return existing.id;
const created = await courseApi.create({ classId: query.classId, name });
courses.value = [...courses.value, created];
return created.id;
}
async function loadSchedules(): Promise<void> {
if (!query.classId) {
grid.value = null;
@@ -153,7 +181,9 @@ function openCreate(): void {
return;
}
editingId.value = null;
form.courseId = courses.value[0]?.id ?? '';
// 默认选第一个统一科目(不是"该班已建课程",避免新班级下拉为空)
form.courseId =
courses.value[0]?.id ?? (SUBJECT_CATALOG[0] ? `${SUBJECT_PREFIX}${SUBJECT_CATALOG[0]}` : '');
form.dayOfWeek = 1;
form.startTime = '08:00';
form.endTime = '08:45';
@@ -185,9 +215,15 @@ async function submitForm(): Promise<void> {
return;
}
const courseId = await resolveCourseId();
if (!courseId) {
ElMessage.warning('请选择科目');
return;
}
const payload: CreateScheduleRequest = {
classId: query.classId,
courseId: form.courseId,
courseId,
dayOfWeek: Number(form.dayOfWeek),
startTime: form.startTime,
endTime: form.endTime,
@@ -205,7 +241,7 @@ async function submitForm(): Promise<void> {
ElMessage.success('课表已新增');
}
formVisible.value = false;
await loadSchedules();
await Promise.all([loadCourses(), loadSchedules()]);
}
async function removeSchedule(item: ScheduleDto): Promise<void> {
@@ -341,10 +377,22 @@ onUnmounted(() => {
<el-dialog v-model="formVisible" :title="editingId ? '编辑课表' : '新增课表'" width="480px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="课程" prop="courseId">
<el-select v-model="form.courseId" placeholder="选择课程" style="width: 100%">
<el-form-item label="科目" prop="courseId">
<el-select v-model="form.courseId" placeholder="选择科目" style="width: 100%" filterable>
<el-option v-for="item in courses" :key="item.id" :label="item.name" :value="item.id" />
<el-option-group v-if="subjectOptions.length" label="统一科目(选中后自动建课)">
<el-option
v-for="name in subjectOptions"
:key="name"
:label="name"
:value="`${SUBJECT_PREFIX}${name}`"
/>
</el-option-group>
</el-select>
<span class="text-muted" style="width: 100%">
科目是全校统一的固定目录(语文/数学/…/听力),不必按班级单独录入;
选这里没有的科目时会自动为该班建好同名课程
</span>
</el-form-item>
<el-form-item label="星期" prop="dayOfWeek">
<el-select v-model="form.dayOfWeek" style="width: 100%">
@@ -0,0 +1,203 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import { ROLE_LABELS, formatDate, type UserDto } from '@classhelper/shared';
import { teacherApi } from '@/api';
import TableImportDialog from '@/components/TableImportDialog.vue';
/**
* 教师管理(**仅管理员可见/可用**,与后端 `requireRole('ADMIN')` 对应)。
*
* 与「学生管理」保持同一套交互:单个录入 / 表格导入名单 / 编辑 / 重置密码 / 删除。
* 学生端主体是班级账号,教师账号则决定"谁是班主任、谁能录课表与成绩",
* 属于系统级配置,所以录入权限只给管理员。
*/
const loading = ref(false);
const saving = ref(false);
const teachers = ref<UserDto[]>([]);
const keyword = ref('');
async function loadTeachers(): Promise<void> {
loading.value = true;
try {
teachers.value = await teacherApi.list(keyword.value.trim() || undefined);
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 录入 / 编辑 */
const formVisible = ref(false);
const formRef = ref<FormInstance>();
const editingId = ref<string | null>(null);
const form = reactive({ username: '', name: '', password: '', role: 'TEACHER' as 'TEACHER' | 'ADMIN' });
const rules: FormRules = {
username: [
{ required: true, message: '请输入登录用户名', trigger: 'blur' },
{ min: 3, max: 32, message: '用户名 3~32 位', trigger: 'blur' },
{ pattern: /^[A-Za-z0-9_.-]+$/, message: '只能用字母、数字、下划线、点、短横线', trigger: 'blur' },
],
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
password: [{ min: 6, message: '密码至少 6 位', trigger: 'blur' }],
};
function openCreate(): void {
editingId.value = null;
form.username = '';
form.name = '';
form.password = '';
form.role = 'TEACHER';
formVisible.value = true;
}
function openEdit(row: UserDto): void {
editingId.value = row.id;
form.username = row.username;
form.name = row.name;
form.password = '';
form.role = row.role === 'ADMIN' ? 'ADMIN' : 'TEACHER';
formVisible.value = true;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
saving.value = true;
try {
if (editingId.value) {
await teacherApi.update(editingId.value, {
username: form.username.trim(),
name: form.name.trim(),
role: form.role,
});
ElMessage.success('教师信息已更新');
} else {
await teacherApi.create({
username: form.username.trim(),
name: form.name.trim(),
role: form.role,
...(form.password ? { password: form.password } : {}),
});
ElMessage.success(
form.password ? '教师账号已创建' : '教师账号已创建(使用默认初始密码,请提醒本人尽快修改)',
);
}
formVisible.value = false;
await loadTeachers();
} finally {
saving.value = false;
}
}
async function resetPassword(row: UserDto): Promise<void> {
await ElMessageBox.confirm(`将「${row.name}」的密码重置为默认初始密码?`, '重置密码', {
type: 'warning',
});
await teacherApi.resetPassword(row.id);
ElMessage.success('密码已重置为默认初始密码');
}
async function removeTeacher(row: UserDto): Promise<void> {
await ElMessageBox.confirm(
`删除教师账号「${row.name}(${row.username})」?` +
'若 TA 还是班主任或持有课程,系统会拒绝删除,请先在班级管理里更换班主任。',
'危险操作',
{ type: 'warning', confirmButtonText: '确认删除' },
);
await teacherApi.remove(row.id);
ElMessage.success('教师账号已删除');
await loadTeachers();
}
/* ------------------------------------------------------------ 导入名单 */
const importVisible = ref(false);
function roleLabel(role: string): string {
return ROLE_LABELS[role as keyof typeof ROLE_LABELS] ?? role;
}
onMounted(loadTeachers);
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">教师管理</h2>
<p class="page-subtitle">
录入教师账号(单个录入或导入名单)、修改姓名与角色、重置密码(仅管理员可见)
</p>
</div>
<div class="toolbar">
<el-input
v-model="keyword"
placeholder="姓名 / 用户名"
clearable
style="width: 180px"
@keyup.enter="loadTeachers"
/>
<el-button :icon="'Search'" @click="loadTeachers">查询</el-button>
<el-button type="primary" :icon="'Plus'" @click="openCreate">新建教师</el-button>
<el-button type="warning" :icon="'Document'" @click="importVisible = true">导入名单</el-button>
</div>
</div>
<el-card shadow="never">
<el-table v-loading="loading" :data="teachers" empty-text="还没有教师账号,点击右上角新建">
<el-table-column prop="name" label="姓名" width="140" />
<el-table-column prop="username" label="用户名" width="180" />
<el-table-column label="角色" width="120">
<template #default="{ row }">
<el-tag :type="row.role === 'ADMIN' ? 'danger' : 'primary'" effect="plain" size="small">
{{ roleLabel(row.role) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="创建时间" min-width="170">
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
</el-table-column>
<el-table-column label="操作" width="220" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
<el-button link type="warning" @click="resetPassword(row)">重置密码</el-button>
<el-button link type="danger" @click="removeTeacher(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="formVisible" :title="editingId ? '编辑教师' : '新建教师'" width="460px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="姓名" prop="name">
<el-input v-model="form.name" placeholder="例如 张老师" />
</el-form-item>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="登录用,例如 teacher3" />
</el-form-item>
<el-form-item v-if="!editingId" label="初始密码">
<el-input v-model="form.password" type="password" show-password placeholder="留空使用默认密码" />
</el-form-item>
<el-form-item label="角色">
<el-radio-group v-model="form.role">
<el-radio value="TEACHER">教师</el-radio>
<el-radio value="ADMIN">管理员</el-radio>
</el-radio-group>
<div class="text-muted" style="width: 100%">
管理员可以管理班级、学生、教师与全部课表;普通教师仅能管理自己负责的班级。
</div>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" :loading="saving" @click="submitForm">保存</el-button>
</template>
</el-dialog>
<!-- 导入教师名单:与班级无关,因此不需要选班级 -->
<TableImportDialog v-model="importVisible" kind="teachers" @imported="loadTeachers" />
</div>
</template>
+309 -2
View File
@@ -43,7 +43,11 @@ const MENU_ITEMS = [
];
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
const HIDDEN_MENU_LABELS = ['班级管理', '学生管理'];
const HIDDEN_MENU_LABELS = ['班级管理', '学生管理', '教师管理'];
/** 管理员账号(教师录入用例):与种子/安装初始化账号一致 */
const ADMIN_USERNAME = process.env.UI_SMOKE_ADMIN ?? 'admin';
const ADMIN_PASSWORD = process.env.UI_SMOKE_ADMIN_PASS ?? 'admin123';
const results = [];
function record(name, ok, detail = '') {
@@ -68,6 +72,152 @@ async function waitFor(win, expression, timeoutMs = 15000) {
return false;
}
/**
* 管理员专属用例:登录 → 教师管理 → 录入教师 → 断言出现在列表 → 删除清理。
*
* 用户要求"支持录入学生也要支持录入老师,且录入老师的权限只有 admin":
* 因此这里用**管理员账号真实点一遍**(表单 → 接口 → 列表刷新),并在收尾删掉测试账号,
* 保证"更支持录入教师"这条不是只有接口能跑、界面点不动。
*/
async function runAdminTeacherChecks(win) {
const username = `smoke_teacher_${Date.now()}`;
// 姓名固定用这个值:界面上要能看出"这是冒烟建的账号",方便人工排查残留
const teacherName = '冒烟测试老师';
const fillLoginAndSubmit = async (user, password) => {
await win.webContents.executeJavaScript(`(() => {
const setValue = (element, value) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
};
const inputs = Array.from(document.querySelectorAll('input'));
setValue(inputs[0], ${JSON.stringify(user)});
const passwordInput = inputs.find((item) => item.type === 'password') ?? inputs[1];
setValue(passwordInput, ${JSON.stringify(password)});
const button = Array.from(document.querySelectorAll('button')).find((item) =>
(item.textContent ?? '').includes('登录'),
);
if (button) button.click();
return true;
})()`);
return waitFor(win, `document.querySelectorAll('.el-menu-item').length >= 6`, 20000);
};
// 1) 换管理员账号(清掉教师登录态 → 整页重载更接近真人操作)
await win.webContents.executeJavaScript(`(() => { localStorage.clear(); return true; })()`);
await win.loadURL(TARGET_URL);
const loginReady = await waitFor(win, `document.querySelectorAll('input').length >= 2`, 15000);
if (!loginReady) {
record('管理员登录(教师录入前置)', false, '登录页未渲染');
return;
}
const adminReady = await fillLoginAndSubmit(ADMIN_USERNAME, ADMIN_PASSWORD);
record('管理员登录(教师录入前置)', adminReady, `账号=${ADMIN_USERNAME}`);
if (!adminReady) return;
// 2) 打开「教师管理」→ 新建教师(表单 → 保存 → 列表出现)
const created = await win.webContents.executeJavaScript(`(async () => {
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
(node.textContent ?? '').trim().startsWith('教师管理'),
);
if (!menu) return { ok: false, reason: '管理员菜单里没有「教师管理」' };
menu.click();
const deadline = Date.now() + 6000;
while (Date.now() < deadline && location.pathname !== '/teachers') {
await new Promise((resolve) => setTimeout(resolve, 80));
}
await new Promise((resolve) => setTimeout(resolve, 600));
const title = document.querySelector('.page-title')?.textContent?.trim() ?? '';
const openButton = Array.from(document.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').trim() === '新建教师',
);
if (!openButton) return { ok: false, reason: '教师管理页没有「新建教师」按钮', title };
openButton.click();
const dialogDeadline = Date.now() + 5000;
while (Date.now() < dialogDeadline && !document.querySelector('.el-dialog')) {
await new Promise((resolve) => setTimeout(resolve, 60));
}
await new Promise((resolve) => setTimeout(resolve, 300));
const dialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('新建教师'),
);
if (!dialog) return { ok: false, reason: '新建教师弹窗未打开', title };
const setValue = (element, value) => {
const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set;
setter.call(element, value);
element.dispatchEvent(new Event('input', { bubbles: true }));
element.dispatchEvent(new Event('change', { bubbles: true }));
};
const inputs = Array.from(dialog.querySelectorAll('input'));
setValue(inputs[0], ${JSON.stringify(teacherName)});
setValue(inputs[1], ${JSON.stringify(username)});
const passwordInput = inputs.find((item) => item.type === 'password');
if (passwordInput) setValue(passwordInput, 'smoke123456');
await new Promise((resolve) => setTimeout(resolve, 200));
const save = Array.from(dialog.querySelectorAll('.el-dialog__footer button')).find((node) =>
(node.textContent ?? '').trim() === '保存',
);
if (!save) return { ok: false, reason: '弹窗没有保存按钮', title };
save.click();
// 等列表刷新出这条账号
const rowDeadline = Date.now() + 8000;
let rowText = '';
while (Date.now() < rowDeadline) {
const rows = Array.from(document.querySelectorAll('.el-table__row'));
const hit = rows.find((row) => (row.textContent ?? '').includes(${JSON.stringify(username)}));
if (hit) {
rowText = (hit.textContent ?? '').replace(/\\s+/g, ' ').trim();
break;
}
await new Promise((resolve) => setTimeout(resolve, 150));
}
return { ok: Boolean(rowText), title, rowText };
})()`);
record(
'管理员录入教师(新建教师弹窗 → 保存 → 列表出现该账号)',
Boolean(created?.ok),
`页面标题=${created?.title ?? '-'} 新账号行="${created?.rowText ?? ''}"${created?.reason ? ` 原因=${created.reason}` : ''}`,
);
// 3) 删除刚建的测试账号(接口有护栏:班主任/有课程时拒绝),保持库干净
const removed = await win.webContents.executeJavaScript(`(async () => {
const rows = Array.from(document.querySelectorAll('.el-table__row'));
const row = rows.find((item) => (item.textContent ?? '').includes(${JSON.stringify(username)}));
if (!row) return { ok: false, reason: '列表里找不到刚建的账号' };
const removeButton = Array.from(row.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').trim() === '删除',
);
if (!removeButton) return { ok: false, reason: '行内没有删除按钮' };
removeButton.click();
const confirmDeadline = Date.now() + 5000;
while (Date.now() < confirmDeadline && !document.querySelector('.el-message-box')) {
await new Promise((resolve) => setTimeout(resolve, 60));
}
await new Promise((resolve) => setTimeout(resolve, 250));
const confirm = Array.from(document.querySelectorAll('.el-message-box__btns button')).find((node) =>
(node.textContent ?? '').trim().includes('确认删除'),
);
if (!confirm) return { ok: false, reason: '确认弹窗没有「确认删除」按钮' };
confirm.click();
const goneDeadline = Date.now() + 8000;
while (Date.now() < goneDeadline) {
const still = Array.from(document.querySelectorAll('.el-table__row')).some((item) =>
(item.textContent ?? '').includes(${JSON.stringify(username)}),
);
if (!still) return { ok: true };
await new Promise((resolve) => setTimeout(resolve, 150));
}
return { ok: false, reason: '删除后列表里仍然存在' };
})()`);
record(
'管理员删除教师账号(收尾清理,接口护栏保留)',
Boolean(removed?.ok),
`${removed?.ok ? `已删除 ${username}` : `原因=${removed?.reason ?? '-'}`}`,
);
}
async function main() {
const win = new BrowserWindow({
width: 1440,
@@ -98,6 +248,18 @@ async function main() {
const loginReady = await waitFor(win, `document.querySelectorAll('input').length >= 2`, 15000);
record('登录页渲染(用户名/密码输入框)', loginReady);
// 2.1 登录页不允许出现任何示例/演示内容(用户要求:"删掉登录页所有示例内容")
const loginContent = await win.webContents.executeJavaScript(`(() => {
const text = (document.body.innerText || '').replace(/\\s+/g, ' ').trim();
const tokens = ['演示', '示例账号', 'admin123', 'teacher123', 'student123', 'G101', '种子数据', '点击填充'];
return { text: text.slice(0, 160), leaked: tokens.filter((item) => text.includes(item)) };
})()`);
record(
'登录页不含示例/演示内容(无演示账号、无示例凭据)',
(loginContent?.leaked ?? ['?']).length === 0,
`越界文案=[${(loginContent?.leaked ?? []).join(',')}] 可见文本="${loginContent?.text ?? ''}"`,
);
// 3. 填写表单并点击登录(用原生 setter + input 事件,保证 Vue 双向绑定生效)
await win.webContents.executeJavaScript(`(() => {
const inputs = Array.from(document.querySelectorAll('input'));
@@ -356,11 +518,24 @@ async function main() {
return { labels, leaked };
})()`);
record(
'教师端隐藏无权入口(班级管理/学生管理)',
'教师端隐藏无权入口(班级管理/学生管理/教师管理)',
(hiddenCheck?.leaked ?? ['?']).length === 0,
`可见菜单=[${(hiddenCheck?.labels ?? []).join(',')}] 越权入口=[${(hiddenCheck?.leaked ?? []).join(',')}]`,
);
// 7.51 仅管理员页面:教师**直接输网址**也必须被路由守卫挡回仪表盘
// (菜单隐藏只是"看不见",不等于没有权限 —— 这里验证真的进不去)
await win.loadURL(`${TARGET_URL.replace(/\/$/, '')}/teachers`);
const teachersBlocked = await waitFor(win, `location.pathname === '/dashboard'`, 15000);
const blockedTitle = await win.webContents.executeJavaScript(
`document.querySelector('.page-title')?.textContent?.trim() ?? ''`,
);
record(
'教师端直接访问 /teachers 被挡回仪表盘(录入教师仅管理员)',
teachersBlocked && blockedTitle !== '教师管理',
`落地=${await win.webContents.executeJavaScript('location.pathname')} 标题=${blockedTitle}`,
);
// 7.6 "叫人"入口(通知发布 → 叫人 → 快捷短语/自定义消息)
const callDialog = await win.webContents.executeJavaScript(`(async () => {
const menu = Array.from(document.querySelectorAll('.el-menu-item')).find((node) =>
@@ -590,6 +765,138 @@ async function main() {
})()`);
await sleep(300);
// 7.8 授课科目统一:新增课表的科目下拉直接列出"全校统一科目"(不必按班级先录入课程),
// 选中后自动建课并写入课表;用例结束把这条课表删掉,保证不污染演示数据。
const unifiedSubject = await win.webContents.executeJavaScript(`(async () => {
const openButton = Array.from(document.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').trim() === '新增课表',
);
if (!openButton) return { ok: false, reason: '课表页没有「新增课表」按钮(当前账号不是班主任?)' };
openButton.click();
const deadline = Date.now() + 5000;
while (Date.now() < deadline && !document.querySelector('.el-dialog')) {
await new Promise((resolve) => setTimeout(resolve, 60));
}
await new Promise((resolve) => setTimeout(resolve, 400));
const dialog = Array.from(document.querySelectorAll('.el-dialog')).find((node) =>
(node.querySelector('.el-dialog__title')?.textContent ?? '').includes('新增课表'),
);
if (!dialog) return { ok: false, reason: '新增课表弹窗未打开' };
const selects = Array.from(dialog.querySelectorAll('.el-select__wrapper'));
if (selects.length < 2) return { ok: false, reason: '弹窗里没有科目/星期下拉' };
// 打开科目下拉,读取"统一科目"分组
selects[0].click();
await new Promise((resolve) => setTimeout(resolve, 450));
const groups = Array.from(document.querySelectorAll('.el-select-group__title')).map((node) =>
node.textContent.trim(),
);
// Element Plus 的 el-option-group 结构是:.el-select-group__wrap > (.el-select-group__title + ul.el-select-group)
const unifiedWrap = Array.from(document.querySelectorAll('.el-select-group__wrap')).find((node) =>
(node.querySelector('.el-select-group__title')?.textContent ?? '').includes('统一科目'),
);
const unifiedNames = unifiedWrap
? Array.from(unifiedWrap.querySelectorAll('.el-select-dropdown__item')).map((node) =>
node.textContent.trim(),
)
: [];
if (unifiedNames.length === 0) {
return { ok: false, reason: '科目下拉里没有「统一科目」分组(该班已把所有科目都建好了?)', groups };
}
const subjectName = unifiedNames[0];
const subjectOption = Array.from(unifiedWrap.querySelectorAll('.el-select-dropdown__item')).find(
(node) => node.textContent.trim() === subjectName,
);
subjectOption.click();
await new Promise((resolve) => setTimeout(resolve, 300));
// 星期改成周日(避开演示课表的周一~周五),时间用默认值
selects[1].click();
await new Promise((resolve) => setTimeout(resolve, 400));
const sunday = Array.from(document.querySelectorAll('.el-select-dropdown__item')).find((node) =>
(node.textContent ?? '').trim() === '周日',
);
if (!sunday) return { ok: false, reason: '星期下拉里没有「周日」' };
sunday.click();
await new Promise((resolve) => setTimeout(resolve, 300));
const save = Array.from(dialog.querySelectorAll('.el-dialog__footer button')).find((node) =>
(node.textContent ?? '').trim() === '保存',
);
if (!save) return { ok: false, reason: '弹窗没有保存按钮' };
save.click();
// 等课表列表出现这条(课程名 = 统一科目名)
const listDeadline = Date.now() + 8000;
let rowText = '';
while (Date.now() < listDeadline) {
const rows = Array.from(document.querySelectorAll('.el-table__row'));
const hit = rows.find((row) => (row.textContent ?? '').includes(subjectName));
if (hit) {
rowText = (hit.textContent ?? '').replace(/\\s+/g, ' ').trim();
break;
}
await new Promise((resolve) => setTimeout(resolve, 150));
}
return { ok: Boolean(rowText), subjectName, groups, unifiedCount: unifiedNames.length, rowText };
})()`);
record(
'课表科目为全校统一目录(选中自动建课并写入课表)',
Boolean(unifiedSubject?.ok) && (unifiedSubject?.unifiedCount ?? 0) >= 1,
`统一科目分组=${(unifiedSubject?.groups ?? []).join('/') || '-'} 可选项=${unifiedSubject?.unifiedCount ?? 0} ` +
`科目=${unifiedSubject?.subjectName ?? '-'} 新行="${unifiedSubject?.rowText ?? ''}"` +
`${unifiedSubject?.reason ? ` 原因=${unifiedSubject.reason}` : ''}`,
);
// 删掉刚写入的那条课表**以及自动建出来的课程**,保持演示数据不变
const removedUnified = await win.webContents.executeJavaScript(`(async () => {
const rows = Array.from(document.querySelectorAll('.el-table__row'));
const row = rows.find((item) => (item.textContent ?? '').includes(${JSON.stringify(
unifiedSubject?.subjectName ?? '',
)}));
if (!row) return { ok: false, reason: '列表里找不到刚写入的课表行' };
const removeButton = Array.from(row.querySelectorAll('button')).find((node) =>
(node.textContent ?? '').trim() === '删除',
);
if (!removeButton) return { ok: false, reason: '行内没有删除按钮' };
removeButton.click();
const confirmDeadline = Date.now() + 5000;
while (Date.now() < confirmDeadline && !document.querySelector('.el-message-box')) {
await new Promise((resolve) => setTimeout(resolve, 60));
}
await new Promise((resolve) => setTimeout(resolve, 250));
const confirm = Array.from(document.querySelectorAll('.el-message-box__btns button')).find((node) =>
(node.textContent ?? '').trim() === '确定',
);
if (!confirm) return { ok: false, reason: '确认弹窗没有确定按钮' };
confirm.click();
await new Promise((resolve) => setTimeout(resolve, 900));
// 自动建课留下的课程行也一并删掉(该科目本来不在这个班里)
const token = localStorage.getItem('classhelper.token') ?? '';
const headers = { authorization: 'Bearer ' + token };
// 工具栏第一个选择器就是班级选择器;el-select 非 filterable 时选中项是 span 文本,读 textContent
const classLabel = (document.querySelector('.toolbar .el-select')?.textContent ?? '').trim();
const classes = (await (await fetch('/api/classes', { headers })).json()).data ?? [];
const target = classes.find((item) => item.name === classLabel);
if (!target) return { ok: true, courseRemoved: false, reason: '未能定位当前班级:' + classLabel };
const courses = (await (await fetch('/api/courses?classId=' + target.id, { headers })).json()).data ?? [];
const created = courses.find((item) => item.name === ${JSON.stringify(unifiedSubject?.subjectName ?? '')});
if (!created) return { ok: true, courseRemoved: false };
const removed = await fetch('/api/courses/' + created.id, { method: 'DELETE', headers });
return { ok: true, courseRemoved: removed.ok, className: target.name };
})()`);
record(
'课表用例收尾:统一科目课表与自动建的课程都已删除(不污染演示数据)',
Boolean(removedUnified?.ok),
removedUnified?.ok
? `课表已删除;课程已删除=${removedUnified.courseRemoved === true}(班级=${removedUnified.className ?? '-'})`
: `原因=${removedUnified?.reason ?? '-'}`,
);
// 7.9 管理员专属:教师录入(本轮新增「教师管理」页,仅管理员可见可用)
await runAdminTeacherChecks(win);
// 8. 手机小屏适配(390×844,1Panel 风格:侧边栏收进抽屉 + 卡片内横向滚动)
await runMobileChecks(win);