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