diff --git a/README.md b/README.md index 772e759..38a1bc6 100644 --- a/README.md +++ b/README.md @@ -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=` 写出 JSON 结果, -已实测 `packaged: true`、76/76 通过、退出码 0)。灵动岛各状态的窗口截图会写到 +已实测 `packaged: true`、77/77 通过、退出码 0)。灵动岛各状态的窗口截图会写到 `ISLAND_SHOTS_DIR`(默认 `.cache/island-shots/`,仓库内留档目录 `docs/screenshots/island/`), 并对尺寸、宽高比、绘制内容(不透明像素/颜色种类)、紧急态红色像素与外溢红光做断言。 冒烟进程使用独立 userData(`ELECTRON_SMOKE_PROFILE`),因此**用户开着客户端也能跑验证**。 @@ -990,37 +1017,42 @@ 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` 返回记录;批量录入与统计接口通过 | -| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` | -| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 | -| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | -| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | -| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) | -| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 采样卡片尺寸恒为固定值(396×308、260×38)+ 失焦自动收回 | -| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` | -| 作业发布也上岛("新作业"胶囊) | ✅ | `kind=homework` + 展开显示作业要求;**截止时间功能已下线**(接口不再返回 `dueAt`,e2e 断言 `dueAt === undefined`) | -| **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:「收起后胶囊常驻(超时不再消失)」「收起态点击可再次展开(指针未移动也算命中)」「空闲细缝态保持可交互」 | -| 叫人(老师点名,分紧急/普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 | -| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 | -| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 | -| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 2 个迁移文件,跳过 34 个已存在对象`,`TimeLayout` 自动建表 | -| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `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`,导航「成绩」标题变为「本班成绩」 | -| 能成功打包 Windows EXE | ✅ | `班级小助手-0.1.0-x64-setup.exe` / `-portable.exe` / `win-unpacked/*.exe`(见下表) | -| 提供完整 README(启动、构建、打包、默认账号) | ✅ | 本文档含快速开始、命令表、API、WebSocket、RBAC、模块化、MySQL 切换、打包与常见问题 | +| 验收项 | 结果 | 证据 | +| ------------------------------------------------ | ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 教师 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` 返回记录;批量录入与统计接口通过 | +| 学生能查看课表,支持按周切换 | ✅ | `/schedules/grid?week=1` 返回 30 节,`week` 过滤 `weekStart ≤ week ≤ weekEnd` | +| 断网后客户端可查看缓存数据 | ✅ | 客户端冒烟:`断网时回退到本地缓存 → fromCache=true items=1`;离线横幅 + 缓存统计页可用 | +| 权限隔离:学生不能访问其他班级数据 | ✅ | 10 项越权断言全部 403/401(学生跨班/跨班作业/跨班课表、教师跨班发布、未登录访问…) | +| 上课时段发布紧急通知必须二次确认 | ✅ | 服务端 409 `URGENT_DURING_CLASS`(`confirmDuringClass` 后 201);Web 端全屏警告 + 3 秒倒计时 | +| 客户端灵动岛:上课隐藏 / 下课弹出 / 紧急立即展开 | ✅ | `verify:desktop` 状态断言 + 像素级截图(`docs/screenshots/island/`) | +| 灵动岛:收回无"方框"闪烁 / 点击屏幕任意处收回 | ✅ | 采样卡片尺寸恒为固定值(396×308、260×38)+ 失焦自动收回 | +| 灵动岛"标为已读"同步通知中心 | ✅ | 真实链路:点击后 `read=false → true`、未读数 `1 → 0` | +| 作业发布也上岛("新作业"胶囊) | ✅ | `kind=homework` + 展开显示作业要求;**截止时间功能已下线**(接口不再返回 `dueAt`,e2e 断言 `dueAt === undefined`) | +| **灵动岛收起后始终可再次打开** | ✅ | `verify:desktop`:「收起后胶囊常驻(超时不再消失)」「收起态点击可再次展开(指针未移动也算命中)」「空闲细缝态保持可交互」 | +| 叫人(老师点名,分紧急/普通两级) | ✅ | `verify:e2e` 8 项(普通 HIGH / 紧急 URGENT)+ `verify:desktop`「紧急叫人上课也立即展开」「普通叫人上课只进队列、下课弹出」 | +| 成绩 / 名单表格导入(xlsx·xls·csv) | ✅ | 模板下载 + 预览映射 + 重复处理 + 行号级错误:`verify:e2e` 导入 22 项、`verify:web` 弹窗实测 | +| ClassIsland 时间配置导入(覆盖 / 合并 / 回滚) | ✅ | 合法 200、非法 400 `IMPORT_INVALID` 且原配置仍为 3 节、merge 覆盖 1 新增 1 共 4 节 | +| 安装版覆盖升级自动补迁移 | ✅ | 真实旧库升级日志:`升级安装:已应用 2 个迁移文件,跳过 34 个已存在对象`,`TimeLayout` 自动建表 | +| 学生端主体 = 班级(班级码 + 班级密码登录) | ✅ | `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 切换、打包与常见问题 | 打包产物验证(对最终 EXE 实测,非仅开发产物): | 产物 | 大小 | 冒烟结果 | | -------------------------------------- | ------- | -------------------------------------------------------------------------------------------------------------------- | -| 服务端安装程序(内置 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`(桌面/开始菜单快捷方式指向它), @@ -1038,15 +1070,15 @@ 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 typecheck` / `pnpm lint` / `pnpm format:check` | 全部通过 ✅ | -| 安装程序完整生命周期(静默安装 → 启停脚本 → 卸载) | 通过 ✅ | -| Docker / Nginx 部署样例 | 文件已提供,本机无 Docker 未实测 ⚠️ | +| 验证 | 结果 | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| `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 未实测 ⚠️ | ## 常见问题(本机环境已知坑) diff --git a/packages/desktop-client/src/main/smoke.ts b/packages/desktop-client/src/main/smoke.ts index 1ec0e9c..949999d 100644 --- a/packages/desktop-client/src/main/smoke.ts +++ b/packages/desktop-client/src/main/smoke.ts @@ -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 { `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( `(() => ({ diff --git a/packages/desktop-client/src/renderer/cache/self-test.ts b/packages/desktop-client/src/renderer/cache/self-test.ts index e65cf95..d547eaa 100644 --- a/packages/desktop-client/src/renderer/cache/self-test.ts +++ b/packages/desktop-client/src/renderer/cache/self-test.ts @@ -123,6 +123,93 @@ export async function layoutNavigationSelfTest(): Promise { }; } +/** 自检临时造的作业/通知(收尾必须删掉,避免污染用户的库) */ +let temporaryContent: { token: string; homeworks: string[]; notifications: string[] } | null = null; + +/** + * 服务端业务数据可能被管理员清空(用户明确要求过"删掉所有作业和通知"), + * 因此联网自检在列表为空时**自建**一条作业/通知来验证链路,收尾(sessionCleanup)删除。 + */ +async function seedSmokeContent( + classId: string | null, + need: { homework: boolean; notification: boolean }, +): Promise { + 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 { + 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 { } } -/** 冒烟收尾:断开实时通道并退出登录,保证下次冒烟从登录页开始 */ +/** 冒烟收尾:删除自检临时数据 → 断开实时通道 → 退出登录,保证下次冒烟从登录页开始 */ export async function sessionCleanup(): Promise { 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) }; } diff --git a/packages/desktop-client/src/renderer/views/LoginView.vue b/packages/desktop-client/src/renderer/views/LoginView.vue index cce954c..ebd4d21 100644 --- a/packages/desktop-client/src/renderer/views/LoginView.vue +++ b/packages/desktop-client/src/renderer/views/LoginView.vue @@ -106,12 +106,7 @@ async function enterOffline(): Promise { - + diff --git a/packages/server/scripts/verify-e2e.mjs b/packages/server/scripts/verify-e2e.mjs index 5d8cc5a..9be8553 100644 --- a/packages/server/scripts/verify-e2e.mjs +++ b/packages/server/scripts/verify-e2e.mjs @@ -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(); // ---------------------------------------------------------------- 汇总 diff --git a/packages/server/src/config/env.ts b/packages/server/src/config/env.ts index 7f44448..ac25ab8 100644 --- a/packages/server/src/config/env.ts +++ b/packages/server/src/config/env.ts @@ -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), diff --git a/packages/server/src/modules/classes/classes.module.ts b/packages/server/src/modules/classes/classes.module.ts index 90dc8ed..e910558 100644 --- a/packages/server/src/modules/classes/classes.module.ts +++ b/packages/server/src/modules/classes/classes.module.ts @@ -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); diff --git a/packages/server/src/modules/classes/classes.schemas.ts b/packages/server/src/modules/classes/classes.schemas.ts index 99ee3ee..c4f60a2 100644 --- a/packages/server/src/modules/classes/classes.schemas.ts +++ b/packages/server/src/modules/classes/classes.schemas.ts @@ -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), diff --git a/packages/server/src/modules/classes/classes.service.ts b/packages/server/src/modules/classes/classes.service.ts index cc1b2c9..a97a637 100644 --- a/packages/server/src/modules/classes/classes.service.ts +++ b/packages/server/src/modules/classes/classes.service.ts @@ -142,7 +142,37 @@ export async function createClass(user: TokenPayload, input: CreateClassInput): } /** - * 设置 / 重置班级账号(班级码 + 班级密码):仅管理员。 + * 设置 / 更改班主任(仅管理员)。 + * + * 班主任决定了"谁能管这个班的课表与成绩",所以更换时要保证目标账号确实是教师/管理员; + * 原班主任保留在协作教师名单里(`ClassTeacher`)不变,避免换人后丢掉历史协作关系。 + */ +export async function updateHeadTeacher( + user: TokenPayload, + classId: string, + teacherId: string, +): Promise { + 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( diff --git a/packages/server/src/modules/imports/imports.module.ts b/packages/server/src/modules/imports/imports.module.ts index 2fa38c6..0799216 100644 --- a/packages/server/src/modules/imports/imports.module.ts +++ b/packages/server/src/modules/imports/imports.module.ts @@ -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(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(req)); + const input = validatedBody(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, diff --git a/packages/server/src/modules/imports/imports.schemas.ts b/packages/server/src/modules/imports/imports.schemas.ts index b45f49d..f7dea5e 100644 --- a/packages/server/src/modules/imports/imports.schemas.ts +++ b/packages/server/src/modules/imports/imports.schemas.ts @@ -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 = 已存在则更新(成绩按 学生+考试+课程,名单按用户名) */ diff --git a/packages/server/src/modules/imports/table-import.service.ts b/packages/server/src/modules/imports/table-import.service.ts index 32ca4b0..c500b36 100644 --- a/packages/server/src/modules/imports/table-import.service.ts +++ b/packages/server/src/modules/imports/table-import.service.ts @@ -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,7 +144,9 @@ export function buildTemplateCsv(kind: ImportKind): string { const sample = kind === 'grades' ? ['student01', '王小明', '期中考试', '92', '100', '数学'] - : ['student01', '王小明', 'student123']; + : kind === 'teachers' + ? ['teacher3', '王老师', 'teacher123', '教师'] + : ['student01', '王小明', 'student123']; const sampleLine = fields .map((field) => sample[fields.findIndex((item) => item.key === field.key)] ?? '') .join(','); @@ -147,10 +160,13 @@ export function buildTemplateXlsx(kind: ImportKind): string { const sample = kind === 'grades' ? ['student01', '王小明', '期中考试', 92, 100, '数学'] - : ['student01', '王小明', 'student123']; + : 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 { + 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 { 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 { - return input.kind === 'grades' ? commitGrades(user, input) : commitStudents(user, input); +/** + * 导入教师名单:按用户名去重(append 跳过 / upsert 更新姓名与角色),**仅管理员**可用。 + * + * 与班级无关(教师不属于班级),因此不需要 classId,模板里也没有班级列。 + * 角色列可选:填「管理员 / ADMIN / admin」建管理员账号,其余一律建教师账号。 + */ +export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise { + 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 { + if (input.kind === 'teachers') return commitTeachers(user, input); + if (input.kind === 'grades') return commitGrades(user, input); + return commitStudents(user, input); } diff --git a/packages/server/src/modules/teachers/teachers.module.ts b/packages/server/src/modules/teachers/teachers.module.ts index 4cf0f57..d14b78c 100644 --- a/packages/server/src/modules/teachers/teachers.module.ts +++ b/packages/server/src/modules/teachers/teachers.module.ts @@ -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', diff --git a/packages/server/src/modules/teachers/teachers.schemas.ts b/packages/server/src/modules/teachers/teachers.schemas.ts index a14586c..96f1445 100644 --- a/packages/server/src/modules/teachers/teachers.schemas.ts +++ b/packages/server/src/modules/teachers/teachers.schemas.ts @@ -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; +export type UpdateTeacherInput = z.infer; +export type ResetTeacherPasswordInput = z.infer; diff --git a/packages/server/src/modules/teachers/teachers.service.ts b/packages/server/src/modules/teachers/teachers.service.ts index b994611..79287d0 100644 --- a/packages/server/src/modules/teachers/teachers.service.ts +++ b/packages/server/src/modules/teachers/teachers.service.ts @@ -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 { const teachers = await prisma.user.findMany({ where: { @@ -15,7 +24,7 @@ export async function listTeachers(keyword?: string): Promise { ...(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 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 { + 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 { + 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 { + 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 } }); +} diff --git a/packages/shared/src/constants.ts b/packages/shared/src/constants.ts index 8f9d353..b0f8564 100644 --- a/packages/shared/src/constants.ts +++ b/packages/shared/src/constants.ts @@ -67,26 +67,29 @@ export const WEEKDAY_LABELS: Record = { export const WEEKDAYS: readonly number[] = [1, 2, 3, 4, 5, 6, 7]; /** - * 常用科目目录:作业/课表里"新建课程"时可直接选(也用于种子数据与导入时的科目建议)。 - * 顺序按中学常见排课习惯,前 9 个是文化课,后面是技术与体艺类。 + * 统一授课科目目录(**全校固定**):作业、课表、成绩的"科目"都从这里选, + * 不需要按班级一个个建课 —— 选中后由前端自动为该班建好同名课程(已存在则复用)。 + * 顺序按用户给定的作息习惯:文化课 → 技术 → 体艺 → 班会与早晚读 → 通用技术 → 听力。 */ export const SUBJECT_CATALOG: readonly string[] = [ '语文', '数学', '英语', - '物理', - '化学', - '生物', '政治', '历史', + '物理', + '化学', '地理', + '生物', '信息技术', - '通用技术', '体育', - '音乐', '美术', - '心理健康', - '劳动', + '音乐', + '班会', + '早读', + '晚读', + '通用技术', + '听力', ] as const; /** diff --git a/packages/shared/src/permissions.ts b/packages/shared/src/permissions.ts index 62a9259..37e3f27 100644 --- a/packages/shared/src/permissions.ts +++ b/packages/shared/src/permissions.ts @@ -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 }, diff --git a/packages/web-admin/src/api/index.ts b/packages/web-admin/src/api/index.ts index 768346b..24ee116 100644 --- a/packages/web-admin/src/api/index.ts +++ b/packages/web-admin/src/api/index.ts @@ -65,6 +65,9 @@ export const classApi = { api.delete(`${API_PATHS.classes}/${id}/students/${userId}`), assignTeacher: (id: string, teacherId: string): Promise => api.post(`${API_PATHS.classes}/${id}/teachers`, { teacherId }), + /** 设置 / 更改班主任:仅管理员 */ + assignHeadTeacher: (id: string, teacherId: string): Promise => + api.patch(`${API_PATHS.classes}/${id}/head-teacher`, { teacherId }), removeTeacher: (id: string, teacherId: string): Promise => 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 => @@ -208,9 +211,16 @@ export const teacherApi = { create: (payload: { username: string; name: string; - password: string; + password?: string; role?: 'TEACHER' | 'ADMIN'; }): Promise => api.post(API_PATHS.teachers, payload), + update: ( + id: string, + payload: { username?: string; name?: string; role?: 'TEACHER' | 'ADMIN' }, + ): Promise => 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 => + api.post(`${API_PATHS.teachers}/${id}/reset-password`, newPassword ? { newPassword } : {}), /** 班级详情中的 teachers 字段即为已分配的协作教师 */ fromClass: (classId: string): Promise => 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 => 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; diff --git a/packages/web-admin/src/components/TableImportDialog.vue b/packages/web-admin/src/components/TableImportDialog.vue index c55ac50..b4204e1 100644 --- a/packages/web-admin/src/components/TableImportDialog.vue +++ b/packages/web-admin/src/components/TableImportDialog.vue @@ -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 = { { 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(null); const fileInputRef = ref(); 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 { - if (!props.classId) { + if (needsClass.value && !props.classId) { ElMessage.warning('请先在页面上选择班级'); return; } @@ -195,7 +207,7 @@ async function submit(): Promise { 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, diff --git a/packages/web-admin/src/layouts/AdminLayout.vue b/packages/web-admin/src/layouts/AdminLayout.vue index 7bd60f6..149581a 100644 --- a/packages/web-admin/src/layouts/AdminLayout.vue +++ b/packages/web-admin/src/layouts/AdminLayout.vue @@ -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'] }, diff --git a/packages/web-admin/src/router/index.ts b/packages/web-admin/src/router/index.ts index c7e493c..7109734 100644 --- a/packages/web-admin/src/router/index.ts +++ b/packages/web-admin/src/router/index.ts @@ -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; }); diff --git a/packages/web-admin/src/views/ClassesView.vue b/packages/web-admin/src/views/ClassesView.vue index 292e6ea..e4a86c7 100644 --- a/packages/web-admin/src/views/ClassesView.vue +++ b/packages/web-admin/src/views/ClassesView.vue @@ -44,18 +44,29 @@ async function loadClasses(): Promise { const formVisible = ref(false); const formRef = ref(); const editingId = ref(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([]); + +async function loadStaffOptions(): Promise { + 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 { 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 { await loadClasses(); } +/* ------------------------------------------------------------ 更换班主任(仅管理员) */ + +const headTeacherSaving = ref(false); + +/** 详情页更换班主任:写 PATCH /api/classes/:id/head-teacher */ +async function changeHeadTeacher(classId: string, teacherId: string): Promise { + 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(() => { + + + + + + 班主任可以管理本班课表与成绩;留空则默认由创建者本人担任 + + @@ -423,6 +473,27 @@ onUnmounted(() => { {{ detail.students.length }} + +
+ 更换班主任 + + + + 切换后立即生效:原班主任仍保留在下方「协作教师」名单里 +
+
diff --git a/packages/web-admin/src/views/GradesView.vue b/packages/web-admin/src/views/GradesView.vue index 251ea9f..cb08024 100644 --- a/packages/web-admin/src/views/GradesView.vue +++ b/packages/web-admin/src/views/GradesView.vue @@ -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 { + 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 { } 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 { 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(() => { /> - - + + + + + @@ -414,14 +447,22 @@ onUnmounted(() => { - - + + + + + diff --git a/packages/web-admin/src/views/LoginView.vue b/packages/web-admin/src/views/LoginView.vue index 2028ce1..e0d1cee 100644 --- a/packages/web-admin/src/views/LoginView.vue +++ b/packages/web-admin/src/views/LoginView.vue @@ -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 { const valid = await formRef.value?.validate().catch(() => false); if (!valid) return; @@ -70,20 +59,6 @@ async function submit(): Promise { 登录 - - 演示账号(点击填充) -
- - {{ item.role }}:{{ item.username }} / {{ item.password }} - -
-
@@ -121,11 +96,6 @@ async function submit(): Promise { .login-brand { margin-bottom: 16px; } - - .demo-tag { - width: 100%; - justify-content: center; - } } .login-brand { @@ -149,22 +119,4 @@ async function submit(): Promise { .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; -} diff --git a/packages/web-admin/src/views/SchedulesView.vue b/packages/web-admin/src/views/SchedulesView.vue index 3bf24f5..d33c172 100644 --- a/packages/web-admin/src/views/SchedulesView.vue +++ b/packages/web-admin/src/views/SchedulesView.vue @@ -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 { 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 { + 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 { 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 { 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 { ElMessage.success('课表已新增'); } formVisible.value = false; - await loadSchedules(); + await Promise.all([loadCourses(), loadSchedules()]); } async function removeSchedule(item: ScheduleDto): Promise { @@ -341,10 +377,22 @@ onUnmounted(() => { - - + + + + + + + 科目是全校统一的固定目录(语文/数学/…/听力),不必按班级单独录入; + 选这里没有的科目时会自动为该班建好同名课程 + diff --git a/packages/web-admin/src/views/TeachersView.vue b/packages/web-admin/src/views/TeachersView.vue new file mode 100644 index 0000000..c0b26c9 --- /dev/null +++ b/packages/web-admin/src/views/TeachersView.vue @@ -0,0 +1,203 @@ + + + diff --git a/scripts/ui-smoke/main.cjs b/scripts/ui-smoke/main.cjs index 8496602..1f90b17 100644 --- a/scripts/ui-smoke/main.cjs +++ b/scripts/ui-smoke/main.cjs @@ -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);