feat: 完善学生生命周期管理与权限范围并发布 1.4.2
No files matched your search
@@ -23,7 +23,7 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
image: classhelper-server:${APP_VERSION:-1.4.1}
|
image: classhelper-server:${APP_VERSION:-1.4.2}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ services:
|
|||||||
context: ..
|
context: ..
|
||||||
dockerfile: deploy/Dockerfile
|
dockerfile: deploy/Dockerfile
|
||||||
target: runtime
|
target: runtime
|
||||||
image: classhelper-server:${APP_VERSION:-1.4.1}
|
image: classhelper-server:${APP_VERSION:-1.4.2}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "classhelper-server-runtime",
|
"name": "classhelper-server-runtime",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
"description": "ClassHelper 服务端运行时(安装包 / 容器内置)",
|
||||||
|
|||||||
@@ -19,8 +19,8 @@ description: 名单、班级会话、任课关系与年度升级。
|
|||||||
|
|
||||||
Student 存学号、姓名、班级、性别、家长电话和状态,没有密码或角色字段。
|
Student 存学号、姓名、班级、性别、家长电话和状态,没有密码或角色字段。
|
||||||
学号为必填唯一标识,格式为 1–32 位字母、数字或 . _ -;不会自动生成登录名。
|
学号为必填唯一标识,格式为 1–32 位字母、数字或 . _ -;不会自动生成登录名。
|
||||||
管理员或本班班主任在具备名单管理权限后管理学生,教师也可按授权范围只读名单。
|
具有名单管理键的管理员、本班班主任或全校名单范围的授权员工管理学生;只读账号按名单读取键和范围查看历史。
|
||||||
名单表格导入仍要求管理员及相应导入权限;学号查重在 Student 表完成,不当成 User 用户名。
|
名单表格导入要求 import:students、roster:manage 和对应班级范围;学号查重在 Student 表完成,不当成 User 用户名。
|
||||||
调班保留历史内容原归属;转出独立记账并从现行业务名单过滤,学号不可复用。
|
调班保留历史内容原归属;转出独立记账并从现行业务名单过滤,学号不可复用。
|
||||||
|
|
||||||
## 教师与科目
|
## 教师与科目
|
||||||
@@ -39,5 +39,26 @@ Student 存学号、姓名、班级、性别、家长电话和状态,没有密
|
|||||||
范围不代替科目写权限,详见[角色与权限](../dev/permissions.md)。
|
范围不代替科目写权限,详见[角色与权限](../dev/permissions.md)。
|
||||||
学期周数受全校可配置上限约束,默认上限 30、允许 1–60,见[课表](schedules.md)。
|
学期周数受全校可配置上限约束,默认上限 30、允许 1–60,见[课表](schedules.md)。
|
||||||
学年升级按计划时间运行,执行前保存快照,以年份幂等;启动发现漏跑需管理员预览补跑。
|
学年升级按计划时间运行,执行前保存快照,以年份幂等;启动发现漏跑需管理员预览补跑。
|
||||||
高三归档、高一/高二升一级,新高一不复制任课关系。归档页查看届别历史与转出记录,
|
默认高中轨道按高一、高二、高三排列,毕业学段归档,其余升一级;也可配置其他轨道。新班不复制任课关系。归档页查看届别历史与转出记录,
|
||||||
不再通过手动创建归档来代替年度升级。撤回转出等操作仍受归档与管理员业务检查约束。
|
不再通过手动创建归档来代替年度升级。撤回转出由 roster:manage 控制,原班归档后可指定其他在读班。
|
||||||
|
|
||||||
|
## 学生生命周期
|
||||||
|
|
||||||
|
资料编辑仅修改学号、姓名、性别与家长电话,不接受状态或班级字段;旧客户端发送这两项会收到 400 和专用接口提示。
|
||||||
|
默认名单只显示未归档的 active;状态筛选可查看 inactive、transferred、graduated,“包含归档”另行控制归档标记。
|
||||||
|
|
||||||
|
调班、转出、转入、复学、撤回与移出班级均在一个事务内修改学生并写历史,包含批次、学年、原新班级与年级、来源去向学校、操作人和备注。
|
||||||
|
调班需同时覆盖源班与目标班;跨年级须管理员勾选允许跨年级并填写备注。目标班必须在读。
|
||||||
|
转出保留原班及业务历史,只接受在读或停用学生。转入支持新建学号或选择转出/停用学生,毕业或归档学生不能通过名单入口复活。
|
||||||
|
撤回转出可选择另一个在读班,原历史保留。默认删除动作改为停用;硬删除另须管理员确认且无成绩、作业、已读或历史。
|
||||||
|
|
||||||
|
## 升级预览与执行
|
||||||
|
|
||||||
|
“学年升级”页面独立要求 promotion:manage,全校范围。先保存轨道、有序年级、起始班数、执行时间、开学日和周次重置策略,再预览逐班影响。
|
||||||
|
预览包含在读/停用/转出人数、新班码、周次删除行数、阻断和警告、计划哈希。未知年级、缺失届别、重复班号、已有届别档案或新班码冲突会阻断执行。
|
||||||
|
执行需确认学年和预览哈希;业务或配置变化后必须重新预览。未来学年禁止执行,过去学年仅 ADMIN 明确补跑。
|
||||||
|
|
||||||
|
毕业班所有学生挂届别:在读转毕业,转出和停用保留状态;非毕业班只升到下一学段并清本班开学日覆盖。
|
||||||
|
新班只有骨架,开学日取计划值(未配置时为执行学年 9 月 1 日)。保留周次时,旧逐周区间会继续覆盖新学年,页面会警告。
|
||||||
|
同年执行幂等,执行记录显示操作人、快照路径、SHA256、明细与错误。快照失败不升级,失败事务回滚后可修正重试。
|
||||||
|
撤销归档只清标记并将毕业生恢复在读,转出/停用不变;不回滚年级、不删除新班、不恢复已清空周次,整学年恢复需管理员手工使用快照。
|
||||||
@@ -8,9 +8,13 @@ description: 日课表、共享作息、轮换、临时换课和教学周。
|
|||||||
Web 管理端统一从「教学事务 → 课表档案」查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。
|
Web 管理端统一从「教学事务 → 课表档案」查看周课表并编辑时间表与日课表,客户端提供“今天”时间轴和“本周”视图。
|
||||||
上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。
|
上课状态、灵动岛课程提醒和紧急通知确认共用服务端的课表判定。
|
||||||
|
|
||||||
编辑工作区参照 [CsesWebEditor](https://github.com/SmartTeachCN/CsesWebEditor) 的资源列表、设置卡片、课时列表与快速选科布局实现。左侧集中切换课程表、时间表、科目、群组与预定、整周预览和文件预览。旧管理页地址 `/schedules` 自动进入同一工作区。
|
编辑工作区参照 [ClassIsland 档案编辑器](https://github.com/ClassIsland/ClassIsland/blob/master/ClassIsland/Views/ProfileSettingsWindow.axaml) 的布局与交互实现,使用独立的深色全屏工作区。顶部切换课表、时间表、科目和调课;默认以周网格展示课程,右侧提供科目简称面板,底部可切换列表、周和月视图。旧管理页地址 `/schedules` 自动进入同一工作区,点击「返回管理」回到管理端。
|
||||||
|
|
||||||
点击课时后可用右侧科目按钮快速填充;拖动课时交换课程,拖动时间点调整时间表顺序,完成后保存。时间表支持任意分钟输入;设置的课程/课间分钟数会用于新建时间点的默认起止时间。完整档案可在页头导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。
|
点击每天列头的「新课表」选择时间表并创建日课表。点击网格中的课时后可用右侧科目按钮快速填充,支持选择是否自动移动到下一节;方向键可移动焦点,Delete 可清空选中课程。课表名称、时间表、启用星期、轮换规则、生效周次与群组在「详细信息」中维护。
|
||||||
|
|
||||||
|
列表视图保留批量排课、复制粘贴、拖动交换课程等操作;时间表页支持拖动时间点排序、任意分钟输入,以及上课、课间、分割线、行动四种时间点。设置的课程/课间分钟数用于新建时间点的默认起止时间。编辑完成点击「保存」或按 Ctrl+S;切周与实时刷新保留草稿,切换班级和离开页面时提醒未保存的修改。
|
||||||
|
|
||||||
|
完整档案可在页头「导入/导出」中导入或导出 JSON,保留轮换、时间点类型、空课、停用、群组和预定。月视图提供日期导航,点击日期后由服务端解析教学周并进入周视图。
|
||||||
|
|
||||||
## 日课表与时间表
|
## 日课表与时间表
|
||||||
|
|
||||||
|
|||||||
@@ -13,10 +13,10 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
|
|||||||
## 身份与班级
|
## 身份与班级
|
||||||
|
|
||||||
| 方法与路径(省略 /api) | 行为及边界 |
|
| 方法与路径(省略 /api) | 行为及边界 |
|
||||||
| ----------------------------------------------------------------------- | -------------------------------------------------------------- |
|
| ----------------------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| POST /auth/login、/auth/class-login | 员工/班级登录,共享登录限流;学生无账号 |
|
| POST /auth/login、/auth/class-login | 员工/班级登录,共享登录限流;学生无账号 |
|
||||||
| GET /auth/me;POST /auth/logout;PATCH /auth/password | 当前身份、退出、员工本人改密码 |
|
| GET /auth/me;POST /auth/logout;PATCH /auth/password | 当前身份、退出、员工本人改密码 |
|
||||||
| GET/POST /classes;GET/PATCH/DELETE /classes/:id | 班级查询/管理,写入需 class:manage 与管理员业务断言 |
|
| GET/POST /classes;GET/PATCH/DELETE /classes/:id | 班级查询/管理,读取 class:read;写入 class:manage 与目标范围,创建/删除需全校范围 |
|
||||||
| PATCH /classes/:id/class-account、/classes/:id/head-teacher | 班级凭据/班主任维护 |
|
| PATCH /classes/:id/class-account、/classes/:id/head-teacher | 班级凭据/班主任维护 |
|
||||||
| PUT /classes/:id/subject-teachers | 任课关系设置,Course 为唯一来源 |
|
| PUT /classes/:id/subject-teachers | 任课关系设置,Course 为唯一来源 |
|
||||||
| GET/PATCH /classes/:id/student-grade-query | 查看/修改班级个人成绩查询开关,班级端不能修改 |
|
| GET/PATCH /classes/:id/student-grade-query | 查看/修改班级个人成绩查询开关,班级端不能修改 |
|
||||||
@@ -28,22 +28,22 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
|
|||||||
## 教学与文件
|
## 教学与文件
|
||||||
|
|
||||||
| 路径/动作 | 行为及边界 |
|
| 路径/动作 | 行为及边界 |
|
||||||
| ---------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
| ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------ |
|
||||||
| /courses | 班级科目与任课关系 |
|
| /courses | 班级科目与任课关系 |
|
||||||
| GET /schedules、/grid、/current、/active-week | 新关系模型的兼容投影,按班级范围读取 |
|
| GET /schedules、/grid、/current、/active-week | 新关系模型的兼容投影,按班级范围读取 |
|
||||||
| POST/PATCH/DELETE /schedules[/:id] | 兼容 CRUD,写新模型,不双写旧 Schedule |
|
| POST/PATCH/DELETE /schedules[/:id] | 兼容 CRUD,写新模型,不双写旧 Schedule |
|
||||||
| POST /schedules/swap、/overlay、/scheduled;GET /schedules/scheduled;DELETE /schedules/scheduled/:id | 换课、临时层、预定 |
|
| POST /schedules/swap、/overlay、/scheduled;GET /schedules/scheduled;DELETE /schedules/scheduled/:id | 换课、临时层、预定 |
|
||||||
| /time-layouts;POST /time-layouts/:id/clone、/:id/import | 时间表 CRUD/克隆/导入,写入 timetable:manage |
|
| /time-layouts;POST /time-layouts/:id/clone、/:id/import | 时间表 CRUD/克隆/导入,写入 timetable:manage |
|
||||||
| /class-plans;PUT /class-plans/:id/entries;PATCH /class-plans/:id/enabled | 日课表 CRUD、整表保存、启用,写入 schedule:write + 班主任/管理员 |
|
| /class-plans;PUT /class-plans/:id/entries;PATCH /class-plans/:id/enabled | 日课表 CRUD、整表保存、启用,写入 schedule:write + 班主任/管理员 |
|
||||||
| POST /class-plans/:id/clone、/class-plans/import、/class-plans/import/profile;GET /class-plans/:id/export | 复制、导入导出及完整档案导入 |
|
| POST /class-plans/:id/clone、/class-plans/import、/class-plans/import/profile;GET /class-plans/:id/export | 复制、导入导出及完整档案导入;导入另需 import:schedule |
|
||||||
| /class-plan-groups;GET /class-plan-groups/selection;POST /class-plan-groups/select | 群组及当前/临时选择 |
|
| /class-plan-groups;GET /class-plan-groups/selection;POST /class-plan-groups/select | 群组及当前/临时选择 |
|
||||||
| /homeworks、/homeworks/bulk、/:id/status、/:id/submissions | 作业、批量、完成与未交;员工功能键+科目检查,班级端可录作业 |
|
| /homeworks、/homeworks/bulk、/:id/status、/:id/submissions | 作业、批量、完成与未交;员工功能键+科目检查,班级端可录作业 |
|
||||||
| /notifications、/notifications/bulk、/days、/creators、/unread-count、/:id/read、/read-all | 通知、归档和已读;发布需 notification:publish,课堂紧急须确认 |
|
| /notifications、/notifications/bulk、/days、/creators、/unread-count、/:id/read、/read-all | 通知、归档和已读;发布需 notification:publish,课堂紧急须确认 |
|
||||||
| POST /calls | 叫人,call:create + 范围检查 |
|
| POST /calls | 叫人,call:create + 范围检查 |
|
||||||
| /grades、/grades/bulk、/stats、/levels、/my、/overview、/collective、/student/:studentNo、/exams、/ranking | [成绩与隐私](../app/grades.md),查询列表参数用 studentId,写入仍按科目 |
|
| /grades、/grades/bulk、/stats、/levels、/my、/overview、/collective、/student/:studentNo、/exams、/ranking | [成绩与隐私](../app/grades.md),查询列表参数用 studentId,写入仍按科目 |
|
||||||
| /imports/template、/table/preview、/table/commit | 表格导入,按 kind 再检查角色、导入权限、班级和科目 |
|
| /imports/template、/table/preview、/table/commit | 表格导入,按 kind 再检查对应导入键、管理键、班级和科目 |
|
||||||
| /imports/class-plan/preview、/imports/class-plan | 旧预览与完整档案提交;提交走关系化导入 |
|
| /imports/class-plan/preview、/imports/class-plan | 预览与提交均需 import:schedule + schedule:write;提交走关系化导入 |
|
||||||
| /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,写入/删除需 timetable:manage |
|
| /imports/time-layout[/:id]、/time-layout/preview | 时间配置兼容入口,导入需 import:schedule + timetable:manage,删除需 timetable:manage |
|
||||||
| GET /class-plans/profile?classId= | 完整档案导出,按班级范围读取,保留时间点与课表关联 |
|
| GET /class-plans/profile?classId= | 完整档案导出,按班级范围读取,保留时间点与课表关联 |
|
||||||
| GET /courseware、/courseware/:id、/:id/download | 课件目录/详情/下载;员工需 courseware:read,班级端按可见范围 |
|
| GET /courseware、/courseware/:id、/:id/download | 课件目录/详情/下载;员工需 courseware:read,班级端按可见范围 |
|
||||||
| POST /courseware/upload、/:id/push;PATCH /courseware/:id/visibility;DELETE /courseware/:id | 写入 courseware:write + 员工身份;本人文件、任课班级与可见范围限制 |
|
| POST /courseware/upload、/:id/push;PATCH /courseware/:id/visibility;DELETE /courseware/:id | 写入 courseware:write + 员工身份;本人文件、任课班级与可见范围限制 |
|
||||||
@@ -53,7 +53,7 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
|
|||||||
## 设备、系统与运维
|
## 设备、系统与运维
|
||||||
|
|
||||||
| 路径/动作 | 行为及边界 |
|
| 路径/动作 | 行为及边界 |
|
||||||
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- |
|
| ------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
|
||||||
| POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 |
|
| POST /clients/heartbeat、/clients/commands/:id/ack | 登录设备心跳/回执,校验 deviceKey 和归属 |
|
||||||
| POST /clients/update-progress | 设备上传下载、校验、等待、安装和失败状态,校验设备班级归属 |
|
| POST /clients/update-progress | 设备上传下载、校验、等待、安装和失败状态,校验设备班级归属 |
|
||||||
| GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 |
|
| GET /clients/devices;DELETE /clients/devices/:id | 设备读取/维护,分别检查功能键和范围 |
|
||||||
@@ -64,8 +64,8 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
|
|||||||
| POST /update/client/fetch、/update/client/releases;DELETE /update/client/releases/:version | 安装包写入口统一 client:update |
|
| POST /update/client/fetch、/update/client/releases;DELETE /update/client/releases/:version | 安装包写入口统一 client:update |
|
||||||
| GET /update/client/download/:version | 已登录下载,不挂员工权限键,供班级端更新 |
|
| GET /update/client/download/:version | 已登录下载,不挂员工权限键,供班级端更新 |
|
||||||
| /term;POST /term/auto;GET /term/holidays | 学期读取/编辑/假日建议,term:read/term:write |
|
| /term;POST /term/auto;GET /term/holidays | 学期读取/编辑/假日建议,term:read/term:write |
|
||||||
| GET/PUT /term/promotion;POST /term/promotion/run | 升级计划、预览和执行,system:manage,执行还做业务校验 |
|
| GET/PUT /term/promotion;POST /term/promotion/run | 升级计划与执行,promotion:manage + 全校范围,执行要求确认与计划哈希 |
|
||||||
| /archives、/archives/transferred、/archives/transferred/:id/restore | 历史归档与撤回转出,archive:manage;不提供创建归档代替年度升级的入口 |
|
| /archives、/archives/transferred、/archives/transferred/:id/restore | 历史归档需 archive:manage 全校范围;撤回转出需 roster:manage;不提供手动创建归档 |
|
||||||
| /database/status、/test-connection、/backups、/export、/import、/sqlite-file、/backup-schedule、/switch | database:manage;目标空库、快照及换库子进程保护 |
|
| /database/status、/test-connection、/backups、/export、/import、/sqlite-file、/backup-schedule、/switch | database:manage;目标空库、快照及换库子进程保护 |
|
||||||
| /system/settings、/autostart、/diagnostics | system:manage,非安装形态自启动返回不可用原因 |
|
| /system/settings、/autostart、/diagnostics | system:manage,非安装形态自启动返回不可用原因 |
|
||||||
| /ai/settings、/interpret、/audit | ai:manage/ai:use;只解析待确认动作,密钥只进不出 |
|
| /ai/settings、/interpret、/audit | ai:manage/ai:use;只解析待确认动作,密钥只进不出 |
|
||||||
@@ -75,3 +75,33 @@ API 前缀 /api,JWT 使用 Authorization: Bearer。成功/失败结构以 `lib
|
|||||||
课件上传是原始文件流,512 MB 限制独立于表格 JSON 的 12 MB 限制;metadata 走上传查询 schema。
|
课件上传是原始文件流,512 MB 限制独立于表格 JSON 的 12 MB 限制;metadata 走上传查询 schema。
|
||||||
心跳 agentProtocol:2 使用统一响应 data,旧协议兼容字段只用于升级过渡。
|
心跳 agentProtocol:2 使用统一响应 data,旧协议兼容字段只用于升级过渡。
|
||||||
更多说明见[权限](permissions.md)、[实时事件](realtime.md)、[数据模型](data-model.md)。
|
更多说明见[权限](permissions.md)、[实时事件](realtime.md)、[数据模型](data-model.md)。
|
||||||
|
|
||||||
|
## 权限治理与学生年度流程
|
||||||
|
|
||||||
|
| 方法与路径(省略 /api) | 参数与权限边界 |
|
||||||
|
| ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| GET /permissions/scopes | permission:manage;返回可选年级、部建议和计划年级 |
|
||||||
|
| GET /permissions/groups/:id/members | permission:manage;包含显式分组及角色默认回落的生效成员 |
|
||||||
|
| GET /permissions/audit?page=&pageSize= | permission:manage;分页操作人、目标、前后 JSON,pageSize 最大 100 |
|
||||||
|
| POST/PATCH/DELETE /permissions/groups[/:id] | permission:manage;范围校验与防提权;默认组仅 ADMIN 可改,内置组不可删 |
|
||||||
|
| PUT /permissions/users/:id/groups | groupIds 整体替换;默认组角色须匹配,空数组恢复角色默认模板;禁止越键越范围 |
|
||||||
|
| GET /students?status=&includeArchived= | roster:read;默认 active,归档单独筛选;历史状态不会再被在读条件覆盖 |
|
||||||
|
| POST /students | roster:manage;仅 active/inactive,学号重复 409,班级不得归档 |
|
||||||
|
| PATCH /students/:id | roster:manage;仅资料字段;classId/status 明确返回 400 |
|
||||||
|
| POST /students/transfer | roster:manage;studentIds、toClassId(null 表示移出)、note;跨年级需 ADMIN、allowGradeChange=true 与备注 |
|
||||||
|
| POST /students/transfer-out | roster:manage;studentIds、note、toSchool;仅 active/inactive,毕业/归档/重复转出 409 |
|
||||||
|
| POST /students/transfer-in | roster:manage;指定 toClassId,新建用 studentNo/name,重入用 studentId;可传 fromSchool/note |
|
||||||
|
| POST /students/:id/restore | roster:manage;可传 toClassId/note;转出或停用恢复,原班归档时应另选在读班 |
|
||||||
|
| POST /archives/transferred/:id/restore | 兼容同上,权限改为 roster:manage |
|
||||||
|
| DELETE /students/:id | roster:manage;默认停用;hard=true&confirm=true 仅 ADMIN,存在业务或历史则 409 |
|
||||||
|
| GET /students/transfers;GET /students/:id/transfers | roster:read;不排除转出状态;支持 studentId/classId/batchId/academicYear 与分页 |
|
||||||
|
| GET/PUT /term/promotion | promotion:manage 全校范围;读取计划、执行时间、应执行学年与记录;保存兼容旧 month/day/hour/classCount,也接受 plan |
|
||||||
|
| POST /term/promotion/preview | promotion:manage 全校范围;year;只读返回逐轨道逐班统计、阻断项、警告、新班码、周次影响、planHash |
|
||||||
|
| POST /term/promotion/run | promotion:manage 全校范围;year、planHash、confirm:true;过去学年还须 ADMIN + catchUp:true;未来年份 400;阻断或哈希变化 409 带 details |
|
||||||
|
| POST /classes/promote | promotion:manage 与各目标范围;仅 classIds,不收自由 grade;从计划推导下一学段,毕业学段 409 |
|
||||||
|
| PATCH /classes/:id | 不再接受 grade,旧请求返回 400;改年级须走升级入口 |
|
||||||
|
| DELETE /archives/:id | archive:manage 全校范围;仅撤销归档标记,保留转出与停用状态,不回滚年级、新班及周次 |
|
||||||
|
|
||||||
|
生命周期历史以 kind 为事实源,mode 为派生兼容字段;新响应另含 batchId、academicYear、fromGrade/toGrade、fromSchool/toSchool。
|
||||||
|
归档详情含 inactive 列表、inactiveCount 和按学生去重的 transferInCount;升级记录含 operatorId/operatorName、detailsJson、snapshotSha256、error。
|
||||||
|
所有学生与历史写入在同一事务,提交后再广播旧班与新班。班级详情只有持 roster:read 且在对应范围内的员工才能获得学生明细。
|
||||||
@@ -11,10 +11,10 @@ description: 现行模型职责、迁移与快照边界。
|
|||||||
## 模型职责
|
## 模型职责
|
||||||
|
|
||||||
| 模型 | 职责 |
|
| 模型 | 职责 |
|
||||||
| ---------------------- | -------------------------------------------------- |
|
| ---------------------- | --------------------------------------------------- |
|
||||||
| `User` | 教师与管理员账号;学生不在此表,班级凭据在 Class |
|
| `User` | 教师与管理员账号;学生不在此表,班级凭据在 Class |
|
||||||
| `Student` | 学生名单、学号、在读/转出/毕业状态,无密码和角色 |
|
| `Student` | 学生名单、学号、在读/转出/毕业状态,无密码和角色 |
|
||||||
| `StudentClassTransfer` | 调班与转出历史,保留班级/姓名快照 |
|
| `StudentClassTransfer` | 生命周期历史,kind/batchId/学年/班级年级及学校快照 |
|
||||||
| `ArchivedYear` | 毕业届别与统计快照 |
|
| `ArchivedYear` | 毕业届别与统计快照 |
|
||||||
| `TermWeek` | 逐周日期区间,classId 空串为全校默认 |
|
| `TermWeek` | 逐周日期区间,classId 空串为全校默认 |
|
||||||
| `Class` | 班级、班级码/密码、班主任、周数与成绩查询开关 |
|
| `Class` | 班级、班级码/密码、班主任、周数与成绩查询开关 |
|
||||||
@@ -26,6 +26,7 @@ description: 现行模型职责、迁移与快照边界。
|
|||||||
| `NotificationRead` | 按 studentId 记录已读 |
|
| `NotificationRead` | 按 studentId 记录已读 |
|
||||||
| `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 |
|
| `TimeLayout` | 共享或班级自有时间表;旧 items JSON 保留迁移审计 |
|
||||||
| `Grade` | studentId、课程、考试、分数和等级 |
|
| `Grade` | studentId、课程、考试、分数和等级 |
|
||||||
|
| `PermissionAudit` | 权限与人工升级审计,操作人和前后 JSON,不建账号外键 |
|
||||||
| `PermissionGroup` | 默认/自定义/岗位组及 scope/scopeValue |
|
| `PermissionGroup` | 默认/自定义/岗位组及 scope/scopeValue |
|
||||||
| `PermissionGroupItem` | 组内权限键 |
|
| `PermissionGroupItem` | 组内权限键 |
|
||||||
| `UserPermissionGroup` | 账号与组的多对多分配 |
|
| `UserPermissionGroup` | 账号与组的多对多分配 |
|
||||||
@@ -73,3 +74,14 @@ User.careMode 是预留列;当前 Web 偏好在 localStorage,客户端偏好
|
|||||||
2. 核对外键级联与删除护栏,特别是教师的班级、课程、作业、通知和课件。
|
2. 核对外键级联与删除护栏,特别是教师的班级、课程、作业、通知和课件。
|
||||||
3. 在临时库验证新建和旧库升级;不对真实业务库运行 seed/reset。
|
3. 在临时库验证新建和旧库升级;不对真实业务库运行 seed/reset。
|
||||||
4. 重新生成 Prisma Client,并按修改范围执行[验收](testing.md)。
|
4. 重新生成 Prisma Client,并按修改范围执行[验收](testing.md)。
|
||||||
|
|
||||||
|
## 权限与生命周期迁移
|
||||||
|
|
||||||
|
`20261023000000_promotion_permission` 为已初始化的 ADMIN 默认组补 promotion:manage;默认组尚未创建时允许不插入,由运行时模板创建。
|
||||||
|
`20261023010000_student_lifecycle_audit` 增加 PermissionAudit、StudentClassTransfer 的批次/学年/班级年级/学校快照、ArchivedYear.inactiveCount、PromotionRun 的操作人/明细/摘要/错误。
|
||||||
|
旧 mode 按 single/batch → class-change、transfer-out → transfer-out、restore-transfer → restore 迁移后删除该列,避免双事实源。
|
||||||
|
旧行的新字符串为空,学年为空,统计默认零;不臆造无法恢复的历史年级或批次。
|
||||||
|
|
||||||
|
SNAPSHOT_TABLES 已加入 PermissionAudit,新增字段随模型导出。旧快照没有审计表可恢复为空,旧历史 mode 在恢复时转为 kind;
|
||||||
|
没有新增列的旧行使用 schema 默认值。执行记录与升级快照不自动清理。
|
||||||
|
升级计划存 AppSetting.term.promotionPlan,时间存 term.promotionAt,无需新增学校或学生账号模型。
|
||||||
@@ -21,7 +21,7 @@ description: 课表、周次、学年、岛、设备与课件的现行实现入
|
|||||||
默认权限组、岗位范围、科目写权限分别由 permissions/permission-scope/access 维护,范围并集不授予任课权。
|
默认权限组、岗位范围、科目写权限分别由 permissions/permission-scope/access 维护,范围并集不授予任课权。
|
||||||
全校周数上限在 `term.maxWeeks`,默认 30、允许 1–60;开学日和逐周区间支持全校与班级设置。
|
全校周数上限在 `term.maxWeeks`,默认 30、允许 1–60;开学日和逐周区间支持全校与班级设置。
|
||||||
个人成绩查询关闭后只返回服务端集体聚合,个人明细、原始全班成绩与排名拒绝读取;样本不足或稀疏分段抑制。
|
个人成绩查询关闭后只返回服务端集体聚合,个人明细、原始全班成绩与排名拒绝读取;样本不足或稀疏分段抑制。
|
||||||
年度升级由 `term.promotionAt` 和 `PromotionRun.year` 管理,先存快照;运行期到点执行,启动漏跑需管理员预览补跑。
|
年度升级由 `term.promotionPlan`、`term.promotionAt` 和 `PromotionRun.year` 管理,先存快照;运行期到点执行,启动漏跑需管理员预览补跑。
|
||||||
毕业班归档,其余升一级,新班不复制任课关系。转出学生由 `lib/students.ts` 统一过滤。
|
毕业班归档,其余升一级,新班不复制任课关系。转出学生由 `lib/students.ts` 统一过滤。
|
||||||
|
|
||||||
## 岛与本机配置
|
## 岛与本机配置
|
||||||
@@ -38,3 +38,15 @@ description: 课表、周次、学年、岛、设备与课件的现行实现入
|
|||||||
更新从服务端缓存下载,客户端验 SHA256,按下发策略安装;远控白名单动作执行后回执,测试只用假执行器。
|
更新从服务端缓存下载,客户端验 SHA256,按下发策略安装;远控白名单动作执行后回执,测试只用假执行器。
|
||||||
课件上限 512 MB,服务端流式落盘后创建元数据和推送,客户端逐设备验摘要并回执,不自动运行文件。
|
课件上限 512 MB,服务端流式落盘后创建元数据和推送,客户端逐设备验摘要并回执,不自动运行文件。
|
||||||
JSON 快照含课件表,完整恢复还必须带文件实体目录。细节见[课件](../app/courseware.md)。
|
JSON 快照含课件表,完整恢复还必须带文件实体目录。细节见[课件](../app/courseware.md)。
|
||||||
|
|
||||||
|
## 学生与权限写入契约
|
||||||
|
|
||||||
|
学生状态/班级写入口集中在 `lib/student-lifecycle.ts`,班级名单添加/移出、名单导入、转出和转入均复用它。
|
||||||
|
资料 PATCH 不承担状态或班级迁移。学生与历史在事务内提交,UUID 标识批次,事务后广播两端班级,历史读取不附带在读过滤。
|
||||||
|
权限、功能对应范围、业务关系依次断言;权限只在当前请求对象上复用,下一请求重新计算。
|
||||||
|
范围解析在 shared,默认组或分组均按当前功能键过滤,不能借另一功能的范围扩大当前操作。
|
||||||
|
权限组或成员分配与 PermissionAudit 同事务,成员权限一次预取计算。
|
||||||
|
|
||||||
|
年度升级服务由计划推导所有轨道的下一学段。预览哈希覆盖配置、班级、学生和周次,执行事务内重新计算;
|
||||||
|
先保存全库快照并计算 SHA256,再以年份唯一键提交升级。毕业班所有状态挂届别,非毕业班仅升级及清班级开学日覆盖。
|
||||||
|
周次是否清空由计划决定,预览公开影响。服务保留 runPromotion(year, dryRun) 内部兼容签名;HTTP 执行必须提供哈希、确认并检查年份。
|
||||||
@@ -35,14 +35,14 @@ HEAD/SUBJECT 是班级关系,级长/部长是岗位组,均不新增 User.rol
|
|||||||
| 部长 | 配置的一组年级,scopeValue 用逗号分隔 |
|
| 部长 | 配置的一组年级,scopeValue 用逗号分隔 |
|
||||||
| 班级端 | 登录绑定班级 |
|
| 班级端 | 登录绑定班级 |
|
||||||
|
|
||||||
空年级/部配置不放行。设备写操作只合并含对应功能键的分组范围,不能借只读组扩权。
|
空年级/部配置不放行。范围按当前功能键独立合并,不能借其他只读组扩大名单、班级或设备写权限。
|
||||||
科目编辑再由 `assertCanManageSubjectContent` 检查:管理员可管理,教师仅自己任课科目,无科目内容归班主任。
|
科目编辑再由 `assertCanManageSubjectContent` 检查:管理员可管理,教师仅自己任课科目,无科目内容归班主任。
|
||||||
班主任不任教某科就不能改该科的作业或成绩;班级端只允许作业写入,不能写成绩。
|
班主任不任教某科就不能改该科的作业或成绩;班级端只允许作业写入,不能写成绩。
|
||||||
|
|
||||||
| 业务 | 主要功能键与额外边界 |
|
| 业务 | 主要功能键与额外边界 |
|
||||||
| ---------------- | ---------------------------------------------------------------------------- |
|
| ---------------- | ----------------------------------------------------------------------------------------------------- |
|
||||||
| 名单读取/管理 | roster:read / roster:manage;管理仍须管理员或本班班主任断言 |
|
| 名单读取/管理 | roster:read / roster:manage;管理需名单键与目标班范围;关联班级仍须班主任关系,全校名单范围可跨班维护 |
|
||||||
| 教师列表/维护 | teacher:read / teacher:manage;导入名单另检查导入权限和角色 |
|
| 教师列表/维护 | teacher:read / teacher:manage;导入教师另检查 import:teachers 与 teacher:manage |
|
||||||
| 日课表写入 | schedule:write + 管理员/本班班主任 |
|
| 日课表写入 | schedule:write + 管理员/本班班主任 |
|
||||||
| 全校时间表写入 | timetable:manage;默认给 ADMIN |
|
| 全校时间表写入 | timetable:manage;默认给 ADMIN |
|
||||||
| 作业/成绩 | homework:write / grade:write 或 grade:import,再按班级科目收口 |
|
| 作业/成绩 | homework:write / grade:write 或 grade:import,再按班级科目收口 |
|
||||||
@@ -50,6 +50,7 @@ HEAD/SUBJECT 是班级关系,级长/部长是岗位组,均不新增 User.rol
|
|||||||
| 课件 | courseware:read / courseware:write,上传和推送自己的文件还受本人授课班级约束 |
|
| 课件 | courseware:read / courseware:write,上传和推送自己的文件还受本人授课班级约束 |
|
||||||
| 客户端维护/远控 | client:read、client:manage、remote:control,写操作按对应键范围 |
|
| 客户端维护/远控 | client:read、client:manage、remote:control,写操作按对应键范围 |
|
||||||
| 版本与安装包写入 | client:update;旧 client:update-push 不再作为写入口要求 |
|
| 版本与安装包写入 | client:update;旧 client:update-push 不再作为写入口要求 |
|
||||||
|
| 学年升级 | promotion:manage;计划、预览与执行独立授权,全校范围,默认给 ADMIN |
|
||||||
| 数据库/系统/权限 | database:manage / system:manage / permission:manage |
|
| 数据库/系统/权限 | database:manage / system:manage / permission:manage |
|
||||||
|
|
||||||
完整键目录读取 `PERMISSION_KEYS`,不在文档维护固定键数。
|
完整键目录读取 `PERMISSION_KEYS`,不在文档维护固定键数。
|
||||||
@@ -67,3 +68,22 @@ Web 菜单属性来自路由,`meta.roles` 粗筛、`meta.permission` 细筛;
|
|||||||
`/api/clients/heartbeat` 和命令回执供已登录设备使用,不依赖班级端的空权限集合;服务端校验 deviceKey 与班级归属。
|
`/api/clients/heartbeat` 和命令回执供已登录设备使用,不依赖班级端的空权限集合;服务端校验 deviceKey 与班级归属。
|
||||||
远控从管理端确认下发,客户端执行后回传真实结果。更新文件由服务端缓存,教室机验 SHA256,
|
远控从管理端确认下发,客户端执行后回传真实结果。更新文件由服务端缓存,教室机验 SHA256,
|
||||||
按立即、放学后、空闲策略安装;关闭自动更新不会强制安装。详见[更新与升级](../management/upgrade.md)。
|
按立即、放学后、空闲策略安装;关闭自动更新不会强制安装。详见[更新与升级](../management/upgrade.md)。
|
||||||
|
|
||||||
|
## 范围配置与权限治理
|
||||||
|
|
||||||
|
范围由 shared 的 `parsePermissionScope` 统一解析。关联班级只接受空值(班主任与任课班)或 HEAD(仅班主任班);
|
||||||
|
GRADE 必选一个年级,DIVISION 必选多个年级组成的集合;全角逗号、顿号和空白统一分隔、去重排序。
|
||||||
|
ALL 存空范围值。年级选项来自数据库已有年级和升级计划,空范围或未知年级返回 400;旧库未配置的岗位组不放行。
|
||||||
|
`GET /permissions/scopes` 提供选择器选项,组 DTO 的 scopeLabel 供页面展示。
|
||||||
|
|
||||||
|
默认组只能分配给同角色;非 ADMIN 的权限管理员只能管理关联班级范围的自定义组,
|
||||||
|
组原有键和新键均不得超过自己当前权限。角色默认组只有 ADMIN 可改;删除组可能触发默认回落,仅 ADMIN 可执行。
|
||||||
|
分配空数组恢复数据库角色默认组,分配任何组则仅取所选组并集;需要保留默认时显式同时选择同角色默认组。
|
||||||
|
|
||||||
|
权限页保存前展示成员与权限差异,支持复制组、查看生效成员和分页审计。
|
||||||
|
PermissionAudit 在组或分配事务中保存操作人、目标、前后 JSON;`GET /permissions/audit` 受 permission:manage 控制。
|
||||||
|
成员权限由一次预取组和账号后计算,不逐账号重复加载。
|
||||||
|
|
||||||
|
新增迁移为旧库已有 ADMIN 默认组补 promotion:manage;新库由默认模板创建。
|
||||||
|
自定义组和已分配其他组的管理员不会自动获得该键,需要在权限页显式授予。
|
||||||
|
班级端权限仍为空,本班名单、课表、通知、叫人和作业代录走显式员工守卫例外;班级端发布内容归属本班班主任。
|
||||||
@@ -11,12 +11,13 @@ description: 隔离 API、隐藏 UI、后台客户端与显式视觉验收。
|
|||||||
## 命令与前置条件
|
## 命令与前置条件
|
||||||
|
|
||||||
| 命令 | 覆盖/前置条件 |
|
| 命令 | 覆盖/前置条件 |
|
||||||
| ------------------------------------------------ | ----------------------------------------------------------------- |
|
| ------------------------------------------------ | ---------------------------------------------------------------------------- |
|
||||||
| pnpm lint / pnpm typecheck / pnpm check:icons | 静态检查;typecheck 会先构建 shared |
|
| pnpm lint / pnpm typecheck / pnpm check:icons | 静态检查;typecheck 会先构建 shared |
|
||||||
| pnpm docs:check | docfx 严格构建,需 .NET SDK 与 docfx |
|
| pnpm docs:check | docfx 严格构建,需 .NET SDK 与 docfx |
|
||||||
| pnpm verify:e2e | 自动临时 SQLite、种子和带前缀账号,启动随机端口服务;无需真实服务 |
|
| pnpm verify:e2e | 自动临时 SQLite、种子和带前缀账号,启动随机端口服务;无需真实服务 |
|
||||||
| pnpm verify:web:isolated | 同上隔离服务,隐藏 Electron UI 回归;先构建 Web 产物 |
|
| pnpm verify:web:isolated | 同上隔离服务,隐藏 Electron UI 回归;先构建 Web 产物 |
|
||||||
| pnpm verify:web | 连接已准备测试服务,需 Web 产物和测试管理员;不应指向业务服务 |
|
| pnpm verify:web | 连接已准备测试服务,需 Web 产物和测试管理员;不应指向业务服务 |
|
||||||
|
| pnpm verify:lifecycle | 临时 SQLite 验证权限治理、事务故障回滚、状态机、历史、多轨道、计划哈希和快照 |
|
||||||
| pnpm verify:v14 | 临时库验证关系课表、周次、范围、隐私与升级等 |
|
| pnpm verify:v14 | 临时库验证关系课表、周次、范围、隐私与升级等 |
|
||||||
| pnpm verify:courseware | 临时库和文件目录的课件专项,不向真实班级推送 |
|
| pnpm verify:courseware | 临时库和文件目录的课件专项,不向真实班级推送 |
|
||||||
| pnpm verify:client-agent | 假系统适配器验证命令、回执和更新策略 |
|
| pnpm verify:client-agent | 假系统适配器验证命令、回执和更新策略 |
|
||||||
@@ -42,6 +43,8 @@ pnpm check:icons
|
|||||||
pnpm docs:check
|
pnpm docs:check
|
||||||
pnpm verify:e2e
|
pnpm verify:e2e
|
||||||
pnpm verify:v14
|
pnpm verify:v14
|
||||||
|
pnpm verify:lifecycle
|
||||||
|
pnpm verify:client-migrations
|
||||||
pnpm verify:courseware
|
pnpm verify:courseware
|
||||||
pnpm build:desktop
|
pnpm build:desktop
|
||||||
pnpm verify:desktop
|
pnpm verify:desktop
|
||||||
@@ -67,3 +70,7 @@ docs/images 为 README/文档展示图;docs/screenshots 为回归留档;webs
|
|||||||
Web 采集入口 `website/tools/capture-shots.mjs`,先准备隔离测试服务和数据。
|
Web 采集入口 `website/tools/capture-shots.mjs`,先准备隔离测试服务和数据。
|
||||||
岛/客户端采集使用 smoke.mjs --visual,并按需 --online;ISLAND_SHOTS_DIR 与 ELECTRON_CLIENT_SHOTS_DIR 指向临时目录。
|
岛/客户端采集使用 smoke.mjs --visual,并按需 --online;ISLAND_SHOTS_DIR 与 ELECTRON_CLIENT_SHOTS_DIR 指向临时目录。
|
||||||
日常 quick 模式不会生成完整视觉截图,不能把 quick 通过表述为动画/透明度已验证。
|
日常 quick 模式不会生成完整视觉截图,不能把 quick 通过表述为动画/透明度已验证。
|
||||||
|
|
||||||
|
权限与年度流程回归入口为 `scripts/verify-lifecycle.mjs`,为子进程指定临时库与测试密钥,不使用业务 .env 的登录凭据。
|
||||||
|
旧库升级脚本动态读取最新迁移,另建重构前夹具校验 ADMIN 默认组补键、历史 kind 映射、空组保留及重复启动。
|
||||||
|
事务回滚用 SQLite 触发器模拟历史写入失败,不能通过删除断言或放宽状态规则通过测试。
|
||||||
@@ -13,15 +13,15 @@ description: 登录、菜单分组、权限与移动布局。
|
|||||||
侧栏按以下分组组织,具体页面由路由权限筛选,并支持浅色/深色和关怀模式。
|
侧栏按以下分组组织,具体页面由路由权限筛选,并支持浅色/深色和关怀模式。
|
||||||
|
|
||||||
| 分组 | 主要页面 |
|
| 分组 | 主要页面 |
|
||||||
| ---------- | -------------------------------------------------- |
|
| ---------- | ------------------------------------------------------------ |
|
||||||
| 工作台 | 仪表盘、AI 助手 |
|
| 工作台 | 仪表盘、AI 助手 |
|
||||||
| 教学事务 | 课表、时间表与日课表编辑、作业、通知、课件、成绩 |
|
| 教学事务 | 课表、时间表与日课表编辑、作业、通知、课件、成绩 |
|
||||||
| 班级与人员 | 班级、学生、教师、只读学生名单、学期周次、毕业归档 |
|
| 班级与人员 | 班级、学生、教师、只读学生名单、学期周次、学年升级、毕业归档 |
|
||||||
| 设备运维 | 客户端在线状态、命令、更新与安装策略 |
|
| 设备运维 | 客户端在线状态、命令、更新与安装策略 |
|
||||||
| 系统管理 | 权限组、系统设置、大模型设置、数据库管理 |
|
| 系统管理 | 权限组、系统设置、大模型设置、数据库管理 |
|
||||||
|
|
||||||
班级/学生/教师管理、学期周次、归档和数据库页面还有管理员角色门槛;
|
班级、学生、学期周次、学年升级和归档页面按各自生效权限开放;教师管理和数据库页面仍保留角色门槛。
|
||||||
教师只读名单、教学页与设备页按权限键和数据范围开放。
|
名单、教学页与设备页还按对应数据范围开放,写按钮需额外管理键。
|
||||||
每次操作服务端继续校验,不是显示菜单就能改所有班和科目。详见[角色与权限](../dev/permissions.md)。
|
每次操作服务端继续校验,不是显示菜单就能改所有班和科目。详见[角色与权限](../dev/permissions.md)。
|
||||||
顶栏提供本人密码、使用引导、检查更新与退出等入口。
|
顶栏提供本人密码、使用引导、检查更新与退出等入口。
|
||||||
|
|
||||||
@@ -32,3 +32,7 @@ description: 登录、菜单分组、权限与移动布局。
|
|||||||
同局域网手机填写服务器局域网地址,不用 localhost;公网反代见[生产部署](../management/production.md)。
|
同局域网手机填写服务器局域网地址,不用 localhost;公网反代见[生产部署](../management/production.md)。
|
||||||
|
|
||||||
下一步:[建班级、排课表](setup-class.md)。
|
下一步:[建班级、排课表](setup-class.md)。
|
||||||
|
|
||||||
|
权限页选组会替代角色默认模板,保存前可查看新增/移除权限与影响成员;保留默认能力时同时选择同角色默认组。
|
||||||
|
名单页默认在读,历史状态与包含归档分开筛选;资料编辑、调班、转出、转入/复学各有专用入口。
|
||||||
|
学年升级先保存计划、预览阻断项和周次影响,再输入学年确认执行;执行记录保留快照与 SHA256。
|
||||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 108 KiB After Width: | Height: | Size: 67 KiB |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "class-helper",
|
"name": "class-helper",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
"description": "ClassHelper - 班级信息管理系统(Web 管理端 + 后端服务 + 桌面客户端)",
|
||||||
"packageManager": "pnpm@11.8.0",
|
"packageManager": "pnpm@11.8.0",
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
"verify:e2e": "node scripts/verify-e2e-isolated.mjs",
|
"verify:e2e": "node scripts/verify-e2e-isolated.mjs",
|
||||||
"verify:courseware": "node scripts/verify-courseware.mjs",
|
"verify:courseware": "node scripts/verify-courseware.mjs",
|
||||||
"verify:v14": "node scripts/verify-v14.mjs",
|
"verify:v14": "node scripts/verify-v14.mjs",
|
||||||
|
"verify:lifecycle": "node scripts/verify-lifecycle.mjs",
|
||||||
"verify:client-agent": "node scripts/verify-client-agent.mjs",
|
"verify:client-agent": "node scripts/verify-client-agent.mjs",
|
||||||
"verify:review-client": "node scripts/verify-review-client.mjs",
|
"verify:review-client": "node scripts/verify-review-client.mjs",
|
||||||
"verify:review-api": "node scripts/verify-review-api.mjs",
|
"verify:review-api": "node scripts/verify-review-api.mjs",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/desktop-client",
|
"name": "@classhelper/desktop-client",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||||
"author": "Class Helper",
|
"author": "Class Helper",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/server",
|
"name": "@classhelper/server",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- 已存在的管理员默认组补升级键;默认组尚未初始化时由运行时模板创建。
|
||||||
|
INSERT INTO "PermissionGroupItem" ("id", "groupId", "key")
|
||||||
|
SELECT 'promotion-key-' || "id", "id", 'promotion:manage'
|
||||||
|
FROM "PermissionGroup"
|
||||||
|
WHERE "defaultRole" = 'ADMIN'
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM "PermissionGroupItem" i WHERE i."groupId" = "PermissionGroup"."id" AND i."key" = 'promotion:manage');
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
ALTER TABLE "StudentClassTransfer" ADD COLUMN "kind" TEXT NOT NULL DEFAULT 'class-change';
|
||||||
|
ALTER TABLE "StudentClassTransfer" ADD COLUMN "batchId" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "StudentClassTransfer" ADD COLUMN "academicYear" INTEGER;
|
||||||
|
ALTER TABLE "StudentClassTransfer" ADD COLUMN "fromGrade" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "StudentClassTransfer" ADD COLUMN "toGrade" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "StudentClassTransfer" ADD COLUMN "fromSchool" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "StudentClassTransfer" ADD COLUMN "toSchool" TEXT NOT NULL DEFAULT '';
|
||||||
|
UPDATE "StudentClassTransfer" SET "kind" = CASE "mode" WHEN 'transfer-out' THEN 'transfer-out' WHEN 'restore-transfer' THEN 'restore' ELSE 'class-change' END;
|
||||||
|
ALTER TABLE "StudentClassTransfer" DROP COLUMN "mode";
|
||||||
|
CREATE INDEX "StudentClassTransfer_batchId_idx" ON "StudentClassTransfer"("batchId");
|
||||||
|
ALTER TABLE "ArchivedYear" ADD COLUMN "inactiveCount" INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE "PromotionRun" ADD COLUMN "operatorId" TEXT;
|
||||||
|
ALTER TABLE "PromotionRun" ADD COLUMN "operatorName" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "PromotionRun" ADD COLUMN "detailsJson" TEXT NOT NULL DEFAULT '{}';
|
||||||
|
ALTER TABLE "PromotionRun" ADD COLUMN "snapshotSha256" TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE "PromotionRun" ADD COLUMN "error" TEXT NOT NULL DEFAULT '';
|
||||||
|
CREATE TABLE "PermissionAudit" (
|
||||||
|
"id" TEXT NOT NULL PRIMARY KEY, "actorId" TEXT NOT NULL, "actorName" TEXT NOT NULL,
|
||||||
|
"action" TEXT NOT NULL, "targetType" TEXT NOT NULL, "targetId" TEXT NOT NULL,
|
||||||
|
"beforeJson" TEXT NOT NULL DEFAULT '{}', "afterJson" TEXT NOT NULL DEFAULT '{}',
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
CREATE INDEX "PermissionAudit_createdAt_idx" ON "PermissionAudit"("createdAt");
|
||||||
@@ -121,14 +121,21 @@ model StudentClassTransfer {
|
|||||||
/// 操作人(User.id);账号被删后留空,姓名快照保留
|
/// 操作人(User.id);账号被删后留空,姓名快照保留
|
||||||
operatorId String?
|
operatorId String?
|
||||||
operatorName String @default("")
|
operatorName String @default("")
|
||||||
/// single = 单个调班 / batch = 批量调班 / transfer-out = 转出(离开本校)
|
/// 生命周期类型;旧 mode 仅在 DTO 中派生兼容
|
||||||
mode String @default("single")
|
kind String @default("class-change")
|
||||||
|
batchId String @default("")
|
||||||
|
academicYear Int?
|
||||||
|
fromGrade String @default("")
|
||||||
|
toGrade String @default("")
|
||||||
|
fromSchool String @default("")
|
||||||
|
toSchool String @default("")
|
||||||
note String @default("")
|
note String @default("")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@index([studentId, createdAt])
|
@@index([studentId, createdAt])
|
||||||
|
@@index([batchId])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -157,6 +164,7 @@ model ArchivedYear {
|
|||||||
classCount Int @default(0)
|
classCount Int @default(0)
|
||||||
studentCount Int @default(0)
|
studentCount Int @default(0)
|
||||||
transferredCount Int @default(0)
|
transferredCount Int @default(0)
|
||||||
|
inactiveCount Int @default(0)
|
||||||
homeworkCount Int @default(0)
|
homeworkCount Int @default(0)
|
||||||
notificationCount Int @default(0)
|
notificationCount Int @default(0)
|
||||||
archivedAt DateTime @default(now())
|
archivedAt DateTime @default(now())
|
||||||
@@ -717,12 +725,32 @@ model PromotionRun {
|
|||||||
year Int @id
|
year Int @id
|
||||||
status String
|
status String
|
||||||
snapshotPath String @default("")
|
snapshotPath String @default("")
|
||||||
|
operatorId String?
|
||||||
|
operatorName String @default("")
|
||||||
|
detailsJson String @default("{}")
|
||||||
|
snapshotSha256 String @default("")
|
||||||
|
error String @default("")
|
||||||
message String @default("")
|
message String @default("")
|
||||||
classCount Int @default(0)
|
classCount Int @default(0)
|
||||||
studentCount Int @default(0)
|
studentCount Int @default(0)
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
finishedAt DateTime?
|
finishedAt DateTime?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 权限变更与人工升级审计;不建账号外键,删除账号后仍保留操作人快照。
|
||||||
|
model PermissionAudit {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
actorId String
|
||||||
|
actorName String
|
||||||
|
action String
|
||||||
|
targetType String
|
||||||
|
targetId String
|
||||||
|
beforeJson String @default("{}")
|
||||||
|
afterJson String @default("{}")
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@index([createdAt])
|
||||||
|
}
|
||||||
/// 课件只在完整接收并计算摘要后入库;存储路径不对客户端暴露。
|
/// 课件只在完整接收并计算摘要后入库;存储路径不对客户端暴露。
|
||||||
model Courseware {
|
model Courseware {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ async function main(): Promise<void> {
|
|||||||
toClassName: classes[1]!.name,
|
toClassName: classes[1]!.name,
|
||||||
operatorId: admin.id,
|
operatorId: admin.id,
|
||||||
operatorName: admin.name,
|
operatorName: admin.name,
|
||||||
mode: 'batch',
|
kind: 'class-change',
|
||||||
note: '军训后按分班考试成绩重新均衡',
|
note: '军训后按分班考试成绩重新均衡',
|
||||||
createdAt: new Date(now - 30 * day),
|
createdAt: new Date(now - 30 * day),
|
||||||
},
|
},
|
||||||
@@ -492,7 +492,7 @@ async function main(): Promise<void> {
|
|||||||
toClassName: '转出本校',
|
toClassName: '转出本校',
|
||||||
operatorId: admin.id,
|
operatorId: admin.id,
|
||||||
operatorName: admin.name,
|
operatorName: admin.name,
|
||||||
mode: 'transfer-out',
|
kind: 'transfer-out',
|
||||||
note: '随家长工作调动转学至外地',
|
note: '随家长工作调动转学至外地',
|
||||||
createdAt: new Date(now - 12 * day),
|
createdAt: new Date(now - 12 * day),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
/**
|
/**
|
||||||
* 端到端联调验证(对应验收标准):
|
* 端到端联调验证(对应验收标准):
|
||||||
* 1. 教师发布通知 -> ClassHelper 班级端 Socket.IO 5 秒内收到
|
* 1. 教师发布通知 -> ClassHelper 班级端 Socket.IO 5 秒内收到
|
||||||
@@ -1056,9 +1057,13 @@ async function main() {
|
|||||||
const studentCalls = await api('/calls', {
|
const studentCalls = await api('/calls', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: studentToken,
|
token: studentToken,
|
||||||
body: { classId, studentId: targetStudent?.id, quickPhrase: '学生不能叫人' },
|
body: { classId, studentId: targetStudent?.id, quickPhrase: '班级端代叫人' },
|
||||||
});
|
});
|
||||||
record('学生调用叫人接口被拒绝(403)', studentCalls.status === 403, `status=${studentCalls.status}`);
|
record(
|
||||||
|
'班级端可为本班叫人(201)',
|
||||||
|
studentCalls.status === 201 && Boolean(studentCalls.payload?.data?.id),
|
||||||
|
`status=${studentCalls.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
const studentListAfterCall = await api('/notifications', { token: studentToken });
|
const studentListAfterCall = await api('/notifications', { token: studentToken });
|
||||||
const receivedCall = (studentListAfterCall.payload?.data ?? []).find(
|
const receivedCall = (studentListAfterCall.payload?.data ?? []).find(
|
||||||
@@ -1566,7 +1571,11 @@ async function main() {
|
|||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
'管理员可以分配权限组(PUT /users/:id/groups → 200)',
|
'管理员可以分配权限组(PUT /users/:id/groups → 200)',
|
||||||
Boolean(adminMember) && assignAdmin.status === 200 && assignAdmin.payload?.data?.fixed === false,
|
Boolean(adminMember) &&
|
||||||
|
assignAdmin.status === 200 &&
|
||||||
|
Array.isArray(assignAdmin.payload?.data?.permissions) &&
|
||||||
|
assignAdmin.payload.data.permissions.includes('permission:manage') &&
|
||||||
|
assignAdmin.payload.data.permissions.includes('system:manage'),
|
||||||
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
|
`status=${assignAdmin.status} 响应=${assignAdmin.payload?.message ?? ''}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2035,9 +2044,13 @@ async function main() {
|
|||||||
const studentPublish = await api('/notifications', {
|
const studentPublish = await api('/notifications', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: studentToken,
|
token: studentToken,
|
||||||
body: { classId, title: '学生越权发布', content: '不应成功' },
|
body: { classId, title: '班级端本班通知', content: '允许本班发布' },
|
||||||
});
|
});
|
||||||
record('学生调用教师接口被拒绝(403)', studentPublish.status === 403, `status=${studentPublish.status}`);
|
record(
|
||||||
|
'班级端可发布本班通知(201)',
|
||||||
|
studentPublish.status === 201 && Boolean(studentPublish.payload?.data?.id),
|
||||||
|
`status=${studentPublish.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
const anonymous = await api('/classes');
|
const anonymous = await api('/classes');
|
||||||
record('未登录访问被拒绝(401)', anonymous.status === 401, `status=${anonymous.status}`);
|
record('未登录访问被拒绝(401)', anonymous.status === 401, `status=${anonymous.status}`);
|
||||||
@@ -2098,8 +2111,8 @@ async function main() {
|
|||||||
token: teacherToken,
|
token: teacherToken,
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
'班主任下载名单模板被拒绝(403,名单仅管理员)',
|
'有导入键的班主任可下载名单模板(200)',
|
||||||
teacherRosterTemplate.status === 403,
|
teacherRosterTemplate.status === 200 && Boolean(teacherRosterTemplate.payload?.data?.content),
|
||||||
`status=${teacherRosterTemplate.status}`,
|
`status=${teacherRosterTemplate.status}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2581,11 +2594,12 @@ async function main() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
'班主任预览学生名单模板被拒绝(403,名单仅管理员)',
|
'有导入键的班主任可预览名单模板(200)',
|
||||||
teacherRosterPreview.status === 403,
|
teacherRosterPreview.status === 200 && Boolean(teacherRosterPreview.payload?.data),
|
||||||
`status=${teacherRosterPreview.status}`,
|
`status=${teacherRosterPreview.status}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const teacherImportedNo = `T${Date.now()}`;
|
||||||
const studentsByTeacher = await api('/imports/table/commit', {
|
const studentsByTeacher = await api('/imports/table/commit', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: teacherToken,
|
token: teacherToken,
|
||||||
@@ -2593,17 +2607,43 @@ async function main() {
|
|||||||
kind: 'students',
|
kind: 'students',
|
||||||
classId,
|
classId,
|
||||||
fileName: 'students.csv',
|
fileName: 'students.csv',
|
||||||
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
|
contentBase64: Buffer.from(
|
||||||
|
`\ufeff学号,姓名${NL}${teacherImportedNo},班主任导入学生${NL}`,
|
||||||
|
'utf8',
|
||||||
|
).toString('base64'),
|
||||||
mapping: { studentNo: '学号', name: '姓名' },
|
mapping: { studentNo: '学号', name: '姓名' },
|
||||||
mode: 'append',
|
mode: 'append',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
record(
|
record(
|
||||||
'教师导入学生名单被拒绝(403)',
|
'有导入键的班主任按范围导入本班名单(200)',
|
||||||
studentsByTeacher.status === 403,
|
studentsByTeacher.status === 200 &&
|
||||||
|
studentsByTeacher.payload?.data?.failed === 0 &&
|
||||||
|
studentsByTeacher.payload?.data?.inserted === 1,
|
||||||
`status=${studentsByTeacher.status}`,
|
`status=${studentsByTeacher.status}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const teacherImportedStudents = await api(`/students?classId=${classId}&keyword=${teacherImportedNo}`, {
|
||||||
|
token: adminToken,
|
||||||
|
});
|
||||||
|
const teacherImportedStudent = teacherImportedStudents.payload?.data?.find(
|
||||||
|
(item) => item.studentNo === teacherImportedNo,
|
||||||
|
);
|
||||||
|
if (teacherImportedStudent)
|
||||||
|
await api(`/students/${teacherImportedStudent.id}`, { method: 'DELETE', token: adminToken });
|
||||||
|
const teacherImportAfter = await api(
|
||||||
|
`/students?classId=${classId}&status=inactive&keyword=${teacherImportedNo}`,
|
||||||
|
{ token: adminToken },
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'班主任导入夹具收尾:停用并保留历史,恢复原在读名单',
|
||||||
|
Boolean(
|
||||||
|
teacherImportAfter.payload?.data?.some(
|
||||||
|
(item) => item.studentNo === teacherImportedNo && item.status === 'inactive',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
// 清理:删除导入产生的成绩、时间配置与学生,保持环境可重复验证
|
// 清理:删除导入产生的成绩、时间配置与学生,保持环境可重复验证
|
||||||
const importedGrades = await api(
|
const importedGrades = await api(
|
||||||
`/grades?classId=${classId}&examName=${encodeURIComponent('期中导入考试')}`,
|
`/grades?classId=${classId}&examName=${encodeURIComponent('期中导入考试')}`,
|
||||||
@@ -2725,9 +2765,13 @@ async function main() {
|
|||||||
const classPublish = await api('/notifications', {
|
const classPublish = await api('/notifications', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: classToken,
|
token: classToken,
|
||||||
body: { classId, title: '班级账号越权发布', content: '不应成功' },
|
body: { classId, title: '班级账号本班发布', content: '允许本班发布' },
|
||||||
});
|
});
|
||||||
record('班级账号发布通知被拒绝(403)', classPublish.status === 403, `status=${classPublish.status}`);
|
record(
|
||||||
|
'班级账号发布本班通知成功(201)',
|
||||||
|
classPublish.status === 201 && Boolean(classPublish.payload?.data?.id),
|
||||||
|
`status=${classPublish.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
const classGradesWrite = await api('/grades', {
|
const classGradesWrite = await api('/grades', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -3201,44 +3245,11 @@ async function main() {
|
|||||||
// ---------------------------------------------------------------- 数据库管理(仅管理员)
|
// ---------------------------------------------------------------- 数据库管理(仅管理员)
|
||||||
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
|
// 覆盖:状态 / 越权 403 / 连接测试 / 备份→导入导出→恢复 roundtrip / 定时配置校验 / 切换校验分支。
|
||||||
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
|
// 注意:**不真跑切换**(会改写 .env 与 schema.prisma),只验证拒绝分支;真实切换需人工复验。
|
||||||
// 数据表清单随模型演进增长(2026-10-10 加了权限组 / 客户端管理 8 张表后共 23 张)。
|
// 直接读取快照注册表,避免新增模型后测试清单遗漏。
|
||||||
// **不写死总数**:断言"服务端快照枚举 == 这份清单",加模型时只改这一处。
|
const snapshotSource = readFileSync(new URL('../src/lib/snapshot.ts', import.meta.url), 'utf8');
|
||||||
const REQUIRED_DB_TABLES = [
|
const registry = snapshotSource.match(/export const SNAPSHOT_TABLES = \[([\s\S]*?)\] as const/);
|
||||||
'User',
|
if (!registry) throw new Error('无法解析快照表注册表');
|
||||||
'ArchivedYear',
|
const REQUIRED_DB_TABLES = [...registry[1].matchAll(/'([^']+)'/g)].map((match) => match[1]);
|
||||||
'Class',
|
|
||||||
'Student',
|
|
||||||
'Course',
|
|
||||||
'StudentClassTransfer',
|
|
||||||
'Schedule',
|
|
||||||
'TimeLayout',
|
|
||||||
'Homework',
|
|
||||||
'HomeworkStatus',
|
|
||||||
'Notification',
|
|
||||||
'NotificationRead',
|
|
||||||
'Grade',
|
|
||||||
'PermissionGroup',
|
|
||||||
'PermissionGroupItem',
|
|
||||||
'UserPermissionGroup',
|
|
||||||
'AppSetting',
|
|
||||||
'ClientDevice',
|
|
||||||
'Courseware',
|
|
||||||
'CoursewareFolder',
|
|
||||||
'CoursewareVisibility',
|
|
||||||
'CoursewarePush',
|
|
||||||
'CoursewareReceipt',
|
|
||||||
'ClientCommand',
|
|
||||||
'ClientRelease',
|
|
||||||
'AiAudit',
|
|
||||||
'TimeLayoutItem',
|
|
||||||
'ClassPlanGroup',
|
|
||||||
'ClassPlan',
|
|
||||||
'ClassPlanEntry',
|
|
||||||
'ScheduledClassPlan',
|
|
||||||
'PromotionRun',
|
|
||||||
'TermWeek',
|
|
||||||
'Exam',
|
|
||||||
];
|
|
||||||
const dbStatus = await api('/database/status', { token: adminToken });
|
const dbStatus = await api('/database/status', { token: adminToken });
|
||||||
const dbStatusData = dbStatus.payload?.data ?? {};
|
const dbStatusData = dbStatus.payload?.data ?? {};
|
||||||
const dbStatusTableNames = Array.isArray(dbStatusData.tables)
|
const dbStatusTableNames = Array.isArray(dbStatusData.tables)
|
||||||
@@ -3278,14 +3289,14 @@ async function main() {
|
|||||||
`status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`,
|
`status=${dbStatusTeacher.status} 备份=${dbBackupTeacher.status} 定时=${dbScheduleTeacher.status} 切换=${dbSwitchTeacher.status}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// 连接测试:当前库必须可达(SQLite 用相对路径,MySQL 用一个不可达地址测失败分支)
|
// 连接测试:当前库必须可达(SQLite 取服务端当前临时库地址,MySQL 用一个不可达地址测失败分支)
|
||||||
let testOkResult;
|
let testOkResult;
|
||||||
let testOkName;
|
let testOkName;
|
||||||
if (dbStatusData.provider === 'sqlite') {
|
if (dbStatusData.provider === 'sqlite') {
|
||||||
testOkResult = await api('/database/test-connection', {
|
testOkResult = await api('/database/test-connection', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
token: adminToken,
|
token: adminToken,
|
||||||
body: { provider: 'sqlite', url: 'file:./prisma/dev.db' },
|
body: { provider: 'sqlite', url: dbStatusData.databaseUrlMasked },
|
||||||
});
|
});
|
||||||
testOkName = '连接测试(当前 SQLite 库可达)';
|
testOkName = '连接测试(当前 SQLite 库可达)';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { createServer } from 'node:http';
|
||||||
|
import { PERMISSION_KEYS, defaultPromotionPlan, parsePermissionScope } from '@classhelper/shared';
|
||||||
|
import { prisma } from '../src/lib/db.js';
|
||||||
|
import { createApp } from '../src/app.js';
|
||||||
|
import { signToken, type TokenPayload } from '../src/lib/jwt.js';
|
||||||
|
import { ensureDefaultGroups, loadEffectivePermissions } from '../src/lib/permissions.js';
|
||||||
|
import { registerEmitter } from '../src/realtime/bus.js';
|
||||||
|
import * as students from '../src/modules/students/students.service.js';
|
||||||
|
import * as classes from '../src/modules/classes/classes.service.js';
|
||||||
|
import * as permissions from '../src/modules/permissions/permissions.service.js';
|
||||||
|
import {
|
||||||
|
configurePromotion,
|
||||||
|
previewPromotion,
|
||||||
|
runPromotion,
|
||||||
|
promotionStatus,
|
||||||
|
} from '../src/modules/term/promotion.service.js';
|
||||||
|
import { deleteArchive, getArchivedYearDetail } from '../src/modules/archives/archives.service.js';
|
||||||
|
import { updateStudentSchema } from '../src/modules/students/students.schemas.js';
|
||||||
|
import { exportSnapshot, restoreSnapshot } from '../src/lib/snapshot.js';
|
||||||
|
|
||||||
|
if (!process.env.DATABASE_URL?.includes('lifecycle-verification'))
|
||||||
|
throw new Error('只能对生命周期专用临时数据库运行');
|
||||||
|
let count = 0;
|
||||||
|
function check(name: string, fn: () => unknown) {
|
||||||
|
fn();
|
||||||
|
console.log(`通过 ${++count}:${name}`);
|
||||||
|
}
|
||||||
|
async function denied(action: Promise<unknown>, status: number) {
|
||||||
|
await assert.rejects(action, (error: unknown) => (error as { status?: number }).status === status);
|
||||||
|
}
|
||||||
|
const account = async (name: string, role: string) => {
|
||||||
|
const row = await prisma.user.create({
|
||||||
|
data: { username: name, name, role, passwordHash: '隔离测试,不登录' },
|
||||||
|
});
|
||||||
|
return { sub: row.id, username: row.username, name, role, classId: null } as TokenPayload;
|
||||||
|
};
|
||||||
|
const admin = await account('临时管理员', 'ADMIN'),
|
||||||
|
teacher = await account('临时班主任', 'TEACHER'),
|
||||||
|
manager = await account('临时权限协助', 'TEACHER');
|
||||||
|
await ensureDefaultGroups();
|
||||||
|
const cls = async (
|
||||||
|
name: string,
|
||||||
|
grade: string,
|
||||||
|
enrollmentYear: number,
|
||||||
|
classIndex: number,
|
||||||
|
teacherId = teacher.sub,
|
||||||
|
) => prisma.class.create({ data: { name, grade, enrollmentYear, classIndex, code: name, teacherId } });
|
||||||
|
const a = await cls('TESTA', '高一', 2025, 1),
|
||||||
|
b = await cls('TESTB', '高一', 2025, 2),
|
||||||
|
c = await cls('TESTC', '高二', 2024, 1, admin.sub);
|
||||||
|
const events: { classId: string; event: string }[] = [];
|
||||||
|
registerEmitter((rooms, event) => {
|
||||||
|
for (const room of rooms) events.push({ classId: room.replace('class:', ''), event });
|
||||||
|
});
|
||||||
|
const server = createServer(createApp());
|
||||||
|
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||||
|
const base = `http://127.0.0.1:${(server.address() as { port: number }).port}/api`;
|
||||||
|
async function request(url: string, actor: TokenPayload, method = 'GET', body?: unknown, expected = 200) {
|
||||||
|
const response = await fetch(base + url, {
|
||||||
|
method,
|
||||||
|
headers: { authorization: `Bearer ${signToken(actor)}`, 'content-type': 'application/json' },
|
||||||
|
...(body === undefined ? {} : { body: JSON.stringify(body) }),
|
||||||
|
});
|
||||||
|
const data = await response.json();
|
||||||
|
assert.equal(response.status, expected, `${method} ${url}: ${JSON.stringify(data)}`);
|
||||||
|
return data.data;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
check('资料 schema 拒绝班级和状态字段', () => {
|
||||||
|
assert.equal(updateStudentSchema.safeParse({ status: 'graduated' }).success, false);
|
||||||
|
assert.equal(updateStudentSchema.safeParse({ classId: b.id }).success, false);
|
||||||
|
});
|
||||||
|
const empty = await permissions.createGroup(admin, { name: '空组', keys: [] });
|
||||||
|
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [empty.id] });
|
||||||
|
for (const url of [
|
||||||
|
'/classes',
|
||||||
|
`/classes/${a.id}`,
|
||||||
|
`/classes/${a.id}/students`,
|
||||||
|
`/schedules?classId=${a.id}`,
|
||||||
|
`/homeworks?classId=${a.id}`,
|
||||||
|
`/notifications?classId=${a.id}`,
|
||||||
|
'/students',
|
||||||
|
'/term',
|
||||||
|
])
|
||||||
|
await request(url, teacher, 'GET', undefined, 403);
|
||||||
|
await request('/homeworks', teacher, 'POST', {}, 403);
|
||||||
|
check('取消读写键立即拒绝所有对应入口', () =>
|
||||||
|
assert.equal((PERMISSION_KEYS as readonly string[]).includes('promotion:manage'), true),
|
||||||
|
);
|
||||||
|
const delegate = await account('临时教务授权', 'TEACHER');
|
||||||
|
const governance = await permissions.createGroup(admin, {
|
||||||
|
name: '教务授权',
|
||||||
|
scope: 'ALL',
|
||||||
|
keys: ['class:read', 'class:manage', 'archive:manage', 'promotion:manage'],
|
||||||
|
});
|
||||||
|
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [governance.id] });
|
||||||
|
const delegatedClass = await request(
|
||||||
|
'/classes',
|
||||||
|
delegate,
|
||||||
|
'POST',
|
||||||
|
{ grade: '高一', enrollmentYear: 2028, classIndex: 99 },
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
await request(`/classes/${delegatedClass.id}`, delegate, 'PATCH', { termWeeks: 10 });
|
||||||
|
await request(`/classes/${delegatedClass.id}`, delegate, 'DELETE');
|
||||||
|
await request('/archives', delegate);
|
||||||
|
await request('/term/promotion', delegate);
|
||||||
|
check('教师持对应全校功能键可管理班级、读取归档与升级配置', () => {});
|
||||||
|
const allRoster = await permissions.createGroup(admin, {
|
||||||
|
name: '全校名单',
|
||||||
|
scope: 'ALL',
|
||||||
|
keys: ['roster:read', 'roster:manage'],
|
||||||
|
});
|
||||||
|
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [allRoster.id] });
|
||||||
|
const unassigned = await students.createStudent(delegate, {
|
||||||
|
studentNo: 'UNASSIGNED',
|
||||||
|
name: '未分班测试学生',
|
||||||
|
});
|
||||||
|
assert(
|
||||||
|
(await students.listStudents(delegate, { includeArchived: false })).some(
|
||||||
|
(student) => student.id === unassigned.id,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert((await students.listTransfers(delegate, { studentId: unassigned.id })).items.length > 0);
|
||||||
|
check('全校名单授权包含未分班学生及其历史', () => {});
|
||||||
|
|
||||||
|
const readAll = await permissions.createGroup(admin, {
|
||||||
|
name: '全校班级只读',
|
||||||
|
scope: 'ALL',
|
||||||
|
keys: ['class:read'],
|
||||||
|
});
|
||||||
|
const ownWrite = await permissions.createGroup(admin, { name: '关联班级维护', keys: ['class:manage'] });
|
||||||
|
await permissions.assignUserGroups(admin, delegate.sub, { groupIds: [readAll.id, ownWrite.id] });
|
||||||
|
const foreignDetail = await request(`/classes/${a.id}`, delegate);
|
||||||
|
assert.equal(foreignDetail.students.length, 0, '班级读取键不能代替名单读取键');
|
||||||
|
await request(`/classes/${a.id}`, delegate, 'PATCH', { termWeeks: 11 }, 403);
|
||||||
|
check('全校读取组不能借给写操作范围,班级详情不泄漏未授权名单', () => {});
|
||||||
|
const defaults = await prisma.permissionGroup.findUniqueOrThrow({ where: { defaultRole: 'TEACHER' } });
|
||||||
|
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] });
|
||||||
|
assert((await loadEffectivePermissions(teacher)).has('roster:manage'));
|
||||||
|
const additional = await permissions.createGroup(admin, {
|
||||||
|
name: '追加升级权限',
|
||||||
|
scope: 'ALL',
|
||||||
|
keys: ['promotion:manage'],
|
||||||
|
});
|
||||||
|
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] });
|
||||||
|
check('无组回落、显式默认组与多组并集', () => {});
|
||||||
|
assert((await loadEffectivePermissions(teacher)).has('promotion:manage'));
|
||||||
|
await denied(
|
||||||
|
permissions.assignUserGroups(admin, teacher.sub, { groupIds: ['default-permission-admin'] }),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
const assistant = await permissions.createGroup(admin, {
|
||||||
|
name: '权限协助',
|
||||||
|
keys: ['permission:manage', 'roster:read'],
|
||||||
|
});
|
||||||
|
await permissions.assignUserGroups(admin, manager.sub, { groupIds: [assistant.id] });
|
||||||
|
await denied(permissions.createGroup(manager, { name: '越权', keys: ['system:manage'] }), 403);
|
||||||
|
await denied(
|
||||||
|
permissions.createGroup(manager, { name: '越范围', scope: 'ALL', keys: ['roster:read'] }),
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
await denied(permissions.updateGroup(manager, defaults.id, { keys: [] }), 403);
|
||||||
|
await denied(permissions.assignUserGroups(manager, manager.sub, { groupIds: [additional.id] }), 403);
|
||||||
|
check('跨角色默认组、越键、越范围和默认组编辑均拒绝', () => {});
|
||||||
|
await denied(permissions.createGroup(admin, { name: '空范围', scope: 'GRADE' }), 400);
|
||||||
|
await denied(permissions.createGroup(admin, { name: '未知范围', scope: 'GRADE', scopeValue: '未知' }), 400);
|
||||||
|
check('范围统一归一化并拒绝空值和未知年级', () =>
|
||||||
|
assert.equal(parsePermissionScope('DIVISION', '高二,高一、 高一').scopeValue, '高一,高二'),
|
||||||
|
);
|
||||||
|
const memberList = await permissions.listMembers();
|
||||||
|
check('成员含真实权限与范围,组成员包含默认回落账号', () =>
|
||||||
|
assert(memberList.find((m) => m.id === teacher.sub)?.permissions.includes('promotion:manage')),
|
||||||
|
);
|
||||||
|
const audits = await permissions.listAudits();
|
||||||
|
check('审计记录操作人与变更前后快照', () =>
|
||||||
|
assert(audits.items.every((audit) => audit.actorId && audit.actorName && audit.afterJson)),
|
||||||
|
);
|
||||||
|
let queryCount = 0;
|
||||||
|
const restoreMethods: (() => void)[] = [];
|
||||||
|
for (const model of [prisma.permissionGroup, prisma.userPermissionGroup, prisma.user]) {
|
||||||
|
for (const method of ['findMany', 'findUnique', 'upsert']) {
|
||||||
|
const original = Reflect.get(model, method);
|
||||||
|
Object.defineProperty(model, method, {
|
||||||
|
configurable: true,
|
||||||
|
value: (...args: unknown[]) => {
|
||||||
|
queryCount++;
|
||||||
|
return Reflect.apply(original, model, args);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
restoreMethods.push(() =>
|
||||||
|
Object.defineProperty(model, method, { configurable: true, value: original }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await permissions.listMembers();
|
||||||
|
const small = queryCount;
|
||||||
|
queryCount = 0;
|
||||||
|
await prisma.user.createMany({
|
||||||
|
data: Array.from({ length: 30 }, (_, i) => ({
|
||||||
|
username: `批量账号${i}`,
|
||||||
|
name: `成员${i}`,
|
||||||
|
role: 'TEACHER',
|
||||||
|
passwordHash: '临时',
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
await permissions.listMembers();
|
||||||
|
check('成员增加后权限查询次数保持不变', () => assert.equal(queryCount, small));
|
||||||
|
} finally {
|
||||||
|
restoreMethods.forEach((restore) => restore());
|
||||||
|
}
|
||||||
|
const scheduleOnly = await permissions.createGroup(admin, { name: '仅课表编辑', keys: ['schedule:write'] });
|
||||||
|
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [scheduleOnly.id] });
|
||||||
|
const layoutPreview = {
|
||||||
|
classId: a.id,
|
||||||
|
payload: { TimeLayouts: [{ Name: '第一节', StartTime: '08:00', EndTime: '08:45', TimeType: 0 }] },
|
||||||
|
};
|
||||||
|
await request('/imports/time-layout/preview', teacher, 'POST', layoutPreview, 403);
|
||||||
|
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [] });
|
||||||
|
const parsedLayout = await request('/imports/time-layout/preview', teacher, 'POST', layoutPreview);
|
||||||
|
check('课表导入键取消后真实预览入口 403,有键可解析', () => assert.equal(parsedLayout.items.length, 1));
|
||||||
|
const student = await students.createStudent(admin, { studentNo: 'S1', name: '学生一', classId: a.id });
|
||||||
|
await request(`/students/${student.id}`, admin, 'PATCH', { status: 'graduated' }, 400);
|
||||||
|
await request(`/students/${student.id}`, admin, 'PATCH', { classId: b.id }, 400);
|
||||||
|
check('HTTP 旧编辑请求明确返回 400', () => {});
|
||||||
|
await prisma.$executeRawUnsafe(
|
||||||
|
`CREATE TRIGGER lifecycle_fail BEFORE INSERT ON StudentClassTransfer BEGIN SELECT RAISE(ABORT, '历史写入故障'); END`,
|
||||||
|
);
|
||||||
|
await assert.rejects(students.transferStudents(admin, { studentIds: [student.id], toClassId: b.id }));
|
||||||
|
assert.equal((await prisma.student.findUniqueOrThrow({ where: { id: student.id } })).classId, a.id);
|
||||||
|
await prisma.$executeRawUnsafe('DROP TRIGGER lifecycle_fail');
|
||||||
|
check('注入历史写入故障后学生班级事务回滚', () => {});
|
||||||
|
const moved = await students.transferStudents(teacher, {
|
||||||
|
studentIds: [student.id],
|
||||||
|
toClassId: b.id,
|
||||||
|
note: '同年级调班',
|
||||||
|
});
|
||||||
|
check('班主任调班按名单键授权、批次精确返回、两班广播', () => {
|
||||||
|
assert.equal(moved.transfers[0].fromGrade, '高一');
|
||||||
|
assert(moved.batchId);
|
||||||
|
assert(events.some((e) => e.classId === a.id) && events.some((e) => e.classId === b.id));
|
||||||
|
});
|
||||||
|
await denied(students.transferStudents(teacher, { studentIds: [student.id], toClassId: c.id }), 403);
|
||||||
|
await denied(students.transferStudents(admin, { studentIds: [student.id], toClassId: c.id }), 409);
|
||||||
|
check('源目标范围与跨年级调班限制生效', () => {});
|
||||||
|
await students.transferOutStudents(admin, { studentIds: [student.id], note: '迁居', toSchool: '另一学校' });
|
||||||
|
const history = await students.listTransfers(admin, { studentId: student.id });
|
||||||
|
check('转出状态筛选与转出历史均可见', () => {
|
||||||
|
assert(history.items.some((h) => h.kind === 'transfer-out' && h.toSchool === '另一学校'));
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(await students.listStudents(admin, { status: 'transferred', includeArchived: false })).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await request(`/students/${student.id}/transfers`, teacher);
|
||||||
|
await denied(students.transferOutStudents(admin, { studentIds: [student.id] }), 409);
|
||||||
|
await students.transferInStudent(admin, { studentId: student.id, toClassId: a.id, fromSchool: '另一学校' });
|
||||||
|
await students.transferInStudent(admin, {
|
||||||
|
studentNo: 'S2',
|
||||||
|
name: '学生二',
|
||||||
|
toClassId: a.id,
|
||||||
|
fromSchool: '其他学校',
|
||||||
|
});
|
||||||
|
check('重复转出拒绝,新建转入与历史学生重入有完整记录', () => {});
|
||||||
|
await students.deleteStudent(admin, student.id);
|
||||||
|
assert.equal(
|
||||||
|
(await students.listStudents(admin, { status: 'inactive', includeArchived: false })).length,
|
||||||
|
1,
|
||||||
|
);
|
||||||
|
await students.restoreStudent(admin, student.id, { toClassId: a.id });
|
||||||
|
await denied(students.deleteStudent(admin, student.id, { hard: true, confirm: true }), 409);
|
||||||
|
check('停用、复学和有历史学生的硬删除保护', () => {});
|
||||||
|
const device: TokenPayload = {
|
||||||
|
sub: a.id,
|
||||||
|
role: 'CLASS_DEVICE',
|
||||||
|
classId: a.id,
|
||||||
|
name: a.name,
|
||||||
|
username: a.code,
|
||||||
|
classSession: true,
|
||||||
|
};
|
||||||
|
for (const url of [
|
||||||
|
`/classes/${a.id}/students`,
|
||||||
|
`/schedules?classId=${a.id}`,
|
||||||
|
`/notifications?classId=${a.id}`,
|
||||||
|
])
|
||||||
|
await request(url, device);
|
||||||
|
await request(
|
||||||
|
'/homeworks',
|
||||||
|
device,
|
||||||
|
'POST',
|
||||||
|
{ classId: a.id, title: '临时作业', content: '完成练习', assignDate: '2026-10-23' },
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
await request(
|
||||||
|
'/notifications',
|
||||||
|
device,
|
||||||
|
'POST',
|
||||||
|
{ classId: a.id, title: '临时通知', content: '隔离验收' },
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
await request('/grades', device, 'POST', {}, 403);
|
||||||
|
await request(
|
||||||
|
'/calls',
|
||||||
|
device,
|
||||||
|
'POST',
|
||||||
|
{ classId: a.id, studentId: student.id, quickPhrase: '请到办公室' },
|
||||||
|
201,
|
||||||
|
);
|
||||||
|
check('班级端空权限保留名单课表通知和代录作业,禁止写成绩', () => {});
|
||||||
|
const g = await cls('TESTG', '高三', 2023, 1);
|
||||||
|
for (const [i, status] of ['active', 'transferred', 'inactive'].entries())
|
||||||
|
await prisma.student.create({
|
||||||
|
data: { studentNo: `G${i}`, name: `毕业班学生${i}`, classId: g.id, status },
|
||||||
|
});
|
||||||
|
await prisma.termWeek.create({
|
||||||
|
data: { classId: a.id, weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-07' },
|
||||||
|
});
|
||||||
|
const plan = defaultPromotionPlan(1);
|
||||||
|
plan.resetTermWeeks = false;
|
||||||
|
plan.termStartDate = '2026-09-08';
|
||||||
|
await configurePromotion({ month: 8, day: 1, hour: 0, plan }, admin);
|
||||||
|
const before = await prisma.promotionRun.count();
|
||||||
|
const preview = await previewPromotion(2026);
|
||||||
|
check('预览逐班逐状态、不写库、保留周次有警告', () => {
|
||||||
|
assert.equal(preview.blockers.length, 0);
|
||||||
|
assert.equal(preview.termWeekDeleteCount, 0);
|
||||||
|
assert(preview.tracks[0].classes.find((c) => c.id === g.id)?.transferred === 1);
|
||||||
|
});
|
||||||
|
assert.equal(await prisma.promotionRun.count(), before);
|
||||||
|
await denied(runPromotion(2026, { actor: admin, planHash: '0'.repeat(64), confirm: true }), 409);
|
||||||
|
const due = (await promotionStatus()).year;
|
||||||
|
await denied(runPromotion(due + 1, { actor: admin, planHash: preview.planHash, confirm: true }), 400);
|
||||||
|
await permissions.assignUserGroups(admin, teacher.sub, { groupIds: [defaults.id, additional.id] });
|
||||||
|
await denied(runPromotion(due - 1, { actor: teacher, planHash: preview.planHash, confirm: true }), 403);
|
||||||
|
check('过期哈希、未来年份与教师补跑过去学年均拒绝', () => {});
|
||||||
|
const result = await runPromotion(2026, { actor: admin, planHash: preview.planHash, confirm: true });
|
||||||
|
assert.equal(result.status, 'done');
|
||||||
|
if (!('snapshotPath' in result)) throw new Error('执行未返回快照');
|
||||||
|
const awaitedContent = await fs.readFile(result.snapshotPath);
|
||||||
|
check('快照 SHA256 与磁盘内容一致、执行记录有操作人明细', () =>
|
||||||
|
assert.equal(result.snapshotSha256, createHash('sha256').update(awaitedContent).digest('hex')),
|
||||||
|
);
|
||||||
|
const archived = await prisma.archivedYear.findUniqueOrThrow({ where: { enrollmentYear: 2023 } });
|
||||||
|
const detail = await getArchivedYearDetail(admin, archived.id);
|
||||||
|
check('毕业转出停用全部挂届别、状态保持且统计完整', () => {
|
||||||
|
assert.equal(detail.graduates.length, 1);
|
||||||
|
assert.equal(detail.transferred.length, 1);
|
||||||
|
assert.equal(detail.inactive.length, 1);
|
||||||
|
assert.equal(detail.inactiveCount, 1);
|
||||||
|
});
|
||||||
|
assert.equal(await prisma.termWeek.count(), 1);
|
||||||
|
const skeleton = await prisma.class.findFirstOrThrow({ where: { enrollmentYear: 2026, grade: '高一' } });
|
||||||
|
assert.equal(skeleton.teacherId, null);
|
||||||
|
assert.equal(await prisma.course.count({ where: { classId: skeleton.id } }), 0);
|
||||||
|
assert.equal(
|
||||||
|
(await prisma.appSetting.findUniqueOrThrow({ where: { key: 'term.startDate' } })).value,
|
||||||
|
'2026-09-08',
|
||||||
|
);
|
||||||
|
await runPromotion(2026, { actor: admin, planHash: preview.planHash, confirm: true });
|
||||||
|
assert.equal(await prisma.archivedYear.count(), 1);
|
||||||
|
check('升级日期按计划、周次保留、新班无任课、重复运行幂等', () => {});
|
||||||
|
await denied(classes.addStudent(admin, a.id, { studentNo: 'G0', name: '不能复活' }), 409);
|
||||||
|
await denied(students.transferOutStudents(admin, { studentIds: [detail.graduates[0].id] }), 409);
|
||||||
|
await students.restoreStudent(admin, detail.transferred[0].id, { toClassId: a.id });
|
||||||
|
check('归档学生不可复活或转出,归档转出学生可指定在读班撤回', () => {});
|
||||||
|
await deleteArchive(admin, archived.id);
|
||||||
|
assert.equal((await prisma.student.findUniqueOrThrow({ where: { studentNo: 'G2' } })).status, 'inactive');
|
||||||
|
check('撤销归档保留停用状态,不回滚升级周次', () => assert.equal(detail.inactive[0].status, 'inactive'));
|
||||||
|
const unknown = await cls('TESTUNKNOWN', '未知学段', 2022, 10);
|
||||||
|
assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'UNKNOWN_GRADE'));
|
||||||
|
await prisma.class.delete({ where: { id: unknown.id } });
|
||||||
|
await prisma.class.create({
|
||||||
|
data: { name: 'CODECONFLICT', code: 'N202701', grade: '高一', enrollmentYear: 2027, classIndex: 2 },
|
||||||
|
});
|
||||||
|
check('未知年级与新班码冲突在预览阶段阻断', () => {});
|
||||||
|
assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'CODE_CONFLICT'));
|
||||||
|
await prisma.class.deleteMany({ where: { name: 'CODECONFLICT' } });
|
||||||
|
const duplicate = await cls('DUPLICATE', '高二', 2025, 1);
|
||||||
|
assert((await previewPromotion(2027)).blockers.some((b) => b.code === 'DUPLICATE_INDEX'));
|
||||||
|
await prisma.class.delete({ where: { id: duplicate.id } });
|
||||||
|
check('同轨道同届重复班号被阻断', () => {});
|
||||||
|
await request(`/classes/${a.id}`, admin, 'PATCH', { grade: '任意年级' }, 400);
|
||||||
|
await denied(classes.promoteClasses(admin, { classIds: [c.id] }), 409);
|
||||||
|
await classes.promoteClasses(admin, { classIds: [a.id] });
|
||||||
|
assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: a.id } })).grade, '高三');
|
||||||
|
check('手动升级只到下一学段,毕业班与直接修改年级被拒绝', () => {});
|
||||||
|
const junior = await cls('JUNIOR', '初一', 2025, 1);
|
||||||
|
const multi = defaultPromotionPlan(0);
|
||||||
|
multi.tracks.push({
|
||||||
|
id: 'junior',
|
||||||
|
name: '初中',
|
||||||
|
entryClassCount: 0,
|
||||||
|
stages: [{ grade: '初一' }, { grade: '初二' }, { grade: '初三', terminal: true }],
|
||||||
|
});
|
||||||
|
await configurePromotion({ month: 8, day: 1, hour: 0, plan: multi }, admin);
|
||||||
|
const multiPreview = await previewPromotion(2099);
|
||||||
|
assert.equal(multiPreview.blockers.length, 0);
|
||||||
|
assert.equal(multiPreview.termWeekDeleteCount, 1);
|
||||||
|
await runPromotion(2099);
|
||||||
|
check('多轨道分别升级,确认重置后仅清除预览中的周次', () => {});
|
||||||
|
assert.equal((await prisma.class.findUniqueOrThrow({ where: { id: junior.id } })).grade, '初二');
|
||||||
|
assert.equal(await prisma.termWeek.count(), 0);
|
||||||
|
const snapshot = await exportSnapshot(prisma, 'sqlite');
|
||||||
|
const auditCount = snapshot.counts.PermissionAudit;
|
||||||
|
await restoreSnapshot(prisma, 'sqlite', snapshot);
|
||||||
|
check('全库快照恢复保留审计、生命周期和执行明细', () => assert(auditCount > 0));
|
||||||
|
assert.equal(await prisma.permissionAudit.count(), auditCount);
|
||||||
|
console.log(`=== 权限与生命周期:${count}/${count} 项通过 ===`);
|
||||||
|
} finally {
|
||||||
|
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||||
|
await prisma.$disconnect();
|
||||||
|
}
|
||||||
@@ -476,6 +476,8 @@ try {
|
|||||||
assert.equal(value.supported, false);
|
assert.equal(value.supported, false);
|
||||||
assert(value.detail.length > 10);
|
assert(value.detail.length > 10);
|
||||||
});
|
});
|
||||||
|
// 范围测试的初中班不属于默认高中计划,结束该测试夹具后再升级高中;未知年级阻断另由 lifecycle 验收。
|
||||||
|
await prisma.class.delete({ where: { id: third.id } });
|
||||||
// 此文件开头已强制临时库,实际升级不会触及演示或业务数据。
|
// 此文件开头已强制临时库,实际升级不会触及演示或业务数据。
|
||||||
await prisma.class.create({
|
await prisma.class.create({
|
||||||
data: {
|
data: {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared';
|
import type { PermissionKey as ContractPermissionKey } from '@classhelper/shared';
|
||||||
import {
|
import {
|
||||||
canAssignTeachers,
|
|
||||||
canManageClasses,
|
|
||||||
canManageGrades,
|
canManageGrades,
|
||||||
canManageRoster,
|
canManageRoster,
|
||||||
canManageSchedule,
|
canManageSchedule,
|
||||||
@@ -13,6 +11,8 @@ import {
|
|||||||
import { teacherScopeWhere } from './permission-scope.js';
|
import { teacherScopeWhere } from './permission-scope.js';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import { ApiError } from './http.js';
|
import { ApiError } from './http.js';
|
||||||
|
import { assertPermission, currentRequestPermission } from './permissions.js';
|
||||||
|
import { hasAllScope } from './permission-scope.js';
|
||||||
import type { TokenPayload } from './jwt.js';
|
import type { TokenPayload } from './jwt.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -55,6 +55,14 @@ export function isClassDevice(user: TokenPayload): boolean {
|
|||||||
return user.role === 'CLASS_DEVICE';
|
return user.role === 'CLASS_DEVICE';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 班级端代录内容归属班主任账号,班级主体不能写入 User 外键。 */
|
||||||
|
export async function resolveContentCreator(user: TokenPayload, classId: string) {
|
||||||
|
if (!isClassDevice(user)) return user.sub;
|
||||||
|
const record = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
|
||||||
|
if (!record?.teacherId) throw ApiError.conflict('请先为班级配置班主任,再在班级端发布内容');
|
||||||
|
return record.teacherId;
|
||||||
|
}
|
||||||
|
|
||||||
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */
|
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */
|
||||||
export function classDeviceClassId(user: TokenPayload): string | null {
|
export function classDeviceClassId(user: TokenPayload): string | null {
|
||||||
return isClassDevice(user) ? (user.classId ?? user.sub) : null;
|
return isClassDevice(user) ? (user.classId ?? user.sub) : null;
|
||||||
@@ -88,11 +96,15 @@ export async function getAccessibleClassIds(
|
|||||||
return classId ? [classId] : [];
|
return classId ? [classId] : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> {
|
export async function canAccessClass(
|
||||||
|
user: TokenPayload,
|
||||||
|
classId: string,
|
||||||
|
permission?: ContractPermissionKey,
|
||||||
|
): Promise<boolean> {
|
||||||
if (isAdmin(user)) return true;
|
if (isAdmin(user)) return true;
|
||||||
if (isTeacher(user)) {
|
if (isTeacher(user)) {
|
||||||
const count = await prisma.class.count({
|
const count = await prisma.class.count({
|
||||||
where: { AND: [{ id: classId }, await teacherScopeWhere(user)] },
|
where: { AND: [{ id: classId }, await teacherScopeWhere(user, permission)] },
|
||||||
});
|
});
|
||||||
return count > 0;
|
return count > 0;
|
||||||
}
|
}
|
||||||
@@ -100,10 +112,14 @@ export async function canAccessClass(user: TokenPayload, classId: string): Promi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 读权限断言 */
|
/** 读权限断言 */
|
||||||
export async function assertClassAccess(user: TokenPayload, classId: string): Promise<void> {
|
export async function assertClassAccess(
|
||||||
|
user: TokenPayload,
|
||||||
|
classId: string,
|
||||||
|
permission?: ContractPermissionKey,
|
||||||
|
): Promise<void> {
|
||||||
const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
|
const exists = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
|
||||||
if (!exists) throw ApiError.notFound('班级不存在');
|
if (!exists) throw ApiError.notFound('班级不存在');
|
||||||
if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据');
|
if (!(await canAccessClass(user, classId, permission))) throw ApiError.forbidden('无权访问该班级的数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
|
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
|
||||||
@@ -135,17 +151,38 @@ export async function resolveUserClassRole(user: TokenPayload, classId: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 班级本身(增删改)与人员分配:仅管理员 */
|
/** 班级本身(增删改)与人员分配:仅管理员 */
|
||||||
export function assertCanManageClasses(user: TokenPayload): void {
|
export async function assertCanManageClasses(user: TokenPayload, classId?: string): Promise<void> {
|
||||||
if (!canManageClasses(user.role)) {
|
await assertPermission(user, 'class:manage');
|
||||||
throw ApiError.forbidden('只有管理员可以创建、修改或删除班级');
|
if (classId) await assertClassInScope(user, classId, 'class:manage');
|
||||||
}
|
else if (!(await hasAllScope(user, 'class:manage'))) throw ApiError.forbidden('创建或删除班级需要全校范围');
|
||||||
|
if (
|
||||||
|
classId &&
|
||||||
|
(await prisma.class.findUnique({ where: { id: classId }, select: { archivedYearId: true } }))
|
||||||
|
?.archivedYearId
|
||||||
|
)
|
||||||
|
throw ApiError.conflict('该班级已归档,请先撤销归档');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertGlobalPermission(user: TokenPayload, key: ContractPermissionKey) {
|
||||||
|
await assertPermission(user, key);
|
||||||
|
if (!(await hasAllScope(user, key))) throw ApiError.forbidden('此操作影响全校,需要对应功能的全校范围');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 班主任 / 各科科任老师的分配:仅管理员 */
|
/** 班主任 / 各科科任老师的分配:仅管理员 */
|
||||||
export function assertCanAssignTeachers(user: TokenPayload): void {
|
export async function assertCanAssignTeachers(user: TokenPayload, classId?: string): Promise<void> {
|
||||||
if (!canAssignTeachers(user.role)) {
|
await assertCanManageClasses(user, classId);
|
||||||
throw ApiError.forbidden('只有管理员可以分配班主任与各科科任老师');
|
}
|
||||||
}
|
|
||||||
|
export async function assertClassInScope(user: TokenPayload, classId: string, key: ContractPermissionKey) {
|
||||||
|
await assertPermission(user, key);
|
||||||
|
if (isAdmin(user)) return;
|
||||||
|
const count = await prisma.class.count({
|
||||||
|
where: { AND: [{ id: classId }, await teacherScopeWhere(user, key)] },
|
||||||
|
});
|
||||||
|
if (!count) throw ApiError.forbidden('目标班级不在此功能的授权范围内');
|
||||||
|
}
|
||||||
|
export async function assertClassesInScope(user: TokenPayload, ids: string[], key: ContractPermissionKey) {
|
||||||
|
for (const id of new Set(ids)) await assertClassInScope(user, id, key);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -154,11 +191,14 @@ export function assertCanAssignTeachers(user: TokenPayload): void {
|
|||||||
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
|
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
|
||||||
*/
|
*/
|
||||||
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
|
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
|
||||||
|
await assertPermission(user, 'roster:manage');
|
||||||
if (!classId) {
|
if (!classId) {
|
||||||
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
|
if (!(await hasAllScope(user, 'roster:manage')))
|
||||||
|
throw ApiError.forbidden('未分班学生操作需要全校名单范围');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await assertClassAccess(user, classId);
|
await assertClassInScope(user, classId, 'roster:manage');
|
||||||
|
if (await hasAllScope(user, 'roster:manage')) return;
|
||||||
const classRole = await resolveUserClassRole(user, classId);
|
const classRole = await resolveUserClassRole(user, classId);
|
||||||
if (!canManageRoster(classRole)) {
|
if (!canManageRoster(classRole)) {
|
||||||
throw ApiError.forbidden(
|
throw ApiError.forbidden(
|
||||||
@@ -182,6 +222,17 @@ export async function assertCanManageSchedule(user: TokenPayload, classId: strin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 导入需同时覆盖导入能力和对应写能力,范围不能从另一项能力借用。 */
|
||||||
|
export async function assertCanImportSchedule(
|
||||||
|
user: TokenPayload,
|
||||||
|
classId: string,
|
||||||
|
writeKey: ContractPermissionKey = 'schedule:write',
|
||||||
|
): Promise<void> {
|
||||||
|
await assertClassInScope(user, classId, 'import:schedule');
|
||||||
|
await assertClassInScope(user, classId, writeKey);
|
||||||
|
await assertCanManageSchedule(user, classId);
|
||||||
|
}
|
||||||
|
|
||||||
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
|
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
|
||||||
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
|
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
|
||||||
await assertClassAccess(user, classId);
|
await assertClassAccess(user, classId);
|
||||||
@@ -298,10 +349,13 @@ export async function resolveClassScope(
|
|||||||
permission?: ContractPermissionKey,
|
permission?: ContractPermissionKey,
|
||||||
): Promise<ClassScope> {
|
): Promise<ClassScope> {
|
||||||
if (requestedClassId) {
|
if (requestedClassId) {
|
||||||
await assertClassAccess(user, requestedClassId);
|
await assertClassAccess(user, requestedClassId, permission);
|
||||||
return { mode: 'in', classIds: [requestedClassId] };
|
return { mode: 'in', classIds: [requestedClassId] };
|
||||||
}
|
}
|
||||||
if (isAdmin(user)) return { mode: 'all' };
|
if (isAdmin(user)) return { mode: 'all' };
|
||||||
|
const scopePermission = permission ?? currentRequestPermission(user);
|
||||||
|
if (isTeacher(user) && scopePermission && (await hasAllScope(user, scopePermission)))
|
||||||
|
return { mode: 'all' };
|
||||||
|
|
||||||
const classIds = await getAccessibleClassIds(user, permission);
|
const classIds = await getAccessibleClassIds(user, permission);
|
||||||
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
|
// 没有任何班级时不抛错,返回空集合让列表接口给出空数组,避免新教师首次登录报错
|
||||||
|
|||||||
@@ -458,7 +458,13 @@ export interface StudentTransferLike {
|
|||||||
toClassName: string;
|
toClassName: string;
|
||||||
operatorId: string | null;
|
operatorId: string | null;
|
||||||
operatorName: string;
|
operatorName: string;
|
||||||
mode: string;
|
kind: string;
|
||||||
|
batchId: string;
|
||||||
|
academicYear: number | null;
|
||||||
|
fromGrade: string;
|
||||||
|
toGrade: string;
|
||||||
|
fromSchool: string;
|
||||||
|
toSchool: string;
|
||||||
note?: string | null;
|
note?: string | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
}
|
}
|
||||||
@@ -475,7 +481,15 @@ export function toStudentTransferDto(item: StudentTransferLike): StudentTransfer
|
|||||||
toClassName: item.toClassName,
|
toClassName: item.toClassName,
|
||||||
operatorId: item.operatorId,
|
operatorId: item.operatorId,
|
||||||
operatorName: item.operatorName,
|
operatorName: item.operatorName,
|
||||||
mode: item.mode as StudentTransferDto['mode'],
|
kind: item.kind as StudentTransferDto['kind'],
|
||||||
|
batchId: item.batchId,
|
||||||
|
academicYear: item.academicYear,
|
||||||
|
fromGrade: item.fromGrade,
|
||||||
|
toGrade: item.toGrade,
|
||||||
|
fromSchool: item.fromSchool,
|
||||||
|
toSchool: item.toSchool,
|
||||||
|
mode:
|
||||||
|
item.kind === 'transfer-out' ? 'transfer-out' : item.kind === 'restore' ? 'restore-transfer' : 'single',
|
||||||
note: item.note ?? '',
|
note: item.note ?? '',
|
||||||
createdAt: toIso(item.createdAt),
|
createdAt: toIso(item.createdAt),
|
||||||
};
|
};
|
||||||
@@ -492,6 +506,7 @@ export interface ArchivedYearLike {
|
|||||||
classCount: number;
|
classCount: number;
|
||||||
studentCount: number;
|
studentCount: number;
|
||||||
transferredCount: number;
|
transferredCount: number;
|
||||||
|
inactiveCount: number;
|
||||||
homeworkCount: number;
|
homeworkCount: number;
|
||||||
notificationCount: number;
|
notificationCount: number;
|
||||||
archivedAt: Date;
|
archivedAt: Date;
|
||||||
@@ -509,6 +524,7 @@ export function toArchivedYearDto(item: ArchivedYearLike): ArchivedYearDto {
|
|||||||
classCount: item.classCount,
|
classCount: item.classCount,
|
||||||
studentCount: item.studentCount,
|
studentCount: item.studentCount,
|
||||||
transferredCount: item.transferredCount,
|
transferredCount: item.transferredCount,
|
||||||
|
inactiveCount: item.inactiveCount,
|
||||||
homeworkCount: item.homeworkCount,
|
homeworkCount: item.homeworkCount,
|
||||||
notificationCount: item.notificationCount,
|
notificationCount: item.notificationCount,
|
||||||
archivedAt: toIso(item.archivedAt),
|
archivedAt: toIso(item.archivedAt),
|
||||||
|
|||||||
@@ -1,32 +1,52 @@
|
|||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import type { TokenPayload } from './jwt.js';
|
import type { TokenPayload } from './jwt.js';
|
||||||
import type { Prisma } from '../generated/prisma/client.js';
|
import type { Prisma } from '../generated/prisma/client.js';
|
||||||
import type { PermissionKey } from '@classhelper/shared';
|
import { parsePermissionScope, type PermissionScope, type PermissionKey } from '@classhelper/shared';
|
||||||
|
import { currentRequestPermission } from './permissions.js';
|
||||||
|
|
||||||
/** 范围与功能键正交;显式分组覆盖角色默认,不在范围内一律不放行。 */
|
/** 范围与功能键正交;显式分组覆盖角色默认,不在范围内一律不放行。 */
|
||||||
export async function teacherScopeWhere(
|
export async function teacherScopeWhere(
|
||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
permission?: PermissionKey,
|
permission?: PermissionKey,
|
||||||
): Promise<Prisma.ClassWhereInput> {
|
): Promise<Prisma.ClassWhereInput> {
|
||||||
|
permission ??= currentRequestPermission(user);
|
||||||
const assignments = await prisma.userPermissionGroup.findMany({
|
const assignments = await prisma.userPermissionGroup.findMany({
|
||||||
where: { userId: user.sub },
|
where: { userId: user.sub },
|
||||||
include: { group: { include: { items: true } } },
|
include: { group: { include: { items: true } } },
|
||||||
});
|
});
|
||||||
const own = { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] };
|
const own = { OR: [{ teacherId: user.sub }, { courses: { some: { teacherId: user.sub } } }] };
|
||||||
if (!assignments.length) return own;
|
const groups = assignments.length
|
||||||
|
? assignments.map(({ group }) => group)
|
||||||
|
: await prisma.permissionGroup.findMany({ where: { defaultRole: user.role }, include: { items: true } });
|
||||||
const alternatives: Prisma.ClassWhereInput[] = [];
|
const alternatives: Prisma.ClassWhereInput[] = [];
|
||||||
for (const { group } of assignments) {
|
for (const group of groups) {
|
||||||
if (permission && !group.items.some((item) => item.key === permission)) continue;
|
if (permission && !group.items.some((item) => item.key === permission)) continue;
|
||||||
if (group.scope === 'ALL') return {};
|
try {
|
||||||
if (group.scope === 'SELF_CLASS')
|
const parsed = parsePermissionScope(group.scope as PermissionScope, group.scopeValue);
|
||||||
alternatives.push(group.scopeValue === 'HEAD' ? { teacherId: user.sub } : own);
|
if (parsed.scope === 'ALL') return {};
|
||||||
if (group.scope === 'GRADE' || group.scope === 'DIVISION') {
|
if (parsed.scope === 'SELF_CLASS')
|
||||||
const grades = group.scopeValue
|
alternatives.push(parsed.scopeValue === 'HEAD' ? { teacherId: user.sub } : own);
|
||||||
.split(',')
|
else alternatives.push({ grade: { in: parsed.grades } });
|
||||||
.map((s) => s.trim())
|
} catch {
|
||||||
.filter(Boolean);
|
/* 旧岗位组未配置或范围非法时不授予任何班级。 */
|
||||||
if (grades.length) alternatives.push({ grade: { in: grades } });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { OR: alternatives };
|
return { OR: alternatives };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function hasAllScope(user: TokenPayload, permission: PermissionKey) {
|
||||||
|
if (user.role === 'ADMIN') return true;
|
||||||
|
const links = await prisma.userPermissionGroup.findMany({
|
||||||
|
where: { userId: user.sub },
|
||||||
|
select: { groupId: true },
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
(await prisma.permissionGroup.count({
|
||||||
|
where: {
|
||||||
|
...(links.length ? { id: { in: links.map((link) => link.groupId) } } : { defaultRole: user.role }),
|
||||||
|
scope: 'ALL',
|
||||||
|
items: { some: { key: permission } },
|
||||||
|
},
|
||||||
|
})) > 0
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,6 +8,37 @@ import {
|
|||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { prisma } from './db.js';
|
import { prisma } from './db.js';
|
||||||
import type { TokenPayload } from './jwt.js';
|
import type { TokenPayload } from './jwt.js';
|
||||||
|
import { ApiError } from './http.js';
|
||||||
|
|
||||||
|
// 仅复用当前请求对象上的权限,绝不跨请求缓存。
|
||||||
|
const requestPermissions = new WeakMap<TokenPayload, ReadonlySet<PermissionKey>>();
|
||||||
|
const requestPermission = new WeakMap<TokenPayload, PermissionKey>();
|
||||||
|
export function bindRequestPermission(user: TokenPayload, key: PermissionKey) {
|
||||||
|
requestPermission.set(user, key);
|
||||||
|
}
|
||||||
|
export function currentRequestPermission(user: TokenPayload) {
|
||||||
|
return requestPermission.get(user);
|
||||||
|
}
|
||||||
|
export function bindRequestPermissions(user: TokenPayload, permissions: ReadonlySet<PermissionKey>) {
|
||||||
|
requestPermissions.set(user, permissions);
|
||||||
|
}
|
||||||
|
export async function hasEffectivePermission(user: TokenPayload, key: PermissionKey) {
|
||||||
|
return (requestPermissions.get(user) ?? (await loadEffectivePermissions(user))).has(key);
|
||||||
|
}
|
||||||
|
export async function assertPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
|
||||||
|
if (!(await hasEffectivePermission(user, key)))
|
||||||
|
throw ApiError.forbidden(`缺少「${PERMISSION_LABELS[key]}」权限`);
|
||||||
|
bindRequestPermission(user, key);
|
||||||
|
}
|
||||||
|
export async function assertAnyPermission(user: TokenPayload, keys: PermissionKey[]): Promise<void> {
|
||||||
|
const permissions = requestPermissions.get(user) ?? (await loadEffectivePermissions(user));
|
||||||
|
if (!keys.some((key) => permissions.has(key))) throw ApiError.forbidden('缺少该操作所需权限');
|
||||||
|
const matched = keys.find((key) => permissions.has(key));
|
||||||
|
if (matched) bindRequestPermission(user, matched);
|
||||||
|
}
|
||||||
|
export async function assertStaffPermission(user: TokenPayload, key: PermissionKey): Promise<void> {
|
||||||
|
if (user.role !== 'CLASS_DEVICE') await assertPermission(user, key);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
|
* 生效权限集合的**唯一加载入口**(权限组功能的全部运行时逻辑都收在这里)。
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export const SETTING_KEYS = {
|
|||||||
termStartDate: 'term.startDate',
|
termStartDate: 'term.startDate',
|
||||||
termMaxWeeks: 'term.maxWeeks',
|
termMaxWeeks: 'term.maxWeeks',
|
||||||
promotionAt: 'term.promotionAt',
|
promotionAt: 'term.promotionAt',
|
||||||
|
promotionPlan: 'term.promotionPlan',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
|
export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const SNAPSHOT_VERSION = 1;
|
|||||||
*/
|
*/
|
||||||
export const SNAPSHOT_TABLES = [
|
export const SNAPSHOT_TABLES = [
|
||||||
'User',
|
'User',
|
||||||
|
'PermissionAudit',
|
||||||
'PermissionGroup',
|
'PermissionGroup',
|
||||||
'PermissionGroupItem',
|
'PermissionGroupItem',
|
||||||
'UserPermissionGroup',
|
'UserPermissionGroup',
|
||||||
@@ -138,7 +139,7 @@ export function assertValidSnapshot(value: unknown): asserts value is DatabaseSn
|
|||||||
for (const table of SNAPSHOT_TABLES) {
|
for (const table of SNAPSHOT_TABLES) {
|
||||||
const rows = (snapshot.data as Record<string, unknown>)[table];
|
const rows = (snapshot.data as Record<string, unknown>)[table];
|
||||||
// 旧版本快照没有课件表;允许恢复,文件实体仍需单独备份。
|
// 旧版本快照没有课件表;允许恢复,文件实体仍需单独备份。
|
||||||
if (rows === undefined && table.startsWith('Courseware')) continue;
|
if (rows === undefined && (table.startsWith('Courseware') || table === 'PermissionAudit')) continue;
|
||||||
if (!Array.isArray(rows)) {
|
if (!Array.isArray(rows)) {
|
||||||
throw new Error(`快照缺少表 ${table} 的数据`);
|
throw new Error(`快照缺少表 ${table} 的数据`);
|
||||||
}
|
}
|
||||||
@@ -182,6 +183,19 @@ export async function restoreSnapshot(
|
|||||||
}
|
}
|
||||||
for (const table of SNAPSHOT_TABLES) {
|
for (const table of SNAPSHOT_TABLES) {
|
||||||
const rows = (snapshot.data[table] ?? []).map((row) => {
|
const rows = (snapshot.data[table] ?? []).map((row) => {
|
||||||
|
if (table === 'StudentClassTransfer') {
|
||||||
|
const { mode, ...rest } = row;
|
||||||
|
return {
|
||||||
|
...rest,
|
||||||
|
kind:
|
||||||
|
rest.kind ??
|
||||||
|
(mode === 'transfer-out'
|
||||||
|
? 'transfer-out'
|
||||||
|
: mode === 'restore-transfer'
|
||||||
|
? 'restore'
|
||||||
|
: 'class-change'),
|
||||||
|
};
|
||||||
|
}
|
||||||
if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
|
if (table !== 'Class' || typeof row !== 'object' || row === null) return row;
|
||||||
const copy = { ...(row as Record<string, unknown>) };
|
const copy = { ...(row as Record<string, unknown>) };
|
||||||
delete copy.notificationChannel;
|
delete copy.notificationChannel;
|
||||||
|
|||||||
@@ -0,0 +1,166 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { currentAcademicYearStart, SOCKET_EVENTS, type StudentLifecycleKind } from '@classhelper/shared';
|
||||||
|
import { prisma } from './db.js';
|
||||||
|
import { ApiError } from './http.js';
|
||||||
|
import { assertCanManageRoster } from './access.js';
|
||||||
|
import type { TokenPayload } from './jwt.js';
|
||||||
|
import { toStudentTransferDto } from './mappers.js';
|
||||||
|
import { emitToClass } from '../realtime/bus.js';
|
||||||
|
|
||||||
|
export function notifyStudentClasses(ids: (string | null)[]) {
|
||||||
|
for (const classId of new Set(ids.filter((id): id is string => Boolean(id))))
|
||||||
|
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生命周期写入只有此入口;数据、班级快照与历史在同一事务提交。 */
|
||||||
|
export async function changeStudentLifecycle(
|
||||||
|
user: TokenPayload,
|
||||||
|
input: {
|
||||||
|
studentIds: string[];
|
||||||
|
kind: StudentLifecycleKind;
|
||||||
|
toClassId?: string | null;
|
||||||
|
note?: string;
|
||||||
|
fromSchool?: string;
|
||||||
|
toSchool?: string;
|
||||||
|
allowGradeChange?: boolean;
|
||||||
|
newStudent?: {
|
||||||
|
studentNo: string;
|
||||||
|
name: string;
|
||||||
|
gender?: string;
|
||||||
|
guardianPhone?: string;
|
||||||
|
status?: 'active' | 'inactive';
|
||||||
|
};
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
const batchId = randomUUID();
|
||||||
|
const ids = [...new Set(input.studentIds)];
|
||||||
|
const existing = ids.length ? await prisma.student.findMany({ where: { id: { in: ids } } }) : [];
|
||||||
|
if (existing.length !== ids.length) throw ApiError.notFound('部分学生不存在,请刷新后重试');
|
||||||
|
for (const cls of new Set(existing.map((student) => student.classId)))
|
||||||
|
await assertCanManageRoster(user, cls ?? undefined);
|
||||||
|
if (input.toClassId) await assertCanManageRoster(user, input.toClassId);
|
||||||
|
else if (!existing.length) await assertCanManageRoster(user);
|
||||||
|
const changedClasses: (string | null)[] = [];
|
||||||
|
const result = await prisma.$transaction(async (tx) => {
|
||||||
|
const target = input.toClassId ? await tx.class.findUnique({ where: { id: input.toClassId } }) : null;
|
||||||
|
if (input.toClassId && !target) throw ApiError.badRequest('目标班级不存在');
|
||||||
|
if (target?.archivedYearId) throw ApiError.conflict('目标班级已归档,不能调入');
|
||||||
|
if (['transfer-in', 'restore'].includes(input.kind) && !target)
|
||||||
|
throw ApiError.badRequest('请指定在读班级');
|
||||||
|
const students = ids.length
|
||||||
|
? await tx.student.findMany({ where: { id: { in: ids } }, include: { class: true } })
|
||||||
|
: [];
|
||||||
|
if (input.newStudent) {
|
||||||
|
if (await tx.student.findUnique({ where: { studentNo: input.newStudent.studentNo } }))
|
||||||
|
throw ApiError.conflict('学号已被占用,请使用转入或复学入口');
|
||||||
|
const created = await tx.student.create({
|
||||||
|
data: { ...input.newStudent, classId: target?.id ?? null },
|
||||||
|
include: { class: true },
|
||||||
|
});
|
||||||
|
students.push(created);
|
||||||
|
}
|
||||||
|
if (!students.length) throw ApiError.badRequest('请至少选择一名学生');
|
||||||
|
const records = [];
|
||||||
|
for (const student of students) {
|
||||||
|
const isNew = Boolean(input.newStudent);
|
||||||
|
const prior = existing.find((item) => item.id === student.id);
|
||||||
|
if (
|
||||||
|
prior &&
|
||||||
|
(prior.classId !== student.classId ||
|
||||||
|
prior.status !== student.status ||
|
||||||
|
prior.archivedYearId !== student.archivedYearId)
|
||||||
|
)
|
||||||
|
throw ApiError.conflict('学生状态已变化,请刷新后重试');
|
||||||
|
// 撤回转出是唯一允许将归档转出记录迁入其他在读班的路径。
|
||||||
|
if (
|
||||||
|
student.status === 'graduated' ||
|
||||||
|
(!isNew &&
|
||||||
|
(student.archivedYearId || student.class?.archivedYearId) &&
|
||||||
|
!(student.status === 'transferred' && input.kind === 'restore'))
|
||||||
|
)
|
||||||
|
throw ApiError.conflict('该生已毕业或已归档,请先在归档页撤销归档');
|
||||||
|
if (!isNew) {
|
||||||
|
const allowed: Record<StudentLifecycleKind, string[]> = {
|
||||||
|
'class-change': ['active'],
|
||||||
|
unassign: ['active'],
|
||||||
|
'transfer-out': ['active', 'inactive'],
|
||||||
|
'transfer-in': ['transferred', 'inactive'],
|
||||||
|
restore: ['transferred', 'inactive'],
|
||||||
|
deactivate: ['active'],
|
||||||
|
};
|
||||||
|
if (!allowed[input.kind].includes(student.status))
|
||||||
|
throw ApiError.conflict('当前学生状态不允许此操作,请刷新名单并使用对应生命周期入口');
|
||||||
|
}
|
||||||
|
const from = isNew ? null : student.class;
|
||||||
|
if (!isNew && input.kind === 'class-change' && student.classId === target?.id) continue;
|
||||||
|
if (
|
||||||
|
input.kind === 'class-change' &&
|
||||||
|
from &&
|
||||||
|
target &&
|
||||||
|
from.grade !== target.grade &&
|
||||||
|
!(user.role === 'ADMIN' && input.allowGradeChange && input.note?.trim())
|
||||||
|
)
|
||||||
|
throw ApiError.conflict('跨年级调班需要管理员勾选允许跨年级并填写备注');
|
||||||
|
const keepClass = ['transfer-out', 'deactivate'].includes(input.kind);
|
||||||
|
const nextClassId = keepClass ? student.classId : (target?.id ?? null);
|
||||||
|
const nextStatus =
|
||||||
|
input.kind === 'transfer-out'
|
||||||
|
? 'transferred'
|
||||||
|
: input.kind === 'deactivate'
|
||||||
|
? 'inactive'
|
||||||
|
: isNew
|
||||||
|
? student.status
|
||||||
|
: 'active';
|
||||||
|
await tx.student.update({
|
||||||
|
where: { id: student.id },
|
||||||
|
data: {
|
||||||
|
classId: nextClassId,
|
||||||
|
status: nextStatus,
|
||||||
|
...(input.kind === 'transfer-out'
|
||||||
|
? { transferredAt: new Date(), transferNote: input.note ?? '' }
|
||||||
|
: {}),
|
||||||
|
...(['transfer-in', 'restore'].includes(input.kind)
|
||||||
|
? { transferredAt: null, archivedYearId: null, archivedAt: null }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
records.push(
|
||||||
|
await tx.studentClassTransfer.create({
|
||||||
|
data: {
|
||||||
|
studentId: student.id,
|
||||||
|
studentNo: student.studentNo,
|
||||||
|
studentName: student.name,
|
||||||
|
kind: input.kind,
|
||||||
|
batchId,
|
||||||
|
academicYear: currentAcademicYearStart(),
|
||||||
|
fromClassId: from?.id ?? null,
|
||||||
|
fromClassName: from?.name ?? '',
|
||||||
|
fromGrade: from?.grade ?? '',
|
||||||
|
toClassId: keepClass ? null : (target?.id ?? null),
|
||||||
|
toClassName:
|
||||||
|
input.kind === 'transfer-out'
|
||||||
|
? '转出本校'
|
||||||
|
: input.kind === 'deactivate'
|
||||||
|
? '停用'
|
||||||
|
: (target?.name ?? '未分班'),
|
||||||
|
toGrade: keepClass ? '' : (target?.grade ?? ''),
|
||||||
|
fromSchool: input.fromSchool ?? '',
|
||||||
|
toSchool: input.toSchool ?? '',
|
||||||
|
operatorId: user.sub,
|
||||||
|
operatorName: user.name,
|
||||||
|
note: input.note ?? '',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
changedClasses.push(from?.id ?? null, nextClassId);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
batchId,
|
||||||
|
count: records.length,
|
||||||
|
transfers: records.map(toStudentTransferDto),
|
||||||
|
studentId: students[0].id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
notifyStudentClasses(changedClasses);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/** 所有在读入口共用;归档/转出专页显式读取历史状态。 */
|
/** 所有在读入口共用;归档/转出专页显式读取历史状态。 */
|
||||||
export function activeStudentWhere() {
|
export function activeStudentWhere() {
|
||||||
return { status: { notIn: ['transferred', 'inactive'] } };
|
return { status: 'active', archivedYearId: null };
|
||||||
}
|
}
|
||||||
export function isActiveStudent(student: { status: string }) {
|
export function isActiveStudent(student: { status: string; archivedYearId?: string | null }) {
|
||||||
return !activeStudentWhere().status.notIn.includes(student.status);
|
return student.status === 'active' && !student.archivedYearId;
|
||||||
}
|
}
|
||||||
@@ -3,7 +3,11 @@ import type { PermissionKey, UserRole } from '@classhelper/shared';
|
|||||||
import { PERMISSION_LABELS } from '@classhelper/shared';
|
import { PERMISSION_LABELS } from '@classhelper/shared';
|
||||||
import { prisma } from '../lib/db.js';
|
import { prisma } from '../lib/db.js';
|
||||||
import { ApiError } from '../lib/http.js';
|
import { ApiError } from '../lib/http.js';
|
||||||
import { loadEffectivePermissions } from '../lib/permissions.js';
|
import {
|
||||||
|
bindRequestPermission,
|
||||||
|
bindRequestPermissions,
|
||||||
|
loadEffectivePermissions,
|
||||||
|
} from '../lib/permissions.js';
|
||||||
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
|
import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,6 +62,7 @@ export function authenticate(): RequestHandler {
|
|||||||
classId: null,
|
classId: null,
|
||||||
};
|
};
|
||||||
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
|
req.auth = { token, user: authUser, permissions: await loadEffectivePermissions(authUser) };
|
||||||
|
bindRequestPermissions(authUser, req.auth.permissions!);
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -100,6 +105,7 @@ export function requirePermission(...keys: PermissionKey[]): RequestHandler {
|
|||||||
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
|
next(ApiError.forbidden(`当前账号缺少${labels}权限,请联系管理员在「权限组」里分配`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (keys[0]) bindRequestPermission(req.auth.user, keys[0]);
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -133,6 +139,8 @@ export function requireAnyPermission(...keys: PermissionKey[]): RequestHandler {
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const matched = keys.find((key) => permissions.has(key));
|
||||||
|
if (matched) bindRequestPermission(req.auth.user, matched);
|
||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { restoreStudentSchema } from '../students/students.schemas.js';
|
||||||
import { listTransferred, restoreTransferred } from './transferred.service.js';
|
import { listTransferred, restoreTransferred } from './transferred.service.js';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendOk } from '../../lib/http.js';
|
import { sendOk } from '../../lib/http.js';
|
||||||
@@ -20,13 +21,14 @@ router.get('/transferred', requirePermission('archive:manage'), async (req, res)
|
|||||||
);
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/transferred/:id/restore',
|
'/transferred/:id/restore',
|
||||||
requirePermission('archive:manage'),
|
requirePermission('roster:manage'),
|
||||||
validate({ params: idParamSchema }),
|
validate({ params: idParamSchema, body: restoreStudentSchema }),
|
||||||
async (req, res) => sendOk(res, await restoreTransferred(getAuthUser(req), String(req.params.id))),
|
async (req, res) =>
|
||||||
|
sendOk(res, await restoreTransferred(getAuthUser(req), String(req.params.id), req.body)),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 毕业归档(**全部接口仅管理员**)。
|
* 毕业归档(**归档操作按 archive:manage 与全校范围校验**)。
|
||||||
*
|
*
|
||||||
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
|
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
|
||||||
* 通过 `GET /:id/classes/:classId/content` 只读查看。
|
* 通过 `GET /:id/classes/:classId/content` 只读查看。
|
||||||
@@ -49,7 +51,7 @@ router.get('/summary', requirePermission('archive:manage'), async (req, res) =>
|
|||||||
sendOk(
|
sendOk(
|
||||||
res,
|
res,
|
||||||
{
|
{
|
||||||
...(await archiveService.getArchiveSummary()),
|
...(await archiveService.getArchiveSummary(getAuthUser(req))),
|
||||||
suggestedEnrollmentYears: suggestedEnrollmentYears(),
|
suggestedEnrollmentYears: suggestedEnrollmentYears(),
|
||||||
},
|
},
|
||||||
'获取归档汇总成功',
|
'获取归档汇总成功',
|
||||||
|
|||||||
@@ -2,14 +2,6 @@ import { z } from 'zod';
|
|||||||
import { currentAcademicYearStart, graduationYearOf } from '@classhelper/shared';
|
import { currentAcademicYearStart, graduationYearOf } from '@classhelper/shared';
|
||||||
import { enrollmentYearSchema } from '../../lib/schemas.js';
|
import { enrollmentYearSchema } from '../../lib/schemas.js';
|
||||||
|
|
||||||
/** 执行毕业归档:按**届别**(入学年份)把整届班级与学生归档 */
|
|
||||||
export const createArchiveSchema = z.object({
|
|
||||||
enrollmentYear: enrollmentYearSchema,
|
|
||||||
/** 毕业年份;留空 = 入学年份 + 默认学制(3 年) */
|
|
||||||
graduationYear: z.coerce.number().int().min(2000).max(2200).optional(),
|
|
||||||
note: z.string().trim().max(200).optional(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/** 届别列表的查询参数 */
|
/** 届别列表的查询参数 */
|
||||||
export const listArchivesQuerySchema = z.object({
|
export const listArchivesQuerySchema = z.object({
|
||||||
/** 只看某一届 */
|
/** 只看某一届 */
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
formatYearLabel,
|
|
||||||
graduationYearOf,
|
|
||||||
type ArchivedClassContentDto,
|
type ArchivedClassContentDto,
|
||||||
type ArchivedYearDetailDto,
|
type ArchivedYearDetailDto,
|
||||||
type ArchivedYearDto,
|
type ArchivedYearDto,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { assertCanManageClasses } from '../../lib/access.js';
|
import { assertGlobalPermission } from '../../lib/access.js';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
@@ -19,10 +17,6 @@ import {
|
|||||||
} from '../../lib/mappers.js';
|
} from '../../lib/mappers.js';
|
||||||
import { emitToClass } from '../../realtime/bus.js';
|
import { emitToClass } from '../../realtime/bus.js';
|
||||||
import { logger } from '../../lib/logger.js';
|
import { logger } from '../../lib/logger.js';
|
||||||
import type { createArchiveSchema } from './archives.schemas.js';
|
|
||||||
import type { z } from 'zod';
|
|
||||||
|
|
||||||
type CreateArchiveInput = z.infer<typeof createArchiveSchema>;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 毕业归档。
|
* 毕业归档。
|
||||||
@@ -36,12 +30,12 @@ type CreateArchiveInput = z.infer<typeof createArchiveSchema>;
|
|||||||
* **未毕业而升级的班级不归档**:升级只改 `Class.grade`(高一 → 高二),
|
* **未毕业而升级的班级不归档**:升级只改 `Class.grade`(高一 → 高二),
|
||||||
* 班级记录、学生名单、学号、作业成绩全部沿用(见 `classes.service.promoteClasses`)。
|
* 班级记录、学生名单、学号、作业成绩全部沿用(见 `classes.service.promoteClasses`)。
|
||||||
*
|
*
|
||||||
* 全部接口**仅管理员**。
|
* 全部接口受 archive:manage 与全校范围保护。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** 届别列表(按入学年份倒序) */
|
/** 届别列表(按入学年份倒序) */
|
||||||
export async function listArchivedYears(user: TokenPayload): Promise<ArchivedYearDto[]> {
|
export async function listArchivedYears(user: TokenPayload): Promise<ArchivedYearDto[]> {
|
||||||
assertCanManageClasses(user);
|
await assertGlobalPermission(user, 'archive:manage');
|
||||||
const records = await prisma.archivedYear.findMany({ orderBy: { enrollmentYear: 'desc' } });
|
const records = await prisma.archivedYear.findMany({ orderBy: { enrollmentYear: 'desc' } });
|
||||||
return records.map(toArchivedYearDto);
|
return records.map(toArchivedYearDto);
|
||||||
}
|
}
|
||||||
@@ -51,7 +45,7 @@ export async function getArchivedYearDetail(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
archiveId: string,
|
archiveId: string,
|
||||||
): Promise<ArchivedYearDetailDto> {
|
): Promise<ArchivedYearDetailDto> {
|
||||||
assertCanManageClasses(user);
|
await assertGlobalPermission(user, 'archive:manage');
|
||||||
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
|
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
|
||||||
if (!record) throw ApiError.notFound('该届别档案不存在');
|
if (!record) throw ApiError.notFound('该届别档案不存在');
|
||||||
|
|
||||||
@@ -72,11 +66,18 @@ export async function getArchivedYearDetail(
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
const dtos = students.map(toStudentDto);
|
const dtos = students.map(toStudentDto);
|
||||||
|
const transferredIn = await prisma.studentClassTransfer.findMany({
|
||||||
|
where: { kind: 'transfer-in', student: { archivedYearId: archiveId } },
|
||||||
|
distinct: ['studentId'],
|
||||||
|
select: { studentId: true },
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
...toArchivedYearDto(record),
|
...toArchivedYearDto(record),
|
||||||
|
transferInCount: transferredIn.length,
|
||||||
classes: classes.map(toArchivedClassDto),
|
classes: classes.map(toArchivedClassDto),
|
||||||
graduates: dtos.filter((item) => item.status === 'graduated'),
|
graduates: dtos.filter((item) => item.status === 'graduated'),
|
||||||
transferred: dtos.filter((item) => item.status === 'transferred'),
|
transferred: dtos.filter((item) => item.status === 'transferred'),
|
||||||
|
inactive: dtos.filter((item) => item.status === 'inactive'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ export async function getArchivedClassContent(
|
|||||||
classId: string,
|
classId: string,
|
||||||
limit = 200,
|
limit = 200,
|
||||||
): Promise<ArchivedClassContentDto> {
|
): Promise<ArchivedClassContentDto> {
|
||||||
assertCanManageClasses(user);
|
await assertGlobalPermission(user, 'archive:manage');
|
||||||
const record = await prisma.class.findUnique({
|
const record = await prisma.class.findUnique({
|
||||||
where: { id: classId },
|
where: { id: classId },
|
||||||
select: { id: true, name: true, archivedYearId: true },
|
select: { id: true, name: true, archivedYearId: true },
|
||||||
@@ -132,99 +133,6 @@ export async function getArchivedClassContent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 执行归档:把某一届的全部班级与学生标记为已归档。
|
|
||||||
*
|
|
||||||
* 步骤:
|
|
||||||
* 1. 找到该届**尚未归档**的班级(`enrollmentYear` 匹配且 `archivedYearId` 为空);
|
|
||||||
* 2. 把这些班的学生标记为 `graduated`;把其中已经 `transferred` 的保持原状态;
|
|
||||||
* 3. 统计班级数 / 学生数 / 转出数 / 作业数 / 通知数写进档案快照;
|
|
||||||
* 4. 全部在**一个事务**里完成,中途失败不会留下"班归档了、学生没归档"的半截状态。
|
|
||||||
*/
|
|
||||||
export async function createArchive(
|
|
||||||
user: TokenPayload,
|
|
||||||
input: CreateArchiveInput,
|
|
||||||
): Promise<ArchivedYearDetailDto> {
|
|
||||||
assertCanManageClasses(user);
|
|
||||||
|
|
||||||
const existing = await prisma.archivedYear.findUnique({
|
|
||||||
where: { enrollmentYear: input.enrollmentYear },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (existing) throw ApiError.conflict(`${formatYearLabel(input.enrollmentYear)} 已经归档过了`);
|
|
||||||
|
|
||||||
const classes = await prisma.class.findMany({
|
|
||||||
where: { enrollmentYear: input.enrollmentYear, archivedYearId: null },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (classes.length === 0) {
|
|
||||||
throw ApiError.badRequest(
|
|
||||||
`没有找到 ${formatYearLabel(input.enrollmentYear)} 的在读班级(可能已归档,或入学年份没填对)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const classIds = classes.map((item) => item.id);
|
|
||||||
const [students, homeworkCount, notificationCount] = await Promise.all([
|
|
||||||
prisma.student.findMany({
|
|
||||||
where: { classId: { in: classIds }, archivedYearId: null },
|
|
||||||
select: { id: true, status: true },
|
|
||||||
}),
|
|
||||||
prisma.homework.count({ where: { classId: { in: classIds } } }),
|
|
||||||
prisma.notification.count({ where: { classId: { in: classIds } } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const transferredCount = students.filter((item) => item.status === 'transferred').length;
|
|
||||||
const graduateCount = students.length - transferredCount;
|
|
||||||
|
|
||||||
const created = await prisma.$transaction(async (tx) => {
|
|
||||||
const archive = await tx.archivedYear.create({
|
|
||||||
data: {
|
|
||||||
enrollmentYear: input.enrollmentYear,
|
|
||||||
graduationYear: input.graduationYear ?? graduationYearOf(input.enrollmentYear),
|
|
||||||
name: formatYearLabel(input.enrollmentYear),
|
|
||||||
note: input.note ?? '',
|
|
||||||
operatorId: user.sub,
|
|
||||||
operatorName: user.name,
|
|
||||||
classCount: classes.length,
|
|
||||||
studentCount: graduateCount,
|
|
||||||
transferredCount,
|
|
||||||
homeworkCount,
|
|
||||||
notificationCount,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await tx.class.updateMany({
|
|
||||||
where: { id: { in: classIds } },
|
|
||||||
data: { archivedYearId: archive.id, archivedAt: now },
|
|
||||||
});
|
|
||||||
|
|
||||||
// 在读的 → 已毕业;已经转出的保持 transferred(他们的归档记录也一并挂到这一届)
|
|
||||||
if (students.length > 0) {
|
|
||||||
await tx.student.updateMany({
|
|
||||||
where: { id: { in: students.filter((s) => s.status !== 'transferred').map((s) => s.id) } },
|
|
||||||
data: { status: 'graduated', archivedYearId: archive.id, archivedAt: now },
|
|
||||||
});
|
|
||||||
await tx.student.updateMany({
|
|
||||||
where: { id: { in: students.filter((s) => s.status === 'transferred').map((s) => s.id) } },
|
|
||||||
data: { archivedYearId: archive.id, archivedAt: now },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return archive;
|
|
||||||
});
|
|
||||||
|
|
||||||
for (const classId of classIds) {
|
|
||||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
|
|
||||||
}
|
|
||||||
logger.info(
|
|
||||||
`毕业归档:${formatYearLabel(input.enrollmentYear)} 班级=${classes.length} 毕业生=${graduateCount} 转出=${transferredCount} 由 ${user.name} 执行`,
|
|
||||||
);
|
|
||||||
|
|
||||||
return getArchivedYearDetail(user, created.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 撤销归档(管理员误操作的退路)。
|
* 撤销归档(管理员误操作的退路)。
|
||||||
*
|
*
|
||||||
@@ -232,7 +140,7 @@ export async function createArchive(
|
|||||||
* **不会删除任何作业或通知** —— 它们本来就没被删过。
|
* **不会删除任何作业或通知** —— 它们本来就没被删过。
|
||||||
*/
|
*/
|
||||||
export async function deleteArchive(user: TokenPayload, archiveId: string): Promise<void> {
|
export async function deleteArchive(user: TokenPayload, archiveId: string): Promise<void> {
|
||||||
assertCanManageClasses(user);
|
await assertGlobalPermission(user, 'archive:manage');
|
||||||
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
|
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
|
||||||
if (!record) throw ApiError.notFound('该届别档案不存在');
|
if (!record) throw ApiError.notFound('该届别档案不存在');
|
||||||
|
|
||||||
@@ -265,12 +173,13 @@ export async function deleteArchive(user: TokenPayload, archiveId: string): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 归档页顶部的汇总(一届一行之外的全局数字) */
|
/** 归档页顶部的汇总(一届一行之外的全局数字) */
|
||||||
export async function getArchiveSummary(): Promise<{
|
export async function getArchiveSummary(user: TokenPayload): Promise<{
|
||||||
archiveCount: number;
|
archiveCount: number;
|
||||||
classCount: number;
|
classCount: number;
|
||||||
graduateCount: number;
|
graduateCount: number;
|
||||||
transferredCount: number;
|
transferredCount: number;
|
||||||
}> {
|
}> {
|
||||||
|
await assertGlobalPermission(user, 'archive:manage');
|
||||||
const [archiveCount, classCount, graduateCount, transferredCount] = await Promise.all([
|
const [archiveCount, classCount, graduateCount, transferredCount] = await Promise.all([
|
||||||
prisma.archivedYear.count(),
|
prisma.archivedYear.count(),
|
||||||
prisma.class.count({ where: { NOT: { archivedYearId: null } } }),
|
prisma.class.count({ where: { NOT: { archivedYearId: null } } }),
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import { assertCanManageClasses } from '../../lib/access.js';
|
import { assertGlobalPermission } from '../../lib/access.js';
|
||||||
|
import { restoreStudent } from '../students/students.service.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
|
||||||
import { notifyScheduleChanged } from '../class-plans/class-plans.service.js';
|
|
||||||
export async function listTransferred(user: TokenPayload) {
|
export async function listTransferred(user: TokenPayload) {
|
||||||
assertCanManageClasses(user);
|
await assertGlobalPermission(user, 'archive:manage');
|
||||||
return prisma.student.findMany({
|
return prisma.student.findMany({
|
||||||
where: { status: 'transferred' },
|
where: { status: 'transferred' },
|
||||||
select: {
|
select: {
|
||||||
@@ -19,31 +18,11 @@ export async function listTransferred(user: TokenPayload) {
|
|||||||
orderBy: { transferredAt: 'desc' },
|
orderBy: { transferredAt: 'desc' },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
export async function restoreTransferred(user: TokenPayload, id: string) {
|
export async function restoreTransferred(
|
||||||
assertCanManageClasses(user);
|
user: TokenPayload,
|
||||||
const student = await prisma.student.findUnique({ where: { id }, include: { class: true } });
|
id: string,
|
||||||
if (student?.status !== 'transferred' || !student.classId || student.class?.archivedYearId)
|
input: { toClassId?: string; note?: string } = {},
|
||||||
throw ApiError.badRequest('只能撤回原班仍在读的转出记录');
|
) {
|
||||||
await prisma.$transaction([
|
await restoreStudent(user, id, input);
|
||||||
prisma.student.update({
|
|
||||||
where: { id },
|
|
||||||
data: { status: 'active', transferredAt: null, archivedAt: null, archivedYearId: null },
|
|
||||||
}),
|
|
||||||
prisma.studentClassTransfer.create({
|
|
||||||
data: {
|
|
||||||
studentId: id,
|
|
||||||
studentNo: student.studentNo,
|
|
||||||
studentName: student.name,
|
|
||||||
fromClassId: null,
|
|
||||||
toClassId: student.classId,
|
|
||||||
toClassName: student.class?.name ?? '',
|
|
||||||
operatorId: user.sub,
|
|
||||||
operatorName: user.name,
|
|
||||||
mode: 'restore-transfer',
|
|
||||||
note: '撤回转出,保留原转出记录',
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
await notifyScheduleChanged(student.classId);
|
|
||||||
return { id, restored: true };
|
return { id, restored: true };
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendCreated } from '../../lib/http.js';
|
import { sendCreated } from '../../lib/http.js';
|
||||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
import { authenticate, getAuthUser, requireStaffPermission } from '../../middleware/auth.js';
|
||||||
import { validate } from '../../middleware/validate.js';
|
import { validate } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
|
import { createCallSchema, type CreateCallInput } from './calls.schemas.js';
|
||||||
@@ -13,10 +13,15 @@ router.use(authenticate());
|
|||||||
* POST /api/calls - 叫人(老师点名让学生过来)
|
* POST /api/calls - 叫人(老师点名让学生过来)
|
||||||
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
|
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
|
||||||
*/
|
*/
|
||||||
router.post('/', requirePermission('call:create'), validate({ body: createCallSchema }), async (req, res) => {
|
router.post(
|
||||||
|
'/',
|
||||||
|
requireStaffPermission('call:create'),
|
||||||
|
validate({ body: createCallSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
|
sendCreated(res, await callService.createCall(user, req.body as CreateCallInput), '已通知该学生');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const callsModule = defineModule({
|
export const callsModule = defineModule({
|
||||||
name: 'calls',
|
name: 'calls',
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { assertStaffPermission } from '../../lib/permissions.js';
|
||||||
|
import { resolveContentCreator } from '../../lib/access.js';
|
||||||
import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared';
|
import { SOCKET_EVENTS, buildCallTitle, dayKeyLocal, type NotificationDto } from '@classhelper/shared';
|
||||||
import { assertCanPublishNotification } from '../../lib/access.js';
|
import { assertCanPublishNotification } from '../../lib/access.js';
|
||||||
import { isActiveStudent } from '../../lib/students.js';
|
import { isActiveStudent } from '../../lib/students.js';
|
||||||
@@ -27,6 +29,7 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
|
|||||||
* 全班都看得到在叫谁,这与需求「教室机器代全班」一致。
|
* 全班都看得到在叫谁,这与需求「教室机器代全班」一致。
|
||||||
*/
|
*/
|
||||||
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
|
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
|
||||||
|
await assertStaffPermission(user, 'call:create');
|
||||||
await assertCanPublishNotification(user, input.classId);
|
await assertCanPublishNotification(user, input.classId);
|
||||||
|
|
||||||
const student = await prisma.student.findUnique({
|
const student = await prisma.student.findUnique({
|
||||||
@@ -54,7 +57,7 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
|
|||||||
// 紧急叫人 → URGENT(客户端无视上课时段立即展开);普通叫人 → HIGH
|
// 紧急叫人 → URGENT(客户端无视上课时段立即展开);普通叫人 → HIGH
|
||||||
// (不会触发上课时段 409 拦截,但客户端按普通通知排队处理)
|
// (不会触发上课时段 409 拦截,但客户端按普通通知排队处理)
|
||||||
priority: input.urgent ? 'URGENT' : 'HIGH',
|
priority: input.urgent ? 'URGENT' : 'HIGH',
|
||||||
createdBy: user.sub,
|
createdBy: await resolveContentCreator(user, input.classId),
|
||||||
assignDate: dayKeyLocal(new Date()),
|
assignDate: dayKeyLocal(new Date()),
|
||||||
},
|
},
|
||||||
include: { creator: creatorSelect, reads: true },
|
include: { creator: creatorSelect, reads: true },
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { prisma } from '../../lib/db.js';
|
|||||||
import { assertCanManageSchedule, assertClassAccess } from '../../lib/access.js';
|
import { assertCanManageSchedule, assertClassAccess } from '../../lib/access.js';
|
||||||
import { sendOk, sendCreated, ApiError } from '../../lib/http.js';
|
import { sendOk, sendCreated, ApiError } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
import {
|
||||||
|
authenticate,
|
||||||
|
getAuthUser,
|
||||||
|
requireStaffPermission,
|
||||||
|
requirePermission,
|
||||||
|
} from '../../middleware/auth.js';
|
||||||
import { validate, validatedQuery } from '../../middleware/validate.js';
|
import { validate, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import * as schemas from './class-plans.schemas.js';
|
import * as schemas from './class-plans.schemas.js';
|
||||||
@@ -14,8 +19,15 @@ import * as service from './class-plans.service.js';
|
|||||||
|
|
||||||
const layouts = Router();
|
const layouts = Router();
|
||||||
layouts.use(authenticate());
|
layouts.use(authenticate());
|
||||||
layouts.get('/', validate({ query: schemas.scopeQuery }), async (req, res) =>
|
layouts.get(
|
||||||
sendOk(res, await service.listLayouts(getAuthUser(req), validatedQuery<{ classId?: string }>(req).classId)),
|
'/',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: schemas.scopeQuery }),
|
||||||
|
async (req, res) =>
|
||||||
|
sendOk(
|
||||||
|
res,
|
||||||
|
await service.listLayouts(getAuthUser(req), validatedQuery<{ classId?: string }>(req).classId),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
layouts.post(
|
layouts.post(
|
||||||
'/',
|
'/',
|
||||||
@@ -83,12 +95,16 @@ layouts.delete(
|
|||||||
|
|
||||||
const plans = Router();
|
const plans = Router();
|
||||||
plans.use(authenticate());
|
plans.use(authenticate());
|
||||||
plans.get('/profile', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) =>
|
plans.get(
|
||||||
|
'/profile',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: z.object({ classId: z.string().min(1) }) }),
|
||||||
|
async (req, res) =>
|
||||||
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
|
sendOk(res, await exportProfile(getAuthUser(req), validatedQuery<{ classId: string }>(req).classId)),
|
||||||
);
|
);
|
||||||
plans.post(
|
plans.post(
|
||||||
'/import/profile',
|
'/import/profile',
|
||||||
requirePermission('schedule:write'),
|
requirePermission('schedule:write', 'import:schedule'),
|
||||||
validate({
|
validate({
|
||||||
body: z.object({
|
body: z.object({
|
||||||
classId: z.string().min(1),
|
classId: z.string().min(1),
|
||||||
@@ -102,10 +118,15 @@ plans.post(
|
|||||||
await importProfile(getAuthUser(req), req.body.classId, req.body.payload, req.body.mode),
|
await importProfile(getAuthUser(req), req.body.classId, req.body.payload, req.body.mode),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
plans.get('/', validate({ query: schemas.scopeQuery }), async (req, res) => {
|
plans.get(
|
||||||
|
'/',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: schemas.scopeQuery }),
|
||||||
|
async (req, res) => {
|
||||||
const q = validatedQuery<{ classId?: string; week?: number }>(req);
|
const q = validatedQuery<{ classId?: string; week?: number }>(req);
|
||||||
sendOk(res, await service.listPlans(getAuthUser(req), q.classId, q.week));
|
sendOk(res, await service.listPlans(getAuthUser(req), q.classId, q.week));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
plans.post(
|
plans.post(
|
||||||
'/',
|
'/',
|
||||||
requirePermission('schedule:write'),
|
requirePermission('schedule:write'),
|
||||||
@@ -145,13 +166,18 @@ plans.post(
|
|||||||
async (req, res) =>
|
async (req, res) =>
|
||||||
sendCreated(res, await service.clonePlan(getAuthUser(req), String(req.params.id), req.body.name)),
|
sendCreated(res, await service.clonePlan(getAuthUser(req), String(req.params.id), req.body.name)),
|
||||||
);
|
);
|
||||||
plans.get('/:id/export', validate({ params: idParamSchema }), async (req, res) => {
|
plans.get(
|
||||||
|
'/:id/export',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ params: idParamSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false);
|
const p = await service.requirePlan(getAuthUser(req), String(req.params.id), false);
|
||||||
sendOk(res, { format: 'classhelper-class-plan', plan: p, timeLayout: p.layout });
|
sendOk(res, { format: 'classhelper-class-plan', plan: p, timeLayout: p.layout });
|
||||||
});
|
},
|
||||||
|
);
|
||||||
plans.post(
|
plans.post(
|
||||||
'/import',
|
'/import',
|
||||||
requirePermission('schedule:write'),
|
requirePermission('schedule:write', 'import:schedule'),
|
||||||
validate({ body: z.object({ plan: schemas.planSchema, entries: schemas.entriesSchema.shape.entries }) }),
|
validate({ body: z.object({ plan: schemas.planSchema, entries: schemas.entriesSchema.shape.entries }) }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const p = await service.savePlan(getAuthUser(req), req.body.plan);
|
const p = await service.savePlan(getAuthUser(req), req.body.plan);
|
||||||
@@ -172,17 +198,27 @@ plans.delete(
|
|||||||
|
|
||||||
const groups = Router();
|
const groups = Router();
|
||||||
groups.use(authenticate());
|
groups.use(authenticate());
|
||||||
groups.get('/selection', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => {
|
groups.get(
|
||||||
|
'/selection',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: z.object({ classId: z.string().min(1) }) }),
|
||||||
|
async (req, res) => {
|
||||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||||
await assertClassAccess(getAuthUser(req), classId);
|
await assertClassAccess(getAuthUser(req), classId);
|
||||||
const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } });
|
const value = await prisma.appSetting.findUnique({ where: { key: `schedule.group.${classId}` } });
|
||||||
sendOk(res, value ? JSON.parse(value.value) : {});
|
sendOk(res, value ? JSON.parse(value.value) : {});
|
||||||
});
|
},
|
||||||
groups.get('/', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => {
|
);
|
||||||
|
groups.get(
|
||||||
|
'/',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: z.object({ classId: z.string().min(1) }) }),
|
||||||
|
async (req, res) => {
|
||||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||||
await assertClassAccess(getAuthUser(req), classId);
|
await assertClassAccess(getAuthUser(req), classId);
|
||||||
sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } }));
|
sendOk(res, await prisma.classPlanGroup.findMany({ where: { classId } }));
|
||||||
});
|
},
|
||||||
|
);
|
||||||
groups.post(
|
groups.post(
|
||||||
'/',
|
'/',
|
||||||
requirePermission('schedule:write'),
|
requirePermission('schedule:write'),
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { createHash } from 'node:crypto';
|
|||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { isDayKey, dayKeyLocal } from '@classhelper/shared';
|
import { isDayKey, dayKeyLocal } from '@classhelper/shared';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import { assertCanManageSchedule } from '../../lib/access.js';
|
import { assertCanImportSchedule } from '../../lib/access.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { notifyScheduleChanged } from './class-plans.service.js';
|
import { notifyScheduleChanged } from './class-plans.service.js';
|
||||||
@@ -21,7 +21,7 @@ function time(value: unknown) {
|
|||||||
}
|
}
|
||||||
/** 直接保留上游三元组/四种时间点/显式课程位置,绝不经过单双周的有损中间格式。 */
|
/** 直接保留上游三元组/四种时间点/显式课程位置,绝不经过单双周的有损中间格式。 */
|
||||||
export async function importProfile(user: TokenPayload, classId: string, payload: unknown, mode = 'merge') {
|
export async function importProfile(user: TokenPayload, classId: string, payload: unknown, mode = 'merge') {
|
||||||
await assertCanManageSchedule(user, classId);
|
await assertCanImportSchedule(user, classId);
|
||||||
let parsed = payload;
|
let parsed = payload;
|
||||||
if (typeof payload === 'string') {
|
if (typeof payload === 'string') {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
import {
|
||||||
|
authenticate,
|
||||||
|
getAuthUser,
|
||||||
|
requireStaffPermission,
|
||||||
|
requirePermission,
|
||||||
|
} from '../../middleware/auth.js';
|
||||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
@@ -35,35 +40,45 @@ router.use(authenticate());
|
|||||||
* 只列**在读**班级(毕业归档的班级属于「归档管理」页);
|
* 只列**在读**班级(毕业归档的班级属于「归档管理」页);
|
||||||
* 教师/管理员看自己可访问的班级(班主任 ∪ 任课),ClassHelper 班级端看自己绑定的那个。
|
* 教师/管理员看自己可访问的班级(班主任 ∪ 任课),ClassHelper 班级端看自己绑定的那个。
|
||||||
*/
|
*/
|
||||||
router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/',
|
||||||
|
requireStaffPermission('class:read'),
|
||||||
|
validate({ query: listClassesQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { keyword } = validatedQuery<{ keyword?: string }>(req);
|
const { keyword } = validatedQuery<{ keyword?: string }>(req);
|
||||||
sendOk(res, await classService.listClasses(user, keyword), '获取班级列表成功');
|
sendOk(res, await classService.listClasses(user, keyword), '获取班级列表成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/classes/promote - 学年升级(批量改年级,仅管理员)
|
* POST /api/classes/promote - 学年升级(批量改年级,按功能权限及范围校验)
|
||||||
*
|
*
|
||||||
* **升级不归档、数据沿用**:只改 `grade`(高一 → 高二),班级记录、学生名单、学号、
|
* **升级不归档、数据沿用**:只改 `grade`(高一 → 高二),班级记录、学生名单、学号、
|
||||||
* 作业与成绩全部原样。归档只在毕业时由「归档管理」页显式执行。
|
* 作业与成绩全部原样。毕业归档只由年度升级执行。
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/promote',
|
'/promote',
|
||||||
requirePermission('class:manage'),
|
requirePermission('promotion:manage'),
|
||||||
validate({ body: promoteClassesSchema }),
|
validate({ body: promoteClassesSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const body = validatedBody<{ classIds: string[]; grade: string }>(req);
|
const body = validatedBody<{ classIds: string[] }>(req);
|
||||||
sendOk(res, await classService.promoteClasses(user, body), '班级已升级,数据继续沿用');
|
sendOk(res, await classService.promoteClasses(user, body), '班级已升级,数据继续沿用');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */
|
/** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */
|
||||||
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
requireStaffPermission('class:read'),
|
||||||
|
validate({ params: idParamSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
sendOk(res, await classService.getClassDetail(user, id), '获取班级详情成功');
|
sendOk(res, await classService.getClassDetail(user, id), '获取班级详情成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
|
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
|
||||||
router.post(
|
router.post(
|
||||||
@@ -76,10 +91,14 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
|
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),按功能权限及范围校验 */
|
||||||
router.patch(
|
router.patch(
|
||||||
'/:id',
|
'/:id',
|
||||||
requirePermission('class:manage'),
|
requirePermission('class:manage'),
|
||||||
|
(req, _res, next) => {
|
||||||
|
if (req.body && 'grade' in req.body) return next(ApiError.badRequest('年级请通过学年升级入口修改'));
|
||||||
|
next();
|
||||||
|
},
|
||||||
validate({ params: idParamSchema, body: updateClassSchema }),
|
validate({ params: idParamSchema, body: updateClassSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -103,7 +122,7 @@ router.delete(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/classes/:id/class-account - 设置 / 重置班级账号(班级码 + 班级密码)
|
* PATCH /api/classes/:id/class-account - 设置 / 重置班级账号(班级码 + 班级密码)
|
||||||
* 仅管理员:教室机器「ClassHelper 班级端」登录用的就是这个班级码
|
* 按功能权限及范围校验:教室机器「ClassHelper 班级端」登录用的就是这个班级码
|
||||||
*/
|
*/
|
||||||
router.patch(
|
router.patch(
|
||||||
'/:id/class-account',
|
'/:id/class-account',
|
||||||
@@ -122,7 +141,7 @@ router.patch(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* PATCH /api/classes/:id/head-teacher - 设置 / 更改班主任(每班 1 人)
|
* PATCH /api/classes/:id/head-teacher - 设置 / 更改班主任(每班 1 人)
|
||||||
* 仅管理员:班主任决定谁能管这个班的学生名单与课表,属于人员分配权限
|
* 按功能权限及范围校验:班主任决定谁能管这个班的学生名单与课表,属于人员分配权限
|
||||||
*/
|
*/
|
||||||
router.patch(
|
router.patch(
|
||||||
'/:id/head-teacher',
|
'/:id/head-teacher',
|
||||||
@@ -139,7 +158,7 @@ router.patch(
|
|||||||
/**
|
/**
|
||||||
* PUT /api/classes/:id/subject-teachers - 设置各科任课老师(班级 + 科目 + 教师)
|
* PUT /api/classes/:id/subject-teachers - 设置各科任课老师(班级 + 科目 + 教师)
|
||||||
*
|
*
|
||||||
* 仅管理员。这是「科任老师」这条关系的唯一写入口 —— 作业与成绩能不能被某位老师改动,
|
* 按功能权限及范围校验。这是「科任老师」这条关系的唯一写入口 —— 作业与成绩能不能被某位老师改动,
|
||||||
* 查的就是这里写下的 `Course.teacherId`。
|
* 查的就是这里写下的 `Course.teacherId`。
|
||||||
* 班主任同时是某科科任老师时,两条记录指向同一个账号:不重复建号、权限自动合并。
|
* 班主任同时是某科科任老师时,两条记录指向同一个账号:不重复建号、权限自动合并。
|
||||||
*/
|
*/
|
||||||
@@ -162,14 +181,20 @@ router.put(
|
|||||||
* GET /api/classes/:id/student-grade-query - 本班是否允许班级端按学号查成绩明细
|
* GET /api/classes/:id/student-grade-query - 本班是否允许班级端按学号查成绩明细
|
||||||
* PATCH 同路径 - 教师/管理员按班级管理权限设置,班级端只读
|
* PATCH 同路径 - 教师/管理员按班级管理权限设置,班级端只读
|
||||||
*/
|
*/
|
||||||
router.get('/:id/student-grade-query', validate({ params: idParamSchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/:id/student-grade-query',
|
||||||
|
requireStaffPermission('class:read'),
|
||||||
|
validate({ params: idParamSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
sendOk(res, await classService.getStudentGradeQuery(user, id), '获取成绩查询开关成功');
|
sendOk(res, await classService.getStudentGradeQuery(user, id), '获取成绩查询开关成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.patch(
|
router.patch(
|
||||||
'/:id/student-grade-query',
|
'/:id/student-grade-query',
|
||||||
|
requirePermission('grade:write'),
|
||||||
validate({ params: idParamSchema, body: updateStudentGradeQuerySchema }),
|
validate({ params: idParamSchema, body: updateStudentGradeQuerySchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -183,11 +208,16 @@ router.patch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** GET /api/classes/:id/students - 班级学生名单 */
|
/** GET /api/classes/:id/students - 班级学生名单 */
|
||||||
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/:id/students',
|
||||||
|
requireStaffPermission('roster:read'),
|
||||||
|
validate({ params: idParamSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
sendOk(res, await classService.listClassStudents(user, id), '获取学生名单成功');
|
sendOk(res, await classService.listClassStudents(user, id), '获取学生名单成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */
|
/** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export const createClassSchema = z.object({
|
|||||||
enrollmentYear: enrollmentYearSchema,
|
enrollmentYear: enrollmentYearSchema,
|
||||||
/** 班号(同一届内的序号) */
|
/** 班号(同一届内的序号) */
|
||||||
classIndex: classIndexSchema,
|
classIndex: classIndexSchema,
|
||||||
/** 仅管理员可指定班主任,教师创建时固定为自己 */
|
/** 班主任可空;具备班级管理权限的员工显式指定 */
|
||||||
teacherId: z.string().min(1).optional(),
|
teacherId: z.string().min(1).optional(),
|
||||||
/** 可选:自定义班级码(ClassHelper 班级端登录用),留空自动生成 */
|
/** 可选:自定义班级码(ClassHelper 班级端登录用),留空自动生成 */
|
||||||
code: z
|
code: z
|
||||||
@@ -37,11 +37,10 @@ export const createClassSchema = z.object({
|
|||||||
/**
|
/**
|
||||||
* 编辑班级。
|
* 编辑班级。
|
||||||
*
|
*
|
||||||
* `grade` 是**学年升级**用的:高一 → 高二 只改它,班级记录与学生数据全部沿用,**不归档**。
|
* 年级只能通过升级计划修改,本接口维护届别、班号和作息覆盖。
|
||||||
* `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。
|
* `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。
|
||||||
*/
|
*/
|
||||||
export const updateClassSchema = z.object({
|
export const updateClassSchema = z.strictObject({
|
||||||
grade: z.string().trim().min(1).max(32).optional(),
|
|
||||||
/** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */
|
/** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */
|
||||||
termStartDate: z
|
termStartDate: z
|
||||||
.union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')])
|
.union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')])
|
||||||
@@ -52,9 +51,8 @@ export const updateClassSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** 学年升级(批量改年级,不归档) */
|
/** 学年升级(批量改年级,不归档) */
|
||||||
export const promoteClassesSchema = z.object({
|
export const promoteClassesSchema = z.strictObject({
|
||||||
classIds: z.array(z.string().min(1)).min(1, '请至少选择一个班级').max(200),
|
classIds: z.array(z.string().min(1)).min(1, '请至少选择一个班级').max(200),
|
||||||
grade: z.string().trim().min(1, '请输入升级后的年级').max(32),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { env } from '../../config/env.js';
|
|||||||
import {
|
import {
|
||||||
assertCanAssignTeachers,
|
assertCanAssignTeachers,
|
||||||
assertCanManageClasses,
|
assertCanManageClasses,
|
||||||
assertCanManageRoster,
|
|
||||||
assertClassAccess,
|
assertClassAccess,
|
||||||
classScopeIdFilter,
|
classScopeIdFilter,
|
||||||
isStaff,
|
isStaff,
|
||||||
@@ -26,7 +25,11 @@ import type {
|
|||||||
UpdateClassInput,
|
UpdateClassInput,
|
||||||
UpdateStudentGradeQueryInput,
|
UpdateStudentGradeQueryInput,
|
||||||
} from './classes.schemas.js';
|
} from './classes.schemas.js';
|
||||||
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
|
import { loadPromotionPlan } from '../term/promotion.service.js';
|
||||||
|
import { assertClassInScope, canAccessClass } from '../../lib/access.js';
|
||||||
|
import { changeStudentLifecycle } from '../../lib/student-lifecycle.js';
|
||||||
|
import { assertStaffPermission, assertPermission, hasEffectivePermission } from '../../lib/permissions.js';
|
||||||
|
import { activeStudentWhere } from '../../lib/students.js';
|
||||||
import { getMaxTermWeeks } from '../../lib/term.js';
|
import { getMaxTermWeeks } from '../../lib/term.js';
|
||||||
import { logger } from '../../lib/logger.js';
|
import { logger } from '../../lib/logger.js';
|
||||||
|
|
||||||
@@ -76,7 +79,8 @@ function toClassDtoWithAccount(
|
|||||||
* 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。
|
* 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。
|
||||||
*/
|
*/
|
||||||
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
|
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
|
||||||
const scope = await resolveClassScope(user);
|
await assertStaffPermission(user, 'class:read');
|
||||||
|
const scope = await resolveClassScope(user, undefined, 'class:read');
|
||||||
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
|
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
|
||||||
const where = {
|
const where = {
|
||||||
...classScopeIdFilter(scope),
|
...classScopeIdFilter(scope),
|
||||||
@@ -98,6 +102,7 @@ export async function listClasses(user: TokenPayload, keyword?: string): Promise
|
|||||||
|
|
||||||
/** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */
|
/** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */
|
||||||
export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> {
|
export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> {
|
||||||
|
await assertStaffPermission(user, 'class:read');
|
||||||
await assertClassAccess(user, classId);
|
await assertClassAccess(user, classId);
|
||||||
|
|
||||||
const item = await prisma.class.findUnique({
|
const item = await prisma.class.findUnique({
|
||||||
@@ -117,7 +122,12 @@ export async function getClassDetail(user: TokenPayload, classId: string): Promi
|
|||||||
const base = toClassDtoWithAccount(user, item);
|
const base = toClassDtoWithAccount(user, item);
|
||||||
return {
|
return {
|
||||||
...base,
|
...base,
|
||||||
students: item.students.map(toStudentDto),
|
students:
|
||||||
|
user.role === 'CLASS_DEVICE' ||
|
||||||
|
((await hasEffectivePermission(user, 'roster:read')) &&
|
||||||
|
(await canAccessClass(user, classId, 'roster:read')))
|
||||||
|
? item.students.map(toStudentDto)
|
||||||
|
: [],
|
||||||
courses: item.courses.map(toCourseDto),
|
courses: item.courses.map(toCourseDto),
|
||||||
headTeacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
headTeacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
||||||
subjectTeachers: item.courses.map((course) => ({
|
subjectTeachers: item.courses.map((course) => ({
|
||||||
@@ -159,7 +169,7 @@ export async function listSubjectTeachers(
|
|||||||
|
|
||||||
/** 创建班级 */
|
/** 创建班级 */
|
||||||
export async function createClass(user: TokenPayload, input: CreateClassInput): Promise<ClassDto> {
|
export async function createClass(user: TokenPayload, input: CreateClassInput): Promise<ClassDto> {
|
||||||
assertCanManageClasses(user);
|
await assertCanManageClasses(user);
|
||||||
if (
|
if (
|
||||||
'termWeeks' in input &&
|
'termWeeks' in input &&
|
||||||
typeof input.termWeeks === 'number' &&
|
typeof input.termWeeks === 'number' &&
|
||||||
@@ -167,8 +177,8 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
|
|||||||
)
|
)
|
||||||
throw ApiError.badRequest('班级周数不能超过全校上限');
|
throw ApiError.badRequest('班级周数不能超过全校上限');
|
||||||
|
|
||||||
let teacherId = user.sub;
|
let teacherId: string | null = null;
|
||||||
if (input.teacherId && user.role === 'ADMIN') {
|
if (input.teacherId) {
|
||||||
teacherId = await requireTeacher(input.teacherId, '指定的班主任不存在或角色不是教师');
|
teacherId = await requireTeacher(input.teacherId, '指定的班主任不存在或角色不是教师');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,7 +210,7 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 设置 / 更改班主任(仅管理员)。
|
* 设置 / 更改班主任(按班级管理权限与范围)。
|
||||||
*
|
*
|
||||||
* 班主任决定了"谁能管这个班的学生名单与课表",所以更换时要保证目标账号确实是教师/管理员。
|
* 班主任决定了"谁能管这个班的学生名单与课表",所以更换时要保证目标账号确实是教师/管理员。
|
||||||
* 科任关系**不受影响** —— 它在 `Course` 上,是独立的一条条记录;
|
* 科任关系**不受影响** —— 它在 `Course` 上,是独立的一条条记录;
|
||||||
@@ -211,7 +221,7 @@ export async function updateHeadTeacher(
|
|||||||
classId: string,
|
classId: string,
|
||||||
teacherId: string,
|
teacherId: string,
|
||||||
): Promise<ClassDto> {
|
): Promise<ClassDto> {
|
||||||
assertCanAssignTeachers(user);
|
await assertCanAssignTeachers(user, classId);
|
||||||
const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师');
|
const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师');
|
||||||
|
|
||||||
const updated = await prisma.class.update({
|
const updated = await prisma.class.update({
|
||||||
@@ -228,7 +238,7 @@ export async function updateHeadTeacher(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 批量设置各科科任老师(仅管理员)。
|
* 批量设置各科科任老师(按班级管理权限与范围)。
|
||||||
*
|
*
|
||||||
* 入参里每条是「某班某科的任课老师」:
|
* 入参里每条是「某班某科的任课老师」:
|
||||||
* - 给了 `courseId` 就改那门课;
|
* - 给了 `courseId` 就改那门课;
|
||||||
@@ -242,7 +252,7 @@ export async function assignSubjectTeachers(
|
|||||||
classId: string,
|
classId: string,
|
||||||
input: AssignSubjectTeachersInput,
|
input: AssignSubjectTeachersInput,
|
||||||
): Promise<SubjectTeacherDto[]> {
|
): Promise<SubjectTeacherDto[]> {
|
||||||
assertCanAssignTeachers(user);
|
await assertCanAssignTeachers(user, classId);
|
||||||
const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
|
const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
|
||||||
if (!targetClass) throw ApiError.notFound('班级不存在');
|
if (!targetClass) throw ApiError.notFound('班级不存在');
|
||||||
|
|
||||||
@@ -288,13 +298,13 @@ export async function assignSubjectTeachers(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
/** 设置 / 重置班级账号(班级码 + 班级密码):按班级管理权限与范围 */
|
||||||
export async function updateClassAccount(
|
export async function updateClassAccount(
|
||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
classId: string,
|
classId: string,
|
||||||
input: { code?: string; password?: string },
|
input: { code?: string; password?: string },
|
||||||
): Promise<ClassDto> {
|
): Promise<ClassDto> {
|
||||||
assertCanManageClasses(user);
|
await assertCanManageClasses(user, classId);
|
||||||
if (
|
if (
|
||||||
'termWeeks' in input &&
|
'termWeeks' in input &&
|
||||||
typeof input.termWeeks === 'number' &&
|
typeof input.termWeeks === 'number' &&
|
||||||
@@ -315,13 +325,13 @@ export async function updateClassAccount(
|
|||||||
return toClassDtoWithAccount(user, updated);
|
return toClassDtoWithAccount(user, updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 编辑班级(年级 / 教学周数 / 届别) */
|
/** 编辑班级资料;年级只能通过升级计划修改。 */
|
||||||
export async function updateClass(
|
export async function updateClass(
|
||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
classId: string,
|
classId: string,
|
||||||
input: UpdateClassInput,
|
input: UpdateClassInput,
|
||||||
): Promise<ClassDto> {
|
): Promise<ClassDto> {
|
||||||
assertCanManageClasses(user);
|
await assertCanManageClasses(user, classId);
|
||||||
|
|
||||||
const current = await prisma.class.findUnique({
|
const current = await prisma.class.findUnique({
|
||||||
where: { id: classId },
|
where: { id: classId },
|
||||||
@@ -329,13 +339,15 @@ export async function updateClass(
|
|||||||
});
|
});
|
||||||
if (!current) throw ApiError.notFound('班级不存在');
|
if (!current) throw ApiError.notFound('班级不存在');
|
||||||
|
|
||||||
|
if ('grade' in input) throw ApiError.badRequest('修改年级请使用学年升级入口');
|
||||||
|
if (input.termWeeks !== undefined && input.termWeeks > (await getMaxTermWeeks()))
|
||||||
|
throw ApiError.badRequest('班级周数不能超过全校上限');
|
||||||
const enrollmentYear = input.enrollmentYear ?? current.enrollmentYear;
|
const enrollmentYear = input.enrollmentYear ?? current.enrollmentYear;
|
||||||
const classIndex = input.classIndex ?? current.classIndex;
|
const classIndex = input.classIndex ?? current.classIndex;
|
||||||
|
|
||||||
const updated = await prisma.class.update({
|
const updated = await prisma.class.update({
|
||||||
where: { id: classId },
|
where: { id: classId },
|
||||||
data: {
|
data: {
|
||||||
...(input.grade ? { grade: input.grade } : {}),
|
|
||||||
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
||||||
// 学期开始日期:空串/null 都表示"回落到全局配置"(如 .env 的 TERM_START_DATE)
|
// 学期开始日期:空串/null 都表示"回落到全局配置"(如 .env 的 TERM_START_DATE)
|
||||||
...(input.termStartDate !== undefined
|
...(input.termStartDate !== undefined
|
||||||
@@ -357,38 +369,60 @@ export async function updateClass(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学年升级(仅管理员):把若干班级的年级改掉,**不归档、数据沿用**。
|
* 学年升级(按升级权限与班级范围):把若干班级的年级改掉,**不归档、数据沿用**。
|
||||||
*
|
*
|
||||||
* 这是「未毕业而升级的班级不归档」这条例则的操作入口 —— 高一 → 高二 只动 `grade`,
|
* 从已保存计划推导下一学段;毕业学段必须走年度升级归档。保留班级业务数据。
|
||||||
* 班级记录、学生名单、学号、作业与成绩全部原样。归档只在毕业时由归档页显式执行。
|
|
||||||
*/
|
*/
|
||||||
export async function promoteClasses(
|
export async function promoteClasses(
|
||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
input: { classIds: string[]; grade: string },
|
input: { classIds: string[] },
|
||||||
): Promise<{ promoted: number }> {
|
): Promise<{ promoted: number }> {
|
||||||
assertCanManageClasses(user);
|
await assertPermission(user, 'promotion:manage');
|
||||||
const result = await prisma.class.updateMany({
|
const plan = await loadPromotionPlan();
|
||||||
where: { id: { in: input.classIds }, archivedYearId: null },
|
const ids = [...new Set(input.classIds)];
|
||||||
data: { grade: input.grade },
|
for (const id of ids) await assertClassInScope(user, id, 'promotion:manage');
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const classes = await tx.class.findMany({ where: { id: { in: ids }, archivedYearId: null } });
|
||||||
|
if (classes.length !== ids.length) throw ApiError.conflict('部分班级不存在或已归档');
|
||||||
|
for (const cls of classes) {
|
||||||
|
const track = plan.tracks.find((track) => track.stages.some((stage) => stage.grade === cls.grade));
|
||||||
|
const index = track?.stages.findIndex((stage) => stage.grade === cls.grade) ?? -1;
|
||||||
|
if (!track || index < 0) throw ApiError.conflict('班级年级不在升级计划内');
|
||||||
|
if (track.stages[index].terminal) throw ApiError.conflict('毕业学段请通过年度升级进行归档');
|
||||||
|
const nextGrade = track.stages[index + 1].grade;
|
||||||
|
await tx.class.update({ where: { id: cls.id }, data: { grade: nextGrade, termStartDate: null } });
|
||||||
|
await tx.permissionAudit.create({
|
||||||
|
data: {
|
||||||
|
actorId: user.sub,
|
||||||
|
actorName: user.name,
|
||||||
|
action: 'promote-class',
|
||||||
|
targetType: 'class',
|
||||||
|
targetId: cls.id,
|
||||||
|
beforeJson: JSON.stringify({ grade: cls.grade }),
|
||||||
|
afterJson: JSON.stringify({ grade: nextGrade }),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
for (const classId of input.classIds) {
|
|
||||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
|
||||||
}
|
}
|
||||||
return { promoted: result.count };
|
});
|
||||||
|
for (const classId of ids) emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||||
|
return { promoted: ids.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除班级(级联删除课表/作业/通知/成绩;学生记录保留但解除班级归属) */
|
/** 删除班级(级联删除课表/作业/通知/成绩;学生记录保留但解除班级归属) */
|
||||||
export async function deleteClass(user: TokenPayload, classId: string): Promise<void> {
|
export async function deleteClass(user: TokenPayload, classId: string): Promise<void> {
|
||||||
assertCanManageClasses(user);
|
await assertCanManageClasses(user);
|
||||||
|
if (await prisma.student.count({ where: { classId } }))
|
||||||
|
throw ApiError.conflict('班级仍有学生,请先通过名单生命周期入口移出或归档,不能级联清除归属');
|
||||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
|
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
|
||||||
await prisma.class.delete({ where: { id: classId } });
|
await prisma.class.delete({ where: { id: classId } });
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 班级学生名单 */
|
/** 班级学生名单 */
|
||||||
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
|
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
|
||||||
|
await assertStaffPermission(user, 'roster:read');
|
||||||
await assertClassAccess(user, classId);
|
await assertClassAccess(user, classId);
|
||||||
const students = await prisma.student.findMany({
|
const students = await prisma.student.findMany({
|
||||||
where: { classId, archivedYearId: null, ...activeStudentWhere() },
|
where: { classId, ...activeStudentWhere() },
|
||||||
include: { class: { select: { name: true, grade: true } } },
|
include: { class: { select: { name: true, grade: true } } },
|
||||||
orderBy: { studentNo: 'asc' },
|
orderBy: { studentNo: 'asc' },
|
||||||
});
|
});
|
||||||
@@ -406,85 +440,28 @@ export async function addStudent(
|
|||||||
classId: string,
|
classId: string,
|
||||||
input: AddStudentInput,
|
input: AddStudentInput,
|
||||||
): Promise<StudentDto> {
|
): Promise<StudentDto> {
|
||||||
await assertCanManageRoster(user, classId);
|
|
||||||
await assertClassAccess(user, classId);
|
|
||||||
|
|
||||||
const existing = await prisma.student.findUnique({ where: { studentNo: input.studentNo } });
|
const existing = await prisma.student.findUnique({ where: { studentNo: input.studentNo } });
|
||||||
let studentId: string;
|
if (existing && (existing.archivedYearId || existing.status !== 'active'))
|
||||||
|
throw ApiError.conflict('学号已被历史学生占用,请使用转入、复学或撤销归档入口');
|
||||||
if (existing && !isActiveStudent(existing))
|
const result = await changeStudentLifecycle(user, {
|
||||||
throw ApiError.conflict('学号已被历史学生占用;请使用新学号或在归档页撤回转出');
|
studentIds: existing ? [existing.id] : [],
|
||||||
if (existing) {
|
kind: 'class-change',
|
||||||
studentId = existing.id;
|
|
||||||
if (existing.classId !== classId) {
|
|
||||||
await prisma.studentClassTransfer.create({
|
|
||||||
data: {
|
|
||||||
studentId: existing.id,
|
|
||||||
studentNo: existing.studentNo,
|
|
||||||
studentName: input.name || existing.name,
|
|
||||||
fromClassId: existing.classId,
|
|
||||||
fromClassName: await classLabel(existing.classId),
|
|
||||||
toClassId: classId,
|
toClassId: classId,
|
||||||
toClassName: await classLabel(classId),
|
note: '班级名单添加',
|
||||||
operatorId: user.sub,
|
...(!existing ? { newStudent: input } : {}),
|
||||||
operatorName: user.name,
|
|
||||||
mode: 'single',
|
|
||||||
note: '在班级名单里直接加入',
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
return toStudentDto(
|
||||||
await prisma.student.update({
|
await prisma.student.findUniqueOrThrow({
|
||||||
where: { id: existing.id },
|
where: { id: result.studentId },
|
||||||
data: { classId, name: input.name, status: 'active' },
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
const created = await prisma.student.create({
|
|
||||||
data: {
|
|
||||||
studentNo: input.studentNo,
|
|
||||||
name: input.name,
|
|
||||||
classId,
|
|
||||||
gender: input.gender ?? '',
|
|
||||||
guardianPhone: input.guardianPhone ?? '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
studentId = created.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const student = await prisma.student.findUniqueOrThrow({
|
|
||||||
where: { id: studentId },
|
|
||||||
include: { class: { select: { name: true, grade: true } } },
|
include: { class: { select: { name: true, grade: true } } },
|
||||||
});
|
}),
|
||||||
|
);
|
||||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
|
||||||
return toStudentDto(student);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 将学生移出班级(保留名单记录,回到「未分班」) */
|
|
||||||
export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise<void> {
|
export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise<void> {
|
||||||
await assertCanManageRoster(user, classId);
|
|
||||||
|
|
||||||
const student = await prisma.student.findUnique({ where: { id: studentId } });
|
const student = await prisma.student.findUnique({ where: { id: studentId } });
|
||||||
if (!student) throw ApiError.notFound('学生不存在');
|
if (!student || student.classId !== classId) throw ApiError.badRequest('学生不属于此班级');
|
||||||
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于此班级');
|
await changeStudentLifecycle(user, { studentIds: [studentId], kind: 'unassign', note: '移出班级' });
|
||||||
|
|
||||||
await prisma.student.update({ where: { id: studentId }, data: { classId: null } });
|
|
||||||
await prisma.studentClassTransfer.create({
|
|
||||||
data: {
|
|
||||||
studentId: student.id,
|
|
||||||
studentNo: student.studentNo,
|
|
||||||
studentName: student.name,
|
|
||||||
fromClassId: classId,
|
|
||||||
fromClassName: await classLabel(classId),
|
|
||||||
toClassId: null,
|
|
||||||
toClassName: '未分班',
|
|
||||||
operatorId: user.sub,
|
|
||||||
operatorName: user.name,
|
|
||||||
mode: 'single',
|
|
||||||
note: '从班级名单里移出',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -543,10 +520,3 @@ async function requireTeacher(teacherId: string, message: string): Promise<strin
|
|||||||
}
|
}
|
||||||
return teacher.id;
|
return teacher.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 班级名(写调班历史快照用);班级不存在时返回空串 */
|
|
||||||
async function classLabel(classId: string | null): Promise<string> {
|
|
||||||
if (!classId) return '';
|
|
||||||
const record = await prisma.class.findUnique({ where: { id: classId }, select: { name: true } });
|
|
||||||
return record?.name ?? '';
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
import {
|
||||||
|
authenticate,
|
||||||
|
getAuthUser,
|
||||||
|
requireStaffPermission,
|
||||||
|
requirePermission,
|
||||||
|
} from '../../middleware/auth.js';
|
||||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
@@ -17,11 +22,16 @@ const router = Router();
|
|||||||
router.use(authenticate());
|
router.use(authenticate());
|
||||||
|
|
||||||
/** GET /api/courses?classId= - 课程列表 */
|
/** GET /api/courses?classId= - 课程列表 */
|
||||||
router.get('/', validate({ query: listCoursesQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: listCoursesQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||||
sendOk(res, await courseService.listCourses(user, classId), '获取课程列表成功');
|
sendOk(res, await courseService.listCourses(user, classId), '获取课程列表成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** POST /api/courses - 新增课程 */
|
/** POST /api/courses - 新增课程 */
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
import {
|
||||||
|
authenticate,
|
||||||
|
getAuthUser,
|
||||||
|
requireStaffPermission,
|
||||||
|
requirePermission,
|
||||||
|
} from '../../middleware/auth.js';
|
||||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +30,11 @@ const router = Router();
|
|||||||
router.use(authenticate());
|
router.use(authenticate());
|
||||||
|
|
||||||
/** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */
|
/** GET /api/homeworks?classId=&courseId=&pendingOnly=&keyword= - 作业列表 */
|
||||||
router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/',
|
||||||
|
requireStaffPermission('homework:read'),
|
||||||
|
validate({ query: listHomeworksQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const query = validatedQuery<{
|
const query = validatedQuery<{
|
||||||
classId?: string;
|
classId?: string;
|
||||||
@@ -34,7 +43,8 @@ router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res)
|
|||||||
keyword?: string;
|
keyword?: string;
|
||||||
}>(req);
|
}>(req);
|
||||||
sendOk(res, await homeworkService.listHomeworks(user, query), '获取作业列表成功');
|
sendOk(res, await homeworkService.listHomeworks(user, query), '获取作业列表成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** GET /api/homeworks/:id - 作业详情 */
|
/** GET /api/homeworks/:id - 作业详情 */
|
||||||
/**
|
/**
|
||||||
@@ -42,17 +52,27 @@ router.get('/', validate({ query: listHomeworksQuerySchema }), async (req, res)
|
|||||||
*
|
*
|
||||||
* 必须注册在 /:id 之前,否则 "days" 会被当成作业 id 命中 /:id 路由。
|
* 必须注册在 /:id 之前,否则 "days" 会被当成作业 id 命中 /:id 路由。
|
||||||
*/
|
*/
|
||||||
router.get('/days', validate({ query: listHomeworkDaysQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/days',
|
||||||
|
requireStaffPermission('homework:read'),
|
||||||
|
validate({ query: listHomeworkDaysQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const query = validatedQuery<ListHomeworkDaysInput>(req);
|
const query = validatedQuery<ListHomeworkDaysInput>(req);
|
||||||
sendOk(res, await homeworkService.listHomeworkDays(user, query), '获取作业日历成功');
|
sendOk(res, await homeworkService.listHomeworkDays(user, query), '获取作业日历成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/:id',
|
||||||
|
requireStaffPermission('homework:read'),
|
||||||
|
validate({ params: idParamSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功');
|
sendOk(res, await homeworkService.getHomework(user, id), '获取作业详情成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/homeworks - 发布作业(发布后广播 homework:new)
|
* POST /api/homeworks - 发布作业(发布后广播 homework:new)
|
||||||
@@ -62,6 +82,7 @@ router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
|
requireStaffPermission('homework:write'),
|
||||||
validate({ body: createHomeworkSchema }),
|
validate({ body: createHomeworkSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -87,10 +108,7 @@ router.post(
|
|||||||
validate({ body: bulkCreateHomeworkSchema }),
|
validate({ body: bulkCreateHomeworkSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const result = await homeworkService.bulkCreateHomeworks(
|
const result = await homeworkService.bulkCreateHomeworks(user, req.body as BulkCreateHomeworkInput);
|
||||||
user,
|
|
||||||
req.body as BulkCreateHomeworkInput,
|
|
||||||
);
|
|
||||||
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
|
sendCreated(res, result, `批量发布完成:成功 ${result.succeeded} 个班,失败 ${result.failed} 个班`);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -127,6 +145,7 @@ router.delete(
|
|||||||
/** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */
|
/** PATCH /api/homeworks/:id/status - 标记完成 / 取消完成 */
|
||||||
router.patch(
|
router.patch(
|
||||||
'/:id/status',
|
'/:id/status',
|
||||||
|
requireStaffPermission('homework:write'),
|
||||||
validate({ params: idParamSchema, body: updateHomeworkStatusSchema }),
|
validate({ params: idParamSchema, body: updateHomeworkStatusSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -141,16 +160,22 @@ router.patch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */
|
/** GET /api/homeworks/:id/submissions - 提交名单(未交名单功能) */
|
||||||
router.get('/:id/submissions', validate({ params: idParamSchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/:id/submissions',
|
||||||
|
requireStaffPermission('homework:read'),
|
||||||
|
validate({ params: idParamSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
const result = await homeworkService.listHomeworkSubmissions(user, id);
|
const result = await homeworkService.listHomeworkSubmissions(user, id);
|
||||||
sendOk(res, result, `未交 ${result.notSubmitted.length} / ${result.total} 人`);
|
sendOk(res, result, `未交 ${result.notSubmitted.length} / ${result.total} 人`);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */
|
/** PATCH /api/homeworks/:id/submissions - 勾选未交名单(其余学生一律视为已交) */
|
||||||
router.patch(
|
router.patch(
|
||||||
'/:id/submissions',
|
'/:id/submissions',
|
||||||
|
requireStaffPermission('homework:write'),
|
||||||
validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }),
|
validate({ params: idParamSchema, body: updateHomeworkSubmissionsSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { assertStaffPermission } from '../../lib/permissions.js';
|
||||||
import {
|
import {
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
dayKeyLocal,
|
dayKeyLocal,
|
||||||
@@ -55,6 +56,7 @@ export async function listHomeworks(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
options: ListHomeworkOptions,
|
options: ListHomeworkOptions,
|
||||||
): Promise<HomeworkDto[]> {
|
): Promise<HomeworkDto[]> {
|
||||||
|
await assertStaffPermission(user, 'homework:read');
|
||||||
const scope = await resolveClassScope(user, options.classId);
|
const scope = await resolveClassScope(user, options.classId);
|
||||||
const device = isClassDevice(user);
|
const device = isClassDevice(user);
|
||||||
// ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态"
|
// ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态"
|
||||||
@@ -95,6 +97,7 @@ export async function listHomeworkDays(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
options: { classId?: string; from?: string; to?: string; days?: number },
|
options: { classId?: string; from?: string; to?: string; days?: number },
|
||||||
): Promise<HomeworkDaysDto> {
|
): Promise<HomeworkDaysDto> {
|
||||||
|
await assertStaffPermission(user, 'homework:read');
|
||||||
const scope = await resolveClassScope(user, options.classId);
|
const scope = await resolveClassScope(user, options.classId);
|
||||||
const today = dayKeyLocal(new Date());
|
const today = dayKeyLocal(new Date());
|
||||||
const to = options.to ?? today;
|
const to = options.to ?? today;
|
||||||
@@ -119,6 +122,7 @@ export async function listHomeworkDays(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
|
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
|
||||||
|
await assertStaffPermission(user, 'homework:read');
|
||||||
const device = isClassDevice(user);
|
const device = isClassDevice(user);
|
||||||
const personalIds = device ? await resolvePersonalIds(user) : [];
|
const personalIds = device ? await resolvePersonalIds(user) : [];
|
||||||
const homework = await prisma.homework.findUnique({
|
const homework = await prisma.homework.findUnique({
|
||||||
@@ -152,6 +156,7 @@ export async function getHomework(user: TokenPayload, homeworkId: string): Promi
|
|||||||
* 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
|
* 见 AGENTS.md 的现行工程约定),班级端录入的作业归属该班班主任。
|
||||||
*/
|
*/
|
||||||
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
|
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
|
||||||
|
await assertStaffPermission(user, 'homework:write');
|
||||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
|
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
|
||||||
allowClassDevice: true,
|
allowClassDevice: true,
|
||||||
});
|
});
|
||||||
@@ -207,6 +212,7 @@ export async function bulkCreateHomeworks(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
input: BulkCreateHomeworkRequest,
|
input: BulkCreateHomeworkRequest,
|
||||||
): Promise<BulkPublishResult> {
|
): Promise<BulkPublishResult> {
|
||||||
|
await assertStaffPermission(user, 'homework:write');
|
||||||
const classIds = normalizeBulkClassIds(input.classIds);
|
const classIds = normalizeBulkClassIds(input.classIds);
|
||||||
const results: BulkPublishClassResult[] = [];
|
const results: BulkPublishClassResult[] = [];
|
||||||
|
|
||||||
@@ -255,6 +261,7 @@ export async function updateHomework(
|
|||||||
homeworkId: string,
|
homeworkId: string,
|
||||||
input: UpdateHomeworkInput,
|
input: UpdateHomeworkInput,
|
||||||
): Promise<HomeworkDto> {
|
): Promise<HomeworkDto> {
|
||||||
|
await assertStaffPermission(user, 'homework:write');
|
||||||
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
|
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
|
||||||
if (!current) throw ApiError.notFound('作业不存在');
|
if (!current) throw ApiError.notFound('作业不存在');
|
||||||
|
|
||||||
@@ -287,6 +294,7 @@ export async function updateHomework(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise<void> {
|
export async function deleteHomework(user: TokenPayload, homeworkId: string): Promise<void> {
|
||||||
|
await assertStaffPermission(user, 'homework:write');
|
||||||
const current = await prisma.homework.findUnique({
|
const current = await prisma.homework.findUnique({
|
||||||
where: { id: homeworkId },
|
where: { id: homeworkId },
|
||||||
include: { course: courseSelect, creator: creatorSelect },
|
include: { course: courseSelect, creator: creatorSelect },
|
||||||
@@ -315,6 +323,7 @@ export async function updateHomeworkStatus(
|
|||||||
homeworkId: string,
|
homeworkId: string,
|
||||||
input: UpdateHomeworkStatusInput,
|
input: UpdateHomeworkStatusInput,
|
||||||
): Promise<HomeworkStatusDto> {
|
): Promise<HomeworkStatusDto> {
|
||||||
|
await assertStaffPermission(user, 'homework:write');
|
||||||
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
|
const homework = await prisma.homework.findUnique({ where: { id: homeworkId } });
|
||||||
if (!homework) throw ApiError.notFound('作业不存在');
|
if (!homework) throw ApiError.notFound('作业不存在');
|
||||||
|
|
||||||
@@ -373,7 +382,7 @@ export async function updateHomeworkStatus(
|
|||||||
async function buildSubmissions(classId: string, homeworkId: string): Promise<HomeworkSubmissionsDto> {
|
async function buildSubmissions(classId: string, homeworkId: string): Promise<HomeworkSubmissionsDto> {
|
||||||
const [students, statuses] = await Promise.all([
|
const [students, statuses] = await Promise.all([
|
||||||
prisma.student.findMany({
|
prisma.student.findMany({
|
||||||
where: { classId, archivedYearId: null, ...activeStudentWhere() },
|
where: { classId, ...activeStudentWhere() },
|
||||||
select: { id: true, name: true, studentNo: true },
|
select: { id: true, name: true, studentNo: true },
|
||||||
orderBy: { studentNo: 'asc' },
|
orderBy: { studentNo: 'asc' },
|
||||||
}),
|
}),
|
||||||
@@ -408,6 +417,7 @@ export async function listHomeworkSubmissions(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
homeworkId: string,
|
homeworkId: string,
|
||||||
): Promise<HomeworkSubmissionsDto> {
|
): Promise<HomeworkSubmissionsDto> {
|
||||||
|
await assertStaffPermission(user, 'homework:read');
|
||||||
const homework = await prisma.homework.findUnique({
|
const homework = await prisma.homework.findUnique({
|
||||||
where: { id: homeworkId },
|
where: { id: homeworkId },
|
||||||
select: { id: true, classId: true },
|
select: { id: true, classId: true },
|
||||||
@@ -427,6 +437,7 @@ export async function updateHomeworkSubmissions(
|
|||||||
homeworkId: string,
|
homeworkId: string,
|
||||||
input: UpdateHomeworkSubmissionsInput,
|
input: UpdateHomeworkSubmissionsInput,
|
||||||
): Promise<HomeworkSubmissionsDto> {
|
): Promise<HomeworkSubmissionsDto> {
|
||||||
|
await assertStaffPermission(user, 'homework:write');
|
||||||
const homework = await prisma.homework.findUnique({
|
const homework = await prisma.homework.findUnique({
|
||||||
where: { id: homeworkId },
|
where: { id: homeworkId },
|
||||||
select: { id: true, classId: true },
|
select: { id: true, classId: true },
|
||||||
@@ -436,7 +447,7 @@ export async function updateHomeworkSubmissions(
|
|||||||
assertCanManageSubmissions(user);
|
assertCanManageSubmissions(user);
|
||||||
|
|
||||||
const students = await prisma.student.findMany({
|
const students = await prisma.student.findMany({
|
||||||
where: { classId: homework.classId, archivedYearId: null, ...activeStudentWhere() },
|
where: { classId: homework.classId, ...activeStudentWhere() },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生');
|
if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { importProfile } from '../class-plans/profile-import.service.js';
|
import { importProfile } from '../class-plans/profile-import.service.js';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { assertCanManageSchedule } from '../../lib/access.js';
|
import { assertCanImportSchedule } from '../../lib/access.js';
|
||||||
import { weekParityFromDiv } from '@classhelper/shared';
|
import { weekParityFromDiv } from '@classhelper/shared';
|
||||||
import type { ClassPlanImportInput } from './imports.schemas.js';
|
import type { ClassPlanImportInput } from './imports.schemas.js';
|
||||||
|
|
||||||
@@ -243,7 +243,7 @@ export async function previewClassPlan(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
input: ClassPlanImportInput,
|
input: ClassPlanImportInput,
|
||||||
): Promise<ClassPlanParseResult & { entries: (ParsedClassPlanEntry & { weekdayLabel: string })[] }> {
|
): Promise<ClassPlanParseResult & { entries: (ParsedClassPlanEntry & { weekdayLabel: string })[] }> {
|
||||||
await assertCanManageSchedule(user, input.classId);
|
await assertCanImportSchedule(user, input.classId);
|
||||||
const parsed = parseClassIslandClassPlan(input.payload);
|
const parsed = parseClassIslandClassPlan(input.payload);
|
||||||
const courses = await prisma.course.findMany({
|
const courses = await prisma.course.findMany({
|
||||||
where: { classId: input.classId },
|
where: { classId: input.classId },
|
||||||
|
|||||||
@@ -1,7 +1,15 @@
|
|||||||
|
import { assertPermission } from '../../lib/permissions.js';
|
||||||
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { ApiError, sendOk } from '../../lib/http.js';
|
import { ApiError, sendOk } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requirePermission, requireAnyPermission } from '../../middleware/auth.js';
|
import {
|
||||||
|
authenticate,
|
||||||
|
getAuthUser,
|
||||||
|
requireStaffPermission,
|
||||||
|
requirePermission,
|
||||||
|
requireAnyPermission,
|
||||||
|
} from '../../middleware/auth.js';
|
||||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
@@ -28,13 +36,21 @@ import {
|
|||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate());
|
router.use(authenticate());
|
||||||
|
|
||||||
/** 名单类导入(学生/教师账号)只有管理员能做;成绩类按班级权限判定 */
|
/** 导入类型分别校验对应导入键,名单还校验管理键。 */
|
||||||
function assertImportKindAllowed(kind: (typeof IMPORT_KINDS)[number], role: string): void {
|
async function assertImportKindAllowed(
|
||||||
if ((kind === 'students' || kind === 'teachers') && role !== 'ADMIN') {
|
kind: (typeof IMPORT_KINDS)[number],
|
||||||
throw ApiError.forbidden(
|
user: TokenPayload,
|
||||||
kind === 'teachers' ? '只有管理员可以录入或导入教师账号' : '只有管理员可以导入学生名单',
|
): Promise<void> {
|
||||||
);
|
const key = {
|
||||||
}
|
students: 'import:students',
|
||||||
|
teachers: 'import:teachers',
|
||||||
|
grades: 'grade:import',
|
||||||
|
classTeachers: 'import:class-teachers',
|
||||||
|
scheduleTimes: 'import:schedule',
|
||||||
|
} as const;
|
||||||
|
await assertPermission(user, key[kind]);
|
||||||
|
if (kind === 'students') await assertPermission(user, 'roster:manage');
|
||||||
|
if (kind === 'teachers') await assertPermission(user, 'teacher:manage');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 查询参数里的 kind 收敛为合法值(默认 grades,避免非法值被当成成绩悄悄写入) */
|
/** 查询参数里的 kind 收敛为合法值(默认 grades,避免非法值被当成成绩悄悄写入) */
|
||||||
@@ -46,20 +62,14 @@ function resolveImportKind(value: unknown): (typeof IMPORT_KINDS)[number] {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
|
* GET /api/imports/template?kind=grades|students|teachers&format=csv|xlsx - 下载导入模板
|
||||||
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:仅管理员
|
* 成绩模板:管理员或班主任(空白模板,不含任何业务数据);学生/教师名单模板:对应导入与管理键
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/template',
|
'/template',
|
||||||
requireAnyPermission(
|
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
|
||||||
'import:students',
|
async (req, res) => {
|
||||||
'grade:import',
|
|
||||||
'import:teachers',
|
|
||||||
'import:schedule',
|
|
||||||
'import:class-teachers',
|
|
||||||
),
|
|
||||||
(req, res) => {
|
|
||||||
const kind = resolveImportKind(req.query.kind);
|
const kind = resolveImportKind(req.query.kind);
|
||||||
assertImportKindAllowed(kind, getAuthUser(req).role);
|
await assertImportKindAllowed(kind, getAuthUser(req));
|
||||||
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);
|
||||||
@@ -78,21 +88,15 @@ router.get(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
|
* POST /api/imports/table/preview - 上传表格并预览(解析 + 必填列校验 + 建议字段映射)
|
||||||
* 成绩:管理员或班主任;学生/教师名单:仅管理员(与写入权限保持一致)
|
* 成绩:管理员或班主任;学生/教师名单按对应导入与管理键校验
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/table/preview',
|
'/table/preview',
|
||||||
requireAnyPermission(
|
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
|
||||||
'import:students',
|
|
||||||
'grade:import',
|
|
||||||
'import:teachers',
|
|
||||||
'import:schedule',
|
|
||||||
'import:class-teachers',
|
|
||||||
),
|
|
||||||
validate({ body: tableFileSchema }),
|
validate({ body: tableFileSchema }),
|
||||||
(req, res) => {
|
async (req, res) => {
|
||||||
const input = validatedBody<TableFileInput>(req);
|
const input = validatedBody<TableFileInput>(req);
|
||||||
assertImportKindAllowed(input.kind, getAuthUser(req).role);
|
await assertImportKindAllowed(input.kind, getAuthUser(req));
|
||||||
sendOk(res, previewTable(input), '表格解析成功');
|
sendOk(res, previewTable(input), '表格解析成功');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -101,21 +105,15 @@ router.post(
|
|||||||
* POST /api/imports/table/commit - 执行导入(字段映射 + 写入模式 + 结果统计)
|
* POST /api/imports/table/commit - 执行导入(字段映射 + 写入模式 + 结果统计)
|
||||||
*
|
*
|
||||||
* 路由放行 ADMIN|TEACHER,真正的权限在 service 内按 kind + 班级二次校验:
|
* 路由放行 ADMIN|TEACHER,真正的权限在 service 内按 kind + 班级二次校验:
|
||||||
* 成绩 → 管理员或本班班主任;学生名单 → 仅管理员;科任老师一律 403(需求 7)。
|
* 成绩按班级与科目收口;学生名单按管理键、导入键与班主任/全校范围校验。
|
||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/table/commit',
|
'/table/commit',
|
||||||
requireAnyPermission(
|
requireAnyPermission('import:students', 'grade:import', 'import:teachers', 'import:class-teachers'),
|
||||||
'import:students',
|
|
||||||
'grade:import',
|
|
||||||
'import:teachers',
|
|
||||||
'import:schedule',
|
|
||||||
'import:class-teachers',
|
|
||||||
),
|
|
||||||
validate({ body: tableCommitSchema }),
|
validate({ body: tableCommitSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const input = validatedBody<TableCommitInput>(req);
|
const input = validatedBody<TableCommitInput>(req);
|
||||||
assertImportKindAllowed(input.kind, getAuthUser(req).role);
|
await assertImportKindAllowed(input.kind, getAuthUser(req));
|
||||||
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
|
// 教师名单与班级无关;成绩/学生名单必须带班级,否则拒绝(避免写到"全局")
|
||||||
if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) {
|
if (input.kind !== 'teachers' && input.kind !== 'classTeachers' && !input.classId) {
|
||||||
throw ApiError.badRequest('请先选择要导入的班级');
|
throw ApiError.badRequest('请先选择要导入的班级');
|
||||||
@@ -133,19 +131,29 @@ router.post(
|
|||||||
* POST /api/imports/time-layout/preview - 预览 ClassIsland 课表时间 JSON(只解析不写库)
|
* POST /api/imports/time-layout/preview - 预览 ClassIsland 课表时间 JSON(只解析不写库)
|
||||||
* 权限与"课表管理"一致:管理员或本班班主任
|
* 权限与"课表管理"一致:管理员或本班班主任
|
||||||
*/
|
*/
|
||||||
router.post('/time-layout/preview', validate({ body: timeLayoutImportSchema }), async (req, res) => {
|
router.post(
|
||||||
|
'/time-layout/preview',
|
||||||
|
requirePermission('import:schedule', 'schedule:write'),
|
||||||
|
validate({ body: timeLayoutImportSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const input = validatedBody<TimeLayoutImportInput>(req);
|
const input = validatedBody<TimeLayoutImportInput>(req);
|
||||||
// 预览也要校验班级权限(与导入一致),避免越权探测
|
// 预览也要校验班级权限(与导入一致),避免越权探测
|
||||||
const { assertCanManageSchedule } = await import('../../lib/access.js');
|
const { assertCanImportSchedule } = await import('../../lib/access.js');
|
||||||
await assertCanManageSchedule(getAuthUser(req), input.classId);
|
await assertCanImportSchedule(getAuthUser(req), input.classId);
|
||||||
sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成');
|
sendOk(res, previewClassIslandTimeLayout(input.payload), '解析完成');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
|
/** GET /api/imports/time-layout?classId= - 查看该班已导入的时间配置 */
|
||||||
router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/time-layout',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: timeLayoutQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||||
sendOk(res, await listTimeLayouts(getAuthUser(req), classId), '获取时间配置成功');
|
sendOk(res, await listTimeLayouts(getAuthUser(req), classId), '获取时间配置成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
|
* POST /api/imports/time-layout - 导入 ClassIsland 时间配置
|
||||||
@@ -153,7 +161,7 @@ router.get('/time-layout', validate({ query: timeLayoutQuerySchema }), async (re
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/time-layout',
|
'/time-layout',
|
||||||
requirePermission('timetable:manage'),
|
requirePermission('timetable:manage', 'import:schedule'),
|
||||||
validate({ body: timeLayoutImportSchema }),
|
validate({ body: timeLayoutImportSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
|
const result = await importTimeLayout(getAuthUser(req), validatedBody<TimeLayoutImportInput>(req));
|
||||||
@@ -183,20 +191,29 @@ router.delete(
|
|||||||
* POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
|
* POST /api/imports/class-plan/preview - 预览 ClassIsland 课程表 JSON(只解析不写库)
|
||||||
* 真实档案里单双周由 ClassPlan.TimeRule.WeekCountDiv / WeekCountDivTotal 表达。
|
* 真实档案里单双周由 ClassPlan.TimeRule.WeekCountDiv / WeekCountDivTotal 表达。
|
||||||
*/
|
*/
|
||||||
router.post('/class-plan/preview', validate({ body: classPlanImportSchema }), async (req, res) => {
|
router.post(
|
||||||
|
'/class-plan/preview',
|
||||||
|
requirePermission('import:schedule', 'schedule:write'),
|
||||||
|
validate({ body: classPlanImportSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const result = await previewClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
|
const result = await previewClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
|
||||||
sendOk(
|
sendOk(
|
||||||
res,
|
res,
|
||||||
result,
|
result,
|
||||||
`解析出 ${result.entries.length} 节课(${result.subjects.length} 个科目,缺 ${result.missingSubjects.length} 个待建课程)`,
|
`解析出 ${result.entries.length} 节课(${result.subjects.length} 个科目,缺 ${result.missingSubjects.length} 个待建课程)`,
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/imports/class-plan - 导入 ClassIsland 课程表(支持单双周)
|
* POST /api/imports/class-plan - 导入 ClassIsland 课程表(支持单双周)
|
||||||
* replace 清空后写入 / merge 按 星期+开始时间+单双周 去重;解析失败返回 400 且不改动现有课表。
|
* replace 清空后写入 / merge 按 星期+开始时间+单双周 去重;解析失败返回 400 且不改动现有课表。
|
||||||
*/
|
*/
|
||||||
router.post('/class-plan', validate({ body: classPlanImportSchema }), async (req, res) => {
|
router.post(
|
||||||
|
'/class-plan',
|
||||||
|
requirePermission('import:schedule', 'schedule:write'),
|
||||||
|
validate({ body: classPlanImportSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const result = await importClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
|
const result = await importClassPlan(getAuthUser(req), validatedBody<ClassPlanImportInput>(req));
|
||||||
sendOk(
|
sendOk(
|
||||||
res,
|
res,
|
||||||
@@ -204,7 +221,8 @@ router.post('/class-plan', validate({ body: classPlanImportSchema }), async (req
|
|||||||
`导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` +
|
`导入成功:新增 ${result.created} 节 / 更新 ${result.updated} 节` +
|
||||||
`${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`,
|
`${result.createdCourses.length > 0 ? `(自动补建课程:${result.createdCourses.join('、')})` : ''}`,
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
export const importsModule = defineModule({
|
export const importsModule = defineModule({
|
||||||
name: 'imports',
|
name: 'imports',
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { assertPermission } from '../../lib/permissions.js';
|
||||||
|
import { assertCanManageRoster, assertCanAssignTeachers, assertClassAccess } from '../../lib/access.js';
|
||||||
|
import { addStudent } from '../classes/classes.service.js';
|
||||||
|
import { updateStudent } from '../students/students.service.js';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
|
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
|
||||||
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
|
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
|
||||||
@@ -566,7 +570,7 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
|||||||
|
|
||||||
const existing = await prisma.student.findUnique({
|
const existing = await prisma.student.findUnique({
|
||||||
where: { studentNo },
|
where: { studentNo },
|
||||||
select: { id: true, classId: true, status: true },
|
select: { id: true, classId: true, status: true, archivedYearId: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existing && !isActiveStudent(existing))
|
if (existing && !isActiveStudent(existing))
|
||||||
@@ -576,40 +580,16 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
|||||||
skipped += 1;
|
skipped += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (existing.classId !== classId) {
|
if (existing.classId !== classId)
|
||||||
// 换班要留痕,与手动调班同一种历史
|
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
|
||||||
const [from, to] = await Promise.all([classNameOf(existing.classId), classNameOf(classId)]);
|
await updateStudent(user, existing.id, {
|
||||||
await prisma.studentClassTransfer.create({
|
|
||||||
data: {
|
|
||||||
studentId: existing.id,
|
|
||||||
studentNo,
|
|
||||||
studentName: name,
|
|
||||||
fromClassId: existing.classId,
|
|
||||||
fromClassName: from,
|
|
||||||
toClassId: classId,
|
|
||||||
toClassName: to,
|
|
||||||
operatorId: user.sub,
|
|
||||||
operatorName: user.name,
|
|
||||||
mode: 'batch',
|
|
||||||
note: '学生名单导入',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await prisma.student.update({
|
|
||||||
where: { id: existing.id },
|
|
||||||
data: {
|
|
||||||
name,
|
name,
|
||||||
classId,
|
|
||||||
status: 'active',
|
|
||||||
...(gender ? { gender } : {}),
|
...(gender ? { gender } : {}),
|
||||||
...(guardianPhone ? { guardianPhone } : {}),
|
...(guardianPhone ? { guardianPhone } : {}),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
updated += 1;
|
updated += 1;
|
||||||
} else {
|
} else {
|
||||||
await prisma.student.create({
|
await addStudent(user, classId, { studentNo, name, gender, guardianPhone });
|
||||||
data: { studentNo, name, classId, gender, guardianPhone },
|
|
||||||
});
|
|
||||||
inserted += 1;
|
inserted += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -769,6 +749,7 @@ export async function commitClassTeachers(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await assertImportAllowed(user, targetClass.id, 'classTeachers');
|
||||||
const role = parseClassTeacherRole(roleCell);
|
const role = parseClassTeacherRole(roleCell);
|
||||||
if (!role) {
|
if (!role) {
|
||||||
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
|
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
|
||||||
@@ -816,6 +797,8 @@ export async function commitClassTeachers(
|
|||||||
});
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
await assertPermission(user, 'teacher:manage');
|
||||||
|
await assertPermission(user, 'import:teachers');
|
||||||
const created = await prisma.user.create({
|
const created = await prisma.user.create({
|
||||||
data: {
|
data: {
|
||||||
username: teacherNo,
|
username: teacherNo,
|
||||||
@@ -898,9 +881,21 @@ async function assertImportAllowed(
|
|||||||
classId: string | undefined,
|
classId: string | undefined,
|
||||||
kind: 'grades' | 'students' | 'teachers' | 'classTeachers' | 'scheduleTimes',
|
kind: 'grades' | 'students' | 'teachers' | 'classTeachers' | 'scheduleTimes',
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
|
const key = {
|
||||||
|
students: 'import:students',
|
||||||
|
teachers: 'import:teachers',
|
||||||
|
grades: 'grade:import',
|
||||||
|
classTeachers: 'import:class-teachers',
|
||||||
|
scheduleTimes: 'import:schedule',
|
||||||
|
} as const;
|
||||||
|
await assertPermission(user, key[kind]);
|
||||||
|
if (classId) await assertClassAccess(user, classId, key[kind]);
|
||||||
|
if (kind === 'students') return assertCanManageRoster(user, classId);
|
||||||
|
if (kind === 'teachers') return assertPermission(user, 'teacher:manage');
|
||||||
|
if (kind === 'classTeachers') return assertCanAssignTeachers(user, classId);
|
||||||
const classRole = classId ? await resolveUserClassRole(user, classId) : 'NONE';
|
const classRole = classId ? await resolveUserClassRole(user, classId) : 'NONE';
|
||||||
if (canImport(user.role, classRole, kind)) return;
|
if (canImport(user.role, classRole, kind)) return;
|
||||||
if (kind === 'students' || kind === 'grades') {
|
if (kind === 'grades') {
|
||||||
throw ApiError.forbidden(
|
throw ApiError.forbidden(
|
||||||
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
|
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
|
||||||
? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员'
|
? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员'
|
||||||
@@ -910,12 +905,6 @@ async function assertImportAllowed(
|
|||||||
throw ApiError.forbidden('该导入仅限管理员使用');
|
throw ApiError.forbidden('该导入仅限管理员使用');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function classNameOf(classId: string | null): Promise<string> {
|
|
||||||
if (!classId) return '';
|
|
||||||
const record = await prisma.class.findUnique({ where: { id: classId }, select: { name: true } });
|
|
||||||
return record?.name ?? '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */
|
/** 只接受明确的角色值;空值或未识别值保留原角色,避免意外降级。 */
|
||||||
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
|
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
|
||||||
const value = cell.trim().toUpperCase();
|
const value = cell.trim().toUpperCase();
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { prisma } from '../../lib/db.js';
|
|||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { logger } from '../../lib/logger.js';
|
import { logger } from '../../lib/logger.js';
|
||||||
import { assertCanManageSchedule } from '../../lib/access.js';
|
import { assertCanManageSchedule, assertCanImportSchedule } from '../../lib/access.js';
|
||||||
import type { TimeLayoutImportInput } from './imports.schemas.js';
|
import type { TimeLayoutImportInput } from './imports.schemas.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -324,7 +324,7 @@ export async function importTimeLayout(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
input: TimeLayoutImportInput,
|
input: TimeLayoutImportInput,
|
||||||
): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> {
|
): Promise<{ layout: TimeLayoutDto; mode: string; warnings: string[]; replaced: number; merged: number }> {
|
||||||
await assertCanManageSchedule(user, input.classId);
|
await assertCanImportSchedule(user, input.classId, 'timetable:manage');
|
||||||
|
|
||||||
const parsed = parseClassIslandTimeLayout(input.payload);
|
const parsed = parseClassIslandTimeLayout(input.payload);
|
||||||
if (parsed.errors.length > 0) {
|
if (parsed.errors.length > 0) {
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
import {
|
||||||
|
authenticate,
|
||||||
|
getAuthUser,
|
||||||
|
requireStaffPermission,
|
||||||
|
requirePermission,
|
||||||
|
} from '../../middleware/auth.js';
|
||||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
@@ -21,44 +26,69 @@ const router = Router();
|
|||||||
router.use(authenticate());
|
router.use(authenticate());
|
||||||
|
|
||||||
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
|
/** GET /api/notifications?classId=&priority=&unreadOnly=&date=&from=&to=&createdBy= - 通知列表 */
|
||||||
router.get('/', validate({ query: listNotificationsQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/',
|
||||||
|
requireStaffPermission('notification:read'),
|
||||||
|
validate({ query: listNotificationsQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const query = validatedQuery<ListNotificationQuery>(req);
|
const query = validatedQuery<ListNotificationQuery>(req);
|
||||||
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
|
sendOk(res, await notificationService.listNotifications(user, query), '获取通知列表成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
|
/** GET /api/notifications/days?classId=&from=&to=&createdBy= - 按天归档(日期 + 条数) */
|
||||||
router.get('/days', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/days',
|
||||||
|
requireStaffPermission('notification:read'),
|
||||||
|
validate({ query: notificationArchiveQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const query = validatedQuery<NotificationArchiveQuery>(req);
|
const query = validatedQuery<NotificationArchiveQuery>(req);
|
||||||
sendOk(res, await notificationService.listNotificationDays(user, query), '获取通知日期归档成功');
|
sendOk(res, await notificationService.listNotificationDays(user, query), '获取通知日期归档成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
|
/** GET /api/notifications/creators?classId=&from=&to=&date= - 按发布人归档 */
|
||||||
router.get('/creators', validate({ query: notificationArchiveQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/creators',
|
||||||
|
requireStaffPermission('notification:read'),
|
||||||
|
validate({ query: notificationArchiveQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const query = validatedQuery<NotificationArchiveQuery>(req);
|
const query = validatedQuery<NotificationArchiveQuery>(req);
|
||||||
sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功');
|
sendOk(res, await notificationService.listNotificationCreators(user, query), '获取通知发布人归档成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
|
/** GET /api/notifications/unread-count?classId= - 未读数(客户端红点) */
|
||||||
router.get('/unread-count', validate({ query: notificationScopeQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/unread-count',
|
||||||
|
requireStaffPermission('notification:read'),
|
||||||
|
validate({ query: notificationScopeQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||||
sendOk(res, { count: await notificationService.unreadCount(user, classId) }, '获取未读数成功');
|
sendOk(res, { count: await notificationService.unreadCount(user, classId) }, '获取未读数成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** POST /api/notifications/read-all - 全部标为已读 */
|
/** POST /api/notifications/read-all - 全部标为已读 */
|
||||||
router.post('/read-all', validate({ body: notificationScopeQuerySchema }), async (req, res) => {
|
router.post(
|
||||||
|
'/read-all',
|
||||||
|
requireStaffPermission('notification:read'),
|
||||||
|
validate({ body: notificationScopeQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { classId } = (req.body ?? {}) as { classId?: string };
|
const { classId } = (req.body ?? {}) as { classId?: string };
|
||||||
sendOk(res, await notificationService.markAllAsRead(user, classId), '已全部标为已读');
|
sendOk(res, await notificationService.markAllAsRead(user, classId), '已全部标为已读');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
|
/** POST /api/notifications - 发布通知(发布后广播 notification:new) */
|
||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
requirePermission('notification:publish'),
|
requireStaffPermission('notification:publish'),
|
||||||
validate({ body: createNotificationSchema }),
|
validate({ body: createNotificationSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -70,7 +100,7 @@ router.post(
|
|||||||
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
|
/** POST /api/notifications/bulk - 一次给多个班级发同一条通知(逐班结果,不整批回滚) */
|
||||||
router.post(
|
router.post(
|
||||||
'/bulk',
|
'/bulk',
|
||||||
requirePermission('notification:publish'),
|
requireStaffPermission('notification:publish'),
|
||||||
validate({ body: bulkCreateNotificationSchema }),
|
validate({ body: bulkCreateNotificationSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -83,11 +113,16 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** POST /api/notifications/:id/read - 标记已读 */
|
/** POST /api/notifications/:id/read - 标记已读 */
|
||||||
router.post('/:id/read', validate({ params: idParamSchema }), async (req, res) => {
|
router.post(
|
||||||
|
'/:id/read',
|
||||||
|
requireStaffPermission('notification:read'),
|
||||||
|
validate({ params: idParamSchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
sendOk(res, await notificationService.markAsRead(user, id), '已标记为已读');
|
sendOk(res, await notificationService.markAsRead(user, id), '已标记为已读');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** DELETE /api/notifications/:id - 删除通知 */
|
/** DELETE /api/notifications/:id - 删除通知 */
|
||||||
router.delete(
|
router.delete(
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js';
|
||||||
|
import { resolveContentCreator } from '../../lib/access.js';
|
||||||
import {
|
import {
|
||||||
SOCKET_EVENTS,
|
SOCKET_EVENTS,
|
||||||
dayKeyLocal,
|
dayKeyLocal,
|
||||||
@@ -71,6 +73,7 @@ export async function listNotifications(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
options: ListNotificationOptions,
|
options: ListNotificationOptions,
|
||||||
): Promise<NotificationDto[]> {
|
): Promise<NotificationDto[]> {
|
||||||
|
await assertStaffPermission(user, 'notification:read');
|
||||||
const scope = await resolveClassScope(user, options.classId);
|
const scope = await resolveClassScope(user, options.classId);
|
||||||
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
|
const isStaff = user.role === 'ADMIN' || user.role === 'TEACHER';
|
||||||
// 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己
|
// 班级账号(班级设备)以全班学生为"个人范围",普通学生就是自己
|
||||||
@@ -109,6 +112,7 @@ export async function listNotificationDays(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string },
|
options: { classId?: string; priority?: string; createdBy?: string; from?: string; to?: string },
|
||||||
): Promise<NotificationDaysDto> {
|
): Promise<NotificationDaysDto> {
|
||||||
|
await assertStaffPermission(user, 'notification:read');
|
||||||
const scope = await resolveClassScope(user, options.classId);
|
const scope = await resolveClassScope(user, options.classId);
|
||||||
const grouped = await prisma.notification.groupBy({
|
const grouped = await prisma.notification.groupBy({
|
||||||
by: ['assignDate'],
|
by: ['assignDate'],
|
||||||
@@ -129,6 +133,7 @@ export async function listNotificationCreators(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
options: { classId?: string; priority?: string; date?: string; from?: string; to?: string },
|
options: { classId?: string; priority?: string; date?: string; from?: string; to?: string },
|
||||||
): Promise<NotificationCreatorsDto> {
|
): Promise<NotificationCreatorsDto> {
|
||||||
|
await assertStaffPermission(user, 'notification:read');
|
||||||
const scope = await resolveClassScope(user, options.classId);
|
const scope = await resolveClassScope(user, options.classId);
|
||||||
const grouped = await prisma.notification.groupBy({
|
const grouped = await prisma.notification.groupBy({
|
||||||
by: ['createdBy'],
|
by: ['createdBy'],
|
||||||
@@ -166,6 +171,7 @@ export async function bulkCreateNotifications(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
input: BulkCreateNotificationRequest,
|
input: BulkCreateNotificationRequest,
|
||||||
): Promise<BulkPublishResult> {
|
): Promise<BulkPublishResult> {
|
||||||
|
await assertStaffPermission(user, 'notification:publish');
|
||||||
const classIds = normalizeBulkClassIds(input.classIds);
|
const classIds = normalizeBulkClassIds(input.classIds);
|
||||||
const results: BulkPublishClassResult[] = [];
|
const results: BulkPublishClassResult[] = [];
|
||||||
|
|
||||||
@@ -202,6 +208,7 @@ export async function createNotification(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
input: CreateNotificationInput,
|
input: CreateNotificationInput,
|
||||||
): Promise<NotificationDto> {
|
): Promise<NotificationDto> {
|
||||||
|
await assertStaffPermission(user, 'notification:publish');
|
||||||
await assertCanPublishNotification(user, input.classId);
|
await assertCanPublishNotification(user, input.classId);
|
||||||
|
|
||||||
const priority = input.priority ?? 'NORMAL';
|
const priority = input.priority ?? 'NORMAL';
|
||||||
@@ -227,7 +234,7 @@ export async function createNotification(
|
|||||||
title: input.title,
|
title: input.title,
|
||||||
content: input.content,
|
content: input.content,
|
||||||
priority,
|
priority,
|
||||||
createdBy: user.sub,
|
createdBy: await resolveContentCreator(user, input.classId),
|
||||||
// 按天归档用「本地日期」:晚间发布的通知不会被 UTC 切片算到第二天(与作业同口径)
|
// 按天归档用「本地日期」:晚间发布的通知不会被 UTC 切片算到第二天(与作业同口径)
|
||||||
assignDate: dayKeyLocal(new Date()),
|
assignDate: dayKeyLocal(new Date()),
|
||||||
},
|
},
|
||||||
@@ -241,6 +248,7 @@ export async function createNotification(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
|
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
|
||||||
|
await assertPermission(user, 'notification:delete');
|
||||||
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
|
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||||
if (!current) throw ApiError.notFound('通知不存在');
|
if (!current) throw ApiError.notFound('通知不存在');
|
||||||
await assertCanPublishNotification(user, current.classId);
|
await assertCanPublishNotification(user, current.classId);
|
||||||
@@ -259,6 +267,7 @@ export async function markAsRead(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
notificationId: string,
|
notificationId: string,
|
||||||
): Promise<{ notificationId: string; readAt: string; marked: number }> {
|
): Promise<{ notificationId: string; readAt: string; marked: number }> {
|
||||||
|
await assertStaffPermission(user, 'notification:read');
|
||||||
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
|
const notification = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||||
if (!notification) throw ApiError.notFound('通知不存在');
|
if (!notification) throw ApiError.notFound('通知不存在');
|
||||||
await assertClassAccess(user, notification.classId);
|
await assertClassAccess(user, notification.classId);
|
||||||
@@ -284,6 +293,7 @@ export async function markAsRead(
|
|||||||
|
|
||||||
/** 全部标为已读(可按班级限定) */
|
/** 全部标为已读(可按班级限定) */
|
||||||
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
|
export async function markAllAsRead(user: TokenPayload, classId?: string): Promise<{ marked: number }> {
|
||||||
|
await assertStaffPermission(user, 'notification:read');
|
||||||
const scope = await resolveClassScope(user, classId);
|
const scope = await resolveClassScope(user, classId);
|
||||||
const personalIds = await resolvePersonalIds(user);
|
const personalIds = await resolvePersonalIds(user);
|
||||||
const personalWhere = personalIdWhere(personalIds);
|
const personalWhere = personalIdWhere(personalIds);
|
||||||
@@ -314,6 +324,7 @@ export async function markAllAsRead(user: TokenPayload, classId?: string): Promi
|
|||||||
|
|
||||||
/** 未读数量(客户端红点) */
|
/** 未读数量(客户端红点) */
|
||||||
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
|
export async function unreadCount(user: TokenPayload, classId?: string): Promise<number> {
|
||||||
|
await assertStaffPermission(user, 'notification:read');
|
||||||
const scope = await resolveClassScope(user, classId);
|
const scope = await resolveClassScope(user, classId);
|
||||||
const personalIds = await resolvePersonalIds(user);
|
const personalIds = await resolvePersonalIds(user);
|
||||||
return prisma.notification.count({
|
return prisma.notification.count({
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||||
import { validate, validatedBody, validatedParams } from '../../middleware/validate.js';
|
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
assignUserGroupsSchema,
|
assignUserGroupsSchema,
|
||||||
@@ -38,6 +39,30 @@ router.get('/groups', requirePermission('permission:manage'), async (_req, res)
|
|||||||
sendOk(res, await permissionService.listGroups(), '获取权限组成功');
|
sendOk(res, await permissionService.listGroups(), '获取权限组成功');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
router.get('/scopes', requirePermission('permission:manage'), async (_req, res) =>
|
||||||
|
sendOk(res, await permissionService.scopeOptions()),
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/audit',
|
||||||
|
requirePermission('permission:manage'),
|
||||||
|
validate({
|
||||||
|
query: z.object({
|
||||||
|
page: z.coerce.number().int().min(1).default(1),
|
||||||
|
pageSize: z.coerce.number().int().min(1).max(100).default(30),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
async (req, res) => {
|
||||||
|
const { page, pageSize } = validatedQuery<{ page: number; pageSize: number }>(req);
|
||||||
|
sendOk(res, await permissionService.listAudits(page, pageSize));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
router.get(
|
||||||
|
'/groups/:id/members',
|
||||||
|
requirePermission('permission:manage'),
|
||||||
|
validate({ params: idParamSchema }),
|
||||||
|
async (req, res) => sendOk(res, await permissionService.groupMembers(String(req.params.id))),
|
||||||
|
);
|
||||||
|
|
||||||
/** POST /api/permissions/groups - 新建权限组 */
|
/** POST /api/permissions/groups - 新建权限组 */
|
||||||
router.post(
|
router.post(
|
||||||
'/groups',
|
'/groups',
|
||||||
@@ -45,7 +70,7 @@ router.post(
|
|||||||
validate({ body: createPermissionGroupSchema }),
|
validate({ body: createPermissionGroupSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const input = validatedBody<CreatePermissionGroupInput>(req);
|
const input = validatedBody<CreatePermissionGroupInput>(req);
|
||||||
sendCreated(res, await permissionService.createGroup(input), '权限组已创建');
|
sendCreated(res, await permissionService.createGroup(getAuthUser(req), input), '权限组已创建');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -57,7 +82,7 @@ router.patch(
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
const input = validatedBody<UpdatePermissionGroupInput>(req);
|
const input = validatedBody<UpdatePermissionGroupInput>(req);
|
||||||
sendOk(res, await permissionService.updateGroup(id, input), '权限组已更新');
|
sendOk(res, await permissionService.updateGroup(getAuthUser(req), id, input), '权限组已更新');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -68,7 +93,7 @@ router.delete(
|
|||||||
validate({ params: idParamSchema }),
|
validate({ params: idParamSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
await permissionService.deleteGroup(id);
|
await permissionService.deleteGroup(getAuthUser(req), id);
|
||||||
sendOk(res, { id }, '权限组已删除,原成员回到角色默认权限');
|
sendOk(res, { id }, '权限组已删除,原成员回到角色默认权限');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -86,7 +111,7 @@ router.put(
|
|||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
const input = validatedBody<AssignUserGroupsInput>(req);
|
const input = validatedBody<AssignUserGroupsInput>(req);
|
||||||
sendOk(res, await permissionService.assignUserGroups(id, input), '权限组分配已保存');
|
sendOk(res, await permissionService.assignUserGroups(getAuthUser(req), id, input), '权限组分配已保存');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,14 @@ import {
|
|||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { logger } from '../../lib/logger.js';
|
import {
|
||||||
|
parsePermissionScope,
|
||||||
|
defaultPromotionPlan,
|
||||||
|
type PermissionScope,
|
||||||
|
type PromotionPlan,
|
||||||
|
} from '@classhelper/shared';
|
||||||
|
import { assertPermission } from '../../lib/permissions.js';
|
||||||
|
import { getSetting } from '../../lib/settings.js';
|
||||||
import {
|
import {
|
||||||
ADMIN_UNREMOVABLE_PERMISSIONS,
|
ADMIN_UNREMOVABLE_PERMISSIONS,
|
||||||
ensureDefaultGroups,
|
ensureDefaultGroups,
|
||||||
@@ -66,14 +73,6 @@ const MEMBER_SELECT = {
|
|||||||
permissionGroups: { select: { groupId: true } },
|
permissionGroups: { select: { groupId: true } },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
type MemberRow = {
|
|
||||||
id: string;
|
|
||||||
username: string;
|
|
||||||
name: string;
|
|
||||||
role: string;
|
|
||||||
permissionGroups: { groupId: string }[];
|
|
||||||
};
|
|
||||||
|
|
||||||
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
|
/** 目录顺序(key → 序号):组的键与"我的权限"都按它排序,前端渲染才稳定 */
|
||||||
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
|
const KEY_ORDER = new Map<string, number>(PERMISSION_KEYS.map((key, index) => [key, index]));
|
||||||
|
|
||||||
@@ -97,6 +96,7 @@ function toGroupDto(row: GroupRow): PermissionGroupDto {
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
scope: row.scope as PermissionGroupDto['scope'],
|
scope: row.scope as PermissionGroupDto['scope'],
|
||||||
scopeValue: row.scopeValue,
|
scopeValue: row.scopeValue,
|
||||||
|
scopeLabel: scopeLabel(row),
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
builtin: row.builtin,
|
builtin: row.builtin,
|
||||||
@@ -111,6 +111,252 @@ function toGroupDto(row: GroupRow): PermissionGroupDto {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function scopeLabel(group: { scope: string; scopeValue: string }) {
|
||||||
|
try {
|
||||||
|
return parsePermissionScope(group.scope as PermissionScope, group.scopeValue).scopeLabel;
|
||||||
|
} catch {
|
||||||
|
return '未配置范围(无班级访问权)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function scopeOptions() {
|
||||||
|
const classes = await prisma.class.findMany({ distinct: ['grade'], select: { grade: true } });
|
||||||
|
const raw = await getSetting('term.promotionPlan');
|
||||||
|
const plan: PromotionPlan = raw ? JSON.parse(raw) : defaultPromotionPlan();
|
||||||
|
const planGrades = plan.tracks.flatMap((track) => track.stages.map((stage) => stage.grade));
|
||||||
|
return {
|
||||||
|
grades: [...new Set([...classes.map((cls) => cls.grade), ...planGrades])].sort(),
|
||||||
|
planGrades,
|
||||||
|
divisions: plan.tracks.map((track) => ({
|
||||||
|
name: track.name,
|
||||||
|
grades: track.stages.map((stage) => stage.grade),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateScope(input: { scope?: PermissionScope; scopeValue?: string }) {
|
||||||
|
try {
|
||||||
|
return parsePermissionScope(
|
||||||
|
input.scope ?? 'SELF_CLASS',
|
||||||
|
input.scopeValue ?? '',
|
||||||
|
(await scopeOptions()).grades,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
throw ApiError.badRequest(String(error instanceof Error ? error.message : error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertGrant(
|
||||||
|
actor: TokenPayload,
|
||||||
|
group: { scope: string; defaultRole?: string | null; keys: readonly string[] },
|
||||||
|
) {
|
||||||
|
await assertPermission(actor, 'permission:manage');
|
||||||
|
if (actor.role === 'ADMIN') return;
|
||||||
|
if (group.defaultRole) throw ApiError.forbidden('只有管理员可以管理角色默认组');
|
||||||
|
if (group.scope !== 'SELF_CLASS') throw ApiError.forbidden('只有管理员可以管理年级、部或全校范围组');
|
||||||
|
const own = await loadEffectivePermissions(actor);
|
||||||
|
if (group.keys.some((key) => !own.has(key as PermissionKey)))
|
||||||
|
throw ApiError.forbidden('不能授予或管理超出自己生效权限的组');
|
||||||
|
}
|
||||||
|
|
||||||
|
function auditData(
|
||||||
|
actor: TokenPayload,
|
||||||
|
action: string,
|
||||||
|
targetType: string,
|
||||||
|
targetId: string,
|
||||||
|
before: unknown,
|
||||||
|
after: unknown,
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
actorId: actor.sub,
|
||||||
|
actorName: actor.name,
|
||||||
|
action,
|
||||||
|
targetType,
|
||||||
|
targetId,
|
||||||
|
beforeJson: JSON.stringify(before),
|
||||||
|
afterJson: JSON.stringify(after),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createGroup(
|
||||||
|
actor: TokenPayload,
|
||||||
|
input: CreatePermissionGroupRequest,
|
||||||
|
): Promise<PermissionGroupDto> {
|
||||||
|
const scope = await validateScope(input);
|
||||||
|
const keys = normalizeKeys(input.keys);
|
||||||
|
await assertGrant(actor, { ...scope, keys });
|
||||||
|
if (await prisma.permissionGroup.findUnique({ where: { name: input.name.trim() } }))
|
||||||
|
throw ApiError.conflict('权限组名称已存在');
|
||||||
|
const created = await prisma.$transaction(async (tx) => {
|
||||||
|
const row = await tx.permissionGroup.create({
|
||||||
|
data: {
|
||||||
|
name: input.name.trim(),
|
||||||
|
description: input.description?.trim() ?? '',
|
||||||
|
scope: scope.scope,
|
||||||
|
scopeValue: scope.scopeValue,
|
||||||
|
items: { create: keys.map((key) => ({ key })) },
|
||||||
|
},
|
||||||
|
select: GROUP_SELECT,
|
||||||
|
});
|
||||||
|
await tx.permissionAudit.create({
|
||||||
|
data: auditData(actor, 'create', 'group', row.id, null, toGroupDto(row)),
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
return toGroupDto(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateGroup(
|
||||||
|
actor: TokenPayload,
|
||||||
|
groupId: string,
|
||||||
|
input: UpdatePermissionGroupRequest,
|
||||||
|
): Promise<PermissionGroupDto> {
|
||||||
|
const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT });
|
||||||
|
if (!current) throw ApiError.notFound('权限组不存在');
|
||||||
|
const before = toGroupDto(current);
|
||||||
|
await assertGrant(actor, { ...current, keys: before.keys });
|
||||||
|
const scope = await validateScope({
|
||||||
|
scope: input.scope ?? before.scope,
|
||||||
|
scopeValue: input.scopeValue ?? before.scopeValue,
|
||||||
|
});
|
||||||
|
const keys = normalizeKeys(input.keys ?? before.keys);
|
||||||
|
await assertGrant(actor, { ...scope, defaultRole: current.defaultRole, keys });
|
||||||
|
const updated = await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.permissionGroupItem.deleteMany({ where: { groupId } });
|
||||||
|
const row = await tx.permissionGroup.update({
|
||||||
|
where: { id: groupId },
|
||||||
|
data: {
|
||||||
|
name: input.name?.trim(),
|
||||||
|
description: input.description?.trim(),
|
||||||
|
scope: scope.scope,
|
||||||
|
scopeValue: scope.scopeValue,
|
||||||
|
items: { create: keys.map((key) => ({ key })) },
|
||||||
|
},
|
||||||
|
select: GROUP_SELECT,
|
||||||
|
});
|
||||||
|
await tx.permissionAudit.create({
|
||||||
|
data: auditData(
|
||||||
|
actor,
|
||||||
|
current.defaultRole ? 'update-default' : 'update',
|
||||||
|
'group',
|
||||||
|
groupId,
|
||||||
|
before,
|
||||||
|
toGroupDto(row),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
return row;
|
||||||
|
});
|
||||||
|
return toGroupDto(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteGroup(actor: TokenPayload, groupId: string) {
|
||||||
|
const current = await prisma.permissionGroup.findUnique({ where: { id: groupId }, select: GROUP_SELECT });
|
||||||
|
if (!current) throw ApiError.notFound('权限组不存在');
|
||||||
|
await assertGrant(actor, { ...current, keys: current.items.map((item) => item.key) });
|
||||||
|
if (current.builtin) throw ApiError.badRequest('内置权限组不可删除');
|
||||||
|
// 删除最后一组会回落默认组;非管理员不能借删除制造额外授权。
|
||||||
|
if (actor.role !== 'ADMIN') throw ApiError.forbidden('删除组会改变成员默认权限,仅管理员可执行');
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.permissionGroup.delete({ where: { id: groupId } });
|
||||||
|
await tx.permissionAudit.create({
|
||||||
|
data: auditData(actor, 'delete', 'group', groupId, toGroupDto(current), null),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return { id: groupId };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组和成员各预取一次,账号数增长不会增加权限查询次数。 */
|
||||||
|
export async function listMembers(): Promise<PermissionMemberDto[]> {
|
||||||
|
await ensureDefaultGroups();
|
||||||
|
const [rows, groups] = await Promise.all([
|
||||||
|
prisma.user.findMany({
|
||||||
|
where: { role: { in: ['ADMIN', 'TEACHER'] } },
|
||||||
|
select: MEMBER_SELECT,
|
||||||
|
orderBy: [{ role: 'asc' }, { name: 'asc' }],
|
||||||
|
}),
|
||||||
|
prisma.permissionGroup.findMany({ include: { items: true } }),
|
||||||
|
]);
|
||||||
|
return rows.map((row) => {
|
||||||
|
const groupIds = row.permissionGroups.map((link) => link.groupId);
|
||||||
|
const effective = groups.filter((group) =>
|
||||||
|
groupIds.length ? groupIds.includes(group.id) : group.defaultRole === row.role,
|
||||||
|
);
|
||||||
|
const permissions = normalizeKeys([
|
||||||
|
...effective.flatMap((group) => group.items.map((item) => item.key)),
|
||||||
|
...(row.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
username: row.username,
|
||||||
|
name: row.name,
|
||||||
|
role: row.role as UserRole,
|
||||||
|
groupIds,
|
||||||
|
permissions,
|
||||||
|
permissionCount: permissions.length,
|
||||||
|
scopeLabels: [...new Set(effective.map(scopeLabel))],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function groupMembers(groupId: string) {
|
||||||
|
const group = await prisma.permissionGroup.findUnique({ where: { id: groupId } });
|
||||||
|
if (!group) throw ApiError.notFound('权限组不存在');
|
||||||
|
return (await listMembers()).filter((member) =>
|
||||||
|
member.groupIds.length ? member.groupIds.includes(groupId) : member.role === group.defaultRole,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assignUserGroups(
|
||||||
|
actor: TokenPayload,
|
||||||
|
userId: string,
|
||||||
|
input: AssignUserGroupsRequest,
|
||||||
|
): Promise<PermissionMemberDto> {
|
||||||
|
await assertPermission(actor, 'permission:manage');
|
||||||
|
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
|
||||||
|
if (!user || !['ADMIN', 'TEACHER'].includes(user.role)) throw ApiError.notFound('员工账号不存在');
|
||||||
|
const ids = [...new Set(input.groupIds)];
|
||||||
|
const groups = await prisma.permissionGroup.findMany({
|
||||||
|
where: ids.length ? { id: { in: ids } } : { defaultRole: user.role },
|
||||||
|
include: { items: true },
|
||||||
|
});
|
||||||
|
if (ids.length && groups.length !== ids.length) throw ApiError.badRequest('部分权限组不存在');
|
||||||
|
for (const group of groups) {
|
||||||
|
if (group.defaultRole && group.defaultRole !== user.role)
|
||||||
|
throw ApiError.forbidden('默认组只能分配给同角色账号');
|
||||||
|
// 同角色默认组可显式选中,但仍检查范围与键的授权能力。
|
||||||
|
await assertGrant(actor, { ...group, defaultRole: null, keys: group.items.map((item) => item.key) });
|
||||||
|
}
|
||||||
|
if (actor.role !== 'ADMIN' && user.role === 'ADMIN') throw ApiError.forbidden('非管理员不能更改管理员分组');
|
||||||
|
const before = (await listMembers()).find((member) => member.id === userId)!;
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
await tx.userPermissionGroup.deleteMany({ where: { userId } });
|
||||||
|
if (ids.length)
|
||||||
|
await tx.userPermissionGroup.createMany({ data: ids.map((groupId) => ({ userId, groupId })) });
|
||||||
|
await tx.permissionAudit.create({
|
||||||
|
data: auditData(actor, 'assign', 'user', userId, before, {
|
||||||
|
groupIds: ids,
|
||||||
|
permissions: normalizeKeys([
|
||||||
|
...groups.flatMap((group) => group.items.map((item) => item.key)),
|
||||||
|
...(user.role === 'ADMIN' ? ADMIN_UNREMOVABLE_PERMISSIONS : []),
|
||||||
|
]),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return (await listMembers()).find((member) => member.id === userId)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listAudits(page = 1, pageSize = 30) {
|
||||||
|
const [items, total] = await Promise.all([
|
||||||
|
prisma.permissionAudit.findMany({
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
skip: (page - 1) * pageSize,
|
||||||
|
take: pageSize,
|
||||||
|
}),
|
||||||
|
prisma.permissionAudit.count(),
|
||||||
|
]);
|
||||||
|
return { items, total, page, pageSize };
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
|
/** GET /api/permissions/catalog - 权限键目录(权限页整棵勾选树都按它渲染) */
|
||||||
export async function permissionCatalog(): Promise<PermissionCatalogDto> {
|
export async function permissionCatalog(): Promise<PermissionCatalogDto> {
|
||||||
await ensureDefaultGroups();
|
await ensureDefaultGroups();
|
||||||
@@ -150,213 +396,13 @@ export async function listGroups(): Promise<PermissionGroupDto[]> {
|
|||||||
select: GROUP_SELECT,
|
select: GROUP_SELECT,
|
||||||
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
|
orderBy: [{ builtin: 'desc' }, { createdAt: 'asc' }],
|
||||||
});
|
});
|
||||||
return (rows as GroupRow[]).map(toGroupDto);
|
const members = await listMembers();
|
||||||
}
|
return (rows as GroupRow[]).map((row) => ({
|
||||||
|
...toGroupDto(row),
|
||||||
export async function createGroup(input: CreatePermissionGroupRequest): Promise<PermissionGroupDto> {
|
memberCount: members.filter((m) =>
|
||||||
const name = input.name.trim();
|
m.groupIds.length ? m.groupIds.includes(row.id) : row.defaultRole === m.role,
|
||||||
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
|
).length,
|
||||||
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
|
}));
|
||||||
|
|
||||||
const keys = normalizeKeys(input.keys);
|
|
||||||
const created = await prisma.permissionGroup.create({
|
|
||||||
data: {
|
|
||||||
name,
|
|
||||||
scope: input.scope ?? 'SELF_CLASS',
|
|
||||||
scopeValue: input.scopeValue ?? '',
|
|
||||||
description: input.description?.trim() ?? '',
|
|
||||||
items: { create: keys.map((key) => ({ key })) },
|
|
||||||
},
|
|
||||||
select: GROUP_SELECT,
|
|
||||||
});
|
|
||||||
logger.info(`新建权限组「${name}」(${keys.length} 个权限键)`);
|
|
||||||
return toGroupDto(created as GroupRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updateGroup(
|
|
||||||
groupId: string,
|
|
||||||
input: UpdatePermissionGroupRequest,
|
|
||||||
): Promise<PermissionGroupDto> {
|
|
||||||
const current = await prisma.permissionGroup.findUnique({
|
|
||||||
where: { id: groupId },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
if (!current) throw ApiError.notFound('权限组不存在');
|
|
||||||
|
|
||||||
const name = input.name?.trim();
|
|
||||||
if (name && name !== current.name) {
|
|
||||||
const sameName = await prisma.permissionGroup.findUnique({ where: { name }, select: { id: true } });
|
|
||||||
if (sameName) throw ApiError.conflict(`已存在名为「${name}」的权限组`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const keys = input.keys ? normalizeKeys(input.keys) : null;
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.permissionGroup.update({
|
|
||||||
where: { id: groupId },
|
|
||||||
data: {
|
|
||||||
...(name ? { name } : {}),
|
|
||||||
...(input.scope ? { scope: input.scope } : {}),
|
|
||||||
...(input.scopeValue !== undefined ? { scopeValue: input.scopeValue } : {}),
|
|
||||||
...(input.description !== undefined ? { description: input.description.trim() } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
// 键是整体替换:先清后建。漏传某个键就等于"取消勾选",不会留下幽灵权限。
|
|
||||||
if (keys) {
|
|
||||||
await tx.permissionGroupItem.deleteMany({ where: { groupId } });
|
|
||||||
if (keys.length > 0) {
|
|
||||||
await tx.permissionGroupItem.createMany({ data: keys.map((key) => ({ groupId, key })) });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const updated = await prisma.permissionGroup.findUniqueOrThrow({
|
|
||||||
where: { id: groupId },
|
|
||||||
select: GROUP_SELECT,
|
|
||||||
});
|
|
||||||
logger.info(
|
|
||||||
`更新权限组「${(updated as GroupRow).name}」(${keys ? `权限键 ${keys.length} 个` : '未改权限键'})`,
|
|
||||||
);
|
|
||||||
return toGroupDto(updated as GroupRow);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deleteGroup(groupId: string): Promise<{ id: string }> {
|
|
||||||
const current = await prisma.permissionGroup.findUnique({
|
|
||||||
where: { id: groupId },
|
|
||||||
select: { id: true, name: true, builtin: true, _count: { select: { users: true } } },
|
|
||||||
});
|
|
||||||
if (!current) throw ApiError.notFound('权限组不存在');
|
|
||||||
if (current.builtin) {
|
|
||||||
throw ApiError.badRequest(`「${current.name}」是内置权限组,不能删除(可以改名或调整里面的权限)`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 键与成员关系都是 onDelete: Cascade,直接删组即可;
|
|
||||||
// 组内成员随后"没有任何分组",会回落角色默认模板(不会变成空权限)。
|
|
||||||
await prisma.permissionGroup.delete({ where: { id: groupId } });
|
|
||||||
logger.info(`删除权限组「${current.name}」(原成员 ${current._count.users} 个,已回到角色默认权限)`);
|
|
||||||
return { id: groupId };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 把一行 User(含分组关联)转成权限页用的成员 DTO */
|
|
||||||
function toMemberDto(row: MemberRow, keysOfGroup: Map<string, PermissionKey[]>): PermissionMemberDto {
|
|
||||||
const groupIds = row.permissionGroups.map((link) => link.groupId);
|
|
||||||
const union = new Set<PermissionKey>();
|
|
||||||
for (const groupId of groupIds) for (const key of keysOfGroup.get(groupId) ?? []) union.add(key);
|
|
||||||
|
|
||||||
const role = row.role as UserRole;
|
|
||||||
const permissionCount =
|
|
||||||
role === 'ADMIN'
|
|
||||||
? PERMISSION_KEYS.length
|
|
||||||
: groupIds.length === 0
|
|
||||||
? ROLE_DEFAULT_PERMISSIONS[role].length
|
|
||||||
: union.size;
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
username: row.username,
|
|
||||||
name: row.name,
|
|
||||||
role,
|
|
||||||
groupIds,
|
|
||||||
permissionCount,
|
|
||||||
fixed: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** GET /api/permissions/users - 成员列表(管理员/教师账号;班级端账号不参与权限组) */
|
|
||||||
export async function listMembers(): Promise<PermissionMemberDto[]> {
|
|
||||||
const [rows, groups] = await Promise.all([
|
|
||||||
prisma.user.findMany({
|
|
||||||
where: { role: { in: ['ADMIN', 'TEACHER'] } },
|
|
||||||
select: MEMBER_SELECT,
|
|
||||||
orderBy: [{ role: 'asc' }, { name: 'asc' }],
|
|
||||||
}),
|
|
||||||
prisma.permissionGroup.findMany({ select: { id: true, items: { select: { key: true } } } }),
|
|
||||||
]);
|
|
||||||
|
|
||||||
const keysOfGroup = new Map<string, PermissionKey[]>(
|
|
||||||
groups.map((group) => [
|
|
||||||
group.id,
|
|
||||||
group.items
|
|
||||||
.map((item) => item.key)
|
|
||||||
.filter(knownKey)
|
|
||||||
.sort(byCatalogOrder),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
|
|
||||||
return Promise.all(
|
|
||||||
(rows as MemberRow[]).map(async (row) => ({
|
|
||||||
...toMemberDto(row, keysOfGroup),
|
|
||||||
permissionCount: (
|
|
||||||
await loadEffectivePermissions({
|
|
||||||
sub: row.id,
|
|
||||||
role: row.role as UserRole,
|
|
||||||
username: row.username,
|
|
||||||
name: row.name,
|
|
||||||
classId: null,
|
|
||||||
})
|
|
||||||
).size,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** PUT /api/permissions/users/:id/groups - 整体替换某账号的权限组 */
|
|
||||||
export async function assignUserGroups(
|
|
||||||
userId: string,
|
|
||||||
input: AssignUserGroupsRequest,
|
|
||||||
): Promise<PermissionMemberDto> {
|
|
||||||
const user = await prisma.user.findUnique({ where: { id: userId }, select: MEMBER_SELECT });
|
|
||||||
if (!user) throw ApiError.notFound('账号不存在');
|
|
||||||
|
|
||||||
const row = user as MemberRow;
|
|
||||||
if (row.role === 'CLASS_DEVICE') {
|
|
||||||
throw ApiError.badRequest(
|
|
||||||
'ClassHelper 班级端账号的能力由"班级内角色"决定(见权限矩阵),不参与权限组分配',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupIds = [...new Set(input.groupIds)];
|
|
||||||
if (groupIds.length > 0) {
|
|
||||||
const found = await prisma.permissionGroup.count({ where: { id: { in: groupIds } } });
|
|
||||||
if (found !== groupIds.length) throw ApiError.badRequest('有权限组不存在,请刷新后重试');
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
await tx.userPermissionGroup.deleteMany({ where: { userId } });
|
|
||||||
if (groupIds.length > 0) {
|
|
||||||
await tx.userPermissionGroup.createMany({
|
|
||||||
data: groupIds.map((groupId) => ({ userId, groupId })),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.info(
|
|
||||||
`分配权限组:${row.username} → ${groupIds.length > 0 ? `${groupIds.length} 个组` : '角色默认模板'}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const groups = await prisma.permissionGroup.findMany({
|
|
||||||
select: { id: true, items: { select: { key: true } } },
|
|
||||||
});
|
|
||||||
const keysOfGroup = new Map<string, PermissionKey[]>(
|
|
||||||
groups.map((group) => [
|
|
||||||
group.id,
|
|
||||||
group.items
|
|
||||||
.map((item) => item.key)
|
|
||||||
.filter(knownKey)
|
|
||||||
.sort(byCatalogOrder),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
...toMemberDto({ ...row, permissionGroups: groupIds.map((groupId) => ({ groupId })) }, keysOfGroup),
|
|
||||||
permissionCount: (
|
|
||||||
await loadEffectivePermissions({
|
|
||||||
sub: row.id,
|
|
||||||
role: row.role as UserRole,
|
|
||||||
username: row.username,
|
|
||||||
name: row.name,
|
|
||||||
classId: null,
|
|
||||||
})
|
|
||||||
).size,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -7,7 +7,12 @@ import { ApiError } from '../../lib/http.js';
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
import {
|
||||||
|
authenticate,
|
||||||
|
getAuthUser,
|
||||||
|
requireStaffPermission,
|
||||||
|
requirePermission,
|
||||||
|
} from '../../middleware/auth.js';
|
||||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
@@ -23,10 +28,18 @@ import * as scheduleService from './schedules.service.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate());
|
router.use(authenticate());
|
||||||
router.get('/active-week', validate({ query: gridQuerySchema }), async (req, res) =>
|
router.get(
|
||||||
|
'/active-week',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: gridQuerySchema }),
|
||||||
|
async (req, res) =>
|
||||||
sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))),
|
sendOk(res, await scheduleService.getActiveScheduleWeek(getAuthUser(req), validatedQuery(req))),
|
||||||
);
|
);
|
||||||
router.get('/scheduled', validate({ query: z.object({ classId: z.string().min(1) }) }), async (req, res) => {
|
router.get(
|
||||||
|
'/scheduled',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: z.object({ classId: z.string().min(1) }) }),
|
||||||
|
async (req, res) => {
|
||||||
const { classId } = validatedQuery<{ classId: string }>(req);
|
const { classId } = validatedQuery<{ classId: string }>(req);
|
||||||
await assertClassAccess(getAuthUser(req), classId);
|
await assertClassAccess(getAuthUser(req), classId);
|
||||||
sendOk(
|
sendOk(
|
||||||
@@ -37,7 +50,8 @@ router.get('/scheduled', validate({ query: z.object({ classId: z.string().min(1)
|
|||||||
orderBy: { date: 'asc' },
|
orderBy: { date: 'asc' },
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
router.delete(
|
router.delete(
|
||||||
'/scheduled/:id',
|
'/scheduled/:id',
|
||||||
requirePermission('schedule:write'),
|
requirePermission('schedule:write'),
|
||||||
@@ -93,14 +107,23 @@ router.post(
|
|||||||
);
|
);
|
||||||
|
|
||||||
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
|
/** GET /api/schedules?classId=&week=&dayOfWeek= - 课表列表 */
|
||||||
router.get('/', validate({ query: listSchedulesQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: listSchedulesQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const query = validatedQuery<{ classId?: string; week?: number; dayOfWeek?: number }>(req);
|
const query = validatedQuery<{ classId?: string; week?: number; dayOfWeek?: number }>(req);
|
||||||
sendOk(res, await scheduleService.listSchedules(user, query), '获取课表成功');
|
sendOk(res, await scheduleService.listSchedules(user, query), '获取课表成功');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */
|
/** GET /api/schedules/grid?classId=&week= - 周视图(7 列结构) */
|
||||||
router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/grid',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: gridQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req);
|
const { classId, week } = validatedQuery<{ classId?: string; week?: number }>(req);
|
||||||
sendOk(
|
sendOk(
|
||||||
@@ -112,14 +135,19 @@ router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
|
|||||||
}),
|
}),
|
||||||
'获取周视图成功',
|
'获取周视图成功',
|
||||||
);
|
);
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/schedules/current?classId=&at= - 班级当前上课状态。
|
* GET /api/schedules/current?classId=&at= - 班级当前上课状态。
|
||||||
* 客户端灵动岛(上课期间隐藏、下课后弹出)与 Web 端"上课时段发布紧急通知"
|
* 客户端灵动岛(上课期间隐藏、下课后弹出)与 Web 端"上课时段发布紧急通知"
|
||||||
* 的全屏二次确认都基于它;`at` 为诊断/联调用时间覆盖。
|
* 的全屏二次确认都基于它;`at` 为诊断/联调用时间覆盖。
|
||||||
*/
|
*/
|
||||||
router.get('/current', validate({ query: classStatusQuerySchema }), async (req, res) => {
|
router.get(
|
||||||
|
'/current',
|
||||||
|
requireStaffPermission('schedule:read'),
|
||||||
|
validate({ query: classStatusQuerySchema }),
|
||||||
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { classId, at } = validatedQuery<{ classId?: string; at?: string }>(req);
|
const { classId, at } = validatedQuery<{ classId?: string; at?: string }>(req);
|
||||||
const status = await scheduleService.getClassStatus(user, {
|
const status = await scheduleService.getClassStatus(user, {
|
||||||
@@ -127,7 +155,8 @@ router.get('/current', validate({ query: classStatusQuerySchema }), async (req,
|
|||||||
at: at ? new Date(at) : undefined,
|
at: at ? new Date(at) : undefined,
|
||||||
});
|
});
|
||||||
sendOk(res, status, status.inClass ? '当前处于上课时间段' : '当前不在上课时间段');
|
sendOk(res, status, status.inClass ? '当前处于上课时间段' : '当前不在上课时间段');
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
/** POST /api/schedules - 新增课表 */
|
/** POST /api/schedules - 新增课表 */
|
||||||
router.post(
|
router.post(
|
||||||
|
|||||||
@@ -1,11 +1,14 @@
|
|||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
|
||||||
import { idParamSchema } from '../../lib/schemas.js';
|
import { idParamSchema } from '../../lib/schemas.js';
|
||||||
import { authenticate, getAuthUser, requirePermission, requireAnyPermission } from '../../middleware/auth.js';
|
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
||||||
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
import { validate, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||||
import { defineModule } from '../module.types.js';
|
import { defineModule } from '../module.types.js';
|
||||||
import {
|
import {
|
||||||
createStudentSchema,
|
createStudentSchema,
|
||||||
|
transferInSchema,
|
||||||
|
restoreStudentSchema,
|
||||||
|
deleteStudentQuerySchema,
|
||||||
listStudentsQuerySchema,
|
listStudentsQuerySchema,
|
||||||
listTransfersQuerySchema,
|
listTransfersQuerySchema,
|
||||||
transferOutStudentsSchema,
|
transferOutStudentsSchema,
|
||||||
@@ -29,11 +32,11 @@ router.use(authenticate());
|
|||||||
* - 学生**没有账号**:这里是"名单记录"的增删改,没有任何密码/角色字段;
|
* - 学生**没有账号**:这里是"名单记录"的增删改,没有任何密码/角色字段;
|
||||||
* - 读:教师/管理员按自己可访问的班级过滤(服务层 `resolveClassScope`);
|
* - 读:教师/管理员按自己可访问的班级过滤(服务层 `resolveClassScope`);
|
||||||
* 教室机器读本班名单请用 `GET /api/classes/:id/students`;
|
* 教室机器读本班名单请用 `GET /api/classes/:id/students`;
|
||||||
* - 写:管理员或**本班班主任**(服务层逐班判权限),调班与转出仅管理员。
|
* - 写:管理员或**本班班主任**(服务层逐班判权限),调班与转出还需覆盖源班和目标班。
|
||||||
*/
|
*/
|
||||||
router.get(
|
router.get(
|
||||||
'/',
|
'/',
|
||||||
requireAnyPermission('roster:read', 'roster:manage'),
|
requirePermission('roster:read'),
|
||||||
validate({ query: listStudentsQuerySchema }),
|
validate({ query: listStudentsQuerySchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -45,7 +48,7 @@ router.get(
|
|||||||
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
|
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
|
||||||
router.get(
|
router.get(
|
||||||
'/transfers',
|
'/transfers',
|
||||||
requireAnyPermission('roster:read', 'roster:manage'),
|
requirePermission('roster:read'),
|
||||||
validate({ query: listTransfersQuerySchema }),
|
validate({ query: listTransfersQuerySchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -62,7 +65,7 @@ router.get(
|
|||||||
*/
|
*/
|
||||||
router.post(
|
router.post(
|
||||||
'/transfer',
|
'/transfer',
|
||||||
requirePermission('class:manage'),
|
requirePermission('roster:manage'),
|
||||||
validate({ body: transferStudentsSchema }),
|
validate({ body: transferStudentsSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -74,7 +77,7 @@ router.post(
|
|||||||
/** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */
|
/** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */
|
||||||
router.post(
|
router.post(
|
||||||
'/transfer-out',
|
'/transfer-out',
|
||||||
requirePermission('class:manage'),
|
requirePermission('roster:manage'),
|
||||||
validate({ body: transferOutStudentsSchema }),
|
validate({ body: transferOutStudentsSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -83,10 +86,24 @@ router.post(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
'/transfer-in',
|
||||||
|
requirePermission('roster:manage'),
|
||||||
|
validate({ body: transferInSchema }),
|
||||||
|
async (req, res) => sendCreated(res, await studentService.transferInStudent(getAuthUser(req), req.body)),
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/:id/restore',
|
||||||
|
requirePermission('roster:manage'),
|
||||||
|
validate({ params: idParamSchema, body: restoreStudentSchema }),
|
||||||
|
async (req, res) =>
|
||||||
|
sendOk(res, await studentService.restoreStudent(getAuthUser(req), String(req.params.id), req.body)),
|
||||||
|
);
|
||||||
|
|
||||||
/** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */
|
/** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */
|
||||||
router.get(
|
router.get(
|
||||||
'/:id/transfers',
|
'/:id/transfers',
|
||||||
requirePermission('roster:manage'),
|
requirePermission('roster:read'),
|
||||||
validate({ params: idParamSchema }),
|
validate({ params: idParamSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -115,6 +132,11 @@ router.post(
|
|||||||
router.patch(
|
router.patch(
|
||||||
'/:id',
|
'/:id',
|
||||||
requirePermission('roster:manage'),
|
requirePermission('roster:manage'),
|
||||||
|
(req, _res, next) => {
|
||||||
|
if (req.body && ('classId' in req.body || 'status' in req.body))
|
||||||
|
return next(ApiError.badRequest('班级和状态请使用调班、转出或复学接口修改'));
|
||||||
|
next();
|
||||||
|
},
|
||||||
validate({ params: idParamSchema, body: updateStudentSchema }),
|
validate({ params: idParamSchema, body: updateStudentSchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
@@ -131,12 +153,16 @@ router.patch(
|
|||||||
router.delete(
|
router.delete(
|
||||||
'/:id',
|
'/:id',
|
||||||
requirePermission('roster:manage'),
|
requirePermission('roster:manage'),
|
||||||
validate({ params: idParamSchema }),
|
validate({ params: idParamSchema, query: deleteStudentQuerySchema }),
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
const user = getAuthUser(req);
|
const user = getAuthUser(req);
|
||||||
const { id } = validatedParams<{ id: string }>(req);
|
const { id } = validatedParams<{ id: string }>(req);
|
||||||
await studentService.deleteStudent(user, id);
|
const query = validatedQuery<{ hard?: string; confirm?: string }>(req);
|
||||||
sendOk(res, { id }, '学生记录已删除');
|
await studentService.deleteStudent(user, id, {
|
||||||
|
hard: query.hard === 'true',
|
||||||
|
confirm: query.confirm === 'true',
|
||||||
|
});
|
||||||
|
sendOk(res, { id }, '学生名单操作已完成');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -23,22 +23,44 @@ export const listStudentsQuerySchema = z.object({
|
|||||||
|
|
||||||
// 学生只有「名单」没有「账号」:没有密码字段、没有角色字段,也不能登录。
|
// 学生只有「名单」没有「账号」:没有密码字段、没有角色字段,也不能登录。
|
||||||
// 学号是学生的唯一标识与查询键,`studentNoSchema` 负责归一化与格式校验。
|
// 学号是学生的唯一标识与查询键,`studentNoSchema` 负责归一化与格式校验。
|
||||||
export const createStudentSchema = z.object({
|
export const createStudentSchema = z
|
||||||
|
.object({
|
||||||
studentNo: studentNoSchema,
|
studentNo: studentNoSchema,
|
||||||
name: studentNameSchema,
|
name: studentNameSchema,
|
||||||
classId: z.string().min(1).nullish(),
|
classId: z.string().min(1).nullish(),
|
||||||
gender: studentGenderSchema,
|
gender: studentGenderSchema,
|
||||||
guardianPhone: guardianPhoneSchema,
|
guardianPhone: guardianPhoneSchema,
|
||||||
status: studentStatusSchema,
|
status: z.enum(['active', 'inactive']).optional(),
|
||||||
});
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
export const updateStudentSchema = z.object({
|
export const updateStudentSchema = z
|
||||||
|
.object({
|
||||||
studentNo: studentNoSchema.optional(),
|
studentNo: studentNoSchema.optional(),
|
||||||
name: studentNameSchema.optional(),
|
name: studentNameSchema.optional(),
|
||||||
classId: z.string().min(1).nullish(),
|
|
||||||
gender: studentGenderSchema,
|
gender: studentGenderSchema,
|
||||||
guardianPhone: guardianPhoneSchema,
|
guardianPhone: guardianPhoneSchema,
|
||||||
status: studentStatusSchema,
|
})
|
||||||
|
.strict();
|
||||||
|
|
||||||
|
export const transferInSchema = z
|
||||||
|
.object({
|
||||||
|
studentId: z.string().min(1).optional(),
|
||||||
|
studentNo: studentNoSchema.optional(),
|
||||||
|
name: studentNameSchema.optional(),
|
||||||
|
toClassId: z.string().min(1),
|
||||||
|
gender: studentGenderSchema,
|
||||||
|
guardianPhone: guardianPhoneSchema,
|
||||||
|
fromSchool: z.string().trim().max(200).optional(),
|
||||||
|
note: z.string().trim().max(200).optional(),
|
||||||
|
})
|
||||||
|
.strict();
|
||||||
|
export const restoreStudentSchema = z
|
||||||
|
.object({ toClassId: z.string().min(1).optional(), note: z.string().trim().max(200).optional() })
|
||||||
|
.strict();
|
||||||
|
export const deleteStudentQuerySchema = z.object({
|
||||||
|
hard: z.enum(['true', 'false']).optional(),
|
||||||
|
confirm: z.enum(['true', 'false']).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,17 +71,21 @@ export const updateStudentSchema = z.object({
|
|||||||
export const transferStudentsSchema = z.object({
|
export const transferStudentsSchema = z.object({
|
||||||
studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500),
|
studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500),
|
||||||
toClassId: z.string().min(1).nullable(),
|
toClassId: z.string().min(1).nullable(),
|
||||||
|
allowGradeChange: z.boolean().optional(),
|
||||||
note: z.string().trim().max(200).optional(),
|
note: z.string().trim().max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 转出(学籍离开本校,不是调班):班级归属保留作历史 */
|
/** 转出(学籍离开本校,不是调班):班级归属保留作历史 */
|
||||||
export const transferOutStudentsSchema = z.object({
|
export const transferOutStudentsSchema = z.object({
|
||||||
|
toSchool: z.string().trim().max(200).optional(),
|
||||||
studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500),
|
studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500),
|
||||||
note: z.string().trim().max(200).optional(),
|
note: z.string().trim().max(200).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 调班历史查询 */
|
/** 调班历史查询 */
|
||||||
export const listTransfersQuerySchema = z.object({
|
export const listTransfersQuerySchema = z.object({
|
||||||
|
batchId: z.string().optional(),
|
||||||
|
academicYear: z.coerce.number().int().optional(),
|
||||||
studentId: z.string().min(1).optional(),
|
studentId: z.string().min(1).optional(),
|
||||||
classId: z.string().min(1).optional(),
|
classId: z.string().min(1).optional(),
|
||||||
page: z.coerce.number().int().min(1).optional(),
|
page: z.coerce.number().int().min(1).optional(),
|
||||||
|
|||||||
@@ -5,15 +5,17 @@ import {
|
|||||||
assertClassAccess,
|
assertClassAccess,
|
||||||
classScopeWhere,
|
classScopeWhere,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
getAccessibleClassIds,
|
||||||
resolveClassScope,
|
resolveClassScope,
|
||||||
} from '../../lib/access.js';
|
} from '../../lib/access.js';
|
||||||
import { activeStudentWhere, isActiveStudent } from '../../lib/students.js';
|
import { assertStaffPermission, assertPermission } from '../../lib/permissions.js';
|
||||||
|
import { changeStudentLifecycle, notifyStudentClasses } from '../../lib/student-lifecycle.js';
|
||||||
|
import { activeStudentWhere } from '../../lib/students.js';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
|
import { hasAllScope } from '../../lib/permission-scope.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
import type { TokenPayload } from '../../lib/jwt.js';
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js';
|
import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js';
|
||||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
|
||||||
import { emitToClass } from '../../realtime/bus.js';
|
|
||||||
import type {
|
import type {
|
||||||
CreateStudentInput,
|
CreateStudentInput,
|
||||||
ListStudentsQuery,
|
ListStudentsQuery,
|
||||||
@@ -36,13 +38,13 @@ const withClass = { class: { select: { name: true, grade: true } } } as const;
|
|||||||
|
|
||||||
/** 学生名单(按班级权限过滤) */
|
/** 学生名单(按班级权限过滤) */
|
||||||
export async function listStudents(user: TokenPayload, options: ListStudentsQuery): Promise<StudentDto[]> {
|
export async function listStudents(user: TokenPayload, options: ListStudentsQuery): Promise<StudentDto[]> {
|
||||||
const scope = await resolveClassScope(user, options.classId);
|
await assertStaffPermission(user, 'roster:read');
|
||||||
|
const scope = await resolveClassScope(user, options.classId, 'roster:read');
|
||||||
|
|
||||||
const students = await prisma.student.findMany({
|
const students = await prisma.student.findMany({
|
||||||
where: {
|
where: {
|
||||||
...classScopeWhere(scope),
|
...classScopeWhere(scope),
|
||||||
...activeStudentWhere(),
|
status: options.status ?? 'active',
|
||||||
...(options.status ? { AND: [{ status: options.status }, activeStudentWhere()] } : {}),
|
|
||||||
// 默认不显示已归档(毕业 / 转出)的学生:他们属于归档页,不该混在在读名单里
|
// 默认不显示已归档(毕业 / 转出)的学生:他们属于归档页,不该混在在读名单里
|
||||||
...(options.includeArchived ? {} : { archivedYearId: null }),
|
...(options.includeArchived ? {} : { archivedYearId: null }),
|
||||||
...(options.keyword
|
...(options.keyword
|
||||||
@@ -74,181 +76,141 @@ export async function findStudentByNo(studentNo: string, classId?: string): Prom
|
|||||||
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
|
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
|
||||||
await assertCanManageRoster(user, input.classId ?? undefined);
|
await assertCanManageRoster(user, input.classId ?? undefined);
|
||||||
await assertStudentNoFree(input.studentNo);
|
await assertStudentNoFree(input.studentNo);
|
||||||
if (input.classId) await assertClassExists(input.classId);
|
|
||||||
|
|
||||||
const created = await prisma.student.create({
|
const result = await changeStudentLifecycle(user, {
|
||||||
data: {
|
studentIds: [],
|
||||||
|
kind: 'class-change',
|
||||||
|
toClassId: input.classId,
|
||||||
|
newStudent: {
|
||||||
studentNo: input.studentNo,
|
studentNo: input.studentNo,
|
||||||
name: input.name,
|
name: input.name,
|
||||||
classId: input.classId ?? null,
|
gender: input.gender,
|
||||||
gender: input.gender ?? '',
|
guardianPhone: input.guardianPhone,
|
||||||
guardianPhone: input.guardianPhone ?? '',
|
status: input.status,
|
||||||
status: input.status ?? 'active',
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
const created = await prisma.student.findUniqueOrThrow({
|
||||||
|
where: { id: result.studentId },
|
||||||
include: withClass,
|
include: withClass,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (created.classId) {
|
|
||||||
emitToClass(created.classId, SOCKET_EVENTS.classUpdated, {
|
|
||||||
classId: created.classId,
|
|
||||||
action: 'updated',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return toStudentDto(created);
|
return toStudentDto(created);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 修改学生信息(含调班;走本接口的单个调班同样会记入调班历史) */
|
/** 资料修改不承担状态或班级迁移;旧请求明确报错。 */
|
||||||
export async function updateStudent(
|
export async function updateStudent(
|
||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
studentId: string,
|
studentId: string,
|
||||||
input: UpdateStudentInput,
|
input: UpdateStudentInput,
|
||||||
): Promise<StudentDto> {
|
): Promise<StudentDto> {
|
||||||
|
if ('classId' in input || 'status' in input)
|
||||||
|
throw ApiError.badRequest('班级和状态请使用调班、转出或复学接口修改');
|
||||||
const student = await prisma.student.findUnique({ where: { id: studentId } });
|
const student = await prisma.student.findUnique({ where: { id: studentId } });
|
||||||
if (!student || !isActiveStudent(student)) throw ApiError.notFound('学生不存在');
|
if (!student) throw ApiError.notFound('学生不存在');
|
||||||
|
if (student.archivedYearId || !['active', 'inactive'].includes(student.status))
|
||||||
|
throw ApiError.conflict('历史学生资料只读,请先办理转入或撤销归档');
|
||||||
await assertCanManageRoster(user, student.classId ?? undefined);
|
await assertCanManageRoster(user, student.classId ?? undefined);
|
||||||
if (input.studentNo && input.studentNo !== student.studentNo) {
|
if (input.studentNo) await assertStudentNoFree(input.studentNo, studentId);
|
||||||
await assertStudentNoFree(input.studentNo, studentId);
|
const updated = await prisma.student.update({ where: { id: studentId }, data: input, include: withClass });
|
||||||
}
|
notifyStudentClasses([student.classId]);
|
||||||
|
|
||||||
const nextClassId = input.classId === undefined ? student.classId : (input.classId ?? null);
|
|
||||||
const classChanged = nextClassId !== student.classId;
|
|
||||||
if (classChanged) await assertCanManageRoster(user, nextClassId ?? undefined);
|
|
||||||
|
|
||||||
const updated = await prisma.student.update({
|
|
||||||
where: { id: studentId },
|
|
||||||
data: {
|
|
||||||
...(input.studentNo ? { studentNo: input.studentNo } : {}),
|
|
||||||
...(input.name ? { name: input.name } : {}),
|
|
||||||
...(input.gender !== undefined ? { gender: input.gender } : {}),
|
|
||||||
...(input.guardianPhone !== undefined ? { guardianPhone: input.guardianPhone } : {}),
|
|
||||||
...(input.status !== undefined ? { status: input.status } : {}),
|
|
||||||
classId: nextClassId,
|
|
||||||
},
|
|
||||||
include: withClass,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 换班要留痕(单个调班与批量调班都写同一种历史)
|
|
||||||
if (classChanged) {
|
|
||||||
await recordTransfer(user, [updated], student.classId, nextClassId, 'single', '');
|
|
||||||
}
|
|
||||||
|
|
||||||
return toStudentDto(updated);
|
return toStudentDto(updated);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 删除学生记录(级联删除其作业状态、成绩、已读记录与调班历史) */
|
/** 默认停用且保留业务;硬删除须管理员确认并证明没有任何历史。 */
|
||||||
export async function deleteStudent(user: TokenPayload, studentId: string): Promise<void> {
|
export async function deleteStudent(
|
||||||
|
user: TokenPayload,
|
||||||
|
studentId: string,
|
||||||
|
options: { hard?: boolean; confirm?: boolean } = {},
|
||||||
|
): Promise<void> {
|
||||||
const student = await prisma.student.findUnique({ where: { id: studentId } });
|
const student = await prisma.student.findUnique({ where: { id: studentId } });
|
||||||
if (!student) throw ApiError.notFound('学生不存在');
|
if (!student) throw ApiError.notFound('学生不存在');
|
||||||
await assertCanManageRoster(user, student.classId ?? undefined);
|
await assertCanManageRoster(user, student.classId ?? undefined);
|
||||||
|
if (!options.hard) {
|
||||||
await prisma.student.delete({ where: { id: studentId } });
|
await changeStudentLifecycle(user, {
|
||||||
|
studentIds: [studentId],
|
||||||
|
kind: 'deactivate',
|
||||||
|
note: '名单停用,保留全部业务历史',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isAdmin(user) || !options.confirm) throw ApiError.forbidden('硬删除需要管理员二次确认');
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
const current = await tx.student.findUniqueOrThrow({
|
||||||
|
where: { id: studentId },
|
||||||
|
include: {
|
||||||
|
_count: {
|
||||||
|
select: { grades: true, homeworkStatuses: true, notificationReads: true, transfers: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
current.archivedYearId ||
|
||||||
|
!['active', 'inactive'].includes(current.status) ||
|
||||||
|
Object.values(current._count).some(Boolean)
|
||||||
|
)
|
||||||
|
throw ApiError.conflict('该生有成绩、作业、已读或生命周期历史,不能硬删除;请停用保留记录');
|
||||||
|
await tx.student.delete({ where: { id: studentId } });
|
||||||
|
});
|
||||||
|
notifyStudentClasses([student.classId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 调班 / 转出 */
|
export async function transferStudents(user: TokenPayload, input: TransferStudentsInput) {
|
||||||
|
const result = await changeStudentLifecycle(user, {
|
||||||
/**
|
...input,
|
||||||
* 调班:单个与批量共用(传一个 id 就是单个)。
|
kind: input.toClassId ? 'class-change' : 'unassign',
|
||||||
*
|
|
||||||
* 规则(需求「管理员调班」):
|
|
||||||
* - 只改学生**当前班级**,学号不变、不创建任何账号;
|
|
||||||
* - 原班级、新班级、操作人、时间写入 `StudentClassTransfer`,历史可查;
|
|
||||||
* - 历史作业与成绩保留原归属(它们挂在各自的 `classId` 上,不随学生走)。
|
|
||||||
*/
|
|
||||||
export async function transferStudents(
|
|
||||||
user: TokenPayload,
|
|
||||||
input: TransferStudentsInput,
|
|
||||||
): Promise<{ moved: number; transfers: StudentTransferDto[] }> {
|
|
||||||
if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以调班');
|
|
||||||
|
|
||||||
const students = await prisma.student.findMany({
|
|
||||||
where: { id: { in: input.studentIds }, ...activeStudentWhere() },
|
|
||||||
});
|
});
|
||||||
if (students.length !== input.studentIds.length) {
|
return { ...result, moved: result.count };
|
||||||
throw ApiError.badRequest('部分学生不存在,请刷新后重试');
|
|
||||||
}
|
|
||||||
if (input.toClassId) await assertClassExists(input.toClassId);
|
|
||||||
|
|
||||||
const movable = students.filter((item) => item.classId !== input.toClassId);
|
|
||||||
if (movable.length === 0) {
|
|
||||||
throw ApiError.badRequest('所选学生已经全部在这个班级里');
|
|
||||||
}
|
|
||||||
|
|
||||||
await prisma.$transaction(
|
|
||||||
movable.map((item) =>
|
|
||||||
prisma.student.update({ where: { id: item.id }, data: { classId: input.toClassId } }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const mode = movable.length > 1 ? 'batch' : 'single';
|
|
||||||
// 注意传的是**调班前**的 movable(classId 还是原班级):历史里的"原班级"要按每条自己的来
|
|
||||||
const transfers = await recordTransfer(user, movable, null, input.toClassId, mode, input.note ?? '');
|
|
||||||
|
|
||||||
notifyClassChange(
|
|
||||||
movable.map((item) => item.classId),
|
|
||||||
input.toClassId,
|
|
||||||
);
|
|
||||||
return { moved: movable.length, transfers };
|
|
||||||
}
|
}
|
||||||
|
export async function transferOutStudents(user: TokenPayload, input: TransferOutStudentsInput) {
|
||||||
/**
|
const result = await changeStudentLifecycle(user, { ...input, kind: 'transfer-out' });
|
||||||
* 学生转出:学籍离开本校(**不是调班**)。
|
return { ...result, transferred: result.count };
|
||||||
*
|
}
|
||||||
* 班级归属保留作历史(成绩/作业/已读都还在),状态置为 `transferred`,
|
export async function transferInStudent(
|
||||||
* 并写一条 `mode = 'transfer-out'` 的历史,归档页据此统计"当年转出的学生"。
|
|
||||||
*/
|
|
||||||
export async function transferOutStudents(
|
|
||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
input: TransferOutStudentsInput,
|
input: {
|
||||||
): Promise<{ transferred: number; transfers: StudentTransferDto[] }> {
|
studentId?: string;
|
||||||
if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以办理学生转出');
|
studentNo?: string;
|
||||||
|
name?: string;
|
||||||
const students = await prisma.student.findMany({ where: { id: { in: input.studentIds } } });
|
toClassId: string;
|
||||||
if (students.length !== input.studentIds.length) {
|
gender?: string;
|
||||||
throw ApiError.badRequest('部分学生不存在,请刷新后重试');
|
guardianPhone?: string;
|
||||||
|
fromSchool?: string;
|
||||||
|
note?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (!input.studentId && (!input.studentNo || !input.name))
|
||||||
|
throw ApiError.badRequest('新转入学生必须填写学号与姓名');
|
||||||
|
const result = await changeStudentLifecycle(user, {
|
||||||
|
...input,
|
||||||
|
kind: 'transfer-in',
|
||||||
|
studentIds: input.studentId ? [input.studentId] : [],
|
||||||
|
...(!input.studentId
|
||||||
|
? {
|
||||||
|
newStudent: {
|
||||||
|
studentNo: input.studentNo!,
|
||||||
|
name: input.name!,
|
||||||
|
gender: input.gender,
|
||||||
|
guardianPhone: input.guardianPhone,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
const pending = students.filter((item) => item.status !== 'transferred');
|
: {}),
|
||||||
if (pending.length === 0) throw ApiError.badRequest('所选学生都已经转出');
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
await prisma.$transaction(
|
|
||||||
pending.map((item) =>
|
|
||||||
prisma.student.update({
|
|
||||||
where: { id: item.id },
|
|
||||||
data: { status: 'transferred', transferredAt: now, transferNote: input.note ?? '' },
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 班级名快照:转出时把学生"离开的那个班"记下来,之后班级改名也不影响历史
|
|
||||||
const classNames = await loadClassNames(pending.map((item) => item.classId));
|
|
||||||
await prisma.studentClassTransfer.createMany({
|
|
||||||
data: pending.map((item) => ({
|
|
||||||
studentId: item.id,
|
|
||||||
studentNo: item.studentNo,
|
|
||||||
studentName: item.name,
|
|
||||||
fromClassId: item.classId,
|
|
||||||
fromClassName: item.classId ? (classNames.get(item.classId) ?? '') : '',
|
|
||||||
toClassId: null,
|
|
||||||
toClassName: '转出本校',
|
|
||||||
operatorId: user.sub,
|
|
||||||
operatorName: user.name,
|
|
||||||
mode: 'transfer-out',
|
|
||||||
note: input.note ?? '',
|
|
||||||
})),
|
|
||||||
});
|
});
|
||||||
|
return toStudentDto(
|
||||||
notifyClassChange(
|
await prisma.student.findUniqueOrThrow({ where: { id: result.studentId }, include: withClass }),
|
||||||
pending.map((item) => item.classId),
|
|
||||||
null,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
const created = await prisma.studentClassTransfer.findMany({
|
export async function restoreStudent(
|
||||||
where: { studentId: { in: pending.map((item) => item.id) }, mode: 'transfer-out' },
|
user: TokenPayload,
|
||||||
orderBy: { createdAt: 'desc' },
|
id: string,
|
||||||
take: pending.length,
|
input: { toClassId?: string; note?: string } = {},
|
||||||
|
) {
|
||||||
|
const student = await prisma.student.findUnique({ where: { id } });
|
||||||
|
if (!student) throw ApiError.notFound('学生不存在');
|
||||||
|
return changeStudentLifecycle(user, {
|
||||||
|
studentIds: [id],
|
||||||
|
kind: 'restore',
|
||||||
|
toClassId: input.toClassId ?? student.classId,
|
||||||
|
note: input.note ?? '撤回转出或复学,保留原历史',
|
||||||
});
|
});
|
||||||
return { transferred: pending.length, transfers: created.map(toStudentTransferDto) };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 调班 / 转出历史(单个学生或某个班级,管理员看全部) */
|
/** 调班 / 转出历史(单个学生或某个班级,管理员看全部) */
|
||||||
@@ -256,9 +218,8 @@ export async function listTransfers(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
options: ListTransfersQuery,
|
options: ListTransfersQuery,
|
||||||
): Promise<PaginatedResult<StudentTransferDto>> {
|
): Promise<PaginatedResult<StudentTransferDto>> {
|
||||||
if (!isAdmin(user) && !options.classId && !options.studentId) {
|
await assertPermission(user, 'roster:read');
|
||||||
throw ApiError.forbidden('查看全部调班历史需要管理员权限');
|
const hasGlobalRosterScope = isAdmin(user) || (await hasAllScope(user, 'roster:read'));
|
||||||
}
|
|
||||||
|
|
||||||
if (options.classId) await assertClassAccess(user, options.classId);
|
if (options.classId) await assertClassAccess(user, options.classId);
|
||||||
if (options.studentId) {
|
if (options.studentId) {
|
||||||
@@ -268,11 +229,16 @@ export async function listTransfers(
|
|||||||
});
|
});
|
||||||
if (!student) throw ApiError.notFound('学生不存在');
|
if (!student) throw ApiError.notFound('学生不存在');
|
||||||
if (student.classId) await assertClassAccess(user, student.classId);
|
if (student.classId) await assertClassAccess(user, student.classId);
|
||||||
else if (!isAdmin(user)) throw ApiError.forbidden('无权查看未分班学生的调班历史');
|
else if (!hasGlobalRosterScope) throw ApiError.forbidden('无权查看未分班学生的调班历史');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const accessible = hasGlobalRosterScope ? null : await getAccessibleClassIds(user, 'roster:read');
|
||||||
const where = {
|
const where = {
|
||||||
student: activeStudentWhere(),
|
...(options.batchId ? { batchId: options.batchId } : {}),
|
||||||
|
...(options.academicYear ? { academicYear: options.academicYear } : {}),
|
||||||
|
...(accessible
|
||||||
|
? { AND: [{ OR: [{ fromClassId: { in: accessible } }, { toClassId: { in: accessible } }] }] }
|
||||||
|
: {}),
|
||||||
...(options.studentId ? { studentId: options.studentId } : {}),
|
...(options.studentId ? { studentId: options.studentId } : {}),
|
||||||
...(options.classId ? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] } : {}),
|
...(options.classId ? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] } : {}),
|
||||||
};
|
};
|
||||||
@@ -292,76 +258,6 @@ export async function listTransfers(
|
|||||||
return { items: items.map(toStudentTransferDto), total, page, pageSize };
|
return { items: items.map(toStudentTransferDto), total, page, pageSize };
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------------------------------------------------------------- 内部工具 */
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 写入调班历史。
|
|
||||||
*
|
|
||||||
* - `fromOverride` 非空时,所有条目都用它作为"原班级"(单个调班场景);
|
|
||||||
* - 为空时逐条取**该学生自己的 classId** 作为"原班级"(批量调班场景,
|
|
||||||
* 各人的来源班可能不同)。
|
|
||||||
*/
|
|
||||||
async function recordTransfer(
|
|
||||||
user: TokenPayload,
|
|
||||||
students: { id: string; studentNo: string; name: string; classId: string | null }[],
|
|
||||||
fromOverride: string | null,
|
|
||||||
toClassId: string | null,
|
|
||||||
mode: 'single' | 'batch',
|
|
||||||
note: string,
|
|
||||||
): Promise<StudentTransferDto[]> {
|
|
||||||
const fromIds = students.map((item) => fromOverride ?? item.classId);
|
|
||||||
const classNames = await loadClassNames([...fromIds, toClassId]);
|
|
||||||
|
|
||||||
await prisma.studentClassTransfer.createMany({
|
|
||||||
data: students.map((item, index) => {
|
|
||||||
const fromClassId = fromIds[index] ?? null;
|
|
||||||
return {
|
|
||||||
studentId: item.id,
|
|
||||||
studentNo: item.studentNo,
|
|
||||||
studentName: item.name,
|
|
||||||
fromClassId,
|
|
||||||
fromClassName: fromClassId ? (classNames.get(fromClassId) ?? '') : '',
|
|
||||||
toClassId,
|
|
||||||
toClassName: toClassId ? (classNames.get(toClassId) ?? '') : '未分班',
|
|
||||||
operatorId: user.sub,
|
|
||||||
operatorName: user.name,
|
|
||||||
mode,
|
|
||||||
note,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const created = await prisma.studentClassTransfer.findMany({
|
|
||||||
where: { studentId: { in: students.map((item) => item.id) } },
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
take: students.length,
|
|
||||||
});
|
|
||||||
return created.map(toStudentTransferDto);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function loadClassNames(classIds: (string | null)[]): Promise<Map<string, string>> {
|
|
||||||
const ids = [...new Set(classIds.filter((id): id is string => Boolean(id)))];
|
|
||||||
if (ids.length === 0) return new Map();
|
|
||||||
const classes = await prisma.class.findMany({
|
|
||||||
where: { id: { in: ids } },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
return new Map(classes.map((item) => [item.id, item.name]));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 调班后让相关班级的前端刷新名单(旧班与新班都要收到) */
|
|
||||||
function notifyClassChange(fromClassIds: (string | null)[], toClassId: string | null): void {
|
|
||||||
const targets = new Set([...fromClassIds, toClassId].filter((id): id is string => Boolean(id)));
|
|
||||||
for (const classId of targets) {
|
|
||||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function assertClassExists(classId: string): Promise<void> {
|
|
||||||
const record = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
|
|
||||||
if (!record) throw ApiError.badRequest('班级不存在');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 学号唯一:`Student.studentNo` 上已有唯一约束,这里给出一句人话的 409 */
|
/** 学号唯一:`Student.studentNo` 上已有唯一约束,这里给出一句人话的 409 */
|
||||||
async function assertStudentNoFree(studentNo: string, exceptId?: string): Promise<void> {
|
async function assertStudentNoFree(studentNo: string, exceptId?: string): Promise<void> {
|
||||||
const existing = await prisma.student.findUnique({
|
const existing = await prisma.student.findUnique({
|
||||||
|
|||||||
@@ -1,210 +1,462 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
import fs from 'node:fs/promises';
|
import fs from 'node:fs/promises';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { dayKeyLocal, formatClassName } from '@classhelper/shared';
|
import {
|
||||||
|
dayKeyLocal,
|
||||||
|
formatClassName,
|
||||||
|
isDayKey,
|
||||||
|
defaultPromotionPlan,
|
||||||
|
SOCKET_EVENTS,
|
||||||
|
type PromotionPlan,
|
||||||
|
} from '@classhelper/shared';
|
||||||
import { prisma } from '../../lib/db.js';
|
import { prisma } from '../../lib/db.js';
|
||||||
|
import type { Prisma } from '../../generated/prisma/client.js';
|
||||||
|
import type { TokenPayload } from '../../lib/jwt.js';
|
||||||
import { env } from '../../config/env.js';
|
import { env } from '../../config/env.js';
|
||||||
import { ApiError } from '../../lib/http.js';
|
import { ApiError } from '../../lib/http.js';
|
||||||
|
import { assertGlobalPermission } from '../../lib/access.js';
|
||||||
import { exportSnapshot } from '../../lib/snapshot.js';
|
import { exportSnapshot } from '../../lib/snapshot.js';
|
||||||
import { getSetting, setSetting, SETTING_KEYS } from '../../lib/settings.js';
|
import { SETTING_KEYS } from '../../lib/settings.js';
|
||||||
import { getMaxTermWeeks } from '../../lib/term.js';
|
import { getMaxTermWeeks } from '../../lib/term.js';
|
||||||
import { hashPassword } from '../../lib/password.js';
|
import { hashPassword } from '../../lib/password.js';
|
||||||
import { logger } from '../../lib/logger.js';
|
import { logger } from '../../lib/logger.js';
|
||||||
|
import { emitToClass } from '../../realtime/bus.js';
|
||||||
import { notifyScheduleChanged } from '../class-plans/class-plans.service.js';
|
import { notifyScheduleChanged } from '../class-plans/class-plans.service.js';
|
||||||
|
|
||||||
export const promotionConfigSchema = z.object({
|
export const promotionPlanSchema = z
|
||||||
|
.object({
|
||||||
|
tracks: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
id: z.string().regex(/^[a-z][a-z0-9-]{0,19}$/),
|
||||||
|
name: z.string().trim().min(1).max(40),
|
||||||
|
entryClassCount: z.number().int().min(0).max(99),
|
||||||
|
stages: z
|
||||||
|
.array(z.object({ grade: z.string().trim().min(1).max(32), terminal: z.boolean().optional() }))
|
||||||
|
.min(1)
|
||||||
|
.max(20),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.min(1)
|
||||||
|
.max(20),
|
||||||
|
termStartDate: z.string().refine(isDayKey, '学期开始日期无效').nullable().default(null),
|
||||||
|
resetTermWeeks: z.boolean().default(true),
|
||||||
|
})
|
||||||
|
.superRefine((plan, ctx) => {
|
||||||
|
const grades = new Set<string>(),
|
||||||
|
tracks = new Set<string>();
|
||||||
|
for (const [index, track] of plan.tracks.entries()) {
|
||||||
|
if (tracks.has(track.id))
|
||||||
|
ctx.addIssue({ code: 'custom', path: ['tracks', index, 'id'], message: '轨道标识不能重复' });
|
||||||
|
tracks.add(track.id);
|
||||||
|
for (const [i, stage] of track.stages.entries()) {
|
||||||
|
if (grades.has(stage.grade))
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['tracks', index, 'stages', i],
|
||||||
|
message: '每个年级只能出现一次',
|
||||||
|
});
|
||||||
|
grades.add(stage.grade);
|
||||||
|
if (Boolean(stage.terminal) !== (i === track.stages.length - 1))
|
||||||
|
ctx.addIssue({
|
||||||
|
code: 'custom',
|
||||||
|
path: ['tracks', index, 'stages', i],
|
||||||
|
message: '仅最后一个学段必须标记为毕业学段',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
export const promotionConfigSchema = z
|
||||||
|
.object({
|
||||||
month: z.number().int().min(1).max(12),
|
month: z.number().int().min(1).max(12),
|
||||||
day: z.number().int().min(1).max(31),
|
day: z.number().int().min(1).max(31),
|
||||||
hour: z.number().int().min(0).max(23),
|
hour: z.number().int().min(0).max(23),
|
||||||
classCount: z.number().int().min(1).max(99).default(1),
|
classCount: z.number().int().min(0).max(99).optional(),
|
||||||
});
|
plan: promotionPlanSchema.optional(),
|
||||||
export async function promotionStatus(now = new Date()) {
|
})
|
||||||
const raw = await getSetting(SETTING_KEYS.promotionAt);
|
.refine((config) => config.day <= new Date(2000, config.month, 0).getDate(), '该月份没有所选日期');
|
||||||
let config = { month: 8, day: 1, hour: 0, classCount: 1 };
|
|
||||||
if (raw) {
|
type Db = Prisma.TransactionClient;
|
||||||
const parsed = promotionConfigSchema.safeParse(JSON.parse(raw));
|
async function loadConfig(db: Db = prisma) {
|
||||||
if (parsed.success) config = parsed.data;
|
const settings = await db.appSetting.findMany({
|
||||||
}
|
where: { key: { in: [SETTING_KEYS.promotionAt, SETTING_KEYS.promotionPlan] } },
|
||||||
|
});
|
||||||
|
const raw = settings.find((setting) => setting.key === SETTING_KEYS.promotionAt)?.value;
|
||||||
|
const config = raw
|
||||||
|
? promotionConfigSchema.parse(JSON.parse(raw))
|
||||||
|
: { month: 8, day: 1, hour: 0, classCount: 1 };
|
||||||
|
const rawPlan = settings.find((setting) => setting.key === SETTING_KEYS.promotionPlan)?.value;
|
||||||
|
const plan = rawPlan
|
||||||
|
? promotionPlanSchema.parse(JSON.parse(rawPlan))
|
||||||
|
: defaultPromotionPlan(config.classCount ?? 1);
|
||||||
|
return {
|
||||||
|
config: {
|
||||||
|
month: config.month,
|
||||||
|
day: config.day,
|
||||||
|
hour: config.hour,
|
||||||
|
classCount: plan.tracks[0].entryClassCount,
|
||||||
|
},
|
||||||
|
plan,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function loadPromotionPlan(): Promise<PromotionPlan> {
|
||||||
|
return (await loadConfig()).plan;
|
||||||
|
}
|
||||||
|
function dueYear(config: { month: number; day: number; hour: number }, now: Date) {
|
||||||
const due = new Date(
|
const due = new Date(
|
||||||
now.getFullYear(),
|
now.getFullYear(),
|
||||||
config.month - 1,
|
config.month - 1,
|
||||||
Math.min(config.day, new Date(now.getFullYear(), config.month, 0).getDate()),
|
Math.min(config.day, new Date(now.getFullYear(), config.month, 0).getDate()),
|
||||||
config.hour,
|
config.hour,
|
||||||
);
|
);
|
||||||
const year = now < due ? now.getFullYear() - 1 : now.getFullYear();
|
return now < due ? now.getFullYear() - 1 : now.getFullYear();
|
||||||
|
}
|
||||||
|
export async function promotionStatus(now = new Date()) {
|
||||||
|
const { config, plan } = await loadConfig();
|
||||||
|
const year = dueYear(config, now);
|
||||||
const run = await prisma.promotionRun.findUnique({ where: { year } });
|
const run = await prisma.promotionRun.findUnique({ where: { year } });
|
||||||
return {
|
return {
|
||||||
config,
|
config,
|
||||||
|
plan,
|
||||||
year,
|
year,
|
||||||
pending: !run || run.status !== 'done',
|
pending: run?.status !== 'done',
|
||||||
runs: await prisma.promotionRun.findMany({ orderBy: { year: 'desc' }, take: 20 }),
|
runs: await prisma.promotionRun.findMany({ orderBy: { year: 'desc' }, take: 30 }),
|
||||||
today: dayKeyLocal(now),
|
today: dayKeyLocal(now),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
export async function configurePromotion(input: z.infer<typeof promotionConfigSchema>) {
|
export async function configurePromotion(input: z.infer<typeof promotionConfigSchema>, actor?: TokenPayload) {
|
||||||
await setSetting(SETTING_KEYS.promotionAt, JSON.stringify(input));
|
if (actor) await assertGlobalPermission(actor, 'promotion:manage');
|
||||||
|
const parsed = promotionConfigSchema.parse(input);
|
||||||
|
const current = await loadConfig();
|
||||||
|
const plan =
|
||||||
|
parsed.plan ??
|
||||||
|
(parsed.classCount === undefined
|
||||||
|
? current.plan
|
||||||
|
: {
|
||||||
|
...current.plan,
|
||||||
|
tracks: current.plan.tracks.map((track, i) =>
|
||||||
|
i ? track : { ...track, entryClassCount: parsed.classCount! },
|
||||||
|
),
|
||||||
|
});
|
||||||
|
await prisma.$transaction(async (tx) => {
|
||||||
|
for (const [key, value] of [
|
||||||
|
[
|
||||||
|
SETTING_KEYS.promotionAt,
|
||||||
|
JSON.stringify({
|
||||||
|
month: parsed.month,
|
||||||
|
day: parsed.day,
|
||||||
|
hour: parsed.hour,
|
||||||
|
classCount: plan.tracks[0].entryClassCount,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
[SETTING_KEYS.promotionPlan, JSON.stringify(plan)],
|
||||||
|
])
|
||||||
|
await tx.appSetting.upsert({ where: { key }, create: { key, value }, update: { value } });
|
||||||
|
});
|
||||||
return promotionStatus();
|
return promotionStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 一年一个事务幂等键;快照失败不得升级。启动遗漏只提示补跑,不擅自改演示库。 */
|
/** 只读预览与执行校验共用同一查询,哈希覆盖配置、学生、班级和周次。 */
|
||||||
export async function runPromotion(year: number, dryRun = false) {
|
async function buildPreview(year: number, db: Db = prisma) {
|
||||||
const existing = await prisma.promotionRun.findUnique({ where: { year } });
|
const { config, plan } = await loadConfig(db);
|
||||||
if (existing?.status === 'done') return existing;
|
const [allClasses, students, weeks, archives] = await Promise.all([
|
||||||
const classes = await prisma.class.findMany({
|
db.class.findMany({ orderBy: { id: 'asc' } }),
|
||||||
where: { archivedYearId: null, grade: { in: ['高一', '高二', '高三'] } },
|
db.student.findMany({
|
||||||
|
orderBy: { id: 'asc' },
|
||||||
|
select: { id: true, classId: true, status: true, archivedYearId: true, updatedAt: true },
|
||||||
|
}),
|
||||||
|
db.termWeek.findMany({ orderBy: { id: 'asc' } }),
|
||||||
|
db.archivedYear.findMany({ orderBy: { id: 'asc' } }),
|
||||||
|
]);
|
||||||
|
const classes = allClasses.filter((cls) => !cls.archivedYearId);
|
||||||
|
const blockers: { code: string; message: string; classId?: string }[] = [];
|
||||||
|
const warnings = ['新建班级只有骨架,没有班主任或任课关系,请随后分配。'];
|
||||||
|
const grades = new Set(plan.tracks.flatMap((track) => track.stages.map((stage) => stage.grade)));
|
||||||
|
for (const cls of classes) {
|
||||||
|
if (!grades.has(cls.grade))
|
||||||
|
blockers.push({
|
||||||
|
code: 'UNKNOWN_GRADE',
|
||||||
|
classId: cls.id,
|
||||||
|
message: `${cls.name} 的年级「${cls.grade}」不在升级计划内`,
|
||||||
});
|
});
|
||||||
const classIds = classes.map((c) => c.id);
|
if (!cls.enrollmentYear || !cls.classIndex)
|
||||||
const studentCount = await prisma.student.count({ where: { classId: { in: classIds }, status: 'active' } });
|
blockers.push({ code: 'MISSING_COHORT', classId: cls.id, message: `${cls.name} 缺少入学年份或班号` });
|
||||||
if (dryRun)
|
if (
|
||||||
return { year, classCount: classes.length, studentCount, status: 'dry-run', snapshotRequired: true };
|
students.some(
|
||||||
|
(student) => student.classId === cls.id && (student.archivedYearId || student.status === 'graduated'),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
blockers.push({
|
||||||
|
code: 'INCONSISTENT_STUDENT',
|
||||||
|
classId: cls.id,
|
||||||
|
message: `${cls.name} 含已归档或已毕业学生,请先处理数据一致性`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const tracks = plan.tracks.map((track, ti) => {
|
||||||
|
const relevant = classes.filter((cls) => track.stages.some((stage) => stage.grade === cls.grade));
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const affected = relevant.map((cls) => {
|
||||||
|
const key = `${cls.enrollmentYear}:${cls.classIndex}`;
|
||||||
|
if (seen.has(key))
|
||||||
|
blockers.push({
|
||||||
|
code: 'DUPLICATE_INDEX',
|
||||||
|
classId: cls.id,
|
||||||
|
message: `${track.name} 同届存在重复班号:${cls.name}`,
|
||||||
|
});
|
||||||
|
seen.add(key);
|
||||||
|
const index = track.stages.findIndex((stage) => stage.grade === cls.grade);
|
||||||
|
const terminal = Boolean(track.stages[index].terminal);
|
||||||
|
if (terminal && archives.some((archive) => archive.enrollmentYear === cls.enrollmentYear))
|
||||||
|
blockers.push({
|
||||||
|
code: 'ARCHIVE_EXISTS',
|
||||||
|
classId: cls.id,
|
||||||
|
message: `${cls.enrollmentYear} 届已存在档案`,
|
||||||
|
});
|
||||||
|
const members = students.filter((student) => student.classId === cls.id);
|
||||||
|
return {
|
||||||
|
id: cls.id,
|
||||||
|
name: cls.name,
|
||||||
|
grade: cls.grade,
|
||||||
|
enrollmentYear: cls.enrollmentYear,
|
||||||
|
classIndex: cls.classIndex,
|
||||||
|
nextGrade: terminal ? '毕业归档' : track.stages[index + 1].grade,
|
||||||
|
terminal,
|
||||||
|
active: members.filter((student) => student.status === 'active').length,
|
||||||
|
inactive: members.filter((student) => student.status === 'inactive').length,
|
||||||
|
transferred: members.filter((student) => student.status === 'transferred').length,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const newClasses = [];
|
||||||
|
for (let i = 1; i <= track.entryClassCount; i++) {
|
||||||
|
if (
|
||||||
|
relevant.some(
|
||||||
|
(cls) => cls.enrollmentYear === year && cls.classIndex === i && cls.grade === track.stages[0].grade,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
const code =
|
||||||
|
ti === 0 ? `N${year}${String(i).padStart(2, '0')}` : `T${ti}${year}${String(i).padStart(2, '0')}`;
|
||||||
|
if (allClasses.some((cls) => cls.code === code))
|
||||||
|
blockers.push({ code: 'CODE_CONFLICT', message: `新班级码 ${code} 已被占用,请先修改冲突班级码` });
|
||||||
|
newClasses.push({
|
||||||
|
name: formatClassName(year, i),
|
||||||
|
grade: track.stages[0].grade,
|
||||||
|
code,
|
||||||
|
classIndex: i,
|
||||||
|
enrollmentYear: year,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { id: track.id, name: track.name, classes: affected, newClasses };
|
||||||
|
});
|
||||||
|
warnings.push(
|
||||||
|
plan.resetTermWeeks
|
||||||
|
? `将删除 ${weeks.length} 条全校及班级周次区间。`
|
||||||
|
: '保留原有周次区间,它们会继续覆盖新学年日期。',
|
||||||
|
);
|
||||||
|
const planHash = createHash('sha256')
|
||||||
|
.update(JSON.stringify({ year, config, plan, allClasses, students, weeks, archives }))
|
||||||
|
.digest('hex');
|
||||||
|
return {
|
||||||
|
year,
|
||||||
|
classCount: classes.length,
|
||||||
|
studentCount: students.filter(
|
||||||
|
(student) => student.status === 'active' && classes.some((cls) => cls.id === student.classId),
|
||||||
|
).length,
|
||||||
|
status: 'dry-run',
|
||||||
|
snapshotRequired: true,
|
||||||
|
plan,
|
||||||
|
tracks,
|
||||||
|
blockers,
|
||||||
|
warnings,
|
||||||
|
termWeekDeleteCount: plan.resetTermWeeks ? weeks.length : 0,
|
||||||
|
termStartDate: plan.termStartDate ?? `${year}-09-01`,
|
||||||
|
planHash,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
export async function previewPromotion(year: number) {
|
||||||
|
return buildPreview(year);
|
||||||
|
}
|
||||||
|
function assertExecutable(preview: Awaited<ReturnType<typeof buildPreview>>, hash?: string) {
|
||||||
|
if (hash && hash !== preview.planHash)
|
||||||
|
throw new ApiError(409, 'CONFLICT', '预览已过期,配置或业务数据已变化,请重新预览', {
|
||||||
|
code: 'PLAN_CHANGED',
|
||||||
|
});
|
||||||
|
if (preview.blockers.length)
|
||||||
|
throw new ApiError(409, 'CONFLICT', '升级存在阻断项,请修正后重新预览', preview.blockers);
|
||||||
|
}
|
||||||
|
type RunOptions = {
|
||||||
|
dryRun?: boolean;
|
||||||
|
actor?: TokenPayload;
|
||||||
|
planHash?: string;
|
||||||
|
confirm?: boolean;
|
||||||
|
catchUp?: boolean;
|
||||||
|
};
|
||||||
|
const running = new Map<number, Promise<Awaited<ReturnType<typeof executePromotion>>>>();
|
||||||
|
export async function runPromotion(year: number, dryRunOrOptions: boolean | RunOptions = false) {
|
||||||
|
const options = typeof dryRunOrOptions === 'boolean' ? { dryRun: dryRunOrOptions } : dryRunOrOptions;
|
||||||
|
if (options.actor) {
|
||||||
|
await assertGlobalPermission(options.actor, 'promotion:manage');
|
||||||
|
const status = await promotionStatus();
|
||||||
|
if (year > status.year) throw ApiError.badRequest('不能执行尚未到期的未来学年');
|
||||||
|
if (year < status.year && !(options.actor.role === 'ADMIN' && options.catchUp))
|
||||||
|
throw ApiError.forbidden('过去学年仅管理员明确补跑');
|
||||||
|
if (!options.dryRun && (!options.confirm || !options.planHash))
|
||||||
|
throw ApiError.badRequest('执行必须确认学年并提供本次预览哈希');
|
||||||
|
}
|
||||||
|
if (options.dryRun) return buildPreview(year);
|
||||||
|
const prior = running.get(year);
|
||||||
|
if (prior) {
|
||||||
|
if (options.planHash && (await prisma.promotionRun.findUnique({ where: { year } }))?.status !== 'done')
|
||||||
|
assertExecutable(await buildPreview(year), options.planHash);
|
||||||
|
return prior;
|
||||||
|
}
|
||||||
|
const task = executePromotion(year, options);
|
||||||
|
running.set(year, task);
|
||||||
|
try {
|
||||||
|
return await task;
|
||||||
|
} finally {
|
||||||
|
running.delete(year);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function executePromotion(year: number, options: RunOptions) {
|
||||||
|
const existing = await prisma.promotionRun.findUnique({ where: { year } });
|
||||||
|
if (existing?.status === 'done') return { ...existing, alreadyDone: true };
|
||||||
|
const preview = await buildPreview(year);
|
||||||
|
assertExecutable(preview, options.planHash);
|
||||||
const dir = path.resolve(
|
const dir = path.resolve(
|
||||||
env.sqliteFilePath ? path.dirname(env.sqliteFilePath) : path.resolve(env.serverRoot, '..', 'data'),
|
env.sqliteFilePath ? path.dirname(env.sqliteFilePath) : path.resolve(env.serverRoot, '..', 'data'),
|
||||||
'promotion-snapshots',
|
'promotion-snapshots',
|
||||||
);
|
);
|
||||||
const snapshotPath = path.join(dir, `${year}-${Date.now()}-${randomUUID()}.json`);
|
const snapshotPath = path.join(dir, `${year}-${Date.now()}-${randomUUID()}.json`);
|
||||||
|
let snapshotSha256 = '';
|
||||||
|
const actor = options.actor;
|
||||||
try {
|
try {
|
||||||
await fs.mkdir(dir, { recursive: true });
|
await fs.mkdir(dir, { recursive: true });
|
||||||
await fs.writeFile(snapshotPath, JSON.stringify(await exportSnapshot(prisma, env.databaseProvider)), {
|
const content = JSON.stringify(await exportSnapshot(prisma, env.databaseProvider));
|
||||||
flag: 'wx',
|
await fs.writeFile(snapshotPath, content, { flag: 'wx' });
|
||||||
});
|
snapshotSha256 = createHash('sha256').update(content).digest('hex');
|
||||||
} catch (error) {
|
|
||||||
await recordPromotionFailure(year, snapshotPath, error, classes.length, studentCount);
|
|
||||||
throw ApiError.internal(`执行前快照失败,未升级任何班级:${String(error)}`);
|
|
||||||
}
|
|
||||||
const { config } = await promotionStatus();
|
|
||||||
const passwordHash = await hashPassword(env.defaultClassPassword);
|
const passwordHash = await hashPassword(env.defaultClassPassword);
|
||||||
const maxWeeks = await getMaxTermWeeks();
|
const maxWeeks = await getMaxTermWeeks();
|
||||||
try {
|
|
||||||
await prisma.$transaction(
|
await prisma.$transaction(
|
||||||
async (tx) => {
|
async (tx) => {
|
||||||
// 唯一键在事务里争抢,重入/多进程都只能有一个提交成功。
|
const fresh = await buildPreview(year, tx);
|
||||||
if (existing) await tx.promotionRun.deleteMany({ where: { year, status: { not: 'done' } } });
|
assertExecutable(fresh, preview.planHash);
|
||||||
|
const current = await tx.promotionRun.findUnique({ where: { year } });
|
||||||
|
if (current?.status === 'done') return;
|
||||||
|
await tx.promotionRun.deleteMany({ where: { year, status: { not: 'done' } } });
|
||||||
await tx.promotionRun.create({
|
await tx.promotionRun.create({
|
||||||
data: { year, status: 'running', snapshotPath, classCount: classes.length, studentCount },
|
data: {
|
||||||
|
year,
|
||||||
|
status: 'running',
|
||||||
|
snapshotPath,
|
||||||
|
snapshotSha256,
|
||||||
|
operatorId: actor?.sub,
|
||||||
|
operatorName: actor?.name ?? '学年自动升级',
|
||||||
|
detailsJson: JSON.stringify(fresh),
|
||||||
|
classCount: fresh.classCount,
|
||||||
|
studentCount: fresh.studentCount,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const graduating = classes.filter((c) => c.grade === '高三');
|
const graduating = fresh.tracks.flatMap((track) => track.classes.filter((cls) => cls.terminal));
|
||||||
const cohorts = [...new Set(graduating.map((c) => c.enrollmentYear ?? year - 3))];
|
for (const enrollmentYear of new Set(graduating.map((cls) => cls.enrollmentYear!))) {
|
||||||
for (const enrollmentYear of cohorts) {
|
const ids = graduating.filter((cls) => cls.enrollmentYear === enrollmentYear).map((cls) => cls.id);
|
||||||
const ids = graduating
|
const counts = graduating.filter((cls) => ids.includes(cls.id));
|
||||||
.filter((c) => (c.enrollmentYear ?? year - 3) === enrollmentYear)
|
const archive = await tx.archivedYear.create({
|
||||||
.map((c) => c.id);
|
|
||||||
const archived = await tx.archivedYear.create({
|
|
||||||
data: {
|
data: {
|
||||||
enrollmentYear,
|
enrollmentYear,
|
||||||
graduationYear: year,
|
graduationYear: year,
|
||||||
name: `${enrollmentYear} 级`,
|
name: `${enrollmentYear} 级`,
|
||||||
operatorName: '学年自动升级',
|
operatorId: actor?.sub,
|
||||||
|
operatorName: actor?.name ?? '学年自动升级',
|
||||||
classCount: ids.length,
|
classCount: ids.length,
|
||||||
studentCount: await tx.student.count({ where: { classId: { in: ids }, status: 'active' } }),
|
studentCount: counts.reduce((n, cls) => n + cls.active, 0),
|
||||||
|
transferredCount: counts.reduce((n, cls) => n + cls.transferred, 0),
|
||||||
|
inactiveCount: counts.reduce((n, cls) => n + cls.inactive, 0),
|
||||||
|
homeworkCount: await tx.homework.count({ where: { classId: { in: ids } } }),
|
||||||
|
notificationCount: await tx.notification.count({ where: { classId: { in: ids } } }),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await tx.class.updateMany({
|
await tx.class.updateMany({
|
||||||
where: { id: { in: ids } },
|
where: { id: { in: ids } },
|
||||||
data: { archivedYearId: archived.id, archivedAt: new Date() },
|
data: { archivedYearId: archive.id, archivedAt: new Date() },
|
||||||
});
|
});
|
||||||
await tx.student.updateMany({
|
await tx.student.updateMany({
|
||||||
where: { classId: { in: ids }, status: 'active' },
|
where: { classId: { in: ids }, status: 'active' },
|
||||||
data: { status: 'graduated', archivedYearId: archived.id, archivedAt: new Date() },
|
data: { status: 'graduated' },
|
||||||
|
});
|
||||||
|
await tx.student.updateMany({
|
||||||
|
where: { classId: { in: ids } },
|
||||||
|
data: { archivedYearId: archive.id, archivedAt: new Date() },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (const cls of classes.filter((c) => c.grade !== '高三'))
|
for (const track of fresh.tracks) {
|
||||||
|
for (const cls of track.classes.filter((cls) => !cls.terminal))
|
||||||
await tx.class.update({
|
await tx.class.update({
|
||||||
where: { id: cls.id },
|
where: { id: cls.id },
|
||||||
data: {
|
data: { grade: cls.nextGrade, termStartDate: null },
|
||||||
grade: cls.grade === '高一' ? '高二' : '高三',
|
|
||||||
termStartDate: null,
|
|
||||||
termWeeks: Math.min(cls.termWeeks, maxWeeks),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
for (let i = 1; i <= config.classCount; i++) {
|
for (const cls of track.newClasses)
|
||||||
if (
|
|
||||||
await tx.class.findFirst({ where: { enrollmentYear: year, classIndex: i, archivedYearId: null } })
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
await tx.class.create({
|
await tx.class.create({
|
||||||
data: {
|
data: {
|
||||||
name: formatClassName(year, i),
|
...cls,
|
||||||
enrollmentYear: year,
|
|
||||||
classIndex: i,
|
|
||||||
grade: '高一',
|
|
||||||
teacherId: null,
|
teacherId: null,
|
||||||
code: `N${year}${String(i).padStart(2, '0')}`,
|
|
||||||
passwordHash,
|
passwordHash,
|
||||||
termWeeks: Math.min(20, maxWeeks),
|
termWeeks: Math.min(20, maxWeeks),
|
||||||
|
termStartDate: null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
await tx.termWeek.deleteMany();
|
if (fresh.plan.resetTermWeeks) await tx.termWeek.deleteMany();
|
||||||
await tx.appSetting.upsert({
|
await tx.appSetting.upsert({
|
||||||
where: { key: SETTING_KEYS.termStartDate },
|
where: { key: SETTING_KEYS.termStartDate },
|
||||||
create: { key: SETTING_KEYS.termStartDate, value: `${year}-09-01` },
|
create: { key: SETTING_KEYS.termStartDate, value: fresh.termStartDate },
|
||||||
update: { value: `${year}-09-01` },
|
update: { value: fresh.termStartDate },
|
||||||
});
|
});
|
||||||
await tx.promotionRun.update({
|
await tx.promotionRun.update({
|
||||||
where: { year },
|
where: { year },
|
||||||
data: {
|
data: { status: 'done', message: '已按计划升级、归档并建立新班骨架', finishedAt: new Date() },
|
||||||
status: 'done',
|
|
||||||
message: '已升级、归档并建立无班主任/任课关系的新班骨架',
|
|
||||||
finishedAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ timeout: 30000 },
|
{ timeout: 30000 },
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if ((await prisma.promotionRun.findUnique({ where: { year } }))?.status === 'done')
|
const completed = await prisma.promotionRun.findUnique({ where: { year } });
|
||||||
return prisma.promotionRun.findUniqueOrThrow({ where: { year } });
|
if (completed?.status === 'done') return { ...completed, alreadyDone: true };
|
||||||
await recordPromotionFailure(year, snapshotPath, error, classes.length, studentCount);
|
const failure = {
|
||||||
throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`);
|
|
||||||
}
|
|
||||||
await notifyScheduleChanged();
|
|
||||||
return prisma.promotionRun.findUniqueOrThrow({ where: { year } });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 失败也留可见审计;事务内检查,不能把并发已成功的年份覆盖成失败。 */
|
|
||||||
async function recordPromotionFailure(
|
|
||||||
year: number,
|
|
||||||
snapshotPath: string,
|
|
||||||
error: unknown,
|
|
||||||
classCount: number,
|
|
||||||
studentCount: number,
|
|
||||||
) {
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
const current = await tx.promotionRun.findUnique({ where: { year } });
|
|
||||||
if (current?.status === 'done') return;
|
|
||||||
const data = {
|
|
||||||
status: 'failed',
|
status: 'failed',
|
||||||
snapshotPath,
|
snapshotPath,
|
||||||
message: String(error),
|
snapshotSha256,
|
||||||
classCount,
|
operatorId: actor?.sub,
|
||||||
studentCount,
|
operatorName: actor?.name ?? '学年自动升级',
|
||||||
|
detailsJson: JSON.stringify(preview),
|
||||||
|
error: String(error),
|
||||||
|
message: '升级失败,业务事务未提交',
|
||||||
finishedAt: new Date(),
|
finishedAt: new Date(),
|
||||||
};
|
};
|
||||||
if (current) await tx.promotionRun.update({ where: { year }, data });
|
await prisma.promotionRun.upsert({ where: { year }, create: { year, ...failure }, update: failure });
|
||||||
else await tx.promotionRun.create({ data: { year, ...data } });
|
if (error instanceof ApiError) throw error;
|
||||||
});
|
throw ApiError.internal(`学年升级失败,事务已回滚:${String(error)}`);
|
||||||
|
}
|
||||||
|
for (const track of preview.tracks)
|
||||||
|
for (const cls of track.classes)
|
||||||
|
emitToClass(cls.id, SOCKET_EVENTS.classUpdated, { classId: cls.id, action: 'updated' });
|
||||||
|
await notifyScheduleChanged();
|
||||||
|
return { ...(await prisma.promotionRun.findUniqueOrThrow({ where: { year } })), alreadyDone: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
export async function startPromotionScheduler() {
|
export async function startPromotionScheduler() {
|
||||||
if (timer) return;
|
if (timer) return;
|
||||||
let observed = new Date();
|
let observed = new Date();
|
||||||
if ((await promotionStatus(observed)).pending)
|
if ((await promotionStatus(observed)).pending) logger.warn('有遗漏的学年升级,请在学年升级页预览后补跑');
|
||||||
logger.warn('有遗漏的学年升级,请管理员在学期周次页查看预览与快照说明后补跑');
|
|
||||||
timer = setInterval(() => {
|
timer = setInterval(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const { config } = await promotionStatus(now);
|
const { config } = await loadConfig();
|
||||||
const due = new Date(
|
if (dueYear(config, now) > dueYear(config, observed)) await runPromotion(now.getFullYear());
|
||||||
now.getFullYear(),
|
|
||||||
config.month - 1,
|
|
||||||
Math.min(config.day, new Date(now.getFullYear(), config.month, 0).getDate()),
|
|
||||||
config.hour,
|
|
||||||
);
|
|
||||||
if (observed < due && now >= due) await runPromotion(now.getFullYear());
|
|
||||||
observed = now;
|
observed = now;
|
||||||
})().catch((e) => logger.error(`自动升级失败:${String(e)}`));
|
})().catch((error) => logger.error(`自动升级失败:${String(error)}`));
|
||||||
}, 30_000);
|
}, 30000);
|
||||||
timer.unref();
|
timer.unref();
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
|
import { assertGlobalPermission } from '../../lib/access.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import {
|
import {
|
||||||
promotionStatus,
|
promotionStatus,
|
||||||
|
previewPromotion,
|
||||||
promotionConfigSchema,
|
promotionConfigSchema,
|
||||||
configurePromotion,
|
configurePromotion,
|
||||||
runPromotion,
|
runPromotion,
|
||||||
} from './promotion.service.js';
|
} from './promotion.service.js';
|
||||||
import { Router } from 'express';
|
import { Router } from 'express';
|
||||||
import { sendOk } from '../../lib/http.js';
|
import { ApiError, sendOk } from '../../lib/http.js';
|
||||||
import { authenticate, getAuthUser, requirePermission } from '../../middleware/auth.js';
|
import { authenticate, getAuthUser, requirePermission } 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';
|
||||||
@@ -22,22 +24,47 @@ import * as termService from './term.service.js';
|
|||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
router.use(authenticate());
|
router.use(authenticate());
|
||||||
router.get('/promotion', requirePermission('system:manage'), async (_req, res) =>
|
router.use('/promotion', async (req, _res, next) => {
|
||||||
|
await assertGlobalPermission(getAuthUser(req), 'promotion:manage');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
router.get('/promotion', requirePermission('promotion:manage'), async (_req, res) =>
|
||||||
sendOk(res, await promotionStatus()),
|
sendOk(res, await promotionStatus()),
|
||||||
);
|
);
|
||||||
router.put(
|
router.put(
|
||||||
'/promotion',
|
'/promotion',
|
||||||
requirePermission('system:manage'),
|
requirePermission('promotion:manage'),
|
||||||
validate({ body: promotionConfigSchema }),
|
validate({ body: promotionConfigSchema }),
|
||||||
async (req, res) => sendOk(res, await configurePromotion(req.body)),
|
async (req, res) => sendOk(res, await configurePromotion(req.body, getAuthUser(req))),
|
||||||
|
);
|
||||||
|
router.post(
|
||||||
|
'/promotion/preview',
|
||||||
|
requirePermission('promotion:manage'),
|
||||||
|
validate({ body: z.object({ year: z.number().int().min(2000).max(2100) }) }),
|
||||||
|
async (req, res) => sendOk(res, await previewPromotion(req.body.year)),
|
||||||
);
|
);
|
||||||
router.post(
|
router.post(
|
||||||
'/promotion/run',
|
'/promotion/run',
|
||||||
requirePermission('system:manage'),
|
requirePermission('promotion:manage'),
|
||||||
|
(req, _res, next) => {
|
||||||
|
if (req.body && 'dryRun' in req.body)
|
||||||
|
return next(
|
||||||
|
ApiError.badRequest('请更新页面:预览使用 /term/promotion/preview,执行需提供计划哈希并确认学年'),
|
||||||
|
);
|
||||||
|
next();
|
||||||
|
},
|
||||||
validate({
|
validate({
|
||||||
body: z.object({ year: z.number().int().min(2000).max(2100), dryRun: z.boolean().default(true) }),
|
body: z
|
||||||
|
.object({
|
||||||
|
year: z.number().int().min(2000).max(2100),
|
||||||
|
planHash: z.string().length(64),
|
||||||
|
confirm: z.literal(true),
|
||||||
|
catchUp: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.strict(),
|
||||||
}),
|
}),
|
||||||
async (req, res) => sendOk(res, await runPromotion(req.body.year, req.body.dryRun)),
|
async (req, res) =>
|
||||||
|
sendOk(res, await runPromotion(req.body.year, { ...req.body, actor: getAuthUser(req) })),
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ import {
|
|||||||
type HolidaySuggestionDto,
|
type HolidaySuggestionDto,
|
||||||
type TermWeeksDto,
|
type TermWeeksDto,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { assertCanManageClasses, assertClassAccess } from '../../lib/access.js';
|
import { assertClassAccess } from '../../lib/access.js';
|
||||||
|
import { assertPermission } from '../../lib/permissions.js';
|
||||||
import { env } from '../../config/env.js';
|
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 { ApiError } from '../../lib/http.js';
|
||||||
@@ -17,7 +18,7 @@ import { SOCKET_EVENTS } from '@classhelper/shared';
|
|||||||
import type { AutoTermWeeksInput, UpdateTermWeeksInput } from './term.schemas.js';
|
import type { AutoTermWeeksInput, UpdateTermWeeksInput } from './term.schemas.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 学期周次(仅管理员可写)。
|
* 学期周次(具有 term:write 的员工可写)。
|
||||||
*
|
*
|
||||||
* 为什么需要逐周配置:`TERM_START_DATE + 每周七天`的线性推算遇到法定节假日调休、
|
* 为什么需要逐周配置:`TERM_START_DATE + 每周七天`的线性推算遇到法定节假日调休、
|
||||||
* 周末补课、错峰开学就会**整体错位**——而课表、作业、成绩都按周次组织,错一周就全错。
|
* 周末补课、错峰开学就会**整体错位**——而课表、作业、成绩都按周次组织,错一周就全错。
|
||||||
@@ -65,7 +66,7 @@ export async function getTermWeeks(user: TokenPayload, classId?: string | null):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存逐周配置(整表覆盖)。仅管理员。
|
* 保存逐周配置(整表覆盖)。需要 term:write。
|
||||||
*
|
*
|
||||||
* 校验:日期格式、起止顺序、**区间不重叠**(重叠会让"当前第几周"出现歧义)。
|
* 校验:日期格式、起止顺序、**区间不重叠**(重叠会让"当前第几周"出现歧义)。
|
||||||
* 保存后把新的周次口径下发给该班所有在线设备。
|
* 保存后把新的周次口径下发给该班所有在线设备。
|
||||||
@@ -74,7 +75,7 @@ export async function updateTermWeeks(
|
|||||||
user: TokenPayload,
|
user: TokenPayload,
|
||||||
input: UpdateTermWeeksInput,
|
input: UpdateTermWeeksInput,
|
||||||
): Promise<TermWeeksDto> {
|
): Promise<TermWeeksDto> {
|
||||||
assertCanManageClasses(user);
|
await assertPermission(user, 'term:write');
|
||||||
const scope = scopeOf(input.classId);
|
const scope = scopeOf(input.classId);
|
||||||
|
|
||||||
const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks());
|
const maximum = input.maxTermWeeks ?? (await getMaxTermWeeks());
|
||||||
@@ -135,13 +136,13 @@ export async function updateTermWeeks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 按学期开始日期**一键生成**逐周区间(管理员再逐周微调)。仅管理员。
|
* 按学期开始日期**一键生成**逐周区间(管理员再逐周微调)。需要 term:write。
|
||||||
*
|
*
|
||||||
* 这是配置的起点:先生成一份"标准学期",再把调休那几周改掉,
|
* 这是配置的起点:先生成一份"标准学期",再把调休那几周改掉,
|
||||||
* 比从空白开始一周周填要快得多。
|
* 比从空白开始一周周填要快得多。
|
||||||
*/
|
*/
|
||||||
export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> {
|
export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> {
|
||||||
assertCanManageClasses(user);
|
await assertPermission(user, 'term:write');
|
||||||
const scope = scopeOf(input.classId);
|
const scope = scopeOf(input.classId);
|
||||||
const context = await loadTermContext(scope || null);
|
const context = await loadTermContext(scope || null);
|
||||||
const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate;
|
const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/shared",
|
"name": "@classhelper/shared",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -10,3 +10,4 @@ export * from './class-plan.js';
|
|||||||
export * from './island-layout.js';
|
export * from './island-layout.js';
|
||||||
export type * from './types.js';
|
export type * from './types.js';
|
||||||
export * from './courseware.js';
|
export * from './courseware.js';
|
||||||
|
export * from './lifecycle.js';
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import type { PermissionScope } from './class-plan.js';
|
||||||
|
|
||||||
|
/** 范围解析供保存、查询与展示共用;旧库空岗位范围会由调用方拒绝访问。 */
|
||||||
|
export function parsePermissionScope(scope: PermissionScope, value = '', knownGrades?: readonly string[]) {
|
||||||
|
if (scope === 'ALL') return { scope, scopeValue: '', grades: [], scopeLabel: '全校' };
|
||||||
|
if (scope === 'SELF_CLASS') {
|
||||||
|
if (value !== '' && value !== 'HEAD') throw new Error('关联班级范围只允许全部关联班级或仅班主任班');
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
scopeValue: value,
|
||||||
|
grades: [],
|
||||||
|
scopeLabel: value === 'HEAD' ? '仅班主任班' : '班主任与任课班级',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (scope !== 'GRADE' && scope !== 'DIVISION') throw new Error('未知的数据范围');
|
||||||
|
const grades = [...new Set(value.split(/[,,、\s]+/u).filter(Boolean))].sort();
|
||||||
|
if (!grades.length) throw new Error('请选择范围中的年级');
|
||||||
|
if (scope === 'GRADE' && grades.length !== 1) throw new Error('年级范围只能选择一个年级');
|
||||||
|
if (knownGrades && grades.some((grade) => !knownGrades.includes(grade)))
|
||||||
|
throw new Error('范围包含未知年级,请刷新选项');
|
||||||
|
return {
|
||||||
|
scope,
|
||||||
|
scopeValue: grades.join(','),
|
||||||
|
grades,
|
||||||
|
scopeLabel: `${scope === 'GRADE' ? '年级' : '部'}:${grades.join('、')}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PromotionPlan {
|
||||||
|
tracks: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
entryClassCount: number;
|
||||||
|
stages: { grade: string; terminal?: boolean }[];
|
||||||
|
}[];
|
||||||
|
termStartDate: string | null;
|
||||||
|
resetTermWeeks: boolean;
|
||||||
|
}
|
||||||
|
export function defaultPromotionPlan(classCount = 1): PromotionPlan {
|
||||||
|
return {
|
||||||
|
tracks: [
|
||||||
|
{
|
||||||
|
id: 'high',
|
||||||
|
name: '高中',
|
||||||
|
entryClassCount: classCount,
|
||||||
|
stages: [{ grade: '高一' }, { grade: '高二' }, { grade: '高三', terminal: true }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
termStartDate: null,
|
||||||
|
resetTermWeeks: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -200,6 +200,7 @@ export const PERMISSION_KEYS = [
|
|||||||
// 学期与归档
|
// 学期与归档
|
||||||
'term:read',
|
'term:read',
|
||||||
'term:write',
|
'term:write',
|
||||||
|
'promotion:manage',
|
||||||
'archive:manage',
|
'archive:manage',
|
||||||
'dashboard:read',
|
'dashboard:read',
|
||||||
// 系统与安全
|
// 系统与安全
|
||||||
@@ -258,7 +259,7 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
|||||||
'class:read': '查看班级与班级列表',
|
'class:read': '查看班级与班级列表',
|
||||||
'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)',
|
'class:manage': '创建 / 修改 / 删除班级(含班级账号密码)',
|
||||||
'roster:read': '查看学生名单',
|
'roster:read': '查看学生名单',
|
||||||
'roster:manage': '学生名单管理(增删 / 调班 / 导入)',
|
'roster:manage': '学生名单管理(资料 / 调班 / 转入转出 / 停用)',
|
||||||
'teacher:read': '查看教师名单',
|
'teacher:read': '查看教师名单',
|
||||||
'teacher:manage': '教师账号管理(新建 / 导入 / 重置密码 / 删除)',
|
'teacher:manage': '教师账号管理(新建 / 导入 / 重置密码 / 删除)',
|
||||||
'schedule:read': '查看课表',
|
'schedule:read': '查看课表',
|
||||||
@@ -285,6 +286,7 @@ export const PERMISSION_LABELS: Record<PermissionKey, string> = {
|
|||||||
'remote:control': '远控客户端电脑(关机 / 重启 / 注销 / 锁屏)',
|
'remote:control': '远控客户端电脑(关机 / 重启 / 注销 / 锁屏)',
|
||||||
'term:read': '查看学期周次',
|
'term:read': '查看学期周次',
|
||||||
'term:write': '修改学期周次与开学日期',
|
'term:write': '修改学期周次与开学日期',
|
||||||
|
'promotion:manage': '配置、预览与执行学年升级',
|
||||||
'archive:manage': '毕业归档',
|
'archive:manage': '毕业归档',
|
||||||
'dashboard:read': '查看统计看板',
|
'dashboard:read': '查看统计看板',
|
||||||
'database:manage': '数据库快照 / 导出 / 切库',
|
'database:manage': '数据库快照 / 导出 / 切库',
|
||||||
@@ -342,7 +344,10 @@ export const PERMISSION_CATEGORIES: readonly { title: string; keys: readonly Per
|
|||||||
title: '客户端设备',
|
title: '客户端设备',
|
||||||
keys: ['client:read', 'client:manage', 'client:update', 'client:update-push', 'remote:control'],
|
keys: ['client:read', 'client:manage', 'client:update', 'client:update-push', 'remote:control'],
|
||||||
},
|
},
|
||||||
{ title: '学期与统计', keys: ['term:read', 'term:write', 'archive:manage', 'dashboard:read'] },
|
{
|
||||||
|
title: '学期与统计',
|
||||||
|
keys: ['term:read', 'term:write', 'promotion:manage', 'archive:manage', 'dashboard:read'],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: '系统与安全',
|
title: '系统与安全',
|
||||||
keys: [
|
keys: [
|
||||||
@@ -399,6 +404,7 @@ export const PERMISSION_DEFAULT_ROLES: Record<PermissionKey, readonly UserRole[]
|
|||||||
'remote:control': ['ADMIN'],
|
'remote:control': ['ADMIN'],
|
||||||
'term:read': ['ADMIN', 'TEACHER'],
|
'term:read': ['ADMIN', 'TEACHER'],
|
||||||
'term:write': ['ADMIN'],
|
'term:write': ['ADMIN'],
|
||||||
|
'promotion:manage': ['ADMIN'],
|
||||||
'archive:manage': ['ADMIN'],
|
'archive:manage': ['ADMIN'],
|
||||||
'dashboard:read': ['ADMIN', 'TEACHER'],
|
'dashboard:read': ['ADMIN', 'TEACHER'],
|
||||||
'database:manage': ['ADMIN'],
|
'database:manage': ['ADMIN'],
|
||||||
@@ -454,6 +460,7 @@ export const PERMISSION_MATRIX: readonly {
|
|||||||
subject: boolean;
|
subject: boolean;
|
||||||
classDevice?: boolean;
|
classDevice?: boolean;
|
||||||
}[] = [
|
}[] = [
|
||||||
|
{ action: '学年升级(默认管理员,可按权限组授权)', admin: true, head: false, subject: false },
|
||||||
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
|
{ action: '班级创建 / 修改 / 删除', admin: true, head: false, subject: false },
|
||||||
{ action: '分配班主任与各科科任老师', admin: true, head: false, subject: false },
|
{ action: '分配班主任与各科科任老师', admin: true, head: false, subject: false },
|
||||||
{ action: '学生名单管理(增删 / 导入 / 调班)', admin: true, head: true, subject: false },
|
{ action: '学生名单管理(增删 / 导入 / 调班)', admin: true, head: true, subject: false },
|
||||||
|
|||||||
@@ -186,16 +186,14 @@ export interface CreateStudentRequest {
|
|||||||
classId?: string | null;
|
classId?: string | null;
|
||||||
gender?: StudentGender;
|
gender?: StudentGender;
|
||||||
guardianPhone?: string;
|
guardianPhone?: string;
|
||||||
status?: StudentStatus;
|
status?: 'active' | 'inactive';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateStudentRequest {
|
export interface UpdateStudentRequest {
|
||||||
studentNo?: string;
|
studentNo?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
classId?: string | null;
|
|
||||||
gender?: StudentGender;
|
gender?: StudentGender;
|
||||||
guardianPhone?: string;
|
guardianPhone?: string;
|
||||||
status?: StudentStatus;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 学生是否属于某个班的判定范围(列表查询用) */
|
/** 学生是否属于某个班的判定范围(列表查询用) */
|
||||||
@@ -207,10 +205,19 @@ export interface StudentQueryParams {
|
|||||||
/* ------------------------------------------------------------------ 调班 */
|
/* ------------------------------------------------------------------ 调班 */
|
||||||
|
|
||||||
/** 调班 / 转出的方式 */
|
/** 调班 / 转出的方式 */
|
||||||
export type StudentTransferMode = 'single' | 'batch' | 'transfer-out';
|
export type StudentTransferMode = 'single' | 'batch' | 'transfer-out' | 'restore-transfer';
|
||||||
|
export type StudentLifecycleKind =
|
||||||
|
'class-change' | 'transfer-in' | 'transfer-out' | 'restore' | 'unassign' | 'deactivate';
|
||||||
|
|
||||||
/** 一次调班 / 转出的历史记录(原班级 / 新班级 / 操作人 / 时间) */
|
/** 一次调班 / 转出的历史记录(原班级 / 新班级 / 操作人 / 时间) */
|
||||||
export interface StudentTransferDto {
|
export interface StudentTransferDto {
|
||||||
|
kind: StudentLifecycleKind;
|
||||||
|
batchId: string;
|
||||||
|
academicYear: number | null;
|
||||||
|
fromGrade: string;
|
||||||
|
toGrade: string;
|
||||||
|
fromSchool: string;
|
||||||
|
toSchool: string;
|
||||||
id: string;
|
id: string;
|
||||||
studentId: string;
|
studentId: string;
|
||||||
/** 学号快照:学生被删除后历史仍可读 */
|
/** 学号快照:学生被删除后历史仍可读 */
|
||||||
@@ -234,6 +241,7 @@ export interface StudentTransferDto {
|
|||||||
* **学号不变**,只改学生当前的班级归属;历史作业与成绩保留原归属。
|
* **学号不变**,只改学生当前的班级归属;历史作业与成绩保留原归属。
|
||||||
*/
|
*/
|
||||||
export interface TransferStudentsRequest {
|
export interface TransferStudentsRequest {
|
||||||
|
allowGradeChange?: boolean;
|
||||||
studentIds: string[];
|
studentIds: string[];
|
||||||
toClassId: string | null;
|
toClassId: string | null;
|
||||||
note?: string;
|
note?: string;
|
||||||
@@ -244,6 +252,7 @@ export interface TransferStudentsRequest {
|
|||||||
* 单个与批量共用 —— 传一个 id 就是单个。
|
* 单个与批量共用 —— 传一个 id 就是单个。
|
||||||
*/
|
*/
|
||||||
export interface TransferOutStudentsRequest {
|
export interface TransferOutStudentsRequest {
|
||||||
|
toSchool?: string;
|
||||||
studentIds: string[];
|
studentIds: string[];
|
||||||
/** 转出去向 / 原因备注 */
|
/** 转出去向 / 原因备注 */
|
||||||
note?: string;
|
note?: string;
|
||||||
@@ -272,6 +281,7 @@ export interface ArchivedYearDto {
|
|||||||
classCount: number;
|
classCount: number;
|
||||||
studentCount: number;
|
studentCount: number;
|
||||||
transferredCount: number;
|
transferredCount: number;
|
||||||
|
inactiveCount: number;
|
||||||
homeworkCount: number;
|
homeworkCount: number;
|
||||||
notificationCount: number;
|
notificationCount: number;
|
||||||
archivedAt: string;
|
archivedAt: string;
|
||||||
@@ -292,11 +302,14 @@ export interface ArchivedClassDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ArchivedYearDetailDto extends ArchivedYearDto {
|
export interface ArchivedYearDetailDto extends ArchivedYearDto {
|
||||||
|
/** 本届保留名单中有转入记录的学生数,按学生去重 */
|
||||||
|
transferInCount: number;
|
||||||
classes: ArchivedClassDto[];
|
classes: ArchivedClassDto[];
|
||||||
/** 本届毕业生 */
|
/** 本届毕业生 */
|
||||||
graduates: StudentDto[];
|
graduates: StudentDto[];
|
||||||
/** 本届在读期间转出的学生 */
|
/** 本届在读期间转出的学生 */
|
||||||
transferred: StudentDto[];
|
transferred: StudentDto[];
|
||||||
|
inactive: StudentDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 归档班级的作业与通知(只读) */
|
/** 归档班级的作业与通知(只读) */
|
||||||
@@ -488,8 +501,6 @@ export interface UpdateClassRequest {
|
|||||||
enrollmentYear?: number;
|
enrollmentYear?: number;
|
||||||
/** 班号(同上) */
|
/** 班号(同上) */
|
||||||
classIndex?: number;
|
classIndex?: number;
|
||||||
/** 当前年级(高一 / 高二 / 高三;学年升级时改它,**不归档**) */
|
|
||||||
grade?: string;
|
|
||||||
/** 本学期教学周数(1~30,仅管理员可调) */
|
/** 本学期教学周数(1~30,仅管理员可调) */
|
||||||
termWeeks?: number;
|
termWeeks?: number;
|
||||||
}
|
}
|
||||||
@@ -1456,6 +1467,7 @@ export interface UpdateInfo {
|
|||||||
export interface PermissionGroupDto {
|
export interface PermissionGroupDto {
|
||||||
scope?: ContractPermissionScope;
|
scope?: ContractPermissionScope;
|
||||||
scopeValue?: string;
|
scopeValue?: string;
|
||||||
|
scopeLabel: string;
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
description: string;
|
description: string;
|
||||||
@@ -1476,10 +1488,10 @@ export interface PermissionMemberDto {
|
|||||||
role: UserRole;
|
role: UserRole;
|
||||||
/** 所属权限组;空数组 = 走角色默认模板 */
|
/** 所属权限组;空数组 = 走角色默认模板 */
|
||||||
groupIds: string[];
|
groupIds: string[];
|
||||||
/** 生效权限数量(ADMIN 恒为全集;班级端账号恒为 0,见权限页的说明) */
|
/** 生效权限数量;管理员在分组并集之外始终保留两个管理入口 */
|
||||||
permissionCount: number;
|
permissionCount: number;
|
||||||
/** 该账号固定拥有全部权限,不接受分配(管理员) */
|
permissions: PermissionKey[];
|
||||||
fixed: boolean;
|
scopeLabels: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */
|
/** `GET /api/permissions/catalog`:权限键目录(与 shared 的常量一一对应,供权限页渲染) */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@classhelper/web-admin",
|
"name": "@classhelper/web-admin",
|
||||||
"version": "1.4.1",
|
"version": "1.4.2",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
type ClientPushResult,
|
type ClientPushResult,
|
||||||
type ClientReleaseDto,
|
type ClientReleaseDto,
|
||||||
type CourseDto,
|
type CourseDto,
|
||||||
type CreateArchiveRequest,
|
|
||||||
type CreateCallRequest,
|
type CreateCallRequest,
|
||||||
type CreateClassRequest,
|
type CreateClassRequest,
|
||||||
type CreateClientCommandRequest,
|
type CreateClientCommandRequest,
|
||||||
@@ -131,8 +130,8 @@ export const classApi = {
|
|||||||
setGradeQuery: (id: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
setGradeQuery: (id: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
||||||
api.patch(`${API_PATHS.classes}/${id}/student-grade-query`, { enabled }),
|
api.patch(`${API_PATHS.classes}/${id}/student-grade-query`, { enabled }),
|
||||||
/** 学年升级:只改年级,不归档、数据沿用 */
|
/** 学年升级:只改年级,不归档、数据沿用 */
|
||||||
promote: (classIds: string[], grade: string): Promise<{ promoted: number }> =>
|
promote: (classIds: string[]): Promise<{ promoted: number }> =>
|
||||||
api.post(`${API_PATHS.classes}/promote`, { classIds, grade }),
|
api.post(`${API_PATHS.classes}/promote`, { classIds }),
|
||||||
};
|
};
|
||||||
|
|
||||||
/* ------------------------------------------------------------------ 课程 */
|
/* ------------------------------------------------------------------ 课程 */
|
||||||
@@ -317,11 +316,16 @@ export const studentApi = {
|
|||||||
api.patch(`${API_PATHS.students}/${id}`, payload),
|
api.patch(`${API_PATHS.students}/${id}`, payload),
|
||||||
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.students}/${id}`),
|
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.students}/${id}`),
|
||||||
/** 调班:单个与批量同一接口(studentIds 长度 1 即单个);学号不变、历史可查 */
|
/** 调班:单个与批量同一接口(studentIds 长度 1 即单个);学号不变、历史可查 */
|
||||||
transfer: (studentIds: string[], toClassId: string | null, note?: string): Promise<{ moved: number }> =>
|
transfer: (
|
||||||
api.post(`${API_PATHS.students}/transfer`, { studentIds, toClassId, note }),
|
studentIds: string[],
|
||||||
|
toClassId: string | null,
|
||||||
|
note?: string,
|
||||||
|
allowGradeChange?: boolean,
|
||||||
|
): Promise<{ moved: number }> =>
|
||||||
|
api.post(`${API_PATHS.students}/transfer`, { studentIds, toClassId, note, allowGradeChange }),
|
||||||
/** 转出(学籍离开本校,不是调班) */
|
/** 转出(学籍离开本校,不是调班) */
|
||||||
transferOut: (studentIds: string[], note?: string): Promise<{ transferred: number }> =>
|
transferOut: (studentIds: string[], note?: string, toSchool?: string): Promise<{ transferred: number }> =>
|
||||||
api.post(`${API_PATHS.students}/transfer-out`, { studentIds, note }),
|
api.post(`${API_PATHS.students}/transfer-out`, { studentIds, note, toSchool }),
|
||||||
/** 某个学生的调班 / 转出历史 */
|
/** 某个学生的调班 / 转出历史 */
|
||||||
transfersOf: (id: string): Promise<PaginatedResult<StudentTransferDto>> =>
|
transfersOf: (id: string): Promise<PaginatedResult<StudentTransferDto>> =>
|
||||||
api.get(`${API_PATHS.students}/${id}/transfers`),
|
api.get(`${API_PATHS.students}/${id}/transfers`),
|
||||||
@@ -380,8 +384,6 @@ export const archiveApi = {
|
|||||||
/** 归档班级的作业与通知(只读) */
|
/** 归档班级的作业与通知(只读) */
|
||||||
classContent: (id: string, classId: string, limit = 200): Promise<ArchivedClassContentDto> =>
|
classContent: (id: string, classId: string, limit = 200): Promise<ArchivedClassContentDto> =>
|
||||||
api.get(`${API_PATHS.archives}/${id}/classes/${classId}/content`, { limit }),
|
api.get(`${API_PATHS.archives}/${id}/classes/${classId}/content`, { limit }),
|
||||||
create: (payload: CreateArchiveRequest): Promise<ArchivedYearDetailDto> =>
|
|
||||||
api.post(API_PATHS.archives, payload),
|
|
||||||
/** 撤销归档(误操作的退路,不删任何数据) */
|
/** 撤销归档(误操作的退路,不删任何数据) */
|
||||||
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.archives}/${id}`),
|
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.archives}/${id}`),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
// 档案编辑器采用与参考一致的细线图标,不依赖系统字体图标。
|
||||||
|
defineProps<{ name: string }>();
|
||||||
|
const paths: Record<string, string> = {
|
||||||
|
archive: 'M3 4h12v7H3z M6 11v8h15v-8H6 M10 14h4 M15 4h6v7',
|
||||||
|
file: 'M6 3h8l4 4v14H6z M14 3v5h4 M9 12h6 M9 15h6 M9 18h4',
|
||||||
|
chart: 'M6 3h8l4 4v14H6z M14 3v5h4 M9 17v-4 M12 17v-7 M15 17v-5',
|
||||||
|
group: 'M8 7H5V4 M16 7h3V4 M8 17H5v3 M16 17h3v3 M9 8h6v8H9z M3 11v2 M21 11v2',
|
||||||
|
save: 'M4 3h13l3 3v15H4z M8 3v6h8V3 M8 21v-8h9v8',
|
||||||
|
transfer: 'M13 3h5l3 3v12h-7 M18 3v5h3 M4 16h8 M8 12l4 4-4 4 M4 5h5v5H4z',
|
||||||
|
help: 'M9 7a3 3 0 1 1 5 2c-2 1-2 2-2 4 M12 17v.2',
|
||||||
|
refresh: 'M20 7v5h-5 M20 12a8 8 0 1 0-2 5 M20 7l-3-3',
|
||||||
|
left: 'M20 12H4 M10 6l-6 6 6 6',
|
||||||
|
right: 'M4 12h16 M14 6l6 6-6 6',
|
||||||
|
plus: 'M12 4v16 M4 12h16',
|
||||||
|
clock: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18 M12 7v5l4 2',
|
||||||
|
grid: 'M4 4h16v16H4z M4 9h16 M4 14h16 M9 4v16 M15 4v16',
|
||||||
|
calendar: 'M4 5h16v16H4z M4 10h16 M8 3v4 M16 3v4 M8 14h1 M12 14h1 M16 14h1 M8 17h1 M12 17h1',
|
||||||
|
list: 'M8 5h13 M8 12h13 M8 19h13 M3 4h1v2H3z M3 11h1v2H3z M3 18h1v2H3z',
|
||||||
|
settings:
|
||||||
|
'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8 M10 3h4l1 3 3 1 3 3v4l-3 1-1 3-3 3h-4l-1-3-3-1-3-3v-4l3-1 1-3z',
|
||||||
|
close: 'M5 5l14 14 M19 5L5 19',
|
||||||
|
shield: 'M12 3l8 3v6c0 5-8 9-8 9s-8-4-8-9V6z M8 12l3 3 5-6',
|
||||||
|
back: 'M11 5l-7 7 7 7 M4 12h13a3 3 0 0 1 3 3v3',
|
||||||
|
chevron: 'M6 9l6 6 6-6',
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="archive-icon"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path :d="paths[name] || paths.file" />
|
||||||
|
</svg>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.archive-icon {
|
||||||
|
display: inline-block;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,77 +1,297 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
|
import { type PromotionPlan } from '@classhelper/shared';
|
||||||
import { api } from '@/api/http';
|
import { api } from '@/api/http';
|
||||||
type Status = {
|
type Run = {
|
||||||
config: { month: number; day: number; hour: number; classCount: number };
|
|
||||||
year: number;
|
|
||||||
pending: boolean;
|
|
||||||
runs: {
|
|
||||||
year: number;
|
year: number;
|
||||||
status: string;
|
status: string;
|
||||||
message: string;
|
message: string;
|
||||||
classCount: number;
|
classCount: number;
|
||||||
studentCount: number;
|
studentCount: number;
|
||||||
snapshotPath: string;
|
snapshotPath: string;
|
||||||
|
snapshotSha256: string;
|
||||||
|
operatorName: string;
|
||||||
|
detailsJson: string;
|
||||||
|
error: string;
|
||||||
|
};
|
||||||
|
type Preview = {
|
||||||
|
year: number;
|
||||||
|
planHash: string;
|
||||||
|
classCount: number;
|
||||||
|
studentCount: number;
|
||||||
|
blockers: { code: string; message: string }[];
|
||||||
|
warnings: string[];
|
||||||
|
termWeekDeleteCount: number;
|
||||||
|
termStartDate: string;
|
||||||
|
tracks: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
classes: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
grade: string;
|
||||||
|
nextGrade: string;
|
||||||
|
active: number;
|
||||||
|
inactive: number;
|
||||||
|
transferred: number;
|
||||||
|
}[];
|
||||||
|
newClasses: { name: string; grade: string; code: string }[];
|
||||||
}[];
|
}[];
|
||||||
};
|
};
|
||||||
|
type Status = {
|
||||||
|
config: { month: number; day: number; hour: number; classCount: number };
|
||||||
|
plan: PromotionPlan;
|
||||||
|
year: number;
|
||||||
|
pending: boolean;
|
||||||
|
runs: Run[];
|
||||||
|
};
|
||||||
const status = ref<Status>();
|
const status = ref<Status>();
|
||||||
|
const preview = ref<Preview>();
|
||||||
|
const year = ref(2026);
|
||||||
|
const visible = ref(false),
|
||||||
|
saving = ref(false),
|
||||||
|
running = ref(false),
|
||||||
|
loading = ref(false);
|
||||||
async function load() {
|
async function load() {
|
||||||
status.value = await api.get('/term/promotion');
|
status.value = await api.get('/term/promotion');
|
||||||
|
year.value = status.value!.year;
|
||||||
|
}
|
||||||
|
function normalizeStages() {
|
||||||
|
for (const track of status.value?.plan.tracks ?? [])
|
||||||
|
track.stages.forEach((stage, i) => {
|
||||||
|
stage.terminal = i === track.stages.length - 1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function addTrack() {
|
||||||
|
status.value?.plan.tracks.push({
|
||||||
|
id: `track-${Date.now().toString(36)}`,
|
||||||
|
name: '新轨道',
|
||||||
|
entryClassCount: 0,
|
||||||
|
stages: [{ grade: '', terminal: true }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function moveStage(track: PromotionPlan['tracks'][number], index: number, direction: number) {
|
||||||
|
const next = index + direction;
|
||||||
|
if (next < 0 || next >= track.stages.length) return;
|
||||||
|
[track.stages[index], track.stages[next]] = [track.stages[next], track.stages[index]];
|
||||||
|
normalizeStages();
|
||||||
}
|
}
|
||||||
async function save() {
|
async function save() {
|
||||||
if (status.value) await api.put('/term/promotion', status.value.config);
|
|
||||||
await load();
|
|
||||||
}
|
|
||||||
async function catchUp() {
|
|
||||||
if (!status.value) return;
|
if (!status.value) return;
|
||||||
const preview = await api.post<{ classCount: number; studentCount: number }>('/term/promotion/run', {
|
normalizeStages();
|
||||||
year: status.value.year,
|
saving.value = true;
|
||||||
dryRun: true,
|
try {
|
||||||
});
|
await api.put('/term/promotion', { ...status.value.config, plan: status.value.plan });
|
||||||
await ElMessageBox.confirm(
|
preview.value = undefined;
|
||||||
`将影响 ${preview.classCount} 个班、${preview.studentCount} 名学生。执行前自动保存全库快照;高三归档,其余升一级,新建高一骨架。`,
|
|
||||||
'补跑学年升级',
|
|
||||||
{ type: 'warning' },
|
|
||||||
);
|
|
||||||
await api.post('/term/promotion/run', { year: status.value.year, dryRun: false });
|
|
||||||
await load();
|
await load();
|
||||||
|
ElMessage.success('升级计划已保存');
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function showPreview() {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
preview.value = await api.post('/term/promotion/preview', { year: year.value });
|
||||||
|
visible.value = true;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function execute() {
|
||||||
|
if (!preview.value || running.value) return;
|
||||||
|
const { year: target, planHash } = preview.value;
|
||||||
|
await ElMessageBox.prompt(
|
||||||
|
`请输入 ${target} 确认执行。快照可供手工恢复;撤销归档不会回滚年级、新班或周次。`,
|
||||||
|
'确认学年升级',
|
||||||
|
{
|
||||||
|
inputValidator: (value) => value === String(target) || '输入必须与预览学年一致',
|
||||||
|
confirmButtonText: '执行升级',
|
||||||
|
type: 'warning',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
running.value = true;
|
||||||
|
try {
|
||||||
|
await api.post('/term/promotion/run', { year: target, planHash, confirm: true, catchUp: true });
|
||||||
|
visible.value = false;
|
||||||
|
await load();
|
||||||
|
ElMessage.success('学年升级完成,快照与执行明细已保留');
|
||||||
|
} finally {
|
||||||
|
running.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<el-card v-if="status">
|
<el-card v-if="status" shadow="never">
|
||||||
<h3>每年自动学年升级</h3>
|
<h3>学年升级计划</h3>
|
||||||
<el-form inline>
|
<el-alert
|
||||||
<el-form-item label="月">
|
title="先保存计划,再预览影响。执行前保存全库快照;恢复整学年需要管理员手工恢复快照。"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
<el-form label-position="top" :disabled="saving || running">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-form-item label="执行月份">
|
||||||
<el-input-number v-model="status.config.month" :min="1" :max="12" />
|
<el-input-number v-model="status.config.month" :min="1" :max="12" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="日">
|
<el-form-item label="执行日期">
|
||||||
<el-input-number v-model="status.config.day" :min="1" :max="31" />
|
<el-input-number v-model="status.config.day" :min="1" :max="31" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="时">
|
<el-form-item label="执行小时">
|
||||||
<el-input-number v-model="status.config.hour" :min="0" :max="23" />
|
<el-input-number v-model="status.config.hour" :min="0" :max="23" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="新高一班级数">
|
<el-form-item label="新学期开学日(空则当年 9 月 1 日)">
|
||||||
<el-input-number v-model="status.config.classCount" :min="1" :max="99" />
|
<el-date-picker
|
||||||
|
v-model="status.plan.termStartDate"
|
||||||
|
type="date"
|
||||||
|
value-format="YYYY-MM-DD"
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-button @click="save">保存升级时间</el-button>
|
<el-form-item label="周次区间处理">
|
||||||
|
<el-switch
|
||||||
|
v-model="status.plan.resetTermWeeks"
|
||||||
|
active-text="重置全校及班级周次"
|
||||||
|
inactive-text="保留原周次"
|
||||||
|
/>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-card v-for="(track, ti) in status.plan.tracks" :key="track.id" shadow="never" class="track-card">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-form-item label="轨道名称"><el-input v-model="track.name" /></el-form-item>
|
||||||
|
<el-form-item label="起始年级新班数">
|
||||||
|
<el-input-number v-model="track.entryClassCount" :min="0" :max="99" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button
|
||||||
|
:disabled="status.plan.tracks.length === 1"
|
||||||
|
type="danger"
|
||||||
|
plain
|
||||||
|
@click="status.plan.tracks.splice(ti, 1)"
|
||||||
|
>
|
||||||
|
删除轨道
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div v-for="(stage, si) in track.stages" :key="si" class="toolbar stage-row">
|
||||||
|
<el-form-item :label="`第 ${si + 1} 学段`">
|
||||||
|
<el-input v-model="stage.grade" placeholder="填写年级名称" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-tag v-if="si === track.stages.length - 1" type="warning">毕业学段</el-tag>
|
||||||
|
<el-button :disabled="si === 0" @click="moveStage(track, si, -1)">上移</el-button>
|
||||||
|
<el-button :disabled="si === track.stages.length - 1" @click="moveStage(track, si, 1)">
|
||||||
|
下移
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
:disabled="track.stages.length === 1"
|
||||||
|
@click="
|
||||||
|
track.stages.splice(si, 1);
|
||||||
|
normalizeStages();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
移除学段
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<el-button
|
||||||
|
@click="
|
||||||
|
track.stages.push({ grade: '', terminal: true });
|
||||||
|
normalizeStages();
|
||||||
|
"
|
||||||
|
>
|
||||||
|
添加学段
|
||||||
|
</el-button>
|
||||||
|
</el-card>
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-button @click="addTrack">添加轨道</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="save">保存计划</el-button>
|
||||||
|
</div>
|
||||||
</el-form>
|
</el-form>
|
||||||
<p>新学年开学日重置为 9 月 1 日,保留总周数上限;新班主任与任课关系由管理员指定。</p>
|
<div class="toolbar preview-toolbar">
|
||||||
|
<el-form-item label="预览学年">
|
||||||
|
<el-input-number v-model="year" :min="2000" :max="status.year" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-button :loading="loading" @click="showPreview">预览已保存计划</el-button>
|
||||||
|
<el-tag :type="status.pending ? 'warning' : 'success'">
|
||||||
|
{{ status.pending ? `待执行 ${status.year} 学年` : '应执行学年已完成' }}
|
||||||
|
</el-tag>
|
||||||
|
</div>
|
||||||
|
<h4>执行记录</h4>
|
||||||
|
<el-table :data="status.runs">
|
||||||
|
<el-table-column prop="year" label="学年" width="90" />
|
||||||
|
<el-table-column prop="status" label="状态" width="100" />
|
||||||
|
<el-table-column prop="operatorName" label="操作人" min-width="130" />
|
||||||
|
<el-table-column prop="classCount" label="班级" width="80" />
|
||||||
|
<el-table-column prop="studentCount" label="在读学生" width="100" />
|
||||||
|
<el-table-column label="记录与快照" min-width="350">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<details>
|
||||||
|
<summary>{{ row.error || row.message }}</summary>
|
||||||
|
<p class="digest">
|
||||||
|
快照:{{ row.snapshotPath }}
|
||||||
|
<br />
|
||||||
|
SHA256:{{ row.snapshotSha256 }}
|
||||||
|
</p>
|
||||||
|
<pre>{{ row.detailsJson }}</pre>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-drawer v-model="visible" title="学年升级影响预览" size="90%" :close-on-click-modal="!running">
|
||||||
|
<template v-if="preview">
|
||||||
|
<p>
|
||||||
|
{{ preview.year }} 学年 · {{ preview.classCount }} 个班 · {{ preview.studentCount }} 名在读学生 ·
|
||||||
|
开学日 {{ preview.termStartDate }} · 删除周次 {{ preview.termWeekDeleteCount }} 条
|
||||||
|
</p>
|
||||||
<el-alert
|
<el-alert
|
||||||
v-if="status.pending"
|
v-for="block in preview.blockers"
|
||||||
title="有尚未执行的学年升级,请预览影响后补跑"
|
:key="block.message"
|
||||||
|
:title="block.message"
|
||||||
|
type="error"
|
||||||
|
:closable="false"
|
||||||
|
/>
|
||||||
|
<el-alert
|
||||||
|
v-for="warning in preview.warnings"
|
||||||
|
:key="warning"
|
||||||
|
:title="warning"
|
||||||
type="warning"
|
type="warning"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
/>
|
/>
|
||||||
<el-button v-if="status.pending" @click="catchUp">预览并补跑 {{ status.year }} 学年</el-button>
|
<section v-for="track in preview.tracks" :key="track.id">
|
||||||
<el-table :data="status.runs">
|
<h4>{{ track.name }}</h4>
|
||||||
<el-table-column prop="year" label="学年" />
|
<el-table :data="track.classes">
|
||||||
<el-table-column prop="status" label="结果" />
|
<el-table-column prop="name" label="班级" min-width="150" />
|
||||||
<el-table-column prop="classCount" label="班级数" />
|
<el-table-column prop="grade" label="当前年级" />
|
||||||
<el-table-column prop="studentCount" label="学生数" />
|
<el-table-column prop="nextGrade" label="处理结果" />
|
||||||
<el-table-column prop="message" label="执行记录" />
|
<el-table-column prop="active" label="在读" />
|
||||||
<el-table-column prop="snapshotPath" label="执行前快照" />
|
<el-table-column prop="inactive" label="停用" />
|
||||||
|
<el-table-column prop="transferred" label="转出" />
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-table :data="track.newClasses" empty-text="不新建班级">
|
||||||
|
<el-table-column prop="name" label="新班" />
|
||||||
|
<el-table-column prop="grade" label="年级" />
|
||||||
|
<el-table-column prop="code" label="班级码" />
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
<p class="digest">计划哈希:{{ preview.planHash }}</p>
|
||||||
|
<el-button type="danger" :disabled="preview.blockers.length > 0" :loading="running" @click="execute">
|
||||||
|
确认学年并执行
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-drawer>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
|
<style scoped>
|
||||||
|
.track-card,
|
||||||
|
.preview-toolbar {
|
||||||
|
margin-top: 16px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.stage-row {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.digest,
|
||||||
|
pre {
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -2,30 +2,77 @@
|
|||||||
import { onMounted, ref } from 'vue';
|
import { onMounted, ref } from 'vue';
|
||||||
import { ElMessageBox } from 'element-plus';
|
import { ElMessageBox } from 'element-plus';
|
||||||
import { api } from '@/api/http';
|
import { api } from '@/api/http';
|
||||||
|
import { classApi } from '@/api';
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import type { ClassDto } from '@classhelper/shared';
|
||||||
|
const auth = useAuthStore();
|
||||||
const students = ref<
|
const students = ref<
|
||||||
{ id: string; studentNo: string; name: string; transferNote: string; class: { name: string } | null }[]
|
{ id: string; studentNo: string; name: string; transferNote: string; class: { name: string } | null }[]
|
||||||
>([]);
|
>([]);
|
||||||
|
const classes = ref<ClassDto[]>([]),
|
||||||
|
target = ref(''),
|
||||||
|
restoring = ref(''),
|
||||||
|
visible = ref(false),
|
||||||
|
saving = ref(false);
|
||||||
async function load() {
|
async function load() {
|
||||||
students.value = await api.get('/archives/transferred');
|
students.value = await api.get('/archives/transferred');
|
||||||
}
|
}
|
||||||
async function restore(id: string) {
|
async function openRestore(id: string) {
|
||||||
await ElMessageBox.confirm('撤回后学生恢复到原班,转出历史仍保留。', '撤回转出');
|
restoring.value = id;
|
||||||
await api.post(`/archives/transferred/${id}/restore`);
|
target.value = '';
|
||||||
|
classes.value = await classApi.list();
|
||||||
|
visible.value = true;
|
||||||
|
}
|
||||||
|
async function restore() {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
'恢复到所选在读班级;原转出历史保留。原班已归档时必须指定其他在读班。',
|
||||||
|
'撤回转出',
|
||||||
|
);
|
||||||
|
saving.value = true;
|
||||||
|
try {
|
||||||
|
await api.post(
|
||||||
|
`/archives/transferred/${restoring.value}/restore`,
|
||||||
|
target.value ? { toClassId: target.value } : {},
|
||||||
|
);
|
||||||
|
visible.value = false;
|
||||||
await load();
|
await load();
|
||||||
|
} finally {
|
||||||
|
saving.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
onMounted(load);
|
onMounted(load);
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<el-card>
|
<el-card shadow="never">
|
||||||
<h3>已转出</h3>
|
<h3>已转出</h3>
|
||||||
<el-table :data="students">
|
<el-table :data="students">
|
||||||
<el-table-column prop="studentNo" label="学号" />
|
<el-table-column prop="studentNo" label="学号" />
|
||||||
<el-table-column prop="name" label="姓名" />
|
<el-table-column prop="name" label="姓名" />
|
||||||
<el-table-column prop="class.name" label="原班级" />
|
<el-table-column prop="class.name" label="原班级" />
|
||||||
<el-table-column prop="transferNote" label="转出说明" />
|
<el-table-column prop="transferNote" label="转出说明" />
|
||||||
<el-table-column label="操作">
|
<el-table-column v-if="auth.can('roster:manage')" label="操作">
|
||||||
<template #default="{ row }"><el-button link @click="restore(row.id)">撤回转出</el-button></template>
|
<template #default="{ row }">
|
||||||
|
<el-button link @click="openRestore(row.id)">撤回转出</el-button>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
<el-dialog v-model="visible" title="撤回转出" width="min(92%, 480px)">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="目标在读班级(留空使用原班)">
|
||||||
|
<el-select v-model="target" clearable>
|
||||||
|
<el-option
|
||||||
|
v-for="cls in classes"
|
||||||
|
:key="cls.id"
|
||||||
|
:value="cls.id"
|
||||||
|
:label="`${cls.name} / ${cls.grade}`"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="visible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="saving" @click="restore">确认撤回</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
</el-card>
|
</el-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -11,7 +11,7 @@ export const MANAGEMENT_GROUPS = [
|
|||||||
id: 'school',
|
id: 'school',
|
||||||
title: '班级与人员',
|
title: '班级与人员',
|
||||||
icon: 'School',
|
icon: 'School',
|
||||||
paths: ['/classes', '/students', '/teachers', '/roster', '/term-weeks', '/archives'],
|
paths: ['/classes', '/students', '/teachers', '/roster', '/term-weeks', '/promotion', '/archives'],
|
||||||
},
|
},
|
||||||
{ id: 'devices', title: '设备运维', icon: 'Monitor', paths: ['/clients'] },
|
{ id: 'devices', title: '设备运维', icon: 'Monitor', paths: ['/clients'] },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -58,13 +58,13 @@ 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', roles: ['ADMIN'] },
|
meta: { title: '班级管理', icon: 'School', permission: 'class:read' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'students',
|
path: 'students',
|
||||||
name: 'students',
|
name: 'students',
|
||||||
component: () => import('@/views/StudentsView.vue'),
|
component: () => import('@/views/StudentsView.vue'),
|
||||||
meta: { title: '学生管理', icon: 'User', roles: ['ADMIN'] },
|
meta: { title: '学生管理', icon: 'User', permission: 'roster:read' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'teachers',
|
path: 'teachers',
|
||||||
@@ -77,14 +77,20 @@ const routes: RouteRecordRaw[] = [
|
|||||||
name: 'term-weeks',
|
name: 'term-weeks',
|
||||||
component: () => import('@/views/TermWeeksView.vue'),
|
component: () => import('@/views/TermWeeksView.vue'),
|
||||||
// 改周次会让全校/某班的课表口径整体变化,与「班级管理」同一档权限
|
// 改周次会让全校/某班的课表口径整体变化,与「班级管理」同一档权限
|
||||||
meta: { title: '学期周次', icon: 'Calendar', roles: ['ADMIN'] },
|
meta: { title: '学期周次', icon: 'Calendar', permission: 'term:read' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'promotion',
|
||||||
|
name: 'promotion',
|
||||||
|
component: () => import('@/components/PromotionSettings.vue'),
|
||||||
|
meta: { title: '学年升级', icon: 'Calendar', permission: 'promotion:manage' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'archives',
|
path: 'archives',
|
||||||
name: 'archives',
|
name: 'archives',
|
||||||
component: () => import('@/views/ArchivesView.vue'),
|
component: () => import('@/views/ArchivesView.vue'),
|
||||||
// 毕业归档会改动整届班级与学生的状态,与「班级管理」同一档权限
|
// 毕业归档会改动整届班级与学生的状态,与「班级管理」同一档权限
|
||||||
meta: { title: '毕业归档', icon: 'FolderOpened', roles: ['ADMIN'] },
|
meta: { title: '毕业归档', icon: 'FolderOpened', permission: 'archive:manage' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'schedules',
|
path: 'schedules',
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ async function openContent(row: ArchivedClassDto): Promise<void> {
|
|||||||
async function undo(row: ArchivedYearDto): Promise<void> {
|
async function undo(row: ArchivedYearDto): Promise<void> {
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`撤销「${row.name}」的毕业归档?\n` +
|
`撤销「${row.name}」的毕业归档?\n` +
|
||||||
'该届班级会回到在读列表、毕业学生会恢复为在读;作业、通知与成绩一条都不会被删除。',
|
'仅撤销归档标记:毕业学生恢复在读,转出和停用状态保留。不回滚年级、不删除新班、不恢复已清空周次;完整回滚需手工恢复快照。',
|
||||||
'撤销归档',
|
'撤销归档',
|
||||||
{ type: 'warning', confirmButtonText: '确认撤销' },
|
{ type: 'warning', confirmButtonText: '确认撤销' },
|
||||||
);
|
);
|
||||||
@@ -154,6 +154,7 @@ onMounted(loadYears);
|
|||||||
<el-table-column label="毕业生" width="90">
|
<el-table-column label="毕业生" width="90">
|
||||||
<template #default="{ row }">{{ row.studentCount }}</template>
|
<template #default="{ row }">{{ row.studentCount }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column prop="inactiveCount" label="停用学生" width="100" />
|
||||||
<el-table-column label="转出学生" width="100">
|
<el-table-column label="转出学生" width="100">
|
||||||
<template #default="{ row }">{{ row.transferredCount }}</template>
|
<template #default="{ row }">{{ row.transferredCount }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -187,6 +188,10 @@ onMounted(loadYears);
|
|||||||
:title="detail.note"
|
:title="detail.note"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<p v-if="detail">
|
||||||
|
有转入记录 {{ detail.transferInCount }} 人 · 转出 {{ detail.transferred.length }} 人 · 停用
|
||||||
|
{{ detail.inactive.length }} 人
|
||||||
|
</p>
|
||||||
<el-tabs v-model="activeTab">
|
<el-tabs v-model="activeTab">
|
||||||
<el-tab-pane label="班级" name="classes">
|
<el-tab-pane label="班级" name="classes">
|
||||||
<el-table :data="detail?.classes ?? []" size="small" class="mt-12" empty-text="该届没有班级">
|
<el-table :data="detail?.classes ?? []" size="small" class="mt-12" empty-text="该届没有班级">
|
||||||
@@ -247,6 +252,13 @@ onMounted(loadYears);
|
|||||||
<el-table-column prop="transferNote" label="去向 / 备注" show-overflow-tooltip />
|
<el-table-column prop="transferNote" label="去向 / 备注" show-overflow-tooltip />
|
||||||
</el-table>
|
</el-table>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<el-tab-pane :label="`停用学生(${detail?.inactive.length ?? 0})`" name="inactive">
|
||||||
|
<el-table :data="detail?.inactive ?? []">
|
||||||
|
<el-table-column prop="studentNo" label="学号" />
|
||||||
|
<el-table-column prop="name" label="姓名" />
|
||||||
|
<el-table-column prop="className" label="原班级" />
|
||||||
|
</el-table>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
</div>
|
</div>
|
||||||
</el-drawer>
|
</el-drawer>
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import {
|
|||||||
type SubjectTeacherDto,
|
type SubjectTeacherDto,
|
||||||
type UpdateClassAccountRequest,
|
type UpdateClassAccountRequest,
|
||||||
type UserDto,
|
type UserDto,
|
||||||
|
type PromotionPlan,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { classApi, courseApi, teacherApi, termApi } from '@/api';
|
import { classApi, courseApi, teacherApi, termApi } from '@/api';
|
||||||
|
import { api } from '@/api/http';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useRealtimeStore } from '@/stores/realtime';
|
import { useRealtimeStore } from '@/stores/realtime';
|
||||||
import { useResponsive } from '@/composables/useResponsive';
|
import { useResponsive } from '@/composables/useResponsive';
|
||||||
@@ -30,7 +32,7 @@ const realtime = useRealtimeStore();
|
|||||||
const { isMobile } = useResponsive();
|
const { isMobile } = useResponsive();
|
||||||
|
|
||||||
/** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */
|
/** 仅管理员可管理班级(增删改)与人员分配 —— 与后端权限矩阵一致 */
|
||||||
const isAdmin = computed(() => auth.role === 'ADMIN');
|
const canManageClass = computed(() => auth.can('class:manage'));
|
||||||
|
|
||||||
/** 小屏下详情描述改为单列,避免文字被压成竖排 */
|
/** 小屏下详情描述改为单列,避免文字被压成竖排 */
|
||||||
const detailColumns = computed(() => (isMobile.value ? 1 : 3));
|
const detailColumns = computed(() => (isMobile.value ? 1 : 3));
|
||||||
@@ -39,7 +41,9 @@ const loading = ref(false);
|
|||||||
const classes = ref<ClassDto[]>([]);
|
const classes = ref<ClassDto[]>([]);
|
||||||
const maxTermWeeks = ref(getMaxTermWeeks());
|
const maxTermWeeks = ref(getMaxTermWeeks());
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
maxTermWeeks.value = getMaxTermWeeks((await termApi.get()).maxTermWeeks);
|
if (auth.can('term:read')) maxTermWeeks.value = getMaxTermWeeks((await termApi.get()).maxTermWeeks);
|
||||||
|
if (auth.can('promotion:manage'))
|
||||||
|
promotionPlan.value = (await api.get<{ plan: PromotionPlan }>('/term/promotion')).plan;
|
||||||
});
|
});
|
||||||
const keyword = ref('');
|
const keyword = ref('');
|
||||||
|
|
||||||
@@ -81,7 +85,7 @@ const previewName = computed(() => formatClassName(form.enrollmentYear, form.cla
|
|||||||
const staffOptions = ref<UserDto[]>([]);
|
const staffOptions = ref<UserDto[]>([]);
|
||||||
|
|
||||||
async function loadStaffOptions(): Promise<void> {
|
async function loadStaffOptions(): Promise<void> {
|
||||||
if (!isAdmin.value) return;
|
if (!canManageClass.value) return;
|
||||||
if (staffOptions.value.length > 0) return;
|
if (staffOptions.value.length > 0) return;
|
||||||
staffOptions.value = await teacherApi.list().catch(() => []);
|
staffOptions.value = await teacherApi.list().catch(() => []);
|
||||||
}
|
}
|
||||||
@@ -125,13 +129,12 @@ async function submitForm(): Promise<void> {
|
|||||||
|
|
||||||
if (editingId.value) {
|
if (editingId.value) {
|
||||||
await classApi.update(editingId.value, {
|
await classApi.update(editingId.value, {
|
||||||
grade: form.grade,
|
|
||||||
enrollmentYear: form.enrollmentYear,
|
enrollmentYear: form.enrollmentYear,
|
||||||
classIndex: form.classIndex,
|
classIndex: form.classIndex,
|
||||||
termWeeks: form.termWeeks,
|
termWeeks: form.termWeeks,
|
||||||
});
|
});
|
||||||
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
|
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
|
||||||
if (isAdmin.value && form.headTeacherId) {
|
if (canManageClass.value && form.headTeacherId) {
|
||||||
const target = classes.value.find((item) => item.id === editingId.value);
|
const target = classes.value.find((item) => item.id === editingId.value);
|
||||||
if (target && target.teacher?.id !== form.headTeacherId) {
|
if (target && target.teacher?.id !== form.headTeacherId) {
|
||||||
await classApi.assignHeadTeacher(editingId.value, form.headTeacherId);
|
await classApi.assignHeadTeacher(editingId.value, form.headTeacherId);
|
||||||
@@ -147,7 +150,7 @@ async function submitForm(): Promise<void> {
|
|||||||
// 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。
|
// 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。
|
||||||
termWeeks: form.termWeeks,
|
termWeeks: form.termWeeks,
|
||||||
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
|
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
|
||||||
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
|
...(canManageClass.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
|
||||||
});
|
});
|
||||||
ElMessage.success(`班级 ${previewName.value} 创建成功`);
|
ElMessage.success(`班级 ${previewName.value} 创建成功`);
|
||||||
}
|
}
|
||||||
@@ -417,28 +420,32 @@ async function toggleGradeQuery(enabled: boolean): Promise<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const promoteForm = reactive({ grade: '', saving: false });
|
const promotionPlan = ref<PromotionPlan>();
|
||||||
|
const promoteForm = reactive({ saving: false });
|
||||||
/** 学年升级:只改年级,**不归档**,班级记录/学生名单/学号/作业成绩全部沿用 */
|
const nextStage = computed(() => {
|
||||||
|
const track = promotionPlan.value?.tracks.find((track) =>
|
||||||
|
track.stages.some((stage) => stage.grade === detail.value?.grade),
|
||||||
|
);
|
||||||
|
const index = track?.stages.findIndex((stage) => stage.grade === detail.value?.grade) ?? -1;
|
||||||
|
return track && index >= 0
|
||||||
|
? track.stages[index].terminal
|
||||||
|
? '毕业归档(请到学年升级页)'
|
||||||
|
: track.stages[index + 1].grade
|
||||||
|
: '请先配置升级计划';
|
||||||
|
});
|
||||||
async function promote(): Promise<void> {
|
async function promote(): Promise<void> {
|
||||||
if (!detail.value || !promoteForm.grade.trim()) {
|
if (!detail.value) return;
|
||||||
ElMessage.warning('请填写升级后的年级,例如「高二」');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const target = promoteForm.grade.trim();
|
|
||||||
await ElMessageBox.confirm(
|
await ElMessageBox.confirm(
|
||||||
`把「${detail.value.name}」的年级改为「${target}」?升级只改年级,不归档:` +
|
`将「${detail.value.name}」从 ${detail.value.grade} 升至 ${nextStage.value},保留班级业务数据。`,
|
||||||
'班级记录、学生名单、学号、作业与成绩全部沿用。',
|
'确认升级',
|
||||||
'学年升级',
|
{ type: 'warning' },
|
||||||
{ type: 'info', confirmButtonText: '确认升级' },
|
|
||||||
);
|
);
|
||||||
promoteForm.saving = true;
|
promoteForm.saving = true;
|
||||||
try {
|
try {
|
||||||
await classApi.promote([detail.value.id], target);
|
await classApi.promote([detail.value.id]);
|
||||||
ElMessage.success('已升级,数据继续沿用');
|
|
||||||
promoteForm.grade = '';
|
|
||||||
await refreshDetail();
|
await refreshDetail();
|
||||||
await loadClasses();
|
await loadClasses();
|
||||||
|
ElMessage.success('班级已升至下一学段');
|
||||||
} finally {
|
} finally {
|
||||||
promoteForm.saving = false;
|
promoteForm.saving = false;
|
||||||
}
|
}
|
||||||
@@ -470,7 +477,7 @@ onUnmounted(() => {
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">班级管理</h2>
|
<h2 class="page-title">班级管理</h2>
|
||||||
<p class="page-subtitle">教师只能看到并管理自己创建或被分配的班级</p>
|
<p class="page-subtitle">按授权范围查看班级,管理操作需要相应权限。</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -489,7 +496,9 @@ onUnmounted(() => {
|
|||||||
>
|
>
|
||||||
导入任课关系
|
导入任课关系
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
|
<el-button v-if="canManageClass" type="primary" :icon="'Plus'" @click="openCreate">
|
||||||
|
新建班级
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -537,9 +546,11 @@ onUnmounted(() => {
|
|||||||
<el-table-column label="操作" width="310" fixed="right">
|
<el-table-column label="操作" width="310" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
<el-button link type="primary" @click="openDetail(row)">详情</el-button>
|
||||||
<el-button v-if="isAdmin" link type="warning" @click="openAccount(row)">班级账号</el-button>
|
<el-button v-if="canManageClass" link type="warning" @click="openAccount(row)">
|
||||||
<el-button v-if="isAdmin" link type="primary" @click="openEdit(row)">编辑</el-button>
|
班级账号
|
||||||
<el-button v-if="isAdmin" link type="danger" @click="removeClass(row)">删除</el-button>
|
</el-button>
|
||||||
|
<el-button v-if="canManageClass" link type="primary" @click="openEdit(row)">编辑</el-button>
|
||||||
|
<el-button v-if="canManageClass" link type="danger" @click="removeClass(row)">删除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -596,12 +607,12 @@ onUnmounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="年级" prop="grade">
|
<el-form-item label="年级" prop="grade">
|
||||||
<el-input v-model="form.grade" placeholder="例如 高一" />
|
<el-input :disabled="!!editingId" v-model="form.grade" placeholder="例如 高一" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<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-form-item v-if="canManageClass" label="班主任">
|
||||||
<el-select v-model="form.headTeacherId" placeholder="选择班主任" style="width: 100%" filterable>
|
<el-select v-model="form.headTeacherId" placeholder="选择班主任" style="width: 100%" filterable>
|
||||||
<el-option
|
<el-option
|
||||||
v-for="item in staffOptions"
|
v-for="item in staffOptions"
|
||||||
@@ -645,7 +656,7 @@ onUnmounted(() => {
|
|||||||
</el-descriptions>
|
</el-descriptions>
|
||||||
|
|
||||||
<!-- 更换班主任(仅管理员):班主任决定谁能管这个班的课表与成绩 -->
|
<!-- 更换班主任(仅管理员):班主任决定谁能管这个班的课表与成绩 -->
|
||||||
<div v-if="isAdmin && detail" class="toolbar mt-12">
|
<div v-if="canManageClass && detail" class="toolbar mt-12">
|
||||||
<span class="text-muted">更换班主任</span>
|
<span class="text-muted">更换班主任</span>
|
||||||
<el-select
|
<el-select
|
||||||
:model-value="detail.teacher?.id ?? ''"
|
:model-value="detail.teacher?.id ?? ''"
|
||||||
@@ -668,7 +679,13 @@ onUnmounted(() => {
|
|||||||
<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">
|
||||||
<el-button type="primary" size="small" :icon="'Plus'" @click="studentFormVisible = true">
|
<el-button
|
||||||
|
v-if="auth.can('roster:manage')"
|
||||||
|
type="primary"
|
||||||
|
size="small"
|
||||||
|
:icon="'Plus'"
|
||||||
|
@click="studentFormVisible = true"
|
||||||
|
>
|
||||||
添加学生
|
添加学生
|
||||||
</el-button>
|
</el-button>
|
||||||
<span class="text-muted">共 {{ detail?.students.length ?? 0 }} 人</span>
|
<span class="text-muted">共 {{ detail?.students.length ?? 0 }} 人</span>
|
||||||
@@ -688,7 +705,9 @@ onUnmounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="90">
|
<el-table-column label="操作" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="danger" @click="removeStudent(row)">移出</el-button>
|
<el-button v-if="auth.can('roster:manage')" link type="danger" @click="removeStudent(row)">
|
||||||
|
移出
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -702,7 +721,9 @@ onUnmounted(() => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="90">
|
<el-table-column label="操作" width="90">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="danger" @click="removeCourse(row)">删除</el-button>
|
<el-button v-if="auth.can('schedule:write')" link type="danger" @click="removeCourse(row)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -731,7 +752,7 @@ onUnmounted(() => {
|
|||||||
<el-table-column label="任课老师" width="260">
|
<el-table-column label="任课老师" width="260">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-select
|
<el-select
|
||||||
v-if="isAdmin"
|
v-if="canManageClass"
|
||||||
:model-value="row.teacherId"
|
:model-value="row.teacherId"
|
||||||
placeholder="选择老师"
|
placeholder="选择老师"
|
||||||
style="width: 220px"
|
style="width: 220px"
|
||||||
@@ -748,14 +769,14 @@ onUnmounted(() => {
|
|||||||
<span v-else>{{ row.teacherName || '-' }}</span>
|
<span v-else>{{ row.teacherName || '-' }}</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="isAdmin" label="操作" width="110">
|
<el-table-column v-if="canManageClass" label="操作" width="110">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="danger" @click="clearSubjectTeacher(row)">解除</el-button>
|
<el-button link type="danger" @click="clearSubjectTeacher(row)">解除</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<div v-if="isAdmin" class="toolbar mt-12">
|
<div v-if="canManageClass" class="toolbar mt-12">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="subjectDraft.subjectName"
|
v-model="subjectDraft.subjectName"
|
||||||
placeholder="选择科目"
|
placeholder="选择科目"
|
||||||
@@ -796,14 +817,14 @@ onUnmounted(() => {
|
|||||||
<el-switch
|
<el-switch
|
||||||
:model-value="detail?.studentGradeQueryEnabled ?? true"
|
:model-value="detail?.studentGradeQueryEnabled ?? true"
|
||||||
:loading="gradeQuerySaving"
|
:loading="gradeQuerySaving"
|
||||||
:disabled="!isAdmin && auth.role !== 'TEACHER'"
|
:disabled="!auth.can('grade:write')"
|
||||||
@change="(value: boolean) => toggleGradeQuery(value)"
|
@change="(value: boolean) => toggleGradeQuery(value)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-divider />
|
<el-divider />
|
||||||
|
|
||||||
<div v-if="isAdmin" class="setting-row">
|
<div v-if="auth.can('promotion:manage')" class="setting-row">
|
||||||
<div>
|
<div>
|
||||||
<div class="setting-title">学年升级</div>
|
<div class="setting-title">学年升级</div>
|
||||||
<div class="text-muted">
|
<div class="text-muted">
|
||||||
@@ -813,12 +834,15 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="toolbar">
|
<div class="toolbar">
|
||||||
<el-input
|
<span>下一学段:{{ nextStage }}</span>
|
||||||
v-model="promoteForm.grade"
|
<el-button
|
||||||
placeholder="升级后的年级,如 高二"
|
type="primary"
|
||||||
style="width: 200px"
|
:loading="promoteForm.saving"
|
||||||
/>
|
:disabled="nextStage.includes('毕业') || nextStage.includes('配置')"
|
||||||
<el-button type="primary" :loading="promoteForm.saving" @click="promote">升级</el-button>
|
@click="promote"
|
||||||
|
>
|
||||||
|
升级
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type UserRole,
|
type UserRole,
|
||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { permissionApi } from '@/api';
|
import { permissionApi } from '@/api';
|
||||||
|
import { api } from '@/api/http';
|
||||||
import { useAuthStore } from '@/stores/auth';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import { useResponsive } from '@/composables/useResponsive';
|
import { useResponsive } from '@/composables/useResponsive';
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ const { isMobile } = useResponsive();
|
|||||||
const auth = useAuthStore();
|
const auth = useAuthStore();
|
||||||
|
|
||||||
/** 当前 Tab:权限组 / 成员 */
|
/** 当前 Tab:权限组 / 成员 */
|
||||||
const activeTab = ref<'groups' | 'members'>('groups');
|
const activeTab = ref<'groups' | 'members' | 'audit'>('groups');
|
||||||
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
/** 加载失败的中文提示(非空 = 页面顶部显示错误态 + 重试按钮) */
|
/** 加载失败的中文提示(非空 = 页面顶部显示错误态 + 重试按钮) */
|
||||||
@@ -95,11 +96,13 @@ async function loadAll(): Promise<void> {
|
|||||||
const [groupList, memberList, catalogData] = await Promise.all([
|
const [groupList, memberList, catalogData] = await Promise.all([
|
||||||
permissionApi.listGroups(),
|
permissionApi.listGroups(),
|
||||||
permissionApi.listMembers(),
|
permissionApi.listMembers(),
|
||||||
catalog.value ? Promise.resolve(catalog.value) : permissionApi.catalog(),
|
permissionApi.catalog(),
|
||||||
]);
|
]);
|
||||||
groups.value = groupList;
|
groups.value = groupList;
|
||||||
members.value = memberList;
|
members.value = memberList;
|
||||||
catalog.value = catalogData;
|
catalog.value = catalogData;
|
||||||
|
scopes.value = await api.get('/permissions/scopes');
|
||||||
|
await loadAudits();
|
||||||
await auth.ensurePermissions(true);
|
await auth.ensurePermissions(true);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loadError.value = errorMessage(error);
|
loadError.value = errorMessage(error);
|
||||||
@@ -171,7 +174,10 @@ function defaultRoleHint(key: PermissionKey): string {
|
|||||||
* 这里只打一个「高权限」标记提醒配置的人。
|
* 这里只打一个「高权限」标记提醒配置的人。
|
||||||
*/
|
*/
|
||||||
function isHighPrivilege(key: PermissionKey): boolean {
|
function isHighPrivilege(key: PermissionKey): boolean {
|
||||||
return catalog.value?.unremovable.includes(key) ?? false;
|
return (
|
||||||
|
(catalog.value?.defaultRoles[key]?.length === 1 && catalog.value.defaultRoles[key][0] === 'ADMIN') ||
|
||||||
|
(catalog.value?.unremovable.includes(key) ?? false)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 分类勾选状态:全选 / 全不选 / 选了一部分(决定"全选-清空"按钮的文案) */
|
/** 分类勾选状态:全选 / 全不选 / 选了一部分(决定"全选-清空"按钮的文案) */
|
||||||
@@ -203,6 +209,10 @@ async function submitGroup(): Promise<void> {
|
|||||||
ElMessage.warning(`已存在名为「${name}」的权限组`);
|
ElMessage.warning(`已存在名为「${name}」的权限组`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await ElMessageBox.confirm(groupImpact.value, '权限变更影响预览', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认保存',
|
||||||
|
});
|
||||||
groupDialog.saving = true;
|
groupDialog.saving = true;
|
||||||
try {
|
try {
|
||||||
const payload = {
|
const payload = {
|
||||||
@@ -264,9 +274,10 @@ const assignDialog = reactive({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function openAssign(member: PermissionMemberDto): void {
|
function openAssign(member: PermissionMemberDto): void {
|
||||||
if (member.fixed) return;
|
|
||||||
assignDialog.member = member;
|
assignDialog.member = member;
|
||||||
assignDialog.groupIds = [...member.groupIds];
|
assignDialog.groupIds = member.groupIds.length
|
||||||
|
? [...member.groupIds]
|
||||||
|
: groups.value.filter((g) => g.defaultRole === member.role).map((g) => g.id);
|
||||||
assignDialog.visible = true;
|
assignDialog.visible = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,6 +291,10 @@ const roleDefaultCount = computed(() => {
|
|||||||
async function submitAssign(): Promise<void> {
|
async function submitAssign(): Promise<void> {
|
||||||
const member = assignDialog.member;
|
const member = assignDialog.member;
|
||||||
if (!member) return;
|
if (!member) return;
|
||||||
|
await ElMessageBox.confirm(assignmentImpact.value, '分组生效差异', {
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认分配',
|
||||||
|
});
|
||||||
assignDialog.saving = true;
|
assignDialog.saving = true;
|
||||||
try {
|
try {
|
||||||
// 空数组 = 回到角色默认模板(服务端契约)
|
// 空数组 = 回到角色默认模板(服务端契约)
|
||||||
@@ -293,15 +308,102 @@ async function submitAssign(): Promise<void> {
|
|||||||
assignDialog.saving = false;
|
assignDialog.saving = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const scopes = ref<{ grades: string[]; divisions: { name: string; grades: string[] }[] }>({
|
||||||
|
grades: [],
|
||||||
|
divisions: [],
|
||||||
|
});
|
||||||
|
const selectedGrades = computed({
|
||||||
|
get: () => groupDialog.scopeValue.split(',').filter(Boolean),
|
||||||
|
set: (value: string[]) => {
|
||||||
|
groupDialog.scopeValue = value.join(',');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const memberSearch = ref(''),
|
||||||
|
memberRole = ref('');
|
||||||
|
const filteredMembers = computed(() =>
|
||||||
|
members.value.filter(
|
||||||
|
(member) =>
|
||||||
|
(!memberRole.value || member.role === memberRole.value) &&
|
||||||
|
`${member.name} ${member.username}`.includes(memberSearch.value.trim()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
type Audit = {
|
||||||
|
id: string;
|
||||||
|
actorName: string;
|
||||||
|
action: string;
|
||||||
|
targetId: string;
|
||||||
|
beforeJson: string;
|
||||||
|
afterJson: string;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
const auditPage = ref(1),
|
||||||
|
audit = ref<{ items: Audit[]; total: number }>({ items: [], total: 0 });
|
||||||
|
async function loadAudits() {
|
||||||
|
audit.value = await api.get('/permissions/audit', { page: auditPage.value, pageSize: 30 });
|
||||||
|
}
|
||||||
|
const groupMembersVisible = ref(false),
|
||||||
|
selectedGroupMembers = ref<PermissionMemberDto[]>([]);
|
||||||
|
async function showMembers(group: PermissionGroupDto) {
|
||||||
|
selectedGroupMembers.value = await api.get(`/permissions/groups/${group.id}/members`);
|
||||||
|
groupMembersVisible.value = true;
|
||||||
|
}
|
||||||
|
function copyGroup(group: PermissionGroupDto) {
|
||||||
|
openEditGroup(group);
|
||||||
|
groupDialog.id = '';
|
||||||
|
groupDialog.name = `${group.name}副本`;
|
||||||
|
}
|
||||||
|
function effectiveKeys(
|
||||||
|
member: PermissionMemberDto,
|
||||||
|
groupIds: string[],
|
||||||
|
override?: { id: string; keys: PermissionKey[] },
|
||||||
|
) {
|
||||||
|
const selected = groups.value.filter((group) =>
|
||||||
|
groupIds.length ? groupIds.includes(group.id) : group.defaultRole === member.role,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
...new Set([
|
||||||
|
...selected.flatMap((group) => (group.id === override?.id ? override.keys : group.keys)),
|
||||||
|
...(member.role === 'ADMIN' ? (catalog.value?.unremovable ?? []) : []),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
function keyChanges(before: PermissionKey[], after: PermissionKey[]) {
|
||||||
|
const labels = (keys: PermissionKey[]) =>
|
||||||
|
keys.map((key) => catalog.value?.labels[key] ?? key).join('、') || '无';
|
||||||
|
return `新增:${labels(after.filter((key) => !before.includes(key)))};移除:${labels(before.filter((key) => !after.includes(key)))}`;
|
||||||
|
}
|
||||||
|
const groupImpact = computed(() => {
|
||||||
|
const group = groups.value.find((group) => group.id === groupDialog.id);
|
||||||
|
const affected = members.value.filter((member) =>
|
||||||
|
member.groupIds.length ? member.groupIds.includes(groupDialog.id) : group?.defaultRole === member.role,
|
||||||
|
);
|
||||||
|
const added = new Set<PermissionKey>(),
|
||||||
|
removed = new Set<PermissionKey>();
|
||||||
|
for (const member of affected) {
|
||||||
|
const next = effectiveKeys(member, member.groupIds, { id: groupDialog.id, keys: groupDialog.keys });
|
||||||
|
next.filter((key) => !member.permissions.includes(key)).forEach((key) => added.add(key));
|
||||||
|
member.permissions.filter((key) => !next.includes(key)).forEach((key) => removed.add(key));
|
||||||
|
}
|
||||||
|
const label = (keys: Set<PermissionKey>) =>
|
||||||
|
[...keys].map((key) => catalog.value?.labels[key] ?? key).join('、') || '无';
|
||||||
|
return `将影响 ${affected.length} 名成员。新增:${label(added)};移除:${label(removed)}。范围按当前选择保存,可能改变成员可访问的班级。`;
|
||||||
|
});
|
||||||
|
const assignmentImpact = computed(() => {
|
||||||
|
const member = assignDialog.member;
|
||||||
|
if (!member) return '';
|
||||||
|
const next = effectiveKeys(member, assignDialog.groupIds);
|
||||||
|
const defaults = catalog.value?.roleDefaults[member.role] ?? [];
|
||||||
|
return `对当前权限:${keyChanges(member.permissions, next)}。对角色默认模板:${keyChanges(defaults, next)}。所选组替代默认模板,如需保留请同时选择同角色默认组。`;
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div v-if="auth.can('permission:manage')" class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2 class="page-title">权限组管理</h2>
|
<h2 class="page-title">权限组管理</h2>
|
||||||
<p class="page-subtitle">
|
<p class="page-subtitle">
|
||||||
创建权限组并把账号分配进去,用来给老师额外开通某项能力(也可以反过来收回权限)。
|
分配的权限组会替代角色默认模板;如需保留默认权限,请同时选择同角色默认组。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -346,6 +448,8 @@ async function submitAssign(): Promise<void> {
|
|||||||
<span v-else class="text-muted">(无描述)</span>
|
<span v-else class="text-muted">(无描述)</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column prop="scopeLabel" label="范围" min-width="170" />
|
||||||
|
<el-table-column prop="updatedAt" label="更新时间" min-width="190" />
|
||||||
<el-table-column label="权限数" width="90">
|
<el-table-column label="权限数" width="90">
|
||||||
<template #default="{ row }">{{ row.keys.length }}</template>
|
<template #default="{ row }">{{ row.keys.length }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -361,9 +465,11 @@ async function submitAssign(): Promise<void> {
|
|||||||
</el-tag>
|
</el-tag>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="140">
|
<el-table-column label="操作" width="230">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="primary" @click="openEditGroup(row)">编辑</el-button>
|
<el-button link type="primary" @click="openEditGroup(row)">编辑</el-button>
|
||||||
|
<el-button link @click="copyGroup(row)">复制</el-button>
|
||||||
|
<el-button link @click="showMembers(row)">成员</el-button>
|
||||||
<!-- 内置组禁止删除:按钮 disabled + tooltip 说明(disabled 元素不发光标事件,外面套一层 span) -->
|
<!-- 内置组禁止删除:按钮 disabled + tooltip 说明(disabled 元素不发光标事件,外面套一层 span) -->
|
||||||
<el-tooltip :disabled="!row.builtin" content="内置权限组不能删除" placement="top">
|
<el-tooltip :disabled="!row.builtin" content="内置权限组不能删除" placement="top">
|
||||||
<span>
|
<span>
|
||||||
@@ -380,6 +486,13 @@ async function submitAssign(): Promise<void> {
|
|||||||
|
|
||||||
<!-- ------------------------------------------------ Tab 2:成员 -->
|
<!-- ------------------------------------------------ Tab 2:成员 -->
|
||||||
<el-tab-pane label="成员" name="members">
|
<el-tab-pane label="成员" name="members">
|
||||||
|
<div class="toolbar">
|
||||||
|
<el-input v-model="memberSearch" placeholder="搜索姓名或工号" clearable style="width: 220px" />
|
||||||
|
<el-select v-model="memberRole" clearable placeholder="全部角色">
|
||||||
|
<el-option value="ADMIN" label="管理员" />
|
||||||
|
<el-option value="TEACHER" label="教师" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
<div class="toolbar tab-toolbar">
|
<div class="toolbar tab-toolbar">
|
||||||
<span class="text-muted">
|
<span class="text-muted">
|
||||||
只列出管理员与教师账号;管理员始终保留权限与系统管理入口,可调整默认组或分配其他权限组。
|
只列出管理员与教师账号;管理员始终保留权限与系统管理入口,可调整默认组或分配其他权限组。
|
||||||
@@ -387,7 +500,7 @@ async function submitAssign(): Promise<void> {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-card shadow="never" class="table-card">
|
<el-card shadow="never" class="table-card">
|
||||||
<el-table v-loading="loading" :data="members" empty-text="暂无成员">
|
<el-table v-loading="loading" :data="filteredMembers" empty-text="暂无成员">
|
||||||
<el-table-column prop="name" label="姓名" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="name" label="姓名" min-width="120" show-overflow-tooltip />
|
||||||
<el-table-column prop="username" label="用户名" min-width="120" show-overflow-tooltip />
|
<el-table-column prop="username" label="用户名" min-width="120" show-overflow-tooltip />
|
||||||
<el-table-column label="角色" width="130">
|
<el-table-column label="角色" width="130">
|
||||||
@@ -409,14 +522,17 @@ async function submitAssign(): Promise<void> {
|
|||||||
<span v-else class="text-muted">角色默认模板</span>
|
<span v-else class="text-muted">角色默认模板</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column label="数据范围" min-width="180">
|
||||||
|
<template #default="{ row }">{{ row.scopeLabels.join(';') }}</template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="生效权限数" width="110">
|
<el-table-column label="生效权限数" width="110">
|
||||||
<template #default="{ row }">{{ row.permissionCount }}</template>
|
<template #default="{ row }">{{ row.permissionCount }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="130">
|
<el-table-column label="操作" width="130">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tooltip :disabled="!row.fixed" content="管理员始终保留权限与系统管理入口" placement="top">
|
<el-tooltip :disabled="true" content="管理员始终保留权限与系统管理入口" placement="top">
|
||||||
<span>
|
<span>
|
||||||
<el-button link type="primary" :disabled="row.fixed" @click="openAssign(row)">
|
<el-button link type="primary" :disabled="false" @click="openAssign(row)">
|
||||||
分配权限组
|
分配权限组
|
||||||
</el-button>
|
</el-button>
|
||||||
</span>
|
</span>
|
||||||
@@ -426,7 +542,38 @@ async function submitAssign(): Promise<void> {
|
|||||||
</el-table>
|
</el-table>
|
||||||
</el-card>
|
</el-card>
|
||||||
</el-tab-pane>
|
</el-tab-pane>
|
||||||
|
<el-tab-pane label="变更记录" name="audit">
|
||||||
|
<el-table :data="audit.items">
|
||||||
|
<el-table-column prop="createdAt" label="时间" min-width="190" />
|
||||||
|
<el-table-column prop="actorName" label="操作人" min-width="120" />
|
||||||
|
<el-table-column prop="action" label="操作" min-width="120" />
|
||||||
|
<el-table-column prop="targetId" label="目标" min-width="180" />
|
||||||
|
<el-table-column label="变更前后" min-width="300">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<details>
|
||||||
|
<summary>查看差异</summary>
|
||||||
|
<pre>{{ row.beforeJson }}</pre>
|
||||||
|
<pre>{{ row.afterJson }}</pre>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="auditPage"
|
||||||
|
:page-size="30"
|
||||||
|
:total="audit.total"
|
||||||
|
layout="prev, pager, next"
|
||||||
|
@current-change="loadAudits"
|
||||||
|
/>
|
||||||
|
</el-tab-pane>
|
||||||
</el-tabs>
|
</el-tabs>
|
||||||
|
<el-drawer v-model="groupMembersVisible" title="生效成员" :size="isMobile ? '92%' : '560px'">
|
||||||
|
<el-table :data="selectedGroupMembers">
|
||||||
|
<el-table-column prop="name" label="姓名" />
|
||||||
|
<el-table-column prop="username" label="工号" />
|
||||||
|
<el-table-column prop="permissionCount" label="生效权限数" />
|
||||||
|
</el-table>
|
||||||
|
</el-drawer>
|
||||||
|
|
||||||
<!-- ------------------------------------------------ 新建 / 编辑权限组 -->
|
<!-- ------------------------------------------------ 新建 / 编辑权限组 -->
|
||||||
<el-dialog
|
<el-dialog
|
||||||
@@ -445,15 +592,36 @@ async function submitAssign(): Promise<void> {
|
|||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="数据范围">
|
<el-form-item label="数据范围">
|
||||||
<el-select v-model="groupDialog.scope">
|
<el-select v-model="groupDialog.scope" @change="groupDialog.scopeValue = ''">
|
||||||
<el-option label="关联班级" value="SELF_CLASS" />
|
<el-option label="关联班级" value="SELF_CLASS" />
|
||||||
<el-option label="年级" value="GRADE" />
|
<el-option label="年级" value="GRADE" />
|
||||||
<el-option label="部(多个年级)" value="DIVISION" />
|
<el-option label="部(多个年级)" value="DIVISION" />
|
||||||
<el-option label="全校" value="ALL" />
|
<el-option label="全校" value="ALL" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="范围值">
|
<el-form-item v-if="groupDialog.scope === 'SELF_CLASS'" label="关联班级规则">
|
||||||
<el-input v-model="groupDialog.scopeValue" placeholder="高一 或 高一,高二,高三;空年级范围不授权" />
|
<el-select v-model="groupDialog.scopeValue">
|
||||||
|
<el-option label="班主任与任课班级" value="" />
|
||||||
|
<el-option label="仅班主任班" value="HEAD" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-else-if="groupDialog.scope === 'GRADE'" label="年级" required>
|
||||||
|
<el-select v-model="groupDialog.scopeValue">
|
||||||
|
<el-option v-for="grade in scopes.grades" :key="grade" :label="grade" :value="grade" />
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item v-else-if="groupDialog.scope === 'DIVISION'" label="部范围(选择多个年级)" required>
|
||||||
|
<el-select v-model="selectedGrades" multiple>
|
||||||
|
<el-option v-for="grade in scopes.grades" :key="grade" :label="grade" :value="grade" />
|
||||||
|
</el-select>
|
||||||
|
<el-button
|
||||||
|
v-for="division in scopes.divisions"
|
||||||
|
:key="division.name"
|
||||||
|
link
|
||||||
|
@click="selectedGrades = division.grades"
|
||||||
|
>
|
||||||
|
{{ division.name }}
|
||||||
|
</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="描述">
|
<el-form-item label="描述">
|
||||||
<el-input
|
<el-input
|
||||||
@@ -497,6 +665,7 @@ async function submitAssign(): Promise<void> {
|
|||||||
</p>
|
</p>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
<el-alert :title="groupImpact" type="warning" :closable="false" />
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<el-button @click="groupDialog.visible = false">取消</el-button>
|
<el-button @click="groupDialog.visible = false">取消</el-button>
|
||||||
<el-button type="primary" :loading="groupDialog.saving" @click="submitGroup">保存</el-button>
|
<el-button type="primary" :loading="groupDialog.saving" @click="submitGroup">保存</el-button>
|
||||||
@@ -523,11 +692,18 @@ async function submitAssign(): Promise<void> {
|
|||||||
filterable
|
filterable
|
||||||
placeholder="不选任何组 = 使用角色默认模板"
|
placeholder="不选任何组 = 使用角色默认模板"
|
||||||
>
|
>
|
||||||
<el-option v-for="group in groups" :key="group.id" :label="group.name" :value="group.id">
|
<el-option
|
||||||
|
v-for="group in groups"
|
||||||
|
:key="group.id"
|
||||||
|
:label="group.name"
|
||||||
|
:value="group.id"
|
||||||
|
:disabled="!!group.defaultRole && group.defaultRole !== assignDialog.member.role"
|
||||||
|
>
|
||||||
<span>{{ group.name }}</span>
|
<span>{{ group.name }}</span>
|
||||||
<span class="text-muted">({{ group.keys.length }} 项权限)</span>
|
<span class="text-muted">({{ group.keys.length }} 项权限)</span>
|
||||||
</el-option>
|
</el-option>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
<el-alert :title="assignmentImpact" type="warning" :closable="false" />
|
||||||
<p class="assign-hint text-muted">
|
<p class="assign-hint text-muted">
|
||||||
不选任何组 = 使用角色默认模板({{ roleLabel(assignDialog.member.role) }} 默认
|
不选任何组 = 使用角色默认模板({{ roleLabel(assignDialog.member.role) }} 默认
|
||||||
{{ roleDefaultCount }} 项权限);选了组就取各组权限的并集。
|
{{ roleDefaultCount }} 项权限);选了组就取各组权限的并集。
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRoute } from 'vue-router';
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
import { api } from '@/api/http';
|
||||||
import { computed, onMounted, reactive, ref } from 'vue';
|
import { computed, onMounted, 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 {
|
||||||
@@ -18,13 +19,17 @@ import {
|
|||||||
import { callApi, classApi, studentApi } from '@/api';
|
import { callApi, classApi, studentApi } from '@/api';
|
||||||
import TableImportDialog from '@/components/TableImportDialog.vue';
|
import TableImportDialog from '@/components/TableImportDialog.vue';
|
||||||
|
|
||||||
const readOnly = computed(() => useRouteName.value === 'roster');
|
const auth = useAuthStore();
|
||||||
const useRouteName = computed(() => route.name);
|
const readOnly = computed(() => !auth.can('roster:manage'));
|
||||||
const route = useRoute();
|
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const students = ref<StudentDto[]>([]);
|
const students = ref<StudentDto[]>([]);
|
||||||
const classes = ref<ClassDto[]>([]);
|
const classes = ref<ClassDto[]>([]);
|
||||||
const filter = reactive({ classId: '', keyword: '', status: '' as '' | StudentStatus });
|
const filter = reactive({
|
||||||
|
classId: '',
|
||||||
|
keyword: '',
|
||||||
|
status: 'active' as '' | StudentStatus,
|
||||||
|
includeArchived: false,
|
||||||
|
});
|
||||||
/** 表格勾选(批量调班 / 批量转出用) */
|
/** 表格勾选(批量调班 / 批量转出用) */
|
||||||
const selection = ref<StudentDto[]>([]);
|
const selection = ref<StudentDto[]>([]);
|
||||||
|
|
||||||
@@ -51,7 +56,7 @@ function exportRoster() {
|
|||||||
const classOptions = computed(() => classes.value);
|
const classOptions = computed(() => classes.value);
|
||||||
|
|
||||||
async function loadClasses(): Promise<void> {
|
async function loadClasses(): Promise<void> {
|
||||||
classes.value = await classApi.list();
|
classes.value = auth.can('class:read') ? await classApi.list() : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
let studentRequest = 0;
|
let studentRequest = 0;
|
||||||
@@ -59,7 +64,8 @@ async function loadStudents(): Promise<void> {
|
|||||||
const request = ++studentRequest;
|
const request = ++studentRequest;
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const params: { classId?: string; keyword?: string; status?: StudentStatus } = {};
|
const params: { classId?: string; keyword?: string; status?: StudentStatus; includeArchived?: boolean } =
|
||||||
|
{ includeArchived: filter.includeArchived };
|
||||||
if (filter.classId) params.classId = filter.classId;
|
if (filter.classId) params.classId = filter.classId;
|
||||||
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
|
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
|
||||||
if (filter.status) params.status = filter.status;
|
if (filter.status) params.status = filter.status;
|
||||||
@@ -125,7 +131,7 @@ async function submitForm(): Promise<void> {
|
|||||||
const payload = {
|
const payload = {
|
||||||
studentNo: form.studentNo.trim(),
|
studentNo: form.studentNo.trim(),
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
classId: form.classId || null,
|
...(!editingId.value ? { classId: form.classId || null } : {}),
|
||||||
gender: form.gender,
|
gender: form.gender,
|
||||||
guardianPhone: form.guardianPhone.trim(),
|
guardianPhone: form.guardianPhone.trim(),
|
||||||
};
|
};
|
||||||
@@ -142,12 +148,27 @@ async function submitForm(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function removeStudent(row: StudentDto): Promise<void> {
|
async function removeStudent(row: StudentDto): Promise<void> {
|
||||||
await ElMessageBox.confirm(`删除学生「${row.name}(${row.studentNo})」及其成绩、作业记录?`, '危险操作', {
|
await ElMessageBox.confirm(
|
||||||
|
`停用学生「${row.name}(${row.studentNo})」?成绩、作业、已读与历史全部保留;停用后可复学。`,
|
||||||
|
'危险操作',
|
||||||
|
{
|
||||||
type: 'warning',
|
type: 'warning',
|
||||||
confirmButtonText: '确认删除',
|
confirmButtonText: '确认停用',
|
||||||
});
|
},
|
||||||
|
);
|
||||||
await studentApi.remove(row.id);
|
await studentApi.remove(row.id);
|
||||||
ElMessage.success('学生已删除');
|
ElMessage.success('学生已停用');
|
||||||
|
await loadStudents();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hardDeleteStudent(row: StudentDto): Promise<void> {
|
||||||
|
await ElMessageBox.prompt(
|
||||||
|
`仅能删除没有成绩、作业、已读或历史的学生记录。请输入学号 ${row.studentNo} 确认永久删除。`,
|
||||||
|
'删除空记录',
|
||||||
|
{ inputValidator: (value) => value === row.studentNo || '学号不一致', type: 'warning' },
|
||||||
|
);
|
||||||
|
await api.delete(`/students/${row.id}?hard=true&confirm=true`);
|
||||||
|
ElMessage.success('空学生记录已删除');
|
||||||
await loadStudents();
|
await loadStudents();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,7 +179,7 @@ async function removeStudent(row: StudentDto): Promise<void> {
|
|||||||
const transferVisible = ref(false);
|
const transferVisible = ref(false);
|
||||||
const transferSaving = ref(false);
|
const transferSaving = ref(false);
|
||||||
const transferTargets = ref<StudentDto[]>([]);
|
const transferTargets = ref<StudentDto[]>([]);
|
||||||
const transferForm = reactive({ toClassId: '', note: '' });
|
const transferForm = reactive({ toClassId: '', note: '', allowGradeChange: false });
|
||||||
|
|
||||||
function openTransfer(rows: StudentDto[]): void {
|
function openTransfer(rows: StudentDto[]): void {
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
@@ -178,6 +199,7 @@ async function submitTransfer(): Promise<void> {
|
|||||||
transferTargets.value.map((item) => item.id),
|
transferTargets.value.map((item) => item.id),
|
||||||
transferForm.toClassId || null,
|
transferForm.toClassId || null,
|
||||||
transferForm.note.trim() || undefined,
|
transferForm.note.trim() || undefined,
|
||||||
|
transferForm.allowGradeChange,
|
||||||
);
|
);
|
||||||
ElMessage.success(`已调动 ${result.moved} 名学生(学号不变,历史可查)`);
|
ElMessage.success(`已调动 ${result.moved} 名学生(学号不变,历史可查)`);
|
||||||
transferVisible.value = false;
|
transferVisible.value = false;
|
||||||
@@ -192,19 +214,64 @@ async function transferOut(rows: StudentDto[]): Promise<void> {
|
|||||||
ElMessage.warning('请先勾选要办理转出的学生');
|
ElMessage.warning('请先勾选要办理转出的学生');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const names = rows.map((item) => item.name).join('、');
|
outTargets.value = rows;
|
||||||
const { value } = await ElMessageBox.prompt(
|
outForm.note = '';
|
||||||
`将 ${rows.length} 名学生(${names})标记为「已转出」。\n` +
|
outForm.toSchool = '';
|
||||||
'转出表示学籍离开本校:班级归属与历史成绩、作业都会保留,只是不再计入在读名单。',
|
outVisible.value = true;
|
||||||
'办理学生转出',
|
}
|
||||||
{ inputPlaceholder: '转出去向 / 原因(可选)', inputValue: '', confirmButtonText: '确认转出' },
|
const outTargets = ref<StudentDto[]>([]),
|
||||||
|
outVisible = ref(false),
|
||||||
|
outSaving = ref(false);
|
||||||
|
const outForm = reactive({ toSchool: '', note: '' });
|
||||||
|
async function submitOut() {
|
||||||
|
outSaving.value = true;
|
||||||
|
try {
|
||||||
|
await studentApi.transferOut(
|
||||||
|
outTargets.value.map((row) => row.id),
|
||||||
|
outForm.note,
|
||||||
|
outForm.toSchool,
|
||||||
);
|
);
|
||||||
const result = await studentApi.transferOut(
|
outVisible.value = false;
|
||||||
rows.map((item) => item.id),
|
|
||||||
(value ?? '').trim() || undefined,
|
|
||||||
);
|
|
||||||
ElMessage.success(`已办理 ${result.transferred} 名学生转出`);
|
|
||||||
await loadStudents();
|
await loadStudents();
|
||||||
|
} finally {
|
||||||
|
outSaving.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const inVisible = ref(false),
|
||||||
|
inSaving = ref(false);
|
||||||
|
const inForm = reactive({ studentId: '', studentNo: '', name: '', toClassId: '', fromSchool: '', note: '' });
|
||||||
|
function openIn(row?: StudentDto) {
|
||||||
|
Object.assign(inForm, {
|
||||||
|
studentId: row?.id ?? '',
|
||||||
|
studentNo: row?.studentNo ?? '',
|
||||||
|
name: row?.name ?? '',
|
||||||
|
toClassId: filter.classId,
|
||||||
|
fromSchool: '',
|
||||||
|
note: '',
|
||||||
|
});
|
||||||
|
inVisible.value = true;
|
||||||
|
}
|
||||||
|
async function submitIn() {
|
||||||
|
if (!inForm.toClassId || !inForm.studentNo || !inForm.name) {
|
||||||
|
ElMessage.warning('请填写学号、姓名和目标班级');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inSaving.value = true;
|
||||||
|
try {
|
||||||
|
await api.post('/students/transfer-in', {
|
||||||
|
...(inForm.studentId
|
||||||
|
? { studentId: inForm.studentId }
|
||||||
|
: { studentNo: inForm.studentNo, name: inForm.name }),
|
||||||
|
toClassId: inForm.toClassId,
|
||||||
|
fromSchool: inForm.fromSchool,
|
||||||
|
note: inForm.note,
|
||||||
|
});
|
||||||
|
inVisible.value = false;
|
||||||
|
await loadStudents();
|
||||||
|
ElMessage.success('转入或复学已完成');
|
||||||
|
} finally {
|
||||||
|
inSaving.value = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ------------------------------------------------------------ 调班历史 */
|
/* ------------------------------------------------------------ 调班历史 */
|
||||||
@@ -227,6 +294,11 @@ async function openHistory(row: StudentDto): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TRANSFER_MODE_LABELS: Record<string, string> = {
|
const TRANSFER_MODE_LABELS: Record<string, string> = {
|
||||||
|
'class-change': '调班 / 建档',
|
||||||
|
restore: '撤回 / 复学',
|
||||||
|
unassign: '移出班级',
|
||||||
|
deactivate: '停用',
|
||||||
|
'transfer-in': '转入',
|
||||||
single: '单个调班',
|
single: '单个调班',
|
||||||
batch: '批量调班',
|
batch: '批量调班',
|
||||||
'transfer-out': '转出',
|
'transfer-out': '转出',
|
||||||
@@ -303,15 +375,14 @@ async function submitCall(): Promise<void> {
|
|||||||
style="width: 170px"
|
style="width: 170px"
|
||||||
@change="loadStudents"
|
@change="loadStudents"
|
||||||
>
|
>
|
||||||
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option
|
||||||
|
v-for="item in classOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.name} / ${item.grade}`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-select
|
<el-select v-model="filter.status" placeholder="在读" style="width: 130px" @change="loadStudents">
|
||||||
v-model="filter.status"
|
|
||||||
placeholder="全部状态"
|
|
||||||
clearable
|
|
||||||
style="width: 130px"
|
|
||||||
@change="loadStudents"
|
|
||||||
>
|
|
||||||
<el-option
|
<el-option
|
||||||
v-for="status in STUDENT_STATUSES"
|
v-for="status in STUDENT_STATUSES"
|
||||||
:key="status"
|
:key="status"
|
||||||
@@ -329,8 +400,17 @@ async function submitCall(): Promise<void> {
|
|||||||
/>
|
/>
|
||||||
<el-button :icon="'Search'" @click="loadStudents">查询</el-button>
|
<el-button :icon="'Search'" @click="loadStudents">查询</el-button>
|
||||||
<el-button @click="exportRoster">导出名单</el-button>
|
<el-button @click="exportRoster">导出名单</el-button>
|
||||||
|
<el-switch v-model="filter.includeArchived" active-text="包含归档" @change="loadStudents" />
|
||||||
|
<el-button v-if="!readOnly" @click="openIn()">转入学生</el-button>
|
||||||
<el-button v-if="!readOnly" type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
|
<el-button v-if="!readOnly" type="primary" :icon="'Plus'" @click="openCreate">新建学生</el-button>
|
||||||
<el-button v-if="!readOnly" type="warning" :icon="'Document'" @click="openImport">导入名单</el-button>
|
<el-button
|
||||||
|
v-if="!readOnly && auth.can('import:students')"
|
||||||
|
type="warning"
|
||||||
|
:icon="'Document'"
|
||||||
|
@click="openImport"
|
||||||
|
>
|
||||||
|
导入名单
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -395,13 +475,59 @@ async function submitCall(): Promise<void> {
|
|||||||
<el-table-column label="创建时间" width="170">
|
<el-table-column label="创建时间" width="170">
|
||||||
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="!readOnly" label="操作" width="330" fixed="right">
|
<el-table-column label="操作" width="370" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-button link type="success" @click="openCall(row)">叫人</el-button>
|
<el-button
|
||||||
<el-button link type="primary" @click="openTransfer([row])">调班</el-button>
|
v-if="auth.can('call:create') && row.status === 'active'"
|
||||||
<el-button link @click="openHistory(row)">调班历史</el-button>
|
link
|
||||||
<el-button link type="primary" @click="openEdit(row)">编辑</el-button>
|
type="success"
|
||||||
<el-button link type="danger" @click="removeStudent(row)">删除</el-button>
|
@click="openCall(row)"
|
||||||
|
>
|
||||||
|
叫人
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!readOnly && row.status === 'active' && !row.archivedYearId"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="openTransfer([row])"
|
||||||
|
>
|
||||||
|
调班
|
||||||
|
</el-button>
|
||||||
|
<el-button link @click="openHistory(row)">生命周期历史</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!readOnly && ['active', 'inactive'].includes(row.status) && !row.archivedYearId"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="openEdit(row)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!readOnly && row.status === 'active' && !row.archivedYearId"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
@click="removeStudent(row)"
|
||||||
|
>
|
||||||
|
停用
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="
|
||||||
|
auth.role === 'ADMIN' && ['active', 'inactive'].includes(row.status) && !row.archivedYearId
|
||||||
|
"
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
@click="hardDeleteStudent(row)"
|
||||||
|
>
|
||||||
|
删除空记录
|
||||||
|
</el-button>
|
||||||
|
<el-button
|
||||||
|
v-if="!readOnly && ['transferred', 'inactive'].includes(row.status) && !row.archivedYearId"
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
@click="openIn(row)"
|
||||||
|
>
|
||||||
|
转入 / 复学
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -417,6 +543,11 @@ async function submitCall(): Promise<void> {
|
|||||||
<el-form-item label="学生">
|
<el-form-item label="学生">
|
||||||
<span>{{ transferTargets.length }} 名({{ transferTargets.map((s) => s.name).join('、') }})</span>
|
<span>{{ transferTargets.length }} 名({{ transferTargets.map((s) => s.name).join('、') }})</span>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item label="原班 / 年级">
|
||||||
|
<span>
|
||||||
|
{{ transferTargets.map((s) => `${s.className || '未分班'} / ${s.grade || ''}`).join('、') }}
|
||||||
|
</span>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="调入班级">
|
<el-form-item label="调入班级">
|
||||||
<el-select
|
<el-select
|
||||||
v-model="transferForm.toClassId"
|
v-model="transferForm.toClassId"
|
||||||
@@ -424,9 +555,17 @@ async function submitCall(): Promise<void> {
|
|||||||
clearable
|
clearable
|
||||||
style="width: 100%"
|
style="width: 100%"
|
||||||
>
|
>
|
||||||
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option
|
||||||
|
v-for="item in classOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.name} / ${item.grade}`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item v-if="auth.role === 'ADMIN'" label="跨年级">
|
||||||
|
<el-checkbox v-model="transferForm.allowGradeChange">允许跨年级(必须填写备注)</el-checkbox>
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="备注">
|
<el-form-item label="备注">
|
||||||
<el-input
|
<el-input
|
||||||
v-model="transferForm.note"
|
v-model="transferForm.note"
|
||||||
@@ -441,6 +580,45 @@ async function submitCall(): Promise<void> {
|
|||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
|
|
||||||
|
<el-dialog v-model="outVisible" title="办理转出" width="min(92%, 520px)">
|
||||||
|
<p>将 {{ outTargets.length }} 名学生转出,原班级和全部业务历史保留。</p>
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="去向学校"><el-input v-model="outForm.toSchool" maxlength="200" /></el-form-item>
|
||||||
|
<el-form-item label="原因 / 备注">
|
||||||
|
<el-input v-model="outForm.note" type="textarea" maxlength="200" />
|
||||||
|
</el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="outVisible = false">取消</el-button>
|
||||||
|
<el-button type="danger" :loading="outSaving" @click="submitOut">确认转出</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
|
<el-dialog v-model="inVisible" title="转入 / 复学" width="min(92%, 520px)">
|
||||||
|
<el-form label-position="top">
|
||||||
|
<el-form-item label="学号" required>
|
||||||
|
<el-input v-model="inForm.studentNo" :disabled="!!inForm.studentId" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="姓名" required>
|
||||||
|
<el-input v-model="inForm.name" :disabled="!!inForm.studentId" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="目标在读班级" required>
|
||||||
|
<el-select v-model="inForm.toClassId">
|
||||||
|
<el-option
|
||||||
|
v-for="item in classOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.name} / ${item.grade}`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
|
</el-select>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="来源学校"><el-input v-model="inForm.fromSchool" maxlength="200" /></el-form-item>
|
||||||
|
<el-form-item label="备注"><el-input v-model="inForm.note" maxlength="200" /></el-form-item>
|
||||||
|
</el-form>
|
||||||
|
<template #footer>
|
||||||
|
<el-button @click="inVisible = false">取消</el-button>
|
||||||
|
<el-button type="primary" :loading="inSaving" @click="submitIn">确认办理</el-button>
|
||||||
|
</template>
|
||||||
|
</el-dialog>
|
||||||
<!-- 调班历史 -->
|
<!-- 调班历史 -->
|
||||||
<el-drawer v-model="historyVisible" :title="`调班历史 — ${historyTarget?.name ?? ''}`" size="640px">
|
<el-drawer v-model="historyVisible" :title="`调班历史 — ${historyTarget?.name ?? ''}`" size="640px">
|
||||||
<el-table v-loading="historyLoading" :data="historyRows" empty-text="该生还没有调班记录">
|
<el-table v-loading="historyLoading" :data="historyRows" empty-text="该生还没有调班记录">
|
||||||
@@ -448,7 +626,7 @@ async function submitCall(): Promise<void> {
|
|||||||
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
<template #default="{ row }">{{ formatDate(row.createdAt, true) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="方式" width="100">
|
<el-table-column label="方式" width="100">
|
||||||
<template #default="{ row }">{{ TRANSFER_MODE_LABELS[row.mode] ?? row.mode }}</template>
|
<template #default="{ row }">{{ TRANSFER_MODE_LABELS[row.kind] ?? row.kind }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="原班级" width="130">
|
<el-table-column label="原班级" width="130">
|
||||||
<template #default="{ row }">{{ row.fromClassName || '未分班' }}</template>
|
<template #default="{ row }">{{ row.fromClassName || '未分班' }}</template>
|
||||||
@@ -456,6 +634,12 @@ async function submitCall(): Promise<void> {
|
|||||||
<el-table-column label="新班级" width="130">
|
<el-table-column label="新班级" width="130">
|
||||||
<template #default="{ row }">{{ row.toClassName || '未分班' }}</template>
|
<template #default="{ row }">{{ row.toClassName || '未分班' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
<el-table-column prop="batchId" label="批次" min-width="200" />
|
||||||
|
<el-table-column prop="academicYear" label="学年" width="90" />
|
||||||
|
<el-table-column prop="fromGrade" label="原年级" width="100" />
|
||||||
|
<el-table-column prop="toGrade" label="新年级" width="100" />
|
||||||
|
<el-table-column prop="fromSchool" label="来源学校" min-width="120" />
|
||||||
|
<el-table-column prop="toSchool" label="去向学校" min-width="120" />
|
||||||
<el-table-column prop="operatorName" label="操作人" width="100" />
|
<el-table-column prop="operatorName" label="操作人" width="100" />
|
||||||
<el-table-column prop="note" label="备注" show-overflow-tooltip />
|
<el-table-column prop="note" label="备注" show-overflow-tooltip />
|
||||||
</el-table>
|
</el-table>
|
||||||
@@ -542,9 +726,14 @@ async function submitCall(): Promise<void> {
|
|||||||
<el-form-item label="家长手机号">
|
<el-form-item label="家长手机号">
|
||||||
<el-input v-model="form.guardianPhone" placeholder="可选" />
|
<el-input v-model="form.guardianPhone" placeholder="可选" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="班级">
|
<el-form-item v-if="!editingId" label="班级">
|
||||||
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
|
<el-select v-model="form.classId" placeholder="暂不分班" clearable style="width: 100%">
|
||||||
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option
|
||||||
|
v-for="item in classOptions"
|
||||||
|
:key="item.id"
|
||||||
|
:label="`${item.name} / ${item.grade}`"
|
||||||
|
:value="item.id"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
*
|
*
|
||||||
* `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
|
* `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
|
||||||
*/
|
*/
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
import PromotionSettings from '@/components/PromotionSettings.vue';
|
import PromotionSettings from '@/components/PromotionSettings.vue';
|
||||||
import { computed, onMounted, ref } from 'vue';
|
import { computed, onMounted, ref } from 'vue';
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus';
|
import { ElMessage, ElMessageBox } from 'element-plus';
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
} from '@classhelper/shared';
|
} from '@classhelper/shared';
|
||||||
import { classApi, termApi } from '@/api';
|
import { classApi, termApi } from '@/api';
|
||||||
|
|
||||||
|
const auth = useAuthStore();
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const classes = ref<ClassDto[]>([]);
|
const classes = ref<ClassDto[]>([]);
|
||||||
@@ -176,7 +178,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="page">
|
<div class="page">
|
||||||
<PromotionSettings />
|
<PromotionSettings v-if="auth.can('promotion:manage')" />
|
||||||
<el-form-item v-if="!scope" label="全校学期总周数">
|
<el-form-item v-if="!scope" label="全校学期总周数">
|
||||||
<el-input-number v-model="maxTermWeeks" :min="1" :max="60" />
|
<el-input-number v-model="maxTermWeeks" :min="1" :max="60" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -201,10 +203,24 @@ onMounted(async () => {
|
|||||||
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
|
||||||
</el-select>
|
</el-select>
|
||||||
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
|
<el-button :icon="'Refresh'" :loading="loading" @click="load">刷新</el-button>
|
||||||
<el-button type="warning" :icon="'Calendar'" :loading="holidayLoading" @click="fetchHolidays">
|
<el-button
|
||||||
|
v-if="auth.can('term:write')"
|
||||||
|
type="warning"
|
||||||
|
:icon="'Calendar'"
|
||||||
|
:loading="holidayLoading"
|
||||||
|
@click="fetchHolidays"
|
||||||
|
>
|
||||||
拉取放假安排
|
拉取放假安排
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button type="primary" :loading="saving" :disabled="!dirty" @click="save">保存</el-button>
|
<el-button
|
||||||
|
v-if="auth.can('term:write')"
|
||||||
|
type="primary"
|
||||||
|
:loading="saving"
|
||||||
|
:disabled="!dirty"
|
||||||
|
@click="save"
|
||||||
|
>
|
||||||
|
保存
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -229,7 +245,9 @@ onMounted(async () => {
|
|||||||
placeholder="第 1 教学周的周一"
|
placeholder="第 1 教学周的周一"
|
||||||
style="width: 200px"
|
style="width: 200px"
|
||||||
/>
|
/>
|
||||||
<el-button type="primary" plain :loading="saving" @click="autoGenerate">按开学日期生成</el-button>
|
<el-button v-if="auth.can('term:write')" type="primary" plain :loading="saving" @click="autoGenerate">
|
||||||
|
按开学日期生成
|
||||||
|
</el-button>
|
||||||
<span class="hint">先生成一份标准学期,再把调休那几周改掉 —— 比一周周填快得多</span>
|
<span class="hint">先生成一份标准学期,再把调休那几周改掉 —— 比一周周填快得多</span>
|
||||||
</div>
|
</div>
|
||||||
</el-card>
|
</el-card>
|
||||||
@@ -254,7 +272,9 @@ onMounted(async () => {
|
|||||||
<template #header>
|
<template #header>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<span>逐周日期区间({{ weeks.length }} 周)</span>
|
<span>逐周日期区间({{ weeks.length }} 周)</span>
|
||||||
<el-button size="small" :icon="'Plus'" @click="addWeek">添加一周</el-button>
|
<el-button v-if="auth.can('term:write')" size="small" :icon="'Plus'" @click="addWeek">
|
||||||
|
添加一周
|
||||||
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -304,7 +324,9 @@ onMounted(async () => {
|
|||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="操作" width="90">
|
<el-table-column label="操作" width="90">
|
||||||
<template #default="{ $index }">
|
<template #default="{ $index }">
|
||||||
<el-button link type="danger" @click="removeWeek($index)">删除</el-button>
|
<el-button v-if="auth.can('term:write')" link type="danger" @click="removeWeek($index)">
|
||||||
|
删除
|
||||||
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|||||||
@@ -0,0 +1,467 @@
|
|||||||
|
# ClassHelper 权限组治理与学生年度生命周期重构提示词
|
||||||
|
|
||||||
|
> 用法:在仓库根目录(含 `AGENTS.md`)把本文件整体交给编码代理。
|
||||||
|
> 这不是“加几个按钮”的任务,而是把**权限键、数据范围、写入口、年度升级**收敛成单一路径。
|
||||||
|
> 如果当前会话支持计划确认,先输出差距清单与分阶段计划;确认后逐阶段实现。若为自动执行模式,按阶段直接实现,但每阶段结束必须停下来汇报实际验证输出。
|
||||||
|
|
||||||
|
## 0. 你的角色与工作方式
|
||||||
|
|
||||||
|
你是本仓库的高级全栈工程师,熟悉 TypeScript、Express、Prisma、Vue 3、Element Plus 与 pnpm monorepo。
|
||||||
|
|
||||||
|
开始前必须按顺序阅读:
|
||||||
|
|
||||||
|
1. `AGENTS.md`(最高优先级工程约束)
|
||||||
|
2. `docs/dev/permissions.md`、`docs/app/classes.md`、`docs/dev/api.md`、`docs/dev/implementation.md`、`docs/dev/data-model.md`、`docs/dev/testing.md`
|
||||||
|
3. 本提示词第 2 节列出的源码文件与符号
|
||||||
|
|
||||||
|
工作规则:
|
||||||
|
|
||||||
|
- 先只读调研,不要先改代码;先给出“已核实差距 + 实施方案 + 风险 + 测试计划”。
|
||||||
|
- 分阶段提交;每阶段结束运行对应验证命令,汇报**本次实际输出**,不要引用历史报告数字。
|
||||||
|
- 不要留下 `TODO`、空实现、只改前端不改服务端、只改服务端不改 UI 的半截功能。
|
||||||
|
- 发现本文与代码冲突时,以代码和 `AGENTS.md` 为准,并在汇报中列出冲突点与你的处理方式。
|
||||||
|
- 代码注释、文档、界面文案、提交描述一律中文;提交使用中文 Conventional Commits。
|
||||||
|
- 只格式化本次改动文件,不要全仓库跑 Prettier;换行用 LF。
|
||||||
|
|
||||||
|
## 1. 硬约束(不可违反)
|
||||||
|
|
||||||
|
- 单一 `packages/server/prisma/schema.prisma`;保持 SQLite/MySQL 通用,不加 Prisma `enum`、不加 `@db.*`。
|
||||||
|
- 模型变动必须同步迁移、`lib/snapshot.ts` 的 `SNAPSHOT_TABLES` 与旧库兼容逻辑;不能修改已应用的历史迁移。
|
||||||
|
- 权限每次请求实时计算,改动立即生效;前端隐藏不是鉴权,服务端必须独立校验。
|
||||||
|
- 班级端会话(`CLASS_DEVICE`)权限集合为空,但既有能力必须保留:本班名单/课表/通知/叫人可读,作业可代录,成绩不可写,个人成绩查询受班级开关控制。
|
||||||
|
- 不破坏离线客户端、preload 白名单、CSP、设备心跳与现有 API 兼容;改路由守卫后必须检查桌面客户端调用链。
|
||||||
|
- 不修改版本号、不发布、不动 `releases/`、不对真实业务库执行 `db:seed`/`db:reset`、不向真实班级发测试消息。
|
||||||
|
- 验收使用隔离临时库;测试失败先定位,不要为了通过而放宽断言。
|
||||||
|
|
||||||
|
## 2. 已核实现状(按这些事实设计,不要重新猜测)
|
||||||
|
|
||||||
|
### 2.1 权限组
|
||||||
|
|
||||||
|
- **P1 权限键有目录、无入口。** `class:read`、`schedule:read`、`homework:read`、`notification:read` 在 `shared/src/permissions.ts` 中定义,但服务端没有任何路由使用它们;`GET /classes`、`GET /schedules`、`GET /homeworks`、`GET /notifications` 只做登录与班级范围断言。结果是:从权限组取消这些键,用户仍然能读。
|
||||||
|
- **P2 写入口也有缺口。** `POST /homeworks` 没有 `homework:write` 守卫;`GET /classes/:id`、`GET /classes/:id/students` 没有功能键守卫;`POST /notifications` 使用 `requirePermission` 而不是 `requireStaffPermission`,班级端会被空权限集合挡住,与 `canPublishNotification` 允许 `CLASS_DEVICE` 的规则不一致。
|
||||||
|
- **P3 键与业务断言错位。** `POST /students/transfer`、`POST /students/transfer-out` 路由要求 `class:manage`,服务层又硬编码 `isAdmin`;持有 `roster:manage` 的班主任不能调班,持有 `class:manage` 的教师仍被 `assertCanManageClasses` 拒绝。`POST /classes/promote` 同样只认 `class:manage` + ADMIN。归档模块更明显:路由要求 `archive:manage`,但 `archives.service` 与 `transferred.service` 的每个入口都调用 `assertCanManageClasses`(只认 ADMIN),非管理员即使被授予 `archive:manage` 也无法使用。
|
||||||
|
- **P4 升级权限过宽。** `/term/promotion` 的读取、配置、执行全部要求 `system:manage`。要授权教务主任执行升级,只能把系统设置权限一并给出。
|
||||||
|
- **P5 页面仍按角色门控。** `web-admin/src/router/index.ts` 中 `students`、`classes`、`archives`、`term-weeks` 的 `meta.roles` 固定为 `['ADMIN']`;权限组即使授予 `roster:manage`/`class:read`/`archive:manage`/`term:read`,页面也进不去。
|
||||||
|
- **P6 分配可越权。** `assignUserGroups` 只校验组存在,不校验 `defaultRole` 与目标账号角色;把 ADMIN 默认组分配给 TEACHER 会让其获得全部键。拥有 `permission:manage` 的教师也可以新建含 `system:manage`/`database:manage` 的组并分配给自己。
|
||||||
|
- **P7 范围是自由文本。** `scopeValue` 直接按逗号拆分;`GRADE` 与 `DIVISION` 实现完全相同;空值、未知年级、全角逗号都没有统一校验;`SELF_CLASS` 的 `HEAD` 语义只存在于代码分支,UI 没有解释。
|
||||||
|
- **P8 导入键不生效。** `import:students` 挂在路由上,但 `imports.module.ts` 的 `assertImportKindAllowed` 仍用 `role !== 'ADMIN'` 拒绝教师。
|
||||||
|
- **P9 成员列表 N+1 且字段失效。** `listMembers` 对每个账号调用一次 `loadEffectivePermissions`;`PermissionMemberDto.fixed` 永远为 false,前端“管理员固定”提示不会出现。
|
||||||
|
- **P10 无审计、无差异预览。** 权限组变更只写 `logger.info`,没有操作人、前后差异或受影响成员;编辑组/分配组时看不到生效权限变化。
|
||||||
|
- **P11 默认组语义与文案相反。** 页面说“给老师额外开通某项能力”,但一旦分配任何组,就不再使用角色默认组(并集只含所分配组),可能反而收掉教师默认权限。必须显式提示并预览,不能靠用户猜。
|
||||||
|
|
||||||
|
### 2.2 学生年度流程
|
||||||
|
|
||||||
|
- **S1 PATCH 绕过生命周期。** `PATCH /students/:id` 接受 `classId` 和 `status`;可直接把学生改成 `transferred`/`graduated`,不写 `transferredAt`、不写历史、不挂归档;换班也不会广播班级更新。
|
||||||
|
- **S2 调班不是原子操作。** `transferStudents` 先事务改 `Student.classId`,事务外再写 `StudentClassTransfer`;历史写入失败会留下“人已调走、无记录”。`recordTransfer` 按 `createdAt desc` 取最近 N 条,没有批次标识,存在取错/漏取风险。
|
||||||
|
- **S3 转出接受毕业生。** `transferOutStudents` 只排除已是 `transferred` 的记录;`graduated`/已归档学生也会被改成 `transferred`,覆盖毕业事实。没有结构化的转入学校/去向字段。
|
||||||
|
- **S4 转出历史查不到。** `listTransfers` 的 where 带 `student: activeStudentWhere()`,而 `activeStudentWhere` 排除 `transferred`;因此转出记录在班级/学生历史里不可见,`GET /students/:id/transfers` 对转出学生返回空。
|
||||||
|
- **S5 状态筛选是坏的。** `listStudents` 始终 AND `activeStudentWhere()`;`status=transferred` 永远返回空,但 UI 提供了“已转出/已毕业”筛选项。
|
||||||
|
- **S6 可复活毕业生。** `isActiveStudent` 只排除 `transferred/inactive`,不排除 `graduated`;`addStudent` 对已毕业学生会走“已存在”分支,把 `status` 改回 `active`,但保留 `archivedYearId`,造成隐藏的不一致记录。该分支也不是事务。
|
||||||
|
- **S7 删除是硬删。** `deleteStudent` 级联删除成绩、作业状态、已读和调班历史;对毕业/转出学生没有保护。
|
||||||
|
- **S8 升级归档漏人。** `runPromotion` 只对 `status: active` 的学生写 `archivedYearId`;转出/停用学生不挂届别,归档详情的“转出学生”为空,`ArchivedYear.transferredCount` 也不会写。
|
||||||
|
- **S9 升级清空周次。** `runPromotion` 直接 `termWeek.deleteMany()`,并把 `term.startDate` 固定写成 `${year}-09-01`;忽略配置日期,清掉班级自定义周次。
|
||||||
|
- **S10 年级硬编码。** 升级只认 `高一/高二/高三`;`PATCH /classes/:id` 可把年级改成任意字符串,`POST /classes/promote` 也接受任意目标年级,能绕过“只升一级”。
|
||||||
|
- **S11 新班码冲突后置。** 新高一按 `enrollmentYear+classIndex` 判重,但 `Class.code` 全局唯一;冲突要到事务里才失败,没有预检。
|
||||||
|
- **S12 升级不可预览。** `dryRun` 只返回班级数/学生数;没有逐班、逐状态明细、阻断项、计划哈希。`PromotionRun` 没有操作人、明细、快照摘要。
|
||||||
|
- **S13 升级年份任意。** `POST /term/promotion/run` 接受 2000–2100 的任意 `year`,可手动升级未来学年并生成假档案;没有“只能补跑应执行学年”的限制。
|
||||||
|
- **S14 死代码。** `archives.service.createArchive` 仍存在,但没有路由;文档明确“不提供创建归档代替年度升级的入口”。保留会让人误以为有第二条毕业路径。
|
||||||
|
- **S15 撤回转出限制过死。** `restoreTransferred` 只允许原班仍在读;原班已归档时无法把学生恢复到其他班,也没有审计记录。
|
||||||
|
|
||||||
|
## 3. 目标与非目标
|
||||||
|
|
||||||
|
### 3.1 目标
|
||||||
|
|
||||||
|
权限组:
|
||||||
|
|
||||||
|
1. 每个权限键至少有一个真实服务端入口;每个受控入口至少有一个权限键。
|
||||||
|
2. 数据范围可配置、可校验、可展示;不再用自由文本猜年级。
|
||||||
|
3. 防止权限管理员自我提权、跨角色分配默认组、越范围授权。
|
||||||
|
4. 权限变更可审计、可预览影响;成员列表不再 N+1。
|
||||||
|
5. Web 菜单/页面/按钮按生效权限门控,而不是继续写死 ADMIN。
|
||||||
|
|
||||||
|
学生年度流程:
|
||||||
|
|
||||||
|
1. 学生状态、班级变更、转出、转入、毕业、升级只有一条受控路径。
|
||||||
|
2. 所有写操作在事务内完成,并写可追溯的历史(操作人、批次、学年、原/新班级与年级)。
|
||||||
|
3. 在读/归档/转出查询语义一致;转出历史必须查得到。
|
||||||
|
4. 年度升级支持可配置学段、逐班预览、阻断项、幂等执行、快照与执行记录。
|
||||||
|
5. 升级不丢学生、不清空未确认的数据;毕业归档包含转出/停用学生。
|
||||||
|
|
||||||
|
### 3.2 非目标
|
||||||
|
|
||||||
|
- 不引入学生个人账号、SSO、多租户、多学校隔离。
|
||||||
|
- 不重做成绩/作业/课表业务,只改权限入口与学年流程的边界。
|
||||||
|
- 不做“一键回滚整个学年升级”;回滚依赖执行前快照与手工恢复,UI 必须写清楚。
|
||||||
|
- 不升版本、不发版、不改 `releases/`。
|
||||||
|
- 不为了兼容旧前端而保留两套权限事实源;DTO 兼容字段可以由新字段派生。
|
||||||
|
|
||||||
|
## 4. 必须实现的设计
|
||||||
|
|
||||||
|
### 4.1 权限键矩阵(单一事实源)
|
||||||
|
|
||||||
|
以 `shared/src/permissions.ts` 为准扩展现有目录;不要新建第二份键表。
|
||||||
|
|
||||||
|
| 能力/入口 | 权限键 | 守卫方式 | 范围与备注 |
|
||||||
|
| ---------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ | ------------------------------------------------------------------------ |
|
||||||
|
| 班级列表/详情读取 | `class:read` | `requireStaffPermission` | 班级端仍看自己绑定的班;教师按 scope |
|
||||||
|
| 班级创建/修改/删除、班级账号、班主任/任课分配 | `class:manage` | `requirePermission` + 服务层 `assertPermission` + 目标班范围 | 创建/删除建议要求 scope=ALL;修改要求目标班在 scope 内 |
|
||||||
|
| 学生名单读取、班级名单读取 | `roster:read` | `requireStaffPermission` | 班级端本班;教师按 scope |
|
||||||
|
| 学生新建/资料修改/导入/调班/转出/转入/撤回转出 | `roster:manage` | `requirePermission` + 服务层断言 | 跨班操作要求 scope=ALL 或同时覆盖源班与目标班;ADMIN 不受范围限制 |
|
||||||
|
| 教师读取/维护 | `teacher:read` / `teacher:manage` | 现有守卫 | 导入教师仍要求 `teacher:manage` + `import:teachers` |
|
||||||
|
| 课表读取/编辑 | `schedule:read` / `schedule:write` | `requireStaffPermission` / `requirePermission` | 班级端读本班;写仍要求班主任/管理员 |
|
||||||
|
| 全校时间表 | `timetable:manage` | 现有守卫 | 不变 |
|
||||||
|
| 作业读取/写入 | `homework:read` / `homework:write` | `requireStaffPermission` | 班级端可代录作业;写仍按科目收口 |
|
||||||
|
| 通知读取/发布/删除 | `notification:read` / `notification:publish` / `notification:delete` | `requireStaffPermission` / `requirePermission` | 班级端可发布;删除仍只给有键者 |
|
||||||
|
| 成绩读取/写入/导入/个人明细 | `grade:read` / `grade:write` / `grade:import` / `student:grade-query` | 现有守卫 | 班级端只读聚合;明细受班级开关 |
|
||||||
|
| 学期周次读取/修改 | `term:read` / `term:write` | 现有守卫 | 修改仅授权者 |
|
||||||
|
| 学年升级读取/配置/预览/执行/手动升级 | **新增 `promotion:manage`** | `requirePermission('promotion:manage')` | 默认仅 ADMIN;替代 `system:manage`;范围按 `archive:manage` 同等敏感处理 |
|
||||||
|
| 毕业归档读取/撤销 | `archive:manage` | `requirePermission` + 服务层 `assertPermission` | 不做手动创建归档;撤回转出改由 `roster:manage` 控制 |
|
||||||
|
| 学生/教师/课表/任课导入 | `import:*` | 路由 + 服务层 | 必须真正按键判断,不能再用 `role !== 'ADMIN'` 代替 |
|
||||||
|
| 客户端/远控/更新 | `client:*` / `remote:control` | 现有守卫 | 不变 |
|
||||||
|
| 系统/数据库/权限 | `system:manage` / `database:manage` / `permission:manage` | 现有守卫 | 不变 |
|
||||||
|
|
||||||
|
强制规则:
|
||||||
|
|
||||||
|
1. **新增键必须同步**:`PERMISSION_KEYS`、`PERMISSION_LABELS`、`PERMISSION_CATEGORIES`、`PERMISSION_DEFAULT_ROLES`、`ROLE_DEFAULT_PERMISSIONS`(派生即可)、必要的 `PERMISSION_MATRIX`。
|
||||||
|
2. **旧库迁移**:`ensureDefaultGroups()` 的 `update: {}` 不会给旧库默认组补新键;`promotion:manage` 必须通过迁移插入已有的 ADMIN 默认组(用 `INSERT ... SELECT ... WHERE defaultRole='ADMIN'`,旧库尚未创建默认组时允许 0 行)。新库由 `ROLE_DEFAULT_PERMISSIONS.ADMIN` 自然包含;随后 `ensureDefaultGroups()` 新建的 ADMIN 默认组也会包含该键。
|
||||||
|
3. **删除装饰键**:如果某个键最终找不到任何真实入口,就删除它并更新迁移/文档,不要保留“看起来能用”的键。
|
||||||
|
4. **班级端兼容**:凡是班级端会调的读/写路由,用 `requireStaffPermission` 或显式 `isClassDevice` 分支;不要因为加了 `requirePermission` 把班级端挡死。改完必须跑桌面客户端相关验收。
|
||||||
|
5. **页面门控**:`students` 用 `permission: 'roster:read'`,`classes` 用 `class:read`,`archives` 用 `archive:manage`,`term-weeks` 用 `term:read`;写按钮再用 `auth.can('...:manage')` 隐藏。路由守卫与服务端都要检查,前端隐藏不能代替鉴权。
|
||||||
|
|
||||||
|
### 4.2 服务端断言分层
|
||||||
|
|
||||||
|
统一为三层,顺序不能反:
|
||||||
|
|
||||||
|
1. **路由层**:`requirePermission`(员工必须持有键,班级端必被挡)、`requireStaffPermission`(班级端放行,员工查键)、`requireAnyPermission`(读或写任一即可)。
|
||||||
|
2. **服务层权限断言**:`assertPermission(user, key)` / `assertAnyPermission(user, keys)`;不要在 service 里继续写 `user.role === 'ADMIN'` 来代替已有键。
|
||||||
|
3. **业务关系断言**:`assertClassAccess`、`assertCanManageRoster`、`assertCanManageSubjectContent`、班主任/任课关系、班级端开关;这些不能被权限组替代。
|
||||||
|
|
||||||
|
建议新增/整理:
|
||||||
|
|
||||||
|
- `lib/permissions.ts`:`assertPermission`、`assertAnyPermission`、`hasEffectivePermission`。
|
||||||
|
- `lib/access.ts`:`assertClassInScope(user, classId, key)`、`assertClassesInScope`;把 `assertCanManageClasses`、`assertCanManageRoster` 改为“键 + 关系 + 范围”,而不是只认 ADMIN。
|
||||||
|
- 避免重复查询:路由中间件已经算过 `req.auth.permissions`;能传上下文就传,不能传时至少不要在同一请求里重复查两次权限组。
|
||||||
|
- **班级端例外**:`assertPermission` 只适用于员工;班级端可用的路径用 `assertStaffPermission` 或显式 `isClassDevice` 分支,不能把班级端的空权限集合当成“没有权限”而误伤作业代录、通知/叫人等既有能力。
|
||||||
|
|
||||||
|
### 4.3 数据范围模型
|
||||||
|
|
||||||
|
在 shared 中保留 `PermissionScope = 'SELF_CLASS' | 'GRADE' | 'DIVISION' | 'ALL'`,增加统一解析与展示:
|
||||||
|
|
||||||
|
- `SELF_CLASS`:`scopeValue` 只允许 `''`(班主任或任课班)或 `'HEAD'`(仅班主任班);其他值拒绝。
|
||||||
|
- `GRADE`:只允许一个年级;`DIVISION`:允许逗号分隔的年级集合;统一全角逗号/顿号/空白,去重排序。
|
||||||
|
- `ALL`:忽略 `scopeValue`,服务端强制存空串。
|
||||||
|
- `GRADE`/`DIVISION` 的空值、未知年级一律 400;未知年级的判定基准是数据库中 `Class.grade` 的 distinct 值加上升级计划里的年级。
|
||||||
|
- 新增 `GET /permissions/scopes`:返回可选年级、部范围建议、当前计划年级;权限页用选择器,不再让用户手填。
|
||||||
|
- `teacherScopeWhere` 必须走同一个解析器;不要在各处 `split(',')`。
|
||||||
|
- DTO 增加 `scopeLabel`(中文展示),避免前端各自拼。
|
||||||
|
|
||||||
|
### 4.4 防提权与审计
|
||||||
|
|
||||||
|
必须实现:
|
||||||
|
|
||||||
|
1. **默认组只能给同角色**:`PermissionGroup.defaultRole` 非空的组,只能分配给相同 `User.role` 的账号;ADMIN 默认组不能分配给 TEACHER。显式分配教师默认组给教师是允许的(用于“保留默认 + 追加权限”)。
|
||||||
|
2. **不能授予自己没有的键**:非 ADMIN 的 `permission:manage` 账号,创建/修改组时提交的键集合必须是其生效权限的子集;修改已有组时,组内现有键也必须是其子集;否则 403。
|
||||||
|
3. **不能分配超出自己的组**:非 ADMIN 只能把“组内所有键都在自己权限内”的组分配给别人;不能给自己分配会新增权限的组。
|
||||||
|
4. **范围提权**:非 ADMIN 不得创建/修改/分配 `scope=ALL`;`GRADE`/`DIVISION` 组只允许 ADMIN,或范围覆盖目标年级的账号(最简实现:仅 ADMIN 可管理非 `SELF_CLASS` 组)。
|
||||||
|
5. **默认组编辑**:ADMIN 默认组、TEACHER 默认组影响所有未分组账号,只有 ADMIN 可改;非 ADMIN 的权限管理员只能改自定义组。
|
||||||
|
6. **内置组**:仍不可删除;改名/改键按上述规则。
|
||||||
|
7. **审计**:新增 `PermissionAudit` 表(`id`、`actorId`、`actorName`、`action`、`targetType`、`targetId`、`beforeJson`、`afterJson`、`createdAt`),记录组的增删改、成员分配、默认组修改。同步 migration 与 `SNAPSHOT_TABLES`。提供分页读取接口(`permission:manage`)和权限页“变更记录”Tab。
|
||||||
|
8. **成员列表**:`listMembers` 一次预取所有组 + 默认组,内存计算每个账号的生效键;`PermissionMemberDto` 增加 `permissions: PermissionKey[]` 与 `scopeLabels`,`fixed` 要么删除,要么只对真正不可分配的账号为 true;不要保留永远 false 的字段。
|
||||||
|
9. **组详情**:新增 `GET /permissions/groups/:id/members` 或在组列表 DTO 带成员摘要;权限页可展开查看成员,不再只有数量。
|
||||||
|
10. **保存前预览**:前端在编辑组/分配组时展示“将影响 N 名成员、新增/移除哪些键”;默认组分配时提示“已选组会替代角色默认模板,如需保留请同时选择教师默认组”。
|
||||||
|
|
||||||
|
### 4.5 学生状态机
|
||||||
|
|
||||||
|
只允许以下状态与迁移;`archivedYearId` 是与状态正交的归档标记,不是第五种状态。
|
||||||
|
|
||||||
|
| 当前状态 | 允许动作 | 结果 |
|
||||||
|
| ------------- | ------------------------------------- | ------------------------------------------------------------------------------ |
|
||||||
|
| `active` | 调班、转出、停用、随年度升级毕业/归档 | 调班改 `classId`;转出→`transferred`;停用→`inactive`;毕业→`graduated` + 归档 |
|
||||||
|
| `inactive` | 复学、转出、随年度升级归档 | 复学→`active`;转出→`transferred`;归档保持 `inactive` |
|
||||||
|
| `transferred` | 撤回转出(原班或指定在读班) | 回到 `active`;保留原转出历史 |
|
||||||
|
| `graduated` | 仅撤销归档(ADMIN) | 回到 `active`;不通过名单编辑直接改 |
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
|
||||||
|
- `PATCH /students/:id` 直接改 `status` 为 `transferred`/`graduated`。
|
||||||
|
- 把 `graduated`/已归档学生加入班级或“复活”。
|
||||||
|
- 对 `transferred`/`graduated` 再次办理转出。
|
||||||
|
- 删除仍有成绩、作业、已读或历史的学生。
|
||||||
|
|
||||||
|
### 4.6 写入口与 API
|
||||||
|
|
||||||
|
| 接口 | 权限 | 行为 |
|
||||||
|
| -------------------------------------------------------------------------- | --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `POST /students` | `roster:manage` | 只允许创建 `active`/`inactive`;学号重复 409,不复活历史学生 |
|
||||||
|
| `PATCH /students/:id` | `roster:manage` | 只改姓名、性别、家长手机号、学号(学号变更需唯一);**移除 `classId`/`status`**,收到就 400 并提示使用专用接口 |
|
||||||
|
| `POST /students/transfer` | `roster:manage` | 批量调班;事务内改班 + 写历史;目标班必须未归档;默认只允许同年级,跨年级需 ADMIN + `allowGradeChange` + 备注 |
|
||||||
|
| `POST /students/transfer-out` | `roster:manage` | 只允许 `active`/`inactive`;写 `transferredAt`、`transferNote`、可选取向学校;保留 `classId` 作历史;写 `kind=transfer-out` |
|
||||||
|
| `POST /students/transfer-in` | `roster:manage` | 新建或把 `transferred` 学生重新入校;必须指定在读班;记录来源学校;写 `kind=transfer-in` |
|
||||||
|
| `POST /students/:id/restore`(或保留 `/archives/transferred/:id/restore`) | `roster:manage` | 撤回转出;可指定目标在读班;原班已归档时允许转到其他班;写 `kind=restore`;保留旧路径时同步改键,归档页按钮按 `auth.can('roster:manage')` 显示 |
|
||||||
|
| `DELETE /students/:id` | `roster:manage` | 默认软停用;仅当无成绩/作业/已读/历史且状态允许时才硬删;硬删需 ADMIN 二次确认 |
|
||||||
|
| `GET /students/transfers` | `roster:read` | 不再按学生状态过滤;按批次/学生/班级/学年分页;转出记录必须可见 |
|
||||||
|
| `GET /students/:id/transfers` | `roster:read` + 班级范围 | 只读权限也能查历史;不再要求 `roster:manage` |
|
||||||
|
| `POST /classes/:id/students` | `roster:manage` | 已存在的在读学生走调班生命周期;`transferred` 需显式转入;`graduated`/归档一律 409 |
|
||||||
|
| `DELETE /classes/:id/students/:studentId` | `roster:manage` | 走“移出班级”生命周期,写历史 + 广播,不直接清空 |
|
||||||
|
| `GET /classes/:id/students` | `roster:read`(班级端放行) | 只返回在读;归档/转出走归档页 |
|
||||||
|
|
||||||
|
实现要求:
|
||||||
|
|
||||||
|
- 所有“学生 + 历史”写入在**同一个事务**内;失败一起回滚。
|
||||||
|
- 每次批量操作生成 `batchId`(UUID),同批次记录可一起查询/撤销说明。
|
||||||
|
- 写操作提交后再广播 `classUpdated`,旧班与新班都通知。
|
||||||
|
- 调班/转出/转入/复学/移出统一收进 `lib/student-lifecycle.ts`,`students.service` 与 `classes.service` 不再各写一套。
|
||||||
|
- 错误文案必须可操作,例如“该生已毕业,请先在归档页撤销归档”“目标班级已归档,不能调入”。
|
||||||
|
|
||||||
|
### 4.7 历史模型
|
||||||
|
|
||||||
|
把 `StudentClassTransfer` 从“调班历史”升级为“学生生命周期历史”:
|
||||||
|
|
||||||
|
- 新增 `kind String @default("class-change")`:`class-change` / `transfer-in` / `transfer-out` / `restore` / `unassign`。
|
||||||
|
- 新增 `batchId String @default("")`,加索引。
|
||||||
|
- 新增 `academicYear Int?`:写入时按升级计划/当前学年计算,历史配置变化不影响旧记录。
|
||||||
|
- 新增 `fromGrade String @default("")`、`toGrade String @default("")`、`fromSchool String @default("")`、`toSchool String @default("")`。
|
||||||
|
- 旧 `mode` 字段迁移为 `kind`(`single`/`batch` → `class-change`,`transfer-out` → `transfer-out`,`restore-transfer` → `restore`),不要长期双写两个事实源;DTO 保留 `mode` 兼容字段时由 `kind` 派生,并标注 deprecated。
|
||||||
|
- `ArchivedYear` 增加 `inactiveCount Int @default(0)`;`PromotionRun` 增加 `operatorId`、`operatorName`、`detailsJson`、`snapshotSha256`、`error`(`message` 保留)。
|
||||||
|
- 新表/新字段同步 `SNAPSHOT_TABLES`;迁移为旧行补默认值,不重写历史语义。
|
||||||
|
|
||||||
|
### 4.8 年度升级计划
|
||||||
|
|
||||||
|
不要把 `高一/高二/高三` 写死在服务里。使用“轨道 + 有序学段”:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tracks": [
|
||||||
|
{
|
||||||
|
"id": "high",
|
||||||
|
"name": "高中",
|
||||||
|
"entryClassCount": 6,
|
||||||
|
"stages": [{ "grade": "高一" }, { "grade": "高二" }, { "grade": "高三", "terminal": true }]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"termStartDate": null,
|
||||||
|
"resetTermWeeks": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 默认只配置高中一条轨道,保持现有行为;结构支持初中/小学/完全中学多条轨道。
|
||||||
|
- 每个年级只能出现在一条轨道;同一轨道内顺序即升级顺序;最后一个学段必须 `terminal: true`。
|
||||||
|
- `entryClassCount` 是每条轨道起始年级每年新建班数;0 表示不新建。
|
||||||
|
- 计划存 `AppSetting` 的 `term.promotionPlan`(JSON 文本),执行时间仍用 `term.promotionAt`;`promotionStatus` 兼容旧 `{month,day,hour,classCount}` 并归一化为新计划。
|
||||||
|
- 旧配置没有计划时,默认生成高中轨道,`entryClassCount` 取旧 `classCount`。
|
||||||
|
|
||||||
|
升级接口:
|
||||||
|
|
||||||
|
| 接口 | 行为 |
|
||||||
|
| ------------------------------ | --------------------------------------------------------------------------------------------------------- |
|
||||||
|
| `GET /term/promotion` | 返回计划、执行时间、应执行学年、是否待执行、最近执行记录、旧字段兼容 |
|
||||||
|
| `PUT /term/promotion` | 保存执行时间与计划;校验轨道/年级/班数/日期;旧扁平格式继续接受 |
|
||||||
|
| `POST /term/promotion/preview` | 只读预览:逐轨道、逐班、逐状态计数;阻断项;将新建班级;将删除的 `TermWeek` 行数;计划哈希 |
|
||||||
|
| `POST /term/promotion/run` | 必须带 `year`、`planHash`、`confirm: true`;只允许当前应执行学年或 ADMIN 明确补跑过去学年;不允许未来学年 |
|
||||||
|
| `POST /classes/promote` | 目标年级由计划推导,不再接受自由文本;terminal 班拒绝并提示走年度升级 |
|
||||||
|
|
||||||
|
预览必须包含:
|
||||||
|
|
||||||
|
- 每条轨道:将升级的班、将归档的班、将新建的班(含生成班级码)、学生 `active/inactive/transferred` 计数。
|
||||||
|
- 阻断项(有任意一项就拒绝执行,返回 409 + 结构化 `details`):
|
||||||
|
- 在读班级的 `grade` 不在计划内;
|
||||||
|
- `enrollmentYear` 缺失或同一届已存在 `ArchivedYear`;
|
||||||
|
- 新班班级码冲突且无法生成唯一码;
|
||||||
|
- 同一轨道同一届出现重复 `classIndex`;
|
||||||
|
- 计划哈希不匹配或配置在校验后被修改。
|
||||||
|
- 警告项:无班主任/无任课的新班、`TermWeek` 将被清空、`resetTermWeeks=false` 时旧周次会继续覆盖新学年。
|
||||||
|
|
||||||
|
执行顺序(单事务,快照成功后才开始):
|
||||||
|
|
||||||
|
1. 计算并校验计划与预览;写入/更新 `PromotionRun(status='running')`。
|
||||||
|
2. terminal 学段:按 `enrollmentYear` 分组创建/校验 `ArchivedYear`;班级写 `archivedYearId/archivedAt`;**所有学生**按状态处理并写 `archivedYearId/archivedAt`:`active → graduated`,`transferred` 保持,`inactive` 保持。统计 `studentCount/transferredCount/inactiveCount/classCount/homeworkCount/notificationCount`。
|
||||||
|
3. 非 terminal 学段:只改 `grade` 为下一学段;清空 `Class.termStartDate`;保留班级/学生/作业/成绩。
|
||||||
|
4. 每条轨道起始年级:按 `entryClassCount` 新建骨架班;`enrollmentYear=year`、`classIndex=1..N`、`grade=起始年级`、`teacherId=null`、无课程;班级码必须唯一;`termWeeks` 取默认值并受全校上限约束。
|
||||||
|
5. 学期:`term.startDate` 设为计划日期(默认 `${year}-09-01`)或计划中的 `termStartDate`;`resetTermWeeks=true` 时删除 `TermWeek`(预览已明确行数),否则保留并警告;新班 `termStartDate=null`。
|
||||||
|
6. `PromotionRun(status='done')` 写操作人、明细 JSON、快照 SHA256;提交后广播课表/班级更新。
|
||||||
|
|
||||||
|
幂等与并发:
|
||||||
|
|
||||||
|
- `PromotionRun.year` 唯一;已 `done` 直接返回既有记录并标记 `alreadyDone`。
|
||||||
|
- 并发执行只允许一个事务提交;另一个返回既有结果或 409。
|
||||||
|
- 失败保留 `failed` 记录、快照路径与错误;允许修复后重试,重试前删除同年的 `failed/running` 记录。
|
||||||
|
- 快照路径与 SHA256 必须在执行记录和 UI 中可见;不要自动删除升级快照。
|
||||||
|
|
||||||
|
毕业/归档:
|
||||||
|
|
||||||
|
- 毕业只由年度升级产生,不恢复手动 `POST /archives` 入口;删除 `createArchive` 死代码及其 schema。
|
||||||
|
- 撤销归档(`DELETE /archives/:id`)只撤销归档标记:`graduated → active`,`transferred/inactive` 保持状态并清 `archivedYearId`;不回滚年级、不删除新建班、不恢复已清空的 `TermWeek`。UI 必须写明这一点。
|
||||||
|
- 归档详情增加 `inactive` 列表;`getArchivedClassContent` 不变。
|
||||||
|
|
||||||
|
手动升级:
|
||||||
|
|
||||||
|
- `promoteClasses` 从计划推导下一学段;terminal 拒绝;只允许 `promotion:manage`;单事务;广播;记录操作日志。
|
||||||
|
- `PATCH /classes/:id` 移除 `grade` 字段(或仅 ADMIN 且走升级逻辑),否则用户可绕过计划任意改年级。
|
||||||
|
|
||||||
|
### 4.9 Web 管理端
|
||||||
|
|
||||||
|
- `router/index.ts`:按 4.1 改为 `meta.permission`;保留 `database` 等真正仅 ADMIN 的页面的角色门槛;守卫同时检查 `roles` 与 `permission`。
|
||||||
|
- `PermissionsView.vue`:
|
||||||
|
- 组表增加“范围”列(`scopeLabel`)、成员数、更新时间;支持查看成员、复制组。
|
||||||
|
- 编辑弹窗:范围选择器(年级多选/部多选/全校/关联班级),不再手填 `scopeValue`;显示每个键的默认角色与高权限标记;保存前展示影响预览。
|
||||||
|
- 成员页:搜索/筛选;分配弹窗展示“角色默认模板 vs 所选组”的生效差异;默认勾选同角色默认组;跨角色默认组禁止选择。
|
||||||
|
- 新增“变更记录”Tab,读取审计接口。
|
||||||
|
- 所有操作按 `permission:manage` 门控;失败提示用服务端文案。
|
||||||
|
- `StudentsView.vue`:
|
||||||
|
- 状态筛选真正可用;默认“在读”;“包含归档”单独开关。
|
||||||
|
- 操作按钮按 `auth.can('roster:manage')` 显示;只读账号仍能看历史。
|
||||||
|
- 调班弹窗显示源班/目标班/年级变化;跨年级警告;转出弹窗增加“去向学校/原因”;新增“转入”入口。
|
||||||
|
- 编辑表单移除班级/状态字段;删除改为“停用/删除”并说明影响。
|
||||||
|
- 历史抽屉显示类型、批次、学年、原/新班级与年级、操作人、备注;补齐 `restore` 等标签。
|
||||||
|
- `ClassesView.vue`:升级区改为显示“下一学段”,不再自由输入;terminal 班引导到年度升级/归档页;批量升级可在年度升级页完成。
|
||||||
|
- `ArchivesView.vue`:显示转入/转出/停用统计;届别详情增加停用学生;撤销归档确认文案写清“不回滚年级与周次”;不显示“新建归档”。
|
||||||
|
- `PromotionSettings.vue`:重做为“计划 + 预览 + 执行 + 记录”:
|
||||||
|
- 轨道/学段编辑(增删、排序、标记毕业学段)、起始年级班数、执行时间、学期开始日期、是否重置周次。
|
||||||
|
- 预览抽屉:逐班表格、状态计数、阻断项/警告、新班班级码、将删除的周次行数。
|
||||||
|
- 执行前要求确认学年与计划哈希;执行中禁用重复提交;执行后展示快照路径/SHA256/操作人/明细。
|
||||||
|
- 执行记录表:学年、状态、操作人、班级/学生统计、快照、错误。
|
||||||
|
- 移动端:保持现有响应式;权限页/升级页的宽表格允许横向滚动,不新造断点体系。
|
||||||
|
|
||||||
|
## 5. 实施阶段
|
||||||
|
|
||||||
|
### Phase 0:基线与测试骨架
|
||||||
|
|
||||||
|
- 运行现有 `pnpm lint`、`pnpm typecheck`、`pnpm verify:v14`,记录实际输出。
|
||||||
|
- 新增隔离验收脚本骨架(建议 `packages/server/scripts/verify-lifecycle.ts` + 根 `scripts/verify-lifecycle.mjs`,复用 `verify-v14` 的临时 SQLite 模式),先放会失败的用例。
|
||||||
|
- 更新 `scripts/verify-client-migrations.mjs` 中“最后一条迁移名”的硬编码断言,改为读取实际最后一条;新迁移必须能被旧库升级回归覆盖。
|
||||||
|
|
||||||
|
**出口**:基线通过;新测试能跑起来并明确失败原因。
|
||||||
|
|
||||||
|
### Phase 1:权限键接线与页面门控
|
||||||
|
|
||||||
|
- 新增 `promotion:manage` 键与迁移,并把 `/term/promotion*` 从 `system:manage` 切换过来;旧库 ADMIN 默认组补键。
|
||||||
|
- 按 4.1 逐键接入路由;补 `homework:write`、读键、`class:read`、`schedule:read`、`homework:read`、`notification:read`;把归档/升级服务里的 `assertCanManageClasses` 换成对应功能键断言。
|
||||||
|
- 班级端路由改 `requireStaffPermission`,确认桌面客户端调用链。
|
||||||
|
- Web 路由/按钮按权限门控;移除 `students/classes/archives/term-weeks` 的 ADMIN 硬门槛。
|
||||||
|
- 修 `import:students` 的角色替代逻辑。
|
||||||
|
|
||||||
|
**出口**:权限矩阵中每个键至少有一个入口;取消键后接口 403;班级端既有能力不回归。
|
||||||
|
|
||||||
|
### Phase 2:范围、防提权与审计
|
||||||
|
|
||||||
|
- 核对 `promotion:manage` 的旧库补键与默认组行为,确保迁移与 `ensureDefaultGroups()` 不会重复/漏键。
|
||||||
|
- 实现 scope parser、`GET /permissions/scopes`、`teacherScopeWhere` 重写、DTO `scopeLabel`。
|
||||||
|
- 实现 4.4 的防提权规则;`PermissionAudit` 表/接口/UI。
|
||||||
|
- 重写 `listMembers` 为一次预取;补组详情/成员接口。
|
||||||
|
|
||||||
|
**出口**:跨角色默认组分配 403;无键授权 403;范围非法 400;审计可查;成员列表查询数不随人数线性增长。
|
||||||
|
|
||||||
|
### Phase 3:学生状态机与写入口
|
||||||
|
|
||||||
|
- 新增 `lib/student-lifecycle.ts`;改造 students/classes 服务。
|
||||||
|
- 收紧 `PATCH /students`,新增转入/复学;调班/转出/移出全部事务化 + `batchId`。
|
||||||
|
- 历史表迁移与 DTO 兼容;修 `activeStudentWhere`/状态筛选/转出历史。
|
||||||
|
- 删除/停用保护;归档学生不可复活。
|
||||||
|
|
||||||
|
**出口**:S1–S7 的复现用例全部转为通过;调班失败不会留下半截数据;转出历史可查。
|
||||||
|
|
||||||
|
### Phase 4:年度升级计划与归档
|
||||||
|
|
||||||
|
- 新增 `PromotionPlan` 类型、schema、默认计划、迁移。
|
||||||
|
- 实现 preview/run/planHash/年份限制/幂等/快照 SHA256/执行明细。
|
||||||
|
- 重写周次重置、归档学生范围、新班码预检、手动升级。
|
||||||
|
- 删除 `createArchive` 死代码;归档详情补 `inactive`。
|
||||||
|
- 保持 `runPromotion(year, dryRun)` 旧签名可用(供 v14 测试),新增 options 重载;路由层做年份限制。
|
||||||
|
|
||||||
|
**出口**:预览不写库;阻断项可复现;转出/停用学生进入归档;同年重复执行幂等;新班码冲突在预览阶段被拦。
|
||||||
|
|
||||||
|
### Phase 5:UI 与文档
|
||||||
|
|
||||||
|
- 完成 4.9 的页面改造。
|
||||||
|
- 更新 `docs/dev/permissions.md`、`docs/app/classes.md`、`docs/dev/api.md`、`docs/dev/data-model.md`、`docs/dev/implementation.md`、`docs/dev/testing.md`、`docs/get-started/web-admin.md`;不要手抄键数/测试数。
|
||||||
|
- 为新增接口补 API 文档表格;为迁移补升级说明。
|
||||||
|
|
||||||
|
**出口**:`pnpm docs:check` 通过;UI 构建通过;关键路径有隔离 UI 回归或手工验收记录。
|
||||||
|
|
||||||
|
## 6. 验收清单
|
||||||
|
|
||||||
|
### 权限组
|
||||||
|
|
||||||
|
- [ ] 逐个键验证:有键可用、无键 403;不存在“有键但接口不检查”或“接口检查但键不在目录”。
|
||||||
|
- [ ] 班级端:本班名单/课表/通知/叫人/作业代录可用;成绩写入 403;个人成绩查询受开关控制。
|
||||||
|
- [ ] 教师默认组、空自定义组、多个组并集、无组回落默认组四种情况均符合文档。
|
||||||
|
- [ ] 教师被授予 `permission:manage` 后不能创建/分配超出自身权限的组;不能给自己提权;不能分配 ADMIN 默认组给教师。
|
||||||
|
- [ ] 非 ADMIN 不能管理 `GRADE`/`DIVISION`/`ALL` 范围组(或按你实现的覆盖规则验证)。
|
||||||
|
- [ ] 范围空值、未知年级、全角逗号、重复年级均被规范化或 400。
|
||||||
|
- [ ] 权限变更审计有操作人、目标、前后差异;成员列表不再 N+1。
|
||||||
|
- [ ] 权限页的“影响预览”与实际生效一致;默认组文案不再误导。
|
||||||
|
|
||||||
|
### 学生流程
|
||||||
|
|
||||||
|
- [ ] `PATCH /students` 不能改班级/状态;直接构造请求返回 400。
|
||||||
|
- [ ] 调班:源班/目标班范围校验;跨年级默认拒绝;历史与班级更新同事务;旧班/新班都收到实时事件。
|
||||||
|
- [ ] 转出:只接受在读/停用;毕业生/已归档 409;写 `transferredAt`、去向、历史;重复转出幂等或 409。
|
||||||
|
- [ ] 转入/复学:新建与 `transferred` 重入两条路径都正确;学号唯一;归档班不能作为目标。
|
||||||
|
- [ ] 撤回转出:原班在读可恢复;原班归档时可指定其他在读班;历史保留。
|
||||||
|
- [ ] 归档学生不可通过 `addStudent` 复活;`isActiveStudent` 不再把 `graduated` 当在读。
|
||||||
|
- [ ] 状态筛选 `transferred/graduated/inactive` 均能返回正确记录;转出历史在班级/学生维度都可见。
|
||||||
|
- [ ] 删除保护:有成绩/作业/已读/历史的学生不能硬删;软停用后不在在读名单。
|
||||||
|
- [ ] 升级预览:逐班、逐状态、阻断项、新班码、周次影响都可见;不写库。
|
||||||
|
- [ ] 升级执行:毕业班归档;非毕业班只升一级;转出/停用学生挂届别;新班无班主任/无课程;同年重复执行幂等。
|
||||||
|
- [ ] 计划含初中/多轨道时,各轨道独立升级;未知年级被阻断。
|
||||||
|
- [ ] `termStartDate` 按计划写入;`resetTermWeeks=false` 时不清空且 UI 有警告;清空时预览行数准确。
|
||||||
|
- [ ] 未来学年不能执行;过去学年仅 ADMIN 补跑;`planHash` 不匹配 409。
|
||||||
|
- [ ] 手动升级只接受计划中的下一学段;terminal 班拒绝;`PATCH /classes` 不能绕过。
|
||||||
|
|
||||||
|
### 兼容与数据
|
||||||
|
|
||||||
|
- [ ] 旧库升级:新列有默认值,旧 `mode` 正确映射 `kind`,旧权限组不丢键,ADMIN 默认组补 `promotion:manage`。
|
||||||
|
- [ ] `SNAPSHOT_TABLES` 包含所有新表;快照导出/恢复/换库不丢数据。
|
||||||
|
- [ ] 旧 Web 客户端/PWA 缓存发送旧请求体时,接口给出明确 400 或兼容处理,不静默写错。
|
||||||
|
- [ ] 桌面客户端离线/在线冒烟通过;未破坏班级端登录、心跳、作业、通知。
|
||||||
|
|
||||||
|
## 7. 验证命令
|
||||||
|
|
||||||
|
按改动范围至少执行:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm lint
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm verify:v14
|
||||||
|
pnpm verify:e2e
|
||||||
|
pnpm verify:lifecycle # 新增脚本;先加 package.json script
|
||||||
|
pnpm verify:client-migrations
|
||||||
|
pnpm --filter @classhelper/web-admin build
|
||||||
|
pnpm verify:web:isolated
|
||||||
|
pnpm docs:check # 改了 docs 时
|
||||||
|
pnpm verify:desktop # 改了班级端会调用的路由/契约时
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:
|
||||||
|
|
||||||
|
- `verify-client-migrations.mjs` 目前硬编码最后一条迁移名;新增迁移后必须同步改为动态读取,并验证旧库升级。
|
||||||
|
- 不在真实业务库上跑升级;升级测试用临时 SQLite。
|
||||||
|
- 单元/接口断言优先直接调用 service + 临时库;UI 回归用隔离服务。
|
||||||
|
- 数量只记录本次输出,不抄历史数字。
|
||||||
|
|
||||||
|
## 8. 交付物与完成定义
|
||||||
|
|
||||||
|
每次汇报必须包含:
|
||||||
|
|
||||||
|
1. 本阶段改动文件清单与一句话说明。
|
||||||
|
2. 迁移说明:新表/新列、旧库兼容、`SNAPSHOT_TABLES` 变更、是否需要管理员补权限。
|
||||||
|
3. 实际执行的验证命令与输出摘要(通过/失败数、失败原因)。
|
||||||
|
4. 行为变化与兼容性:旧前端、班级端、离线客户端、旧权限组。
|
||||||
|
5. 风险与未完成项;不允许用“应该可以”代替验证。
|
||||||
|
|
||||||
|
完成定义:
|
||||||
|
|
||||||
|
- 第 6 节清单全部通过,或明确列出未通过项及原因、影响、后续任务。
|
||||||
|
- 没有装饰性权限键、没有第二条毕业路径、没有硬编码三个年级、没有绕过生命周期的写入口。
|
||||||
|
- 所有新增/修改接口在 `docs/dev/api.md` 有边界说明;所有新增权限键在 `docs/dev/permissions.md` 有归属说明。
|
||||||
|
|
||||||
|
## 9. 如果只能做最小闭环
|
||||||
|
|
||||||
|
按以下顺序交付,仍须保证每步可运行:
|
||||||
|
|
||||||
|
1. 修 S1–S7(状态机、事务、历史查询、复活、删除保护)。
|
||||||
|
2. 接通 P1–P5(权限键、班级端守卫、页面门控、`promotion:manage`)。
|
||||||
|
3. 升级预览/执行正确性(S8–S13),暂不做多轨道 UI 的完整编辑,只保留默认高中轨道 + 只读计划展示。
|
||||||
|
4. 最后做 P6–P11(防提权、审计、权限页预览)。
|
||||||
|
|
||||||
|
不要为了“最小闭环”保留已知的数据不一致;宁可缩小 UI 范围,也不要保留两条写路径。
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
# 权限组治理与学生年度生命周期交付记录
|
||||||
|
|
||||||
|
日期:2026-10-23(Asia/Shanghai)。工作目录:`D:\classhelper`。版本保持 `1.4.1`。
|
||||||
|
|
||||||
|
任务源是 `prompts/permission-and-student-lifecycle.prompt.md`;用户消息中的路径缺少 prompts 后的目录分隔符,已按仓库实际文件定位。
|
||||||
|
工作目录未包含 `.git`,无法分阶段提交或提供 Git diff;没有初始化仓库、发布远端或改动 releases。已有无关工作保留。
|
||||||
|
|
||||||
|
## 实施与阶段验证
|
||||||
|
|
||||||
|
- Phase 0:先阅读规则、指定文档和实现。基线 lint/typecheck 通过;v14 最初因缺测试密钥退出,隔离注入后 37/37 通过。新增专项先复现“资料 PATCH 接受毕业状态”的失败,再修复。
|
||||||
|
- Phase 1:读写功能键、班级端守卫、独立升级键、页面门控和教师名单导入接通。取消键返回 403;教师全校授权可管理班级、查看归档和升级配置;班级详情不向无名单键者附带学生。
|
||||||
|
- Phase 2:统一范围解析,阻止跨角色默认组、越键和越范围授权;角色默认组只由 ADMIN 编辑。审计同事务,成员权限预取计算;增加成员不增加权限查询次数。
|
||||||
|
- Phase 3:资料不接受状态/班级,调班、转入出、撤回、复学、停用及移出共用事务入口。故障触发器证明历史失败时班级修改回滚;转出历史可读,毕业/归档不可复活,业务及历史阻止硬删除。
|
||||||
|
- Phase 4:按轨道与有序学段升级;未知年级、缺届别、重复班号、已有档案、新班码冲突阻断。预览不写库,执行复核哈希,保存快照/SHA256,归档包含转出与停用,周次按计划保留或重置,同年幂等。
|
||||||
|
- Phase 5:完成权限、学生、班级、归档、升级界面及指定文档同步。Web 构建和文档严格构建通过;关键页面完成隔离浏览器人工验收。
|
||||||
|
|
||||||
|
## 迁移与兼容
|
||||||
|
|
||||||
|
1. `20261023000000_promotion_permission`:为已存在的 ADMIN 默认组补 promotion:manage;无默认组时运行时初始模板自然包含。不会覆盖已有默认组的其他键。
|
||||||
|
2. `20261023010000_student_lifecycle_audit`:增加 PermissionAudit;历史新增 kind/batchId/academicYear/年级与学校快照;ArchivedYear 新增 inactiveCount;PromotionRun 新增操作人、明细、摘要和错误。
|
||||||
|
3. 旧 single/batch → class-change,transfer-out 保持,restore-transfer → restore,然后删除 mode 列。DTO 中 mode 仅派生兼容,不再双写。
|
||||||
|
4. SNAPSHOT_TABLES 已包含 PermissionAudit。旧快照没有此表时恢复为空,旧历史 mode 恢复时转换,缺列使用默认值。快照和执行记录不自动删除。
|
||||||
|
5. 自定义权限组不会自动得到新升级键;已显式分配其他组的管理员或教务账号需要显式授予。旧客户端资料请求携带 classId/status、班级 PATCH 携带 grade 返回明确 400;旧 dryRun 升级请求提示改用预览和哈希确认接口。
|
||||||
|
6. CLASS_DEVICE 权限集合仍为空。本班名单、课表、通知、叫人及代录作业保留,成绩写入拒绝,个人成绩查询开关保护保留。离线缓存、preload、CSP、设备心跳实现未改。
|
||||||
|
7. 迁移、升级、种子与生命周期写入只在本次专用临时 SQLite 运行,未对业务库执行迁移、seed、reset 或升级。修正旧 e2e 固定 dev.db 连接探针;其本次创建的 0 字节空文件已核查后移除。
|
||||||
|
|
||||||
|
## 实际验证结果
|
||||||
|
|
||||||
|
| 命令 | 本次结果 |
|
||||||
|
| ------------------------------------------ | -------------------------------------------------------------------- |
|
||||||
|
| pnpm lint | 通过,无错误或警告 |
|
||||||
|
| pnpm typecheck | 通过,shared/server/web/desktop 全部完成 |
|
||||||
|
| pnpm verify:lifecycle | 32/32 通过,最近一次进程退出码 0 |
|
||||||
|
| pnpm verify:v14 | 37/37 通过,进程退出码 0 |
|
||||||
|
| pnpm verify:e2e | 244/244 通过,进程退出码 0 |
|
||||||
|
| pnpm verify:client-migrations | 通过:旧默认组补键、旧 mode 映射、旧行默认值、空组保留及重启幂等 |
|
||||||
|
| pnpm --filter @classhelper/web-admin build | 通过 |
|
||||||
|
| pnpm build:desktop | 通过,产物凭据检查通过 |
|
||||||
|
| pnpm docs:check | 通过,docfx 0 warning / 0 error |
|
||||||
|
| pnpm verify:web:isolated | 未通过:Electron 启动退出 2147483651(0x80000003),尚未进入 UI 用例 |
|
||||||
|
| pnpm verify:desktop | 未通过:同样在 Electron 启动阶段退出 2147483651,尚未进入客户端用例 |
|
||||||
|
|
||||||
|
受限终端最初读取 node_modules 遇到 EPERM;上表运行使用自动审批后的正常权限终端。pnpm 为仓库锁定的 11.8.0。
|
||||||
|
docfx 原先缺失,已安装到 `.cache/docfx`,NuGet/.NET 工具缓存也限制在仓库 `.cache`,没有改构建脚本的全局查找逻辑。
|
||||||
|
文档重跑时需将 `.cache/docfx` 加入 PATH。
|
||||||
|
|
||||||
|
原先 e2e 中“班级端不得通知/叫人”“名单仅管理员”“固定管理员字段”等旧断言与本次明确要求冲突,已改为验证新行为,并保留无键、跨班、成绩写入等拒绝断言。
|
||||||
|
导入成功夹具改用合法独立学生,结束时停用保留历史,恢复原在读名单,避免影响后续全班已读/完成统计。
|
||||||
|
|
||||||
|
## 隔离浏览器人工验收
|
||||||
|
|
||||||
|
在 127.0.0.1:54127 的专用临时库使用临时管理员、临时教师执行,没有连接真实班级。浏览器保持后台。
|
||||||
|
|
||||||
|
- 管理员能打开独立“学年升级”页面,看到轨道、执行时间、开学日、周次策略与记录。
|
||||||
|
- 点击预览后显示逐班/逐状态、哈希、周次影响;测试夹具的未知年级显示阻断,执行按钮禁用。未通过浏览器执行升级。
|
||||||
|
- 权限页显示范围标签、实际成员数、更新时间及审计 Tab;编辑级长组使用年级下拉,显示服务端年级选项和影响预览,未提交权限变更。
|
||||||
|
- 学生默认在读;编辑弹窗只有学号、姓名、性别、家长电话,缺少班级/状态字段。
|
||||||
|
- 切到已转出可返回学生;历史抽屉包含转出记录、批次/学年、原新年级和学校字段。
|
||||||
|
- 教师菜单可见班级、学生及学期周次;班级页面可读且隐藏创建、编辑、账号、删除等无管理键按钮。
|
||||||
|
- 目视检查升级预览与名单布局。验收结束后检查 54127 无监听;临时浏览器页由会话清理。
|
||||||
|
|
||||||
|
## 已知限制与后续
|
||||||
|
|
||||||
|
- Electron 自动 UI 与桌面冒烟仍未通过,需要在可正常启动该 Electron 运行时的 Windows 环境补跑;浏览器验收不能证明桌面离线、桥接或原生窗口运行正确。本次没有关闭安全保护来绕过启动问题。
|
||||||
|
- 一次生命周期专项在全部断言通过后的连接释放阶段出现原生退出 0xC0000005;相同命令重跑 32/32 且正常退出。根因未确认,不把这次异常解释为断言失败,也不宣称已根治依赖层退出问题。
|
||||||
|
- SQLite 新库与旧库升级、快照恢复已实测;未配置可用的隔离 MySQL 实例,未做 MySQL 真机读写回归。schema 没有加入 Prisma enum 或 @db.*。
|
||||||
|
- 并发同年升级及幂等已由 v14 回归;没有另行穷举不同数据库进程、所有权限/岗位/接口组合。业务唯一键与事务仍是跨进程保护边界。
|
||||||
|
- 旧库如果已有毕业标记与归档不一致等异常数据,预览会阻断并提示先核对,迁移不擅自猜测或复活学生。
|
||||||
|
|
||||||
|
## 修改文件
|
||||||
|
|
||||||
|
- [docs/app/classes.md](D:/classhelper/docs/app/classes.md):同步现行 API、权限、数据模型或操作/验收说明。
|
||||||
|
- [docs/dev/api.md](D:/classhelper/docs/dev/api.md):同步现行 API、权限、数据模型或操作/验收说明。
|
||||||
|
- [docs/dev/data-model.md](D:/classhelper/docs/dev/data-model.md):同步现行 API、权限、数据模型或操作/验收说明。
|
||||||
|
- [docs/dev/implementation.md](D:/classhelper/docs/dev/implementation.md):同步现行 API、权限、数据模型或操作/验收说明。
|
||||||
|
- [docs/dev/permissions.md](D:/classhelper/docs/dev/permissions.md):同步现行 API、权限、数据模型或操作/验收说明。
|
||||||
|
- [docs/dev/testing.md](D:/classhelper/docs/dev/testing.md):同步现行 API、权限、数据模型或操作/验收说明。
|
||||||
|
- [docs/get-started/web-admin.md](D:/classhelper/docs/get-started/web-admin.md):同步现行 API、权限、数据模型或操作/验收说明。
|
||||||
|
- [package.json](D:/classhelper/package.json):登记 verify:lifecycle,不修改版本。
|
||||||
|
- [packages/server/prisma/migrations/20261023000000_promotion_permission/migration.sql](D:/classhelper/packages/server/prisma/migrations/20261023000000_promotion_permission/migration.sql):新增迁移;不修改任何历史迁移。
|
||||||
|
- [packages/server/prisma/migrations/20261023010000_student_lifecycle_audit/migration.sql](D:/classhelper/packages/server/prisma/migrations/20261023010000_student_lifecycle_audit/migration.sql):新增迁移;不修改任何历史迁移。
|
||||||
|
- [packages/server/prisma/schema.prisma](D:/classhelper/packages/server/prisma/schema.prisma):增加审计与生命周期字段,保持字符串状态和单一跨库 schema。
|
||||||
|
- [packages/server/prisma/seed.ts](D:/classhelper/packages/server/prisma/seed.ts):演示历史写入新 kind 字段;本次只在隔离库执行。
|
||||||
|
- [packages/server/scripts/verify-e2e.mjs](D:/classhelper/packages/server/scripts/verify-e2e.mjs):读取真实快照表注册表,探测当前临时库,并对齐新授权行为及测试数据收尾。
|
||||||
|
- [packages/server/scripts/verify-lifecycle.ts](D:/classhelper/packages/server/scripts/verify-lifecycle.ts):覆盖授权、防提权、事务故障、状态机、多轨道、哈希和快照恢复。
|
||||||
|
- [packages/server/scripts/verify-v14.ts](D:/classhelper/packages/server/scripts/verify-v14.ts):结束初中范围夹具后再执行默认高中计划,不放宽升级断言。
|
||||||
|
- [packages/server/src/lib/access.ts](D:/classhelper/packages/server/src/lib/access.ts):统一功能键、目标范围和业务关系断言;授权员工可管理对应班级。
|
||||||
|
- [packages/server/src/lib/mappers.ts](D:/classhelper/packages/server/src/lib/mappers.ts):输出生命周期字段并从 kind 派生旧 mode。
|
||||||
|
- [packages/server/src/lib/permission-scope.ts](D:/classhelper/packages/server/src/lib/permission-scope.ts):默认组与显式组均按功能键过滤,再使用共享解析器生成范围。
|
||||||
|
- [packages/server/src/lib/permissions.ts](D:/classhelper/packages/server/src/lib/permissions.ts):新增服务断言,保留每请求实时生效和管理员必要入口。
|
||||||
|
- [packages/server/src/lib/settings.ts](D:/classhelper/packages/server/src/lib/settings.ts):登记 term.promotionPlan。
|
||||||
|
- [packages/server/src/lib/snapshot.ts](D:/classhelper/packages/server/src/lib/snapshot.ts):加入 PermissionAudit,兼容缺审计表的旧快照并转换旧 mode。
|
||||||
|
- [packages/server/src/lib/student-lifecycle.ts](D:/classhelper/packages/server/src/lib/student-lifecycle.ts):集中学生状态/班级/历史的事务写入,提交后广播相关班级。
|
||||||
|
- [packages/server/src/lib/students.ts](D:/classhelper/packages/server/src/lib/students.ts):在读统一为 active 且未归档,排除毕业、转出和停用。
|
||||||
|
- [packages/server/src/middleware/auth.ts](D:/classhelper/packages/server/src/middleware/auth.ts):在当前请求复用权限和功能范围上下文,保留班级端显式例外。
|
||||||
|
- [packages/server/src/modules/archives/archives.module.ts](D:/classhelper/packages/server/src/modules/archives/archives.module.ts):移除第二条毕业路径,按功能键读取/撤销归档并复用撤回生命周期。
|
||||||
|
- [packages/server/src/modules/archives/archives.schemas.ts](D:/classhelper/packages/server/src/modules/archives/archives.schemas.ts):移除第二条毕业路径,按功能键读取/撤销归档并复用撤回生命周期。
|
||||||
|
- [packages/server/src/modules/archives/archives.service.ts](D:/classhelper/packages/server/src/modules/archives/archives.service.ts):移除第二条毕业路径,按功能键读取/撤销归档并复用撤回生命周期。
|
||||||
|
- [packages/server/src/modules/archives/transferred.service.ts](D:/classhelper/packages/server/src/modules/archives/transferred.service.ts):移除第二条毕业路径,按功能键读取/撤销归档并复用撤回生命周期。
|
||||||
|
- [packages/server/src/modules/calls/calls.module.ts](D:/classhelper/packages/server/src/modules/calls/calls.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
|
||||||
|
- [packages/server/src/modules/calls/calls.service.ts](D:/classhelper/packages/server/src/modules/calls/calls.service.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
|
||||||
|
- [packages/server/src/modules/class-plans/class-plans.module.ts](D:/classhelper/packages/server/src/modules/class-plans/class-plans.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
|
||||||
|
- [packages/server/src/modules/class-plans/profile-import.service.ts](D:/classhelper/packages/server/src/modules/class-plans/profile-import.service.ts):按具体导入键和写键校验范围,学生导入复用受控生命周期。
|
||||||
|
- [packages/server/src/modules/classes/classes.module.ts](D:/classhelper/packages/server/src/modules/classes/classes.module.ts):班级服务接通授权与范围,名单复用生命周期,阻止直接修改年级。
|
||||||
|
- [packages/server/src/modules/classes/classes.schemas.ts](D:/classhelper/packages/server/src/modules/classes/classes.schemas.ts):班级服务接通授权与范围,名单复用生命周期,阻止直接修改年级。
|
||||||
|
- [packages/server/src/modules/classes/classes.service.ts](D:/classhelper/packages/server/src/modules/classes/classes.service.ts):班级服务接通授权与范围,名单复用生命周期,阻止直接修改年级。
|
||||||
|
- [packages/server/src/modules/courses/courses.module.ts](D:/classhelper/packages/server/src/modules/courses/courses.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
|
||||||
|
- [packages/server/src/modules/homeworks/homeworks.module.ts](D:/classhelper/packages/server/src/modules/homeworks/homeworks.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
|
||||||
|
- [packages/server/src/modules/homeworks/homeworks.service.ts](D:/classhelper/packages/server/src/modules/homeworks/homeworks.service.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
|
||||||
|
- [packages/server/src/modules/imports/class-plan.service.ts](D:/classhelper/packages/server/src/modules/imports/class-plan.service.ts):按具体导入键和写键校验范围,学生导入复用受控生命周期。
|
||||||
|
- [packages/server/src/modules/imports/imports.module.ts](D:/classhelper/packages/server/src/modules/imports/imports.module.ts):按具体导入键和写键校验范围,学生导入复用受控生命周期。
|
||||||
|
- [packages/server/src/modules/imports/table-import.service.ts](D:/classhelper/packages/server/src/modules/imports/table-import.service.ts):按具体导入键和写键校验范围,学生导入复用受控生命周期。
|
||||||
|
- [packages/server/src/modules/imports/time-layout.service.ts](D:/classhelper/packages/server/src/modules/imports/time-layout.service.ts):按具体导入键和写键校验范围,学生导入复用受控生命周期。
|
||||||
|
- [packages/server/src/modules/notifications/notifications.module.ts](D:/classhelper/packages/server/src/modules/notifications/notifications.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
|
||||||
|
- [packages/server/src/modules/notifications/notifications.service.ts](D:/classhelper/packages/server/src/modules/notifications/notifications.service.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
|
||||||
|
- [packages/server/src/modules/permissions/permissions.module.ts](D:/classhelper/packages/server/src/modules/permissions/permissions.module.ts):接入规范范围、防越权授权、批量成员计算与事务审计。
|
||||||
|
- [packages/server/src/modules/permissions/permissions.service.ts](D:/classhelper/packages/server/src/modules/permissions/permissions.service.ts):接入规范范围、防越权授权、批量成员计算与事务审计。
|
||||||
|
- [packages/server/src/modules/schedules/schedules.module.ts](D:/classhelper/packages/server/src/modules/schedules/schedules.module.ts):补功能守卫与服务断言,保持班级端既有调用及业务关系边界。
|
||||||
|
- [packages/server/src/modules/students/students.module.ts](D:/classhelper/packages/server/src/modules/students/students.module.ts):收紧资料入口,接入专用生命周期动作、状态筛选与只读历史。
|
||||||
|
- [packages/server/src/modules/students/students.schemas.ts](D:/classhelper/packages/server/src/modules/students/students.schemas.ts):收紧资料入口,接入专用生命周期动作、状态筛选与只读历史。
|
||||||
|
- [packages/server/src/modules/students/students.service.ts](D:/classhelper/packages/server/src/modules/students/students.service.ts):收紧资料入口,接入专用生命周期动作、状态筛选与只读历史。
|
||||||
|
- [packages/server/src/modules/term/promotion.service.ts](D:/classhelper/packages/server/src/modules/term/promotion.service.ts):实现多轨道计划、预览、阻断、哈希、快照摘要、事务执行、幂等和失败记录。
|
||||||
|
- [packages/server/src/modules/term/term.module.ts](D:/classhelper/packages/server/src/modules/term/term.module.ts):独立升级权限、确认与哈希接口,学期写入按授权而非角色判断。
|
||||||
|
- [packages/server/src/modules/term/term.service.ts](D:/classhelper/packages/server/src/modules/term/term.service.ts):独立升级权限、确认与哈希接口,学期写入按授权而非角色判断。
|
||||||
|
- [packages/shared/src/index.ts](D:/classhelper/packages/shared/src/index.ts):导出范围和升级计划契约。
|
||||||
|
- [packages/shared/src/lifecycle.ts](D:/classhelper/packages/shared/src/lifecycle.ts):提供唯一范围解析器及多轨道升级计划契约和默认计划。
|
||||||
|
- [packages/shared/src/permissions.ts](D:/classhelper/packages/shared/src/permissions.ts):登记 promotion:manage、默认角色、标签、分类与矩阵。
|
||||||
|
- [packages/shared/src/types.ts](D:/classhelper/packages/shared/src/types.ts):扩展权限、学生历史与归档 DTO,收紧资料和班级更新契约。
|
||||||
|
- [packages/web-admin/src/api/index.ts](D:/classhelper/packages/web-admin/src/api/index.ts):同步收紧后的写入参数,移除无服务端入口的手工创建归档 API。
|
||||||
|
- [packages/web-admin/src/components/PromotionSettings.vue](D:/classhelper/packages/web-admin/src/components/PromotionSettings.vue):提供计划编辑、只读影响预览、学年确认执行和快照明细记录。
|
||||||
|
- [packages/web-admin/src/components/TransferredStudents.vue](D:/classhelper/packages/web-admin/src/components/TransferredStudents.vue):按名单管理键撤回转出,允许选择其他在读班。
|
||||||
|
- [packages/web-admin/src/config/navigation.ts](D:/classhelper/packages/web-admin/src/config/navigation.ts):将学年升级加入班级与人员菜单,权限仍来自路由。
|
||||||
|
- [packages/web-admin/src/router/index.ts](D:/classhelper/packages/web-admin/src/router/index.ts):移除班级、学生、周次和归档的固定 ADMIN 门槛,新增独立升级页面。
|
||||||
|
- [packages/web-admin/src/views/ArchivesView.vue](D:/classhelper/packages/web-admin/src/views/ArchivesView.vue):展示转入、转出、停用及停用名单,明确撤销归档的实际边界。
|
||||||
|
- [packages/web-admin/src/views/ClassesView.vue](D:/classhelper/packages/web-admin/src/views/ClassesView.vue):按键控制写按钮,年级只读并展示计划推导的下一学段。
|
||||||
|
- [packages/web-admin/src/views/PermissionsView.vue](D:/classhelper/packages/web-admin/src/views/PermissionsView.vue):范围选择、真实成员、复制、差异确认、成员搜索和审计分页。
|
||||||
|
- [packages/web-admin/src/views/StudentsView.vue](D:/classhelper/packages/web-admin/src/views/StudentsView.vue):状态/归档筛选、专用调转入出、复学、停用/空记录删除与历史明细。
|
||||||
|
- [packages/web-admin/src/views/TermWeeksView.vue](D:/classhelper/packages/web-admin/src/views/TermWeeksView.vue):按 term:write 显示写按钮,升级区按独立键显示。
|
||||||
|
- [scripts/ui-smoke/main.cjs](D:/classhelper/scripts/ui-smoke/main.cjs):同步教师按权限显示菜单与新版升级页面断言。
|
||||||
|
- [scripts/verify-client-migrations.mjs](D:/classhelper/scripts/verify-client-migrations.mjs):动态读取最新迁移,并验证旧默认组补键、旧历史映射与重启幂等。
|
||||||
|
- [scripts/verify-e2e-isolated.mjs](D:/classhelper/scripts/verify-e2e-isolated.mjs):明确隔离环境,保持随机端口、临时账号和临时库。
|
||||||
|
- [scripts/verify-lifecycle.mjs](D:/classhelper/scripts/verify-lifecycle.mjs):创建专用临时 SQLite 与测试环境,回放迁移后运行专项。
|
||||||
|
- [scripts/verify-v14.mjs](D:/classhelper/scripts/verify-v14.mjs):为隔离回归提供独立测试密钥和环境。
|
||||||
|
|
||||||
|
本记录的日志位于 `.cache/lifecycle-*.log`,变更清单位于 `.cache/lifecycle-changed-files.json`。
|
||||||
@@ -36,6 +36,9 @@ const RESULT_FILE = process.env.UI_SMOKE_RESULT ?? '';
|
|||||||
*/
|
*/
|
||||||
const MENU_ITEMS = [
|
const MENU_ITEMS = [
|
||||||
{ label: '仪表盘', path: '/dashboard' },
|
{ label: '仪表盘', path: '/dashboard' },
|
||||||
|
{ label: '班级管理', path: '/classes' },
|
||||||
|
{ label: '学生管理', path: '/students' },
|
||||||
|
{ label: '学期周次', path: '/term-weeks' },
|
||||||
{ label: '课表档案', path: '/plan-editor' },
|
{ label: '课表档案', path: '/plan-editor' },
|
||||||
{ label: '作业发布', path: '/homeworks' },
|
{ label: '作业发布', path: '/homeworks' },
|
||||||
{ label: '通知发布', path: '/notifications' },
|
{ label: '通知发布', path: '/notifications' },
|
||||||
@@ -46,7 +49,7 @@ const MENU_ITEMS = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
|
/** 教师端必须隐藏的入口(前端隐藏 + 后端 403,双重保障) */
|
||||||
const HIDDEN_MENU_LABELS = ['班级管理', '学生管理', '教师管理', '数据库管理'];
|
const HIDDEN_MENU_LABELS = ['教师管理', '数据库管理'];
|
||||||
|
|
||||||
/** 管理员账号(教师录入用例):与种子/安装初始化账号一致 */
|
/** 管理员账号(教师录入用例):与种子/安装初始化账号一致 */
|
||||||
const ADMIN_USERNAME = process.env.UI_SMOKE_ADMIN ?? 'admin';
|
const ADMIN_USERNAME = process.env.UI_SMOKE_ADMIN ?? 'admin';
|
||||||
@@ -558,10 +561,10 @@ async function main() {
|
|||||||
// 等视图渲染
|
// 等视图渲染
|
||||||
const viewDeadline = Date.now() + 3000;
|
const viewDeadline = Date.now() + 3000;
|
||||||
while (Date.now() < viewDeadline) {
|
while (Date.now() < viewDeadline) {
|
||||||
if (document.querySelector('.page-title')) break;
|
if (document.querySelector('.page-title, .archive-titlebar > span')) break;
|
||||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||||
}
|
}
|
||||||
const title = (document.querySelector('.page-title')?.textContent ?? '').trim();
|
const title = (document.querySelector('.page-title, .archive-titlebar > span')?.textContent ?? '').trim();
|
||||||
return { ok: location.pathname === expected && Boolean(title), path: location.pathname, title, expected };
|
return { ok: location.pathname === expected && Boolean(title), path: location.pathname, title, expected };
|
||||||
})()`);
|
})()`);
|
||||||
|
|
||||||
@@ -796,7 +799,7 @@ 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(',')}]`,
|
||||||
);
|
);
|
||||||
@@ -1013,7 +1016,10 @@ async function main() {
|
|||||||
await sleep(600);
|
await sleep(600);
|
||||||
|
|
||||||
const buttons = Array.from(document.querySelectorAll('button'));
|
const buttons = Array.from(document.querySelectorAll('button'));
|
||||||
const openButton = buttons.find((node) => (node.textContent ?? '').trim() === '导入档案');
|
const transfer = buttons.find((node) => (node.textContent ?? '').trim() === '导入/导出');
|
||||||
|
transfer?.click();
|
||||||
|
await sleep(200);
|
||||||
|
const openButton = Array.from(document.querySelectorAll('button')).find((node) => (node.textContent ?? '').trim() === '导入 ClassIsland 档案');
|
||||||
if (!openButton) return { ok: false, reason: '课表页没有"导入 ClassIsland 课程表"按钮(班主任权限未生效?)' };
|
if (!openButton) return { ok: false, reason: '课表页没有"导入 ClassIsland 课程表"按钮(班主任权限未生效?)' };
|
||||||
// 旧入口必须已经下线(需求 ⑩)
|
// 旧入口必须已经下线(需求 ⑩)
|
||||||
const legacy = buttons.some((node) => (node.textContent ?? '').trim() === '导入时间配置');
|
const legacy = buttons.some((node) => (node.textContent ?? '').trim() === '导入时间配置');
|
||||||
@@ -1357,57 +1363,107 @@ async function runPlanEditorChecks(win) {
|
|||||||
await win.loadURL(new URL('/plan-editor', TARGET_URL).href);
|
await win.loadURL(new URL('/plan-editor', TARGET_URL).href);
|
||||||
const ready = await waitFor(
|
const ready = await waitFor(
|
||||||
win,
|
win,
|
||||||
`document.body.innerText.includes('课表编辑') && Boolean(document.querySelector('.course-row'))`,
|
`Boolean(document.querySelector('.schedule-grid thead')) && document.querySelectorAll('.schedule-grid thead th').length === 8`,
|
||||||
);
|
|
||||||
record('统一课表档案工作区与课程时段编辑可用', ready);
|
|
||||||
const save = await win.webContents.executeJavaScript(
|
|
||||||
`(() => {const b=[...document.querySelectorAll('button')].find(e=>e.textContent.trim()==='保存课表');b?.click();return Boolean(b&&!b.disabled);})()`,
|
|
||||||
);
|
|
||||||
const saved = save && (await waitFor(win, `document.body.innerText.includes('整张日课表已保存')`));
|
|
||||||
record('日课表整表保存可以从页面提交', saved);
|
|
||||||
await win.webContents.executeJavaScript(
|
|
||||||
`(() => {[...document.querySelectorAll('.editor-nav button')].find(e=>e.textContent.trim()==='时间表')?.click();})()`,
|
|
||||||
);
|
);
|
||||||
record(
|
record(
|
||||||
'时间表页四种时间点与管理员保存入口可见',
|
'ClassIsland 风格档案工作区默认展示周课表和选科侧栏',
|
||||||
|
ready &&
|
||||||
|
(await win.webContents.executeJavaScript(
|
||||||
|
`Boolean(document.querySelector('.subject-palette')) && document.querySelectorAll('.archive-tabs button').length === 4`,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
const assignment = await win.webContents.executeJavaScript(`(() => {
|
||||||
|
const cell = document.querySelector('.lesson-cell');
|
||||||
|
if (!cell) return null;
|
||||||
|
const original = cell.querySelector('strong')?.textContent.trim();
|
||||||
|
cell.click();
|
||||||
|
const button = [...document.querySelectorAll('.subject-palette button')].find(b => !b.disabled && b.title !== original);
|
||||||
|
button?.click();
|
||||||
|
return { original, editable: Boolean(button) };
|
||||||
|
})()`);
|
||||||
|
const dirty =
|
||||||
|
assignment?.editable && (await waitFor(win, `document.body.innerText.includes('有未保存的修改')`));
|
||||||
|
record('点击周课表并从右侧选科生成可保存的课程草稿', dirty);
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`(() => {[...document.querySelectorAll('.archive-commands button')].find(b => b.textContent.trim() === '保存')?.click();})()`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'课表草稿可通过现有接口保存',
|
||||||
|
dirty &&
|
||||||
|
(await waitFor(
|
||||||
|
win,
|
||||||
|
`document.body.innerText.includes('档案已保存') && !document.querySelector('.draft-label')`,
|
||||||
|
)),
|
||||||
|
);
|
||||||
|
// 恢复隔离验收班级的原科目。
|
||||||
|
if (assignment?.original) {
|
||||||
|
await win.webContents.executeJavaScript(`(() => {
|
||||||
|
document.querySelector('.lesson-cell')?.click();
|
||||||
|
const original = ${JSON.stringify(assignment.original)};
|
||||||
|
[...document.querySelectorAll('.subject-palette button')].find(b => b.title === original)?.click();
|
||||||
|
})()`);
|
||||||
|
await sleep(150);
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`(() => {[...document.querySelectorAll('.archive-commands button')].find(b => b.textContent.trim() === '保存')?.click();})()`,
|
||||||
|
);
|
||||||
|
await waitFor(win, `!document.querySelector('.draft-label')`);
|
||||||
|
}
|
||||||
|
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="列表视图"]').click()`);
|
||||||
|
record(
|
||||||
|
'列表视图保留课时编辑和批量操作',
|
||||||
await waitFor(
|
await waitFor(
|
||||||
win,
|
win,
|
||||||
`document.body.innerText.includes('新建课时') && document.body.innerText.includes('保存时间表')`,
|
`Boolean(document.querySelector('.course-row')) && document.body.innerText.includes('批量排课')`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`document.querySelector('.archive-tabs button:nth-child(2)').click()`,
|
||||||
|
);
|
||||||
|
record(
|
||||||
|
'时间表页四种时间点与保存入口可见',
|
||||||
|
await waitFor(
|
||||||
|
win,
|
||||||
|
`document.body.innerText.includes('新建课时') && document.body.innerText.includes('保存时间表') && document.body.innerText.includes('分割线') && document.body.innerText.includes('行动')`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const quickAdd = await win.webContents.executeJavaScript(`(async () => {
|
const quickAdd = await win.webContents.executeJavaScript(`(async () => {
|
||||||
const rows = () => document.querySelectorAll('.time-row');
|
const before = document.querySelectorAll('.time-row').length;
|
||||||
const before = rows().length;
|
[...document.querySelectorAll('button')].find(b => b.textContent.trim() === '+ 新建课时')?.click();
|
||||||
[...document.querySelectorAll('button')].find(b=>b.textContent.trim()==='+ 新建课时')?.click();
|
await new Promise(resolve => setTimeout(resolve, 180));
|
||||||
await new Promise((resolve) => setTimeout(resolve, 180));
|
const rows = document.querySelectorAll('.time-row');
|
||||||
const after = rows().length;
|
const values = [...(rows[rows.length - 1]?.querySelectorAll('.el-date-editor input') || [])].map(input => input.value);
|
||||||
const last = rows()[after - 1];
|
const minutes = (value) => { const [h, m] = (value || '').split(':').map(Number); return h * 60 + m; };
|
||||||
const values = last ? [...last.querySelectorAll('.el-date-editor input')].map((input) => input.value) : [];
|
return { before, after: rows.length, diff: minutes(values[1]) - minutes(values[0]) };
|
||||||
const [start, end] = values;
|
|
||||||
const diff = (() => {
|
|
||||||
if (!/^\\d{2}:\\d{2}$/.test(start ?? '') || !/^\\d{2}:\\d{2}$/.test(end ?? '')) return -1;
|
|
||||||
const [h1, m1] = start.split(':').map(Number), [h2, m2] = end.split(':').map(Number);
|
|
||||||
return h2 * 60 + m2 - (h1 * 60 + m1);
|
|
||||||
})();
|
|
||||||
return { before, after, start, end, diff };
|
|
||||||
})()`);
|
})()`);
|
||||||
record(
|
record(
|
||||||
'时间表新建课时按快速时长生成起止时间',
|
'时间表新建课时按快速时长生成起止时间',
|
||||||
quickAdd.after === quickAdd.before + 1 && quickAdd.diff === 45,
|
quickAdd.after === quickAdd.before + 1 && quickAdd.diff === 45,
|
||||||
`行数 ${quickAdd.before}→${quickAdd.after} 时间=${quickAdd.start ?? '-'}–${quickAdd.end ?? '-'} 分钟=${quickAdd.diff}`,
|
|
||||||
);
|
);
|
||||||
await win.webContents.executeJavaScript(
|
await win.webContents.executeJavaScript(
|
||||||
`document.querySelector('.editor-nav button:nth-child(5)').click()`,
|
`(() => {[...document.querySelectorAll('.archive-toolbar button')].find(b => b.textContent.trim() === '刷新')?.click();})()`,
|
||||||
);
|
);
|
||||||
|
await waitFor(win, `Boolean(document.querySelector('.el-message-box'))`);
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`(() => {[...document.querySelectorAll('.el-message-box button')].find(b => b.textContent.trim() === '放弃修改')?.click();})()`,
|
||||||
|
);
|
||||||
|
await waitFor(win, `!document.querySelector('.draft-label')`);
|
||||||
|
await win.webContents.executeJavaScript(
|
||||||
|
`document.querySelector('.archive-tabs button:first-child').click()`,
|
||||||
|
);
|
||||||
|
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="月视图"]').click()`);
|
||||||
record(
|
record(
|
||||||
'整周预览与当前生效课表保持一致',
|
'月视图提供日期导航',
|
||||||
await waitFor(
|
await waitFor(win, `document.querySelectorAll('.calendar-grid button').length === 42`),
|
||||||
win,
|
|
||||||
`document.body.innerText.includes('整周课表预览') && document.querySelector('.week-grid .el-table__body')`,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
await win.webContents.executeJavaScript(`document.querySelector('.editor-nav button:first-child').click()`);
|
await win.webContents.executeJavaScript(`document.querySelector('[aria-label="周视图"]').click()`);
|
||||||
await sleep(300);
|
record('底部视图切换可返回周课表', await waitFor(win, `Boolean(document.querySelector('.schedule-grid'))`));
|
||||||
|
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(__dirname, '../../docs/screenshots/plan-editor-live-api.png'),
|
||||||
|
(await win.webContents.capturePage()).toPNG(),
|
||||||
|
);
|
||||||
|
await win.webContents.executeJavaScript(`document.querySelector('.return-button').click()`);
|
||||||
|
await waitFor(win, `location.pathname === '/dashboard'`);
|
||||||
const collapsedBefore = await win.webContents.executeJavaScript(
|
const collapsedBefore = await win.webContents.executeJavaScript(
|
||||||
`Boolean(document.querySelector('.layout-aside.is-collapsed'))`,
|
`Boolean(document.querySelector('.layout-aside.is-collapsed'))`,
|
||||||
);
|
);
|
||||||
@@ -1425,7 +1481,7 @@ async function runPlanEditorChecks(win) {
|
|||||||
await sleep(700);
|
await sleep(700);
|
||||||
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
|
fs.mkdirSync(path.join(__dirname, '../../docs/screenshots'), { recursive: true });
|
||||||
fs.writeFileSync(
|
fs.writeFileSync(
|
||||||
path.join(__dirname, '../../docs/screenshots/plan-editor-workspace.png'),
|
path.join(__dirname, '../../docs/screenshots/plan-editor-shell.png'),
|
||||||
(await win.webContents.capturePage()).toPNG(),
|
(await win.webContents.capturePage()).toPNG(),
|
||||||
);
|
);
|
||||||
await win.loadURL(new URL('/system-settings', TARGET_URL).href);
|
await win.loadURL(new URL('/system-settings', TARGET_URL).href);
|
||||||
@@ -1441,7 +1497,7 @@ async function runPlanEditorChecks(win) {
|
|||||||
'学年升级设置与待补跑提示可见且不执行升级',
|
'学年升级设置与待补跑提示可见且不执行升级',
|
||||||
await waitFor(
|
await waitFor(
|
||||||
win,
|
win,
|
||||||
`document.body.innerText.includes('每年自动学年升级') && document.body.innerText.includes('总周数')`,
|
`document.body.innerText.includes('学年升级计划') && document.body.innerText.includes('总周数')`,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
await win.loadURL(new URL('/archives', TARGET_URL).href);
|
await win.loadURL(new URL('/archives', TARGET_URL).href);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ try {
|
|||||||
.readdirSync(migrationRoot)
|
.readdirSync(migrationRoot)
|
||||||
.filter((name) => fs.existsSync(path.join(migrationRoot, name, 'migration.sql')))
|
.filter((name) => fs.existsSync(path.join(migrationRoot, name, 'migration.sql')))
|
||||||
.sort();
|
.sort();
|
||||||
assert.equal(names.at(-1), '20261017090000_permission_read_keys');
|
assert.ok(names.length > 0, '必须存在可回放的迁移');
|
||||||
const baseline = names.indexOf('20261016090000_retire_integration_and_schedule_updates');
|
const baseline = names.indexOf('20261016090000_retire_integration_and_schedule_updates');
|
||||||
await client.execute(
|
await client.execute(
|
||||||
'CREATE TABLE "_prisma_migrations" (migration_name TEXT, finished_at DATETIME, rolled_back_at DATETIME)',
|
'CREATE TABLE "_prisma_migrations" (migration_name TEXT, finished_at DATETIME, rolled_back_at DATETIME)',
|
||||||
@@ -46,7 +46,7 @@ try {
|
|||||||
await client.execute(
|
await client.execute(
|
||||||
`INSERT INTO "PermissionGroup" (id,name,updatedAt) VALUES ('custom','教师默认权限',CURRENT_TIMESTAMP)`,
|
`INSERT INTO "PermissionGroup" (id,name,updatedAt) VALUES ('custom','教师默认权限',CURRENT_TIMESTAMP)`,
|
||||||
);
|
);
|
||||||
const run = (expected) => {
|
const run = (expected, targetUrl = databaseUrl) => {
|
||||||
const script = `const {ensureSchema}=await import('./packages/server/src/lib/db-bootstrap.ts');
|
const script = `const {ensureSchema}=await import('./packages/server/src/lib/db-bootstrap.ts');
|
||||||
const result=await ensureSchema(); if(result.applied!==${expected})throw new Error(JSON.stringify(result));
|
const result=await ensureSchema(); if(result.applied!==${expected})throw new Error(JSON.stringify(result));
|
||||||
const {ensureDefaultGroups}=await import('./packages/server/src/lib/permissions.ts');
|
const {ensureDefaultGroups}=await import('./packages/server/src/lib/permissions.ts');
|
||||||
@@ -56,7 +56,13 @@ try {
|
|||||||
['--import', './packages/server/node_modules/tsx/dist/loader.mjs', '--input-type=module', '-e', script],
|
['--import', './packages/server/node_modules/tsx/dist/loader.mjs', '--input-type=module', '-e', script],
|
||||||
{
|
{
|
||||||
cwd: root,
|
cwd: root,
|
||||||
env: { ...process.env, DATABASE_URL: databaseUrl, DATABASE_PROVIDER: 'sqlite' },
|
env: {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
JWT_SECRET: 'migrations-isolated-verification-secret-2026',
|
||||||
|
DATABASE_URL: targetUrl,
|
||||||
|
DATABASE_PROVIDER: 'sqlite',
|
||||||
|
},
|
||||||
encoding: 'utf8',
|
encoding: 'utf8',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
},
|
},
|
||||||
@@ -85,11 +91,82 @@ try {
|
|||||||
).rows[0].n,
|
).rows[0].n,
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
// 第二个夹具模拟本次重构前已初始化的默认组和旧 mode 历史。
|
||||||
|
const legacyUrl = `file:${path.join(dir, 'lifecycle-legacy.db').replaceAll('\\', '/')}`;
|
||||||
|
const legacy = createClient({ url: legacyUrl });
|
||||||
|
try {
|
||||||
|
const split = names.indexOf('20261023000000_promotion_permission');
|
||||||
|
assert(split > 0);
|
||||||
|
await legacy.execute(
|
||||||
|
'CREATE TABLE "_prisma_migrations" (migration_name TEXT, finished_at DATETIME, rolled_back_at DATETIME)',
|
||||||
|
);
|
||||||
|
for (const name of names.slice(0, split)) {
|
||||||
|
await legacy.executeMultiple(fs.readFileSync(path.join(migrationRoot, name, 'migration.sql'), 'utf8'));
|
||||||
|
await legacy.execute({
|
||||||
|
sql: 'INSERT INTO "_prisma_migrations" VALUES (?, CURRENT_TIMESTAMP, NULL)',
|
||||||
|
args: [name],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await legacy.execute(
|
||||||
|
`INSERT INTO "PermissionGroup" (id,name,defaultRole,scope,updatedAt) VALUES ('legacy-admin','旧管理员默认组','ADMIN','ALL',CURRENT_TIMESTAMP)`,
|
||||||
|
);
|
||||||
|
await legacy.execute(
|
||||||
|
`INSERT INTO "PermissionGroupItem" (id,groupId,key) VALUES ('legacy-key','legacy-admin','roster:read')`,
|
||||||
|
);
|
||||||
|
await legacy.execute(
|
||||||
|
`INSERT INTO "Student" (id,studentNo,name,updatedAt) VALUES ('legacy-student','LEGACY','旧学生',CURRENT_TIMESTAMP)`,
|
||||||
|
);
|
||||||
|
for (const [id, mode] of [
|
||||||
|
['old-single', 'single'],
|
||||||
|
['old-out', 'transfer-out'],
|
||||||
|
['old-restore', 'restore-transfer'],
|
||||||
|
])
|
||||||
|
await legacy.execute({
|
||||||
|
sql: "INSERT INTO \"StudentClassTransfer\" (id,studentId,studentNo,studentName,mode) VALUES (?,'legacy-student','LEGACY','旧学生',?)",
|
||||||
|
args: [id, mode],
|
||||||
|
});
|
||||||
|
legacy.close();
|
||||||
|
run(names.length - split, legacyUrl);
|
||||||
|
const upgraded = createClient({ url: legacyUrl });
|
||||||
|
try {
|
||||||
|
const keys = (
|
||||||
|
await upgraded.execute(`SELECT key FROM "PermissionGroupItem" WHERE groupId='legacy-admin'`)
|
||||||
|
).rows.map((row) => row.key);
|
||||||
|
assert(keys.includes('roster:read') && keys.includes('promotion:manage'));
|
||||||
|
const kinds = (await upgraded.execute('SELECT id,kind,batchId FROM "StudentClassTransfer" ORDER BY id'))
|
||||||
|
.rows;
|
||||||
|
assert.deepEqual(
|
||||||
|
kinds.map((row) => row.kind),
|
||||||
|
['transfer-out', 'restore', 'class-change'],
|
||||||
|
);
|
||||||
|
assert(kinds.every((row) => row.batchId === ''));
|
||||||
|
assert(
|
||||||
|
!(await upgraded.execute('PRAGMA table_info("StudentClassTransfer")')).rows.some(
|
||||||
|
(row) => row.name === 'mode',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
(await upgraded.execute('PRAGMA table_info("ArchivedYear")')).rows.some(
|
||||||
|
(row) => row.name === 'inactiveCount',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
upgraded.close();
|
||||||
|
}
|
||||||
|
run(0, legacyUrl);
|
||||||
|
} finally {
|
||||||
|
legacy.close();
|
||||||
|
}
|
||||||
console.log(
|
console.log(
|
||||||
'数据库升级回归通过:接纳 Prisma 迁移记录、保留业务课表、移除联动、避开默认组重名、重启保留空权限组。',
|
'数据库升级回归通过:保留业务课表与空权限组、默认组补升级键、旧 mode 映射 kind、旧行默认值与重启幂等。',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
client.close();
|
client.close();
|
||||||
|
if (
|
||||||
|
path.dirname(path.resolve(dir)) !== path.resolve(os.tmpdir()) ||
|
||||||
|
!path.basename(dir).startsWith('classhelper-upgrade-')
|
||||||
|
)
|
||||||
|
throw new Error('拒绝清理临时目录以外的路径');
|
||||||
await fsp
|
await fsp
|
||||||
.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
.rm(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 })
|
||||||
.catch((error) => console.error('清理临时测试库失败:', error.message));
|
.catch((error) => console.error('清理临时测试库失败:', error.message));
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'),
|
|||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
NODE_ENV: 'test',
|
||||||
DATABASE_PROVIDER: 'sqlite',
|
DATABASE_PROVIDER: 'sqlite',
|
||||||
|
JWT_SECRET: 'e2e-isolated-verification-secret-2026',
|
||||||
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
|
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
|
||||||
TZ: 'Asia/Shanghai',
|
TZ: 'Asia/Shanghai',
|
||||||
RATE_LIMIT_ENABLED: 'false',
|
RATE_LIMIT_ENABLED: 'false',
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { createRequire } from 'node:module';
|
||||||
|
|
||||||
|
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
const dir = path.join(root, '.cache', 'lifecycle-verification', `run-${process.pid}-${Date.now()}`);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
NODE_ENV: 'test',
|
||||||
|
DATABASE_PROVIDER: 'sqlite',
|
||||||
|
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
|
||||||
|
JWT_SECRET: 'lifecycle-isolated-verification-secret-2026',
|
||||||
|
TZ: 'Asia/Shanghai',
|
||||||
|
RATE_LIMIT_ENABLED: 'false',
|
||||||
|
LOG_LEVEL: 'warn',
|
||||||
|
};
|
||||||
|
const require = createRequire(path.join(root, 'packages/server/package.json'));
|
||||||
|
const database = require('@libsql/client').createClient({ url: env.DATABASE_URL });
|
||||||
|
await database.execute('PRAGMA user_version=0');
|
||||||
|
database.close();
|
||||||
|
async function run(args) {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const child = spawn(process.execPath, args, { cwd: root, env, stdio: 'inherit', windowsHide: true });
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('exit', (code) => (code === 0 ? resolve() : reject(new Error(`隔离验收失败:${code}`))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await run([
|
||||||
|
'packages/server/node_modules/prisma/build/index.js',
|
||||||
|
'migrate',
|
||||||
|
'deploy',
|
||||||
|
'--config',
|
||||||
|
'packages/server/prisma.config.ts',
|
||||||
|
]);
|
||||||
|
await run(['packages/server/node_modules/tsx/dist/cli.mjs', 'packages/server/scripts/verify-lifecycle.ts']);
|
||||||
|
console.log(`生命周期隔离数据库:${dir}`);
|
||||||
@@ -8,7 +8,9 @@ const dir = path.join(root, '.cache', 'v14-verification', `run-${process.pid}-${
|
|||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
const env = {
|
const env = {
|
||||||
...process.env,
|
...process.env,
|
||||||
|
NODE_ENV: 'test',
|
||||||
DATABASE_PROVIDER: 'sqlite',
|
DATABASE_PROVIDER: 'sqlite',
|
||||||
|
JWT_SECRET: 'v14-isolated-verification-secret-2026',
|
||||||
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
|
DATABASE_URL: `file:${path.join(dir, 'test.db').replaceAll('\\', '/')}`,
|
||||||
TZ: 'Asia/Shanghai',
|
TZ: 'Asia/Shanghai',
|
||||||
RATE_LIMIT_ENABLED: 'false',
|
RATE_LIMIT_ENABLED: 'false',
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
<!-- 页面上所有版本号与文件名都从这一行起步:脚本会去 GitHub Releases 取真实清单,
|
<!-- 页面上所有版本号与文件名都从这一行起步:脚本会去 GitHub Releases 取真实清单,
|
||||||
取不到时(离线、被限流)就按这份版本号 + 发布命名约定拼直链。
|
取不到时(离线、被限流)就按这份版本号 + 发布命名约定拼直链。
|
||||||
它和根 package.json 一起由 `pnpm version:bump` 维护 —— 别在这儿单独改。 -->
|
它和根 package.json 一起由 `pnpm version:bump` 维护 —— 别在这儿单独改。 -->
|
||||||
<meta name="ch-version" content="1.4.1" />
|
<meta name="ch-version" content="1.4.2" />
|
||||||
<link rel="icon" href="assets/icon.png" />
|
<link rel="icon" href="assets/icon.png" />
|
||||||
<link rel="apple-touch-icon" href="assets/icon.png" />
|
<link rel="apple-touch-icon" href="assets/icon.png" />
|
||||||
|
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
<a class="brand" href="#top" data-press="0.96">
|
<a class="brand" href="#top" data-press="0.96">
|
||||||
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
|
<img class="brand-mark" src="assets/icon.png" alt="" width="32" height="32" />
|
||||||
<span class="brand-name">ClassHelper</span>
|
<span class="brand-name">ClassHelper</span>
|
||||||
<span class="brand-ver">1.4.1</span>
|
<span class="brand-ver">1.4.2</span>
|
||||||
</a>
|
</a>
|
||||||
<nav class="topnav" aria-label="页内导航">
|
<nav class="topnav" aria-label="页内导航">
|
||||||
<span class="nav-pill" aria-hidden="true"></span>
|
<span class="nav-pill" aria-hidden="true"></span>
|
||||||
@@ -93,7 +93,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
|
<ul class="hero-badges intro-seq" style="--intro-delay: 300ms">
|
||||||
<li>v1.4.1</li>
|
<li>v1.4.2</li>
|
||||||
<li>Windows 10/11</li>
|
<li>Windows 10/11</li>
|
||||||
<li>Linux · Docker</li>
|
<li>Linux · Docker</li>
|
||||||
<li>触摸屏可用</li>
|
<li>触摸屏可用</li>
|
||||||
@@ -733,7 +733,7 @@
|
|||||||
ClassHelper
|
ClassHelper
|
||||||
<span>Class Helper</span>
|
<span>Class Helper</span>
|
||||||
</p>
|
</p>
|
||||||
<p class="foot-ver">版本 1.4.1 · 源码公开 · 装在你自己的机器上</p>
|
<p class="foot-ver">版本 1.4.2 · 源码公开 · 装在你自己的机器上</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="foot-nav" aria-label="相关链接">
|
<nav class="foot-nav" aria-label="相关链接">
|
||||||
|
|||||||