feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)
学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。 学生不是账号 - 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段 - 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支 - 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher - 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失 任课关系唯一来源 = Course(班级 + 科目 + 教师) - 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号 - 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员 - 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关) 成绩与名单 - 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级 - 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制 - 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属 - 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业) - 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」 ClassHelper 班级端在线状态 - Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳 - lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开 学期周次 - 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置 - 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常) - 改周次后随课表下发给教室的 ClassHelper 班级端 课表方向反转 - 主方向改为「服务端 → 教室」自动下发;教室不再自动回传 - 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报 改名 - 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、 安装目录、systemd 描述、插件清单与设置页、官网、文档站、README BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe), 安装目录随之改变,不支持覆盖安装,需先卸载旧版。 服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢), 升级前请先备份。
This commit is contained in:
1 parent
17f55864bf
commit
9d5ac7adf9
172 files changed
+8761
-1896
No files matched your search
@@ -1,15 +1,15 @@
|
||||
# 班级小助手联动插件
|
||||
# ClassHelper 联动插件
|
||||
|
||||
把 [ClassIsland](https://classisland.tech) 与 [班级小助手](https://github.com/LoveChengke/ClassHelper) 连起来:
|
||||
让班级小助手**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
|
||||
把 [ClassIsland](https://classisland.tech) 与 [ClassHelper](https://github.com/LoveChengke/ClassHelper) 连起来:
|
||||
让 ClassHelper**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
|
||||
|
||||
## 它能做什么
|
||||
|
||||
| 方向 | 说明 |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| ClassIsland → 班级小助手 | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
|
||||
| 班级小助手 → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
|
||||
| 班级小助手 → ClassIsland(可选) | 把班级小助手上排好的课表**镜像**回 ClassIsland(新建 `班级小助手-<班级名>` 档案课表,不改动老师原有的课表) |
|
||||
| ClassIsland → ClassHelper | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
|
||||
| ClassHelper → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
|
||||
| ClassHelper → ClassIsland(可选) | 把 ClassHelper上排好的课表**镜像**回 ClassIsland(新建 `ClassHelper-<班级名>` 档案课表,不改动老师原有的课表) |
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -17,23 +17,23 @@
|
||||
- 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」;
|
||||
- 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录
|
||||
(Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。
|
||||
2. 重启 ClassIsland,在「设置 → 插件」里确认「班级小助手联动」已启用。
|
||||
3. 打开「设置 → 班级小助手联动」,填入:
|
||||
- **服务器地址**:班级小助手后端地址,例如 `http://127.0.0.1:4000`;
|
||||
- **设备令牌**:在班级小助手 Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
|
||||
2. 重启 ClassIsland,在「设置 → 插件」里确认「ClassHelper 联动」已启用。
|
||||
3. 打开「设置 → ClassHelper 联动」,填入:
|
||||
- **服务器地址**:ClassHelper后端地址,例如 `http://127.0.0.1:4000`;
|
||||
- **设备令牌**:在 ClassHelper Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
|
||||
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
|
||||
|
||||
## 配置项(全部在「设置 → 班级小助手联动」里)
|
||||
## 配置项(全部在「设置 → ClassHelper 联动」里)
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 服务器地址 | 班级小助手后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
|
||||
| 服务器地址 | ClassHelper后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
|
||||
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
|
||||
| 上报课表到班级小助手 | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
|
||||
| 上报课表到 ClassHelper | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
|
||||
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
|
||||
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
|
||||
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
|
||||
| 接收班级小助手提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
|
||||
| 接收 ClassHelper提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
|
||||
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
|
||||
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
|
||||
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
|
||||
@@ -48,20 +48,20 @@
|
||||
|
||||
> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段
|
||||
> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来,
|
||||
> 不依赖班级小助手那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
|
||||
> 不依赖 ClassHelper那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
|
||||
|
||||
> **两个方向各自需要的开关**:
|
||||
>
|
||||
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
|
||||
> - ClassIsland → 服务端(上报课表):插件「上报课表到班级小助手」打开;
|
||||
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到班级小助手」开关已关闭)」),不是上报失败;
|
||||
> - ClassIsland → 服务端(上报课表):插件「上报课表到 ClassHelper」打开;
|
||||
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到 ClassHelper」开关已关闭)」),不是上报失败;
|
||||
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
|
||||
|
||||
> **提醒到底会不会弹,取决于两个开关:**
|
||||
>
|
||||
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
|
||||
> 服务端根本不会推过来(此时本插件收不到东西,属正常);
|
||||
> 2. 本插件的「接收班级小助手提醒」关掉时,即使推过来也不弹。
|
||||
> 2. 本插件的「接收 ClassHelper提醒」关掉时,即使推过来也不弹。
|
||||
>
|
||||
> 两者都开着,提醒才会在 ClassIsland 上出现。
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
|
||||
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
|
||||
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
|
||||
「班级小助手提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
|
||||
「ClassHelper提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
|
||||
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
|
||||
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
|
||||
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
|
||||
@@ -85,7 +85,7 @@
|
||||
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
|
||||
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
|
||||
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
|
||||
- **非破坏性**:镜像只创建 / 更新名字以 `班级小助手-` 开头的课表与时间表;
|
||||
- **非破坏性**:镜像只创建 / 更新名字以 `ClassHelper-` 开头的课表与时间表;
|
||||
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
|
||||
|
||||
## 上机联调记录(ClassIsland 2.1.0.1 实测)
|
||||
@@ -129,7 +129,7 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
|
||||
|
||||
## 常见问题
|
||||
|
||||
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是班级小助手的标准格式)**
|
||||
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是 ClassHelper的标准格式)**
|
||||
这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里,
|
||||
于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。
|
||||
插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**;
|
||||
@@ -151,7 +151,7 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
|
||||
2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403);
|
||||
3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。
|
||||
|
||||
**课表上报了但班级小助手上没变化**
|
||||
**课表上报了但 ClassHelper上没变化**
|
||||
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
|
||||
|
||||
**镜像开关打开了,但 ClassIsland 的课表没变**
|
||||
@@ -160,4 +160,4 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
|
||||
|
||||
## 许可
|
||||
|
||||
与班级小助手仓库一致。
|
||||
与 ClassHelper仓库一致。
|
||||
@@ -1,19 +1,19 @@
|
||||
# 班级小助手 ClassIsland 联动插件 —— 清单文件
|
||||
# ClassHelper ClassIsland 联动插件 —— 清单文件
|
||||
#
|
||||
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
|
||||
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
|
||||
|
||||
id: classhelper.classisland.bridge
|
||||
name: 班级小助手联动
|
||||
name: ClassHelper 联动
|
||||
description: |-
|
||||
把 ClassIsland 的课表与上课状态同步到班级小助手,并在 ClassIsland 上显示老师下发的提醒。
|
||||
把 ClassIsland 的课表与上课状态同步到 ClassHelper,并在 ClassIsland 上显示老师下发的提醒。
|
||||
功能:
|
||||
1. 上报当前课表与节次时间(班级小助手 Web 端与桌面客户端据此显示课程);
|
||||
1. 上报当前课表与节次时间(ClassHelper Web 端与桌面客户端据此显示课程);
|
||||
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
|
||||
3. 接收老师在班级小助手上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
|
||||
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
|
||||
3. 接收老师在 ClassHelper上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
|
||||
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
|
||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||
url: https://github.com/LoveChengke/ClassHelper
|
||||
version: 1.1.2.0
|
||||
version: 1.2.0.0
|
||||
apiVersion: 2.0.0.0
|
||||
author: 班级小助手
|
||||
author: ClassHelper
|
||||
@@ -101,7 +101,7 @@ check(
|
||||
|
||||
check(
|
||||
'设置页带 [SettingsPageInfo] 且声明了页面 id',
|
||||
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"班级小助手联动"/.test(settingsPageSource),
|
||||
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"ClassHelper 联动"/.test(settingsPageSource),
|
||||
);
|
||||
check(
|
||||
'设置页 XAML 的 x:Class 与代码后置类一致',
|
||||
@@ -255,6 +255,34 @@ check(
|
||||
);
|
||||
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
|
||||
|
||||
// 3.10 课表同步方向(2026-10-06 反转):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
|
||||
// 「把教室课表传上来」改成 Web 端的一次性人工请求。这几条盯着反转后的契约,别删。
|
||||
check(
|
||||
'默认「上报课表到 ClassHelper」关闭、镜像到 ClassIsland 开启(新方向)',
|
||||
/_uploadSchedule\s*=\s*false/.test(settingsSource) && /_mirrorSchedule\s*=\s*true/.test(settingsSource),
|
||||
);
|
||||
check(
|
||||
'响应 DTO 里有 scheduleRequested(服务端请求立刻上报一次课表)',
|
||||
/scheduleRequested/.test(clientSource),
|
||||
);
|
||||
check(
|
||||
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
|
||||
/ScheduleRequested\s*==\s*true/.test(bridgeSource) &&
|
||||
/forceSchedule:\s*true/.test(bridgeSource),
|
||||
);
|
||||
check(
|
||||
'服务端请求课表不依赖「自动回传」开关(forceSchedule 或上开关二者取一)',
|
||||
/forceSchedule\s*\|\|\s*_settings\.UploadSchedule/.test(bridgeSource),
|
||||
);
|
||||
check(
|
||||
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
|
||||
bridgeSource.indexOf('ScheduleRequested == true') < bridgeSource.indexOf('MirrorScheduleAsync()'),
|
||||
);
|
||||
check(
|
||||
'课表拉取 DTO 带 weekRanges(管理员逐周配的学期周次区间)',
|
||||
/weekRanges/.test(clientSource) && /TermWeekRangeDto/.test(clientSource),
|
||||
);
|
||||
|
||||
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
|
||||
check(
|
||||
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
|
||||
@@ -286,7 +314,7 @@ check(
|
||||
);
|
||||
check(
|
||||
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
|
||||
bridgeSource.includes('上报课表到班级小助手') && bridgeSource.includes('开关已关闭'),
|
||||
bridgeSource.includes('上报课表到 ClassHelper') && bridgeSource.includes('开关已关闭'),
|
||||
);
|
||||
check(
|
||||
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
||||
@@ -330,7 +358,7 @@ check(
|
||||
);
|
||||
check(
|
||||
'2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小',
|
||||
clientSource.includes('响应不是班级小助手的标准格式') &&
|
||||
clientSource.includes('响应不是 ClassHelper的标准格式') &&
|
||||
clientSource.includes('MaxResponseContentBufferSize'),
|
||||
);
|
||||
check(
|
||||
|
||||
@@ -80,6 +80,14 @@ public class ReportSettingsDto
|
||||
{
|
||||
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
|
||||
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务端请求**立刻上报一次课表**(老师在 Web 端点了「从教室机器获取课表」)。
|
||||
///
|
||||
/// 它与本机的「上报课表到 ClassHelper」开关无关:那个开关管的是"要不要**自动**回传",
|
||||
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
|
||||
/// </summary>
|
||||
[JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; }
|
||||
}
|
||||
|
||||
public class ReportResultDto
|
||||
@@ -157,10 +165,32 @@ public class ClassPlanMirrorDto
|
||||
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>服务端逐周配的学期周次区间(管理员在「学期周次」里维护)。</summary>
|
||||
public class TermWeekRangeDto
|
||||
{
|
||||
[JsonPropertyName("weekNumber")] public int WeekNumber { get; set; }
|
||||
/// <summary>YYYY-MM-DD</summary>
|
||||
[JsonPropertyName("startDate")] public string StartDate { get; set; } = "";
|
||||
/// <summary>YYYY-MM-DD</summary>
|
||||
[JsonPropertyName("endDate")] public string EndDate { get; set; } = "";
|
||||
}
|
||||
|
||||
public class PullResultDto
|
||||
{
|
||||
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
|
||||
[JsonPropertyName("week")] public int Week { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 学期逐周区间;空 = 服务端只按学期开始日期线性推算。
|
||||
///
|
||||
/// ClassIsland 自身只认"教学周序号 + 单双周",没有"按日期区间定义某一周"的模型
|
||||
/// (本机装的 2.1.0.1 没有该字段),所以这份区间在插件侧只用于:
|
||||
/// 1) 校正"现在是第几周"(调休会让线性推算整体错位);
|
||||
/// 2) 在设置页里显示教室这台机器当前处在哪一段日期区间,方便老师核对。
|
||||
/// 课表本身的周次口径仍由 <see cref="ClassPlanMirrorDto.TermStartDate"/> 与单双周表达。
|
||||
/// </summary>
|
||||
[JsonPropertyName("weekRanges")] public List<TermWeekRangeDto> WeekRanges { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -174,7 +204,7 @@ internal class ApiEnvelope<T>
|
||||
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>调用班级小助手后端的结果(不抛异常,便于设置页展示原因)。</summary>
|
||||
/// <summary>调用 ClassHelper后端的结果(不抛异常,便于设置页展示原因)。</summary>
|
||||
public class ApiCallResult
|
||||
{
|
||||
public bool Ok { get; init; }
|
||||
@@ -187,7 +217,7 @@ public class ApiCallResult
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 班级小助手后端客户端。
|
||||
/// ClassHelper后端客户端。
|
||||
///
|
||||
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
|
||||
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
|
||||
@@ -214,7 +244,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 与班级小助手通信的 HttpClient。
|
||||
/// 与 ClassHelper通信的 HttpClient。
|
||||
///
|
||||
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
|
||||
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
|
||||
@@ -335,7 +365,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
// 老师截图给我们就能一眼定位。
|
||||
message = envelope is null
|
||||
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
||||
"响应不是班级小助手的标准格式,请检查服务器地址是否正确、" +
|
||||
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确、" +
|
||||
"或该系统代理是否拦截了内网请求"
|
||||
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
|
||||
}
|
||||
@@ -350,7 +380,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
|
||||
LastError =
|
||||
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
||||
"响应不是班级小助手的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
|
||||
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
|
||||
return new ApiEnvelope<T> { Success = false, Message = LastError };
|
||||
}
|
||||
|
||||
@@ -365,7 +395,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
LastError = $"无法连接班级小助手服务:{exception.Message}";
|
||||
LastError = $"无法连接 ClassHelper服务:{exception.Message}";
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
@@ -17,8 +17,11 @@ public class PluginSettings : ObservableObject
|
||||
private string _deviceKey = "";
|
||||
private bool _autoReport = true;
|
||||
private int _reportIntervalSeconds = 60;
|
||||
private bool _uploadSchedule = true;
|
||||
private bool _mirrorSchedule = false;
|
||||
// 同步方向(2026-10-06 起):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
|
||||
// 「把教室课表传上去」改为 Web 端的一次性人工动作(点之前会弹警告)。
|
||||
// 自动回传会悄悄盖掉老师刚手排的课,所以默认关闭。
|
||||
private bool _uploadSchedule = false;
|
||||
private bool _mirrorSchedule = true;
|
||||
private bool _receiveNotifications = true;
|
||||
private int _notificationDurationSeconds = 8;
|
||||
private int _notificationPollSeconds = 10;
|
||||
@@ -26,7 +29,7 @@ public class PluginSettings : ObservableObject
|
||||
private bool _showDebugInfo = false;
|
||||
|
||||
/// <summary>
|
||||
/// 班级小助手后端地址,例如 <c>http://127.0.0.1:4000</c>。
|
||||
/// ClassHelper后端地址,例如 <c>http://127.0.0.1:4000</c>。
|
||||
/// </summary>
|
||||
public string ServerUrl
|
||||
{
|
||||
@@ -35,7 +38,7 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设备令牌(形如 <c>chci_...</c>):在班级小助手 Web 端「ClassIsland 联动」页面生成。
|
||||
/// 设备令牌(形如 <c>chci_...</c>):在 ClassHelper Web 端「ClassIsland 联动」页面生成。
|
||||
/// </summary>
|
||||
public string DeviceToken
|
||||
{
|
||||
@@ -74,8 +77,13 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否把 ClassIsland 的课表同步到班级小助手。
|
||||
/// 关闭后仍会上报"当前上什么课"的状态,只是不再写课表。
|
||||
/// 是否把 ClassIsland 的课表**自动**同步到 ClassHelper。
|
||||
///
|
||||
/// **默认关闭**:课表以 ClassHelper为准,教室这边改完课表不会自己传上去
|
||||
/// (否则老师刚在 ClassHelper上排好的课会被教室的旧课表悄悄覆盖)。
|
||||
/// 要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,
|
||||
/// 与本开关无关(见 <c>ReportSettingsDto.ScheduleRequested</c>)。
|
||||
/// 关闭后仍会上报"当前上什么课"的状态。
|
||||
/// </summary>
|
||||
public bool UploadSchedule
|
||||
{
|
||||
@@ -84,8 +92,10 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否把班级小助手上排好的课表镜像回 ClassIsland
|
||||
/// (新建一份名为「班级小助手-<班级名>」的档案课表,不覆盖老师原有的课表)。
|
||||
/// 是否把 ClassHelper上排好的课表镜像回 ClassIsland
|
||||
/// (新建一份名为「ClassHelper-<班级名>」的档案课表,不覆盖老师原有的课表)。
|
||||
///
|
||||
/// **默认开启**:这是现在课表同步的主方向。
|
||||
/// </summary>
|
||||
public bool MirrorSchedule
|
||||
{
|
||||
@@ -94,7 +104,7 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否接收老师在班级小助手上发布的提醒,并在 ClassIsland 上弹出。
|
||||
/// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
|
||||
/// </summary>
|
||||
public bool ReceiveNotifications
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ public class Plugin : PluginBase
|
||||
var application = AppBase.Current;
|
||||
if (application is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到 ClassIsland 应用实例,联动未启动");
|
||||
Logger?.LogError("ClassHelper 联动:拿不到 ClassIsland 应用实例,联动未启动");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,14 +72,14 @@ public class Plugin : PluginBase
|
||||
var lessons = IAppHost.TryGetService<ILessonsService>();
|
||||
if (lessons is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到课程服务,联动已停用");
|
||||
Logger?.LogError("ClassHelper 联动:拿不到课程服务,联动已停用");
|
||||
return;
|
||||
}
|
||||
|
||||
var bridge = IAppHost.TryGetService<BridgeService>();
|
||||
if (bridge is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到联动服务,联动已停用");
|
||||
Logger?.LogError("ClassHelper 联动:拿不到联动服务,联动已停用");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,12 +89,12 @@ public class Plugin : PluginBase
|
||||
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
|
||||
// 不这么做的话,最长要等一个上报间隔才会弹出来
|
||||
_ = bridge.PullPendingAsync();
|
||||
Logger?.LogInformation("班级小助手联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
|
||||
Logger?.LogInformation("ClassHelper 联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 联动失败绝不能把 ClassIsland 本体拖下水
|
||||
Logger?.LogError(exception, "班级小助手联动:初始化联动服务失败");
|
||||
Logger?.LogError(exception, "ClassHelper 联动:初始化联动服务失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ public class Plugin : PluginBase
|
||||
{
|
||||
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
|
||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||
"班级小助手联动:读取设置失败,已回退到默认设置({Path})", path);
|
||||
"ClassHelper 联动:读取设置失败,已回退到默认设置({Path})", path);
|
||||
}
|
||||
|
||||
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
|
||||
@@ -165,7 +165,7 @@ public class Plugin : PluginBase
|
||||
catch (Exception exception)
|
||||
{
|
||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||
"班级小助手联动:保存设置失败({Path})", path);
|
||||
"ClassHelper 联动:保存设置失败({Path})", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,10 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
/// <summary>
|
||||
/// 联动主循环。三件事:
|
||||
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
|
||||
/// 把"当前上什么课"与(可选的)全量课表推给班级小助手;
|
||||
/// 把"当前上什么课"与(可选的)全量课表推给 ClassHelper;
|
||||
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
|
||||
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
|
||||
/// 3. <b>镜像</b>:开启镜像开关时,把班级小助手上排好的课表写回 ClassIsland 档案。
|
||||
/// 3. <b>镜像</b>:开启镜像开关时,把 ClassHelper上排好的课表写回 ClassIsland 档案。
|
||||
///
|
||||
/// 为什么用"上报即拉取"而不是长连接:
|
||||
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
|
||||
@@ -174,7 +174,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
}
|
||||
|
||||
Raise(nameof(IsAttached));
|
||||
_logger.LogInformation("班级小助手联动:已订阅课程事件");
|
||||
_logger.LogInformation("ClassHelper 联动:已订阅课程事件");
|
||||
RequestReport("应用启动");
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
/// 执行一次上报并返回是否真的发出去了。
|
||||
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
|
||||
/// </summary>
|
||||
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true)
|
||||
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true, bool forceSchedule = false)
|
||||
{
|
||||
if (_disposed) return false;
|
||||
|
||||
@@ -253,17 +253,21 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
var layoutCount = 0;
|
||||
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
|
||||
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
|
||||
if (includeSchedule && !_settings.UploadSchedule)
|
||||
// forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求,
|
||||
// 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested)
|
||||
var uploadSchedule = forceSchedule || _settings.UploadSchedule;
|
||||
|
||||
if (includeSchedule && !uploadSchedule)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:本机设置里「上报课表到班级小助手」是关闭的,本次只上报状态;" +
|
||||
"需要把教室课表同步到班级小助手请打开该开关");
|
||||
"ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" +
|
||||
"需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」");
|
||||
}
|
||||
|
||||
if (includeSchedule && _settings.UploadSchedule && profile is not null)
|
||||
if (includeSchedule && uploadSchedule && profile is not null)
|
||||
{
|
||||
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
|
||||
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning);
|
||||
foreach (var warning in warnings) _logger.LogWarning("ClassHelper 联动:{Warning}", warning);
|
||||
entryCount = entries.Count;
|
||||
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
|
||||
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
|
||||
@@ -272,8 +276,8 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
request.Schedule = null;
|
||||
_logger.LogWarning(
|
||||
"班级小助手联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
|
||||
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由班级小助手下发一份");
|
||||
"ClassHelper 联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
|
||||
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由 ClassHelper下发一份");
|
||||
}
|
||||
|
||||
var layout = ResolvePrimaryTimeLayout(profile);
|
||||
@@ -296,7 +300,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
.ConfigureAwait(false);
|
||||
if (!result.Ok || data is null)
|
||||
{
|
||||
_logger.LogWarning("班级小助手联动上报失败({Reason}):{Message}", reason, result.Message);
|
||||
_logger.LogWarning("ClassHelper 联动上报失败({Reason}):{Message}", reason, result.Message);
|
||||
SetState(false, result.Message, entryCount, layoutCount, null);
|
||||
return false;
|
||||
}
|
||||
@@ -306,15 +310,15 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
if (data.ScheduleApplied)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
||||
"ClassHelper 联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
||||
data.ScheduleCreated, data.ScheduleUpdated);
|
||||
}
|
||||
|
||||
var detail = entryCount > 0
|
||||
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
||||
: _settings.UploadSchedule
|
||||
: uploadSchedule
|
||||
? "仅状态(ClassIsland 里还没有启用的课表)"
|
||||
: "仅状态(本机「上报课表到班级小助手」开关已关闭)";
|
||||
: "仅状态(本机「上报课表到 ClassHelper」开关已关闭)";
|
||||
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
|
||||
entryCount, layoutCount, data.Week);
|
||||
|
||||
@@ -325,6 +329,14 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
NotificationReceived?.Invoke(this, pending);
|
||||
}
|
||||
|
||||
// 服务端请求"立刻上报一次课表"(老师在 Web 端点了「从教室机器获取课表」)。
|
||||
// 放在镜像之前:先把教室的现状推上去,再做服务端→教室的镜像,顺序反了会把刚拿到的课表盖掉。
|
||||
if (data.Settings?.ScheduleRequested == true && !uploadSchedule)
|
||||
{
|
||||
_logger.LogInformation("ClassHelper 联动:服务端请求立即上报课表,正在补发一次…");
|
||||
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
|
||||
}
|
||||
|
||||
if (_settings.MirrorSchedule)
|
||||
{
|
||||
await MirrorScheduleAsync().ConfigureAwait(false);
|
||||
@@ -338,7 +350,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
|
||||
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
|
||||
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
|
||||
_logger.LogError(exception, "班级小助手联动:上报异常({Reason})", reason);
|
||||
_logger.LogError(exception, "ClassHelper 联动:上报异常({Reason})", reason);
|
||||
SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
|
||||
return false;
|
||||
}
|
||||
@@ -369,13 +381,13 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!result.Ok)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:拉取待提醒失败({Message})", result.Message);
|
||||
_logger.LogInformation("ClassHelper 联动:拉取待提醒失败({Message})", result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
|
||||
_logger.LogInformation("ClassHelper 联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
|
||||
NotificationReceived?.Invoke(this, item);
|
||||
}
|
||||
}
|
||||
@@ -392,16 +404,16 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
if (result.Ok)
|
||||
{
|
||||
NotificationCount += 1;
|
||||
_logger.LogInformation("班级小助手联动:已确认提醒 {Id}", id);
|
||||
_logger.LogInformation("ClassHelper 联动:已确认提醒 {Id}", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
|
||||
_logger.LogWarning("班级小助手联动:确认提醒失败 {Message}", result.Message);
|
||||
_logger.LogWarning("ClassHelper 联动:确认提醒失败 {Message}", result.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把班级小助手上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
|
||||
/// <summary>把 ClassHelper上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
|
||||
public async Task MirrorScheduleAsync()
|
||||
{
|
||||
var profile = ResolveProfile();
|
||||
@@ -412,7 +424,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:跳过课表镜像({Message})。" +
|
||||
"ClassHelper 联动:跳过课表镜像({Message})。" +
|
||||
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
|
||||
result.Message);
|
||||
return;
|
||||
@@ -423,7 +435,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
var signature = MirrorSignature(plan);
|
||||
if (signature == _lastMirrorSignature)
|
||||
{
|
||||
_logger.LogDebug("班级小助手联动:课表内容与上次一致,跳过镜像");
|
||||
_logger.LogDebug("ClassHelper 联动:课表内容与上次一致,跳过镜像");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,22 +463,22 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
|
||||
if (!outcome.Applied)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
|
||||
_logger.LogInformation("ClassHelper 联动:课表镜像未生效({Reason})",
|
||||
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
||||
return;
|
||||
}
|
||||
|
||||
_lastMirrorSignature = signature;
|
||||
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
|
||||
_logger.LogInformation("ClassHelper 联动:已把班级课表写入 ClassIsland({Plans})",
|
||||
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
|
||||
foreach (var warning in outcome.Warnings)
|
||||
{
|
||||
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
|
||||
_logger.LogWarning("ClassHelper 联动镜像提示:{Warning}", warning);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "班级小助手联动:课表镜像失败");
|
||||
_logger.LogError(exception, "ClassHelper 联动:课表镜像失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +507,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
/* ---------------------------------------------------------------- 内部实现 */
|
||||
|
||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||
private const string PluginVersion = "1.1.2.0";
|
||||
private const string PluginVersion = "1.2.0.0";
|
||||
|
||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||
private static string SafeAppVersion()
|
||||
@@ -524,13 +536,13 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
|
||||
_logger.LogWarning(exception, "班级小助手联动:读取课程状态失败,本次只上报心跳");
|
||||
_logger.LogWarning(exception, "ClassHelper 联动:读取课程状态失败,本次只上报心跳");
|
||||
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 选一份"最有代表性"的时间表上报给班级小助手:**上课点最多的那一份**。
|
||||
/// 选一份"最有代表性"的时间表上报给 ClassHelper:**上课点最多的那一份**。
|
||||
///
|
||||
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
|
||||
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
|
||||
@@ -578,7 +590,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "班级小助手联动:读取档案失败");
|
||||
_logger.LogWarning(exception, "ClassHelper 联动:读取档案失败");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@ using Microsoft.Extensions.Logging;
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 提醒提供方:把老师在班级小助手上发布的提醒显示到 ClassIsland 上。
|
||||
/// 提醒提供方:把老师在 ClassHelper上发布的提醒显示到 ClassIsland 上。
|
||||
///
|
||||
/// 与 <see cref="BridgeService"/> 的分工:
|
||||
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
|
||||
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
|
||||
/// 全屏特效 / 语音 / 音效那一整套流程。
|
||||
///
|
||||
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「班级小助手联动」设置页里,
|
||||
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「ClassHelper 联动」设置页里,
|
||||
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
|
||||
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
|
||||
/// </summary>
|
||||
@@ -27,9 +27,9 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
|
||||
[NotificationProviderInfo(
|
||||
ProviderGuidValue,
|
||||
"班级小助手提醒",
|
||||
"ClassHelper提醒",
|
||||
"\uE8BD",
|
||||
"显示老师从班级小助手下发的提醒。")]
|
||||
"显示老师从 ClassHelper下发的提醒。")]
|
||||
public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
{
|
||||
/// <summary>
|
||||
@@ -100,11 +100,11 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||
_lessonsHooked = true;
|
||||
_logger.LogInformation("班级小助手联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
|
||||
_logger.LogInformation("ClassHelper 联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,将在下次提醒时重试");
|
||||
_logger.LogWarning(exception, "ClassHelper 联动:订阅课程事件失败,将在下次提醒时重试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
|
||||
var pending = _deferred.ToList();
|
||||
_deferred.Clear();
|
||||
_logger.LogInformation("班级小助手联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
|
||||
_logger.LogInformation("ClassHelper 联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
|
||||
foreach (var item in pending) ShowOnUiThread(item);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
{
|
||||
if (!_playing.Add(notification.Id))
|
||||
{
|
||||
_logger.LogDebug("班级小助手联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
|
||||
_logger.LogDebug("ClassHelper 联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
if (IsInClass() && !IsImmediate(notification))
|
||||
{
|
||||
_deferred.Add(notification);
|
||||
_logger.LogInformation("班级小助手联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
|
||||
_logger.LogInformation("ClassHelper 联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
|
||||
AckWhenFinished(request, notification);
|
||||
ShowNotification(request);
|
||||
_logger.LogInformation("班级小助手联动:已弹出提醒「{Title}」({Seconds} 秒)",
|
||||
_logger.LogInformation("ClassHelper 联动:已弹出提醒「{Title}」({Seconds} 秒)",
|
||||
notification.Title, notification.DurationSeconds);
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -185,7 +185,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
|
||||
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
|
||||
lock (_playing) _playing.Remove(notification.Id);
|
||||
_logger.LogError(exception, "班级小助手联动:显示提醒「{Title}」失败", notification.Title);
|
||||
_logger.LogError(exception, "ClassHelper 联动:显示提醒「{Title}」失败", notification.Title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
var duration = TimeSpan.FromSeconds(seconds);
|
||||
var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds));
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(notification.Title) ? "班级小助手" : notification.Title;
|
||||
var title = string.IsNullOrWhiteSpace(notification.Title) ? "ClassHelper" : notification.Title;
|
||||
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
|
||||
|
||||
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
|
||||
@@ -240,7 +240,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
|
||||
}
|
||||
|
||||
/// <summary>提醒播完 / 被关掉后回执给班级小助手,服务端不再补发。</summary>
|
||||
/// <summary>提醒播完 / 被关掉后回执给 ClassHelper,服务端不再补发。</summary>
|
||||
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
|
||||
{
|
||||
void Handler(object? sender, EventArgs args)
|
||||
|
||||
@@ -5,10 +5,10 @@ using ClassIsland.Shared.Models.Profile;
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 把班级小助手拉回来的课表写进 ClassIsland 档案。
|
||||
/// 把 ClassHelper拉回来的课表写进 ClassIsland 档案。
|
||||
///
|
||||
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
|
||||
/// (即一个星期几 + 一个单双周相位),而班级小助手的课表同时覆盖 7 天与单双周。
|
||||
/// (即一个星期几 + 一个单双周相位),而 ClassHelper的课表同时覆盖 7 天与单双周。
|
||||
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
|
||||
///
|
||||
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
|
||||
@@ -25,10 +25,10 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
public static class ClassPlanWriter
|
||||
{
|
||||
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
|
||||
public const string PlanNamePrefix = "班级小助手-";
|
||||
public const string PlanNamePrefix = "ClassHelper-";
|
||||
|
||||
/// <summary>时间表名字后缀。</summary>
|
||||
public const string TimeLayoutNameSuffix = "(班级小助手)";
|
||||
public const string TimeLayoutNameSuffix = "(ClassHelper)";
|
||||
|
||||
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
|
||||
public sealed class ApplyResult
|
||||
@@ -146,7 +146,7 @@ public static class ClassPlanWriter
|
||||
{
|
||||
result.Warnings.Add(
|
||||
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
|
||||
"两者重叠时以班级小助手同步的课表为准");
|
||||
"两者重叠时以 ClassHelper同步的课表为准");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ public static class ClassPlanWriter
|
||||
/// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
|
||||
///
|
||||
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
|
||||
/// 不依赖服务端有没有给课间点 —— 这样无论班级小助手那边是否维护节次时间表,
|
||||
/// 不依赖服务端有没有给课间点 —— 这样无论 ClassHelper那边是否维护节次时间表,
|
||||
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
|
||||
/// </summary>
|
||||
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
|
||||
@@ -226,7 +226,7 @@ public static class ClassPlanWriter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 课表名:单组时用「班级小助手-<班级名>」;多组时补上星期与单双周以免互相覆盖。
|
||||
/// 课表名:单组时用「ClassHelper-<班级名>」;多组时补上星期与单双周以免互相覆盖。
|
||||
/// </summary>
|
||||
private static string BuildPlanName(
|
||||
string profileName,
|
||||
@@ -235,7 +235,7 @@ public static class ClassPlanWriter
|
||||
int weekCountDivTotal,
|
||||
int groupCount)
|
||||
{
|
||||
var baseName = string.IsNullOrWhiteSpace(profileName) ? "班级小助手课表" : profileName.Trim();
|
||||
var baseName = string.IsNullOrWhiteSpace(profileName) ? "ClassHelper课表" : profileName.Trim();
|
||||
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
baseName = PlanNamePrefix + baseName;
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
/// <summary>
|
||||
/// 课表 / 状态 → 上报载荷 的换算层。
|
||||
///
|
||||
/// 这里集中全部"ClassIsland 口径 ↔ 班级小助手口径"的转换,保证只有一处需要跟着
|
||||
/// 这里集中全部"ClassIsland 口径 ↔ ClassHelper口径"的转换,保证只有一处需要跟着
|
||||
/// ClassIsland 的模型变化而修改:
|
||||
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
|
||||
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
|
||||
@@ -91,7 +91,7 @@ public static class ScheduleMapper
|
||||
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
||||
if (plan.TimeRule.WeekCountDivTotal > 2)
|
||||
{
|
||||
warning = "存在 3 周以上的轮换课表,班级小助手只支持单双周,已按「每周」处理";
|
||||
warning = "存在 3 周以上的轮换课表,ClassHelper只支持单双周,已按「每周」处理";
|
||||
}
|
||||
|
||||
var classes = plan.Classes;
|
||||
@@ -102,7 +102,7 @@ public static class ScheduleMapper
|
||||
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
|
||||
var point = classPoints[index];
|
||||
if (info is null) continue;
|
||||
// 老师停用的一节:本系统没有"停用"概念,不上报(否则学生端会看到一节实际上不上的课)
|
||||
// 老师停用的一节:本系统没有"停用"概念,不上报(否则 ClassHelper 班级端会看到一节实际上不上的课)
|
||||
if (!info.IsEnabled) continue;
|
||||
|
||||
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Classes="settings-container animated-intro" Spacing="0">
|
||||
<!-- 连接 -->
|
||||
<TextBlock Text="连接班级小助手"
|
||||
<TextBlock Text="连接 ClassHelper"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="填入班级小助手后端的地址与设备令牌。设备令牌在班级小助手 Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
|
||||
Text="填入 ClassHelper后端的地址与设备令牌。设备令牌在 ClassHelper Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
|
||||
|
||||
<TextBox Watermark="http://127.0.0.1:4000"
|
||||
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
|
||||
@@ -53,10 +53,10 @@
|
||||
<TextBlock Margin="0 24 0 0" Text="同步内容"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="课表以「合并」方式写入班级小助手:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
||||
Text="课表以「合并」方式写入 ClassHelper:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
||||
|
||||
<fa:SettingsExpander Header="上报课表到班级小助手"
|
||||
Description="把 ClassIsland 里启用的课表同步给班级小助手,Web 端与桌面客户端即可看到课程表。">
|
||||
<fa:SettingsExpander Header="自动上报课表到 ClassHelper"
|
||||
Description="把 ClassIsland 里启用的课表**自动**同步给 ClassHelper。默认关闭:课表以 ClassHelper 为准,自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,与本开关无关。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
@@ -66,7 +66,7 @@
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
|
||||
Description="反方向同步:把班级小助手上排好的课表写回 ClassIsland,会新建一份以「班级小助手-」开头的档案课表,不会修改老师原有的课表。">
|
||||
Description="反方向同步:把 ClassHelper上排好的课表写回 ClassIsland,会新建一份以「ClassHelper-」开头的档案课表,不会修改老师原有的课表。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
@@ -101,10 +101,10 @@
|
||||
<TextBlock Margin="0 24 0 0" Text="提醒"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="老师在班级小助手上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
|
||||
Text="老师在 ClassHelper上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
|
||||
|
||||
<fa:SettingsExpander Header="接收班级小助手提醒"
|
||||
Description="关闭后本机会忽略老师在班级小助手上发布的提醒(提醒仍会留在班级通知中心)。">
|
||||
<fa:SettingsExpander Header="接收 ClassHelper提醒"
|
||||
Description="关闭后本机会忽略老师在 ClassHelper上发布的提醒(提醒仍会留在班级通知中心)。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
@@ -165,7 +165,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="提示:班级小助手 Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
|
||||
Text="提示:ClassHelper Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</controls:SettingsPageBase>
|
||||
@@ -11,7 +11,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
namespace ClassHelper.ClassIslandPlugin.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 「班级小助手联动」设置页。
|
||||
/// 「ClassHelper 联动」设置页。
|
||||
///
|
||||
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
|
||||
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
|
||||
@@ -20,7 +20,7 @@ namespace ClassHelper.ClassIslandPlugin.Views;
|
||||
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
|
||||
[SettingsPageInfo(
|
||||
"classhelper.classisland.bridge.settings",
|
||||
"班级小助手联动",
|
||||
"ClassHelper 联动",
|
||||
"\uE71B",
|
||||
"\uE71B",
|
||||
SettingsPageCategory.External)]
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
appId: com.classhelper.desktop
|
||||
productName: 班级小助手
|
||||
productName: ClassHelper
|
||||
copyright: Copyright © 2026 Class Helper
|
||||
|
||||
# 语言包:Electron 发行版自带 55 个 `locales/*.pak`(实测 48.3MB 未压缩 / 约 10MB 压缩后),
|
||||
@@ -10,7 +10,7 @@ copyright: Copyright © 2026 Class Helper
|
||||
# 注意:**不要**为了继续压体积去删 dxcompiler/vk_swiftshader/d3dcompiler_47 这些 GPU 回退组件,
|
||||
# 或 LICENSES.chromium.html —— 前者是教室老显卡/虚拟机上的 D3D12/D3D11/Vulkan 渲染回退,
|
||||
# 后者是 Chromium 的许可证声明,合计只能再省约 12MB,风险不成比例。
|
||||
# 另外安装包有硬底:`班级小助手.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。
|
||||
# 另外安装包有硬底:`ClassHelper.exe`(就是 electron.exe)压缩后本身就有 85~90MB,别再指望压到 60MB。
|
||||
electronLanguages:
|
||||
- zh-CN
|
||||
|
||||
@@ -57,7 +57,7 @@ nsis:
|
||||
allowToChangeInstallationDirectory: true
|
||||
createDesktopShortcut: true
|
||||
createStartMenuShortcut: true
|
||||
shortcutName: 班级小助手
|
||||
shortcutName: ClassHelper
|
||||
artifactName: ${productName}-${version}-${arch}-setup.${ext}
|
||||
|
||||
portable:
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
|
||||
/>
|
||||
<title>班级小助手</title>
|
||||
<title>ClassHelper</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self' http: https: ws: wss:; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'self'"
|
||||
/>
|
||||
<title>班级小助手 · 灵动岛</title>
|
||||
<title>ClassHelper · 灵动岛</title>
|
||||
<style>
|
||||
/* 透明背景:窗口本身无边框透明,由页面绘制圆角卡片 */
|
||||
html,
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@classhelper/desktop-client",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"description": "班级小助手 EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
|
||||
"author": "Class Helper",
|
||||
"main": "dist/main/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
* node scripts/dist-win.mjs --dir # 仅生成免安装目录
|
||||
*
|
||||
* 产物统一落到 releases/client/<版本>/(releases/ 不入库,与服务端、插件同级):
|
||||
* 安装包/ 班级小助手-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
||||
* 安装包/ ClassHelper-<版本>-x64-setup.exe / -portable.exe(+ .blockmap)
|
||||
* 免安装/ electron-builder 的 win-unpacked 内容
|
||||
* 构建中间/ builder-*.yml、*.nsis.7z
|
||||
* electron-builder 自身的 `directories.output` 由本脚本用 --config 覆盖成上面的版本目录;
|
||||
|
||||
@@ -58,7 +58,7 @@ if (online === undefined) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 准备一个已知可用的班级账号(学生端已改为班级码 + 班级密码登录):
|
||||
* 准备一个已知可用的班级账号(ClassHelper 班级端已改为班级码 + 班级密码登录):
|
||||
* 用管理员账号登录 → 取第一个班级 → 先试"已知密码"(冒烟密码 / 种子默认 123456),
|
||||
* 命中就直接用、**不改动任何数据**;都不行才把该班重置成冒烟专用密码,
|
||||
* 并在脚本收尾时恢复为种子默认密码(避免把演示实例的 123456 悄悄改掉)。
|
||||
|
||||
@@ -827,7 +827,7 @@ onMounted(async () => {
|
||||
<footer class="foot">
|
||||
<span v-if="isListMode" class="more">共 {{ pendingCount }} 条待处理</span>
|
||||
<span v-else-if="queueCount > 0" class="more">还有 {{ queueCount }} 条通知</span>
|
||||
<span v-else class="more muted">来自班级小助手</span>
|
||||
<span v-else class="more muted">来自 ClassHelper</span>
|
||||
<span class="actions">
|
||||
<button type="button" class="ghost-btn" @click.stop="openApp">打开应用</button>
|
||||
<button v-if="canMarkRead" type="button" class="ghost-btn" @click.stop="markRead">
|
||||
|
||||
@@ -60,7 +60,7 @@ function createWindow(): BrowserWindow {
|
||||
minWidth: 1000,
|
||||
minHeight: 540,
|
||||
center: true,
|
||||
title: '班级小助手',
|
||||
title: 'ClassHelper',
|
||||
// 冒烟验证时不弹窗,避免打扰使用者
|
||||
show: !isSmokeTest,
|
||||
autoHideMenuBar: true,
|
||||
|
||||
@@ -4253,7 +4253,7 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
|
||||
record('渲染进程挂载(#app 有子节点)', (dom?.appChildren ?? 0) > 0, `children=${dom?.appChildren ?? 0}`);
|
||||
record(
|
||||
'窗口标题正确',
|
||||
dom?.title === '班级小助手' || (dom?.title ?? '').includes('班级小助手'),
|
||||
dom?.title === 'ClassHelper' || (dom?.title ?? '').includes('ClassHelper'),
|
||||
`title=${dom?.title}`,
|
||||
);
|
||||
record(
|
||||
|
||||
@@ -34,13 +34,13 @@ export function createTray(handlers: TrayHandlers): Tray | null {
|
||||
const image = iconPath ? nativeImage.createFromPath(iconPath) : nativeImage.createEmpty();
|
||||
tray = new Tray(image);
|
||||
|
||||
tray.setToolTip('班级小助手(后台运行中)');
|
||||
tray.setToolTip('ClassHelper(后台运行中)');
|
||||
tray.setContextMenu(
|
||||
Menu.buildFromTemplate([
|
||||
{ label: '显示主窗口', click: () => handlers.onShow() },
|
||||
{ label: '隐藏到托盘', click: () => handlers.onHide() },
|
||||
{ type: 'separator' },
|
||||
{ label: '退出班级小助手', click: () => handlers.onQuit() },
|
||||
{ label: '退出 ClassHelper', click: () => handlers.onQuit() },
|
||||
]),
|
||||
);
|
||||
// 左键单击/双击托盘图标:恢复窗口(用户习惯)
|
||||
|
||||
@@ -136,7 +136,7 @@ onUnmounted(() => {
|
||||
<template>
|
||||
<div v-if="booting" class="boot-screen">
|
||||
<el-icon :size="30" class="is-loading" color="#409eff"><Loading /></el-icon>
|
||||
<p class="boot-title">班级小助手</p>
|
||||
<p class="boot-title">ClassHelper</p>
|
||||
<!--
|
||||
启动文案会依次经过「读取本地配置 → 恢复登录状态」,直接换字是一下"啪"的跳变。
|
||||
交叉淡入(旧的上移淡出、新的从下方淡入)把两次状态读成**同一行字在更新**,
|
||||
|
||||
@@ -15,19 +15,20 @@ import {
|
||||
type NotificationDto,
|
||||
type ScheduleDto,
|
||||
type ScheduleWeekView,
|
||||
type StudentDto,
|
||||
type SessionUser,
|
||||
type StudentGradeDetailDto,
|
||||
} from '@classhelper/shared';
|
||||
import { api } from './http.js';
|
||||
|
||||
/** 客户端只需用到学生视角的这批接口(与后端同一套 REST 契约) */
|
||||
/** ClassHelper 班级端只需要这一批接口(与后端同一套 REST 契约) */
|
||||
export const authApi = {
|
||||
/** 账号登录(教师/管理员用;客户端界面走班级登录,学生个人账号已清理不能登录) */
|
||||
login: (payload: LoginRequest): Promise<LoginResponse> => api.post(API_PATHS.auth.login, payload),
|
||||
/** 班级账号登录(学生端主入口):班级码 + 班级密码 */
|
||||
/** 班级账号登录(ClassHelper 班级端主入口):班级码 + 班级密码 */
|
||||
classLogin: (payload: { code: string; password: string }): Promise<LoginResponse> =>
|
||||
api.post('/auth/class-login', payload),
|
||||
logout: (): Promise<{ loggedOut: boolean }> => api.post(API_PATHS.auth.logout, {}),
|
||||
me: (): Promise<StudentDto> => api.get(API_PATHS.auth.me),
|
||||
me: (): Promise<SessionUser> => api.get(API_PATHS.auth.me),
|
||||
/**
|
||||
* 修改密码(设置页「账号信息」里的入口)。
|
||||
* 服务端同一个接口按会话类型分流:班级账号改的是**班级密码**,教师/管理员改的是本人登录密码 ——
|
||||
@@ -128,6 +129,24 @@ export const notificationApi = {
|
||||
|
||||
export const gradeApi = {
|
||||
my: (): Promise<GradeDto[]> => api.get(`${API_PATHS.grades}/my`),
|
||||
/**
|
||||
* 按**学号**查某个学生的成绩明细。
|
||||
* 学生没有账号,因此这是教室机器代学生查本人的通道;是否开放由班级上的
|
||||
* `studentGradeQueryEnabled` 开关决定(关闭时服务端返回 403 并给出原因)。
|
||||
*/
|
||||
studentDetail: (studentNo: string): Promise<StudentGradeDetailDto> =>
|
||||
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`),
|
||||
};
|
||||
|
||||
/**
|
||||
* ClassHelper 班级端设置:「是否允许按学号查询本班学生成绩明细」。
|
||||
* 与通知显示位置同一套模式 —— 由教室机器自己决定,教师/管理员在 Web 端也能改。
|
||||
*/
|
||||
export const classGradeQueryApi = {
|
||||
get: (classId: string): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
||||
api.get(`${API_PATHS.classes}/${classId}/student-grade-query`),
|
||||
set: (classId: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
|
||||
api.patch(`${API_PATHS.classes}/${classId}/student-grade-query`, { enabled }),
|
||||
};
|
||||
|
||||
export const dashboardApi = {
|
||||
|
||||
+1
-1
@@ -315,7 +315,7 @@ export async function onlineScenario(credentials?: {
|
||||
const detail: string[] = [];
|
||||
|
||||
try {
|
||||
// 学生端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入。
|
||||
// ClassHelper 班级端已改为「班级账号」登录:凭据由 scripts/smoke.mjs 通过管理端接口准备并传入。
|
||||
// 这里**不再回退到种子班级码**(G101/123456 也是真实可用的登录凭据,不能写进安装包):
|
||||
// 没有凭据就明确跳过,由 scripts/smoke.mjs 打印"未能准备班级账号"。
|
||||
const code = credentials?.code ?? '';
|
||||
|
||||
@@ -17,9 +17,9 @@ const onboarding = useOnboardingStore();
|
||||
const steps = [
|
||||
{
|
||||
icon: 'School',
|
||||
title: '欢迎使用班级小助手',
|
||||
title: '欢迎使用 ClassHelper',
|
||||
lines: [
|
||||
'这台电脑将以「班级」的身份使用班级小助手:实时接收课表、作业、通知与成绩。',
|
||||
'这台电脑将以「班级」的身份使用 ClassHelper:实时接收课表、作业、通知与成绩。',
|
||||
'第一次使用,先花半分钟看看怎么开始。',
|
||||
],
|
||||
},
|
||||
@@ -127,7 +127,7 @@ onUnmounted(() => {
|
||||
<el-icon :size="18"><School /></el-icon>
|
||||
</span>
|
||||
<div class="onboarding-heading">
|
||||
<h2 class="onboarding-title">班级小助手</h2>
|
||||
<h2 class="onboarding-title">ClassHelper</h2>
|
||||
<p class="onboarding-sub">初次启动引导 · 第 {{ index + 1 }} / {{ steps.length }} 步</p>
|
||||
</div>
|
||||
<el-button link class="onboarding-skip" data-action="skip" @click="finish">跳过引导</el-button>
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
Pointer,
|
||||
Refresh,
|
||||
School,
|
||||
Search,
|
||||
Setting,
|
||||
Sunny,
|
||||
SwitchButton,
|
||||
@@ -75,6 +76,7 @@ const APP_ICONS = {
|
||||
Pointer,
|
||||
Refresh,
|
||||
School,
|
||||
Search,
|
||||
Setting,
|
||||
Sunny,
|
||||
SwitchButton,
|
||||
|
||||
@@ -298,7 +298,7 @@ onUnmounted(() => {
|
||||
<!-- 品牌区在折叠时**不是 v-if 删掉**,而是靠 CSS 收宽度 + 淡出:
|
||||
v-if 会在动画中途把节点直接摘掉,看上去是"啪"地一跳;收放要连续就得让它们一直在。 -->
|
||||
<img class="brand-logo" :src="BRAND_LOGO_URL" alt="" width="26" height="26" />
|
||||
<span class="brand-text">班级小助手</span>
|
||||
<span class="brand-text">ClassHelper</span>
|
||||
</div>
|
||||
<!-- 激活项的滑动指示器:单个底片在菜单项之间平移,位置由 syncIndicator() 量出来。
|
||||
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
|
||||
|
||||
@@ -44,9 +44,9 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级账号登录(学生端主入口):班级码 + 班级密码。
|
||||
* 班级账号登录(ClassHelper 班级端主入口):班级码 + 班级密码。
|
||||
*
|
||||
* 需求 1:学生端以「班级」为主体,个人学生不再是登录主体;
|
||||
* 需求 1:ClassHelper 班级端以「班级」为主体,个人学生不再是登录主体;
|
||||
* 服务端据此把作业完成 / 通知已读等个人数据按"全班"范围读写。
|
||||
* 保存的 username 字段记录班级码,便于下次预填。
|
||||
*/
|
||||
|
||||
@@ -18,7 +18,7 @@ type RealtimeEventName = keyof ServerToClientEvents;
|
||||
type EventHandler = (payload: unknown) => void;
|
||||
|
||||
/**
|
||||
* 实时通道(学生端)。
|
||||
* 实时通道(ClassHelper 班级端)。
|
||||
* 与 Web 管理端使用同一套事件名;收到事件后除了通知用户,
|
||||
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* ============================================================================
|
||||
* 班级小助手客户端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
|
||||
* ClassHelper 班级端主题 —— ClassIsland 同款 Fluent 风格(浅色 / 深色双主题)
|
||||
*
|
||||
* 参考 ClassIsland(Windows 课件表应用)的观感:
|
||||
* - WinUI / Fluent 设计语言:Mica 底 + 半透明"亚克力"卡片 + 1px 细腻描边
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
gradeLevel,
|
||||
gradePercent,
|
||||
type GradeDto,
|
||||
type StudentGradeDetailDto,
|
||||
} from '@classhelper/shared';
|
||||
import { gradeApi } from '../api/index.js';
|
||||
import { fetchWithCache } from '../cache/index.js';
|
||||
@@ -16,7 +17,10 @@ import { useAuthStore } from '../stores/auth.js';
|
||||
import { useRealtimeStore } from '../stores/realtime.js';
|
||||
|
||||
const appStore = useAppStore();
|
||||
/** 班级账号(班级设备)看到的是全班成绩总览,标题与副标题随会话类型变化 */
|
||||
/**
|
||||
* ClassHelper 班级端看到的是**全班成绩总览**(学生不是账号,登录主体就是班级),
|
||||
* 标题与副标题随会话类型变化。
|
||||
*/
|
||||
const auth = useAuthStore();
|
||||
const realtime = useRealtimeStore();
|
||||
|
||||
@@ -42,15 +46,46 @@ const latestGrades = computed(() =>
|
||||
[...grades.value].sort((a, b) => b.publishedAt.localeCompare(a.publishedAt)).slice(0, 5),
|
||||
);
|
||||
|
||||
/**
|
||||
* 等级分布:优先用**库里存的等级**(教师可以手改、也可能是自定义等级),
|
||||
* 只有老数据没有等级时才按得分率现算 —— 否则教师在 Web 端改过的等级在这里会被"算回去"。
|
||||
*/
|
||||
const levelCounts = computed(() => {
|
||||
const levels: Record<string, number> = { A: 0, B: 0, C: 0, D: 0, E: 0 };
|
||||
const levels: Record<string, number> = {};
|
||||
for (const item of grades.value) {
|
||||
levels[gradeLevel(gradePercent(item.score, item.totalScore))] =
|
||||
(levels[gradeLevel(gradePercent(item.score, item.totalScore))] ?? 0) + 1;
|
||||
const level = item.level || gradeLevel(gradePercent(item.score, item.totalScore));
|
||||
levels[level] = (levels[level] ?? 0) + 1;
|
||||
}
|
||||
return levels;
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 按学号查成绩明细
|
||||
*
|
||||
* 学生没有账号、不能登录,因此"查自己的成绩"这件事由**教室机器**代做:
|
||||
* 输入学号 → 服务端返回该生的全部成绩明细(含等级)。
|
||||
* 是否开放由教师/管理员在班级里开关控制(关闭后服务端返回 403,这里直接提示原因)。
|
||||
*/
|
||||
const queryNo = ref('');
|
||||
const queryLoading = ref(false);
|
||||
const queried = ref<StudentGradeDetailDto | null>(null);
|
||||
const queryError = ref('');
|
||||
|
||||
async function queryByStudentNo(): Promise<void> {
|
||||
const studentNo = queryNo.value.trim();
|
||||
if (!studentNo) return;
|
||||
queryLoading.value = true;
|
||||
queryError.value = '';
|
||||
queried.value = null;
|
||||
try {
|
||||
queried.value = await gradeApi.studentDetail(studentNo);
|
||||
} catch (error) {
|
||||
// 403 的文案由服务端给出("本班未开放学生自助查询成绩,请联系老师开启"),原样展示最准确
|
||||
queryError.value = error instanceof Error ? error.message : '查询失败,请稍后重试';
|
||||
} finally {
|
||||
queryLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 按考试聚合平均得分率(最近 8 次考试),交给 ScoreBarChart 渲染。
|
||||
* 这里原先调的是 `echarts.setOption(...)`;改用纯 CSS 柱状图是为了去掉
|
||||
@@ -111,10 +146,55 @@ onUnmounted(() => {
|
||||
</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<el-input
|
||||
v-model="queryNo"
|
||||
placeholder="输入学号查个人明细"
|
||||
clearable
|
||||
style="width: 200px"
|
||||
@keyup.enter="queryByStudentNo"
|
||||
/>
|
||||
<el-button :icon="'Search'" :loading="queryLoading" @click="queryByStudentNo">按学号查询</el-button>
|
||||
<el-button :icon="'Refresh'" @click="loadGrades">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 按学号查询结果:ClassHelper 班级端代学生查本人明细 -->
|
||||
<el-card v-if="queried || queryError" shadow="never" class="mt-12">
|
||||
<template #header>
|
||||
<div class="query-header">
|
||||
<span v-if="queried">
|
||||
学号 {{ queried.student.studentNo }} · {{ queried.student.name }}
|
||||
<span class="text-muted">({{ queried.className ?? '未分班' }})</span>
|
||||
</span>
|
||||
<span v-else>查询失败</span>
|
||||
<el-button link @click="((queried = null), (queryError = ''))">收起</el-button>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert v-if="queryError" type="warning" :closable="false" show-icon :title="queryError" />
|
||||
<template v-else-if="queried">
|
||||
<div class="text-muted" style="margin-bottom: 8px">
|
||||
共 {{ queried.items.length }} 条记录 · 平均得分率 {{ queried.averagePercent }}%
|
||||
</div>
|
||||
<el-table :data="queried.items" size="small" empty-text="该生还没有成绩记录" max-height="320">
|
||||
<el-table-column prop="examName" label="考试" width="150" />
|
||||
<el-table-column label="科目" width="110">
|
||||
<template #default="{ row }">{{ row.course?.name ?? '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="分数" width="120">
|
||||
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="等级" width="100">
|
||||
<template #default="{ row }">
|
||||
<el-tag size="small" effect="plain">{{ row.level || '-' }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="发布时间">
|
||||
<template #default="{ row }">{{ formatDate(row.publishedAt, true) }}</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="12">
|
||||
<el-col :xs="24" :md="8">
|
||||
<div class="stat-card">
|
||||
|
||||
@@ -467,7 +467,7 @@ async function openSubmissions(item: HomeworkDto): Promise<void> {
|
||||
const result = await homeworkApi.submissions(item.id);
|
||||
submissions.value = result.students;
|
||||
// 默认沿用服务端已有的未交数据(老师只需改动的部分)
|
||||
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId);
|
||||
notSubmittedIds.value = result.notSubmitted.map((student) => student.studentId);
|
||||
} catch (error) {
|
||||
ElMessage.error(error instanceof Error ? error.message : '读取未交名单失败');
|
||||
submissions.value = [];
|
||||
@@ -482,7 +482,7 @@ function markAllSubmitted(): void {
|
||||
}
|
||||
|
||||
function markAllNotSubmitted(): void {
|
||||
notSubmittedIds.value = submissions.value.map((student) => student.userId);
|
||||
notSubmittedIds.value = submissions.value.map((student) => student.studentId);
|
||||
}
|
||||
|
||||
async function saveSubmissions(): Promise<void> {
|
||||
@@ -496,7 +496,7 @@ async function saveSubmissions(): Promise<void> {
|
||||
try {
|
||||
const result = await homeworkApi.saveSubmissions(target.id, notSubmittedIds.value);
|
||||
submissions.value = result.students;
|
||||
notSubmittedIds.value = result.notSubmitted.map((student) => student.userId);
|
||||
notSubmittedIds.value = result.notSubmitted.map((student) => student.studentId);
|
||||
target.completedCount = result.completedCount;
|
||||
ElMessage.success(`未交名单已保存(未交 ${result.notSubmitted.length} / ${result.total} 人)`);
|
||||
submissionsVisible.value = false;
|
||||
@@ -835,7 +835,7 @@ onUnmounted(() => {
|
||||
<div v-loading="submissionsLoading" class="submission-list">
|
||||
<el-empty v-if="submissions.length === 0 && !submissionsLoading" description="该班还没有学生账号" />
|
||||
<el-checkbox-group v-model="notSubmittedIds">
|
||||
<el-checkbox v-for="student in submissions" :key="student.userId" :value="student.userId">
|
||||
<el-checkbox v-for="student in submissions" :key="student.studentId" :value="student.studentId">
|
||||
{{ student.name }}
|
||||
</el-checkbox>
|
||||
</el-checkbox-group>
|
||||
|
||||
@@ -95,7 +95,7 @@ async function enterOffline(): Promise<void> {
|
||||
<div class="login-head">
|
||||
<img class="login-logo" :src="BRAND_LOGO_URL" alt="" width="44" height="44" />
|
||||
<div>
|
||||
<h1 class="login-title">班级小助手</h1>
|
||||
<h1 class="login-title">ClassHelper</h1>
|
||||
<p class="login-sub">班级客户端 · 以班级账号登录,实时接收课表、作业、通知与成绩</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -85,11 +85,11 @@ onMounted(async () => {
|
||||
<el-icon><School /></el-icon>
|
||||
{{ collapseItems[0].title }}
|
||||
</span>
|
||||
<span class="about-extra">班级小助手 v{{ appInfo?.appVersion ?? '-' }}</span>
|
||||
<span class="about-extra">ClassHelper v{{ appInfo?.appVersion ?? '-' }}</span>
|
||||
</template>
|
||||
<div class="about-section">
|
||||
<p class="about-copy">
|
||||
Copyright © 2025-2026 班级小助手
|
||||
Copyright © 2025-2026 ClassHelper
|
||||
<br />
|
||||
面向班级的课表 · 作业 · 通知 · 成绩信息工具(Web 管理端 + 桌面客户端 + 灵动岛)
|
||||
</p>
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
formatDate,
|
||||
type ClassIslandNotificationChannel,
|
||||
} from '@classhelper/shared';
|
||||
import { classIslandStatusApi } from '../../api/index.js';
|
||||
import { classGradeQueryApi, classIslandStatusApi } from '../../api/index.js';
|
||||
import { useAppStore } from '../../stores/app.js';
|
||||
import { useAuthStore } from '../../stores/auth.js';
|
||||
|
||||
@@ -37,6 +37,38 @@ const classIslandStatus = ref<{
|
||||
classIslandVersion: string | null;
|
||||
} | null>(null);
|
||||
|
||||
/* ------------------------------------------------------------ 班级端成绩查询开关
|
||||
*
|
||||
* 「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」——
|
||||
* 教室机器在这里决定,值存在班级上;Web 端「班级管理」里也能改同一个开关。
|
||||
*/
|
||||
const gradeQueryEnabled = ref(true);
|
||||
const gradeQuerySaving = ref(false);
|
||||
|
||||
async function loadGradeQuery(): Promise<void> {
|
||||
const classId = auth.user?.classId;
|
||||
if (!classId) return;
|
||||
try {
|
||||
const result = await classGradeQueryApi.get(classId);
|
||||
gradeQueryEnabled.value = result.studentGradeQueryEnabled;
|
||||
} catch {
|
||||
// 读不到就按默认值展示(默认开启),不阻断设置页
|
||||
}
|
||||
}
|
||||
|
||||
async function applyGradeQuery(value: boolean): Promise<void> {
|
||||
const classId = auth.user?.classId;
|
||||
if (!classId) return;
|
||||
gradeQuerySaving.value = true;
|
||||
try {
|
||||
const result = await classGradeQueryApi.set(classId, value);
|
||||
gradeQueryEnabled.value = result.studentGradeQueryEnabled;
|
||||
ElMessage.success(value ? '已允许按学号查询成绩明细' : '已关闭成绩查询');
|
||||
} finally {
|
||||
gradeQuerySaving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
const classIslandStatusText = computed(() => {
|
||||
const status = classIslandStatus.value;
|
||||
if (!status || !status.connected) return '未接入';
|
||||
@@ -71,6 +103,8 @@ onMounted(async () => {
|
||||
classIslandStatus.value = status;
|
||||
classIslandConnected.value = status?.connected === true;
|
||||
}
|
||||
// 班级端成绩查询开关(存在班级上,与 Web 端共享同一个值)
|
||||
await loadGradeQuery();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -94,7 +128,7 @@ onMounted(async () => {
|
||||
</el-descriptions>
|
||||
<p class="text-muted">
|
||||
<strong>通知模式</strong>
|
||||
:老师在班级小助手上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||
:老师在 ClassHelper 上发布通知 / 叫人时,提醒弹在灵动岛还是 ClassIsland,
|
||||
由这台教室机器决定。选择会同步到班级,服务端据此决定是否推送到教室的 ClassIsland。
|
||||
</p>
|
||||
<el-radio-group
|
||||
@@ -114,11 +148,28 @@ onMounted(async () => {
|
||||
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
|
||||
<template v-if="!classIslandConnected">
|
||||
<br />
|
||||
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在
|
||||
提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassHelper 联动」里接入后才能选"只在
|
||||
ClassIsland 上弹")。
|
||||
</template>
|
||||
</p>
|
||||
</el-card>
|
||||
|
||||
<!-- 成绩查询:让教室机器能按学号代学生查本人明细 -->
|
||||
<el-card shadow="never" class="mt-16">
|
||||
<template #header><span>成绩查询</span></template>
|
||||
<p class="text-muted">
|
||||
打开后,可以在「成绩」页按<b>学号</b>查到本班学生的成绩明细(含等级)。
|
||||
学生本身没有账号、不能登录,查成绩就是由这台教室机器代做。
|
||||
关闭后本机查成绩会被服务端拒绝,教师端不受影响。
|
||||
</p>
|
||||
<el-switch
|
||||
:model-value="gradeQueryEnabled"
|
||||
:loading="gradeQuerySaving"
|
||||
active-text="允许按学号查询成绩明细"
|
||||
inactive-text="已关闭"
|
||||
@change="(value: string | number | boolean) => applyGradeQuery(value === true)"
|
||||
/>
|
||||
</el-card>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@classhelper/server",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"description": "班级小助手后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||
"description": "ClassHelper后端服务:REST API + Socket.IO 实时推送 + Prisma 数据访问",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
-- 学生从「账号」变成「名单记录」:新建 Student 表,个人数据外键改指向它
|
||||
--
|
||||
-- 为什么要有这一版:
|
||||
-- 在此之前学生是 `User` 表里 role='STUDENT' 的行 —— 与教师登录名共用同一个全局唯一
|
||||
-- 命名空间、靠 passwordHash='' 占位、靠"登录时先判 role 再 403"来阻止登录。
|
||||
-- 「学生不是账号」这条不变量只靠约定维持,任何一处漏判都会让一个学生变成可登录账号。
|
||||
-- 本迁移把它变成**结构性保证**:学生只存在于 Student 表,那里没有 passwordHash、没有 role。
|
||||
--
|
||||
-- 语义变化:
|
||||
-- 1) `User` 收缩为纯账号表(ADMIN / TEACHER / CLASS_DEVICE),去掉 classId 与 role='STUDENT';
|
||||
-- 2) `Student.studentNo`(学号)= 原 `User.username`,是学生唯一标识与查询键;
|
||||
-- 3) `Grade.userId` / `HomeworkStatus.userId` / `NotificationRead.userId` → `studentId`,
|
||||
-- 指向 Student(值就是原来那个 User.id,因此历史数据一条不丢);
|
||||
-- 4) 删除 `Enrollment`(`Student.classId` 已是唯一归属,两处双写必然漂移)
|
||||
-- 与 `ClassTeacher`(没有科目的协作关系;任课关系的唯一来源改为 `Course.teacherId`);
|
||||
-- 5) `User.phone`(教师联系方式)。
|
||||
--
|
||||
-- 数据保全:
|
||||
-- - 学生行按 id 原样搬进 Student,因此 Grade / HomeworkStatus / NotificationRead 里
|
||||
-- 旧 `userId` 直接就是新的 `studentId`,无需任何映射表;
|
||||
-- - 三张个人数据表回填时用 `IN (SELECT id FROM Student)` 兜底:历史上若有教师/管理员
|
||||
-- 误留下个人记录(例如教师给自己勾过作业完成),这些行会被丢弃而不是撞外键失败;
|
||||
-- - 成绩的 `level` 按旧口径(得分率)回填,保持升级后界面显示不变。
|
||||
|
||||
-- DropIndex
|
||||
DROP INDEX "ClassTeacher_classId_teacherId_key";
|
||||
DROP INDEX "ClassTeacher_teacherId_idx";
|
||||
DROP INDEX "Enrollment_userId_classId_key";
|
||||
DROP INDEX "Enrollment_classId_idx";
|
||||
|
||||
-- DropTable
|
||||
PRAGMA foreign_keys=off;
|
||||
DROP TABLE "ClassTeacher";
|
||||
DROP TABLE "Enrollment";
|
||||
PRAGMA foreign_keys=on;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Student" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"studentNo" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"classId" TEXT,
|
||||
"gender" TEXT NOT NULL DEFAULT '',
|
||||
"guardianPhone" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- 把原「学生账号」整行搬成名单记录:id 保持不变,这样下面三张表的 userId 可以直接当 studentId 用。
|
||||
INSERT INTO "Student" ("id", "studentNo", "name", "classId", "gender", "guardianPhone", "status", "createdAt", "updatedAt")
|
||||
SELECT "id", "username", "name", "classId", '', '', 'active', "createdAt", "updatedAt"
|
||||
FROM "User"
|
||||
WHERE "role" = 'STUDENT';
|
||||
|
||||
CREATE TABLE "StudentClassTransfer" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"studentId" TEXT NOT NULL,
|
||||
"studentNo" TEXT NOT NULL,
|
||||
"studentName" TEXT NOT NULL,
|
||||
"fromClassId" TEXT,
|
||||
"fromClassName" TEXT NOT NULL DEFAULT '',
|
||||
"toClassId" TEXT,
|
||||
"toClassName" TEXT NOT NULL DEFAULT '',
|
||||
"operatorId" TEXT,
|
||||
"operatorName" TEXT NOT NULL DEFAULT '',
|
||||
"mode" TEXT NOT NULL DEFAULT 'single',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "StudentClassTransfer_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
CREATE TABLE "new_Class" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"grade" TEXT NOT NULL,
|
||||
"teacherId" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"passwordHash" TEXT,
|
||||
"termWeeks" INTEGER NOT NULL DEFAULT 20,
|
||||
"notificationChannel" TEXT NOT NULL DEFAULT 'both',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Class_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Class" ("code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "teacherId", "termWeeks", "updatedAt") SELECT "code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "teacherId", "termWeeks", "updatedAt" FROM "Class";
|
||||
DROP TABLE "Class";
|
||||
ALTER TABLE "new_Class" RENAME TO "Class";
|
||||
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
|
||||
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
|
||||
|
||||
CREATE TABLE "new_Grade" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"courseId" TEXT,
|
||||
"studentId" TEXT NOT NULL,
|
||||
"examName" TEXT NOT NULL,
|
||||
"score" REAL NOT NULL,
|
||||
"totalScore" REAL NOT NULL DEFAULT 100,
|
||||
"publishedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Grade_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Grade_courseId_fkey" FOREIGN KEY ("courseId") REFERENCES "Course" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Grade_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Grade" ("classId", "courseId", "createdAt", "examName", "id", "publishedAt", "score", "studentId", "totalScore", "updatedAt")
|
||||
SELECT "classId", "courseId", "createdAt", "examName", "id", "publishedAt", "score", "userId", "totalScore", "updatedAt"
|
||||
FROM "Grade"
|
||||
WHERE "userId" IN (SELECT "id" FROM "Student");
|
||||
DROP TABLE "Grade";
|
||||
ALTER TABLE "new_Grade" RENAME TO "Grade";
|
||||
CREATE INDEX "Grade_studentId_publishedAt_idx" ON "Grade"("studentId", "publishedAt");
|
||||
CREATE INDEX "Grade_classId_examName_idx" ON "Grade"("classId", "examName");
|
||||
CREATE INDEX "Grade_courseId_idx" ON "Grade"("courseId");
|
||||
|
||||
CREATE TABLE "new_HomeworkStatus" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"homeworkId" TEXT NOT NULL,
|
||||
"studentId" TEXT NOT NULL,
|
||||
"completed" BOOLEAN NOT NULL DEFAULT false,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "HomeworkStatus_homeworkId_fkey" FOREIGN KEY ("homeworkId") REFERENCES "Homework" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "HomeworkStatus_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_HomeworkStatus" ("completed", "homeworkId", "id", "studentId", "updatedAt")
|
||||
SELECT "completed", "homeworkId", "id", "userId", "updatedAt"
|
||||
FROM "HomeworkStatus"
|
||||
WHERE "userId" IN (SELECT "id" FROM "Student");
|
||||
DROP TABLE "HomeworkStatus";
|
||||
ALTER TABLE "new_HomeworkStatus" RENAME TO "HomeworkStatus";
|
||||
CREATE INDEX "HomeworkStatus_studentId_idx" ON "HomeworkStatus"("studentId");
|
||||
CREATE UNIQUE INDEX "HomeworkStatus_homeworkId_studentId_key" ON "HomeworkStatus"("homeworkId", "studentId");
|
||||
|
||||
CREATE TABLE "new_NotificationRead" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"notificationId" TEXT NOT NULL,
|
||||
"studentId" TEXT NOT NULL,
|
||||
"readAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "NotificationRead_notificationId_fkey" FOREIGN KEY ("notificationId") REFERENCES "Notification" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "NotificationRead_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_NotificationRead" ("id", "notificationId", "readAt", "studentId")
|
||||
SELECT "id", "notificationId", "readAt", "userId"
|
||||
FROM "NotificationRead"
|
||||
WHERE "userId" IN (SELECT "id" FROM "Student");
|
||||
DROP TABLE "NotificationRead";
|
||||
ALTER TABLE "new_NotificationRead" RENAME TO "NotificationRead";
|
||||
CREATE INDEX "NotificationRead_studentId_idx" ON "NotificationRead"("studentId");
|
||||
CREATE UNIQUE INDEX "NotificationRead_notificationId_studentId_key" ON "NotificationRead"("notificationId", "studentId");
|
||||
|
||||
-- User 收缩成纯账号表:只留 ADMIN / TEACHER(+ 未来的 CLASS_DEVICE),去掉 classId,加 phone。
|
||||
-- 学生行在这里被丢弃 —— 它们已经在上面搬进 Student 了。
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"username" TEXT NOT NULL,
|
||||
"passwordHash" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"role" TEXT NOT NULL DEFAULT 'TEACHER',
|
||||
"phone" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
INSERT INTO "new_User" ("createdAt", "id", "name", "passwordHash", "role", "updatedAt", "username")
|
||||
SELECT "createdAt", "id", "name", "passwordHash", "role", "updatedAt", "username"
|
||||
FROM "User"
|
||||
WHERE "role" <> 'STUDENT';
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
CREATE UNIQUE INDEX "User_username_key" ON "User"("username");
|
||||
CREATE INDEX "User_role_idx" ON "User"("role");
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Student_studentNo_key" ON "Student"("studentNo");
|
||||
CREATE INDEX "Student_classId_idx" ON "Student"("classId");
|
||||
CREATE INDEX "Student_name_idx" ON "Student"("name");
|
||||
CREATE INDEX "StudentClassTransfer_studentId_createdAt_idx" ON "StudentClassTransfer"("studentId", "createdAt");
|
||||
CREATE INDEX "StudentClassTransfer_createdAt_idx" ON "StudentClassTransfer"("createdAt");
|
||||
CREATE INDEX "Course_teacherId_idx" ON "Course"("teacherId");
|
||||
@@ -0,0 +1,8 @@
|
||||
-- ClassHelper 班级端成绩查询开关(按班级存,默认开启)
|
||||
--
|
||||
-- 需求:「ClassHelper 班级端可按学号查询本班学生成绩明细;是否允许学生通过班级端查看本人明细,
|
||||
-- 由教师或管理员开关控制」。
|
||||
--
|
||||
-- 默认 true 是刻意的:教室机器装好就能查(开箱可用),要收紧的班级由班主任/管理员关掉即可。
|
||||
-- 关闭后班级端调 `GET /api/grades/student/:studentNo` 一律 403,教师端不受影响。
|
||||
ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 成绩等级:落到库里,教师可单个/批量手动编辑
|
||||
--
|
||||
-- 在此之前等级是**前端按得分率现算**的(`@classhelper/shared` 的 `gradeLevel(percent)`),
|
||||
-- 教师既看不到存下来的等级,也无法手动调整 —— 而需求要求
|
||||
-- 「发布成绩时可选百分制 / 等级制 A·B·C·D / 自定义等级,且可手动编辑等级(单个 + 批量)」。
|
||||
--
|
||||
-- `levelType`:
|
||||
-- percent 百分制 —— 等级由得分率自动换算(教师仍可手改并覆盖)
|
||||
-- letter 等级制 —— 直接给 A / B / C / D
|
||||
-- custom 自定义等级 —— 教师填任意文本(优 / 良 / 合格 / 待提高…)
|
||||
--
|
||||
-- `level` 永远有值:percent 下写入换算结果,其余两种按教师指定写入。
|
||||
ALTER TABLE "Grade" ADD COLUMN "levelType" TEXT NOT NULL DEFAULT 'percent';
|
||||
ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT '';
|
||||
|
||||
-- 回填旧数据:按升级前的同一套口径(得分率 ≥90 A / ≥80 B / ≥70 C / ≥60 D / 其余 E)算,
|
||||
-- 保证升级前后界面上看到的等级完全一致。总分非法(≤0)一律落到 E。
|
||||
UPDATE "Grade"
|
||||
SET "level" = CASE
|
||||
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 90 THEN 'A'
|
||||
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 80 THEN 'B'
|
||||
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 70 THEN 'C'
|
||||
WHEN "totalScore" > 0 AND ("score" * 100.0 / "totalScore") >= 60 THEN 'D'
|
||||
ELSE 'E'
|
||||
END
|
||||
WHERE "level" = '';
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
-- ClassHelper 班级端心跳时间:把「在线判定」和「最后带状态的上报」拆成两个字段
|
||||
--
|
||||
-- 原先只有一个 `lastSeenAt`(每次上报都刷)。但同一台设备的上报其实有两种:
|
||||
-- - **心跳**:插件每隔 60 秒打一次,只表明"我还在线";
|
||||
-- - **状态上报**:带运行状态 / 课表,表明"我拿到了新东西"。
|
||||
-- 合成一个字段后,教师网页无法区分「设备在线但一直没有新状态」和「设备整个失联」。
|
||||
--
|
||||
-- 于是:
|
||||
-- `lastHeartbeatAt` 每次上报都刷新 —— **在线判定看它**(60 秒窗口,见 shared 的
|
||||
-- `CLASS_HELPER_ONLINE_WINDOW_MS`);
|
||||
-- `lastSeenAt` 只在带运行状态的上报时刷新 —— 展示为「最后在线时间」。
|
||||
--
|
||||
-- 回填:老库只有 lastSeenAt,就把它当作心跳时间的初值(否则所有设备升级后都会显示"从未心跳")。
|
||||
ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME;
|
||||
UPDATE "IntegrationDevice" SET "lastHeartbeatAt" = "lastSeenAt" WHERE "lastHeartbeatAt" IS NULL;
|
||||
@@ -0,0 +1,145 @@
|
||||
-- 毕业归档:届别档案 + 班级/学生的归档标记
|
||||
--
|
||||
-- 背景(需求):管理员要有一个「归档」页,按**年度**记录毕业的班级与学生、当年转出的学生,
|
||||
-- 以及毕业班级发过的作业与通知;**未毕业而升级的班级不归档、数据沿用**。
|
||||
--
|
||||
-- 关键设计:**归档是打标记 + 只读,不删任何数据**。
|
||||
-- `ArchivedYear` 是届别档案(入学年份 + 毕业年份 + 归档时的统计快照);
|
||||
-- `Class.archivedYearId` / `Student.archivedYearId` 指向它,常规列表据此过滤掉归档项;
|
||||
-- 毕业班级的作业与通知原样留在库里(`Homework.classId` / `Notification.classId` 不变),
|
||||
-- 归档详情页只读地查它们 —— 「记录每个年度毕业班级的消息和作业」就是这么满足的。
|
||||
--
|
||||
-- 升级 ≠ 归档:高一 → 高二 只改 `Class.grade`,班级记录、学生名单、作业成绩全部沿用,
|
||||
-- 不碰 archivedYearId。因此归档只能由管理员在归档页显式执行。
|
||||
--
|
||||
-- 班级称呼改成「XXXX级X班」:新列 `enrollmentYear`(入学年份)+ `classIndex`(班号),
|
||||
-- `Class.name` 由服务端按二者生成。下面会对**已有班级**做一次尽力而为的推断回填。
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ArchivedYear" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"enrollmentYear" INTEGER NOT NULL,
|
||||
"graduationYear" INTEGER NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"note" TEXT NOT NULL DEFAULT '',
|
||||
"operatorId" TEXT,
|
||||
"operatorName" TEXT NOT NULL DEFAULT '',
|
||||
"classCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"studentCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"transferredCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"homeworkCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"notificationCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"archivedAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
|
||||
CREATE TABLE "new_Class" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"grade" TEXT NOT NULL,
|
||||
"enrollmentYear" INTEGER,
|
||||
"classIndex" INTEGER,
|
||||
"teacherId" TEXT NOT NULL,
|
||||
"code" TEXT NOT NULL,
|
||||
"passwordHash" TEXT,
|
||||
"termWeeks" INTEGER NOT NULL DEFAULT 20,
|
||||
"notificationChannel" TEXT NOT NULL DEFAULT 'both',
|
||||
"studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"archivedYearId" TEXT,
|
||||
"archivedAt" DATETIME,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Class_teacherId_fkey" FOREIGN KEY ("teacherId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
|
||||
CONSTRAINT "Class_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Class" ("code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termWeeks", "updatedAt") SELECT "code", "createdAt", "grade", "id", "name", "notificationChannel", "passwordHash", "studentGradeQueryEnabled", "teacherId", "termWeeks", "updatedAt" FROM "Class";
|
||||
DROP TABLE "Class";
|
||||
ALTER TABLE "new_Class" RENAME TO "Class";
|
||||
CREATE UNIQUE INDEX "Class_code_key" ON "Class"("code");
|
||||
CREATE INDEX "Class_teacherId_idx" ON "Class"("teacherId");
|
||||
CREATE INDEX "Class_enrollmentYear_idx" ON "Class"("enrollmentYear");
|
||||
CREATE INDEX "Class_archivedYearId_idx" ON "Class"("archivedYearId");
|
||||
|
||||
-- 已有班级的届别推断(尽力而为):
|
||||
-- 入学年份 = 当前学年起始年 − 该班现在的年级偏移。学年在 9 月切换,
|
||||
-- 所以 9 月及以后算今年起始、之前算去年起始;高一偏移 0、高二 1、高三 2,
|
||||
-- 认不出的年级(初中 / 四年制等)按高三处理(宁可偏早,管理员可在班级管理页改正)。
|
||||
UPDATE "Class"
|
||||
SET "enrollmentYear" = (
|
||||
CASE WHEN CAST(strftime('%m', 'now') AS INTEGER) >= 9
|
||||
THEN CAST(strftime('%Y', 'now') AS INTEGER)
|
||||
ELSE CAST(strftime('%Y', 'now') AS INTEGER) - 1 END
|
||||
) - CASE "grade" WHEN '高二' THEN 1 WHEN '高三' THEN 2 ELSE 0 END
|
||||
WHERE "enrollmentYear" IS NULL;
|
||||
|
||||
-- 班号按「同一届内按创建先后」编号(老名字里的括号数字没法跨库 reliably 解析,这是等价的近似)。
|
||||
UPDATE "Class"
|
||||
SET "classIndex" = (
|
||||
SELECT COUNT(*) FROM "Class" c2
|
||||
WHERE c2."enrollmentYear" = "Class"."enrollmentYear"
|
||||
AND (c2."createdAt" < "Class"."createdAt"
|
||||
OR (c2."createdAt" = "Class"."createdAt" AND c2."id" <= "Class"."id"))
|
||||
)
|
||||
WHERE "classIndex" IS NULL;
|
||||
|
||||
-- 按新口径重写称呼:「2026级1班」。管理员若觉得推断不对,可在班级管理页改入学年份/班号。
|
||||
UPDATE "Class"
|
||||
SET "name" = CAST("enrollmentYear" AS TEXT) || '级' || CAST("classIndex" AS TEXT) || '班'
|
||||
WHERE "enrollmentYear" IS NOT NULL AND "classIndex" IS NOT NULL;
|
||||
|
||||
CREATE TABLE "new_Student" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"studentNo" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"classId" TEXT,
|
||||
"gender" TEXT NOT NULL DEFAULT '',
|
||||
"guardianPhone" TEXT NOT NULL DEFAULT '',
|
||||
"status" TEXT NOT NULL DEFAULT 'active',
|
||||
"archivedYearId" TEXT,
|
||||
"archivedAt" DATETIME,
|
||||
"transferredAt" DATETIME,
|
||||
"transferNote" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "Student_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
|
||||
CONSTRAINT "Student_archivedYearId_fkey" FOREIGN KEY ("archivedYearId") REFERENCES "ArchivedYear" ("id") ON DELETE SET NULL ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Student" ("classId", "createdAt", "gender", "guardianPhone", "id", "name", "status", "studentNo", "updatedAt") SELECT "classId", "createdAt", "gender", "guardianPhone", "id", "name", "status", "studentNo", "updatedAt" FROM "Student";
|
||||
DROP TABLE "Student";
|
||||
ALTER TABLE "new_Student" RENAME TO "Student";
|
||||
CREATE UNIQUE INDEX "Student_studentNo_key" ON "Student"("studentNo");
|
||||
CREATE INDEX "Student_classId_idx" ON "Student"("classId");
|
||||
CREATE INDEX "Student_name_idx" ON "Student"("name");
|
||||
CREATE INDEX "Student_status_idx" ON "Student"("status");
|
||||
CREATE INDEX "Student_archivedYearId_idx" ON "Student"("archivedYearId");
|
||||
|
||||
CREATE TABLE "new_StudentClassTransfer" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"studentId" TEXT NOT NULL,
|
||||
"studentNo" TEXT NOT NULL,
|
||||
"studentName" TEXT NOT NULL,
|
||||
"fromClassId" TEXT,
|
||||
"fromClassName" TEXT NOT NULL DEFAULT '',
|
||||
"toClassId" TEXT,
|
||||
"toClassName" TEXT NOT NULL DEFAULT '',
|
||||
"operatorId" TEXT,
|
||||
"operatorName" TEXT NOT NULL DEFAULT '',
|
||||
"mode" TEXT NOT NULL DEFAULT 'single',
|
||||
"note" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "StudentClassTransfer_studentId_fkey" FOREIGN KEY ("studentId") REFERENCES "Student" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_StudentClassTransfer" ("createdAt", "fromClassId", "fromClassName", "id", "mode", "operatorId", "operatorName", "studentId", "studentName", "studentNo", "toClassId", "toClassName") SELECT "createdAt", "fromClassId", "fromClassName", "id", "mode", "operatorId", "operatorName", "studentId", "studentName", "studentNo", "toClassId", "toClassName" FROM "StudentClassTransfer";
|
||||
DROP TABLE "StudentClassTransfer";
|
||||
ALTER TABLE "new_StudentClassTransfer" RENAME TO "StudentClassTransfer";
|
||||
CREATE INDEX "StudentClassTransfer_studentId_createdAt_idx" ON "StudentClassTransfer"("studentId", "createdAt");
|
||||
CREATE INDEX "StudentClassTransfer_createdAt_idx" ON "StudentClassTransfer"("createdAt");
|
||||
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "ArchivedYear_enrollmentYear_key" ON "ArchivedYear"("enrollmentYear");
|
||||
@@ -0,0 +1,79 @@
|
||||
-- 学期周次配置 + 课表同步方向反转
|
||||
--
|
||||
-- 一、学期周次(需求:「第几周的周次数据可以由 admin 修改」)
|
||||
-- `TermWeek` 让管理员为**每一个教学周**指定实际的起止日期(调休、周末补课、错峰开学),
|
||||
-- `classId = ''` 表示全校默认;没配到的周次仍按学期开始日期线性兜底。
|
||||
-- `Class.termStartDate` 允许某个班单独覆盖全局的开学日期(它随镜像课表下发给教室的 ClassIsland)。
|
||||
--
|
||||
-- 二、课表同步方向反转(需求:「课表不再自动从教室的 ClassIsland 获取,改为手动获取;
|
||||
-- ClassHelper 班级端的课表由服务端自动下发」)
|
||||
-- `syncScheduleToServer` 默认 true → **false**:插件不再自动把教室的课表推上来
|
||||
-- `mirrorScheduleToClassIsland` 默认 false → **true**:服务端的课表自动下发给教室
|
||||
-- `requestScheduleReport` 新增:Web 端点「从教室机器获取课表」时置位,
|
||||
-- 插件下次心跳看到就把当前课表推上来并清除标记
|
||||
--
|
||||
-- 下面会把**已有设备**也改成新方向:这正是需求要的行为变化(否则升级后教室里
|
||||
-- 仍然在自动回传,老师手排的课会被教室的旧课表悄悄覆盖)。设备管理员可以逐台改回去。
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "Class" ADD COLUMN "termStartDate" TEXT;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TermWeek" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL DEFAULT '',
|
||||
"weekNumber" INTEGER NOT NULL,
|
||||
"startDate" TEXT NOT NULL,
|
||||
"endDate" TEXT NOT NULL,
|
||||
"note" TEXT NOT NULL DEFAULT '',
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL
|
||||
);
|
||||
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_IntegrationDevice" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"classId" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"deviceKey" TEXT NOT NULL,
|
||||
"mode" TEXT NOT NULL DEFAULT 'plugin',
|
||||
"enabled" BOOLEAN NOT NULL DEFAULT true,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"tokenHint" TEXT NOT NULL,
|
||||
"syncScheduleToServer" BOOLEAN NOT NULL DEFAULT false,
|
||||
"mirrorScheduleToClassIsland" BOOLEAN NOT NULL DEFAULT true,
|
||||
"requestScheduleReport" BOOLEAN NOT NULL DEFAULT false,
|
||||
"classIslandVersion" TEXT,
|
||||
"pluginVersion" TEXT,
|
||||
"lastSeenAt" DATETIME,
|
||||
"lastHeartbeatAt" DATETIME,
|
||||
"classPlanLoaded" BOOLEAN NOT NULL DEFAULT false,
|
||||
"currentSubject" TEXT,
|
||||
"currentTimeState" TEXT,
|
||||
"currentPeriodStart" TEXT,
|
||||
"currentPeriodEnd" TEXT,
|
||||
"nextSubject" TEXT,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
CONSTRAINT "IntegrationDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_IntegrationDevice" ("classId", "classIslandVersion", "classPlanLoaded", "createdAt", "currentPeriodEnd", "currentPeriodStart", "currentSubject", "currentTimeState", "deviceKey", "enabled", "id", "lastHeartbeatAt", "lastSeenAt", "mirrorScheduleToClassIsland", "mode", "name", "nextSubject", "pluginVersion", "syncScheduleToServer", "tokenHash", "tokenHint", "updatedAt") SELECT "classId", "classIslandVersion", "classPlanLoaded", "createdAt", "currentPeriodEnd", "currentPeriodStart", "currentSubject", "currentTimeState", "deviceKey", "enabled", "id", "lastHeartbeatAt", "lastSeenAt", "mirrorScheduleToClassIsland", "mode", "name", "nextSubject", "pluginVersion", "syncScheduleToServer", "tokenHash", "tokenHint", "updatedAt" FROM "IntegrationDevice";
|
||||
|
||||
-- 已有设备切到新方向:不再自动回传课表,改为由服务端自动下发。
|
||||
UPDATE "new_IntegrationDevice" SET "syncScheduleToServer" = false, "mirrorScheduleToClassIsland" = true;
|
||||
|
||||
DROP TABLE "IntegrationDevice";
|
||||
ALTER TABLE "new_IntegrationDevice" RENAME TO "IntegrationDevice";
|
||||
CREATE INDEX "IntegrationDevice_classId_idx" ON "IntegrationDevice"("classId");
|
||||
CREATE INDEX "IntegrationDevice_tokenHash_idx" ON "IntegrationDevice"("tokenHash");
|
||||
CREATE UNIQUE INDEX "IntegrationDevice_classId_deviceKey_key" ON "IntegrationDevice"("classId", "deviceKey");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TermWeek_classId_idx" ON "TermWeek"("classId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "TermWeek_classId_weekNumber_key" ON "TermWeek"("classId", "weekNumber");
|
||||
@@ -1,4 +1,4 @@
|
||||
// 班级小助手 - 数据模型
|
||||
// ClassHelper - 数据模型
|
||||
//
|
||||
// 设计说明:
|
||||
// 1) 本 schema 使用 sqlite provider(本地 MVP / 离线开发)。
|
||||
@@ -6,10 +6,22 @@
|
||||
// 不使用 Prisma enum 与 @db.* 原生类型,因此可平移到 MySQL。
|
||||
// MySQL 复用这一份 schema(就地改写下面的 provider,见 `pnpm db:switch:mysql`),
|
||||
// 没有第二份 schema.mysql.prisma;切换步骤见 docs/mysql.md。
|
||||
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'STUDENT'、
|
||||
// 2) 角色与优先级用 String 存储('ADMIN' | 'TEACHER' | 'CLASS_DEVICE'、
|
||||
// 'LOW' | 'NORMAL' | 'HIGH' | 'URGENT'),在 API 层用 zod 校验,
|
||||
// 这样 SQLite / MySQL 行为完全一致。
|
||||
// 3) 所有主键使用 cuid 字符串,便于多端离线生成与合并。
|
||||
//
|
||||
// **学生不是账号**(2026-10-06 重构的核心不变量):
|
||||
// `Student` 是独立的一张表,只有学号 / 姓名 / 班级 / 性别 / 家长手机号,
|
||||
// 没有 passwordHash、没有 role、没有登录字段 —— 「学生不能登录」由表结构保证,
|
||||
// 不再靠"登录前先判 role === 'STUDENT' 并 403"这类代码分支维持。
|
||||
// `User` 因此收缩为**纯账号表**(管理员 / 教师 / ClassHelper 班级端)。
|
||||
//
|
||||
// **任课关系唯一来源是 `Course`**:`Course(classId, name, teacherId)` 就是
|
||||
// 「班级 + 科目 + 教师」这条关系本身。班主任另由 `Class.teacherId` 表示(每班 1 人)。
|
||||
// 历史上那张没有科目的 `ClassTeacher` 协作表已删除 —— 两套关系并存必然漂移。
|
||||
// 同一教师可以在同一个班同时是班主任和某科科任老师:两处都指向同一个 `User`,
|
||||
// 不重复建号,权限在 `@classhelper/shared` 的 `resolveClassRole()` 里合并。
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
@@ -20,101 +32,229 @@ datasource db {
|
||||
provider = "sqlite"
|
||||
}
|
||||
|
||||
/// 用户:教师 / 管理员 / 学生统一存储
|
||||
/// 账号:管理员 / 教师 / ClassHelper 班级端。**学生不在这里**(见 Student)。
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
/// 登录名:教师即**工号**,班级端即班级码
|
||||
username String @unique
|
||||
passwordHash String
|
||||
name String
|
||||
role String @default("STUDENT")
|
||||
classId String?
|
||||
/// ADMIN | TEACHER | CLASS_DEVICE
|
||||
role String @default("TEACHER")
|
||||
/// 手机号(教师联系方式,可空)
|
||||
phone String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
// 学生的主班级(一个学生属于一个主班级)
|
||||
class Class? @relation("ClassStudents", fields: [classId], references: [id], onDelete: SetNull)
|
||||
// 教师创建(负责)的班级
|
||||
ownedClasses Class[] @relation("ClassOwner")
|
||||
// 教师被分配协作的班级
|
||||
classAssignments ClassTeacher[]
|
||||
/// 教师担任班主任的班级(每班 1 人)
|
||||
ownedClasses Class[] @relation("ClassOwner")
|
||||
/// 教师任教的课程 —— 「班级 + 科目 + 教师」任课关系
|
||||
courses Course[]
|
||||
homeworkStatuses HomeworkStatus[]
|
||||
notificationReads NotificationRead[]
|
||||
grades Grade[]
|
||||
enrollments Enrollment[]
|
||||
createdHomeworks Homework[] @relation("HomeworkCreator")
|
||||
createdNotices Notification[] @relation("NotificationCreator")
|
||||
createdHomeworks Homework[] @relation("HomeworkCreator")
|
||||
createdNotices Notification[] @relation("NotificationCreator")
|
||||
|
||||
@@index([classId])
|
||||
@@index([role])
|
||||
}
|
||||
|
||||
/// 学生:**只是班级名单里的一条记录**,没有账号、没有密码、不能登录。
|
||||
///
|
||||
/// `studentNo`(学号)是学生在系统里的唯一标识与查询键:
|
||||
/// 名单、成绩、作业提交、导入模板、导出、查询显示一律用它。
|
||||
/// 历史作业与成绩挂在 `Homework.classId` / `Grade.classId` 上,
|
||||
/// 因此**调班只改本表的 classId,历史数据保留原归属**。
|
||||
model Student {
|
||||
id String @id @default(cuid())
|
||||
/// 学号:学生唯一标识(全校唯一)
|
||||
studentNo String @unique
|
||||
name String
|
||||
/// 当前班级;null = 未分班(转出/毕业后班级归属仍保留,靠 status 区分)
|
||||
classId String?
|
||||
/// 性别:'' | 'MALE' | 'FEMALE'(字符串存,跨库通用)
|
||||
gender String @default("")
|
||||
/// 家长手机号(可空)
|
||||
guardianPhone String @default("")
|
||||
/// active = 在读 / graduated = 已毕业 / transferred = 已转出 / inactive = 停用
|
||||
status String @default("active")
|
||||
/// 毕业归档所属届别(`ArchivedYear.id`);null = 未归档
|
||||
archivedYearId String?
|
||||
archivedAt DateTime?
|
||||
/// 转出时间(status='transferred' 时写入)
|
||||
transferredAt DateTime?
|
||||
/// 转出去向 / 备注
|
||||
transferNote String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
class Class? @relation(fields: [classId], references: [id], onDelete: SetNull)
|
||||
archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
|
||||
homeworkStatuses HomeworkStatus[]
|
||||
notificationReads NotificationRead[]
|
||||
grades Grade[]
|
||||
transfers StudentClassTransfer[]
|
||||
|
||||
@@index([classId])
|
||||
@@index([name])
|
||||
@@index([status])
|
||||
@@index([archivedYearId])
|
||||
}
|
||||
|
||||
/// 调班 / 转出历史:原班级 / 新班级 / 操作人 / 时间。
|
||||
///
|
||||
/// 班级 id 与班级名**都存快照且不建外键** —— 班级被删除后这条历史仍要能读出来,
|
||||
/// 而外键的 Cascade 会把历史一起带走。
|
||||
model StudentClassTransfer {
|
||||
id String @id @default(cuid())
|
||||
studentId String
|
||||
/// 学号快照(学生被删除后历史仍可读)
|
||||
studentNo String
|
||||
studentName String
|
||||
fromClassId String?
|
||||
fromClassName String @default("")
|
||||
toClassId String?
|
||||
toClassName String @default("")
|
||||
/// 操作人(User.id);账号被删后留空,姓名快照保留
|
||||
operatorId String?
|
||||
operatorName String @default("")
|
||||
/// single = 单个调班 / batch = 批量调班 / transfer-out = 转出(离开本校)
|
||||
mode String @default("single")
|
||||
note String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([studentId, createdAt])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
/// 届别档案:一次「毕业归档」的留痕(管理员在「归档管理」页执行)。
|
||||
///
|
||||
/// 归档是**打标记 + 只读**,不删任何数据:
|
||||
/// - `Class.archivedYearId` / `Student.archivedYearId` 指向本届档案,
|
||||
/// 常规的班级/学生列表按它过滤掉归档项;
|
||||
/// - 毕业班级的作业与通知原样留在库里,通过归档详情只读查看
|
||||
/// (需求「记录每个年度毕业班级的消息和作业」就是这么满足的)。
|
||||
///
|
||||
/// 「未毕业而升级的班级不归档」:升级只改 `Class.grade`(高一→高二→高三),
|
||||
/// 班级记录、学生名单、作业成绩全部沿用,不碰 `archivedYearId`。
|
||||
model ArchivedYear {
|
||||
id String @id @default(cuid())
|
||||
/// 届别 = 入学年份(2026 表示 2026 级,班级称呼因此是「2026级1班」)
|
||||
enrollmentYear Int @unique
|
||||
/// 毕业年份(= 入学年份 + 学制年数)
|
||||
graduationYear Int
|
||||
/// 届别名,如「2026 级」
|
||||
name String
|
||||
note String @default("")
|
||||
operatorId String?
|
||||
operatorName String @default("")
|
||||
/// 归档时的统计快照(给人看的一行汇总;明细实时查班级与学生)
|
||||
classCount Int @default(0)
|
||||
studentCount Int @default(0)
|
||||
transferredCount Int @default(0)
|
||||
homeworkCount Int @default(0)
|
||||
notificationCount Int @default(0)
|
||||
archivedAt DateTime @default(now())
|
||||
|
||||
classes Class[]
|
||||
students Student[]
|
||||
}
|
||||
|
||||
/// 学期周次区间:管理员可以为**每一个教学周**指定实际的起止日期。
|
||||
///
|
||||
/// 为什么不能只靠"学期开始日期 + 每周七天"线性推算:法定节假日调休、周末补课、
|
||||
/// 错峰开学都会让某一周变长或变短(甚至整周不上课),线性推算出来的"第几周"
|
||||
/// 会与教室里的实际情况整体错位一周以上 —— 而课表、作业、成绩都按周次组织。
|
||||
///
|
||||
/// 因此支持逐周设置:
|
||||
/// `classId = ''` 表示**全校默认**(没有为某个班单独配置时用它);
|
||||
/// 有班级维度的行则覆盖默认。
|
||||
/// 没配置的周次仍按学期开始日期线性兜底(见 shared 的 `resolveCurrentWeek`)。
|
||||
model TermWeek {
|
||||
id String @id @default(cuid())
|
||||
/// 归属班级;**空串 = 全校默认**(用空串而不是 null,唯一约束才在两个库上都生效)
|
||||
classId String @default("")
|
||||
/// 教学周次(1 起)
|
||||
weekNumber Int
|
||||
/// 该周的起始日期(YYYY-MM-DD,本地日期)
|
||||
startDate String
|
||||
/// 该周的结束日期(YYYY-MM-DD)
|
||||
endDate String
|
||||
note String @default("")
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@unique([classId, weekNumber])
|
||||
@@index([classId])
|
||||
}
|
||||
|
||||
/// 班级
|
||||
///
|
||||
/// **班级称呼是「XXXX级X班」**(入学年份 + 班号),不是「高一(1)班」:
|
||||
/// 后者每年都要改,前者一路跟着这届学生到毕业。`name` 由服务端按
|
||||
/// `enrollmentYear` + `classIndex` 生成(见 shared 的 `formatClassName()`),
|
||||
/// 保留成列是为了兼容既有引用,不在别处手写。
|
||||
///
|
||||
/// `grade`(高一/高二/高三)表示**当前年级**,学年升级时批量改它即可 ——
|
||||
/// 升级只动这一个字段,班级记录、学生名单、作业与成绩全部沿用,**不归档**。
|
||||
model Class {
|
||||
id String @id @default(cuid())
|
||||
/// 班级称呼:由 enrollmentYear + classIndex 生成,如「2026级1班」
|
||||
name String
|
||||
/// 当前年级(高一 / 高二 / 高三),学年升级时更新
|
||||
grade String
|
||||
/// 入学年份(届别):2026 → 称呼「2026级…」、毕业年份 2029
|
||||
enrollmentYear Int?
|
||||
/// 班号(同一届内的序号)
|
||||
classIndex Int?
|
||||
/// 班主任(每班 1 人);科任老师按科目配在 Course 上
|
||||
teacherId String
|
||||
/// 班级码:学生端「班级账号」登录用(唯一,字母数字,4~16 位,存大写)
|
||||
/// 班级码:ClassHelper 班级端登录用(唯一,字母数字,4~16 位,存大写)
|
||||
code String @unique
|
||||
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级账号登录
|
||||
/// 班级密码(bcrypt 哈希);null = 尚未设置,此时禁止班级端登录
|
||||
passwordHash String?
|
||||
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
|
||||
termWeeks Int @default(20)
|
||||
/// 通知显示位置(由教室的班级客户端在设置页里选):
|
||||
/// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland
|
||||
/// 本班学期开始日期(YYYY-MM-DD,本地日期);留空则用全局配置(.env 的 TERM_START_DATE)。
|
||||
/// 管理员改它会重算「当前第几周」,并随镜像课表下发给教室的 ClassIsland。
|
||||
termStartDate String?
|
||||
/// 通知显示位置(由 ClassHelper 班级端在设置页里选):
|
||||
/// both=班级端与 ClassIsland 都弹 / client=只弹班级端 / classisland=只弹 ClassIsland
|
||||
notificationChannel String @default("both")
|
||||
/// 是否允许 ClassHelper 班级端按学号查询本班学生的成绩明细(默认开启)。
|
||||
/// 关闭后班级端查成绩一律 403;教师端不受影响。
|
||||
studentGradeQueryEnabled Boolean @default(true)
|
||||
/// 毕业归档所属届别;null = 在读(常规班级列表只显示未归档的)
|
||||
archivedYearId String?
|
||||
archivedAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
teacher User @relation("ClassOwner", fields: [teacherId], references: [id], onDelete: Cascade)
|
||||
students User[] @relation("ClassStudents")
|
||||
teachers ClassTeacher[]
|
||||
archivedYear ArchivedYear? @relation(fields: [archivedYearId], references: [id], onDelete: SetNull)
|
||||
students Student[]
|
||||
courses Course[]
|
||||
schedules Schedule[]
|
||||
homeworks Homework[]
|
||||
notifications Notification[]
|
||||
grades Grade[]
|
||||
enrollments Enrollment[]
|
||||
timeLayouts TimeLayout[]
|
||||
integrationDevices IntegrationDevice[]
|
||||
classIslandPushes ClassIslandPush[]
|
||||
|
||||
@@index([teacherId])
|
||||
@@index([enrollmentYear])
|
||||
@@index([archivedYearId])
|
||||
}
|
||||
|
||||
/// 教师-班级 分配关系(一个教师可管理多个班级)
|
||||
model ClassTeacher {
|
||||
id String @id @default(cuid())
|
||||
classId String
|
||||
teacherId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
teacher User @relation(fields: [teacherId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([classId, teacherId])
|
||||
@@index([teacherId])
|
||||
}
|
||||
|
||||
/// 学生-班级 选课/归属记录(与 User.classId 保持同步,支持未来多班)
|
||||
model Enrollment {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
classId String
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([userId, classId])
|
||||
@@index([classId])
|
||||
}
|
||||
|
||||
/// 课程
|
||||
/// 课程:同时也是「班级 + 科目 + 教师」这条**任课关系**的载体。
|
||||
///
|
||||
/// `teacherId` 就是该班该科的任课老师(管理员在「班级管理 → 任课老师」里指定)。
|
||||
/// 作业与成绩都挂在 Course 上,因此"这位老师能不能动这一科的作业/成绩"
|
||||
/// 就是查 `Course.teacherId`—— 不需要第二张任课表。
|
||||
model Course {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
/// 该班该科的任课老师
|
||||
teacherId String
|
||||
classId String
|
||||
createdAt DateTime @default(now())
|
||||
@@ -127,6 +267,7 @@ model Course {
|
||||
|
||||
@@unique([classId, name])
|
||||
@@index([classId])
|
||||
@@index([teacherId])
|
||||
}
|
||||
|
||||
/// 课表条目
|
||||
@@ -190,19 +331,19 @@ model Homework {
|
||||
@@index([dueAt])
|
||||
}
|
||||
|
||||
/// 学生作业完成状态
|
||||
/// 学生作业完成状态(按**学生名单记录**记,学生没有账号)
|
||||
model HomeworkStatus {
|
||||
id String @id @default(cuid())
|
||||
homeworkId String
|
||||
userId String
|
||||
studentId String
|
||||
completed Boolean @default(false)
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
homework Homework @relation(fields: [homeworkId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([homeworkId, userId])
|
||||
@@index([userId])
|
||||
@@unique([homeworkId, studentId])
|
||||
@@index([studentId])
|
||||
}
|
||||
|
||||
/// 通知
|
||||
@@ -227,14 +368,14 @@ model Notification {
|
||||
model NotificationRead {
|
||||
id String @id @default(cuid())
|
||||
notificationId String
|
||||
userId String
|
||||
studentId String
|
||||
readAt DateTime @default(now())
|
||||
|
||||
notification Notification @relation(fields: [notificationId], references: [id], onDelete: Cascade)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([notificationId, userId])
|
||||
@@index([userId])
|
||||
@@unique([notificationId, studentId])
|
||||
@@index([studentId])
|
||||
}
|
||||
|
||||
|
||||
@@ -256,34 +397,44 @@ model TimeLayout {
|
||||
}
|
||||
|
||||
/// 成绩
|
||||
///
|
||||
/// `classId` 是**发布时的归属班级**,调班不改变它 —— 历史成绩留在原班级,
|
||||
/// 后续新成绩按学生当前班级生成(需求「历史作业和成绩保留原归属」)。
|
||||
model Grade {
|
||||
id String @id @default(cuid())
|
||||
classId String
|
||||
courseId String?
|
||||
userId String
|
||||
/// 学生(名单记录)id
|
||||
studentId String
|
||||
examName String
|
||||
score Float
|
||||
totalScore Float @default(100)
|
||||
/// 等级口径:percent 百分制 / letter 等级制 A-D / custom 自定义等级
|
||||
levelType String @default("percent")
|
||||
/// 等级文本。percent 下由服务端按得分率生成初值,教师可手改覆盖
|
||||
level String @default("")
|
||||
publishedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
|
||||
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
|
||||
student User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
student Student @relation(fields: [studentId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, publishedAt])
|
||||
@@index([studentId, publishedAt])
|
||||
@@index([classId, examName])
|
||||
@@index([courseId])
|
||||
}
|
||||
|
||||
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端)
|
||||
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班的 ClassHelper 班级端)
|
||||
///
|
||||
/// 说明:
|
||||
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
|
||||
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
|
||||
/// 避免设备列表里堆满重复项;
|
||||
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。
|
||||
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课";
|
||||
/// 4) `lastHeartbeatAt` 每次上报都刷新(在线判定用它),`lastSeenAt` 只在**带运行状态**的
|
||||
/// 上报时刷新 —— 这样"能连上但一直没上报状态"的设备不会被误判成完全正常。
|
||||
model IntegrationDevice {
|
||||
id String @id @default(cuid())
|
||||
classId String
|
||||
@@ -300,12 +451,22 @@ model IntegrationDevice {
|
||||
/// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
|
||||
tokenHint String
|
||||
/// 是否允许插件把 ClassIsland 的课表回传到本服务
|
||||
syncScheduleToServer Boolean @default(true)
|
||||
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)
|
||||
mirrorScheduleToClassIsland Boolean @default(false)
|
||||
///
|
||||
/// **默认关闭**(2026-10-06 起):课表以服务端为准,由服务端**自动下发**到教室;
|
||||
/// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
|
||||
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
|
||||
syncScheduleToServer Boolean @default(false)
|
||||
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。**默认开启** —— 这是现在的主方向。
|
||||
mirrorScheduleToClassIsland Boolean @default(true)
|
||||
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
|
||||
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
|
||||
requestScheduleReport Boolean @default(false)
|
||||
classIslandVersion String?
|
||||
pluginVersion String?
|
||||
/// 最后一次**带运行状态**的上报时间
|
||||
lastSeenAt DateTime?
|
||||
/// 最后一次心跳时间(每次上报都刷新);「在线」看它
|
||||
lastHeartbeatAt DateTime?
|
||||
/// 以下为最后一次上报的快照
|
||||
classPlanLoaded Boolean @default(false)
|
||||
currentSubject String?
|
||||
|
||||
+208
-42
@@ -2,15 +2,25 @@
|
||||
* 种子数据:pnpm db:seed
|
||||
*
|
||||
* 会清空业务表后重建一套完整演示数据:
|
||||
* 1 管理员 / 2 教师 / 3 个班级 / 15 名学生 / 课程 / 课表 / 作业 / 通知 / 成绩
|
||||
* 1 管理员 / 2 教师 / 3 个在读班级 / 1 个已毕业届别 / 15 名学生 /
|
||||
* 课程(含各科任课老师)/ 课表 / 作业 / 通知 / 成绩 / 调班与转出历史
|
||||
*
|
||||
* 班级称呼一律是「XXXX级X班」(入学年份 + 班号)—— 见 shared 的 `formatClassName()`。
|
||||
*
|
||||
* 演示账号(密码见文件末尾打印结果):
|
||||
* admin / admin123 - 系统管理员
|
||||
* teacher1 / teacher123 - 张老师(高一(1)班、高二(3)班班主任)
|
||||
* teacher2 / teacher123 - 李老师(高一(2)班班主任,高二(3)班协作教师)
|
||||
* student01..student15 / student123
|
||||
* teacher1 / teacher123 - 张老师(2026级1班 班主任,兼 2026级1班 数学)
|
||||
* teacher2 / teacher123 - 李老师(2026级2班 班主任,兼 2026级1班 语文)
|
||||
* (学生只有名单、没有账号:ClassHelper 班级端统一用班级码 + 班级密码登录)
|
||||
*/
|
||||
import { SUBJECT_CATALOG, dayKeyLocal } from '@classhelper/shared';
|
||||
import {
|
||||
SUBJECT_CATALOG,
|
||||
dayKeyLocal,
|
||||
formatClassName,
|
||||
formatYearLabel,
|
||||
gradeLevel,
|
||||
gradePercent,
|
||||
} from '@classhelper/shared';
|
||||
import { disconnectPrisma, prisma } from '../src/lib/db.js';
|
||||
import { logger } from '../src/lib/logger.js';
|
||||
import { hashPassword } from '../src/lib/password.js';
|
||||
@@ -67,11 +77,13 @@ async function resetDatabase(): Promise<void> {
|
||||
await prisma.notification.deleteMany();
|
||||
await prisma.schedule.deleteMany();
|
||||
await prisma.course.deleteMany();
|
||||
await prisma.enrollment.deleteMany();
|
||||
await prisma.classTeacher.deleteMany();
|
||||
await prisma.user.updateMany({ data: { classId: null } });
|
||||
await prisma.studentClassTransfer.deleteMany();
|
||||
// 学生要排在调班历史之后、班级之前(Student.classId 是 SetNull,但历史是 Cascade)
|
||||
await prisma.student.deleteMany();
|
||||
await prisma.user.deleteMany();
|
||||
// 班级归到届别上,所以先删班级再删届别
|
||||
await prisma.class.deleteMany();
|
||||
await prisma.archivedYear.deleteMany();
|
||||
}
|
||||
|
||||
async function main(): Promise<void> {
|
||||
@@ -86,11 +98,11 @@ async function main(): Promise<void> {
|
||||
const random = createRandom(20260901);
|
||||
|
||||
// 同一角色共用密码,只需各哈希一次
|
||||
// 学生没有密码:个人学生账号已清理(2026-10-01),学生端统一用班级码 + 班级密码登录
|
||||
// 学生没有密码:个人学生账号已清理(2026-10-01),ClassHelper 班级端统一用班级码 + 班级密码登录
|
||||
const [adminHash, teacherHash, classPasswordHash] = await Promise.all([
|
||||
hashPassword('admin123'),
|
||||
hashPassword('teacher123'),
|
||||
// 班级账号默认密码(学生端「班级登录」)
|
||||
// 班级账号默认密码(ClassHelper 班级端「班级登录」)
|
||||
hashPassword('123456'),
|
||||
]);
|
||||
|
||||
@@ -105,19 +117,24 @@ async function main(): Promise<void> {
|
||||
data: { username: 'teacher2', name: '李老师', role: 'TEACHER', passwordHash: teacherHash },
|
||||
});
|
||||
|
||||
// 班级账号:班级码(学生端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
|
||||
// 班级账号:班级码(ClassHelper 班级端「班级登录」的账号)+ 班级密码(默认 123456,可在班级管理里重置)
|
||||
//
|
||||
// 称呼按「入学年份 + 班号」生成:2026 级是高一(当前学年 2026-2027),2025 级是高二。
|
||||
// 毕业年份 = 入学年份 + 3,所以这一届 2026 级将在 2029 年归档。
|
||||
const classSeeds = [
|
||||
{ name: '高一(1)班', grade: '高一', teacherId: teacher1.id, code: 'G101' },
|
||||
{ name: '高一(2)班', grade: '高一', teacherId: teacher2.id, code: 'G102' },
|
||||
{ name: '高二(3)班', grade: '高二', teacherId: teacher1.id, code: 'G203' },
|
||||
{ enrollmentYear: 2026, classIndex: 1, grade: '高一', teacherId: teacher1.id, code: 'G101' },
|
||||
{ enrollmentYear: 2026, classIndex: 2, grade: '高一', teacherId: teacher2.id, code: 'G102' },
|
||||
{ enrollmentYear: 2025, classIndex: 3, grade: '高二', teacherId: teacher1.id, code: 'G203' },
|
||||
];
|
||||
|
||||
const classes = [];
|
||||
for (const [index, seed] of classSeeds.entries()) {
|
||||
const created = await prisma.class.create({
|
||||
data: {
|
||||
name: seed.name,
|
||||
name: formatClassName(seed.enrollmentYear, seed.classIndex),
|
||||
grade: seed.grade,
|
||||
enrollmentYear: seed.enrollmentYear,
|
||||
classIndex: seed.classIndex,
|
||||
teacherId: seed.teacherId,
|
||||
code: seed.code,
|
||||
passwordHash: classPasswordHash,
|
||||
@@ -126,37 +143,41 @@ async function main(): Promise<void> {
|
||||
classes.push({ ...created, room: CLASS_ROOMS[index] ?? '教学楼 A101' });
|
||||
}
|
||||
|
||||
// 高二(3)班:李老师作为协作教师
|
||||
await prisma.classTeacher.create({
|
||||
data: { classId: classes[2]!.id, teacherId: teacher2.id },
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 学生
|
||||
// ---------------------------------------------------------------- 学生(只是名单,不是账号)
|
||||
const students = [];
|
||||
for (let index = 0; index < STUDENT_NAMES.length; index += 1) {
|
||||
const classIndex = Math.floor(index / 5);
|
||||
const targetClass = classes[classIndex] ?? classes[0]!;
|
||||
const student = await prisma.user.create({
|
||||
const classSlot = Math.floor(index / 5);
|
||||
const targetClass = classes[classSlot] ?? classes[0]!;
|
||||
// 学号:入学年份 + 两位序号(与班级称呼同一套「届别」口径)
|
||||
const student = await prisma.student.create({
|
||||
data: {
|
||||
username: `student${String(index + 1).padStart(2, '0')}`,
|
||||
studentNo: `${targetClass.enrollmentYear}${String(index + 1).padStart(2, '0')}`,
|
||||
name: STUDENT_NAMES[index] ?? `学生${index + 1}`,
|
||||
role: 'STUDENT',
|
||||
classId: targetClass.id,
|
||||
// 空串占位:User.passwordHash 必填,但学生登录的 403 判定在密码校验之前,永远用不到
|
||||
passwordHash: '',
|
||||
gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
|
||||
guardianPhone: `138${String(10000000 + index * 137).slice(0, 8)}`,
|
||||
},
|
||||
});
|
||||
students.push(student);
|
||||
await prisma.enrollment.create({ data: { userId: student.id, classId: targetClass.id } });
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 课程
|
||||
// ---------------------------------------------------------------- 课程(= 班级 + 科目 + 教师的任课关系)
|
||||
//
|
||||
// 每门课都要挂一位**任课老师**:作业与成绩能不能被某位老师改动,查的就是 `Course.teacherId`。
|
||||
// 演示数据刻意让「语文」由另一位老师任教 —— 这样班主任在语文上就没有编辑权,
|
||||
// 正好覆盖需求「班主任不自动拥有所有科目作业成绩编辑权」这条规则。
|
||||
const OTHER_TEACHER_SUBJECT = '语文';
|
||||
const coursesByClass = new Map<string, { id: string; name: string }[]>();
|
||||
for (const targetClass of classes) {
|
||||
const otherTeacher = targetClass.teacherId === teacher1.id ? teacher2.id : teacher1.id;
|
||||
const created = [];
|
||||
for (const courseName of COURSE_NAMES) {
|
||||
const course = await prisma.course.create({
|
||||
data: { name: courseName, classId: targetClass.id, teacherId: targetClass.teacherId },
|
||||
data: {
|
||||
name: courseName,
|
||||
classId: targetClass.id,
|
||||
teacherId: courseName === OTHER_TEACHER_SUBJECT ? otherTeacher : targetClass.teacherId,
|
||||
},
|
||||
});
|
||||
created.push({ id: course.id, name: course.name });
|
||||
}
|
||||
@@ -280,7 +301,7 @@ async function main(): Promise<void> {
|
||||
const completed = (index + homework.title.length) % 3 !== 0;
|
||||
if (!completed && index % 2 === 1) continue;
|
||||
await prisma.homeworkStatus.create({
|
||||
data: { homeworkId: homework.id, userId: student.id, completed },
|
||||
data: { homeworkId: homework.id, studentId: student.id, completed },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -331,7 +352,7 @@ async function main(): Promise<void> {
|
||||
const classmates = (studentsByClass.get(targetClass.id) ?? []).slice(0, 3);
|
||||
for (const student of classmates) {
|
||||
await prisma.notificationRead.create({
|
||||
data: { notificationId: notification.id, userId: student.id },
|
||||
data: { notificationId: notification.id, studentId: student.id },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -339,23 +360,46 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 成绩
|
||||
//
|
||||
// 三种等级口径各演示一门,方便在界面上看出差别:
|
||||
// 数学(percent)—— 按得分率自动换算 A~E
|
||||
// 语文(custom) —— 自定义等级文本
|
||||
// 英语(letter) —— A / B / C / D
|
||||
const examNames = ['第一次月考', '期中考试'];
|
||||
const customLevels = ['优', '良', '合格'] as const;
|
||||
const letterLevels = ['A', 'B', 'C', 'D'] as const;
|
||||
const levelPlan = [
|
||||
{ type: 'percent' as const },
|
||||
{ type: 'custom' as const },
|
||||
{ type: 'letter' as const },
|
||||
];
|
||||
|
||||
for (const targetClass of classes) {
|
||||
const classCourses = coursesByClass.get(targetClass.id) ?? [];
|
||||
const classmates = studentsByClass.get(targetClass.id) ?? [];
|
||||
for (const [examIndex, examName] of examNames.entries()) {
|
||||
for (const course of classCourses.slice(0, 3)) {
|
||||
for (const [courseIndex, course] of classCourses.slice(0, 3).entries()) {
|
||||
const plan = levelPlan[courseIndex % levelPlan.length]!;
|
||||
for (const student of classmates) {
|
||||
const base = 60 + random() * 38;
|
||||
const score = Math.round(Math.min(100, Math.max(35, base - examIndex * 2)));
|
||||
const percent = gradePercent(score, 100);
|
||||
const level =
|
||||
plan.type === 'percent'
|
||||
? gradeLevel(percent)
|
||||
: plan.type === 'letter'
|
||||
? (letterLevels[Math.min(letterLevels.length - 1, Math.floor((100 - percent) / 12))] ?? 'D')
|
||||
: (customLevels[Math.min(customLevels.length - 1, Math.floor((100 - percent) / 15))] ?? '合格');
|
||||
await prisma.grade.create({
|
||||
data: {
|
||||
classId: targetClass.id,
|
||||
courseId: course.id,
|
||||
userId: student.id,
|
||||
studentId: student.id,
|
||||
examName,
|
||||
score,
|
||||
totalScore: 100,
|
||||
levelType: plan.type,
|
||||
level,
|
||||
publishedAt: new Date(now - (examIndex + 1) * 6 * day),
|
||||
},
|
||||
});
|
||||
@@ -364,24 +408,146 @@ async function main(): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 调班 / 转出历史
|
||||
//
|
||||
// 调班只改 `Student.classId`(学号不变,历史作业与成绩留在原班级),并留一条可查的痕迹。
|
||||
const movedStudent = students[7]!; // 赵依然:2026级2班 → 2026级1班(曾经)
|
||||
await prisma.student.update({
|
||||
where: { id: movedStudent.id },
|
||||
data: { classId: classes[0]!.id },
|
||||
});
|
||||
await prisma.studentClassTransfer.create({
|
||||
data: {
|
||||
studentId: movedStudent.id,
|
||||
studentNo: movedStudent.studentNo,
|
||||
studentName: movedStudent.name,
|
||||
fromClassId: classes[0]!.id,
|
||||
fromClassName: classes[0]!.name,
|
||||
toClassId: classes[1]!.id,
|
||||
toClassName: classes[1]!.name,
|
||||
operatorId: admin.id,
|
||||
operatorName: admin.name,
|
||||
mode: 'batch',
|
||||
note: '军训后按分班考试成绩重新均衡',
|
||||
createdAt: new Date(now - 30 * day),
|
||||
},
|
||||
});
|
||||
|
||||
// 转出:学籍离开本校(不是调班),班级归属保留作历史
|
||||
const transferredStudent = students[13]!; // 朱佳琪
|
||||
await prisma.student.update({
|
||||
where: { id: transferredStudent.id },
|
||||
data: {
|
||||
status: 'transferred',
|
||||
transferredAt: new Date(now - 12 * day),
|
||||
transferNote: '随家长工作调动转学至外地',
|
||||
},
|
||||
});
|
||||
await prisma.studentClassTransfer.create({
|
||||
data: {
|
||||
studentId: transferredStudent.id,
|
||||
studentNo: transferredStudent.studentNo,
|
||||
studentName: transferredStudent.name,
|
||||
fromClassId: transferredStudent.classId,
|
||||
fromClassName: classes[2]!.name,
|
||||
toClassId: null,
|
||||
toClassName: '转出本校',
|
||||
operatorId: admin.id,
|
||||
operatorName: admin.name,
|
||||
mode: 'transfer-out',
|
||||
note: '随家长工作调动转学至外地',
|
||||
createdAt: new Date(now - 12 * day),
|
||||
},
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------- 已毕业届别(归档页的演示数据)
|
||||
//
|
||||
// 归档 = 打标记 + 只读:班级与它的作业/通知原样留在库里,只是从常规列表里隐去。
|
||||
const graduatedClass = await prisma.class.create({
|
||||
data: {
|
||||
name: formatClassName(2023, 1),
|
||||
grade: '高三',
|
||||
enrollmentYear: 2023,
|
||||
classIndex: 1,
|
||||
teacherId: teacher1.id,
|
||||
code: 'G231',
|
||||
passwordHash: classPasswordHash,
|
||||
},
|
||||
});
|
||||
const archivedYear = await prisma.archivedYear.create({
|
||||
data: {
|
||||
enrollmentYear: 2023,
|
||||
graduationYear: 2023 + 3,
|
||||
name: formatYearLabel(2023),
|
||||
note: '演示用:2023 级已于 2026 年 6 月毕业',
|
||||
operatorId: admin.id,
|
||||
operatorName: admin.name,
|
||||
archivedAt: new Date(now - 100 * day),
|
||||
},
|
||||
});
|
||||
await prisma.class.update({
|
||||
where: { id: graduatedClass.id },
|
||||
data: { archivedYearId: archivedYear.id, archivedAt: new Date(now - 100 * day) },
|
||||
});
|
||||
|
||||
const graduateNames = ['毕业演示甲', '毕业演示乙', '毕业演示丙'];
|
||||
for (const [index, name] of graduateNames.entries()) {
|
||||
await prisma.student.create({
|
||||
data: {
|
||||
studentNo: `2023${String(index + 1).padStart(2, '0')}`,
|
||||
name,
|
||||
classId: graduatedClass.id,
|
||||
gender: index % 2 === 0 ? 'MALE' : 'FEMALE',
|
||||
status: 'graduated',
|
||||
archivedYearId: archivedYear.id,
|
||||
archivedAt: new Date(now - 100 * day),
|
||||
},
|
||||
});
|
||||
}
|
||||
// 这一届也要有作业与通知,「记录每个年度毕业班级的消息和作业」才有东西可看
|
||||
await prisma.homework.create({
|
||||
data: {
|
||||
classId: graduatedClass.id,
|
||||
title: '高考前最后一轮复习',
|
||||
content: '按考点清单过一遍错题本,重点看函数与数列。',
|
||||
assignDate: dayKeyLocal(new Date(now - 120 * day)),
|
||||
createdBy: teacher1.id,
|
||||
},
|
||||
});
|
||||
await prisma.notification.create({
|
||||
data: {
|
||||
classId: graduatedClass.id,
|
||||
title: '毕业典礼安排',
|
||||
content: '6 月 20 日上午 9 点在礼堂举行毕业典礼,请全体同学准时参加。',
|
||||
priority: 'HIGH',
|
||||
createdBy: teacher1.id,
|
||||
createdAt: new Date(now - 110 * day),
|
||||
},
|
||||
});
|
||||
|
||||
const counts = {
|
||||
用户: await prisma.user.count(),
|
||||
班级: await prisma.class.count(),
|
||||
账号: await prisma.user.count(),
|
||||
在读班级: await prisma.class.count({ where: { archivedYearId: null } }),
|
||||
学生: await prisma.student.count({ where: { status: 'active' } }),
|
||||
课程: await prisma.course.count(),
|
||||
课表: await prisma.schedule.count(),
|
||||
作业: await prisma.homework.count(),
|
||||
通知: await prisma.notification.count(),
|
||||
成绩: await prisma.grade.count(),
|
||||
调班历史: await prisma.studentClassTransfer.count(),
|
||||
归档届别: await prisma.archivedYear.count(),
|
||||
};
|
||||
|
||||
logger.info('种子数据写入完成', counts);
|
||||
|
||||
console.log('\n================ 演示账号 ================');
|
||||
console.log(`管理员 admin / admin123 (${admin.name})`);
|
||||
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · 高一(1)班、高二(3)班)`);
|
||||
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · 高一(2)班、高二(3)班协作)`);
|
||||
console.log('学生端(班级账号):G101 / G102 / G203,密码 123456');
|
||||
console.log('(学生只有名单、没有个人账号,成绩/未交/叫人按名单记录)');
|
||||
console.log(`管理员 admin / admin123 (${admin.name} · 可执行毕业归档与调班)`);
|
||||
console.log(`教师 teacher1 / teacher123 (${teacher1.name} · ${classes[0]!.name} 班主任,教数学)`);
|
||||
console.log(`教师 teacher2 / teacher123 (${teacher2.name} · ${classes[1]!.name} 班主任,教 ${classes[0]!.name} 语文)`);
|
||||
console.log('ClassHelper 班级端(班级码):G101 / G102 / G203,密码 123456');
|
||||
console.log('(学生只有名单、没有账号:成绩/未交/叫人按学号记录)');
|
||||
console.log(`已在读班级:${classes.map((item) => item.name).join(' / ')}`);
|
||||
console.log(`已归档届别:${formatYearLabel(2023)}(${graduatedClass.name},${graduateNames.length} 名毕业生)`);
|
||||
console.log('==========================================\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -38,8 +38,53 @@ const COLUMNS = [
|
||||
sql: `ALTER TABLE "Class" ADD COLUMN "termWeeks" INTEGER NOT NULL DEFAULT 20`,
|
||||
note: '班级教学周数(班主任可调,默认 20)',
|
||||
},
|
||||
{
|
||||
table: 'Class',
|
||||
column: 'studentGradeQueryEnabled',
|
||||
sql: `ALTER TABLE "Class" ADD COLUMN "studentGradeQueryEnabled" BOOLEAN NOT NULL DEFAULT true`,
|
||||
note: 'ClassHelper 班级端按学号查成绩的开关(默认开启)',
|
||||
},
|
||||
{
|
||||
table: 'Grade',
|
||||
column: 'levelType',
|
||||
sql: `ALTER TABLE "Grade" ADD COLUMN "levelType" TEXT NOT NULL DEFAULT 'percent'`,
|
||||
note: '成绩等级口径:percent / letter / custom',
|
||||
},
|
||||
{
|
||||
table: 'Grade',
|
||||
column: 'level',
|
||||
sql: `ALTER TABLE "Grade" ADD COLUMN "level" TEXT NOT NULL DEFAULT ''`,
|
||||
note: '成绩等级文本(percent 下由得分率换算,可手改)',
|
||||
},
|
||||
{
|
||||
table: 'IntegrationDevice',
|
||||
column: 'lastHeartbeatAt',
|
||||
sql: `ALTER TABLE "IntegrationDevice" ADD COLUMN "lastHeartbeatAt" DATETIME`,
|
||||
note: 'ClassHelper 班级端最后一次心跳(在线判定看它)',
|
||||
},
|
||||
{
|
||||
table: 'User',
|
||||
column: 'phone',
|
||||
sql: `ALTER TABLE "User" ADD COLUMN "phone" TEXT NOT NULL DEFAULT ''`,
|
||||
note: '教师手机号',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* ⚠ 本脚本只能补**新增的列**,补不了结构变化。
|
||||
*
|
||||
* 2026-10-06 那版「学生从账号变成名单记录」(`prisma/migrations/20261006120000_add_student_table`)
|
||||
* 是一次真正的表重构:新建 `Student` / `StudentClassTransfer`、把 `Grade`/`HomeworkStatus`/
|
||||
* `NotificationRead` 的外键从 `userId` 换到 `studentId`、删掉 `Enrollment` 与 `ClassTeacher`。
|
||||
* 这些**必须走正式的迁移链路**(`prisma migrate deploy`,安装程序升级时自动执行):
|
||||
*
|
||||
* node apply-column-migrations.cjs "<安装目录>/server/dist/lib/db.js" # 只补列
|
||||
* pnpm --filter @classhelper/server db:deploy # 结构变化走这条
|
||||
*
|
||||
* 只跑本脚本、不跑迁移的库会停在"列有了但表没换"的中间态 —— 下面的列已经能把
|
||||
* 界面撑起来不报错,但学生数据仍留在 `User` 里,属于不可用状态。
|
||||
*/
|
||||
|
||||
(async () => {
|
||||
let prisma;
|
||||
try {
|
||||
|
||||
@@ -444,11 +444,34 @@ async function main() {
|
||||
});
|
||||
record('非法显示位置被拒(422)', badChannel.status === 422, `status=${badChannel.status}`);
|
||||
|
||||
// ---------------------------------------------------------------- 5. 课表镜像(默认关闭 → 打开 → 拉取)
|
||||
// ---------------------------------------------------------------- 5. 课表镜像
|
||||
//
|
||||
// 同步方向在 2026-10-06 反转:**服务端 → 教室自动下发**成为主方向(默认开启),
|
||||
// 「把教室课表传上来」改成 Web 端的一次性人工动作。因此这里先验证"默认就下发",
|
||||
// 再验证"关掉之后确实不下发"。
|
||||
const mirrorDefault = await api('/integrations/classisland/class-plan', { deviceToken });
|
||||
record(
|
||||
'课表默认由服务端自动下发(新方向:服务端 → 教室)',
|
||||
mirrorDefault.status === 200 && mirrorDefault.payload?.data?.classPlan?.entries?.length >= 0,
|
||||
`status=${mirrorDefault.status} data=${mirrorDefault.payload?.data === null ? 'null' : 'object'}`,
|
||||
);
|
||||
|
||||
const disabled = await api(`/integrations/devices/${deviceId}`, {
|
||||
method: 'PATCH',
|
||||
token: teacherToken,
|
||||
body: { mirrorScheduleToClassIsland: false },
|
||||
});
|
||||
record(
|
||||
'关闭「镜像课表到 ClassIsland」',
|
||||
disabled.status === 200 && disabled.payload?.data?.mirrorScheduleToClassIsland === false,
|
||||
`status=${disabled.status}`,
|
||||
);
|
||||
|
||||
const mirrorOff = await api('/integrations/classisland/class-plan', { deviceToken });
|
||||
record(
|
||||
'镜像开关关闭时不下发课表',
|
||||
mirrorOff.status === 200 && mirrorOff.payload?.data === null,
|
||||
'关掉镜像后不再下发课表',
|
||||
mirrorOff.status === 200 &&
|
||||
(mirrorOff.payload?.data === null || mirrorOff.payload?.data === undefined),
|
||||
`data=${mirrorOff.payload?.data === null ? 'null' : 'object'}`,
|
||||
);
|
||||
|
||||
@@ -458,7 +481,7 @@ async function main() {
|
||||
body: { mirrorScheduleToClassIsland: true },
|
||||
});
|
||||
record(
|
||||
'打开「镜像课表到 ClassIsland」',
|
||||
'重新打开「镜像课表到 ClassIsland」',
|
||||
enabled.status === 200 && enabled.payload?.data?.mirrorScheduleToClassIsland === true,
|
||||
`status=${enabled.status}`,
|
||||
);
|
||||
@@ -562,7 +585,7 @@ async function main() {
|
||||
|
||||
// 第二次上报:只报第二节 → 第一节应当被清理。
|
||||
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
|
||||
// 学生端与 Web 端会一直显示一节实际上已经不上的课。
|
||||
// ClassHelper 班级端与 Web 端会一直显示一节实际上已经不上的课。
|
||||
const pruneReport = await api('/integrations/classisland/report', {
|
||||
method: 'POST',
|
||||
deviceToken,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 端到端联调验证(对应验收标准):
|
||||
* 1. 教师发布通知 -> 学生端 Socket.IO 5 秒内收到
|
||||
* 2. 教师发布作业 -> 学生端实时收到,并可标记完成
|
||||
* 3. 教师录入成绩 -> 学生端实时收到,且 /grades/my 可查
|
||||
* 1. 教师发布通知 -> ClassHelper 班级端 Socket.IO 5 秒内收到
|
||||
* 2. 教师发布作业 -> ClassHelper 班级端实时收到,并可标记完成
|
||||
* 3. 教师录入成绩 -> ClassHelper 班级端实时收到,且 /grades/my 可查
|
||||
* 4. 权限隔离:学生无法访问其他班级数据、无法调用教师接口
|
||||
* 5. 课表按周查询可用
|
||||
*
|
||||
@@ -52,7 +52,7 @@ function waitForEvent(socket, event, timeoutMs = TIME_LIMIT_MS) {
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`\n=== 班级小助手 端到端验证(${BASE_URL})===\n`);
|
||||
console.log(`\n=== ClassHelper 端到端验证(${BASE_URL})===\n`);
|
||||
|
||||
// ---------------------------------------------------------------- 0. 健康检查
|
||||
const health = await api('/health');
|
||||
@@ -97,12 +97,12 @@ async function main() {
|
||||
record('管理员登录 admin', Boolean(adminToken), `status=${adminLogin.status}`);
|
||||
if (!adminToken) process.exit(1);
|
||||
|
||||
// 学生端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口)
|
||||
// ClassHelper 班级端主体 = 班级:用「班级码 + 班级密码」登录(个人学生账号已停用登录入口)
|
||||
const classesForLogin = await api('/classes', { token: teacherToken });
|
||||
const loginClass = classesForLogin.payload?.data?.[0];
|
||||
if (!loginClass?.code) {
|
||||
record(
|
||||
'学生端班级登录(班级码 + 班级密码 → classSession)',
|
||||
'ClassHelper 班级端班级登录(班级码 + 班级密码 → classSession)',
|
||||
false,
|
||||
'没有可用班级码,请先执行 pnpm db:seed',
|
||||
);
|
||||
@@ -127,15 +127,19 @@ async function main() {
|
||||
const studentToken = studentLogin.payload?.data?.token;
|
||||
const studentUser = studentLogin.payload?.data?.user;
|
||||
record(
|
||||
'学生端班级登录(班级码 + 班级密码 → classSession)',
|
||||
'ClassHelper 班级端班级登录(班级码 + 班级密码 → classSession)',
|
||||
Boolean(studentToken) && studentUser?.classSession === true,
|
||||
`code=${loginClass.code} status=${studentLogin.status} classId=${studentUser?.classId ?? studentUser?.id}`,
|
||||
);
|
||||
// 学生不是账号:`User` 表里根本没有学生行,所以拿学号当账号登录只可能是"账号不存在"
|
||||
const studentAsAccount = await api('/auth/login', {
|
||||
method: 'POST',
|
||||
body: { username: '202601', password: '123456' },
|
||||
});
|
||||
record(
|
||||
'个人学生账号登录已停用(403)',
|
||||
(await api('/auth/login', { method: 'POST', body: { username: 'student01', password: 'student123' } }))
|
||||
.status === 403,
|
||||
'student01 → 403',
|
||||
'学生没有账号:拿学号当账号登录被拒(401)',
|
||||
studentAsAccount.status === 401,
|
||||
`status=${studentAsAccount.status}`,
|
||||
);
|
||||
|
||||
const wrongPassword = await api('/auth/login', {
|
||||
@@ -149,7 +153,7 @@ async function main() {
|
||||
const classes = myClasses.payload?.data ?? [];
|
||||
record('教师获取班级列表', classes.length >= 2, `共 ${classes.length} 个班级`);
|
||||
|
||||
const ownClass = classes.find((item) => item.name?.includes('高一(1)')) ?? classes[0];
|
||||
const ownClass = classes.find((item) => item.name?.includes('2026级1')) ?? classes[0];
|
||||
const classId = ownClass?.id;
|
||||
if (!classId) {
|
||||
record('教师至少管理一个班级', false, '数据为空,请先执行 pnpm db:seed');
|
||||
@@ -169,14 +173,17 @@ async function main() {
|
||||
const teacher2Classes = (await api('/classes', { token: teacher2Token })).payload?.data ?? [];
|
||||
/** 本次验证临时创建的班级(收尾统一删除,不污染演示数据) */
|
||||
const probeClassIds = [];
|
||||
/** 权限矩阵用例里新建的作业(收尾统一删除) */
|
||||
const tempHomeworkIds = [];
|
||||
let foreignClass = teacher2Classes.find((item) => !teacher1ClassIds.has(item.id)) ?? null;
|
||||
if (!foreignClass) {
|
||||
const created = await api('/classes', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: {
|
||||
name: `权限校验班 ${Date.now()}`,
|
||||
grade: '高一',
|
||||
enrollmentYear: new Date().getFullYear(),
|
||||
classIndex: 93,
|
||||
code: `PV${Date.now().toString().slice(-6)}`,
|
||||
...(teacher2Id ? { teacherId: teacher2Id } : {}),
|
||||
},
|
||||
@@ -194,6 +201,8 @@ async function main() {
|
||||
const classmates = studentList.payload?.data ?? [];
|
||||
// 班级会话下没有"当前学生",目标学生固定取班里第一位(用于成绩/叫人等需要具体学生 id 的用例)
|
||||
const targetStudent = classmates[0];
|
||||
/** 同班第二位学生的学号(导入用例里要造「两条不同的学号」) */
|
||||
const classmateTwoNo = classmates[1]?.studentNo ?? '202602';
|
||||
record('教师获取学生名单', classmates.length > 0, `共 ${classmates.length} 人`);
|
||||
|
||||
const courses = await api(`/courses?classId=${classId}`, { token: teacherToken });
|
||||
@@ -420,7 +429,7 @@ async function main() {
|
||||
});
|
||||
});
|
||||
record(
|
||||
'学生端 Socket.IO 连接与鉴权',
|
||||
'ClassHelper 班级端 Socket.IO 连接与鉴权',
|
||||
Boolean(connected && !connected.error),
|
||||
connected?.error ?? `rooms=${connected?.rooms?.length ?? 0}`,
|
||||
);
|
||||
@@ -441,9 +450,9 @@ async function main() {
|
||||
|
||||
try {
|
||||
const { payload, elapsed } = await notificationWait;
|
||||
record('学生端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
|
||||
record('ClassHelper 班级端 5 秒内收到 notification:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
|
||||
} catch (error) {
|
||||
record('学生端 5 秒内收到 notification:new', false, error.message);
|
||||
record('ClassHelper 班级端 5 秒内收到 notification:new', false, error.message);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 5. 作业实时推送 + 标记完成
|
||||
@@ -499,9 +508,9 @@ async function main() {
|
||||
|
||||
try {
|
||||
const { payload, elapsed } = await homeworkWait;
|
||||
record('学生端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
|
||||
record('ClassHelper 班级端 5 秒内收到 homework:new', elapsed < TIME_LIMIT_MS, `${elapsed}ms · ${payload?.title}`);
|
||||
} catch (error) {
|
||||
record('学生端 5 秒内收到 homework:new', false, error.message);
|
||||
record('ClassHelper 班级端 5 秒内收到 homework:new', false, error.message);
|
||||
}
|
||||
|
||||
const statusUpdate = await api(`/homeworks/${homeworkId}/status`, {
|
||||
@@ -517,7 +526,7 @@ async function main() {
|
||||
|
||||
/* ---------------------------------------------------------------- 5.1 未交名单 */
|
||||
const submissions = await api(`/homeworks/${homeworkId}/submissions`, { token: teacherToken });
|
||||
const studentIds = (submissions.payload?.data?.students ?? []).map((item) => item.userId);
|
||||
const studentIds = (submissions.payload?.data?.students ?? []).map((item) => item.studentId);
|
||||
record(
|
||||
'教师可读取作业提交名单(全班学生 + 完成状态)',
|
||||
submissions.status === 200 &&
|
||||
@@ -531,13 +540,13 @@ async function main() {
|
||||
const savedSubmissions = await api(`/homeworks/${homeworkId}/submissions`, {
|
||||
method: 'PATCH',
|
||||
token: teacherToken,
|
||||
body: { notSubmittedUserIds: [firstStudentId] },
|
||||
body: { notSubmittedStudentIds: [firstStudentId] },
|
||||
});
|
||||
record(
|
||||
'保存未交名单:勾选者标记未交、其余学生自动标记已交',
|
||||
savedSubmissions.status === 200 &&
|
||||
(savedSubmissions.payload?.data?.notSubmitted ?? []).length === 1 &&
|
||||
savedSubmissions.payload?.data?.notSubmitted?.[0]?.userId === firstStudentId &&
|
||||
savedSubmissions.payload?.data?.notSubmitted?.[0]?.studentId === firstStudentId &&
|
||||
savedSubmissions.payload?.data?.completedCount === studentIds.length - 1,
|
||||
`status=${savedSubmissions.status} 未交=${(savedSubmissions.payload?.data?.notSubmitted ?? []).length} ` +
|
||||
`已完成=${savedSubmissions.payload?.data?.completedCount ?? '-'}/${savedSubmissions.payload?.data?.total ?? '-'}`,
|
||||
@@ -584,7 +593,7 @@ async function main() {
|
||||
body: {
|
||||
classId,
|
||||
courseId,
|
||||
userId: targetStudent?.id,
|
||||
studentId: targetStudent?.id,
|
||||
examName: `联调验证考试 ${new Date().toISOString().slice(0, 10)}`,
|
||||
score: 88,
|
||||
totalScore: 100,
|
||||
@@ -596,16 +605,16 @@ async function main() {
|
||||
try {
|
||||
const { payload, elapsed } = await gradeWait;
|
||||
record(
|
||||
'学生端 5 秒内收到 grade:updated',
|
||||
'ClassHelper 班级端 5 秒内收到 grade:updated',
|
||||
elapsed < TIME_LIMIT_MS,
|
||||
`${elapsed}ms · ${payload?.score}/${payload?.totalScore}`,
|
||||
);
|
||||
} catch (error) {
|
||||
record('学生端 5 秒内收到 grade:updated', false, error.message);
|
||||
record('ClassHelper 班级端 5 秒内收到 grade:updated', false, error.message);
|
||||
}
|
||||
} else {
|
||||
gradeWait.catch(() => {});
|
||||
record('学生端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
|
||||
record('ClassHelper 班级端成绩推送(目标学生非验证账号,跳过等待)', true, '已改用 /grades/my 校验');
|
||||
}
|
||||
|
||||
const myGrades = await api('/grades/my', { token: studentToken });
|
||||
@@ -658,23 +667,42 @@ async function main() {
|
||||
record('删除课程 DELETE /courses/:id', removedCourse.status === 200, `status=${removedCourse.status}`);
|
||||
}
|
||||
|
||||
const assigned = await api(`/classes/${classId}/teachers`, {
|
||||
method: 'POST',
|
||||
// 任课关系 = 班级 + 科目 + 教师,唯一写入口是 PUT /classes/:id/subject-teachers。
|
||||
// 用一个演示数据里没用来录成绩的科目,验证完就还回班主任,避免影响后面的成绩权限用例。
|
||||
const probeSubject = '通用技术';
|
||||
const adminAssignSubject = await api(`/classes/${classId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { teacherId: teacher2Login.payload?.data?.user?.id },
|
||||
body: { assignments: [{ subjectName: probeSubject, teacherId: teacher2Login.payload?.data?.user?.id }] },
|
||||
});
|
||||
record('分配协作教师', assigned.status === 201, `status=${assigned.status}`);
|
||||
const unassigned = await api(`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`, {
|
||||
method: 'DELETE',
|
||||
record(
|
||||
'管理员设置某班某科的任课老师(PUT /subject-teachers)',
|
||||
adminAssignSubject.status === 200 &&
|
||||
(adminAssignSubject.payload?.data ?? []).some(
|
||||
(item) => item.subjectName === probeSubject &&
|
||||
item.teacherId === teacher2Login.payload?.data?.user?.id,
|
||||
),
|
||||
`status=${adminAssignSubject.status}`,
|
||||
);
|
||||
const restoreSubject = await api(`/classes/${classId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { assignments: [{ subjectName: probeSubject, teacherId: ownClass?.teacherId }] },
|
||||
});
|
||||
record('取消协作教师', unassigned.status === 200, `status=${unassigned.status}`);
|
||||
record(
|
||||
'任课老师可改回班主任(同一个人既是班主任又是科任,不重复建号)',
|
||||
restoreSubject.status === 200 &&
|
||||
(restoreSubject.payload?.data ?? []).some(
|
||||
(item) => item.subjectName === probeSubject && item.teacherId === ownClass?.teacherId,
|
||||
),
|
||||
`status=${restoreSubject.status}`,
|
||||
);
|
||||
|
||||
const tempUsername = `e2e_student_${Date.now()}`;
|
||||
const tempUsername = `E2E${Date.now().toString().slice(-8)}`;
|
||||
const addedStudent = await api(`/classes/${classId}/students`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { username: tempUsername, name: '联调学生' },
|
||||
body: { studentNo: tempUsername, name: '联调学生' },
|
||||
});
|
||||
const addedStudentId = addedStudent.payload?.data?.id;
|
||||
record('班级添加学生', addedStudent.status === 201, `status=${addedStudent.status}`);
|
||||
@@ -682,8 +710,10 @@ async function main() {
|
||||
if (addedStudentId) {
|
||||
const studentListAfter = await api(`/classes/${classId}/students`, { token: teacherToken });
|
||||
record(
|
||||
'学生名单包含新加入学生',
|
||||
(studentListAfter.payload?.data ?? []).some((item) => item.id === addedStudentId),
|
||||
'学生名单包含新加入学生(按学号)',
|
||||
(studentListAfter.payload?.data ?? []).some(
|
||||
(item) => item.id === addedStudentId && item.studentNo === tempUsername.toUpperCase(),
|
||||
),
|
||||
`共 ${studentListAfter.payload?.data?.length ?? 0} 人`,
|
||||
);
|
||||
|
||||
@@ -728,7 +758,7 @@ async function main() {
|
||||
courseId,
|
||||
examName: `联调批量考试 ${new Date().toISOString().slice(0, 10)}`,
|
||||
totalScore: 100,
|
||||
items: classmates.slice(0, 3).map((item, index) => ({ userId: item.id, score: 70 + index * 5 })),
|
||||
items: classmates.slice(0, 3).map((item, index) => ({ studentId: item.id, score: 70 + index * 5 })),
|
||||
},
|
||||
});
|
||||
record(
|
||||
@@ -739,9 +769,13 @@ async function main() {
|
||||
|
||||
const gradeStats = await api(`/grades/stats?classId=${classId}`, { token: teacherToken });
|
||||
record(
|
||||
// 等级分布不再固定 A~E 五档:等级制(A-D)与自定义等级(优/良/合格)都在用,
|
||||
// 因此断言"有分布且每个档位都有人",而不是数档位个数。
|
||||
'成绩统计 /grades/stats',
|
||||
gradeStats.status === 200 && (gradeStats.payload?.data?.distribution ?? []).length === 5,
|
||||
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}%`,
|
||||
gradeStats.status === 200 &&
|
||||
(gradeStats.payload?.data?.distribution ?? []).length >= 1 &&
|
||||
(gradeStats.payload?.data?.distribution ?? []).every((item) => typeof item.level === 'string'),
|
||||
`平均得分率 ${gradeStats.payload?.data?.averagePercent ?? 0}% 档位=${(gradeStats.payload?.data?.distribution ?? []).map((item) => item.level).join('/')}`,
|
||||
);
|
||||
|
||||
const unread = await api('/notifications/unread-count', { token: studentToken });
|
||||
@@ -931,7 +965,7 @@ async function main() {
|
||||
`status=${callByTeacher.status} title="${callTitle}"`,
|
||||
);
|
||||
record(
|
||||
'普通叫人(默认)不是紧急级别:priority=HIGH,学生端按普通通知排队',
|
||||
'普通叫人(默认)不是紧急级别:priority=HIGH,ClassHelper 班级端按普通通知排队',
|
||||
callByTeacher.payload?.data?.priority === 'HIGH',
|
||||
`priority=${callByTeacher.payload?.data?.priority ?? '-'}(期望 HIGH)`,
|
||||
);
|
||||
@@ -947,7 +981,7 @@ async function main() {
|
||||
},
|
||||
});
|
||||
record(
|
||||
'紧急叫人(urgent=true → priority=URGENT,学生端无视上课时段立即展开)',
|
||||
'紧急叫人(urgent=true → priority=URGENT,ClassHelper 班级端无视上课时段立即展开)',
|
||||
urgentCall.status === 201 && urgentCall.payload?.data?.priority === 'URGENT',
|
||||
`status=${urgentCall.status} priority=${urgentCall.payload?.data?.priority ?? '-'}`,
|
||||
);
|
||||
@@ -1013,27 +1047,28 @@ async function main() {
|
||||
{
|
||||
record('管理员登录(用于权限矩阵校验)', Boolean(adminToken), '已在上文登录');
|
||||
|
||||
// 科任老师:teacher2 是某班的协作(科任)老师。
|
||||
// 同样不依赖演示数据:推断出来的班级不可用(被人工改过权限 / 与外部班级同一个)时现建一个:
|
||||
// teacher1 当班主任 + teacher2 协作,收尾删除。
|
||||
// 科任老师:teacher2 在某班**有任课关系**(Course.teacherId === 他)。
|
||||
// 同样不依赖演示数据:推断出来的班级不可用(被人工改过 / 与外班同一个)时现建一个,
|
||||
// 用 teacher1 当班主任 + 给 teacher2 配一门课,收尾删除。
|
||||
let subjectClass = teacher2Classes.find((item) => item.teacherId !== teacher2Id) ?? null;
|
||||
if (!subjectClass || subjectClass.id === foreignClass?.id) {
|
||||
const created = await api('/classes', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: {
|
||||
name: `科任校验班 ${Date.now()}`,
|
||||
grade: '高一',
|
||||
enrollmentYear: new Date().getFullYear(),
|
||||
classIndex: 90,
|
||||
code: `SJ${Date.now().toString().slice(-6)}`,
|
||||
teacherId: teacher1Id,
|
||||
},
|
||||
});
|
||||
const createdId = created.payload?.data?.id;
|
||||
if (createdId) {
|
||||
await api(`/classes/${createdId}/teachers`, {
|
||||
method: 'POST',
|
||||
await api(`/classes/${createdId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { teacherId: teacher2Id },
|
||||
body: { assignments: [{ subjectName: '数学', teacherId: teacher2Id }] },
|
||||
});
|
||||
subjectClass = { id: createdId, name: created.payload?.data?.name ?? '科任校验班' };
|
||||
probeClassIds.push(createdId);
|
||||
@@ -1050,53 +1085,52 @@ async function main() {
|
||||
const headCreate = await api('/classes', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { name: '班主任越权班', grade: '高一' },
|
||||
body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 91 },
|
||||
});
|
||||
record('班主任创建班级被拒绝(403)', headCreate.status === 403, `status=${headCreate.status}`);
|
||||
|
||||
const subjectCreate = await api('/classes', {
|
||||
method: 'POST',
|
||||
token: teacher2Token,
|
||||
body: { name: '科任越权班', grade: '高一' },
|
||||
body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 92 },
|
||||
});
|
||||
record('科任老师创建班级被拒绝(403)', subjectCreate.status === 403, `status=${subjectCreate.status}`);
|
||||
|
||||
const headDelete = await api(`/classes/${classId}`, { method: 'DELETE', token: teacherToken });
|
||||
record('班主任删除班级被拒绝(403)', headDelete.status === 403, `status=${headDelete.status}`);
|
||||
|
||||
const headAssign = await api(`/classes/${classId}/teachers`, {
|
||||
method: 'POST',
|
||||
const headAssign = await api(`/classes/${classId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
token: teacherToken,
|
||||
body: { teacherId: teacher2Login.payload?.data?.user?.id },
|
||||
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
|
||||
});
|
||||
record('班主任分配科任老师被拒绝(403)', headAssign.status === 403, `status=${headAssign.status}`);
|
||||
record('班主任分配任课老师被拒绝(403)', headAssign.status === 403, `status=${headAssign.status}`);
|
||||
|
||||
const subjectAssign = await api(`/classes/${classId}/teachers`, {
|
||||
method: 'POST',
|
||||
const subjectAssign = await api(`/classes/${classId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
token: teacher2Token,
|
||||
body: { teacherId: teacher2Login.payload?.data?.user?.id },
|
||||
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
|
||||
});
|
||||
record('科任老师分配人员被拒绝(403)', subjectAssign.status === 403, `status=${subjectAssign.status}`);
|
||||
|
||||
// 管理员可以分配(用"先加后删"验证)
|
||||
const adminAssign = await api(`/classes/${classId}/teachers`, {
|
||||
method: 'POST',
|
||||
const adminAssign = await api(`/classes/${classId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { teacherId: teacher2Login.payload?.data?.user?.id },
|
||||
body: { assignments: [{ subjectName: '音乐', teacherId: teacher2Login.payload?.data?.user?.id }] },
|
||||
});
|
||||
record(
|
||||
'管理员分配科任老师成功(201/200)',
|
||||
[200, 201].includes(adminAssign.status),
|
||||
'管理员分配任课老师成功(200)',
|
||||
adminAssign.status === 200,
|
||||
`status=${adminAssign.status}`,
|
||||
);
|
||||
const adminUnassign = await api(
|
||||
`/classes/${classId}/teachers/${teacher2Login.payload?.data?.user?.id}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
token: adminToken,
|
||||
},
|
||||
);
|
||||
record('管理员取消科任老师成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
|
||||
// 解除 = 把该科还回班主任(Course.teacherId 是必填列,不能留空)
|
||||
const adminUnassign = await api(`/classes/${classId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { assignments: [{ subjectName: '音乐', teacherId: ownClass?.teacherId }] },
|
||||
});
|
||||
record('管理员解除任课关系成功(200)', adminUnassign.status === 200, `status=${adminUnassign.status}`);
|
||||
|
||||
// 课表:班主任可管理本班,科任老师不可
|
||||
if (subjectClassId) {
|
||||
@@ -1120,16 +1154,59 @@ async function main() {
|
||||
);
|
||||
}
|
||||
|
||||
// 新规则:**作业按科目授权**。
|
||||
// 科任老师只能布置自己任教科目(Course.teacherId)的作业;
|
||||
// 不指定科目的作业只有班主任与管理员能发 —— 两条都要验到。
|
||||
const subjectCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
|
||||
.payload?.data?.find((item) => item.teacherId === teacher2Id);
|
||||
const subjectHomework = await api('/homeworks', {
|
||||
method: 'POST',
|
||||
token: subjectClassId ? teacher2Token : teacherToken,
|
||||
body: {
|
||||
classId: subjectClassId,
|
||||
courseId: subjectCourse?.id,
|
||||
title: '科任老师布置的作业',
|
||||
content: '权限矩阵校验用',
|
||||
},
|
||||
});
|
||||
record('科任老师可布置作业(201)', subjectHomework.status === 201, `status=${subjectHomework.status}`);
|
||||
record(
|
||||
'科任老师可布置自己任教科目的作业(201)',
|
||||
subjectHomework.status === 201,
|
||||
`status=${subjectHomework.status} 科目=${subjectCourse?.name ?? '-'}`,
|
||||
);
|
||||
if (subjectHomework.payload?.data?.id) {
|
||||
tempHomeworkIds.push(subjectHomework.payload.data.id);
|
||||
}
|
||||
const subjectHomeworkNoCourse = await api('/homeworks', {
|
||||
method: 'POST',
|
||||
token: subjectClassId ? teacher2Token : teacherToken,
|
||||
body: { classId: subjectClassId, title: '科任老师的不指定科目作业', content: '应被拒绝' },
|
||||
});
|
||||
record(
|
||||
'科任老师布置不指定科目的作业被拒绝(403)',
|
||||
subjectHomeworkNoCourse.status === 403,
|
||||
`status=${subjectHomeworkNoCourse.status}`,
|
||||
);
|
||||
// 科任老师改**别人任教科目**的作业同样被拒
|
||||
if (subjectCourse) {
|
||||
const otherCourse = (await api(`/courses?classId=${subjectClassId}`, { token: adminToken }))
|
||||
.payload?.data?.find((item) => item.teacherId !== teacher2Id);
|
||||
const foreignHomework = await api('/homeworks', {
|
||||
method: 'POST',
|
||||
token: teacher2Token,
|
||||
body: {
|
||||
classId: subjectClassId,
|
||||
courseId: otherCourse?.id,
|
||||
title: '科任老师替别人发作业',
|
||||
content: '应被拒绝',
|
||||
},
|
||||
});
|
||||
record(
|
||||
'科任老师给非任教科目布置作业被拒绝(403)',
|
||||
foreignHomework.status === 403,
|
||||
`status=${foreignHomework.status} 科目=${otherCourse?.name ?? '-'}`,
|
||||
);
|
||||
}
|
||||
if (subjectHomework.payload?.data?.id) {
|
||||
await api(`/homeworks/${subjectHomework.payload.data.id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -1142,7 +1219,7 @@ async function main() {
|
||||
token: teacher2Token,
|
||||
body: {
|
||||
classId: classId,
|
||||
userId: targetStudent?.id,
|
||||
studentId: targetStudent?.id,
|
||||
examName: '越权成绩',
|
||||
score: 90,
|
||||
totalScore: 100,
|
||||
@@ -1156,7 +1233,7 @@ async function main() {
|
||||
token: teacherToken,
|
||||
body: {
|
||||
classId: classId,
|
||||
userId: targetStudent?.id,
|
||||
studentId: targetStudent?.id,
|
||||
examName: '班主任本班成绩',
|
||||
score: 90,
|
||||
totalScore: 100,
|
||||
@@ -1177,7 +1254,7 @@ async function main() {
|
||||
token: teacherToken,
|
||||
body: {
|
||||
classId: foreignClass.id,
|
||||
userId: targetStudent?.id,
|
||||
studentId: targetStudent?.id,
|
||||
examName: '班主任跨班成绩',
|
||||
score: 90,
|
||||
totalScore: 100,
|
||||
@@ -1195,7 +1272,7 @@ async function main() {
|
||||
token: adminToken,
|
||||
body: {
|
||||
classId: classId,
|
||||
userId: targetStudent?.id,
|
||||
studentId: targetStudent?.id,
|
||||
examName: '管理员录入成绩',
|
||||
score: 88,
|
||||
totalScore: 100,
|
||||
@@ -1438,10 +1515,10 @@ async function main() {
|
||||
|
||||
// 8.3 成绩表格导入(CSV 校验 + XLSX 新增/重复/更新 + 错误行提示)
|
||||
const gradesCsv = [
|
||||
'用户名,姓名,考试名称,分数,总分,课程',
|
||||
'student01,,期中导入考试,92,100,',
|
||||
'学号,姓名,考试名称,分数,总分,科目',
|
||||
`${targetStudent?.studentNo ?? '202601'},,期中导入考试,92,100,`,
|
||||
`,${targetStudent?.name ?? '张同学'},期中导入考试,不是数字,100,`,
|
||||
'unknown-user,,期中导入考试,88,100,',
|
||||
'UNKNOWN-USER,,期中导入考试,88,100,',
|
||||
].join(NL);
|
||||
const gradesCsvBase64 = Buffer.from(`\ufeff${gradesCsv}${NL}`, 'utf8').toString('base64');
|
||||
|
||||
@@ -1490,8 +1567,8 @@ async function main() {
|
||||
const XLSX = xlsxNamespace.default ?? xlsxNamespace;
|
||||
const xlsxSheet = XLSX.utils.aoa_to_sheet([
|
||||
['学号', '姓名', '考试', '得分', '满分', '科目'],
|
||||
['student02', '', '期中导入考试', 85, 100, ''],
|
||||
['student01', '', '期中导入考试', 78, 100, ''],
|
||||
[classmateTwoNo, '', '期中导入考试', 85, 100, ''],
|
||||
[targetStudent?.studentNo ?? '202601', '', '期中导入考试', 78, 100, ''],
|
||||
]);
|
||||
const xlsxBook = XLSX.utils.book_new();
|
||||
XLSX.utils.book_append_sheet(xlsxBook, xlsxSheet, '成绩');
|
||||
@@ -1506,13 +1583,13 @@ async function main() {
|
||||
'XLSX 成绩表预览(同义词自动映射 学号/得分)',
|
||||
xlsxPreview.status === 200 &&
|
||||
xlsxPreview.payload?.data?.totalRows === 2 &&
|
||||
xlsxPreview.payload?.data?.suggestedMapping?.username === '学号' &&
|
||||
xlsxPreview.payload?.data?.suggestedMapping?.studentNo === '学号' &&
|
||||
xlsxPreview.payload?.data?.suggestedMapping?.score === '得分',
|
||||
`status=${xlsxPreview.status} 映射=${JSON.stringify(xlsxPreview.payload?.data?.suggestedMapping ?? {})}`,
|
||||
);
|
||||
|
||||
const commitMapping = {
|
||||
username: '学号',
|
||||
studentNo: '学号',
|
||||
name: '姓名',
|
||||
examName: '考试',
|
||||
score: '得分',
|
||||
@@ -1573,12 +1650,12 @@ async function main() {
|
||||
fileName: 'grades.csv',
|
||||
contentBase64: gradesCsvBase64,
|
||||
mapping: {
|
||||
username: '用户名',
|
||||
studentNo: '学号',
|
||||
name: '姓名',
|
||||
examName: '考试名称',
|
||||
score: '分数',
|
||||
totalScore: '总分',
|
||||
courseName: '课程',
|
||||
courseName: '科目',
|
||||
},
|
||||
mode: 'append',
|
||||
},
|
||||
@@ -1608,9 +1685,9 @@ async function main() {
|
||||
);
|
||||
|
||||
// 8.4 学生名单导入(仅管理员)+ 数据清理
|
||||
const importedUsername = `imp${Date.now().toString(36).slice(-6)}`;
|
||||
// 学生导入模板已无"初始密码"列(学生没有账号属性)
|
||||
const studentsCsv = ['用户名,姓名', `${importedUsername},导入测试生`, 'bad user,非法用户名'].join(NL);
|
||||
const importedUsername = `IMP${Date.now().toString(36).slice(-6)}`.toUpperCase();
|
||||
// 学生导入模板已无"初始密码"列(学生没有账号属性);表头是「学号」
|
||||
const studentsCsv = ['学号,姓名', `${importedUsername},导入测试生`, 'bad@user,非法学号'].join(NL);
|
||||
const studentsCommit = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
@@ -1619,12 +1696,12 @@ async function main() {
|
||||
classId,
|
||||
fileName: 'students.csv',
|
||||
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
|
||||
mapping: { username: '用户名', name: '姓名' },
|
||||
mapping: { studentNo: '学号', name: '姓名' },
|
||||
mode: 'append',
|
||||
},
|
||||
});
|
||||
record(
|
||||
'学生名单导入(新增 1 / 非法用户名进错误行 1)',
|
||||
'学生名单导入(新增 1 / 非法学号进错误行 1)',
|
||||
studentsCommit.status === 200 &&
|
||||
studentsCommit.payload?.data?.inserted === 1 &&
|
||||
studentsCommit.payload?.data?.failed === 1,
|
||||
@@ -1653,12 +1730,12 @@ async function main() {
|
||||
fileName: 'grades.csv',
|
||||
contentBase64: gradesCsvBase64,
|
||||
mapping: {
|
||||
username: '用户名',
|
||||
studentNo: '学号',
|
||||
name: '姓名',
|
||||
examName: '考试名称',
|
||||
score: '分数',
|
||||
totalScore: '总分',
|
||||
courseName: '课程',
|
||||
courseName: '科目',
|
||||
},
|
||||
mode: 'upsert',
|
||||
},
|
||||
@@ -1672,8 +1749,8 @@ async function main() {
|
||||
`status=${teacherGradesCommit.status} 新增=${teacherGradesCommit.payload?.data?.inserted} 更新=${teacherGradesCommit.payload?.data?.updated} 失败=${teacherGradesCommit.payload?.data?.failed}`,
|
||||
);
|
||||
|
||||
// teacher2 是高二(3)班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景
|
||||
const subjectOnlyClass = classes.find((item) => item.name?.includes('高二(3)')) ?? null;
|
||||
// teacher2 是2025级3班的科任老师(班主任是 teacher1)——这才是真正的"科任"场景
|
||||
const subjectOnlyClass = classes.find((item) => item.name?.includes('2025级3')) ?? null;
|
||||
const subjectGradesImport = await api('/imports/table/commit', {
|
||||
method: 'POST',
|
||||
token: teacher2Token,
|
||||
@@ -1683,12 +1760,12 @@ async function main() {
|
||||
fileName: 'grades.csv',
|
||||
contentBase64: gradesCsvBase64,
|
||||
mapping: {
|
||||
username: '用户名',
|
||||
studentNo: '学号',
|
||||
name: '姓名',
|
||||
examName: '考试名称',
|
||||
score: '分数',
|
||||
totalScore: '总分',
|
||||
courseName: '课程',
|
||||
courseName: '科目',
|
||||
},
|
||||
mode: 'upsert',
|
||||
},
|
||||
@@ -1696,7 +1773,7 @@ async function main() {
|
||||
record(
|
||||
'科任老师导入本班成绩被拒绝(403,需求 7)',
|
||||
Boolean(subjectOnlyClass) && subjectGradesImport.status === 403,
|
||||
`班级=${subjectOnlyClass?.name ?? '未找到高二(3)班'} status=${subjectGradesImport.status}`,
|
||||
`班级=${subjectOnlyClass?.name ?? '未找到2025级3班'} status=${subjectGradesImport.status}`,
|
||||
);
|
||||
|
||||
if (foreignClass) {
|
||||
@@ -1709,12 +1786,12 @@ async function main() {
|
||||
fileName: 'grades.csv',
|
||||
contentBase64: gradesCsvBase64,
|
||||
mapping: {
|
||||
username: '用户名',
|
||||
studentNo: '学号',
|
||||
name: '姓名',
|
||||
examName: '考试名称',
|
||||
score: '分数',
|
||||
totalScore: '总分',
|
||||
courseName: '课程',
|
||||
courseName: '科目',
|
||||
},
|
||||
mode: 'upsert',
|
||||
},
|
||||
@@ -1749,7 +1826,7 @@ async function main() {
|
||||
classId,
|
||||
fileName: 'students.csv',
|
||||
contentBase64: Buffer.from(`\ufeff${studentsCsv}${NL}`, 'utf8').toString('base64'),
|
||||
mapping: { username: '用户名', name: '姓名' },
|
||||
mapping: { studentNo: '学号', name: '姓名' },
|
||||
mode: 'append',
|
||||
},
|
||||
});
|
||||
@@ -1774,7 +1851,7 @@ async function main() {
|
||||
record('导入的成绩可正常删除(清理验证数据)', gradesCleaned >= 2, `已删除 ${gradesCleaned} 条`);
|
||||
|
||||
const roster = await api(`/students?classId=${classId}`, { token: adminToken });
|
||||
const importedStudent = (roster.payload?.data ?? []).find((item) => item.username === importedUsername);
|
||||
const importedStudent = (roster.payload?.data ?? []).find((item) => item.studentNo === importedUsername);
|
||||
if (importedStudent) {
|
||||
const removedStudent = await api(`/students/${importedStudent.id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -1795,7 +1872,7 @@ async function main() {
|
||||
record('时间配置可删除(清理验证数据)', layoutDeleted.status === 200, `status=${layoutDeleted.status}`);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 9. 班级账号(学生端主体 = 班级)
|
||||
// ---------------------------------------------------------------- 9. 班级账号(ClassHelper 班级端主体 = 班级)
|
||||
// 设计:班级码 + 班级密码 → classSession 的 JWT;个人数据(作业完成 / 通知已读 / 成绩)
|
||||
// 由服务端按"全班"范围读写,个人学生不再是登录主体(个人账号接口仍向后兼容)。
|
||||
// 注意:本段会临时改动班级码,结束时必须恢复(否则演示实例会留下 E2E#### 这种随机码)。
|
||||
@@ -1887,7 +1964,7 @@ async function main() {
|
||||
const classGradesWrite = await api('/grades', {
|
||||
method: 'POST',
|
||||
token: classToken,
|
||||
body: { classId, userId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
|
||||
body: { classId, studentId: targetStudent?.id, examName: '班级账号越权成绩', score: 90, totalScore: 100 },
|
||||
});
|
||||
record(
|
||||
'班级账号录入成绩被拒绝(403)',
|
||||
@@ -2020,10 +2097,10 @@ async function main() {
|
||||
const classNameGrade = await api('/grades', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { classId, userId: targetStudent?.id, examName: '班级总览回归', score: 77, totalScore: 100 },
|
||||
body: { classId, studentId: targetStudent?.id, examName: '班级总览回归', score: 77, totalScore: 100 },
|
||||
});
|
||||
const classMyGrades = await api('/grades/my', { token: classToken });
|
||||
const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.userId));
|
||||
const gradeStudentIds = new Set((classMyGrades.payload?.data ?? []).map((item) => item.studentId));
|
||||
record(
|
||||
'班级账号查看成绩 = 全班总览',
|
||||
classMyGrades.status === 200 &&
|
||||
@@ -2180,7 +2257,7 @@ async function main() {
|
||||
|
||||
// 教师名单表格导入:教师预览 403;管理员预览 + 提交(教师与班级无关,classId 可省略)
|
||||
const teacherCsv =
|
||||
'\ufeff用户名,姓名,初始密码,角色\n' +
|
||||
'\ufeff工号,姓名,初始密码,角色\n' +
|
||||
`imp_t_${Date.now().toString().slice(-8)},冒烟导入教师,import123456,教师\n`;
|
||||
const teacherCsvBase64 = Buffer.from(teacherCsv, 'utf8').toString('base64');
|
||||
const importedTeacherUsername = teacherCsv.split('\n')[1]?.split(',')[0] ?? '';
|
||||
@@ -2202,7 +2279,7 @@ async function main() {
|
||||
kind: 'teachers',
|
||||
fileName: 'teachers.csv',
|
||||
contentBase64: teacherCsvBase64,
|
||||
mapping: { username: '用户名', name: '姓名', password: '初始密码', role: '角色' },
|
||||
mapping: { username: '工号', name: '姓名', password: '初始密码', role: '角色' },
|
||||
mode: 'upsert',
|
||||
},
|
||||
});
|
||||
@@ -2253,11 +2330,11 @@ async function main() {
|
||||
});
|
||||
const cascadeTeacherToken = cascadeTeacherLogin.payload?.data?.token;
|
||||
|
||||
// 1) 先让他成为该班科任(这样才有发布权限),发一条通知
|
||||
await api(`/classes/${classId}/teachers`, {
|
||||
method: 'POST',
|
||||
// 1) 先让他成为该班某科的任课老师(这样才有发布通知的权限),发一条通知
|
||||
await api(`/classes/${classId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { teacherId: cascadeTeacherId },
|
||||
body: { assignments: [{ subjectName: '美术', teacherId: cascadeTeacherId }] },
|
||||
});
|
||||
const cascadeNotification = await api('/notifications', {
|
||||
method: 'POST',
|
||||
@@ -2265,10 +2342,11 @@ async function main() {
|
||||
body: { classId, title: '级联删除回归', content: '用于验证删除教师不会连带删除已发布内容' },
|
||||
});
|
||||
const cascadeNotificationId = cascadeNotification.payload?.data?.id;
|
||||
// 2) 解除班级分配:此时他"不在任何班级、名下无课程",但发布过内容
|
||||
await api(`/classes/${classId}/teachers/${cascadeTeacherId}`, {
|
||||
method: 'DELETE',
|
||||
// 2) 解除任课关系(把该科还回班主任):此时他"不在任何班级、名下无课程",但发布过内容
|
||||
await api(`/classes/${classId}/subject-teachers`, {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { assignments: [{ subjectName: '美术', teacherId: ownClass?.teacherId }] },
|
||||
});
|
||||
// 3) 删除账号应当被拒(409),且那条通知必须还在
|
||||
const cascadeDelete = await api(`/teachers/${cascadeTeacherId}`, {
|
||||
@@ -2303,7 +2381,7 @@ async function main() {
|
||||
const teacherCreateClass = await api('/classes', {
|
||||
method: 'POST',
|
||||
token: teacherToken,
|
||||
body: { name: '越权班级', grade: '高一' },
|
||||
body: { grade: '高一', enrollmentYear: new Date().getFullYear(), classIndex: 94 },
|
||||
});
|
||||
record(
|
||||
'仅管理员可创建班级(教师 403)',
|
||||
@@ -2316,8 +2394,9 @@ async function main() {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: {
|
||||
name: `冒烟班级 ${Date.now()}`,
|
||||
grade: '高一',
|
||||
enrollmentYear: new Date().getFullYear(),
|
||||
classIndex: 95,
|
||||
code: tempClassCode,
|
||||
...(teacher2 ? { teacherId: teacher2.id } : {}),
|
||||
},
|
||||
@@ -2357,11 +2436,11 @@ async function main() {
|
||||
const dbStatus = await api('/database/status', { token: adminToken });
|
||||
const dbStatusData = dbStatus.payload?.data ?? {};
|
||||
record(
|
||||
'数据库状态(管理员):已连接且返回 14 张表行数',
|
||||
'数据库状态(管理员):已连接且返回 15 张表行数',
|
||||
dbStatus.status === 200 &&
|
||||
dbStatusData.connected === true &&
|
||||
Array.isArray(dbStatusData.tables) &&
|
||||
dbStatusData.tables.length === 14 &&
|
||||
dbStatusData.tables.length === 15 &&
|
||||
dbStatusData.tables.every((item) => item.count >= 0),
|
||||
`provider=${dbStatusData.provider} 延迟=${dbStatusData.latencyMs}ms 版本=${dbStatusData.version} 表=${
|
||||
(dbStatusData.tables ?? []).length
|
||||
@@ -2478,10 +2557,10 @@ async function main() {
|
||||
snapshotParsed = null;
|
||||
}
|
||||
record(
|
||||
'导出快照(JSON 格式正确、14 张表、用户数 > 0)',
|
||||
'导出快照(JSON 格式正确、15 张表、账号数 > 0)',
|
||||
dbExport.status === 200 &&
|
||||
snapshotParsed?.format === 'classhelper-snapshot' &&
|
||||
Object.keys(snapshotParsed?.data ?? {}).length === 14 &&
|
||||
Object.keys(snapshotParsed?.data ?? {}).length === 15 &&
|
||||
(snapshotParsed?.counts?.User ?? 0) > 0,
|
||||
`status=${dbExport.status} 格式=${snapshotParsed?.format ?? '-'} 用户=${snapshotParsed?.counts?.User ?? '-'}`,
|
||||
);
|
||||
@@ -2665,6 +2744,169 @@ async function main() {
|
||||
`status=${updateTeacherForced.status} 命中缓存=${updateTeacherForced.payload?.data?.checkedAt === updateData.checkedAt}`,
|
||||
);
|
||||
|
||||
|
||||
/* ---------------------------------------------------------------- 13. 学期周次(仅管理员可写) */
|
||||
// 需求:「第几周的周次数据可以由 admin 修改,并对所有 ClassIsland 下发修改」。
|
||||
// 线性推算遇到调休/补课会整体错位,所以支持逐周指定日期区间。
|
||||
|
||||
const termGlobal = await api('/term', { token: adminToken });
|
||||
record(
|
||||
'学期周次:读取全校默认口径',
|
||||
termGlobal.status === 200 &&
|
||||
typeof termGlobal.payload?.data?.currentWeek === 'number' &&
|
||||
Array.isArray(termGlobal.payload?.data?.weeks),
|
||||
`status=${termGlobal.status} 当前第 ${termGlobal.payload?.data?.currentWeek ?? '-'} 周 配置=${termGlobal.payload?.data?.configured}`,
|
||||
);
|
||||
|
||||
const termForTeacher = await api('/term', { token: teacherToken });
|
||||
record(
|
||||
'学期周次:教师可读(课表页要显示"第几周")',
|
||||
termForTeacher.status === 200,
|
||||
`status=${termForTeacher.status}`,
|
||||
);
|
||||
|
||||
// 用一段**覆盖今天**的区间,验证"当前周次按区间判定"而不是按开学日期线性推算
|
||||
const today = new Date();
|
||||
const pad = (value) => String(value).padStart(2, '0');
|
||||
const dayKey = (date) => `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
const rangeStart = new Date(today.getTime() - 2 * 86400000);
|
||||
const rangeEnd = new Date(today.getTime() + 2 * 86400000);
|
||||
const beforeTerm = (await api('/term', { token: adminToken })).payload?.data;
|
||||
|
||||
const termSaved = await api('/term', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: {
|
||||
weeks: [
|
||||
{ weekNumber: 7, startDate: dayKey(rangeStart), endDate: dayKey(rangeEnd), note: 'e2e 区间回归' },
|
||||
],
|
||||
},
|
||||
});
|
||||
record(
|
||||
'学期周次:管理员保存逐周区间(PUT /term)',
|
||||
termSaved.status === 200 &&
|
||||
termSaved.payload?.data?.configured === true &&
|
||||
termSaved.payload?.data?.currentWeek === 7,
|
||||
`status=${termSaved.status} 当前第 ${termSaved.payload?.data?.currentWeek ?? '-'} 周(区间覆盖今天应为第 7 周)`,
|
||||
);
|
||||
|
||||
const termOverlap = await api('/term', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: {
|
||||
weeks: [
|
||||
{ weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-10' },
|
||||
{ weekNumber: 2, startDate: '2026-09-05', endDate: '2026-09-14' },
|
||||
],
|
||||
},
|
||||
});
|
||||
record(
|
||||
'学期周次:区间重叠被拒绝(400,避免"当前第几周"出现歧义)',
|
||||
termOverlap.status === 400,
|
||||
`status=${termOverlap.status} message=${termOverlap.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
const termDenied = await api('/term', {
|
||||
method: 'PUT',
|
||||
token: teacherToken,
|
||||
body: { weeks: [{ weekNumber: 1, startDate: '2026-09-01', endDate: '2026-09-07' }] },
|
||||
});
|
||||
record(
|
||||
'学期周次:教师写入被拒绝(403,周次会让全校课表错位)',
|
||||
termDenied.status === 403,
|
||||
`status=${termDenied.status}`,
|
||||
);
|
||||
|
||||
const termAuto = await api('/term/auto', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { termStartDate: '2026-09-07', maxWeek: 3 },
|
||||
});
|
||||
record(
|
||||
'学期周次:按开学日期一键生成(POST /term/auto)',
|
||||
termAuto.status === 200 &&
|
||||
(termAuto.payload?.data?.weeks ?? []).length === 3 &&
|
||||
termAuto.payload?.data?.weeks?.[0]?.startDate === '2026-09-07' &&
|
||||
termAuto.payload?.data?.weeks?.[2]?.endDate === '2026-09-27',
|
||||
`status=${termAuto.status} 首周=${termAuto.payload?.data?.weeks?.[0]?.startDate} 末周=${termAuto.payload?.data?.weeks?.[2]?.endDate}`,
|
||||
);
|
||||
|
||||
const holidays = await api('/term/holidays', { token: adminToken });
|
||||
record(
|
||||
'学期周次:联网拉取放假安排(机房无外网时 ok=false 属正常结果,不能抛 500)',
|
||||
holidays.status === 200 && typeof holidays.payload?.data?.ok === 'boolean',
|
||||
`status=${holidays.status} ok=${holidays.payload?.data?.ok} 条数=${holidays.payload?.data?.holidays?.length ?? 0}`,
|
||||
);
|
||||
|
||||
// 收尾:还原成纯线性推算,不污染演示数据
|
||||
await api('/term', {
|
||||
method: 'PUT',
|
||||
token: adminToken,
|
||||
body: { termStartDate: beforeTerm?.termStartDate ?? undefined, weeks: [] },
|
||||
});
|
||||
const termRestored = await api('/term', { token: adminToken });
|
||||
record(
|
||||
'学期周次用例收尾:清空逐周配置(回到按开学日期线性推算)',
|
||||
termRestored.payload?.data?.configured === false,
|
||||
`configured=${termRestored.payload?.data?.configured}`,
|
||||
);
|
||||
|
||||
/* ---------------------------------------------------------------- 14. 课表同步方向反转 */
|
||||
|
||||
// 自建一台临时设备(收尾删掉):不依赖演示库里有没有接入过设备
|
||||
const tempDevice = await api('/integrations/devices', {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
body: { classId, name: 'E2E 课表方向校验设备' },
|
||||
});
|
||||
const tempDeviceId = tempDevice.payload?.data?.device?.id;
|
||||
record(
|
||||
'课表方向:新建设备默认「服务端自动下发、不自动回传」',
|
||||
tempDevice.status === 201 &&
|
||||
tempDevice.payload?.data?.device?.syncScheduleToServer === false &&
|
||||
tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland === true,
|
||||
`status=${tempDevice.status} syncScheduleToServer=${tempDevice.payload?.data?.device?.syncScheduleToServer} mirrorScheduleToClassIsland=${tempDevice.payload?.data?.device?.mirrorScheduleToClassIsland}`,
|
||||
);
|
||||
|
||||
if (tempDeviceId) {
|
||||
const pullRequest = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
|
||||
method: 'POST',
|
||||
token: adminToken,
|
||||
});
|
||||
record(
|
||||
'从教室机器获取课表:管理员可发起(POST /request-schedule)',
|
||||
pullRequest.status === 200 && pullRequest.payload?.data?.requested === true,
|
||||
`status=${pullRequest.status} ${pullRequest.payload?.message ?? ''}`,
|
||||
);
|
||||
|
||||
// 置位后再读设备列表应当看到待办(插件下一次心跳看到就会立刻把课表推上来)
|
||||
const afterRequest = await api(`/integrations/devices?classId=${classId}`, { token: adminToken });
|
||||
const requestedDevice = (afterRequest.payload?.data ?? []).find((item) => item.id === tempDeviceId);
|
||||
record(
|
||||
'从教室机器获取课表:待办标记已落库(插件下次心跳会照做)',
|
||||
requestedDevice?.requestScheduleReport === true,
|
||||
`requestScheduleReport=${requestedDevice?.requestScheduleReport}`,
|
||||
);
|
||||
|
||||
// 教师对**别人班**的设备不能发起
|
||||
const deniedPull = await api(`/integrations/devices/${tempDeviceId}/request-schedule`, {
|
||||
method: 'POST',
|
||||
token: teacher2Token,
|
||||
});
|
||||
record(
|
||||
'从教室机器获取课表:非本班教师被拒绝(403)',
|
||||
deniedPull.status === 403,
|
||||
`status=${deniedPull.status}`,
|
||||
);
|
||||
|
||||
// 收尾:删掉临时设备
|
||||
const removedDevice = await api(`/integrations/devices/${tempDeviceId}`, {
|
||||
method: 'DELETE',
|
||||
token: adminToken,
|
||||
});
|
||||
record('课表方向用例收尾:临时设备已删除', removedDevice.status === 200, `status=${removedDevice.status}`);
|
||||
}
|
||||
|
||||
socket.close();
|
||||
|
||||
// ---------------------------------------------------------------- 汇总
|
||||
|
||||
@@ -98,7 +98,7 @@ function renderLandingPage(webAdminServed: boolean): string {
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>班级小助手 · 后端服务</title>
|
||||
<title>ClassHelper · 后端服务</title>
|
||||
<style>
|
||||
body { margin: 0; padding: 40px; font-family: 'PingFang SC', 'Microsoft YaHei', sans-serif; background: #f5f7fa; color: #303133; }
|
||||
.card { max-width: 760px; margin: 0 auto; background: #fff; border: 1px solid #e4e7ed; border-radius: 12px; padding: 28px 32px; }
|
||||
@@ -116,7 +116,7 @@ function renderLandingPage(webAdminServed: boolean): string {
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>班级小助手 · 后端服务 <span class="ok">running</span></h1>
|
||||
<h1>ClassHelper · 后端服务 <span class="ok">running</span></h1>
|
||||
<p class="sub">版本 ${APP_VERSION} · 运行环境 ${env.nodeEnv} · 数据库 ${env.databaseProvider}</p>
|
||||
<p class="sub">
|
||||
健康检查 <a href="${API_PREFIX}/health">${API_PREFIX}/health</a> ·
|
||||
@@ -127,7 +127,7 @@ function renderLandingPage(webAdminServed: boolean): string {
|
||||
<ul>${moduleRows}</ul>
|
||||
<div class="hint">
|
||||
WebSocket 端点:<code>ws://${env.host === '0.0.0.0' ? '本机地址' : env.host}:${env.port}/socket.io</code><br />
|
||||
本服务只提供 REST API 与 WebSocket;学生端请使用「班级小助手」桌面客户端。
|
||||
本服务只提供 REST API 与 WebSocket;ClassHelper 班级端请使用「ClassHelper」桌面客户端。
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
@@ -182,7 +182,7 @@ export function createApp(): Express {
|
||||
|
||||
// 限流:先挂通用限流,再挂更严格的登录限流(后挂者决定响应头里的策略,便于观察暴力破解防护)
|
||||
app.use(API_PREFIX, apiRateLimiter());
|
||||
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(学生端
|
||||
// 登录限流必须覆盖**两个**登录入口:`/auth/login`(教师/管理员)与 `/auth/class-login`(ClassHelper 班级端
|
||||
// 班级账号)。后者的密码默认就是 123456,漏掉它等于形同虚设 —— 这里用数组形式挂载,
|
||||
// 两个路径共享同一个计数器(同一 IP 对两种登录的失败次数合并计算)。
|
||||
app.use([`${API_PREFIX}/auth/login`, `${API_PREFIX}/auth/class-login`], loginRateLimiter());
|
||||
|
||||
@@ -27,7 +27,7 @@ const envSchema = z.object({
|
||||
JWT_EXPIRES_IN: z.string().default('7d'),
|
||||
CORS_ORIGIN: z.string().default('*'),
|
||||
BCRYPT_ROUNDS: z.coerce.number().int().min(4).max(15).default(10),
|
||||
/** 新建班级时的默认班级密码(学生端班级账号登录用),管理员可随时重置 */
|
||||
/** 新建班级时的默认班级密码(ClassHelper 班级端班级账号登录用),管理员可随时重置 */
|
||||
DEFAULT_CLASS_PASSWORD: z.string().min(6).default('123456'),
|
||||
/** 新建/重置教师账号时的默认密码(管理员录入教师用) */
|
||||
DEFAULT_TEACHER_PASSWORD: z.string().min(6).default('123456'),
|
||||
|
||||
@@ -79,7 +79,7 @@ httpServer.listen(env.port, env.host, () => {
|
||||
writePidFile();
|
||||
const displayHost = env.host === '0.0.0.0' ? '127.0.0.1' : env.host;
|
||||
const webDir = resolveWebDistDir();
|
||||
logger.info(`班级小助手后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`);
|
||||
logger.info(`ClassHelper后端已启动:http://${displayHost}:${env.port}${API_PREFIX}`);
|
||||
logger.info(`运行环境:${env.nodeEnv} · 监听:${env.host}:${env.port} · 数据库:${env.databaseProvider}`);
|
||||
if (webDir) {
|
||||
logger.info(`Web 管理端:http://${displayHost}:${env.port}/(静态目录 ${webDir})`);
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import {
|
||||
canManageGrades,
|
||||
canManageSchedule,
|
||||
canPublishContent,
|
||||
canManageClasses,
|
||||
canAssignTeachers,
|
||||
canManageClasses,
|
||||
canManageGrades,
|
||||
canManageRoster,
|
||||
canManageSchedule,
|
||||
canManageSubjectContent,
|
||||
canPublishNotification,
|
||||
resolveClassRole,
|
||||
type ClassRole,
|
||||
} from '@classhelper/shared';
|
||||
@@ -13,11 +14,20 @@ import { ApiError } from './http.js';
|
||||
import type { TokenPayload } from './jwt.js';
|
||||
|
||||
/**
|
||||
* 权限(RBAC)规则:
|
||||
* - ADMIN :全部班级;班级增删改、人员分配、成绩、课表
|
||||
* - HEAD :班主任(Class.teacherId)—— 本班课表 + 作业/通知/叫人
|
||||
* - SUBJECT :科任老师(ClassTeacher)—— 仅作业/通知/叫人
|
||||
* - STUDENT :只读;班级账号(classSession)以班级为单位读写自己的数据
|
||||
* 权限(RBAC)规则。
|
||||
*
|
||||
* | 角色 | 范围 | 能做什么 |
|
||||
* | ---- | ---- | -------- |
|
||||
* | ADMIN | 全局 | 班级/教师/学生名单/调班/归档/全部科目的作业与成绩 |
|
||||
* | HEAD 班主任 | 本班 | 本班学生名单、课表、本班汇总;**作业与成绩仍按科目收口** |
|
||||
* | SUBJECT 科任 | 班级 + 科目 | 只能发布/修改/删除**自己任教科目**的作业与成绩 |
|
||||
* | CLASS_DEVICE | 绑定班级 | ClassHelper 班级端:代本班读写个人数据、按开关查成绩明细 |
|
||||
* | 学生 | 无账号 | 仅作为 `Student` 名单记录,不登录、无权限 |
|
||||
*
|
||||
* 三条铁律(都是这次重构的落点):
|
||||
* 1. **任课关系的唯一来源是 `Course`**(班级 + 科目 + 教师),没有第二张关系表;
|
||||
* 2. **班主任不自动拥有本班所有科目的作业成绩编辑权** —— 只有同时是该科科任老师时才有;
|
||||
* 3. **所有权限都在后端判**,前端只做展示控制。
|
||||
*
|
||||
* 判定逻辑与前端共用 `@classhelper/shared/permissions`,避免前后端口径漂移。
|
||||
*/
|
||||
@@ -30,14 +40,36 @@ export function isTeacher(user: TokenPayload): boolean {
|
||||
return user.role === 'TEACHER';
|
||||
}
|
||||
|
||||
export function isStudent(user: TokenPayload): boolean {
|
||||
return user.role === 'STUDENT';
|
||||
}
|
||||
|
||||
/** 教师与管理员(staff):能以自己的身份操作班级内容 */
|
||||
export function isStaff(user: TokenPayload): boolean {
|
||||
return user.role === 'ADMIN' || user.role === 'TEACHER';
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否是 ClassHelper 班级端(教室机器上那台绑定到某个班的客户端)。
|
||||
* 它代全班读写个人数据(作业完成、通知已读),也能按开关查本班学生成绩明细。
|
||||
*/
|
||||
export function isClassDevice(user: TokenPayload): boolean {
|
||||
return user.role === 'CLASS_DEVICE';
|
||||
}
|
||||
|
||||
/** ClassHelper 班级端绑定的班级 id;不是班级端会话时返回 null */
|
||||
export function classDeviceClassId(user: TokenPayload): string | null {
|
||||
return isClassDevice(user) ? (user.classId ?? user.sub) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级访问的判定条件:班主任,**或在该班有任课关系**(`Course.teacherId`)。
|
||||
*
|
||||
* 这里就是「科任老师」的判定源 —— 以前查的是没有科目的 `ClassTeacher` 协作表,
|
||||
* 现在查 `Course`,于是"能不能进这个班"与"能不能改这一科的作业成绩"用的是同一份事实。
|
||||
*/
|
||||
function classMembershipWhere(teacherId: string) {
|
||||
return {
|
||||
OR: [{ teacherId }, { courses: { some: { teacherId } } }],
|
||||
};
|
||||
}
|
||||
|
||||
/** 当前用户可访问的全部班级 id */
|
||||
export async function getAccessibleClassIds(user: TokenPayload): Promise<string[]> {
|
||||
if (isAdmin(user)) {
|
||||
@@ -47,39 +79,23 @@ export async function getAccessibleClassIds(user: TokenPayload): Promise<string[
|
||||
|
||||
if (isTeacher(user)) {
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: user.sub } } }] },
|
||||
where: classMembershipWhere(user.sub),
|
||||
select: { id: true },
|
||||
});
|
||||
return classes.map((item) => item.id);
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
if (user.classId) ids.add(user.classId);
|
||||
const enrollments = await prisma.enrollment.findMany({
|
||||
where: { userId: user.sub },
|
||||
select: { classId: true },
|
||||
});
|
||||
for (const item of enrollments) ids.add(item.classId);
|
||||
return [...ids];
|
||||
const classId = classDeviceClassId(user);
|
||||
return classId ? [classId] : [];
|
||||
}
|
||||
|
||||
export async function canAccessClass(user: TokenPayload, classId: string): Promise<boolean> {
|
||||
if (isAdmin(user)) return true;
|
||||
if (isTeacher(user)) {
|
||||
const count = await prisma.class.count({
|
||||
where: {
|
||||
id: classId,
|
||||
OR: [{ teacherId: user.sub }, { teachers: { some: { teacherId: user.sub } } }],
|
||||
},
|
||||
});
|
||||
const count = await prisma.class.count({ where: { id: classId, ...classMembershipWhere(user.sub) } });
|
||||
return count > 0;
|
||||
}
|
||||
if (user.classId === classId) return true;
|
||||
const enrollment = await prisma.enrollment.findFirst({
|
||||
where: { userId: user.sub, classId },
|
||||
select: { id: true },
|
||||
});
|
||||
return enrollment !== null;
|
||||
return classDeviceClassId(user) === classId;
|
||||
}
|
||||
|
||||
/** 读权限断言 */
|
||||
@@ -89,31 +105,31 @@ export async function assertClassAccess(user: TokenPayload, classId: string): Pr
|
||||
if (!(await canAccessClass(user, classId))) throw ApiError.forbidden('无权访问该班级的数据');
|
||||
}
|
||||
|
||||
/** 写权限断言:学生一律拒绝 */
|
||||
/** 写权限断言:无账号的学生不在会话里,因此这里只放行 staff 与 ClassHelper 班级端 */
|
||||
export async function assertClassWritable(user: TokenPayload, classId: string): Promise<void> {
|
||||
if (!isStaff(user)) throw ApiError.forbidden('学生账号没有管理权限');
|
||||
if (!isStaff(user) && !isClassDevice(user)) throw ApiError.forbidden('当前身份没有管理权限');
|
||||
await assertClassAccess(user, classId);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 班级内角色 */
|
||||
|
||||
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / STUDENT / NONE) */
|
||||
/** 解析当前用户在某班级里的角色(ADMIN / HEAD / SUBJECT / CLASS_DEVICE / NONE) */
|
||||
export async function resolveUserClassRole(user: TokenPayload, classId: string): Promise<ClassRole> {
|
||||
if (user.role === 'ADMIN') return 'ADMIN';
|
||||
if (user.role === 'STUDENT') return canAccessClass(user, classId).then((ok) => (ok ? 'STUDENT' : 'NONE'));
|
||||
if (user.role === 'CLASS_DEVICE') return (await canAccessClass(user, classId)) ? 'CLASS_DEVICE' : 'NONE';
|
||||
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: {
|
||||
teacherId: true,
|
||||
teachers: { where: { teacherId: user.sub }, select: { id: true } },
|
||||
courses: { where: { teacherId: user.sub }, select: { id: true } },
|
||||
},
|
||||
});
|
||||
if (!record) return 'NONE';
|
||||
return resolveClassRole({
|
||||
role: user.role,
|
||||
isHeadTeacher: record.teacherId === user.sub,
|
||||
isSubjectTeacher: record.teachers.length > 0,
|
||||
teachesSubjects: record.courses.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,17 +140,30 @@ export function assertCanManageClasses(user: TokenPayload): void {
|
||||
}
|
||||
}
|
||||
|
||||
/** 班主任 / 科任老师分配:仅管理员 */
|
||||
/** 班主任 / 各科科任老师的分配:仅管理员 */
|
||||
export function assertCanAssignTeachers(user: TokenPayload): void {
|
||||
if (!canAssignTeachers(user.role)) {
|
||||
throw ApiError.forbidden('只有管理员可以分配班主任与科任老师');
|
||||
throw ApiError.forbidden('只有管理员可以分配班主任与各科科任老师');
|
||||
}
|
||||
}
|
||||
|
||||
/** 学生名单管理:仅管理员 */
|
||||
export function assertCanManageRoster(user: TokenPayload): void {
|
||||
if (!canManageRoster(user.role)) {
|
||||
throw ApiError.forbidden('只有管理员可以管理学生名单与账号');
|
||||
/**
|
||||
* 学生名单(增删学生 / 导入名单 / 调班):管理员,或**本班班主任**。
|
||||
*
|
||||
* @param classId 目标班级;不传表示"全局名单操作"(例如未分班的学生),此时仅管理员
|
||||
*/
|
||||
export async function assertCanManageRoster(user: TokenPayload, classId?: string): Promise<void> {
|
||||
if (!classId) {
|
||||
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
|
||||
return;
|
||||
}
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
if (!canManageRoster(classRole)) {
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT'
|
||||
? '科任老师不能管理学生名单,请联系班主任或管理员'
|
||||
: '只能管理自己担任班主任的班级的学生名单',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,30 +172,32 @@ export async function assertCanManageSchedule(user: TokenPayload, classId: strin
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
if (!canManageSchedule(classRole)) {
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT' ? '科任老师不能管理课表,请联系班主任或管理员' : '无权管理该班级的课表',
|
||||
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
|
||||
? '科任老师与班级端不能管理课表,请联系班主任或管理员'
|
||||
: '无权管理该班级的课表',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 作业 / 通知 / 叫人:管理员、班主任、科任老师 */
|
||||
export async function assertCanPublishContent(user: TokenPayload, classId: string): Promise<void> {
|
||||
/** 通知 / 叫人:管理员、班主任、科任老师、ClassHelper 班级端(面向全班,不分科目) */
|
||||
export async function assertCanPublishNotification(user: TokenPayload, classId: string): Promise<void> {
|
||||
await assertClassAccess(user, classId);
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
if (!canPublishContent(classRole)) {
|
||||
throw ApiError.forbidden('当前账号没有发布作业/通知或叫人的权限');
|
||||
if (!canPublishNotification(classRole)) {
|
||||
throw ApiError.forbidden('当前账号没有发布通知或叫人的权限');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 成绩录入 / 修改 / 导入:管理员,或**本班班主任**(需求 6"老师端可用且不越权")。
|
||||
* 科任老师仍然被拒(需求 7:科任只有作业 / 叫人 / 通知)。
|
||||
* 成绩录入 / 修改 / 导入的**班级维度**准入:管理员,或本班班主任。
|
||||
*
|
||||
* @param classId 目标成绩所属班级;不传时按"全局"判定(仅管理员可用,例如空白模板下载)
|
||||
*
|
||||
* 这只是第一道门:具体某一条成绩能不能动,还要过 `assertCanManageSubjectContent` 的**科目**那道门。
|
||||
*/
|
||||
export async function assertCanManageGrades(user: TokenPayload, classId?: string): Promise<void> {
|
||||
if (!classId) {
|
||||
// 没有班级上下文(例如下载空白模板):只有管理员可以执行
|
||||
if (user.role !== 'ADMIN') throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
|
||||
if (!isAdmin(user)) throw ApiError.forbidden('该操作需要指定班级,只有管理员可以全局执行');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -174,22 +205,86 @@ export async function assertCanManageGrades(user: TokenPayload, classId?: string
|
||||
if (canManageGrades(classRole)) return;
|
||||
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT'
|
||||
? '科任老师不能录入或导入成绩,请联系班主任或管理员'
|
||||
: '只能录入或导入自己担任班主任的班级的成绩',
|
||||
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
|
||||
? '科任老师与班级端不能代录全班成绩,请在自己的科目里录入'
|
||||
: '只能录入自己担任班主任的班级的成绩',
|
||||
);
|
||||
}
|
||||
|
||||
/** 学生的主班级,未分配班级时抛 403 */
|
||||
export function requireStudentClassId(user: TokenPayload): string {
|
||||
if (!user.classId) throw ApiError.forbidden('当前账号尚未被分配到任何班级,请联系班主任');
|
||||
return user.classId;
|
||||
/**
|
||||
* **科目维度的写权限**(作业与成绩共用的那道细门)—— 本次重构的核心规则。
|
||||
*
|
||||
* | 情形 | 谁可以写 |
|
||||
* | ---- | -------- |
|
||||
* | 管理员 | 全部 |
|
||||
* | ClassHelper 班级端 | 仅当 `allowClassDevice`(作业允许、成绩不允许) |
|
||||
* | 挂了科目(courseId 有值) | **该科的任课老师**(`Course.teacherId`) |
|
||||
* | 没挂科目(courseId 为空) | 本班**班主任** |
|
||||
*
|
||||
* 于是:班主任若不教这一科,就改不了这一科的作业与成绩 —— 这正是需求
|
||||
* 「班主任不自动拥有所有科目作业成绩编辑权,只有同时是该科科任老师时才有该科权限」。
|
||||
*
|
||||
* @param courseId 目标内容挂在哪门课(科目)上;null/undefined 表示"不指定科目"
|
||||
* @param action 给用户看的动作名,例如「布置作业」「录入成绩」
|
||||
* @param options.allowClassDevice 是否允许教室机器代写(作业为 true,成绩为 false)
|
||||
*/
|
||||
export async function assertCanManageSubjectContent(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
courseId: string | null | undefined,
|
||||
action: string,
|
||||
options: { allowClassDevice?: boolean } = {},
|
||||
): Promise<void> {
|
||||
await assertClassAccess(user, classId);
|
||||
const classRole = await resolveUserClassRole(user, classId);
|
||||
const allowClassDevice = options.allowClassDevice === true;
|
||||
|
||||
// 管理员不受科目限制,提前返回省掉一次课程查询
|
||||
if (classRole === 'ADMIN') return;
|
||||
if (classRole === 'CLASS_DEVICE') {
|
||||
if (allowClassDevice) return;
|
||||
throw ApiError.forbidden(`ClassHelper 班级端不能${action},请由任课老师在 Web 端操作`);
|
||||
}
|
||||
|
||||
let isSubjectTeacherOfCourse = false;
|
||||
let courseName = '';
|
||||
if (courseId) {
|
||||
const course = await prisma.course.findUnique({
|
||||
where: { id: courseId },
|
||||
select: { classId: true, teacherId: true, name: true },
|
||||
});
|
||||
if (!course) throw ApiError.badRequest('课程不存在');
|
||||
if (course.classId !== classId) throw ApiError.badRequest('该课程不属于所选班级');
|
||||
courseName = course.name;
|
||||
isSubjectTeacherOfCourse = course.teacherId === user.sub;
|
||||
}
|
||||
|
||||
const allowed = canManageSubjectContent({
|
||||
classRole,
|
||||
hasSubject: Boolean(courseId),
|
||||
isSubjectTeacherOfCourse,
|
||||
allowClassDevice,
|
||||
});
|
||||
if (allowed) return;
|
||||
|
||||
throw ApiError.forbidden(
|
||||
courseId
|
||||
? `「${courseName}」不是你任教的科目,不能${action}该科目的内容`
|
||||
: `未指定科目的内容只有班主任或管理员可以${action}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 学生的主班级(ClassHelper 班级端会话);未绑定班级时抛 403 */
|
||||
export function requireClassDeviceClassId(user: TokenPayload): string {
|
||||
const classId = classDeviceClassId(user);
|
||||
if (!classId) throw ApiError.forbidden('当前会话未绑定班级,请联系管理员');
|
||||
return classId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询范围解析:
|
||||
* - 传入 classId:校验后只查该班级
|
||||
* - 未传:教师/学生只能查自己可访问的班级;管理员不加限制
|
||||
* - 未传:教师只能查自己可访问的班级;管理员不加限制
|
||||
*/
|
||||
export type ClassScope = { mode: 'all' } | { mode: 'in'; classIds: string[] };
|
||||
|
||||
@@ -215,8 +310,8 @@ export function classScopeIdFilter(scope: ClassScope): { id?: { in: string[] } }
|
||||
return scope.mode === 'all' ? {} : { id: { in: scope.classIds } };
|
||||
}
|
||||
|
||||
/** 单班级场景:学生强制取自己班级,其他角色沿用请求参数 */
|
||||
/** 单班级场景:班级端强制取自己绑定的班级,其他角色沿用请求参数 */
|
||||
export function resolveEffectiveClassId(user: TokenPayload, requestedClassId?: string): string | undefined {
|
||||
if (isStudent(user)) return requireStudentClassId(user);
|
||||
if (isClassDevice(user)) return requireClassDeviceClassId(user);
|
||||
return requestedClassId;
|
||||
}
|
||||
@@ -4,12 +4,12 @@ import { ApiError } from './http.js';
|
||||
import { hashPassword, verifyPassword } from './password.js';
|
||||
|
||||
/**
|
||||
* 班级账号(学生端「班级」主体):
|
||||
* ClassHelper 班级端(ClassHelper 班级端「班级」主体):
|
||||
* - 每个班级有一个**班级码**(唯一、大写字母数字)和**班级密码**(bcrypt);
|
||||
* - 学生端班级设备用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT;
|
||||
* - 教室机器用「班级码 + 班级密码」登录,拿到 `classSession` 的 JWT(角色 `CLASS_DEVICE`);
|
||||
* - 管理员可在「班级管理」里修改班级码、重置班级密码。
|
||||
*
|
||||
* 兼容性:个人学生账号(User.username)完全不受影响,仍然是独立账号。
|
||||
* **学生没有账号**:`Student` 只是名单记录,这里与它无关。
|
||||
*/
|
||||
|
||||
/** 班级码:去掉易混淆字符(0/O/1/I),4~16 位大写字母数字 */
|
||||
@@ -31,7 +31,7 @@ export async function generateClassCode(prefix = ''): Promise<string> {
|
||||
const length = Math.max(4, 6 - prefix.length);
|
||||
let body = '';
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
// 用 CSPRNG 而不是 Math.random():班级码会作为学生端登录的账号名打印/张贴,
|
||||
// 用 CSPRNG 而不是 Math.random():班级码会作为 ClassHelper 班级端登录的账号名打印/张贴,
|
||||
// 用可预测的 PRNG 生成等于把"半个凭证"送给猜码的人(randomInt 无取模偏差)
|
||||
body += CODE_ALPHABET[randomInt(0, CODE_ALPHABET.length)];
|
||||
}
|
||||
@@ -91,18 +91,27 @@ export async function updateClassAccount(
|
||||
return { code: updated.code, hasPassword: Boolean(updated.passwordHash) };
|
||||
}
|
||||
|
||||
/** 班级账号登录:返回班级记录(密码校验在 service 层完成) */
|
||||
/** ClassHelper 班级端登录:返回班级记录(密码校验在 service 层完成) */
|
||||
export async function findClassForLogin(code: string): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
code: string;
|
||||
passwordHash: string | null;
|
||||
studentGradeQueryEnabled: boolean;
|
||||
}> {
|
||||
const normalized = normalizeClassCode(code);
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { code: normalized },
|
||||
select: { id: true, name: true, grade: true, code: true, passwordHash: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
code: true,
|
||||
passwordHash: true,
|
||||
studentGradeQueryEnabled: true,
|
||||
archivedYearId: true,
|
||||
},
|
||||
});
|
||||
if (!record) throw ApiError.unauthorized('班级码或班级密码错误');
|
||||
if (!record.passwordHash) {
|
||||
@@ -112,6 +121,11 @@ export async function findClassForLogin(code: string): Promise<{
|
||||
'该班级尚未设置班级密码,请联系管理员在「班级管理」中设置',
|
||||
);
|
||||
}
|
||||
// 已毕业归档的班级不再允许教室机器登录:继续登进去只会看到一个只读的历史壳子,
|
||||
// 明确拒绝比"能进但没有东西可做"好排查。
|
||||
if (record.archivedYearId) {
|
||||
throw new ApiError(403, 'CLASS_ARCHIVED', '该班级已毕业归档,教室端已停止使用,请联系管理员');
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,8 @@ export type ApiErrorCode =
|
||||
| 'IMPORT_EMPTY'
|
||||
/** 班级账号:该班级尚未设置班级密码(禁止班级登录,提示管理员设置) */
|
||||
| 'CLASS_PASSWORD_NOT_SET'
|
||||
/** 班级账号:该班级已毕业归档,教室端已停止使用 */
|
||||
| 'CLASS_ARCHIVED'
|
||||
| 'INTERNAL_ERROR';
|
||||
|
||||
/** 业务异常:中间件会把它转换成统一错误响应体 */
|
||||
|
||||
@@ -7,13 +7,14 @@ import { ApiError } from './http.js';
|
||||
/** JWT 载荷(同时作为 req.auth.user 的类型来源) */
|
||||
export interface TokenPayload {
|
||||
sub: string;
|
||||
/** 教师/管理员的工号,或 ClassHelper 班级端的班级码 */
|
||||
username: string;
|
||||
name: string;
|
||||
role: UserRole;
|
||||
classId: string | null;
|
||||
/** true 表示"班级账号(班级设备)"登录:sub 是班级 id,而非某个学生账号 */
|
||||
/** true 表示「ClassHelper 班级端」登录:sub 是班级 id,而非某个账号 */
|
||||
classSession?: boolean;
|
||||
/** 班级账号的班级码(便于日志/审计) */
|
||||
/** 班级端的班级码(便于日志/审计) */
|
||||
classCode?: string;
|
||||
}
|
||||
|
||||
@@ -31,7 +32,7 @@ export function verifyToken(token: string): TokenPayload {
|
||||
|
||||
const record = decoded as Record<string, unknown>;
|
||||
const role = record.role;
|
||||
if (role !== 'ADMIN' && role !== 'TEACHER' && role !== 'STUDENT') {
|
||||
if (role !== 'ADMIN' && role !== 'TEACHER' && role !== 'CLASS_DEVICE') {
|
||||
throw new Error('unexpected role in token');
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { dayKeyLocal } from '@classhelper/shared';
|
||||
import type {
|
||||
ArchivedClassDto,
|
||||
ArchivedYearDto,
|
||||
ClassDto,
|
||||
ClassIslandNotificationChannel,
|
||||
ClassTeacherBrief,
|
||||
@@ -11,7 +13,10 @@ import type {
|
||||
NotificationDto,
|
||||
NotificationPriority,
|
||||
ScheduleDto,
|
||||
SessionUser,
|
||||
StudentBrief,
|
||||
StudentDto,
|
||||
StudentTransferDto,
|
||||
UserDto,
|
||||
UserRole,
|
||||
} from '@classhelper/shared';
|
||||
@@ -28,13 +33,13 @@ const toIso = (value: Date): string => value.toISOString();
|
||||
const toIsoOrNull = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
|
||||
|
||||
/**
|
||||
* "个人记录"归属范围解析:
|
||||
* - 传入 userIds(班级账号 = 全班学生)时优先使用;
|
||||
* - 否则退化为单个 userId(普通学生账号 = 自己)。
|
||||
* "个人记录"归属范围解析(记录键是**学生 id**):
|
||||
* - 传入 studentIds(ClassHelper 班级端 = 全班学生)时优先使用;
|
||||
* - 否则退化为单个 studentId。
|
||||
*/
|
||||
function resolvePool(options: { userId?: string | null; userIds?: string[] }): string[] {
|
||||
if (options.userIds && options.userIds.length > 0) return options.userIds;
|
||||
return options.userId ? [options.userId] : [];
|
||||
function resolvePool(options: { studentId?: string | null; studentIds?: string[] }): string[] {
|
||||
if (options.studentIds && options.studentIds.length > 0) return options.studentIds;
|
||||
return options.studentId ? [options.studentId] : [];
|
||||
}
|
||||
|
||||
export interface UserLike {
|
||||
@@ -42,7 +47,8 @@ export interface UserLike {
|
||||
username: string;
|
||||
name: string;
|
||||
role: string;
|
||||
classId: string | null;
|
||||
/** 手机号;老库/老对象可能没有这个字段 */
|
||||
phone?: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
@@ -52,7 +58,32 @@ export interface TeacherBriefLike {
|
||||
username: string;
|
||||
}
|
||||
|
||||
export interface StudentLike extends UserLike {
|
||||
/** 学生简要信息(id + 学号 + 姓名) */
|
||||
export interface StudentBriefLike {
|
||||
id: string;
|
||||
studentNo: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生记录(`Student` 表)。
|
||||
*
|
||||
* 学生不是账号 —— 这里没有 `role`、没有 `passwordHash`,
|
||||
* 唯一的标识是 `studentNo`(学号)。
|
||||
*/
|
||||
export interface StudentLike {
|
||||
id: string;
|
||||
studentNo: string;
|
||||
name: string;
|
||||
classId: string | null;
|
||||
gender: string;
|
||||
guardianPhone: string;
|
||||
status: string;
|
||||
archivedYearId?: string | null;
|
||||
archivedAt?: Date | null;
|
||||
transferredAt?: Date | null;
|
||||
transferNote?: string;
|
||||
createdAt: Date;
|
||||
class?: { name: string; grade: string } | null;
|
||||
}
|
||||
|
||||
@@ -67,19 +98,78 @@ export function toUserDto(user: UserLike): UserDto {
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: user.classId,
|
||||
phone: user.phone ?? '',
|
||||
createdAt: toIso(user.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function toStudentDto(user: StudentLike): StudentDto {
|
||||
/** 账号的会话主体(登录返回 / GET /auth/me) */
|
||||
export function toUserSessionUser(user: UserLike): SessionUser {
|
||||
return {
|
||||
...toUserDto(user),
|
||||
className: user.class?.name ?? null,
|
||||
grade: user.class?.grade ?? null,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
phone: user.phone ?? '',
|
||||
classId: null,
|
||||
createdAt: toIso(user.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** ClassHelper 班级端登录时用到的班级字段 */
|
||||
export interface ClassDeviceLike {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
studentGradeQueryEnabled: boolean;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* ClassHelper 班级端的会话主体:`id` 与 `classId` 都是**班级 id**,
|
||||
* 角色是 `CLASS_DEVICE`(学生不是账号,因此这里不会出现学生身份)。
|
||||
*/
|
||||
export function toClassDeviceUser(record: ClassDeviceLike): SessionUser {
|
||||
return {
|
||||
id: record.id,
|
||||
username: record.code,
|
||||
name: record.name,
|
||||
role: 'CLASS_DEVICE',
|
||||
classId: record.id,
|
||||
className: record.name,
|
||||
grade: record.grade,
|
||||
classSession: true,
|
||||
classCode: record.code,
|
||||
studentGradeQueryEnabled: record.studentGradeQueryEnabled,
|
||||
createdAt: toIso(record.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
export function toStudentDto(student: StudentLike): StudentDto {
|
||||
return {
|
||||
id: student.id,
|
||||
studentNo: student.studentNo,
|
||||
name: student.name,
|
||||
classId: student.classId,
|
||||
className: student.class?.name ?? null,
|
||||
grade: student.class?.grade ?? null,
|
||||
gender: (student.gender ?? '') as StudentDto['gender'],
|
||||
guardianPhone: student.guardianPhone ?? '',
|
||||
status: (student.status ?? 'active') as StudentDto['status'],
|
||||
archivedYearId: student.archivedYearId ?? null,
|
||||
archivedAt: toIsoOrNull(student.archivedAt),
|
||||
transferredAt: toIsoOrNull(student.transferredAt),
|
||||
transferNote: student.transferNote ?? '',
|
||||
createdAt: toIso(student.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
/** 学生简要信息(成绩、提交名单等引用位) */
|
||||
export function toStudentBrief(student: StudentBriefLike): StudentBrief {
|
||||
return { id: student.id, studentNo: student.studentNo, name: student.name };
|
||||
}
|
||||
|
||||
export function toTeacherBrief(user: TeacherBriefLike): ClassTeacherBrief {
|
||||
return { id: user.id, name: user.name, username: user.username };
|
||||
}
|
||||
@@ -94,11 +184,20 @@ export interface ClassLike {
|
||||
id: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
/** 入学年份(届别);老数据可能为空 */
|
||||
enrollmentYear?: number | null;
|
||||
/** 班号;老数据可能为空 */
|
||||
classIndex?: number | null;
|
||||
teacherId: string;
|
||||
/** 本学期教学周数(班主任可调,默认 20) */
|
||||
termWeeks?: number;
|
||||
/** 通知显示位置:both / client / classisland(由教室的班级客户端设置) */
|
||||
/** 通知显示位置:both / client / classisland(由 ClassHelper 班级端设置) */
|
||||
notificationChannel?: string | null;
|
||||
/** 班级端按学号查成绩的开关 */
|
||||
studentGradeQueryEnabled?: boolean | null;
|
||||
/** 毕业归档所属届别;null = 在读 */
|
||||
archivedYearId?: string | null;
|
||||
archivedAt?: Date | null;
|
||||
createdAt: Date;
|
||||
teacher?: TeacherBriefLike | null;
|
||||
_count?: {
|
||||
@@ -120,9 +219,15 @@ export function toClassDto(item: ClassLike): ClassDto {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
grade: item.grade,
|
||||
enrollmentYear: item.enrollmentYear ?? null,
|
||||
classIndex: item.classIndex ?? null,
|
||||
teacherId: item.teacherId,
|
||||
termWeeks: item.termWeeks ?? 20,
|
||||
notificationChannel: toNotificationChannel(item.notificationChannel),
|
||||
// 默认开启:老库补列时的默认值也是 true,两边保持一致
|
||||
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
|
||||
archivedYearId: item.archivedYearId ?? null,
|
||||
archivedAt: toIsoOrNull(item.archivedAt),
|
||||
createdAt: toIso(item.createdAt),
|
||||
teacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
||||
studentCount: item._count?.students ?? undefined,
|
||||
@@ -193,7 +298,7 @@ export function toScheduleDto(item: ScheduleLike): ScheduleDto {
|
||||
export interface HomeworkStatusLike {
|
||||
id: string;
|
||||
homeworkId: string;
|
||||
userId: string;
|
||||
studentId: string;
|
||||
completed: boolean;
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -218,24 +323,24 @@ export function toHomeworkStatusDto(item: HomeworkStatusLike): HomeworkStatusDto
|
||||
return {
|
||||
id: item.id,
|
||||
homeworkId: item.homeworkId,
|
||||
userId: item.userId,
|
||||
studentId: item.studentId,
|
||||
completed: item.completed,
|
||||
updatedAt: toIso(item.updatedAt),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param options.userId 指定当前用户,用于填充 completed / homeworkStatus
|
||||
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找完成状态
|
||||
* @param options.studentId 指定单个学生,用于填充 completed / homeworkStatus
|
||||
* @param options.studentIds ClassHelper 班级端场景:以"全班学生"为范围找完成状态
|
||||
* @param options.withStatus 是否统计完成人数(教师视角)
|
||||
*/
|
||||
export function toHomeworkDto(
|
||||
item: HomeworkLike,
|
||||
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
|
||||
options: { studentId?: string | null; studentIds?: string[]; withStatus?: boolean } = {},
|
||||
): HomeworkDto {
|
||||
const statuses = item.statuses ?? [];
|
||||
const pool = resolvePool(options);
|
||||
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.userId)) : undefined;
|
||||
const own = pool.length > 0 ? statuses.find((status) => pool.includes(status.studentId)) : undefined;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -261,7 +366,7 @@ export function toHomeworkDto(
|
||||
export interface NotificationReadLike {
|
||||
id: string;
|
||||
notificationId: string;
|
||||
userId: string;
|
||||
studentId: string;
|
||||
readAt: Date;
|
||||
}
|
||||
|
||||
@@ -278,17 +383,17 @@ export interface NotificationLike {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param options.userId 指定当前用户(普通学生账号)
|
||||
* @param options.userIds 班级账号(班级设备)场景:以"全班学生"为范围找已读状态
|
||||
* @param options.studentId 指定单个学生
|
||||
* @param options.studentIds ClassHelper 班级端场景:以"全班学生"为范围找已读状态
|
||||
* @param options.withStatus 是否统计已读人数(教师视角)
|
||||
*/
|
||||
export function toNotificationDto(
|
||||
item: NotificationLike,
|
||||
options: { userId?: string | null; userIds?: string[]; withStatus?: boolean } = {},
|
||||
options: { studentId?: string | null; studentIds?: string[]; withStatus?: boolean } = {},
|
||||
): NotificationDto {
|
||||
const reads = item.reads ?? [];
|
||||
const pool = resolvePool(options);
|
||||
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.userId)) : undefined;
|
||||
const own = pool.length > 0 ? reads.find((read) => pool.includes(read.studentId)) : undefined;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
@@ -311,13 +416,17 @@ export interface GradeLike {
|
||||
id: string;
|
||||
classId: string;
|
||||
courseId: string | null;
|
||||
userId: string;
|
||||
studentId: string;
|
||||
examName: string;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
/** 等级口径:percent / letter / custom(老库可能为空,按 percent 兜底) */
|
||||
levelType?: string | null;
|
||||
/** 等级文本(老库可能为空) */
|
||||
level?: string | null;
|
||||
publishedAt: Date;
|
||||
course?: CourseBriefLike | null;
|
||||
student?: TeacherBriefLike | null;
|
||||
student?: StudentBriefLike | null;
|
||||
class?: { name: string } | null;
|
||||
}
|
||||
|
||||
@@ -326,13 +435,111 @@ export function toGradeDto(item: GradeLike): GradeDto {
|
||||
id: item.id,
|
||||
classId: item.classId,
|
||||
courseId: item.courseId,
|
||||
userId: item.userId,
|
||||
studentId: item.studentId,
|
||||
examName: item.examName,
|
||||
score: item.score,
|
||||
totalScore: item.totalScore,
|
||||
levelType: (item.levelType ?? 'percent') as GradeDto['levelType'],
|
||||
level: item.level ?? '',
|
||||
publishedAt: toIso(item.publishedAt),
|
||||
course: item.course ? toCourseBrief(item.course) : null,
|
||||
student: item.student ? toTeacherBrief(item.student) : null,
|
||||
student: item.student ? toStudentBrief(item.student) : null,
|
||||
className: item.class?.name ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 调班 / 归档 */
|
||||
|
||||
export interface StudentTransferLike {
|
||||
id: string;
|
||||
studentId: string;
|
||||
studentNo: string;
|
||||
studentName: string;
|
||||
fromClassId: string | null;
|
||||
fromClassName: string;
|
||||
toClassId: string | null;
|
||||
toClassName: string;
|
||||
operatorId: string | null;
|
||||
operatorName: string;
|
||||
mode: string;
|
||||
note?: string | null;
|
||||
createdAt: Date;
|
||||
}
|
||||
|
||||
export function toStudentTransferDto(item: StudentTransferLike): StudentTransferDto {
|
||||
return {
|
||||
id: item.id,
|
||||
studentId: item.studentId,
|
||||
studentNo: item.studentNo,
|
||||
studentName: item.studentName,
|
||||
fromClassId: item.fromClassId,
|
||||
fromClassName: item.fromClassName,
|
||||
toClassId: item.toClassId,
|
||||
toClassName: item.toClassName,
|
||||
operatorId: item.operatorId,
|
||||
operatorName: item.operatorName,
|
||||
mode: item.mode as StudentTransferDto['mode'],
|
||||
note: item.note ?? '',
|
||||
createdAt: toIso(item.createdAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ArchivedYearLike {
|
||||
id: string;
|
||||
enrollmentYear: number;
|
||||
graduationYear: number;
|
||||
name: string;
|
||||
note: string;
|
||||
operatorId: string | null;
|
||||
operatorName: string;
|
||||
classCount: number;
|
||||
studentCount: number;
|
||||
transferredCount: number;
|
||||
homeworkCount: number;
|
||||
notificationCount: number;
|
||||
archivedAt: Date;
|
||||
}
|
||||
|
||||
export function toArchivedYearDto(item: ArchivedYearLike): ArchivedYearDto {
|
||||
return {
|
||||
id: item.id,
|
||||
enrollmentYear: item.enrollmentYear,
|
||||
graduationYear: item.graduationYear,
|
||||
name: item.name,
|
||||
note: item.note,
|
||||
operatorId: item.operatorId,
|
||||
operatorName: item.operatorName,
|
||||
classCount: item.classCount,
|
||||
studentCount: item.studentCount,
|
||||
transferredCount: item.transferredCount,
|
||||
homeworkCount: item.homeworkCount,
|
||||
notificationCount: item.notificationCount,
|
||||
archivedAt: toIso(item.archivedAt),
|
||||
};
|
||||
}
|
||||
|
||||
export interface ArchivedClassLike {
|
||||
id: string;
|
||||
name: string;
|
||||
grade: string;
|
||||
enrollmentYear: number | null;
|
||||
classIndex: number | null;
|
||||
archivedAt: Date | null;
|
||||
teacher?: { name: string } | null;
|
||||
_count?: { students?: number; homeworks?: number; notifications?: number };
|
||||
}
|
||||
|
||||
export function toArchivedClassDto(item: ArchivedClassLike): ArchivedClassDto {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
grade: item.grade,
|
||||
enrollmentYear: item.enrollmentYear,
|
||||
classIndex: item.classIndex,
|
||||
headTeacherName: item.teacher?.name ?? null,
|
||||
studentCount: item._count?.students ?? 0,
|
||||
homeworkCount: item._count?.homeworks ?? 0,
|
||||
notificationCount: item._count?.notifications ?? 0,
|
||||
archivedAt: toIsoOrNull(item.archivedAt),
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
import { isValidStudentNo, normalizeStudentNo } from '@classhelper/shared';
|
||||
|
||||
/** 通用路径参数::id */
|
||||
export const idParamSchema = z.object({ id: z.string().min(1, '缺少 id') });
|
||||
@@ -52,3 +53,43 @@ export const attachmentUrlSchema = z
|
||||
|
||||
/** 周次:1..30 */
|
||||
export const weekNumberSchema = z.coerce.number().int().min(1).max(30);
|
||||
|
||||
/**
|
||||
* 学号:学生的唯一标识与查询键。
|
||||
*
|
||||
* 只做**格式**校验(1~32 位数字/字母/`._-`),归一化(去空白)后入库;
|
||||
* 唯一性由 `Student.studentNo` 的唯一约束 + service 层的大小写无关查重共同保证。
|
||||
*/
|
||||
export const studentNoSchema = z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请填写学号')
|
||||
.max(32, '学号最多 32 位')
|
||||
.transform((value) => normalizeStudentNo(value))
|
||||
.refine((value) => isValidStudentNo(value), {
|
||||
message: '学号只能包含字母、数字与 . _ -(1~32 位)',
|
||||
});
|
||||
|
||||
/** 学生姓名 */
|
||||
export const studentNameSchema = z.string().trim().min(1, '请输入姓名').max(32);
|
||||
|
||||
/** 学生性别:'' / MALE / FEMALE */
|
||||
export const studentGenderSchema = z.enum(['', 'MALE', 'FEMALE']).optional();
|
||||
|
||||
/** 学生状态 */
|
||||
export const studentStatusSchema = z.enum(['active', 'graduated', 'transferred', 'inactive']).optional();
|
||||
|
||||
/** 家长手机号(可空) */
|
||||
export const guardianPhoneSchema = z.string().trim().max(32).optional();
|
||||
|
||||
/** 入学年份(届别) */
|
||||
export const enrollmentYearSchema = z.coerce.number().int().min(2000).max(2100);
|
||||
|
||||
/** 班号 */
|
||||
export const classIndexSchema = z.coerce.number().int().min(1).max(99);
|
||||
|
||||
/** 成绩等级口径 */
|
||||
export const gradeLevelTypeSchema = z.enum(['percent', 'letter', 'custom']);
|
||||
|
||||
/** 成绩等级文本(自定义等级也要能填,所以只限长度) */
|
||||
export const gradeLevelSchema = z.string().trim().max(16);
|
||||
@@ -1,36 +1,32 @@
|
||||
import { classDeviceClassId, isClassDevice } from './access.js';
|
||||
import { prisma } from './db.js';
|
||||
import type { TokenPayload } from './jwt.js';
|
||||
|
||||
/**
|
||||
* 会话主体解析:普通账号 vs 班级账号(班级设备)。
|
||||
* 会话主体解析:staff 账号(管理员 / 教师)vs ClassHelper 班级端。
|
||||
*
|
||||
* 背景(需求 1):学生端以「班级」为主体登录,个人学生不再作为登录入口。
|
||||
* 班级账号的 JWT 形如:
|
||||
* { sub: <classId>, username: <班级码>, name: <班级名>, role: 'STUDENT',
|
||||
* 背景:**学生不是账号**(`Student` 只是班级名单记录),因此班级端成为个人数据的唯一读写主体。
|
||||
* 班级端的 JWT 形如:
|
||||
* { sub: <classId>, username: <班级码>, name: <班级名>, role: 'CLASS_DEVICE',
|
||||
* classId: <classId>, classSession: true }
|
||||
*
|
||||
* 语义(由用户确认):
|
||||
* - **班级设备代全班操作**:勾选作业完成、标记通知已读,都会写到该班全体学生的记录上,
|
||||
* 语义:
|
||||
* - **班级端代全班操作**:勾选作业完成、标记通知已读,都会写到该班全体学生的记录上,
|
||||
* 因此教师端的"完成人数/已读人数"统计依然准确;
|
||||
* - 读状态判定使用 `userId in 全班学生`:任一条记录存在即视为该班设备读过/完成过;
|
||||
* - 个人学生账号(student01…)保持完全兼容,此时"全班"范围退化为"只有自己"。
|
||||
*
|
||||
* 这样所有个人数据的读写都收敛到 `resolvePersonalIds()` 一个入口,
|
||||
* 既不需要改数据库结构,也不会让旧的个人账号行为发生变化。
|
||||
* - 读状态判定使用 `studentId in 全班学生`:任一条记录存在即视为该班设备读过/完成过;
|
||||
* - 所有个人数据的读写都收敛到 `resolvePersonalIds()` 一个入口。
|
||||
*/
|
||||
export function isClassSession(user: TokenPayload): boolean {
|
||||
return user.role === 'STUDENT' && user.classSession === true;
|
||||
export { isClassDevice };
|
||||
|
||||
/** 班级端对应的班级 id;不是班级端会话时为 null */
|
||||
export function classSessionClassId(user: TokenPayload): string | null {
|
||||
return classDeviceClassId(user);
|
||||
}
|
||||
|
||||
/** 班级账号对应的班级 id */
|
||||
export function classSessionClassId(user: TokenPayload): string {
|
||||
return user.classId ?? user.sub;
|
||||
}
|
||||
|
||||
/** 该班级的全部学生 id(班级账号的"全班"范围) */
|
||||
/** 该班级的全部学生 id(班级端的"全班"范围) */
|
||||
export async function listClassStudentIds(classId: string): Promise<string[]> {
|
||||
const students = await prisma.user.findMany({
|
||||
where: { classId, role: 'STUDENT' },
|
||||
const students = await prisma.student.findMany({
|
||||
where: { classId, status: { not: 'inactive' } },
|
||||
select: { id: true },
|
||||
});
|
||||
return students.map((item) => item.id);
|
||||
@@ -38,16 +34,16 @@ export async function listClassStudentIds(classId: string): Promise<string[]> {
|
||||
|
||||
/**
|
||||
* 当前会话的"个人记录主体"列表:
|
||||
* - 普通学生:自己
|
||||
* - 班级账号:全班学生
|
||||
* - 教师/管理员:自己(教师侧的完成情况统计走 withStatus,不依赖这个列表)
|
||||
* - ClassHelper 班级端:全班学生的 id;
|
||||
* - 教师 / 管理员:空数组 —— 他们没有"个人作业完成/成绩"这类记录。
|
||||
*/
|
||||
export async function resolvePersonalIds(user: TokenPayload): Promise<string[]> {
|
||||
if (isClassSession(user)) return listClassStudentIds(classSessionClassId(user));
|
||||
return [user.sub];
|
||||
const classId = classDeviceClassId(user);
|
||||
if (classId) return listClassStudentIds(classId);
|
||||
return [];
|
||||
}
|
||||
|
||||
/** Prisma 过滤片段:`{ userId: { in: ids } }` */
|
||||
export function personalIdWhere(ids: string[]): { userId: { in: string[] } } {
|
||||
return { userId: { in: ids } };
|
||||
/** Prisma 过滤片段:`{ studentId: { in: ids } }` */
|
||||
export function personalIdWhere(ids: string[]): { studentId: { in: string[] } } {
|
||||
return { studentId: { in: ids } };
|
||||
}
|
||||
@@ -8,7 +8,8 @@ import type { DatabaseProvider } from './db.js';
|
||||
* - 文件级备份只对 SQLite 有意义,跨库迁移根本用不上;
|
||||
* - JSON 快照与 provider 无关,一份实现同时服务备份、导入导出和 SQLite⇄MySQL 迁移。
|
||||
*
|
||||
* 外键注意:schema 里 User.classId → Class 且 Class.teacherId → User(互相引用),
|
||||
* 外键注意:`Class.teacherId → User` 与 `Student.classId → Class` 构成一条链,
|
||||
* 而 `Class.archivedYearId` / `Student.archivedYearId → ArchivedYear` 又是另一条;
|
||||
* 恢复时必须**临时关闭外键检查**再按序写入,否则任何排序都会被其中一侧卡死。
|
||||
* - SQLite:PRAGMA foreign_keys=OFF(libsql 连接级,进程内安全)
|
||||
* - MySQL:SET FOREIGN_KEY_CHECKS=0/1(会话级,连接池下同一客户端实例内生效)
|
||||
@@ -17,13 +18,22 @@ import type { DatabaseProvider } from './db.js';
|
||||
export const SNAPSHOT_FORMAT = 'classhelper-snapshot';
|
||||
export const SNAPSHOT_VERSION = 1;
|
||||
|
||||
/** 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查) */
|
||||
/**
|
||||
* 导出与恢复顺序(外键关闭后顺序不再影响写入,但保持拓扑序便于人工阅读与排查)。
|
||||
*
|
||||
* **学生不是账号**:`Student` 是独立的一张表(没有密码、没有 role),
|
||||
* `Grade` / `HomeworkStatus` / `NotificationRead` 都按 `studentId` 指向它;
|
||||
* `StudentClassTransfer` 存调班与转出历史;`ArchivedYear` 是毕业归档的届别档案。
|
||||
* 历史上那两张 `ClassTeacher`(无科目的协作关系)与 `Enrollment`(与 Student.classId 双写)
|
||||
* 已随 2026-10-06 的重构删除。
|
||||
*/
|
||||
export const SNAPSHOT_TABLES = [
|
||||
'User',
|
||||
'ArchivedYear',
|
||||
'Class',
|
||||
'Student',
|
||||
'Course',
|
||||
'ClassTeacher',
|
||||
'Enrollment',
|
||||
'StudentClassTransfer',
|
||||
'Schedule',
|
||||
'TimeLayout',
|
||||
'Homework',
|
||||
|
||||
@@ -1,13 +1,80 @@
|
||||
import { MAX_TERM_WEEK, resolveCurrentWeek as resolveWeek } from '@classhelper/shared';
|
||||
import {
|
||||
MAX_TERM_WEEK,
|
||||
resolveCurrentWeek as resolveWeek,
|
||||
resolveWeekFromRanges,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../config/env.js';
|
||||
import { prisma } from './db.js';
|
||||
|
||||
export { MAX_TERM_WEEK };
|
||||
|
||||
/** 某个班的周次口径(管理员可以为每个班单独配开学日期与逐周区间) */
|
||||
export interface TermWeekContext {
|
||||
termStartDate: string;
|
||||
/** 逐周区间;空数组 = 完全按学期开始日期线性推算 */
|
||||
weeks: Array<{ weekNumber: number; startDate: string; endDate: string }>;
|
||||
maxWeek: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前教学周。
|
||||
* 具体计算逻辑在 @classhelper/shared(三端共用),这里只负责注入服务端的
|
||||
* TERM_START_DATE 与周次上限,避免"周次"口径在服务端/客户端出现分叉。
|
||||
* 具体计算逻辑在 `@classhelper/shared`(三端共用),这里只负责注入服务端的
|
||||
* 配置,避免"周次"口径在服务端/客户端出现分叉。
|
||||
*
|
||||
* @param context 班级维度的周次口径(`Class.termStartDate` + 该班的逐周区间)。
|
||||
* 不传时退回全局 `TERM_START_DATE` 线性推算。
|
||||
*/
|
||||
export function resolveCurrentWeek(now: Date = new Date()): number {
|
||||
return resolveWeek(env.termStartDate, now, MAX_TERM_WEEK);
|
||||
export function resolveCurrentWeek(now: Date = new Date(), context?: TermWeekContext | null): number {
|
||||
const termStartDate = context?.termStartDate || env.termStartDate;
|
||||
const maxWeek = context?.maxWeek ?? MAX_TERM_WEEK;
|
||||
|
||||
// 配了逐周区间就按区间判定 —— 调休/补课让某一周变长变短时,线性推算会整体错位
|
||||
if (context && context.weeks.length > 0) {
|
||||
const fromRanges = resolveWeekFromRanges(context.weeks, now);
|
||||
if (fromRanges !== null) return fromRanges;
|
||||
}
|
||||
return resolveWeek(termStartDate, now, maxWeek);
|
||||
}
|
||||
|
||||
/**
|
||||
* 取出某个班的周次口径。
|
||||
*
|
||||
* 优先级:班级自己的逐周区间 → 全校默认的逐周区间 → 纯线性推算。
|
||||
* 开学日期同理:`Class.termStartDate` 优先,没配就用全局 `TERM_START_DATE`。
|
||||
*
|
||||
* @param classId 不传/空串表示**只看全校默认**(管理员在「学期周次」页编辑默认口径时就是这种调用)
|
||||
*/
|
||||
export async function loadTermContext(classId?: string | null): Promise<TermWeekContext> {
|
||||
const globalWeeks = await prisma.termWeek.findMany({
|
||||
where: { classId: '' },
|
||||
orderBy: { weekNumber: 'asc' },
|
||||
select: { weekNumber: true, startDate: true, endDate: true },
|
||||
});
|
||||
if (!classId) {
|
||||
// 全校默认没有"班级记录"可以存开学日期,因此**第 1 周的起始日就是它**
|
||||
// (管理员点「按开学日期生成」时,第 1 周就是从那天开始的)
|
||||
const firstGlobal = globalWeeks.find((item) => item.weekNumber === 1)?.startDate;
|
||||
return { termStartDate: firstGlobal || env.termStartDate, weeks: globalWeeks, maxWeek: MAX_TERM_WEEK };
|
||||
}
|
||||
|
||||
const [cls, classWeeks] = await Promise.all([
|
||||
prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { termStartDate: true, termWeeks: true },
|
||||
}),
|
||||
prisma.termWeek.findMany({
|
||||
where: { classId },
|
||||
orderBy: { weekNumber: 'asc' },
|
||||
select: { weekNumber: true, startDate: true, endDate: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
const weeks = classWeeks.length > 0 ? classWeeks : globalWeeks;
|
||||
// 班级的逐周区间若覆盖了第 1 周,开学日期就以它为准(管理员改周次时最直观)
|
||||
const firstWeekStart = weeks.find((item) => item.weekNumber === 1)?.startDate;
|
||||
return {
|
||||
termStartDate: cls?.termStartDate || firstWeekStart || env.termStartDate,
|
||||
weeks,
|
||||
maxWeek: cls?.termWeeks && cls.termWeeks > 0 ? cls.termWeeks : MAX_TERM_WEEK,
|
||||
};
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { extractBearerToken, verifyToken, type TokenPayload } from '../lib/jwt.j
|
||||
|
||||
/**
|
||||
* 认证中间件:解析 Bearer token 并回查数据库,
|
||||
* 保证角色 / 班级变更(例如教师把学生转到别的班)立即生效,不被过期载荷影响。
|
||||
* 保证角色 / 班级变更(例如管理员换了班主任)立即生效,不被过期载荷影响。
|
||||
*/
|
||||
export function authenticate(): RequestHandler {
|
||||
return async (req, _res, next) => {
|
||||
@@ -16,7 +16,7 @@ export function authenticate(): RequestHandler {
|
||||
|
||||
const payload = verifyToken(token);
|
||||
|
||||
// 班级账号(班级设备):主体是班级而不是某个学生账号,回查 Class 表
|
||||
// ClassHelper 班级端:主体是**班级**而不是某个账号,回查 Class 表
|
||||
if (payload.classSession) {
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: payload.classId ?? payload.sub },
|
||||
@@ -30,7 +30,7 @@ export function authenticate(): RequestHandler {
|
||||
sub: record.id,
|
||||
username: record.code,
|
||||
name: record.name,
|
||||
role: 'STUDENT',
|
||||
role: 'CLASS_DEVICE',
|
||||
classId: record.id,
|
||||
classSession: true,
|
||||
classCode: record.code,
|
||||
@@ -40,9 +40,10 @@ export function authenticate(): RequestHandler {
|
||||
return;
|
||||
}
|
||||
|
||||
// 账号(教师 / 管理员):`User` 是纯账号表,学生不在里面
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: payload.sub },
|
||||
select: { id: true, username: true, name: true, role: true, classId: true },
|
||||
select: { id: true, username: true, name: true, role: true },
|
||||
});
|
||||
if (!user) throw ApiError.unauthorized('账号不存在或已被删除');
|
||||
|
||||
@@ -53,7 +54,7 @@ export function authenticate(): RequestHandler {
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: user.classId,
|
||||
classId: null,
|
||||
},
|
||||
};
|
||||
next();
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
archiveClassParamSchema,
|
||||
archivedContentQuerySchema,
|
||||
createArchiveSchema,
|
||||
listArchivesQuerySchema,
|
||||
suggestedEnrollmentYears,
|
||||
} from './archives.schemas.js';
|
||||
import * as archiveService from './archives.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* 毕业归档(**全部接口仅管理员**)。
|
||||
*
|
||||
* 归档是**打标记 + 只读**:毕业班级的作业与通知原样留在库里,
|
||||
* 通过 `GET /:id/classes/:classId/content` 只读查看。
|
||||
* 「未毕业而升级的班级不归档」—— 升级走 `POST /api/classes/promote`,只改年级。
|
||||
*/
|
||||
|
||||
/** GET /api/archives - 届别列表 */
|
||||
router.get('/', requireRole('ADMIN'), validate({ query: listArchivesQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await archiveService.listArchivedYears(user), '获取届别列表成功');
|
||||
});
|
||||
|
||||
/** GET /api/archives/summary - 归档页顶部汇总 + 可归档的届别建议 */
|
||||
router.get('/summary', requireRole('ADMIN'), async (req, res) => {
|
||||
sendOk(
|
||||
res,
|
||||
{
|
||||
...(await archiveService.getArchiveSummary()),
|
||||
suggestedEnrollmentYears: suggestedEnrollmentYears(),
|
||||
},
|
||||
'获取归档汇总成功',
|
||||
);
|
||||
});
|
||||
|
||||
/** GET /api/archives/:id - 某届详情(班级 + 毕业生 + 转出的学生) */
|
||||
router.get('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await archiveService.getArchivedYearDetail(user, id), '获取届别详情成功');
|
||||
});
|
||||
|
||||
/** GET /api/archives/:id/classes/:classId/content - 归档班级的作业与通知(只读) */
|
||||
router.get(
|
||||
'/:id/classes/:classId/content',
|
||||
requireRole('ADMIN'),
|
||||
validate({ params: archiveClassParamSchema, query: archivedContentQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id, classId } = validatedParams<{ id: string; classId: string }>(req);
|
||||
const { limit } = validatedQuery<{ limit?: number }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await archiveService.getArchivedClassContent(user, id, classId, limit ?? 200),
|
||||
'获取归档班级内容成功',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/archives - 执行毕业归档(按届别整届归档) */
|
||||
router.post('/', requireRole('ADMIN'), validate({ body: createArchiveSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await archiveService.createArchive(user, validatedBody(req));
|
||||
sendCreated(res, result, `${result.name} 已归档,共 ${result.classCount} 个班级`);
|
||||
});
|
||||
|
||||
/** DELETE /api/archives/:id - 撤销归档(管理员误操作的退路,不删任何数据) */
|
||||
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await archiveService.deleteArchive(user, id);
|
||||
sendOk(res, { id }, '已撤销归档,班级与学生回到在读状态');
|
||||
});
|
||||
|
||||
export const archivesModule = defineModule({
|
||||
name: 'archives',
|
||||
basePath: '/archives',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { z } from 'zod';
|
||||
import { currentAcademicYearStart, graduationYearOf } from '@classhelper/shared';
|
||||
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({
|
||||
/** 只看某一届 */
|
||||
enrollmentYear: enrollmentYearSchema.optional(),
|
||||
keyword: z.string().trim().max(64).optional(),
|
||||
});
|
||||
|
||||
/** 归档班级的内容(作业 / 通知)查询 */
|
||||
export const archivedContentQuerySchema = z.object({
|
||||
/** 作业与通知最多各返回多少条(默认 200) */
|
||||
limit: z.coerce.number().int().min(1).max(500).optional(),
|
||||
});
|
||||
|
||||
export const archiveClassParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
classId: z.string().min(1),
|
||||
});
|
||||
|
||||
/**
|
||||
* 可归档的届别(前端下拉用):当前学年往前推几届,已经毕业的那几届就是候选。
|
||||
* 只给"看起来该毕业了"的届别,避免管理员手滑把在读的届归档掉。
|
||||
*/
|
||||
export function suggestedEnrollmentYears(now: Date = new Date()): number[] {
|
||||
const start = currentAcademicYearStart(now);
|
||||
return [start - 2, start - 3, start - 4, start - 5];
|
||||
}
|
||||
|
||||
export { graduationYearOf };
|
||||
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
formatYearLabel,
|
||||
graduationYearOf,
|
||||
type ArchivedClassContentDto,
|
||||
type ArchivedYearDetailDto,
|
||||
type ArchivedYearDto,
|
||||
} from '@classhelper/shared';
|
||||
import { assertCanManageClasses } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import {
|
||||
toArchivedClassDto,
|
||||
toArchivedYearDto,
|
||||
toHomeworkDto,
|
||||
toNotificationDto,
|
||||
toStudentDto,
|
||||
} from '../../lib/mappers.js';
|
||||
import { emitToClass } from '../../realtime/bus.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>;
|
||||
|
||||
/**
|
||||
* 毕业归档。
|
||||
*
|
||||
* **归档 = 打标记 + 只读,不删任何数据**:
|
||||
* - `ArchivedYear` 是一届的档案(入学年份、毕业年份、归档时的统计快照);
|
||||
* - `Class.archivedYearId` / `Student.archivedYearId` 指向它,常规列表据此过滤掉归档项;
|
||||
* - 毕业班级发过的作业与通知**原样留在库里**,通过归档详情只读查看
|
||||
* —— 「记录每个年度毕业班级的消息和作业」就是这么满足的。
|
||||
*
|
||||
* **未毕业而升级的班级不归档**:升级只改 `Class.grade`(高一 → 高二),
|
||||
* 班级记录、学生名单、学号、作业成绩全部沿用(见 `classes.service.promoteClasses`)。
|
||||
*
|
||||
* 全部接口**仅管理员**。
|
||||
*/
|
||||
|
||||
/** 届别列表(按入学年份倒序) */
|
||||
export async function listArchivedYears(user: TokenPayload): Promise<ArchivedYearDto[]> {
|
||||
assertCanManageClasses(user);
|
||||
const records = await prisma.archivedYear.findMany({ orderBy: { enrollmentYear: 'desc' } });
|
||||
return records.map(toArchivedYearDto);
|
||||
}
|
||||
|
||||
/** 某届的详情:班级 + 毕业生 + 转出的学生 */
|
||||
export async function getArchivedYearDetail(
|
||||
user: TokenPayload,
|
||||
archiveId: string,
|
||||
): Promise<ArchivedYearDetailDto> {
|
||||
assertCanManageClasses(user);
|
||||
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
|
||||
if (!record) throw ApiError.notFound('该届别档案不存在');
|
||||
|
||||
const [classes, students] = await Promise.all([
|
||||
prisma.class.findMany({
|
||||
where: { archivedYearId: archiveId },
|
||||
orderBy: { classIndex: 'asc' },
|
||||
include: {
|
||||
teacher: { select: { name: true } },
|
||||
_count: { select: { students: true, homeworks: true, notifications: true } },
|
||||
},
|
||||
}),
|
||||
prisma.student.findMany({
|
||||
where: { archivedYearId: archiveId },
|
||||
orderBy: [{ status: 'asc' }, { studentNo: 'asc' }],
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
}),
|
||||
]);
|
||||
|
||||
const dtos = students.map(toStudentDto);
|
||||
return {
|
||||
...toArchivedYearDto(record),
|
||||
classes: classes.map(toArchivedClassDto),
|
||||
graduates: dtos.filter((item) => item.status === 'graduated'),
|
||||
transferred: dtos.filter((item) => item.status === 'transferred'),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 归档班级的作业与通知(只读)。
|
||||
*
|
||||
* 归档不删数据,所以这里就是普通的按 classId 查询;单独开一个接口是为了让
|
||||
* 「归档详情 → 查看这个班发过什么」有一个明确的、只读的入口。
|
||||
*/
|
||||
export async function getArchivedClassContent(
|
||||
user: TokenPayload,
|
||||
archiveId: string,
|
||||
classId: string,
|
||||
limit = 200,
|
||||
): Promise<ArchivedClassContentDto> {
|
||||
assertCanManageClasses(user);
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { id: true, name: true, archivedYearId: true },
|
||||
});
|
||||
if (!record) throw ApiError.notFound('班级不存在');
|
||||
if (record.archivedYearId !== archiveId) {
|
||||
throw ApiError.badRequest('该班级不属于这个届别档案');
|
||||
}
|
||||
|
||||
const [homeworks, notifications] = await Promise.all([
|
||||
prisma.homework.findMany({
|
||||
where: { classId },
|
||||
include: {
|
||||
course: { select: { id: true, name: true } },
|
||||
creator: { select: { id: true, name: true, username: true } },
|
||||
statuses: true,
|
||||
},
|
||||
orderBy: [{ assignDate: 'desc' }, { createdAt: 'desc' }],
|
||||
take: limit,
|
||||
}),
|
||||
prisma.notification.findMany({
|
||||
where: { classId },
|
||||
include: {
|
||||
creator: { select: { id: true, name: true, username: true } },
|
||||
reads: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: limit,
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
classId: record.id,
|
||||
className: record.name,
|
||||
homeworks: homeworks.map((item) => toHomeworkDto(item, { withStatus: true })),
|
||||
notifications: notifications.map((item) => toNotificationDto(item, { withStatus: true })),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行归档:把某一届的全部班级与学生标记为已归档。
|
||||
*
|
||||
* 步骤:
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 撤销归档(管理员误操作的退路)。
|
||||
*
|
||||
* 把该届的班级与学生的归档标记清掉:班级回到在读列表,学生状态 `graduated → active`。
|
||||
* **不会删除任何作业或通知** —— 它们本来就没被删过。
|
||||
*/
|
||||
export async function deleteArchive(user: TokenPayload, archiveId: string): Promise<void> {
|
||||
assertCanManageClasses(user);
|
||||
const record = await prisma.archivedYear.findUnique({ where: { id: archiveId } });
|
||||
if (!record) throw ApiError.notFound('该届别档案不存在');
|
||||
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { archivedYearId: archiveId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.class.updateMany({
|
||||
where: { archivedYearId: archiveId },
|
||||
data: { archivedYearId: null, archivedAt: null },
|
||||
}),
|
||||
// 毕业的还原成在读;转出的保持转出(那是事实,不是归档造成的)
|
||||
prisma.student.updateMany({
|
||||
where: { archivedYearId: archiveId, status: 'graduated' },
|
||||
data: { status: 'active', archivedYearId: null, archivedAt: null },
|
||||
}),
|
||||
prisma.student.updateMany({
|
||||
where: { archivedYearId: archiveId, status: { not: 'graduated' } },
|
||||
data: { archivedYearId: null, archivedAt: null },
|
||||
}),
|
||||
prisma.archivedYear.delete({ where: { id: archiveId } }),
|
||||
]);
|
||||
|
||||
for (const item of classes) {
|
||||
emitToClass(item.id, SOCKET_EVENTS.classUpdated, { classId: item.id, action: 'updated' });
|
||||
}
|
||||
logger.info(`撤销毕业归档:${record.name} 由 ${user.name} 执行`);
|
||||
}
|
||||
|
||||
/** 归档页顶部的汇总(一届一行之外的全局数字) */
|
||||
export async function getArchiveSummary(): Promise<{
|
||||
archiveCount: number;
|
||||
classCount: number;
|
||||
graduateCount: number;
|
||||
transferredCount: number;
|
||||
}> {
|
||||
const [archiveCount, classCount, graduateCount, transferredCount] = await Promise.all([
|
||||
prisma.archivedYear.count(),
|
||||
prisma.class.count({ where: { NOT: { archivedYearId: null } } }),
|
||||
prisma.student.count({ where: { status: 'graduated' } }),
|
||||
prisma.student.count({ where: { status: 'transferred' } }),
|
||||
]);
|
||||
return { archiveCount, classCount, graduateCount, transferredCount };
|
||||
}
|
||||
@@ -23,7 +23,7 @@ router.post('/login', validate({ body: loginSchema }), async (req, res) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/auth/class-login - 班级账号登录(公开,学生端主入口)
|
||||
* POST /api/auth/class-login - 班级账号登录(公开,ClassHelper 班级端主入口)
|
||||
*
|
||||
* 班级码 + 班级密码 → 以「班级」为主体的 JWT(个人数据按全班读写)。
|
||||
* 个人学生账号仍可用 /auth/login 登录(向后兼容,客户端已不再展示该入口)。
|
||||
|
||||
@@ -5,7 +5,7 @@ export const loginSchema = z.object({
|
||||
password: z.string().min(1, '请输入密码').max(128),
|
||||
});
|
||||
|
||||
/** 班级账号登录(学生端):班级码 + 班级密码 */
|
||||
/** 班级账号登录(ClassHelper 班级端):班级码 + 班级密码 */
|
||||
export const classLoginSchema = z.object({
|
||||
code: z.string().trim().min(4, '请输入班级码').max(16),
|
||||
password: z.string().min(1, '请输入班级密码').max(128),
|
||||
|
||||
@@ -1,51 +1,45 @@
|
||||
import type { LoginResponse, StudentDto, UserRole } from '@classhelper/shared';
|
||||
import type { LoginResponse, SessionUser, UserRole } from '@classhelper/shared';
|
||||
import { changeClassPassword, findClassForLogin, verifyClassPassword } from '../../lib/class-account.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import { signToken, type TokenPayload } from '../../lib/jwt.js';
|
||||
import { toStudentDto, toUserDto } from '../../lib/mappers.js';
|
||||
import { toClassDeviceUser, toUserDto, toUserSessionUser } from '../../lib/mappers.js';
|
||||
import { hashPassword, verifyPassword } from '../../lib/password.js';
|
||||
import { isClassSession } from '../../lib/session.js';
|
||||
import { isClassDevice } from '../../lib/access.js';
|
||||
import type { ChangePasswordInput, ClassLoginInput, LoginInput } from './auth.schemas.js';
|
||||
|
||||
/**
|
||||
* 登录:校验用户名密码并签发 JWT。
|
||||
* 登录:校验工号(管理员为登录名)与密码并签发 JWT。
|
||||
*
|
||||
* 学生端主体是**班级**(班级码 + 班级密码):个人学生账号(student01…)**不再允许登录**,
|
||||
* 它们的用户名只是"作业完成 / 通知已读 / 成绩"等个人数据的记录键(由班级会话代全班读写)。
|
||||
* 教师与管理员仍用用户名登录。
|
||||
* **学生不在 `User` 表里**,因此这里没有、也不该有"学生账号"分支:
|
||||
* 学生只是 `Student` 名单记录,唯一的登录途径是教室机器用班级码 + 班级密码
|
||||
* (`classLogin`),而那签发的会话主体是**班级**,不是某个学生。
|
||||
*/
|
||||
export async function login(input: LoginInput): Promise<LoginResponse> {
|
||||
const user = await prisma.user.findUnique({ where: { username: input.username } });
|
||||
if (!user) throw ApiError.unauthorized('用户名或密码错误');
|
||||
|
||||
// 角色判定必须放在密码校验**之前**:否则"密码错"与"密码对但学生账号已停用"会给出
|
||||
// 不同的响应(401 / 403),攻击者据此就能把学生账号的密码当成可离线验证的预言机。
|
||||
// 文案与状态码保持不变(客户端按 403 提示"请用班级码登录")。
|
||||
if (user.role === 'STUDENT') {
|
||||
throw ApiError.forbidden('学生请使用「班级码 + 班级密码」登录(个人学生账号已停用)');
|
||||
}
|
||||
if (!user) throw ApiError.unauthorized('工号或密码错误');
|
||||
|
||||
const passwordMatched = await verifyPassword(input.password, user.passwordHash);
|
||||
if (!passwordMatched) throw ApiError.unauthorized('用户名或密码错误');
|
||||
if (!passwordMatched) throw ApiError.unauthorized('工号或密码错误');
|
||||
|
||||
const payload: TokenPayload = {
|
||||
sub: user.id,
|
||||
username: user.username,
|
||||
name: user.name,
|
||||
role: user.role as UserRole,
|
||||
classId: user.classId,
|
||||
classId: null,
|
||||
};
|
||||
const token = signToken(payload);
|
||||
|
||||
return { token, user: toUserDto(user) };
|
||||
return { token, user: toUserSessionUser(user) };
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级账号登录(学生端):班级码 + 班级密码。
|
||||
* ClassHelper 班级端登录:班级码 + 班级密码。
|
||||
*
|
||||
* 签发的 JWT 以**班级**为主体:`sub` 与 `classId` 都是班级 id,并带 `classSession: true`,
|
||||
* 后续个人数据(作业完成、通知已读)由服务端按"全班"范围读写,个人学生不再是登录主体。
|
||||
* 角色是 `CLASS_DEVICE`。后续个人数据(作业完成、通知已读)由服务端按"全班学生"范围读写,
|
||||
* 因此教师端的"完成人数 / 已读人数"统计依然准确。
|
||||
*/
|
||||
export async function classLogin(input: ClassLoginInput): Promise<LoginResponse> {
|
||||
const record = await findClassForLogin(input.code);
|
||||
@@ -58,7 +52,7 @@ export async function classLogin(input: ClassLoginInput): Promise<LoginResponse>
|
||||
sub: record.id,
|
||||
username: record.code,
|
||||
name: record.name,
|
||||
role: 'STUDENT',
|
||||
role: 'CLASS_DEVICE',
|
||||
classId: record.id,
|
||||
classSession: true,
|
||||
classCode: record.code,
|
||||
@@ -67,57 +61,53 @@ export async function classLogin(input: ClassLoginInput): Promise<LoginResponse>
|
||||
|
||||
return {
|
||||
token,
|
||||
user: {
|
||||
user: toClassDeviceUser({
|
||||
id: record.id,
|
||||
username: record.code,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
role: 'STUDENT',
|
||||
classId: record.id,
|
||||
createdAt: new Date().toISOString(),
|
||||
className: record.name,
|
||||
grade: record.grade,
|
||||
classSession: true,
|
||||
classCode: record.code,
|
||||
},
|
||||
studentGradeQueryEnabled: record.studentGradeQueryEnabled,
|
||||
createdAt: new Date(),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前用户信息(学生额外带班级名与年级)。
|
||||
* 班级账号返回班级信息(id 即班级 id)。
|
||||
* 当前账号信息。
|
||||
* ClassHelper 班级端返回班级信息(id 即班级 id)。
|
||||
*/
|
||||
export async function getProfile(user: TokenPayload): Promise<StudentDto> {
|
||||
if (isClassSession(user)) {
|
||||
export async function getProfile(user: TokenPayload): Promise<SessionUser> {
|
||||
if (isClassDevice(user)) {
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: user.classId ?? user.sub },
|
||||
select: { id: true, name: true, grade: true, code: true, createdAt: true },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
code: true,
|
||||
studentGradeQueryEnabled: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
if (!record) throw ApiError.unauthorized('班级不存在或已被删除');
|
||||
return {
|
||||
return toClassDeviceUser({
|
||||
id: record.id,
|
||||
username: record.code,
|
||||
code: record.code,
|
||||
name: record.name,
|
||||
role: 'STUDENT',
|
||||
classId: record.id,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
className: record.name,
|
||||
grade: record.grade,
|
||||
classSession: true,
|
||||
classCode: record.code,
|
||||
} as StudentDto & { classSession: boolean; classCode: string };
|
||||
studentGradeQueryEnabled: record.studentGradeQueryEnabled,
|
||||
createdAt: record.createdAt,
|
||||
});
|
||||
}
|
||||
|
||||
const user_ = await prisma.user.findUnique({
|
||||
where: { id: user.sub },
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
});
|
||||
if (!user_) throw ApiError.unauthorized('账号不存在或已被删除');
|
||||
return toStudentDto(user_);
|
||||
const record = await prisma.user.findUnique({ where: { id: user.sub } });
|
||||
if (!record) throw ApiError.unauthorized('账号不存在或已被删除');
|
||||
return toUserSessionUser(record);
|
||||
}
|
||||
|
||||
/** 修改自己的密码;班级账号修改的是「班级密码」 */
|
||||
/** 修改自己的密码;ClassHelper 班级端修改的是「班级密码」 */
|
||||
export async function changePassword(user: TokenPayload, input: ChangePasswordInput): Promise<void> {
|
||||
if (isClassSession(user)) {
|
||||
if (isClassDevice(user)) {
|
||||
await changeClassPassword(user.classId ?? user.sub, input.currentPassword, input.newPassword);
|
||||
return;
|
||||
}
|
||||
@@ -133,3 +123,6 @@ export async function changePassword(user: TokenPayload, input: ChangePasswordIn
|
||||
data: { passwordHash: await hashPassword(input.newPassword) },
|
||||
});
|
||||
}
|
||||
|
||||
/** 供 auth 模块的路由复用:账号 DTO */
|
||||
export { toUserDto };
|
||||
@@ -11,7 +11,7 @@ router.use(authenticate());
|
||||
|
||||
/**
|
||||
* POST /api/calls - 叫人(老师点名让学生过来)
|
||||
* 学生端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
|
||||
* ClassHelper 班级端会在灵动岛上立即弹出"请 XXX 同学找 XXX 老师",无论是否在上课。
|
||||
*/
|
||||
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createCallSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
|
||||
@@ -13,7 +13,7 @@ export const createCallSchema = z
|
||||
quickPhrase: z.string().trim().max(64).optional(),
|
||||
/** 自定义消息(可选;与 quickPhrase 至少填一个) */
|
||||
message: z.string().trim().max(200).optional(),
|
||||
/** 紧急叫人:落库 URGENT,学生端无视上课时段立即展开;默认普通(只进队列) */
|
||||
/** 紧急叫人:落库 URGENT,ClassHelper 班级端无视上课时段立即展开;默认普通(只进队列) */
|
||||
urgent: z.boolean().optional(),
|
||||
})
|
||||
.refine((data) => Boolean(data.studentId), { message: '请选择学生', path: ['studentId'] })
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { SOCKET_EVENTS, buildCallTitle, type NotificationDto } from '@classhelper/shared';
|
||||
import { assertCanPublishContent } from '../../lib/access.js';
|
||||
import { assertCanPublishNotification } from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { toNotificationDto } from '../../lib/mappers.js';
|
||||
import { emitToClass, emitToUser } from '../../realtime/bus.js';
|
||||
import { emitToClass, emitToSession } from '../../realtime/bus.js';
|
||||
import { pushToClassIsland, shouldPushToClassIsland } from '../integrations/integrations.service.js';
|
||||
import type { CreateCallInput } from './calls.schemas.js';
|
||||
|
||||
@@ -14,20 +14,24 @@ const creatorSelect = { select: { id: true, name: true, username: true } } as co
|
||||
/**
|
||||
* "叫人":老师点名让某位同学去找他。
|
||||
*
|
||||
* 设计:复用通知表落库(学生端通知中心可见、已读状态天然可用、离线也能看到历史),
|
||||
* 同时额外广播 `call:new` 到该学生的 user 房间 —— 客户端据此把消息标记为
|
||||
* "叫人"类型(徽标/按钮/尺寸),**是否立即展开则由 priority 决定**:
|
||||
* 设计:复用通知表落库(通知中心可见、已读状态天然可用、离线也能看到历史),
|
||||
* 同时额外广播 `call:new`,客户端据此把消息标记为"叫人"类型(徽标/按钮/尺寸)。
|
||||
* **是否立即展开由 priority 决定**:
|
||||
* - 紧急叫人(urgent=true → URGENT):无视上课时段立即展开;
|
||||
* - 普通叫人(默认 → HIGH):按普通通知处理,上课时段只进队列、下课后弹出,
|
||||
* 课间则先显示胶囊、点击后展开。
|
||||
* 两种情况都不会被"上课时段发布紧急通知"的 409 拦截(老师确实需要学生过来)。
|
||||
*
|
||||
* 注:学生不是账号,没有"学生自己的客户端",因此 `call:new` 只发给
|
||||
* **教室机器**(ClassHelper 班级端,会话房间以班级 id 为键)—— 学生姓名在标题里,
|
||||
* 全班都看得到在叫谁,这与需求「教室机器代全班」一致。
|
||||
*/
|
||||
export async function createCall(user: TokenPayload, input: CreateCallInput): Promise<NotificationDto> {
|
||||
await assertCanPublishContent(user, input.classId);
|
||||
await assertCanPublishNotification(user, input.classId);
|
||||
|
||||
const student = await prisma.user.findUnique({
|
||||
const student = await prisma.student.findUnique({
|
||||
where: { id: input.studentId },
|
||||
select: { id: true, name: true, username: true, classId: true },
|
||||
select: { id: true, name: true, studentNo: true, classId: true },
|
||||
});
|
||||
if (!student) throw ApiError.notFound('学生不存在');
|
||||
if (student.classId !== input.classId) {
|
||||
@@ -39,7 +43,7 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
|
||||
select: { name: true, username: true },
|
||||
});
|
||||
const teacherName = teacher?.name ?? teacher?.username ?? '老师';
|
||||
const studentName = student.name || student.username;
|
||||
const studentName = student.name || student.studentNo;
|
||||
const message = input.message?.trim() || input.quickPhrase?.trim() || '请尽快到老师这里来';
|
||||
|
||||
const created = await prisma.notification.create({
|
||||
@@ -48,24 +52,21 @@ export async function createCall(user: TokenPayload, input: CreateCallInput): Pr
|
||||
title: buildCallTitle(studentName, teacherName),
|
||||
content: message,
|
||||
// 紧急叫人 → URGENT(客户端无视上课时段立即展开);普通叫人 → HIGH
|
||||
// (不会触发上课时段 409 拦截,但学生端按普通通知排队处理)
|
||||
// (不会触发上课时段 409 拦截,但客户端按普通通知排队处理)
|
||||
priority: input.urgent ? 'URGENT' : 'HIGH',
|
||||
createdBy: user.sub,
|
||||
},
|
||||
include: { creator: creatorSelect, reads: true },
|
||||
});
|
||||
|
||||
const dto = toNotificationDto(created, { userIds: [student.id], withStatus: true });
|
||||
// 班级房间广播:通知中心/其他端都能看到
|
||||
const dto = toNotificationDto(created, { studentId: student.id, withStatus: true });
|
||||
// 班级房间广播:通知中心都能看到
|
||||
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
|
||||
// 定向广播给被叫的学生:个人学生端灵动岛立即展开
|
||||
emitToUser(student.id, SOCKET_EVENTS.callNew, dto);
|
||||
// 同时广播到 classId 对应的 user 房间:班级账号(班级设备)以此房间登录,
|
||||
// 因此"叫人"消息在班级设备上也会立即展开(学生姓名在标题里,全班都能看到叫谁)
|
||||
emitToUser(created.classId, SOCKET_EVENTS.callNew, dto);
|
||||
// 定向广播给教室机器:会话房间以**班级 id** 为键,叫人消息因此立即展开
|
||||
emitToSession(created.classId, SOCKET_EVENTS.callNew, dto);
|
||||
|
||||
// 叫人同样推到教室的 ClassIsland:老师点名时,教室大屏也弹一条(学生姓名在标题里)。
|
||||
// 显示位置由教室客户端决定;推送失败不影响叫人本身。
|
||||
// 显示位置由班级端决定;推送失败不影响叫人本身。
|
||||
try {
|
||||
if (await shouldPushToClassIsland(input.classId)) {
|
||||
await pushToClassIsland({
|
||||
|
||||
@@ -12,19 +12,22 @@ import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
addStudentSchema,
|
||||
assignHeadTeacherSchema,
|
||||
assignTeacherSchema,
|
||||
assignSubjectTeachersSchema,
|
||||
classStudentParamSchema,
|
||||
classTeacherParamSchema,
|
||||
createClassSchema,
|
||||
listClassesQuerySchema,
|
||||
promoteClassesSchema,
|
||||
updateClassAccountSchema,
|
||||
updateClassSchema,
|
||||
updateNotificationChannelSchema,
|
||||
updateStudentGradeQuerySchema,
|
||||
type AddStudentInput,
|
||||
type AssignSubjectTeachersInput,
|
||||
type CreateClassInput,
|
||||
type UpdateClassAccountInput,
|
||||
type UpdateClassInput,
|
||||
type UpdateNotificationChannelInput,
|
||||
type UpdateStudentGradeQueryInput,
|
||||
} from './classes.schemas.js';
|
||||
import * as classService from './classes.service.js';
|
||||
|
||||
@@ -33,30 +36,52 @@ const router = Router();
|
||||
// 班级相关接口统一需要登录
|
||||
router.use(authenticate());
|
||||
|
||||
/** GET /api/classes - 班级列表(教师/管理员:自己管理的班级;学生:自己所在班级) */
|
||||
/**
|
||||
* GET /api/classes - 班级列表
|
||||
*
|
||||
* 只列**在读**班级(毕业归档的班级属于「归档管理」页);
|
||||
* 教师/管理员看自己可访问的班级(班主任 ∪ 任课),ClassHelper 班级端看自己绑定的那个。
|
||||
*/
|
||||
router.get('/', validate({ query: listClassesQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { keyword } = validatedQuery<{ keyword?: string }>(req);
|
||||
sendOk(res, await classService.listClasses(user, keyword), '获取班级列表成功');
|
||||
});
|
||||
|
||||
/** GET /api/classes/:id - 班级详情 */
|
||||
/**
|
||||
* POST /api/classes/promote - 学年升级(批量改年级,仅管理员)
|
||||
*
|
||||
* **升级不归档、数据沿用**:只改 `grade`(高一 → 高二),班级记录、学生名单、学号、
|
||||
* 作业与成绩全部原样。归档只在毕业时由「归档管理」页显式执行。
|
||||
*/
|
||||
router.post(
|
||||
'/promote',
|
||||
requireRole('ADMIN'),
|
||||
validate({ body: promoteClassesSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const body = validatedBody<{ classIds: string[]; grade: string }>(req);
|
||||
sendOk(res, await classService.promoteClasses(user, body), '班级已升级,数据继续沿用');
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/classes/:id - 班级详情(学生名单 + 课程 + 班主任 + 各科任课老师) */
|
||||
router.get('/:id', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await classService.getClassDetail(user, id), '获取班级详情成功');
|
||||
});
|
||||
|
||||
/** POST /api/classes - 创建班级 */
|
||||
/** POST /api/classes - 创建班级(称呼由入学年份 + 班号生成,如「2026级1班」) */
|
||||
router.post('/', requireRole('ADMIN'), validate({ body: createClassSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(res, await classService.createClass(user, req.body as CreateClassInput), '班级创建成功');
|
||||
});
|
||||
|
||||
/** PATCH /api/classes/:id - 编辑班级 */
|
||||
/** PATCH /api/classes/:id - 编辑班级(年级 / 届别 / 教学周数),仅管理员 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requireRole('ADMIN'),
|
||||
validate({ params: idParamSchema, body: updateClassSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -68,7 +93,7 @@ router.patch(
|
||||
/** DELETE /api/classes/:id - 删除班级 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
requireRole('ADMIN'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -80,7 +105,7 @@ router.delete(
|
||||
|
||||
/**
|
||||
* PATCH /api/classes/:id/class-account - 设置 / 重置班级账号(班级码 + 班级密码)
|
||||
* 仅管理员:学生端「班级登录」用的就是这个班级码
|
||||
* 仅管理员:教室机器「ClassHelper 班级端」登录用的就是这个班级码
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/class-account',
|
||||
@@ -98,8 +123,8 @@ router.patch(
|
||||
);
|
||||
|
||||
/**
|
||||
* PATCH /api/classes/:id/head-teacher - 设置 / 更改班主任
|
||||
* 仅管理员:班主任决定谁能管这个班的课表与成绩,属于人员分配权限
|
||||
* PATCH /api/classes/:id/head-teacher - 设置 / 更改班主任(每班 1 人)
|
||||
* 仅管理员:班主任决定谁能管这个班的学生名单与课表,属于人员分配权限
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/head-teacher',
|
||||
@@ -114,9 +139,31 @@ router.patch(
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/classes/:id/classisland-status - 本班 ClassIsland 联动状态
|
||||
* PUT /api/classes/:id/subject-teachers - 设置各科任课老师(班级 + 科目 + 教师)
|
||||
*
|
||||
* 教室机器(班级账号)也能读:客户端「设置 → ClassIsland 联动」据此显示"已接入 / 未接入"。
|
||||
* 仅管理员。这是「科任老师」这条关系的唯一写入口 —— 作业与成绩能不能被某位老师改动,
|
||||
* 查的就是这里写下的 `Course.teacherId`。
|
||||
* 班主任同时是某科科任老师时,两条记录指向同一个账号:不重复建号、权限自动合并。
|
||||
*/
|
||||
router.put(
|
||||
'/:id/subject-teachers',
|
||||
requireRole('ADMIN'),
|
||||
validate({ params: idParamSchema, body: assignSubjectTeachersSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await classService.assignSubjectTeachers(user, id, validatedBody<AssignSubjectTeachersInput>(req)),
|
||||
'任课老师已更新',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/classes/:id/classisland-status - 本班 ClassHelper 班级端 / ClassIsland 联动状态
|
||||
*
|
||||
* 教室机器(ClassHelper 班级端)也能读:客户端「设置 → ClassIsland 联动」据此显示状态。
|
||||
*/
|
||||
router.get('/:id/classisland-status', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -127,7 +174,7 @@ router.get('/:id/classisland-status', validate({ params: idParamSchema }), async
|
||||
/**
|
||||
* GET /api/classes/:id/notification-channel - 读取"通知显示到哪个端"
|
||||
*
|
||||
* 教室的班级客户端登录后会拉这个值,据此决定自己是弹窗/上岛还是静默;
|
||||
* 教室的 ClassHelper 班级端登录后会拉这个值,据此决定自己是弹窗/上岛还是静默;
|
||||
* 教师/管理员也能读(Web 端展示用)。
|
||||
*/
|
||||
router.get('/:id/notification-channel', validate({ params: idParamSchema }), async (req, res) => {
|
||||
@@ -139,8 +186,7 @@ router.get('/:id/notification-channel', validate({ params: idParamSchema }), asy
|
||||
/**
|
||||
* PATCH /api/classes/:id/notification-channel - 设置"通知显示到哪个端"
|
||||
*
|
||||
* **由教室的班级客户端自己选**(班级账号可改),教师/管理员也可改;
|
||||
* 普通学生账号被拒(见 service 内的断言)。
|
||||
* **由教室的 ClassHelper 班级端自己选**,教师/管理员也可改。
|
||||
*/
|
||||
router.patch(
|
||||
'/:id/notification-channel',
|
||||
@@ -160,6 +206,34 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/classes/:id/student-grade-query - 本班是否允许班级端按学号查成绩明细
|
||||
* PATCH 同路径 - 开关它(班级端自己可改,教师/管理员也可改)
|
||||
*/
|
||||
router.get('/:id/student-grade-query', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(res, await classService.getStudentGradeQuery(user, id), '获取成绩查询开关成功');
|
||||
});
|
||||
|
||||
router.patch(
|
||||
'/:id/student-grade-query',
|
||||
validate({ params: idParamSchema, body: updateStudentGradeQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await classService.updateStudentGradeQuery(
|
||||
user,
|
||||
id,
|
||||
validatedBody<UpdateStudentGradeQueryInput>(req),
|
||||
),
|
||||
'成绩查询开关已更新',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/classes/:id/students - 班级学生名单 */
|
||||
router.get('/:id/students', validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -167,7 +241,7 @@ router.get('/:id/students', validate({ params: idParamSchema }), async (req, res
|
||||
sendOk(res, await classService.listClassStudents(user, id), '获取学生名单成功');
|
||||
});
|
||||
|
||||
/** POST /api/classes/:id/students - 添加学生(支持已存在账号转入) */
|
||||
/** POST /api/classes/:id/students - 把学生加入班级(按学号,已存在则转入并留调班痕迹) */
|
||||
router.post(
|
||||
'/:id/students',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
@@ -179,43 +253,16 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/classes/:id/students/:userId - 移出学生 */
|
||||
/** DELETE /api/classes/:id/students/:studentId - 移出学生(回到「未分班」,名单记录保留) */
|
||||
router.delete(
|
||||
'/:id/students/:userId',
|
||||
'/:id/students/:studentId',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: classStudentParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id, userId } = validatedParams<{ id: string; userId: string }>(req);
|
||||
await classService.removeStudent(user, id, userId);
|
||||
sendOk(res, { classId: id, userId }, '学生已移出班级');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/classes/:id/teachers - 分配协作教师 */
|
||||
router.post(
|
||||
'/:id/teachers',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema, body: assignTeacherSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const { teacherId } = req.body as { teacherId: string };
|
||||
await classService.assignTeacher(user, id, teacherId);
|
||||
sendCreated(res, { classId: id, teacherId }, '教师已分配');
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/classes/:id/teachers/:teacherId - 取消协作教师 */
|
||||
router.delete(
|
||||
'/:id/teachers/:teacherId',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: classTeacherParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id, teacherId } = validatedParams<{ id: string; teacherId: string }>(req);
|
||||
await classService.removeTeacher(user, id, teacherId);
|
||||
sendOk(res, { classId: id, teacherId }, '已取消教师分配');
|
||||
const { id, studentId } = validatedParams<{ id: string; studentId: string }>(req);
|
||||
await classService.removeStudent(user, id, studentId);
|
||||
sendOk(res, { classId: id, studentId }, '学生已移出班级');
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
import { CLASSISLAND_NOTIFICATION_CHANNELS, MAX_TERM_WEEK } from '@classhelper/shared';
|
||||
import { keywordSchema } from '../../lib/schemas.js';
|
||||
import { CLASSISLAND_NOTIFICATION_CHANNELS, MAX_TERM_WEEK, SUBJECT_CATALOG, isDayKey } from '@classhelper/shared';
|
||||
import {
|
||||
classIndexSchema,
|
||||
enrollmentYearSchema,
|
||||
guardianPhoneSchema,
|
||||
keywordSchema,
|
||||
studentGenderSchema,
|
||||
studentNameSchema,
|
||||
studentNoSchema,
|
||||
} from '../../lib/schemas.js';
|
||||
|
||||
export const createClassSchema = z.object({
|
||||
name: z.string().trim().min(1, '请输入班级名称').max(64),
|
||||
/** 显示名由入学年份与班号生成,不收;保留字段仅为兼容旧客户端 */
|
||||
name: z.string().trim().max(64).optional(),
|
||||
grade: z.string().trim().min(1, '请输入年级').max(32),
|
||||
/** 入学年份(届别):2026 → 班级称呼「2026级1班」 */
|
||||
enrollmentYear: enrollmentYearSchema,
|
||||
/** 班号(同一届内的序号) */
|
||||
classIndex: classIndexSchema,
|
||||
/** 仅管理员可指定班主任,教师创建时固定为自己 */
|
||||
teacherId: z.string().min(1).optional(),
|
||||
/** 可选:自定义班级码(学生端班级账号登录用),留空自动生成 */
|
||||
/** 可选:自定义班级码(ClassHelper 班级端登录用),留空自动生成 */
|
||||
code: z
|
||||
.string()
|
||||
.trim()
|
||||
@@ -21,13 +34,29 @@ export const createClassSchema = z.object({
|
||||
termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(),
|
||||
});
|
||||
|
||||
/**
|
||||
* 编辑班级。
|
||||
*
|
||||
* `grade` 是**学年升级**用的:高一 → 高二 只改它,班级记录与学生数据全部沿用,**不归档**。
|
||||
* `enrollmentYear` / `classIndex` 是给"有名无实的旧数据"补届别用的,改了会重算班级称呼。
|
||||
*/
|
||||
export const updateClassSchema = z.object({
|
||||
name: z.string().trim().min(1).max(64).optional(),
|
||||
grade: z.string().trim().min(1).max(32).optional(),
|
||||
/** 本学期教学周数(1~30,管理员可调) */
|
||||
/** 本班学期开始日期(第 1 教学周的周一);空串 = 回落到全局配置 */
|
||||
termStartDate: z
|
||||
.union([z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' }), z.literal('')])
|
||||
.nullish(),
|
||||
enrollmentYear: enrollmentYearSchema.optional(),
|
||||
classIndex: classIndexSchema.optional(),
|
||||
termWeeks: z.coerce.number().int().min(1).max(MAX_TERM_WEEK).optional(),
|
||||
});
|
||||
|
||||
/** 学年升级(批量改年级,不归档) */
|
||||
export const promoteClassesSchema = z.object({
|
||||
classIds: z.array(z.string().min(1)).min(1, '请至少选择一个班级').max(200),
|
||||
grade: z.string().trim().min(1, '请输入升级后的年级').max(32),
|
||||
});
|
||||
|
||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||
export const updateClassAccountSchema = z
|
||||
.object({
|
||||
@@ -46,13 +75,38 @@ export const listClassesQuerySchema = z.object({
|
||||
keyword: keywordSchema,
|
||||
});
|
||||
|
||||
/** 往班级名单里加一名学生(学生没有账号,只有学号与姓名) */
|
||||
export const addStudentSchema = z.object({
|
||||
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
||||
name: z.string().trim().min(1, '请输入姓名').max(32),
|
||||
studentNo: studentNoSchema,
|
||||
name: studentNameSchema,
|
||||
gender: studentGenderSchema,
|
||||
guardianPhone: guardianPhoneSchema,
|
||||
});
|
||||
|
||||
export const assignTeacherSchema = z.object({
|
||||
teacherId: z.string().min(1, '请选择教师'),
|
||||
/**
|
||||
* 设置某班各科的任课老师(班级 + 科目 + 教师)。
|
||||
*
|
||||
* `subjectName` 用全校统一科目目录里的名字(找不到就自动建课);
|
||||
* `teacherId` 留空表示解除该科的任课老师。
|
||||
*/
|
||||
export const assignSubjectTeachersSchema = z.object({
|
||||
assignments: z
|
||||
.array(
|
||||
z.object({
|
||||
courseId: z.string().min(1).nullish(),
|
||||
subjectName: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请选择或填写科目')
|
||||
.max(32)
|
||||
.refine(
|
||||
(value) => value.length > 0,
|
||||
{ message: '科目名不能为空' },
|
||||
),
|
||||
teacherId: z.string().min(1).nullish(),
|
||||
}),
|
||||
)
|
||||
.max(SUBJECT_CATALOG.length + 20, '科目数量超出预期'),
|
||||
});
|
||||
|
||||
/** 设置 / 更改班主任(仅管理员) */
|
||||
@@ -62,7 +116,7 @@ export const assignHeadTeacherSchema = z.object({
|
||||
|
||||
/**
|
||||
* 通知显示位置(both / client / classisland)。
|
||||
* 由**教室的班级客户端**在设置页里改,教师/管理员也可以在 Web 端改。
|
||||
* 由 **ClassHelper 班级端**在设置页里改,教师/管理员也可以在 Web 端改。
|
||||
*/
|
||||
export const updateNotificationChannelSchema = z.object({
|
||||
notificationChannel: z.enum(
|
||||
@@ -71,18 +125,20 @@ export const updateNotificationChannelSchema = z.object({
|
||||
),
|
||||
});
|
||||
|
||||
export const classStudentParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
userId: z.string().min(1),
|
||||
/** 班级端按学号查询成绩明细的开关 */
|
||||
export const updateStudentGradeQuerySchema = z.object({
|
||||
enabled: z.boolean(),
|
||||
});
|
||||
|
||||
export const classTeacherParamSchema = z.object({
|
||||
export const classStudentParamSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
teacherId: z.string().min(1),
|
||||
studentId: z.string().min(1),
|
||||
});
|
||||
|
||||
export type CreateClassInput = z.infer<typeof createClassSchema>;
|
||||
export type UpdateClassInput = z.infer<typeof updateClassSchema>;
|
||||
export type UpdateClassAccountInput = z.infer<typeof updateClassAccountSchema>;
|
||||
export type UpdateNotificationChannelInput = z.infer<typeof updateNotificationChannelSchema>;
|
||||
export type UpdateStudentGradeQueryInput = z.infer<typeof updateStudentGradeQuerySchema>;
|
||||
export type AddStudentInput = z.infer<typeof addStudentSchema>;
|
||||
export type AssignSubjectTeachersInput = z.infer<typeof assignSubjectTeachersSchema>;
|
||||
@@ -3,14 +3,17 @@ import type {
|
||||
ClassDto,
|
||||
ClassIslandNotificationChannel,
|
||||
StudentDto,
|
||||
SubjectTeacherDto,
|
||||
} from '@classhelper/shared';
|
||||
import { SOCKET_EVENTS, formatClassName } from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import {
|
||||
assertClassAccess,
|
||||
assertCanAssignTeachers,
|
||||
assertCanManageClasses,
|
||||
assertCanManageRoster,
|
||||
assertClassAccess,
|
||||
classScopeIdFilter,
|
||||
isClassDevice,
|
||||
isStaff,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
@@ -29,16 +32,16 @@ import {
|
||||
toTeacherBrief,
|
||||
} from '../../lib/mappers.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||
import type {
|
||||
AddStudentInput,
|
||||
AssignSubjectTeachersInput,
|
||||
CreateClassInput,
|
||||
UpdateClassInput,
|
||||
UpdateNotificationChannelInput,
|
||||
UpdateStudentGradeQueryInput,
|
||||
} from './classes.schemas.js';
|
||||
import { attachIntegrationFlag } from '../integrations/integrations.service.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { isClassSession } from '../../lib/session.js';
|
||||
|
||||
const countSelect = {
|
||||
students: true,
|
||||
@@ -47,6 +50,8 @@ const countSelect = {
|
||||
notifications: true,
|
||||
} as const;
|
||||
|
||||
const teacherBriefSelect = { select: { id: true, name: true, username: true } } as const;
|
||||
|
||||
/**
|
||||
* 说明:班级码 code 与班级密码哈希 passwordHash 是 Class 的标量字段,
|
||||
* 使用 include 查询时会默认返回,无需(也不能)写进 include 里。
|
||||
@@ -56,7 +61,7 @@ const countSelect = {
|
||||
/**
|
||||
* 是否可以看到该班级的班级账号信息:
|
||||
* 管理员(管理全部班级)或该班级的班主任(要发给学生用,只读展示)。
|
||||
* 科任老师与学生都看不到班级码。
|
||||
* 科任老师与 ClassHelper 班级端都看不到别人班的班级码。
|
||||
*/
|
||||
function canSeeClassAccount(user: TokenPayload, item: { teacherId: string }): boolean {
|
||||
if (user.role === 'ADMIN') return true;
|
||||
@@ -77,55 +82,92 @@ function toClassDtoWithAccount(
|
||||
};
|
||||
}
|
||||
|
||||
/** 班级列表(按权限过滤) */
|
||||
/**
|
||||
* 班级列表(按权限过滤)。
|
||||
*
|
||||
* **默认只列在读班级**(`archivedYearId IS NULL`):毕业归档的班级属于「归档管理」页,
|
||||
* 混在常规列表里会让老师误以为那届还在上课。需要看归档班级请用 `/api/archives`。
|
||||
*/
|
||||
export async function listClasses(user: TokenPayload, keyword?: string): Promise<ClassDto[]> {
|
||||
const scope = await resolveClassScope(user);
|
||||
// Class 表按主键 id 过滤(classScopeWhere 针对的是带 classId 字段的子表)
|
||||
const where = {
|
||||
...classScopeIdFilter(scope),
|
||||
archivedYearId: null,
|
||||
...(keyword ? { OR: [{ name: { contains: keyword } }, { grade: { contains: keyword } }] } : {}),
|
||||
};
|
||||
|
||||
const classes = await prisma.class.findMany({
|
||||
where,
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
teacher: teacherBriefSelect,
|
||||
_count: { select: countSelect },
|
||||
},
|
||||
orderBy: [{ grade: 'asc' }, { name: 'asc' }],
|
||||
orderBy: [{ enrollmentYear: 'desc' }, { classIndex: 'asc' }, { grade: 'asc' }],
|
||||
});
|
||||
|
||||
// 附上「是否已接入 ClassIsland」徽标:Web 端班级列表/课表页据此提示
|
||||
return attachIntegrationFlag(classes.map((item) => toClassDtoWithAccount(user, item)));
|
||||
}
|
||||
|
||||
/** 班级详情:学生名单 + 课程 + 协作教师 */
|
||||
/** 班级详情:学生名单 + 课程 + 班主任 + 各科任课老师 */
|
||||
export async function getClassDetail(user: TokenPayload, classId: string): Promise<ClassDetailDto> {
|
||||
await assertClassAccess(user, classId);
|
||||
|
||||
const item = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
teacher: teacherBriefSelect,
|
||||
_count: { select: countSelect },
|
||||
students: { orderBy: { username: 'asc' } },
|
||||
students: { orderBy: { studentNo: 'asc' } },
|
||||
courses: {
|
||||
orderBy: { name: 'asc' },
|
||||
include: { teacher: { select: { id: true, name: true, username: true } } },
|
||||
},
|
||||
teachers: {
|
||||
include: { teacher: { select: { id: true, name: true, username: true } } },
|
||||
orderBy: { createdAt: 'asc' },
|
||||
include: { teacher: teacherBriefSelect },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!item) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const base = toClassDtoWithAccount(user, item);
|
||||
return {
|
||||
...toClassDtoWithAccount(user, item),
|
||||
students: item.students.map((student) => toStudentDto(student)),
|
||||
...base,
|
||||
students: item.students.map(toStudentDto),
|
||||
courses: item.courses.map(toCourseDto),
|
||||
teachers: item.teachers.map((assignment) => toTeacherBrief(assignment.teacher)),
|
||||
headTeacher: item.teacher ? toTeacherBrief(item.teacher) : null,
|
||||
subjectTeachers: item.courses.map((course) => ({
|
||||
courseId: course.id,
|
||||
subjectName: course.name,
|
||||
teacherId: course.teacherId,
|
||||
teacherName: course.teacher?.name ?? '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 班级的任课老师表(班级 + 科目 + 教师)。
|
||||
* 与 `getClassDetail` 分开,是因为「ClassHelper 在线状态」等页面只需要这一小块。
|
||||
*/
|
||||
export async function listSubjectTeachers(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
): Promise<{ headTeacher: SubjectTeacherDto | null; subjectTeachers: SubjectTeacherDto[] }> {
|
||||
await assertClassAccess(user, classId);
|
||||
const item = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
include: {
|
||||
teacher: teacherBriefSelect,
|
||||
courses: { orderBy: { name: 'asc' }, include: { teacher: teacherBriefSelect } },
|
||||
},
|
||||
});
|
||||
if (!item) throw ApiError.notFound('班级不存在');
|
||||
return {
|
||||
headTeacher: null,
|
||||
subjectTeachers: item.courses.map((course) => ({
|
||||
courseId: course.id,
|
||||
subjectName: course.name,
|
||||
teacherId: course.teacherId,
|
||||
teacherName: course.teacher?.name ?? '',
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -134,28 +176,29 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
|
||||
assertCanManageClasses(user);
|
||||
let teacherId = user.sub;
|
||||
if (input.teacherId && user.role === 'ADMIN') {
|
||||
const teacher = await prisma.user.findUnique({ where: { id: input.teacherId } });
|
||||
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
|
||||
throw ApiError.badRequest('指定的班主任不存在或角色不是教师');
|
||||
}
|
||||
teacherId = teacher.id;
|
||||
teacherId = await requireTeacher(input.teacherId, '指定的班主任不存在或角色不是教师');
|
||||
}
|
||||
|
||||
// 班级账号:自动生成班级码(也可由管理员指定)+ 默认班级密码
|
||||
const account = await buildClassAccountCreateData(env.defaultClassPassword, input.code);
|
||||
|
||||
// 称呼一律由「入学年份 + 班号」生成:名字与届别必须一致,
|
||||
// 否则会出现"名字是高一(1)班、届别却写 2026 级"这种自相矛盾的数据
|
||||
const name = formatClassName(input.enrollmentYear, input.classIndex);
|
||||
|
||||
const created = await prisma.class.create({
|
||||
data: {
|
||||
name: input.name,
|
||||
name,
|
||||
grade: input.grade,
|
||||
enrollmentYear: input.enrollmentYear,
|
||||
classIndex: input.classIndex,
|
||||
teacherId,
|
||||
code: account.code,
|
||||
passwordHash: account.passwordHash,
|
||||
// 新建时就能指定学期周数(之前只有编辑接口支持,Web 端表单里填了也会被丢弃)
|
||||
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
||||
},
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
teacher: teacherBriefSelect,
|
||||
_count: { select: countSelect },
|
||||
},
|
||||
});
|
||||
@@ -166,8 +209,9 @@ export async function createClass(user: TokenPayload, input: CreateClassInput):
|
||||
/**
|
||||
* 设置 / 更改班主任(仅管理员)。
|
||||
*
|
||||
* 班主任决定了"谁能管这个班的课表与成绩",所以更换时要保证目标账号确实是教师/管理员;
|
||||
* 原班主任保留在协作教师名单里(`ClassTeacher`)不变,避免换人后丢掉历史协作关系。
|
||||
* 班主任决定了"谁能管这个班的学生名单与课表",所以更换时要保证目标账号确实是教师/管理员。
|
||||
* 科任关系**不受影响** —— 它在 `Course` 上,是独立的一条条记录;
|
||||
* 同一位老师可以同时是班主任与某科科任,系统里只有一个账号,权限在 resolveClassRole 里合并。
|
||||
*/
|
||||
export async function updateHeadTeacher(
|
||||
user: TokenPayload,
|
||||
@@ -175,17 +219,13 @@ export async function updateHeadTeacher(
|
||||
teacherId: string,
|
||||
): Promise<ClassDto> {
|
||||
assertCanAssignTeachers(user);
|
||||
|
||||
const target = await prisma.user.findUnique({ where: { id: teacherId } });
|
||||
if (!target || (target.role !== 'TEACHER' && target.role !== 'ADMIN')) {
|
||||
throw ApiError.badRequest('指定的班主任不存在或角色不是教师');
|
||||
}
|
||||
const targetId = await requireTeacher(teacherId, '指定的班主任不存在或角色不是教师');
|
||||
|
||||
const updated = await prisma.class.update({
|
||||
where: { id: classId },
|
||||
data: { teacherId: target.id },
|
||||
data: { teacherId: targetId },
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
teacher: teacherBriefSelect,
|
||||
_count: { select: countSelect },
|
||||
},
|
||||
});
|
||||
@@ -194,9 +234,64 @@ export async function updateHeadTeacher(
|
||||
return toClassDtoWithAccount(user, updated);
|
||||
}
|
||||
|
||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员。
|
||||
* 班级码即学生端"班级登录"的账号;密码留空表示不修改。
|
||||
/**
|
||||
* 批量设置各科科任老师(仅管理员)。
|
||||
*
|
||||
* 入参里每条是「某班某科的任课老师」:
|
||||
* - 给了 `courseId` 就改那门课;
|
||||
* - 只给 `subjectName` 时按名字找,找不到就**自动建课**(复用全校统一科目目录的习惯做法);
|
||||
* - `teacherId` 留空表示**解除**该科的任课老师(课程本身保留,作业与成绩仍挂在它上面)。
|
||||
*
|
||||
* 班主任同时是科任老师时,这里指向的还是同一个 `User` —— 不重复建号、不产生权限冲突。
|
||||
*/
|
||||
export async function assignSubjectTeachers(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
input: AssignSubjectTeachersInput,
|
||||
): Promise<SubjectTeacherDto[]> {
|
||||
assertCanAssignTeachers(user);
|
||||
const targetClass = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
|
||||
if (!targetClass) throw ApiError.notFound('班级不存在');
|
||||
|
||||
for (const assignment of input.assignments) {
|
||||
const teacherId = assignment.teacherId
|
||||
? await requireTeacher(assignment.teacherId, `「${assignment.subjectName}」的任课老师不存在或不是教师`)
|
||||
: // 解除任课关系时把课程挂回班主任,Course.teacherId 是必填列
|
||||
(await prisma.class.findUniqueOrThrow({ where: { id: classId }, select: { teacherId: true } }))
|
||||
.teacherId;
|
||||
|
||||
const existing = assignment.courseId
|
||||
? await prisma.course.findUnique({ where: { id: assignment.courseId }, select: { id: true, classId: true } })
|
||||
: await prisma.course.findUnique({
|
||||
where: { classId_name: { classId, name: assignment.subjectName } },
|
||||
select: { id: true, classId: true },
|
||||
});
|
||||
|
||||
if (existing && existing.classId !== classId) {
|
||||
throw ApiError.badRequest(`课程「${assignment.subjectName}」不属于该班级`);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await prisma.course.update({ where: { id: existing.id }, data: { teacherId } });
|
||||
} else {
|
||||
await prisma.course.create({ data: { name: assignment.subjectName, classId, teacherId } });
|
||||
}
|
||||
}
|
||||
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
const refreshed = await prisma.class.findUniqueOrThrow({
|
||||
where: { id: classId },
|
||||
include: { courses: { orderBy: { name: 'asc' }, include: { teacher: teacherBriefSelect } } },
|
||||
});
|
||||
return refreshed.courses.map((course) => ({
|
||||
courseId: course.id,
|
||||
subjectName: course.name,
|
||||
teacherId: course.teacherId,
|
||||
teacherName: course.teacher?.name ?? '',
|
||||
}));
|
||||
}
|
||||
|
||||
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
|
||||
export async function updateClassAccount(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
@@ -208,7 +303,7 @@ export async function updateClassAccount(
|
||||
const updated = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
teacher: teacherBriefSelect,
|
||||
_count: { select: countSelect },
|
||||
},
|
||||
});
|
||||
@@ -216,7 +311,7 @@ export async function updateClassAccount(
|
||||
return toClassDtoWithAccount(user, updated);
|
||||
}
|
||||
|
||||
/** 编辑班级 */
|
||||
/** 编辑班级(年级 / 教学周数 / 届别) */
|
||||
export async function updateClass(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
@@ -224,16 +319,31 @@ export async function updateClass(
|
||||
): Promise<ClassDto> {
|
||||
assertCanManageClasses(user);
|
||||
|
||||
const current = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { enrollmentYear: true, classIndex: true },
|
||||
});
|
||||
if (!current) throw ApiError.notFound('班级不存在');
|
||||
|
||||
const enrollmentYear = input.enrollmentYear ?? current.enrollmentYear;
|
||||
const classIndex = input.classIndex ?? current.classIndex;
|
||||
|
||||
const updated = await prisma.class.update({
|
||||
where: { id: classId },
|
||||
data: {
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
...(input.grade ? { grade: input.grade } : {}),
|
||||
// 教学周数(仅管理员可调,见 assertCanManageClasses):课表周次选择与默认 weekEnd 都用它
|
||||
...(input.termWeeks !== undefined ? { termWeeks: input.termWeeks } : {}),
|
||||
// 学期开始日期:空串/null 都表示"回落到全局配置"(如 .env 的 TERM_START_DATE)
|
||||
...(input.termStartDate !== undefined
|
||||
? { termStartDate: input.termStartDate ? input.termStartDate : null }
|
||||
: {}),
|
||||
...(input.enrollmentYear !== undefined ? { enrollmentYear } : {}),
|
||||
...(input.classIndex !== undefined ? { classIndex } : {}),
|
||||
// 届别齐了就把称呼重算一遍:班级称呼的唯一来源是「入学年份 + 班号」
|
||||
...(enrollmentYear && classIndex ? { name: formatClassName(enrollmentYear, classIndex) } : {}),
|
||||
},
|
||||
include: {
|
||||
teacher: { select: { id: true, name: true, username: true } },
|
||||
teacher: teacherBriefSelect,
|
||||
_count: { select: countSelect },
|
||||
},
|
||||
});
|
||||
@@ -242,7 +352,28 @@ export async function updateClass(
|
||||
return toClassDto(updated);
|
||||
}
|
||||
|
||||
/** 删除班级(级联删除课表/作业/通知/成绩) */
|
||||
/**
|
||||
* 学年升级(仅管理员):把若干班级的年级改掉,**不归档、数据沿用**。
|
||||
*
|
||||
* 这是「未毕业而升级的班级不归档」这条例则的操作入口 —— 高一 → 高二 只动 `grade`,
|
||||
* 班级记录、学生名单、学号、作业与成绩全部原样。归档只在毕业时由归档页显式执行。
|
||||
*/
|
||||
export async function promoteClasses(
|
||||
user: TokenPayload,
|
||||
input: { classIds: string[]; grade: string },
|
||||
): Promise<{ promoted: number }> {
|
||||
assertCanManageClasses(user);
|
||||
const result = await prisma.class.updateMany({
|
||||
where: { id: { in: input.classIds }, archivedYearId: null },
|
||||
data: { grade: input.grade },
|
||||
});
|
||||
for (const classId of input.classIds) {
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
}
|
||||
return { promoted: result.count };
|
||||
}
|
||||
|
||||
/** 删除班级(级联删除课表/作业/通知/成绩;学生记录保留但解除班级归属) */
|
||||
export async function deleteClass(user: TokenPayload, classId: string): Promise<void> {
|
||||
assertCanManageClasses(user);
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'deleted' });
|
||||
@@ -252,61 +383,68 @@ export async function deleteClass(user: TokenPayload, classId: string): Promise<
|
||||
/** 班级学生名单 */
|
||||
export async function listClassStudents(user: TokenPayload, classId: string): Promise<StudentDto[]> {
|
||||
await assertClassAccess(user, classId);
|
||||
const students = await prisma.user.findMany({
|
||||
where: { classId, role: 'STUDENT' },
|
||||
const students = await prisma.student.findMany({
|
||||
where: { classId, archivedYearId: null },
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
orderBy: { username: 'asc' },
|
||||
orderBy: { studentNo: 'asc' },
|
||||
});
|
||||
return students.map(toStudentDto);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加学生到班级(名单实体)。
|
||||
* 已存在的学生直接转入本班(同时维护 Enrollment,保证"一个学生一个主班级")。
|
||||
* 学生没有账号属性:不设密码、不能登录,学生端统一用班级码 + 班级密码。
|
||||
* 添加学生到班级(名单记录)。
|
||||
*
|
||||
* 已存在的学生(按学号)直接转入本班,并记一条调班历史;
|
||||
* 学生没有账号属性:不设密码、不能登录,教室机器用班级码 + 班级密码。
|
||||
*/
|
||||
export async function addStudent(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
input: AddStudentInput,
|
||||
): Promise<StudentDto> {
|
||||
assertCanManageRoster(user);
|
||||
await assertCanManageRoster(user, classId);
|
||||
await assertClassAccess(user, classId);
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username: input.username } });
|
||||
const existing = await prisma.student.findUnique({ where: { studentNo: input.studentNo } });
|
||||
let studentId: string;
|
||||
|
||||
if (existing) {
|
||||
if (existing.role !== 'STUDENT') {
|
||||
throw ApiError.conflict(`用户名 ${input.username} 已被教师/管理员账号占用`);
|
||||
}
|
||||
studentId = existing.id;
|
||||
// 换班:清理其他班级的选课记录
|
||||
await prisma.enrollment.deleteMany({ where: { userId: existing.id, classId: { not: classId } } });
|
||||
await prisma.user.update({
|
||||
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,
|
||||
toClassName: await classLabel(classId),
|
||||
operatorId: user.sub,
|
||||
operatorName: user.name,
|
||||
mode: 'single',
|
||||
note: '在班级名单里直接加入',
|
||||
},
|
||||
});
|
||||
}
|
||||
await prisma.student.update({
|
||||
where: { id: existing.id },
|
||||
data: { classId, name: input.name },
|
||||
data: { classId, name: input.name, status: 'active' },
|
||||
});
|
||||
} else {
|
||||
const created = await prisma.user.create({
|
||||
const created = await prisma.student.create({
|
||||
data: {
|
||||
username: input.username,
|
||||
studentNo: input.studentNo,
|
||||
name: input.name,
|
||||
role: 'STUDENT',
|
||||
classId,
|
||||
// 空串占位:User.passwordHash 必填,但学生的 403 登录判定在密码校验之前,永远用不到
|
||||
passwordHash: '',
|
||||
gender: input.gender ?? '',
|
||||
guardianPhone: input.guardianPhone ?? '',
|
||||
},
|
||||
});
|
||||
studentId = created.id;
|
||||
}
|
||||
|
||||
await prisma.enrollment.upsert({
|
||||
where: { userId_classId: { userId: studentId, classId } },
|
||||
create: { userId: studentId, classId },
|
||||
update: {},
|
||||
});
|
||||
|
||||
const student = await prisma.user.findUniqueOrThrow({
|
||||
const student = await prisma.student.findUniqueOrThrow({
|
||||
where: { id: studentId },
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
});
|
||||
@@ -315,55 +453,41 @@ export async function addStudent(
|
||||
return toStudentDto(student);
|
||||
}
|
||||
|
||||
/** 将学生移出班级(保留账号) */
|
||||
/** 将学生移出班级(保留名单记录,回到「未分班」) */
|
||||
export async function removeStudent(user: TokenPayload, classId: string, studentId: string): Promise<void> {
|
||||
assertCanManageRoster(user);
|
||||
await assertCanManageRoster(user, classId);
|
||||
|
||||
const student = await prisma.user.findUnique({ where: { id: studentId } });
|
||||
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
|
||||
const student = await prisma.student.findUnique({ where: { id: studentId } });
|
||||
if (!student) throw ApiError.notFound('学生不存在');
|
||||
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于此班级');
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({ where: { id: studentId }, data: { classId: null } }),
|
||||
prisma.enrollment.deleteMany({ where: { userId: studentId, classId } }),
|
||||
]);
|
||||
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' });
|
||||
}
|
||||
|
||||
/** 分配协作教师 */
|
||||
export async function assignTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
|
||||
assertCanAssignTeachers(user);
|
||||
|
||||
const teacher = await prisma.user.findUnique({ where: { id: teacherId } });
|
||||
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
|
||||
throw ApiError.badRequest('目标账号不存在或角色不是教师');
|
||||
}
|
||||
|
||||
await prisma.classTeacher.upsert({
|
||||
where: { classId_teacherId: { classId, teacherId } },
|
||||
create: { classId, teacherId },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
/** 取消协作教师(班主任不可被移除) */
|
||||
export async function removeTeacher(user: TokenPayload, classId: string, teacherId: string): Promise<void> {
|
||||
assertCanAssignTeachers(user);
|
||||
|
||||
const target = await prisma.class.findUnique({ where: { id: classId }, select: { teacherId: true } });
|
||||
if (target?.teacherId === teacherId) throw ApiError.badRequest('班主任不能被移除,请先转移班级');
|
||||
|
||||
await prisma.classTeacher.deleteMany({ where: { classId, teacherId } });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- ClassIsland 联动状态 */
|
||||
/* ---------------------------------------------------------------- ClassHelper 班级端状态 */
|
||||
|
||||
/**
|
||||
* 本班的 ClassIsland 联动状态(教室客户端的「ClassIsland 联动」面板展示)。
|
||||
* 本班的 ClassHelper 联动状态(教室机器上的「联动」面板展示)。
|
||||
*
|
||||
* 为什么单独开一个接口:设备列表(/integrations/devices)是管理面,只对教师/管理员开放;
|
||||
* 而教室机器(班级账号)需要知道"本班接没接 ClassIsland、最后什么时候上报的",
|
||||
* 而教室机器(ClassHelper 班级端)需要知道"本班接没接 ClassIsland、最后什么时候上报的",
|
||||
* 才能给出准确的提示(例如没接就别选"只在 ClassIsland 上弹")。
|
||||
*/
|
||||
export async function getClassIslandStatus(
|
||||
@@ -373,35 +497,46 @@ export async function getClassIslandStatus(
|
||||
connected: boolean;
|
||||
deviceName: string | null;
|
||||
deviceCount: number;
|
||||
onlineCount: number;
|
||||
lastSeenAt: string | null;
|
||||
lastHeartbeatAt: string | null;
|
||||
pluginVersion: string | null;
|
||||
classIslandVersion: string | null;
|
||||
}> {
|
||||
await assertClassAccess(user, classId);
|
||||
const devices = await prisma.integrationDevice.findMany({
|
||||
where: { classId, enabled: true, mode: 'plugin' },
|
||||
select: { name: true, lastSeenAt: true, pluginVersion: true, classIslandVersion: true },
|
||||
orderBy: { lastSeenAt: 'desc' },
|
||||
select: {
|
||||
name: true,
|
||||
lastSeenAt: true,
|
||||
lastHeartbeatAt: true,
|
||||
pluginVersion: true,
|
||||
classIslandVersion: true,
|
||||
},
|
||||
orderBy: { lastHeartbeatAt: 'desc' },
|
||||
});
|
||||
|
||||
const latest = devices[0];
|
||||
const window = 60_000;
|
||||
return {
|
||||
connected: devices.length > 0,
|
||||
deviceName: latest?.name ?? null,
|
||||
deviceCount: devices.length,
|
||||
onlineCount: devices.filter(
|
||||
(item) => item.lastHeartbeatAt && Date.now() - item.lastHeartbeatAt.getTime() < window,
|
||||
).length,
|
||||
lastSeenAt: latest?.lastSeenAt ? latest.lastSeenAt.toISOString() : null,
|
||||
lastHeartbeatAt: latest?.lastHeartbeatAt ? latest.lastHeartbeatAt.toISOString() : null,
|
||||
pluginVersion: latest?.pluginVersion ?? null,
|
||||
classIslandVersion: latest?.classIslandVersion ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 通知显示位置 */
|
||||
/* ---------------------------------------------------------------- 班级端设置 */
|
||||
|
||||
/**
|
||||
* 读取班级的通知显示位置(both / client / classisland)。
|
||||
*
|
||||
* 这是**教室客户端**要读的值:客户端登录后拉一次,把自己的弹窗行为对齐到同一个选择。
|
||||
* 学生也能读自己班的(只读),便于排查"为什么教室没弹"。
|
||||
* 这是**教室机器**要读的值:登录后拉一次,把自己的弹窗行为对齐到同一个选择。
|
||||
*/
|
||||
export async function getNotificationChannel(
|
||||
user: TokenPayload,
|
||||
@@ -418,9 +553,8 @@ export async function getNotificationChannel(
|
||||
/**
|
||||
* 设置班级的通知显示位置。
|
||||
*
|
||||
* 允许两类人改:**该班的班级账号**(教室机器的客户端 —— 需求就是"由客户端自己选")
|
||||
* 允许两类人改:**该班的 ClassHelper 班级端**(需求就是"由教室机器自己选")
|
||||
* 与教师 / 管理员(Web 端也能改,便于老师统一要求)。
|
||||
* 普通学生账号不允许:否则"教室里到底弹哪个端"会变成任意学生都能改的开关。
|
||||
*/
|
||||
export async function updateNotificationChannel(
|
||||
user: TokenPayload,
|
||||
@@ -428,14 +562,77 @@ export async function updateNotificationChannel(
|
||||
input: UpdateNotificationChannelInput,
|
||||
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> {
|
||||
await assertClassAccess(user, classId);
|
||||
if (!isClassSession(user) && !isStaff(user)) {
|
||||
throw ApiError.forbidden('只有班级账号或教师/管理员可以修改通知显示位置');
|
||||
if (!isClassDevice(user) && !isStaff(user)) {
|
||||
throw ApiError.forbidden('只有 ClassHelper 班级端或教师/管理员可以修改通知显示位置');
|
||||
}
|
||||
|
||||
const channel = toNotificationChannel(input.notificationChannel);
|
||||
await prisma.class.update({ where: { id: classId }, data: { notificationChannel: channel } });
|
||||
// 广播出去:其他端(Web 列表 / 同班客户端)能立刻看到这个选择变了
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
logger.info(`班级通知显示位置已更新:classId=${classId} channel=${channel} by=${user.sub}`);
|
||||
return { notificationChannel: channel };
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取「是否允许 ClassHelper 班级端按学号查询本班学生成绩明细」。
|
||||
* 班级端设置页用它渲染开关的当前状态。
|
||||
*/
|
||||
export async function getStudentGradeQuery(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
): Promise<{ studentGradeQueryEnabled: boolean }> {
|
||||
await assertClassAccess(user, classId);
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: classId },
|
||||
select: { studentGradeQueryEnabled: true },
|
||||
});
|
||||
if (!record) throw ApiError.notFound('班级不存在');
|
||||
// 老库补列时的默认值也是 true,两边保持一致
|
||||
return { studentGradeQueryEnabled: record.studentGradeQueryEnabled ?? true };
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置「是否允许 ClassHelper 班级端按学号查询本班学生成绩明细」。
|
||||
*
|
||||
* 与通知显示位置同一套模式:**班级端自己改**(教室里老师最清楚要不要给学生看),
|
||||
* 教师 / 管理员在 Web 端也能改。关闭后班级端查成绩一律 403,教师端不受影响。
|
||||
*/
|
||||
export async function updateStudentGradeQuery(
|
||||
user: TokenPayload,
|
||||
classId: string,
|
||||
input: UpdateStudentGradeQueryInput,
|
||||
): Promise<{ studentGradeQueryEnabled: boolean }> {
|
||||
await assertClassAccess(user, classId);
|
||||
if (!isClassDevice(user) && !isStaff(user)) {
|
||||
throw ApiError.forbidden('只有 ClassHelper 班级端或教师/管理员可以修改成绩查询开关');
|
||||
}
|
||||
|
||||
await prisma.class.update({
|
||||
where: { id: classId },
|
||||
data: { studentGradeQueryEnabled: input.enabled },
|
||||
});
|
||||
emitToClass(classId, SOCKET_EVENTS.classUpdated, { classId, action: 'updated' });
|
||||
logger.info(`班级端成绩查询开关:classId=${classId} enabled=${input.enabled} by=${user.sub}`);
|
||||
return { studentGradeQueryEnabled: input.enabled };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 内部工具 */
|
||||
|
||||
/** 校验目标账号确实是教师/管理员,返回其 id */
|
||||
async function requireTeacher(teacherId: string, message: string): Promise<string> {
|
||||
const teacher = await prisma.user.findUnique({
|
||||
where: { id: teacherId },
|
||||
select: { id: true, role: true },
|
||||
});
|
||||
if (!teacher || (teacher.role !== 'TEACHER' && teacher.role !== 'ADMIN')) {
|
||||
throw ApiError.badRequest(message);
|
||||
}
|
||||
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 ?? '';
|
||||
}
|
||||
@@ -3,9 +3,9 @@ import {
|
||||
assertClassAccess,
|
||||
classScopeIdFilter,
|
||||
classScopeWhere,
|
||||
isStudent,
|
||||
isClassDevice,
|
||||
resolveClassScope,
|
||||
requireStudentClassId,
|
||||
requireClassDeviceClassId,
|
||||
} from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { MAX_TERM_WEEK } from '../../lib/term.js';
|
||||
@@ -16,20 +16,16 @@ import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||
const courseSelect = { select: { id: true, name: true } } as const;
|
||||
const creatorSelect = { select: { id: true, name: true, username: true } } as const;
|
||||
|
||||
/**
|
||||
* 仪表盘汇总。
|
||||
* 教师/管理员看到的是"自己管理的班级"的汇总;学生看到自己班级的汇总。
|
||||
*/
|
||||
/**
|
||||
* 本班教学周数(默认 20)。
|
||||
* - 学生 / 班级会话:固定为自己所在班级;
|
||||
* - ClassHelper 班级端:固定为自己绑定的班级;
|
||||
* - 教师 / 管理员:优先用显式 classId,**并且必须通过班级访问校验**(与其他接口同口径,
|
||||
* 否则任意登录用户都能拿 classId 探测别班的教学周数);
|
||||
* - 都不传:全局默认(MAX_TERM_WEEK 兜底)。
|
||||
*/
|
||||
export async function resolveTermWeeks(user: TokenPayload, classId?: string): Promise<number> {
|
||||
if (classId) await assertClassAccess(user, classId);
|
||||
const targetClassId = classId ?? (isStudent(user) ? requireStudentClassId(user) : undefined);
|
||||
const targetClassId = classId ?? (isClassDevice(user) ? requireClassDeviceClassId(user) : undefined);
|
||||
if (!targetClassId) return MAX_TERM_WEEK;
|
||||
const record = await prisma.class.findUnique({
|
||||
where: { id: targetClassId },
|
||||
@@ -37,14 +33,19 @@ export async function resolveTermWeeks(user: TokenPayload, classId?: string): Pr
|
||||
});
|
||||
return record?.termWeeks && record.termWeeks > 0 ? record.termWeeks : MAX_TERM_WEEK;
|
||||
}
|
||||
|
||||
/**
|
||||
* 仪表盘汇总。
|
||||
* 教师/管理员看到的是"自己管理的班级"的汇总;ClassHelper 班级端看到自己绑定班级的汇总。
|
||||
*/
|
||||
export async function getDashboardSummary(user: TokenPayload): Promise<DashboardSummary> {
|
||||
const student = isStudent(user);
|
||||
// 学生强制限定在自己班级,避免越权统计
|
||||
const scope = await resolveClassScope(user, student ? requireStudentClassId(user) : undefined);
|
||||
const device = isClassDevice(user);
|
||||
// 班级端强制限定在自己班级,避免越权统计
|
||||
const scope = await resolveClassScope(user, device ? requireClassDeviceClassId(user) : undefined);
|
||||
// 子表按 classId 过滤;Class 表本身按主键 id 过滤
|
||||
const where = classScopeWhere(scope);
|
||||
const classWhere = classScopeIdFilter(scope);
|
||||
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
|
||||
// ClassHelper 班级端以全班学生为范围
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
const personalWhere = personalIdWhere(personalIds);
|
||||
|
||||
@@ -61,15 +62,16 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
recentNotifications,
|
||||
recentHomeworks,
|
||||
] = await Promise.all([
|
||||
// 学生数只算在读的:已毕业/转出的学生不属于"本班现在有多少人"
|
||||
prisma.class.count({ where: classWhere }),
|
||||
prisma.user.count({ where: { ...where, role: 'STUDENT' } }),
|
||||
prisma.student.count({ where: { ...where, status: 'active', archivedYearId: null } }),
|
||||
prisma.course.count({ where }),
|
||||
prisma.schedule.count({ where }),
|
||||
prisma.homework.count({ where }),
|
||||
prisma.notification.count({ where }),
|
||||
prisma.grade.count({ where }),
|
||||
prisma.notification.count({ where: { ...where, reads: { none: personalWhere } } }),
|
||||
student
|
||||
device
|
||||
? prisma.homework.count({
|
||||
where: { ...where, statuses: { none: { ...personalWhere, completed: true } } },
|
||||
})
|
||||
@@ -85,7 +87,7 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
include: {
|
||||
course: courseSelect,
|
||||
creator: creatorSelect,
|
||||
statuses: student ? { where: personalWhere } : true,
|
||||
statuses: device ? { where: personalWhere } : true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
@@ -103,10 +105,10 @@ export async function getDashboardSummary(user: TokenPayload): Promise<Dashboard
|
||||
unreadNotificationCount,
|
||||
pendingHomeworkCount,
|
||||
recentNotifications: recentNotifications.map((item): NotificationDto =>
|
||||
toNotificationDto(item, { userIds: personalIds }),
|
||||
toNotificationDto(item, { studentIds: personalIds }),
|
||||
),
|
||||
recentHomeworks: recentHomeworks.map((item): HomeworkDto =>
|
||||
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
|
||||
toHomeworkDto(item, { studentIds: device ? personalIds : [], withStatus: !device }),
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -9,22 +9,50 @@ import {
|
||||
createGradeSchema,
|
||||
listGradesQuerySchema,
|
||||
statsQuerySchema,
|
||||
studentGradeParamSchema,
|
||||
studentGradeQuerySchema,
|
||||
updateGradeLevelsSchema,
|
||||
updateGradeSchema,
|
||||
type BulkCreateGradeInput,
|
||||
type CreateGradeInput,
|
||||
type UpdateGradeInput,
|
||||
type UpdateGradeLevelsInput,
|
||||
} from './grades.schemas.js';
|
||||
import * as gradeService from './grades.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/** GET /api/grades/my - 学生查看个人成绩(任意角色都可查自己的) */
|
||||
/** GET /api/grades/my - ClassHelper 班级端查看本班成绩(教师/管理员返回空数组) */
|
||||
router.get('/my', async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(res, await gradeService.listMyGrades(user), '获取个人成绩成功');
|
||||
sendOk(res, await gradeService.listMyGrades(user), '获取本班成绩成功');
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/grades/student/:studentNo?classId= - 按**学号**查询某学生的成绩明细
|
||||
*
|
||||
* 三类调用方共用这一个接口:
|
||||
* - 教师 / 管理员(Web 端"按学号查成绩");
|
||||
* - ClassHelper 班级端(教室机器,学生按学号查自己的明细);
|
||||
* - 科任老师查自己班的学生。
|
||||
* 班级端需要该班打开 `studentGradeQueryEnabled`,否则 403。
|
||||
*/
|
||||
router.get(
|
||||
'/student/:studentNo',
|
||||
validate({ params: studentGradeParamSchema, query: studentGradeQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { studentNo } = validatedParams<{ studentNo: string }>(req);
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(
|
||||
res,
|
||||
await gradeService.getStudentGradeDetail(user, studentNo, classId),
|
||||
'获取学生成绩明细成功',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/grades/stats?classId=&courseId=&examName= - 成绩统计(图表) */
|
||||
router.get(
|
||||
'/stats',
|
||||
@@ -37,21 +65,24 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/grades?classId=&courseId=&userId=&examName= - 成绩列表(教师) */
|
||||
/** GET /api/grades?classId=&courseId=&studentId=&examName= - 成绩列表(教师) */
|
||||
router.get(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ query: listGradesQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<{ classId?: string; courseId?: string; userId?: string; examName?: string }>(
|
||||
req,
|
||||
);
|
||||
const query = validatedQuery<{
|
||||
classId?: string;
|
||||
courseId?: string;
|
||||
studentId?: string;
|
||||
examName?: string;
|
||||
}>(req);
|
||||
sendOk(res, await gradeService.listGrades(user, query), '获取成绩列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/grades - 录入单条成绩(广播 grade:updated) */
|
||||
/** POST /api/grades - 录入单条成绩(仅自己任教科目) */
|
||||
router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(
|
||||
@@ -61,6 +92,23 @@ router.post('/', requireRole('ADMIN', 'TEACHER'), validate({ body: createGradeSc
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* PATCH /api/grades/levels - 批量修改等级
|
||||
*
|
||||
* 两种用法:`items` 逐条指定;或 `classId + examName + levelType` 整批重算。
|
||||
* 逐条改时按每条成绩自己的班级 + 科目判权限。
|
||||
*/
|
||||
router.patch(
|
||||
'/levels',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ body: updateGradeLevelsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await gradeService.updateGradeLevels(user, req.body as UpdateGradeLevelsInput);
|
||||
sendOk(res, result, `已更新 ${result.updated} 条成绩的等级`);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/grades/bulk - 批量录入 / 导入成绩 */
|
||||
router.post(
|
||||
'/bulk',
|
||||
@@ -73,7 +121,7 @@ router.post(
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/grades/:id - 修改成绩 */
|
||||
/** PATCH /api/grades/:id - 修改成绩(含单个改等级) */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { z } from 'zod';
|
||||
import { optionalDateTimeSchema } from '../../lib/schemas.js';
|
||||
import { gradeLevelSchema, gradeLevelTypeSchema, optionalDateTimeSchema } from '../../lib/schemas.js';
|
||||
|
||||
export const listGradesQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
courseId: z.string().min(1).optional(),
|
||||
userId: z.string().min(1).optional(),
|
||||
/** 学生 id(学生是名单记录,不是账号) */
|
||||
studentId: z.string().min(1).optional(),
|
||||
examName: z.string().trim().max(64).optional(),
|
||||
});
|
||||
|
||||
@@ -17,10 +18,14 @@ export const statsQuerySchema = z.object({
|
||||
export const createGradeSchema = z.object({
|
||||
classId: z.string().min(1, '请选择班级'),
|
||||
courseId: z.string().min(1).nullish(),
|
||||
userId: z.string().min(1, '请选择学生'),
|
||||
studentId: z.string().min(1, '请选择学生'),
|
||||
examName: z.string().trim().min(1, '请输入考试名称').max(64),
|
||||
score: z.coerce.number().min(0, '分数不能为负数'),
|
||||
totalScore: z.coerce.number().positive('总分必须大于 0').optional(),
|
||||
/** 等级口径:percent 百分制 / letter 等级制 A-D / custom 自定义等级 */
|
||||
levelType: gradeLevelTypeSchema.optional(),
|
||||
/** 手填等级;percent 且留空时由服务端按得分率换算 */
|
||||
level: gradeLevelSchema.optional(),
|
||||
publishedAt: optionalDateTimeSchema,
|
||||
});
|
||||
|
||||
@@ -29,6 +34,8 @@ export const updateGradeSchema = z.object({
|
||||
score: z.coerce.number().min(0).optional(),
|
||||
totalScore: z.coerce.number().positive().optional(),
|
||||
courseId: z.string().min(1).nullish(),
|
||||
levelType: gradeLevelTypeSchema.optional(),
|
||||
level: gradeLevelSchema.optional(),
|
||||
publishedAt: optionalDateTimeSchema,
|
||||
});
|
||||
|
||||
@@ -38,17 +45,56 @@ export const bulkCreateGradeSchema = z.object({
|
||||
courseId: z.string().min(1).nullish(),
|
||||
examName: z.string().trim().min(1).max(64),
|
||||
totalScore: z.coerce.number().positive().optional(),
|
||||
levelType: gradeLevelTypeSchema.optional(),
|
||||
publishedAt: optionalDateTimeSchema,
|
||||
items: z
|
||||
.array(
|
||||
z.object({
|
||||
userId: z.string().min(1),
|
||||
studentId: z.string().min(1),
|
||||
score: z.coerce.number().min(0),
|
||||
level: gradeLevelSchema.optional(),
|
||||
}),
|
||||
)
|
||||
.min(1, '至少需要一条成绩记录'),
|
||||
});
|
||||
|
||||
/**
|
||||
* 批量修改等级,两种用法二选一:
|
||||
* 1. `items`:逐条指定等级(前端表格里改完一次提交;单个编辑就是长度为 1 的数组);
|
||||
* 2. `classId + examName + levelType`:把某个考试的某门课**整批重算**等级
|
||||
* (例如把一次百分制月考整班切成 A/B/C/D 等级制)。
|
||||
*/
|
||||
export const updateGradeLevelsSchema = z
|
||||
.object({
|
||||
items: z
|
||||
.array(z.object({ id: z.string().min(1), level: gradeLevelSchema }))
|
||||
.min(1, '至少需要一条等级记录')
|
||||
.max(1000)
|
||||
.optional(),
|
||||
classId: z.string().min(1).optional(),
|
||||
examName: z.string().trim().min(1).max(64).optional(),
|
||||
courseId: z.string().min(1).nullish(),
|
||||
levelType: gradeLevelTypeSchema.optional(),
|
||||
})
|
||||
.refine(
|
||||
(value) =>
|
||||
(value.items && value.items.length > 0) ||
|
||||
Boolean(value.classId && value.examName && value.levelType),
|
||||
{ message: '请提供 items,或同时提供 classId + examName + levelType' },
|
||||
);
|
||||
|
||||
/** 按学号查成绩明细:学号走路径参数,classId 可选(班级端固定为自己绑定的班级) */
|
||||
export const studentGradeParamSchema = z.object({
|
||||
studentNo: z.string().trim().min(1, '请填写学号').max(32),
|
||||
});
|
||||
|
||||
export const studentGradeQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
/** 设备令牌通道用查询参数传学号(JWT 通道走路径参数) */
|
||||
studentNo: z.string().trim().min(1, '请填写学号').max(32).optional(),
|
||||
});
|
||||
|
||||
export type CreateGradeInput = z.infer<typeof createGradeSchema>;
|
||||
export type UpdateGradeInput = z.infer<typeof updateGradeSchema>;
|
||||
export type BulkCreateGradeInput = z.infer<typeof bulkCreateGradeSchema>;
|
||||
export type UpdateGradeLevelsInput = z.infer<typeof updateGradeLevelsSchema>;
|
||||
@@ -1,64 +1,81 @@
|
||||
import {
|
||||
SOCKET_EVENTS,
|
||||
canQueryStudentGrades,
|
||||
gradeLevel,
|
||||
gradePercent,
|
||||
normalizeStudentNo,
|
||||
type GradeDto,
|
||||
type GradeLevel,
|
||||
type GradeLevelType,
|
||||
type GradeStats,
|
||||
type StudentGradeDetailDto,
|
||||
} from '@classhelper/shared';
|
||||
import { assertCanManageGrades, classScopeWhere, resolveClassScope } from '../../lib/access.js';
|
||||
import {
|
||||
assertCanManageSubjectContent,
|
||||
assertClassAccess,
|
||||
classScopeWhere,
|
||||
isClassDevice,
|
||||
resolveClassScope,
|
||||
resolveUserClassRole,
|
||||
} from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toGradeDto } from '../../lib/mappers.js';
|
||||
import { toGradeDto, toStudentBrief } from '../../lib/mappers.js';
|
||||
import { parseOptionalDate } from '../../lib/schemas.js';
|
||||
import { resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass, emitToUser } from '../../realtime/bus.js';
|
||||
import type { BulkCreateGradeInput, CreateGradeInput, UpdateGradeInput } from './grades.schemas.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import type {
|
||||
BulkCreateGradeInput,
|
||||
CreateGradeInput,
|
||||
UpdateGradeInput,
|
||||
UpdateGradeLevelsInput,
|
||||
} from './grades.schemas.js';
|
||||
|
||||
const courseSelect = { select: { id: true, name: true } } as const;
|
||||
const studentSelect = { select: { id: true, name: true, username: true } } as const;
|
||||
const studentSelect = { select: { id: true, studentNo: true, name: true } } as const;
|
||||
const classSelect = { select: { name: true } } as const;
|
||||
const include = { course: courseSelect, student: studentSelect, class: classSelect } as const;
|
||||
|
||||
export interface ListGradeOptions {
|
||||
classId?: string;
|
||||
courseId?: string;
|
||||
userId?: string;
|
||||
studentId?: string;
|
||||
examName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生查看成绩。
|
||||
* - 普通学生账号:只看自己的成绩;
|
||||
* - 班级账号(班级设备):返回**本班全部学生的成绩总览**(个人学生不再是登录主体,
|
||||
* 班级设备上一屏展示全班成绩,便于张贴/核对)。
|
||||
* ClassHelper 班级端查看本班成绩。
|
||||
*
|
||||
* 班级端是个人数据的唯一读写主体(学生不是账号),因此这里返回**本班全部学生的成绩**,
|
||||
* 便于在教室大屏上一屏展示全班成绩供核对。
|
||||
* 教师/管理员调用时返回空数组(他们没有"自己的成绩"),请用 `listGrades`。
|
||||
*/
|
||||
export async function listMyGrades(user: TokenPayload): Promise<GradeDto[]> {
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
const studentIds = await resolvePersonalIds(user);
|
||||
if (studentIds.length === 0) return [];
|
||||
|
||||
const grades = await prisma.grade.findMany({
|
||||
where: { userId: { in: personalIds } },
|
||||
where: { studentId: { in: studentIds } },
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
||||
take: 500,
|
||||
take: 1000,
|
||||
});
|
||||
return grades.map(toGradeDto);
|
||||
}
|
||||
|
||||
/** 教师查看班级成绩 */
|
||||
/** 教师 / 管理员查看成绩(按可访问班级过滤) */
|
||||
export async function listGrades(user: TokenPayload, options: ListGradeOptions): Promise<GradeDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const grades = await prisma.grade.findMany({
|
||||
where: {
|
||||
...classScopeWhere(scope),
|
||||
...(options.courseId ? { courseId: options.courseId } : {}),
|
||||
...(options.userId ? { userId: options.userId } : {}),
|
||||
...(options.studentId ? { studentId: options.studentId } : {}),
|
||||
...(options.examName ? { examName: options.examName } : {}),
|
||||
},
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
||||
take: 500,
|
||||
take: 1000,
|
||||
});
|
||||
return grades.map(toGradeDto);
|
||||
}
|
||||
@@ -66,15 +83,15 @@ export async function listGrades(user: TokenPayload, options: ListGradeOptions):
|
||||
/** 成绩统计(等级分布 + 各课程平均得分率),供 Web 端图表使用 */
|
||||
export async function getGradeStats(user: TokenPayload, options: ListGradeOptions): Promise<GradeStats> {
|
||||
const grades = await listGrades(user, options);
|
||||
const levels: GradeLevel[] = ['A', 'B', 'C', 'D', 'E'];
|
||||
const distribution = levels.map((level) => ({ level, count: 0 }));
|
||||
|
||||
const distributionMap = new Map<string, number>();
|
||||
const byCourseMap = new Map<string, { courseId: string | null; courseName: string; percents: number[] }>();
|
||||
|
||||
for (const grade of grades) {
|
||||
const percent = gradePercent(grade.score, grade.totalScore);
|
||||
const bucket = distribution.find((item) => item.level === gradeLevel(percent));
|
||||
if (bucket) bucket.count += 1;
|
||||
// 自定义等级也要能统计,所以按 level 文本分桶(不再是固定的 A~E)
|
||||
const level = grade.level || gradeLevel(percent);
|
||||
distributionMap.set(level, (distributionMap.get(level) ?? 0) + 1);
|
||||
|
||||
const key = grade.courseId ?? '__none__';
|
||||
const entry = byCourseMap.get(key) ?? {
|
||||
@@ -101,58 +118,77 @@ export async function getGradeStats(user: TokenPayload, options: ListGradeOption
|
||||
averagePercent:
|
||||
Math.round((entry.percents.reduce((sum, value) => sum + value, 0) / entry.percents.length) * 10) / 10,
|
||||
})),
|
||||
distribution,
|
||||
distribution: [...distributionMap.entries()]
|
||||
.map(([level, count]) => ({ level, count }))
|
||||
.sort((a, b) => b.count - a.count),
|
||||
};
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 录入 / 修改 / 删除 */
|
||||
|
||||
/**
|
||||
* 录入一条成绩。
|
||||
*
|
||||
* 权限走 `assertCanManageSubjectContent`:**科任老师只能录自己任教科目**的成绩,
|
||||
* 班主任若不教这一科同样被拒;不指定科目(courseId 为空)时只有班主任/管理员可录。
|
||||
*/
|
||||
export async function createGrade(user: TokenPayload, input: CreateGradeInput): Promise<GradeDto> {
|
||||
await assertCanManageGrades(user, input.classId);
|
||||
await assertStudentInClass(input.userId, input.classId);
|
||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
|
||||
await assertStudentInClass(input.studentId, input.classId);
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
||||
|
||||
const levelType: GradeLevelType = input.levelType ?? 'percent';
|
||||
const totalScore = input.totalScore ?? 100;
|
||||
|
||||
const dto = await upsertGrade({
|
||||
classId: input.classId,
|
||||
courseId: input.courseId ?? null,
|
||||
userId: input.userId,
|
||||
studentId: input.studentId,
|
||||
examName: input.examName,
|
||||
score: input.score,
|
||||
totalScore: input.totalScore ?? 100,
|
||||
totalScore,
|
||||
levelType,
|
||||
level: resolveLevel(levelType, input.level, input.score, totalScore),
|
||||
publishedAt: parseOptionalDate(input.publishedAt) ?? new Date(),
|
||||
});
|
||||
|
||||
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
|
||||
emitToUser(dto.userId, SOCKET_EVENTS.gradeUpdated, dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
/** 批量录入:同一个考试一次提交整班成绩 */
|
||||
export async function bulkCreateGrades(user: TokenPayload, input: BulkCreateGradeInput): Promise<GradeDto[]> {
|
||||
await assertCanManageGrades(user, input.classId);
|
||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '录入成绩');
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
||||
|
||||
const publishedAt = parseOptionalDate(input.publishedAt) ?? new Date();
|
||||
const levelType: GradeLevelType = input.levelType ?? 'percent';
|
||||
const totalScore = input.totalScore ?? 100;
|
||||
const results: GradeDto[] = [];
|
||||
|
||||
for (const item of input.items) {
|
||||
await assertStudentInClass(item.userId, input.classId);
|
||||
await assertStudentInClass(item.studentId, input.classId);
|
||||
const dto = await upsertGrade({
|
||||
classId: input.classId,
|
||||
courseId: input.courseId ?? null,
|
||||
userId: item.userId,
|
||||
studentId: item.studentId,
|
||||
examName: input.examName,
|
||||
score: item.score,
|
||||
totalScore: input.totalScore ?? 100,
|
||||
totalScore,
|
||||
levelType,
|
||||
level: resolveLevel(levelType, item.level, item.score, totalScore),
|
||||
publishedAt,
|
||||
});
|
||||
results.push(dto);
|
||||
}
|
||||
|
||||
// 一次广播覆盖整班(房间内包含全部学生)
|
||||
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
|
||||
...results[0],
|
||||
bulk: true,
|
||||
count: results.length,
|
||||
});
|
||||
if (results[0]) {
|
||||
emitToClass(input.classId, SOCKET_EVENTS.gradeUpdated, {
|
||||
...results[0],
|
||||
bulk: true,
|
||||
count: results.length,
|
||||
});
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@@ -163,8 +199,22 @@ export async function updateGrade(
|
||||
): Promise<GradeDto> {
|
||||
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
|
||||
if (!current) throw ApiError.notFound('成绩记录不存在');
|
||||
await assertCanManageGrades(user, current.classId);
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
|
||||
|
||||
// 改的是哪条成绩,就按那条成绩所属的班级 + 科目判权限
|
||||
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
|
||||
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改成绩');
|
||||
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
|
||||
|
||||
const totalScore = input.totalScore ?? current.totalScore;
|
||||
const score = input.score ?? current.score;
|
||||
const levelType = (input.levelType ?? current.levelType) as GradeLevelType;
|
||||
// 改了分数就重算 percent 口径下的等级;显式传了 level 则以它为准
|
||||
const level =
|
||||
input.level !== undefined
|
||||
? input.level
|
||||
: levelType === 'percent'
|
||||
? resolveLevel(levelType, '', score, totalScore)
|
||||
: current.level;
|
||||
|
||||
const updated = await prisma.grade.update({
|
||||
where: { id: gradeId },
|
||||
@@ -172,7 +222,9 @@ export async function updateGrade(
|
||||
...(input.examName ? { examName: input.examName } : {}),
|
||||
...(input.score !== undefined ? { score: input.score } : {}),
|
||||
...(input.totalScore !== undefined ? { totalScore: input.totalScore } : {}),
|
||||
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
|
||||
...(input.courseId !== undefined ? { courseId: nextCourseId } : {}),
|
||||
levelType,
|
||||
level,
|
||||
...(input.publishedAt !== undefined
|
||||
? { publishedAt: parseOptionalDate(input.publishedAt) ?? new Date() }
|
||||
: {}),
|
||||
@@ -182,14 +234,14 @@ export async function updateGrade(
|
||||
|
||||
const dto = toGradeDto(updated);
|
||||
emitToClass(dto.classId, SOCKET_EVENTS.gradeUpdated, dto);
|
||||
emitToUser(dto.userId, SOCKET_EVENTS.gradeUpdated, dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<void> {
|
||||
const current = await prisma.grade.findUnique({ where: { id: gradeId } });
|
||||
if (!current) throw ApiError.notFound('成绩记录不存在');
|
||||
await assertCanManageGrades(user, current.classId);
|
||||
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除成绩');
|
||||
|
||||
await prisma.grade.delete({ where: { id: gradeId } });
|
||||
emitToClass(current.classId, SOCKET_EVENTS.gradeUpdated, {
|
||||
...toGradeDto({ ...current, course: null, student: null, class: null }),
|
||||
@@ -197,6 +249,172 @@ export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<
|
||||
});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 等级:单个 / 批量 */
|
||||
|
||||
/**
|
||||
* 批量修改等级。
|
||||
*
|
||||
* 两种用法:
|
||||
* 1. `items`:逐条指定等级(前端表格里改完一次提交;单个编辑就是长度 1 的数组);
|
||||
* 2. `classId + examName + levelType`:把某个考试的某门课整批**重算**等级
|
||||
* (percent 按得分率换算,其余口径沿用原等级或留空)。
|
||||
*
|
||||
* 逐条改时按**每条成绩自己的班级 + 科目**判权限,科任老师改不了别人的科目。
|
||||
*/
|
||||
export async function updateGradeLevels(
|
||||
user: TokenPayload,
|
||||
input: UpdateGradeLevelsInput,
|
||||
): Promise<{ updated: number }> {
|
||||
if (input.items && input.items.length > 0) {
|
||||
const records = await prisma.grade.findMany({
|
||||
where: { id: { in: input.items.map((item) => item.id) } },
|
||||
select: { id: true, classId: true, courseId: true },
|
||||
});
|
||||
if (records.length !== input.items.length) {
|
||||
throw ApiError.badRequest('部分成绩记录不存在,请刷新后重试');
|
||||
}
|
||||
const byId = new Map(records.map((item) => [item.id, item]));
|
||||
|
||||
// 逐条判权限:可能跨多个班级/科目(管理员在成绩页多选修改时会这样)
|
||||
for (const item of input.items) {
|
||||
const record = byId.get(item.id)!;
|
||||
await assertCanManageSubjectContent(user, record.classId, record.courseId, '修改成绩等级');
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
input.items.map((item) =>
|
||||
prisma.grade.update({ where: { id: item.id }, data: { level: item.level } }),
|
||||
),
|
||||
);
|
||||
|
||||
const sample = byId.get(input.items[0]!.id);
|
||||
if (sample) emitToClass(sample.classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count: input.items.length });
|
||||
return { updated: input.items.length };
|
||||
}
|
||||
|
||||
// 整批重算
|
||||
const classId = input.classId!;
|
||||
await assertCanManageSubjectContent(user, classId, input.courseId ?? null, '调整成绩等级');
|
||||
const levelType = input.levelType!;
|
||||
const records = await prisma.grade.findMany({
|
||||
where: {
|
||||
classId,
|
||||
examName: input.examName!,
|
||||
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
|
||||
},
|
||||
select: { id: true, score: true, totalScore: true, level: true },
|
||||
});
|
||||
|
||||
await prisma.$transaction(
|
||||
records.map((item) =>
|
||||
prisma.grade.update({
|
||||
where: { id: item.id },
|
||||
data: {
|
||||
levelType,
|
||||
level:
|
||||
levelType === 'percent'
|
||||
? gradeLevel(gradePercent(item.score, item.totalScore))
|
||||
: item.level,
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
emitToClass(classId, SOCKET_EVENTS.gradeUpdated, { bulk: true, count: records.length });
|
||||
return { updated: records.length };
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 按学号查明细 */
|
||||
|
||||
/**
|
||||
* 按**学号**查询某个学生的成绩明细(ClassHelper 班级端 / 教师端共用)。
|
||||
*
|
||||
* 权限:
|
||||
* - 管理员:全库任意学号;
|
||||
* - 班主任 / 科任老师:自己可访问的班级;
|
||||
* - ClassHelper 班级端:仅本班,**且**该班打开了 `studentGradeQueryEnabled`
|
||||
* —— 这就是「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」。
|
||||
*/
|
||||
export async function getStudentGradeDetail(
|
||||
user: TokenPayload,
|
||||
studentNo: string,
|
||||
requestedClassId?: string,
|
||||
): Promise<StudentGradeDetailDto> {
|
||||
const scope = await resolveClassScope(user, requestedClassId);
|
||||
|
||||
const student = await prisma.student.findFirst({
|
||||
where: { studentNo: normalizeStudentNo(studentNo), ...classScopeWhere(scope) },
|
||||
include: { class: { select: { id: true, name: true, studentGradeQueryEnabled: true } } },
|
||||
});
|
||||
if (!student) throw ApiError.notFound('没找到这个学号的学生,请核对学号是否输入正确');
|
||||
|
||||
if (student.class) {
|
||||
const classRole = await resolveUserClassRole(user, student.class.id);
|
||||
const allowed = canQueryStudentGrades({
|
||||
classRole,
|
||||
// 老库补列时的默认值是 true(开箱可用),所以 null/undefined 都按开启处理
|
||||
gradeQueryEnabled: student.class.studentGradeQueryEnabled ?? true,
|
||||
});
|
||||
if (!allowed) {
|
||||
throw ApiError.forbidden('本班未开放学生自助查询成绩,请联系老师开启');
|
||||
}
|
||||
} else if (isClassDevice(user)) {
|
||||
throw ApiError.forbidden('该学生尚未分班,无法查询');
|
||||
}
|
||||
|
||||
const grades = await prisma.grade.findMany({
|
||||
where: { studentId: student.id },
|
||||
include,
|
||||
orderBy: [{ publishedAt: 'desc' }, { examName: 'asc' }],
|
||||
take: 500,
|
||||
});
|
||||
|
||||
const percents = grades.map((item) => gradePercent(item.score, item.totalScore));
|
||||
return {
|
||||
student: toStudentBrief(student),
|
||||
classId: student.classId,
|
||||
className: student.class?.name ?? null,
|
||||
items: grades.map(toGradeDto),
|
||||
averagePercent:
|
||||
percents.length === 0
|
||||
? 0
|
||||
: Math.round((percents.reduce((sum, value) => sum + value, 0) / percents.length) * 10) / 10,
|
||||
};
|
||||
}
|
||||
|
||||
/** 教师端:查看某个学生的成绩明细(按学生 id,内部复用按学号的实现) */
|
||||
export async function getStudentGradeDetailById(
|
||||
user: TokenPayload,
|
||||
studentId: string,
|
||||
): Promise<StudentGradeDetailDto> {
|
||||
const student = await prisma.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { studentNo: true, classId: true },
|
||||
});
|
||||
if (!student) throw ApiError.notFound('学生不存在');
|
||||
await assertClassAccess(user, student.classId ?? '');
|
||||
return getStudentGradeDetail(user, student.studentNo);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 内部工具 */
|
||||
|
||||
/**
|
||||
* 等级文本:
|
||||
* - 显式给了就用它(教师手改的等级优先);
|
||||
* - 否则 percent 口径按得分率换算;
|
||||
* - letter / custom 留空时给空串,由前端提示"请选择等级"。
|
||||
*/
|
||||
function resolveLevel(
|
||||
levelType: GradeLevelType,
|
||||
level: string | undefined,
|
||||
score: number,
|
||||
totalScore: number,
|
||||
): string {
|
||||
const provided = (level ?? '').trim();
|
||||
if (provided) return provided;
|
||||
return levelType === 'percent' ? gradeLevel(gradePercent(score, totalScore)) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一学生 + 同一课程 + 同一考试名称视为同一条记录,
|
||||
* 重复录入时覆盖旧分数,避免教师"改分"产生重复行。
|
||||
@@ -204,17 +422,19 @@ export async function deleteGrade(user: TokenPayload, gradeId: string): Promise<
|
||||
async function upsertGrade(data: {
|
||||
classId: string;
|
||||
courseId: string | null;
|
||||
userId: string;
|
||||
studentId: string;
|
||||
examName: string;
|
||||
score: number;
|
||||
totalScore: number;
|
||||
levelType: GradeLevelType;
|
||||
level: string;
|
||||
publishedAt: Date;
|
||||
}): Promise<GradeDto> {
|
||||
const existing = await prisma.grade.findFirst({
|
||||
where: {
|
||||
classId: data.classId,
|
||||
courseId: data.courseId,
|
||||
userId: data.userId,
|
||||
studentId: data.studentId,
|
||||
examName: data.examName,
|
||||
},
|
||||
select: { id: true },
|
||||
@@ -223,7 +443,13 @@ async function upsertGrade(data: {
|
||||
if (existing) {
|
||||
const updated = await prisma.grade.update({
|
||||
where: { id: existing.id },
|
||||
data: { score: data.score, totalScore: data.totalScore, publishedAt: data.publishedAt },
|
||||
data: {
|
||||
score: data.score,
|
||||
totalScore: data.totalScore,
|
||||
levelType: data.levelType,
|
||||
level: data.level,
|
||||
publishedAt: data.publishedAt,
|
||||
},
|
||||
include,
|
||||
});
|
||||
return toGradeDto(updated);
|
||||
@@ -233,12 +459,12 @@ async function upsertGrade(data: {
|
||||
return toGradeDto(created);
|
||||
}
|
||||
|
||||
async function assertStudentInClass(userId: string, classId: string): Promise<void> {
|
||||
const student = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true, classId: true },
|
||||
async function assertStudentInClass(studentId: string, classId: string): Promise<void> {
|
||||
const student = await prisma.student.findUnique({
|
||||
where: { id: studentId },
|
||||
select: { classId: true },
|
||||
});
|
||||
if (!student || student.role !== 'STUDENT') throw ApiError.badRequest('学生不存在');
|
||||
if (!student) throw ApiError.badRequest('学生不存在');
|
||||
if (student.classId !== classId) throw ApiError.badRequest('该学生不属于所选班级');
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ const dayKeySchema = z
|
||||
export const listHomeworksQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
courseId: z.string().min(1).optional(),
|
||||
/** 学生端:只看未完成 */
|
||||
/** ClassHelper 班级端:只看未完成 */
|
||||
pendingOnly: booleanFlagSchema,
|
||||
keyword: z.string().trim().max(64).optional(),
|
||||
/** 只看某一天的作业(按 Homework.assignDate,本地日期) */
|
||||
@@ -48,13 +48,16 @@ export const updateHomeworkSchema = z.object({
|
||||
|
||||
export const updateHomeworkStatusSchema = z.object({
|
||||
completed: z.boolean(),
|
||||
/** 教师代学生标记时使用;学生只能标记自己 */
|
||||
userId: z.string().min(1).optional(),
|
||||
/**
|
||||
* 教师代学生标记时必填(学生不是账号,必须显式指定本班学生);
|
||||
* ClassHelper 班级端**不要传** —— 它代全班标记。
|
||||
*/
|
||||
studentId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
/** 保存"未交名单":列出未交的学生 id(其余学生一律标记为已交) */
|
||||
export const updateHomeworkSubmissionsSchema = z.object({
|
||||
notSubmittedUserIds: z.array(z.string().min(1)).max(200).default([]),
|
||||
notSubmittedStudentIds: z.array(z.string().min(1)).max(500).default([]),
|
||||
});
|
||||
|
||||
export type ListHomeworkDaysInput = z.infer<typeof listHomeworkDaysQuerySchema>;
|
||||
|
||||
@@ -9,22 +9,17 @@ import {
|
||||
type HomeworkSubmissionsDto,
|
||||
} from '@classhelper/shared';
|
||||
import {
|
||||
assertCanPublishContent,
|
||||
assertCanManageSubjectContent,
|
||||
assertClassAccess,
|
||||
classScopeWhere,
|
||||
isStudent,
|
||||
isClassDevice,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toHomeworkDto, toHomeworkStatusDto } from '../../lib/mappers.js';
|
||||
import {
|
||||
classSessionClassId,
|
||||
isClassSession,
|
||||
personalIdWhere,
|
||||
resolvePersonalIds,
|
||||
} from '../../lib/session.js';
|
||||
import { personalIdWhere, resolvePersonalIds } from '../../lib/session.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import type {
|
||||
CreateHomeworkInput,
|
||||
@@ -47,7 +42,7 @@ export interface ListHomeworkOptions {
|
||||
|
||||
/**
|
||||
* 作业列表。
|
||||
* - 学生:附带自己的完成状态,可通过 pendingOnly 只看未完成
|
||||
* - ClassHelper 班级端:附带本班的完成情况,可通过 pendingOnly 只看未完成
|
||||
* - 教师/管理员:附带完成人数统计
|
||||
*/
|
||||
export async function listHomeworks(
|
||||
@@ -55,9 +50,9 @@ export async function listHomeworks(
|
||||
options: ListHomeworkOptions,
|
||||
): Promise<HomeworkDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
const student = isStudent(user);
|
||||
// 班级账号(班级设备)以全班学生为范围;普通学生即自己
|
||||
const personalIds = student ? await resolvePersonalIds(user) : [];
|
||||
const device = isClassDevice(user);
|
||||
// ClassHelper 班级端以全班学生为范围;教师/管理员没有"自己的完成状态"
|
||||
const personalIds = device ? await resolvePersonalIds(user) : [];
|
||||
const personalWhere = personalIdWhere(personalIds);
|
||||
|
||||
const homeworks = await prisma.homework.findMany({
|
||||
@@ -67,7 +62,7 @@ export async function listHomeworks(
|
||||
...(options.keyword
|
||||
? { OR: [{ title: { contains: options.keyword } }, { content: { contains: options.keyword } }] }
|
||||
: {}),
|
||||
...(student && options.pendingOnly
|
||||
...(device && options.pendingOnly
|
||||
? { statuses: { none: { ...personalWhere, completed: true } } }
|
||||
: {}),
|
||||
// 按天查看:作业「属于哪一天」看 assignDate(本地日期),不是 createdAt
|
||||
@@ -76,14 +71,14 @@ export async function listHomeworks(
|
||||
include: {
|
||||
course: courseSelect,
|
||||
creator: creatorSelect,
|
||||
statuses: student ? { where: personalWhere } : true,
|
||||
statuses: device ? { where: personalWhere } : true,
|
||||
},
|
||||
orderBy: [{ assignDate: 'desc' }, { createdAt: 'desc' }],
|
||||
take: 200,
|
||||
});
|
||||
|
||||
return homeworks.map((item) =>
|
||||
toHomeworkDto(item, { userIds: student ? personalIds : [], withStatus: !student }),
|
||||
toHomeworkDto(item, { studentIds: device ? personalIds : [], withStatus: !device }),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -118,44 +113,48 @@ export async function listHomeworkDays(
|
||||
.sort((a, b) => a.date.localeCompare(b.date)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHomework(user: TokenPayload, homeworkId: string): Promise<HomeworkDto> {
|
||||
const student = isStudent(user);
|
||||
const personalIds = student ? await resolvePersonalIds(user) : [];
|
||||
const device = isClassDevice(user);
|
||||
const personalIds = device ? await resolvePersonalIds(user) : [];
|
||||
const homework = await prisma.homework.findUnique({
|
||||
where: { id: homeworkId },
|
||||
include: {
|
||||
course: courseSelect,
|
||||
creator: creatorSelect,
|
||||
statuses: student ? { where: personalIdWhere(personalIds) } : true,
|
||||
statuses: device ? { where: personalIdWhere(personalIds) } : true,
|
||||
},
|
||||
});
|
||||
if (!homework) throw ApiError.notFound('作业不存在');
|
||||
await assertClassAccess(user, homework.classId);
|
||||
|
||||
return toHomeworkDto(homework, {
|
||||
userIds: student ? personalIds : [],
|
||||
withStatus: !student,
|
||||
studentIds: device ? personalIds : [],
|
||||
withStatus: !device,
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 发布 / 修改 / 删除 */
|
||||
|
||||
/**
|
||||
* 发布作业并实时推送到班级房间。
|
||||
*
|
||||
* 权限:管理员 / 班主任 / 科任老师,**以及本班的班级账号**(教室机器的客户端录入 ——
|
||||
* 需求就是「支持作业在客户端录入」,老师在教室电脑上顺手就能记一条)。
|
||||
* 权限走 `assertCanManageSubjectContent`(`allowClassDevice: true`):
|
||||
* - **选了科目** → 必须是该科的任课老师(班主任不教这一科也发不了);
|
||||
* - **没选科目** → 本班班主任或管理员;
|
||||
* - **ClassHelper 班级端** → 可以录(老师在教室电脑上顺手记一条是常见需求,
|
||||
* 见 AGENTS.md §5 第 20 条),班级端录入的作业归属该班班主任。
|
||||
*/
|
||||
export async function createHomework(user: TokenPayload, input: CreateHomeworkInput): Promise<HomeworkDto> {
|
||||
if (isClassSession(user) && classSessionClassId(user) === input.classId) {
|
||||
await assertClassAccess(user, input.classId);
|
||||
} else {
|
||||
await assertCanPublishContent(user, input.classId);
|
||||
}
|
||||
await assertCanManageSubjectContent(user, input.classId, input.courseId ?? null, '布置作业', {
|
||||
allowClassDevice: true,
|
||||
});
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, input.classId);
|
||||
|
||||
// 班级账号的 sub 是**班级 id**(不是用户 id),直接写 createdBy 会撞外键;
|
||||
// ClassHelper 班级端的 sub 是**班级 id**(不是账号 id),直接写 createdBy 会撞外键;
|
||||
// 教室机器录入的作业归属该班班主任,Web 端看到的作者才是真人。
|
||||
let createdBy = user.sub;
|
||||
if (isClassSession(user)) {
|
||||
if (isClassDevice(user)) {
|
||||
const owner = await prisma.class.findUnique({
|
||||
where: { id: input.classId },
|
||||
select: { teacherId: true },
|
||||
@@ -190,16 +189,20 @@ export async function updateHomework(
|
||||
): Promise<HomeworkDto> {
|
||||
const current = await prisma.homework.findUnique({ where: { id: homeworkId } });
|
||||
if (!current) throw ApiError.notFound('作业不存在');
|
||||
await assertCanPublishContent(user, current.classId);
|
||||
|
||||
if (input.courseId) await assertCourseInClass(input.courseId, current.classId);
|
||||
// 改科目等于把这条作业挪到另一个科目的权限域下,两端都要判
|
||||
const nextCourseId = input.courseId === undefined ? current.courseId : (input.courseId ?? null);
|
||||
await assertCanManageSubjectContent(user, current.classId, nextCourseId, '修改作业', {
|
||||
allowClassDevice: true,
|
||||
});
|
||||
if (nextCourseId) await assertCourseInClass(nextCourseId, current.classId);
|
||||
|
||||
const updated = await prisma.homework.update({
|
||||
where: { id: homeworkId },
|
||||
data: {
|
||||
...(input.title ? { title: input.title } : {}),
|
||||
...(input.content ? { content: input.content } : {}),
|
||||
...(input.courseId !== undefined ? { courseId: input.courseId ?? null } : {}),
|
||||
...(input.courseId !== undefined ? { courseId: nextCourseId } : {}),
|
||||
...(input.attachmentUrl !== undefined ? { attachmentUrl: input.attachmentUrl ?? null } : {}),
|
||||
...(input.assignDate !== undefined ? { assignDate: input.assignDate } : {}),
|
||||
},
|
||||
@@ -217,7 +220,9 @@ export async function deleteHomework(user: TokenPayload, homeworkId: string): Pr
|
||||
include: { course: courseSelect, creator: creatorSelect },
|
||||
});
|
||||
if (!current) throw ApiError.notFound('作业不存在');
|
||||
await assertCanPublishContent(user, current.classId);
|
||||
await assertCanManageSubjectContent(user, current.classId, current.courseId, '删除作业', {
|
||||
allowClassDevice: true,
|
||||
});
|
||||
|
||||
await prisma.homework.delete({ where: { id: homeworkId } });
|
||||
emitToClass(current.classId, SOCKET_EVENTS.homeworkUpdated, {
|
||||
@@ -226,11 +231,12 @@ export async function deleteHomework(user: TokenPayload, homeworkId: string): Pr
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 完成状态 */
|
||||
|
||||
/**
|
||||
* 标记完成 / 取消完成。
|
||||
* - 普通学生:标记自己;
|
||||
* - 班级账号(班级设备):为全班学生写入完成状态(代全班操作);
|
||||
* - 教师 / 管理员:可代指定学生标记。
|
||||
* - ClassHelper 班级端:为**全班学生**写入完成状态(代全班操作,因此教师端的完成人数依然准确);
|
||||
* - 教师 / 管理员:必须显式指定该班的学生(学生不是账号,不能落到操作者自己身上)。
|
||||
*/
|
||||
export async function updateHomeworkStatus(
|
||||
user: TokenPayload,
|
||||
@@ -242,47 +248,45 @@ export async function updateHomeworkStatus(
|
||||
|
||||
await assertClassAccess(user, homework.classId);
|
||||
|
||||
if (isStudent(user)) {
|
||||
const personalIds = await resolvePersonalIds(user);
|
||||
let result: {
|
||||
id: string;
|
||||
homeworkId: string;
|
||||
userId: string;
|
||||
completed: boolean;
|
||||
updatedAt: Date;
|
||||
} | null = null;
|
||||
if (isClassDevice(user)) {
|
||||
const studentIds = await resolvePersonalIds(user);
|
||||
let result: HomeworkStatusDto | null = null;
|
||||
|
||||
for (const userId of personalIds) {
|
||||
for (const studentId of studentIds) {
|
||||
const status = await prisma.homeworkStatus.upsert({
|
||||
where: { homeworkId_userId: { homeworkId, userId } },
|
||||
create: { homeworkId, userId, completed: input.completed },
|
||||
where: { homeworkId_studentId: { homeworkId, studentId } },
|
||||
create: { homeworkId, studentId, completed: input.completed },
|
||||
update: { completed: input.completed },
|
||||
});
|
||||
result ??= status;
|
||||
result ??= toHomeworkStatusDto(status);
|
||||
}
|
||||
|
||||
if (!result) throw ApiError.forbidden('当前班级还没有学生账号,无法标记作业完成状态');
|
||||
if (!result) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生');
|
||||
|
||||
const dto = toHomeworkStatusDto(result);
|
||||
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, { ...dto, classId: homework.classId });
|
||||
return dto;
|
||||
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, {
|
||||
...result,
|
||||
classId: homework.classId,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
// 教师 / 管理员分支:必须落到**该班学生**身上。旧实现允许不传 userId 并默默写到
|
||||
// 操作者自己的账号上(教师时 user.sub 就是教师 id),那条记录既不在学生名单里,
|
||||
// 又会被教师视角的 completedCount 计入,导致"完成人数 > 班级人数"。
|
||||
const targetUserId = input.userId ?? user.sub;
|
||||
const target = await prisma.user.findUnique({
|
||||
where: { id: targetUserId },
|
||||
select: { role: true, classId: true },
|
||||
// 教师 / 管理员分支:必须落到**该班学生**身上。旧实现允许不传 id 并默默写到操作者自己的账号上
|
||||
// (教师时 sub 就是教师 id),那条记录既不在学生名单里、又会被 completedCount 计入,
|
||||
// 导致"完成人数 > 班级人数"。
|
||||
if (!input.studentId) {
|
||||
throw ApiError.badRequest('请指定该作业所在班级的学生(studentId)');
|
||||
}
|
||||
const target = await prisma.student.findUnique({
|
||||
where: { id: input.studentId },
|
||||
select: { classId: true },
|
||||
});
|
||||
if (!target || target.role !== 'STUDENT' || target.classId !== homework.classId) {
|
||||
throw ApiError.badRequest('请指定该作业所在班级的学生(userId),未指定时只能操作自己的账号');
|
||||
if (!target || target.classId !== homework.classId) {
|
||||
throw ApiError.badRequest('请指定该作业所在班级的学生(studentId)');
|
||||
}
|
||||
|
||||
const status = await prisma.homeworkStatus.upsert({
|
||||
where: { homeworkId_userId: { homeworkId, userId: targetUserId } },
|
||||
create: { homeworkId, userId: targetUserId, completed: input.completed },
|
||||
where: { homeworkId_studentId: { homeworkId, studentId: input.studentId } },
|
||||
create: { homeworkId, studentId: input.studentId, completed: input.completed },
|
||||
update: { completed: input.completed },
|
||||
});
|
||||
|
||||
@@ -293,21 +297,21 @@ export async function updateHomeworkStatus(
|
||||
|
||||
/* ------------------------------------------------------------------ 未交名单 */
|
||||
|
||||
/** 组装某次作业的全班提交名单(未交名单 = completed=false 的那些) */
|
||||
/** 组装某次作业的全班提交名单(未交名单 = completed=false 的那些),按学号排序 */
|
||||
async function buildSubmissions(classId: string, homeworkId: string): Promise<HomeworkSubmissionsDto> {
|
||||
const [students, statuses] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: { classId, role: 'STUDENT' },
|
||||
select: { id: true, name: true, username: true },
|
||||
orderBy: { username: 'asc' },
|
||||
prisma.student.findMany({
|
||||
where: { classId, archivedYearId: null },
|
||||
select: { id: true, name: true, studentNo: true },
|
||||
orderBy: { studentNo: 'asc' },
|
||||
}),
|
||||
prisma.homeworkStatus.findMany({ where: { homeworkId }, select: { userId: true, completed: true } }),
|
||||
prisma.homeworkStatus.findMany({ where: { homeworkId }, select: { studentId: true, completed: true } }),
|
||||
]);
|
||||
const completedBy = new Map(statuses.map((item) => [item.userId, item.completed]));
|
||||
const completedBy = new Map(statuses.map((item) => [item.studentId, item.completed]));
|
||||
const list: HomeworkSubmissionDto[] = students.map((item) => ({
|
||||
userId: item.id,
|
||||
name: item.name || item.username,
|
||||
username: item.username,
|
||||
studentId: item.id,
|
||||
studentNo: item.studentNo,
|
||||
name: item.name || item.studentNo,
|
||||
completed: completedBy.get(item.id) === true,
|
||||
}));
|
||||
return {
|
||||
@@ -320,14 +324,14 @@ async function buildSubmissions(classId: string, homeworkId: string): Promise<Ho
|
||||
};
|
||||
}
|
||||
|
||||
/** 只有教师 / 管理员 / 班级设备可以维护"未交名单"(普通学生只能标记自己) */
|
||||
/** 只有教师 / 管理员 / ClassHelper 班级端可以维护"未交名单" */
|
||||
function assertCanManageSubmissions(user: TokenPayload): void {
|
||||
if (user.role === 'ADMIN' || user.role === 'TEACHER') return;
|
||||
if (isClassSession(user)) return;
|
||||
throw ApiError.forbidden('只有教师或班级设备可以维护未交名单');
|
||||
if (isClassDevice(user)) return;
|
||||
throw ApiError.forbidden('只有教师或 ClassHelper 班级端可以维护未交名单');
|
||||
}
|
||||
|
||||
/** 读取作业提交名单(教师 / 班级设备) */
|
||||
/** 读取作业提交名单(教师 / 班级端) */
|
||||
export async function listHomeworkSubmissions(
|
||||
user: TokenPayload,
|
||||
homeworkId: string,
|
||||
@@ -344,7 +348,7 @@ export async function listHomeworkSubmissions(
|
||||
|
||||
/**
|
||||
* 保存"未交名单":勾选的学生标记未完成,其余学生一律标记完成。
|
||||
* 这样班级设备的操作语义是"点名谁没交",比逐个勾"谁交了"更省事。
|
||||
* 这样教室机器的操作语义是"点名谁没交",比逐个勾"谁交了"更省事。
|
||||
*/
|
||||
export async function updateHomeworkSubmissions(
|
||||
user: TokenPayload,
|
||||
@@ -359,13 +363,13 @@ export async function updateHomeworkSubmissions(
|
||||
await assertClassAccess(user, homework.classId);
|
||||
assertCanManageSubmissions(user);
|
||||
|
||||
const students = await prisma.user.findMany({
|
||||
where: { classId: homework.classId, role: 'STUDENT' },
|
||||
const students = await prisma.student.findMany({
|
||||
where: { classId: homework.classId, archivedYearId: null },
|
||||
select: { id: true },
|
||||
});
|
||||
if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生账号');
|
||||
if (students.length === 0) throw ApiError.badRequest('当前班级还没有学生,请先在名单里添加学生');
|
||||
|
||||
const notSubmitted = new Set(input.notSubmittedUserIds);
|
||||
const notSubmitted = new Set(input.notSubmittedStudentIds);
|
||||
const studentIds = new Set(students.map((item) => item.id));
|
||||
const outsiders = [...notSubmitted].filter((id) => !studentIds.has(id));
|
||||
if (outsiders.length > 0) throw ApiError.badRequest('未交名单里包含非本班学生');
|
||||
@@ -373,15 +377,15 @@ export async function updateHomeworkSubmissions(
|
||||
await prisma.$transaction(
|
||||
students.map((item) =>
|
||||
prisma.homeworkStatus.upsert({
|
||||
where: { homeworkId_userId: { homeworkId, userId: item.id } },
|
||||
create: { homeworkId, userId: item.id, completed: !notSubmitted.has(item.id) },
|
||||
where: { homeworkId_studentId: { homeworkId, studentId: item.id } },
|
||||
create: { homeworkId, studentId: item.id, completed: !notSubmitted.has(item.id) },
|
||||
update: { completed: !notSubmitted.has(item.id) },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const result = await buildSubmissions(homework.classId, homeworkId);
|
||||
// 实时刷新教师端/学生端的完成情况
|
||||
// 实时刷新教师端/班级端的完成情况
|
||||
emitToClass(homework.classId, SOCKET_EVENTS.homeworkStatus, {
|
||||
homeworkId,
|
||||
classId: homework.classId,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/** 导入类型:成绩 / 学生名单 / 教师名单(仅管理员) */
|
||||
export const IMPORT_KINDS = ['grades', 'students', 'teachers'] as const;
|
||||
/** 导入类型:成绩 / 学生名单 / 教师名单 / 班级任课老师 */
|
||||
export const IMPORT_KINDS = ['grades', 'students', 'teachers', 'classTeachers'] as const;
|
||||
|
||||
/** 表格导入:上传的文件(base64)+ 类型 */
|
||||
export const tableFileSchema = z.object({
|
||||
@@ -17,12 +17,17 @@ export const tableFileSchema = z.object({
|
||||
|
||||
/** 表格提交:在预览基础上带字段映射、目标班级与写入模式 */
|
||||
export const tableCommitSchema = tableFileSchema.extend({
|
||||
/** 目标班级:成绩/学生名单必填;教师名单与班级无关(可省略) */
|
||||
/** 目标班级:成绩/学生名单必填;教师名单与本班任课老师也可用表格里的班级列 */
|
||||
classId: z.string().min(1, '请选择班级').optional(),
|
||||
/** 列映射:规范字段 → 文件列名(或列索引字符串) */
|
||||
mapping: z.record(z.string(), z.string().min(1)),
|
||||
/** append = 仅新增;upsert = 已存在则更新(成绩按 学生+考试+课程,名单按用户名) */
|
||||
/** append = 仅新增;upsert = 已存在则更新(成绩按 学生+考试+课程,名单按学号) */
|
||||
mode: z.enum(['append', 'upsert']).default('upsert'),
|
||||
/**
|
||||
* **班级任课老师导入**专用:是否用「工号」去匹配已有教师账号。
|
||||
* 打开时优先按工号匹配(匹配不到就按工号新建账号);关闭时只按姓名匹配、匹配不到即报错。
|
||||
*/
|
||||
useTeacherNo: z.boolean().optional(),
|
||||
});
|
||||
|
||||
/** ClassIsland 课表时间导入:先校验/预览,再提交 */
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import * as XLSX from 'xlsx';
|
||||
import { formatClassName, normalizeStudentNo, type TableImportKind } from '@classhelper/shared';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { env } from '../../config/env.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { hashPassword } from '../../lib/password.js';
|
||||
import { assertCanManageGrades, assertCanManageRoster } from '../../lib/access.js';
|
||||
import { resolveUserClassRole } from '../../lib/access.js';
|
||||
import { canImport } from '@classhelper/shared';
|
||||
import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
|
||||
|
||||
/**
|
||||
@@ -16,23 +18,32 @@ import type { TableCommitInput, TableFileInput } from './imports.schemas.js';
|
||||
*
|
||||
* 异常处理:空文件、超大文件、格式错误、缺列、非法数值、重复数据、未知学生等
|
||||
* 都会给出明确原因与行号,绝不静默失败。
|
||||
*
|
||||
* **学生一律按「学号」匹配**(原「用户名」列已改名):
|
||||
* 学生不是账号,导入只生成班级名单,不产生任何登录凭证。
|
||||
*/
|
||||
const MAX_IMPORT_BYTES = 8 * 1024 * 1024; // 8MB
|
||||
const PREVIEW_ROWS = 20;
|
||||
|
||||
export type ImportKind = TableFileInput['kind'];
|
||||
|
||||
interface ImportField {
|
||||
key: string;
|
||||
label: string;
|
||||
required: boolean;
|
||||
synonyms: string[];
|
||||
}
|
||||
|
||||
/** 规范字段定义(预览、模板、映射提示共用) */
|
||||
export const IMPORT_FIELDS: Record<
|
||||
ImportKind,
|
||||
{ key: string; label: string; required: boolean; synonyms: string[] }[]
|
||||
> = {
|
||||
export const IMPORT_FIELDS: Record<ImportKind, ImportField[]> = {
|
||||
grades: [
|
||||
{
|
||||
key: 'username',
|
||||
label: '学生用户名',
|
||||
key: 'studentNo',
|
||||
label: '学生学号',
|
||||
required: false,
|
||||
synonyms: ['用户名', 'username', '学号', '账号'],
|
||||
// 「用户名 / username」保留为兼容别名:老模板导出的文件还能直接导入,
|
||||
// 但界面上、提示里一律叫「学号」。
|
||||
synonyms: ['学号', 'studentNo', '用户名', 'username', '账号', '编号'],
|
||||
},
|
||||
{ key: 'name', label: '学生姓名', required: false, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
|
||||
{
|
||||
@@ -43,16 +54,46 @@ export const IMPORT_FIELDS: Record<
|
||||
},
|
||||
{ key: 'score', label: '分数', required: true, synonyms: ['分数', 'score', '成绩', '得分'] },
|
||||
{ key: 'totalScore', label: '总分', required: false, synonyms: ['总分', 'totalScore', '满分'] },
|
||||
{ key: 'courseName', label: '课程', required: false, synonyms: ['课程', '科目', 'course', 'courseName'] },
|
||||
{ key: 'courseName', label: '科目', required: false, synonyms: ['科目', '课程', 'course', 'courseName'] },
|
||||
{
|
||||
key: 'level',
|
||||
label: '等级',
|
||||
required: false,
|
||||
synonyms: ['等级', 'level', '档次', '评级'],
|
||||
},
|
||||
],
|
||||
students: [
|
||||
// 学生只有名单没有账号(不能登录、无密码),模板不再有"初始密码"列
|
||||
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '学号', '账号'] },
|
||||
// 学生只有名单没有账号(不能登录、无密码),模板里既没有密码列也不该有
|
||||
{
|
||||
key: 'studentNo',
|
||||
label: '学号',
|
||||
required: true,
|
||||
synonyms: ['学号', 'studentNo', '用户名', 'username', '账号', '学籍号', '编号'],
|
||||
},
|
||||
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '学生', '学生姓名'] },
|
||||
{
|
||||
key: 'className',
|
||||
label: '班级',
|
||||
required: false,
|
||||
synonyms: ['班级', 'class', 'className', '班', '所在班级'],
|
||||
},
|
||||
{ key: 'gender', label: '性别', required: false, synonyms: ['性别', 'gender', 'sex'] },
|
||||
{
|
||||
key: 'guardianPhone',
|
||||
label: '家长手机号',
|
||||
required: false,
|
||||
synonyms: ['家长手机号', '家长电话', '手机号', '联系电话', 'phone', '电话', '家长联系方式'],
|
||||
},
|
||||
],
|
||||
teachers: [
|
||||
{ key: 'username', label: '用户名', required: true, synonyms: ['用户名', 'username', '工号', '账号'] },
|
||||
{
|
||||
key: 'username',
|
||||
label: '工号',
|
||||
required: true,
|
||||
synonyms: ['工号', '用户名', 'username', '账号', '教师工号'],
|
||||
},
|
||||
{ key: 'name', label: '姓名', required: true, synonyms: ['姓名', 'name', '教师', '教师姓名'] },
|
||||
{ key: 'phone', label: '手机号', required: false, synonyms: ['手机号', '电话', 'phone', '联系方式'] },
|
||||
{ key: 'password', label: '初始密码', required: false, synonyms: ['密码', 'password', '初始密码'] },
|
||||
{
|
||||
key: 'role',
|
||||
@@ -61,6 +102,39 @@ export const IMPORT_FIELDS: Record<
|
||||
synonyms: ['角色', 'role', '身份', '权限', '管理员'],
|
||||
},
|
||||
],
|
||||
classTeachers: [
|
||||
{
|
||||
key: 'className',
|
||||
label: '班级',
|
||||
required: true,
|
||||
synonyms: ['班级', 'class', 'className', '班', '班级名称'],
|
||||
},
|
||||
{
|
||||
key: 'subjectName',
|
||||
label: '科目',
|
||||
required: false,
|
||||
// 班主任行没有科目;科任行必须有
|
||||
synonyms: ['科目', '课程', '学科', 'subject', 'subjectName'],
|
||||
},
|
||||
{
|
||||
key: 'teacherNo',
|
||||
label: '教师工号',
|
||||
required: true,
|
||||
synonyms: ['工号', '教师工号', '用户名', 'teacherNo', 'username', '账号'],
|
||||
},
|
||||
{
|
||||
key: 'teacherName',
|
||||
label: '教师姓名',
|
||||
required: false,
|
||||
synonyms: ['姓名', '教师姓名', '教师', 'name', 'teacherName'],
|
||||
},
|
||||
{
|
||||
key: 'role',
|
||||
label: '角色',
|
||||
required: true,
|
||||
synonyms: ['角色', 'role', '身份', '职务'],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
export interface TablePreview {
|
||||
@@ -105,9 +179,7 @@ function parseTable(buffer: Buffer, fileName: string): ParsedTable {
|
||||
400,
|
||||
'IMPORT_FORMAT_INVALID',
|
||||
`文件解析失败:${(error as Error).message}(支持 .xlsx/.xls/.csv)`,
|
||||
{
|
||||
fileName,
|
||||
},
|
||||
{ fileName },
|
||||
);
|
||||
}
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
@@ -137,36 +209,37 @@ function parseTable(buffer: Buffer, fileName: string): ParsedTable {
|
||||
return { columns, rows };
|
||||
}
|
||||
|
||||
/** 各类导入的模板示例行(与 IMPORT_FIELDS 的顺序一一对应) */
|
||||
const TEMPLATE_SAMPLES: Record<ImportKind, (string | number)[]> = {
|
||||
grades: ['202601', '王小明', '期中考试', 92, 100, '数学', 'A'],
|
||||
students: ['202601', '王小明', '2026级1班', '男', '13800000000'],
|
||||
teachers: ['T1001', '王老师', '13800000000', 'teacher123', '教师'],
|
||||
classTeachers: ['2026级1班', '数学', 'T1001', '王老师', '科任'],
|
||||
};
|
||||
|
||||
const TEMPLATE_SHEET_NAMES: Record<ImportKind, string> = {
|
||||
grades: '成绩',
|
||||
students: '学生名单',
|
||||
teachers: '教师名单',
|
||||
classTeachers: '班级任课老师',
|
||||
};
|
||||
|
||||
/** 生成模板 CSV(同时用于预览弹窗的"下载模板") */
|
||||
export function buildTemplateCsv(kind: ImportKind): string {
|
||||
const fields = IMPORT_FIELDS[kind];
|
||||
const header = fields.map((field) => field.label).join(',');
|
||||
const sample =
|
||||
kind === 'grades'
|
||||
? ['student01', '王小明', '期中考试', '92', '100', '数学']
|
||||
: kind === 'teachers'
|
||||
? ['teacher3', '王老师', 'teacher123', '教师']
|
||||
: ['student01', '王小明', 'student123'];
|
||||
const sampleLine = fields
|
||||
.map((field) => sample[fields.findIndex((item) => item.key === field.key)] ?? '')
|
||||
.join(',');
|
||||
return `\ufeff${header}\n${sampleLine}\n`;
|
||||
const sample = TEMPLATE_SAMPLES[kind];
|
||||
const sampleLine = fields.map((_, index) => sample[index] ?? '').join(',');
|
||||
return `${header}\n${sampleLine}\n`;
|
||||
}
|
||||
|
||||
/** 生成模板 XLSX(Base64),让老师可以直接下载后填写 */
|
||||
export function buildTemplateXlsx(kind: ImportKind): string {
|
||||
const fields = IMPORT_FIELDS[kind];
|
||||
const header = fields.map((field) => field.label);
|
||||
const sample =
|
||||
kind === 'grades'
|
||||
? ['student01', '王小明', '期中考试', 92, 100, '数学']
|
||||
: kind === 'teachers'
|
||||
? ['teacher3', '王老师', 'teacher123', '教师']
|
||||
: ['student01', '王小明', 'student123'];
|
||||
const sheet = XLSX.utils.aoa_to_sheet([header, sample]);
|
||||
const sheet = XLSX.utils.aoa_to_sheet([header, TEMPLATE_SAMPLES[kind]]);
|
||||
const workbook = XLSX.utils.book_new();
|
||||
const sheetName = kind === 'grades' ? '成绩' : kind === 'teachers' ? '教师名单' : '学生名单';
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, sheetName);
|
||||
XLSX.utils.book_append_sheet(workbook, sheet, TEMPLATE_SHEET_NAMES[kind]);
|
||||
return XLSX.write(workbook, { type: 'base64', bookType: 'xlsx' });
|
||||
}
|
||||
|
||||
@@ -235,20 +308,32 @@ function cellValue(row: string[], columns: string[], mapping: Record<string, str
|
||||
return (row[index] ?? '').trim();
|
||||
}
|
||||
|
||||
/** 导入成绩:学生按用户名/姓名匹配;重复(同班级+学生+考试+课程)按 mode 处理 */
|
||||
/** 性别文本归一:男/M/1 → MALE;女/F/2 → FEMALE;其余空 */
|
||||
function parseGender(value: string): '' | 'MALE' | 'FEMALE' {
|
||||
const text = value.trim();
|
||||
if (!text) return '';
|
||||
if (/^(男|male|m|1)$/i.test(text)) return 'MALE';
|
||||
if (/^(女|female|f|2)$/i.test(text)) return 'FEMALE';
|
||||
return '';
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 成绩 */
|
||||
|
||||
/** 导入成绩:学生按**学号**匹配(学号缺失时退化为按姓名匹配) */
|
||||
export async function commitGrades(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
const classId = input.classId;
|
||||
if (!classId) throw ApiError.badRequest('导入成绩需要先选择班级');
|
||||
// 成绩导入:管理员或本班班主任(需求 6);科任 403
|
||||
await assertCanManageGrades(user, classId);
|
||||
await assertImportAllowed(user, classId, 'grades');
|
||||
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
|
||||
const students = await prisma.user.findMany({
|
||||
where: { classId, role: 'STUDENT' },
|
||||
select: { id: true, username: true, name: true },
|
||||
const students = await prisma.student.findMany({
|
||||
where: { classId },
|
||||
select: { id: true, studentNo: true, name: true },
|
||||
});
|
||||
const byUsername = new Map(students.map((item) => [item.username.toLowerCase(), item]));
|
||||
// 学号在库里已归一化为大写,比对前统一一次
|
||||
const byStudentNo = new Map(students.map((item) => [normalizeStudentNo(item.studentNo), item]));
|
||||
const byName = new Map(students.map((item) => [item.name.toLowerCase(), item]));
|
||||
const courses = await prisma.course.findMany({
|
||||
where: { classId },
|
||||
@@ -263,22 +348,23 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
|
||||
for (const [index, row] of parsed.rows.entries()) {
|
||||
const rowNumber = index + 2; // 含表头,从 Excel 视角的行号
|
||||
const username = cellValue(row, parsed.columns, input.mapping, 'username');
|
||||
const studentNo = cellValue(row, parsed.columns, input.mapping, 'studentNo');
|
||||
const name = cellValue(row, parsed.columns, input.mapping, 'name');
|
||||
const examName = cellValue(row, parsed.columns, input.mapping, 'examName');
|
||||
const scoreText = cellValue(row, parsed.columns, input.mapping, 'score');
|
||||
const totalText = cellValue(row, parsed.columns, input.mapping, 'totalScore');
|
||||
const courseName = cellValue(row, parsed.columns, input.mapping, 'courseName');
|
||||
const levelText = cellValue(row, parsed.columns, input.mapping, 'level');
|
||||
|
||||
if (!username && !name) {
|
||||
errors.push({ row: rowNumber, message: '缺少学生标识(用户名与姓名至少填写一个)' });
|
||||
if (!studentNo && !name) {
|
||||
errors.push({ row: rowNumber, message: '缺少学生标识(学号与姓名至少填写一个)' });
|
||||
continue;
|
||||
}
|
||||
const student =
|
||||
(username ? byUsername.get(username.toLowerCase()) : undefined) ??
|
||||
(studentNo ? byStudentNo.get(normalizeStudentNo(studentNo)) : undefined) ??
|
||||
(name ? byName.get(name.toLowerCase()) : undefined);
|
||||
if (!student) {
|
||||
errors.push({ row: rowNumber, message: `找不到该班学生:${username || name}` });
|
||||
errors.push({ row: rowNumber, message: `找不到该班学生:${studentNo || name}` });
|
||||
continue;
|
||||
}
|
||||
if (!examName) {
|
||||
@@ -286,7 +372,7 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
continue;
|
||||
}
|
||||
const score = Number(scoreText);
|
||||
if (!Number.isFinite(score)) {
|
||||
if (scoreText === '' || !Number.isFinite(score)) {
|
||||
errors.push({ row: rowNumber, message: `分数「${scoreText}」不是数字` });
|
||||
continue;
|
||||
}
|
||||
@@ -302,10 +388,12 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
|
||||
const course = courseName ? courses.find((item) => item.name === courseName) : null;
|
||||
if (courseName && !course)
|
||||
warnings.push(`第 ${rowNumber} 行:课程「${courseName}」不存在,已按"不指定课程"导入`);
|
||||
warnings.push(`第 ${rowNumber} 行:科目「${courseName}」不存在,已按"不指定科目"导入`);
|
||||
|
||||
// 表格里带等级列就按"自定义等级"落库(教师明确给了等级),否则按百分制换算
|
||||
const levelType = levelText ? 'custom' : 'percent';
|
||||
const existing = await prisma.grade.findFirst({
|
||||
where: { classId: classId, userId: student.id, examName, courseId: course?.id ?? null },
|
||||
where: { classId, studentId: student.id, examName, courseId: course?.id ?? null },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
@@ -316,18 +404,20 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
}
|
||||
await prisma.grade.update({
|
||||
where: { id: existing.id },
|
||||
data: { score, totalScore, publishedAt: new Date() },
|
||||
data: { score, totalScore, levelType, level: levelText, publishedAt: new Date() },
|
||||
});
|
||||
updated += 1;
|
||||
} else {
|
||||
await prisma.grade.create({
|
||||
data: {
|
||||
classId: classId,
|
||||
userId: student.id,
|
||||
classId,
|
||||
studentId: student.id,
|
||||
courseId: course?.id ?? null,
|
||||
examName,
|
||||
score,
|
||||
totalScore,
|
||||
levelType,
|
||||
level: levelText,
|
||||
},
|
||||
});
|
||||
inserted += 1;
|
||||
@@ -350,16 +440,34 @@ export async function commitGrades(user: TokenPayload, input: TableCommitInput):
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 导入学生名单:按用户名去重(append 跳过 / upsert 更新姓名与班级),仅管理员可用 */
|
||||
/* ------------------------------------------------------------------ 学生名单 */
|
||||
|
||||
/**
|
||||
* 导入学生名单:**按学号**去重(append 跳过 / upsert 更新姓名、班级、性别、家长手机号)。
|
||||
*
|
||||
* 只写 `Student`(名单记录),**不创建任何账号** —— 学生没有密码、不能登录。
|
||||
* 班级列可选:填了按班级名解析(重名报错),否则用弹窗里选中的班级。
|
||||
*/
|
||||
export async function commitStudents(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
assertCanManageRoster(user);
|
||||
const classId = input.classId;
|
||||
if (!classId) throw ApiError.badRequest('导入学生名单需要先选择班级');
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
|
||||
const cls = await prisma.class.findUnique({ where: { id: classId }, select: { id: true } });
|
||||
if (!cls) throw ApiError.notFound('班级不存在');
|
||||
// 班级列可用时按名字解析;同名班级(跨年级)会让导入变得含糊,直接报错让老师改用班级码
|
||||
const hasClassColumn = Boolean(input.mapping.className);
|
||||
const fallbackClassId = input.classId ?? null;
|
||||
if (fallbackClassId) await assertImportAllowed(user, fallbackClassId, 'students');
|
||||
else if (!hasClassColumn) throw ApiError.badRequest('导入学生名单需要选择班级,或在表格里提供「班级」列');
|
||||
|
||||
const allClasses = await prisma.class.findMany({
|
||||
where: { archivedYearId: null },
|
||||
select: { id: true, name: true, enrollmentYear: true, classIndex: true },
|
||||
});
|
||||
const classByName = new Map<string, { id: string; name: string }>();
|
||||
for (const item of allClasses) {
|
||||
const key = item.name.toLowerCase();
|
||||
if (classByName.has(key)) classByName.delete(key); // 重名 → 标记为不可用
|
||||
else classByName.set(key, item);
|
||||
}
|
||||
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
@@ -369,40 +477,91 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
||||
|
||||
for (const [index, row] of parsed.rows.entries()) {
|
||||
const rowNumber = index + 2;
|
||||
const username = cellValue(row, parsed.columns, input.mapping, 'username');
|
||||
const studentNoRaw = cellValue(row, parsed.columns, input.mapping, 'studentNo');
|
||||
const name = cellValue(row, parsed.columns, input.mapping, 'name');
|
||||
const className = cellValue(row, parsed.columns, input.mapping, 'className');
|
||||
const gender = parseGender(cellValue(row, parsed.columns, input.mapping, 'gender'));
|
||||
const guardianPhone = cellValue(row, parsed.columns, input.mapping, 'guardianPhone');
|
||||
|
||||
if (!username || !name) {
|
||||
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
|
||||
if (!studentNoRaw || !name) {
|
||||
errors.push({ row: rowNumber, message: '学号与姓名都必须填写' });
|
||||
continue;
|
||||
}
|
||||
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
|
||||
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
|
||||
const studentNo = normalizeStudentNo(studentNoRaw);
|
||||
if (!/^[A-Za-z0-9._-]{1,32}$/.test(studentNo)) {
|
||||
errors.push({ row: rowNumber, message: `学号「${studentNoRaw}」格式不合法(1~32 位字母/数字/._-)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
|
||||
if (existing) {
|
||||
if (existing.role !== 'STUDENT') {
|
||||
errors.push({ row: rowNumber, message: `用户名「${username}」已被教师/管理员账号占用` });
|
||||
// 目标班级:表格里的班级列优先,其次弹窗选的班级
|
||||
let classId = fallbackClassId;
|
||||
if (className) {
|
||||
const matched = classByName.get(className.toLowerCase());
|
||||
if (!matched) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: `找不到班级「${className}」(可能已毕业归档或名称重复,请核对后在班级管理里查看)`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
classId = matched.id;
|
||||
if (fallbackClassId && fallbackClassId !== matched.id) {
|
||||
// 表格里写了班级就以表格为准,只是提醒一下
|
||||
warnings.push(`第 ${rowNumber} 行:按表格里的班级「${className}」导入(与所选班级不同)`);
|
||||
}
|
||||
}
|
||||
if (!classId) {
|
||||
errors.push({ row: rowNumber, message: '该行没有班级信息,请填写「班级」列或在上一步选择班级' });
|
||||
continue;
|
||||
}
|
||||
await assertImportAllowed(user, classId, 'students');
|
||||
|
||||
const existing = await prisma.student.findUnique({
|
||||
where: { studentNo },
|
||||
select: { id: true, classId: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
if (input.mode === 'append') {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await prisma.user.update({ where: { id: existing.id }, data: { name, classId: classId } });
|
||||
if (existing.classId !== classId) {
|
||||
// 换班要留痕,与手动调班同一种历史
|
||||
const [from, to] = await Promise.all([
|
||||
classNameOf(existing.classId),
|
||||
classNameOf(classId),
|
||||
]);
|
||||
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,
|
||||
classId,
|
||||
status: 'active',
|
||||
...(gender ? { gender } : {}),
|
||||
...(guardianPhone ? { guardianPhone } : {}),
|
||||
},
|
||||
});
|
||||
updated += 1;
|
||||
} else {
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
username,
|
||||
name,
|
||||
role: 'STUDENT',
|
||||
classId: classId,
|
||||
// 学生不设密码(空串占位,登录 403 判定在密码校验之前),见"学生只有名单没有账号"
|
||||
passwordHash: '',
|
||||
},
|
||||
await prisma.student.create({
|
||||
data: { studentNo, name, classId, gender, guardianPhone },
|
||||
});
|
||||
inserted += 1;
|
||||
}
|
||||
@@ -419,19 +578,19 @@ export async function commitStudents(user: TokenPayload, input: TableCommitInput
|
||||
warnings,
|
||||
};
|
||||
logger.info(
|
||||
`学生名单导入:班级=${classId} 模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
`学生名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 教师名单 */
|
||||
|
||||
/**
|
||||
* 导入教师名单:按用户名去重(append 跳过 / upsert 更新姓名与角色),**仅管理员**可用。
|
||||
*
|
||||
* 与班级无关(教师不属于班级),因此不需要 classId,模板里也没有班级列。
|
||||
* 角色列可选:填「管理员 / ADMIN / admin」建管理员账号,其余一律建教师账号。
|
||||
* 导入教师名单:按**工号**去重(append 跳过 / upsert 更新姓名与角色),**仅管理员**可用。
|
||||
* 与班级无关(教师不属于班级),因此不需要 classId。
|
||||
*/
|
||||
export async function commitTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
assertCanManageRoster(user);
|
||||
await assertImportAllowed(user, undefined, 'teachers');
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
|
||||
@@ -445,30 +604,30 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
|
||||
const rowNumber = index + 2;
|
||||
const username = cellValue(row, parsed.columns, input.mapping, 'username');
|
||||
const name = cellValue(row, parsed.columns, input.mapping, 'name');
|
||||
const phone = cellValue(row, parsed.columns, input.mapping, 'phone');
|
||||
const password = cellValue(row, parsed.columns, input.mapping, 'password');
|
||||
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
|
||||
|
||||
if (!username || !name) {
|
||||
errors.push({ row: rowNumber, message: '用户名与姓名都必须填写' });
|
||||
errors.push({ row: rowNumber, message: '工号与姓名都必须填写' });
|
||||
continue;
|
||||
}
|
||||
if (!/^[A-Za-z0-9_.-]{3,32}$/.test(username)) {
|
||||
errors.push({ row: rowNumber, message: `用户名「${username}」格式不合法(3~32 位字母/数字/_.-)` });
|
||||
errors.push({ row: rowNumber, message: `工号「${username}」格式不合法(3~32 位字母/数字/._-)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const existing = await prisma.user.findUnique({ where: { username }, select: { id: true, role: true } });
|
||||
if (existing) {
|
||||
if (existing.role === 'STUDENT') {
|
||||
errors.push({ row: rowNumber, message: `用户名「${username}」已被学生账号占用` });
|
||||
continue;
|
||||
}
|
||||
if (input.mode === 'append') {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
const nextRole = parseTeacherRole(roleCell) ?? existing.role;
|
||||
await prisma.user.update({ where: { id: existing.id }, data: { name, role: nextRole } });
|
||||
await prisma.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { name, role: nextRole, ...(phone ? { phone } : {}) },
|
||||
});
|
||||
updated += 1;
|
||||
} else {
|
||||
const initialPassword = password || env.defaultTeacherPassword;
|
||||
@@ -476,6 +635,7 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
|
||||
data: {
|
||||
username,
|
||||
name,
|
||||
phone,
|
||||
role: parseTeacherRole(roleCell) ?? 'TEACHER',
|
||||
passwordHash: await hashPassword(initialPassword),
|
||||
},
|
||||
@@ -494,12 +654,216 @@ export async function commitTeachers(user: TokenPayload, input: TableCommitInput
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
logger.info(`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 班级任课老师 */
|
||||
|
||||
/**
|
||||
* 导入班级任课老师(班级 + 科目 + 工号 + 姓名 + 角色),**仅管理员**。
|
||||
*
|
||||
* 角色列:
|
||||
* - 「班主任」→ 写 `Class.teacherId`(每班 1 人,后导入的覆盖先导入的);
|
||||
* - 「科任」 → 写 `Course.teacherId`(该班该科的任课老师,没有该科目就自动建课)。
|
||||
*
|
||||
* **不产生重复教师**:同一工号在表格里出现多次(班主任行 + 科任行)时,
|
||||
* 后面几行指向的还是同一个账号(`useTeacherNo` 打开时按工号匹配,关闭时按姓名唯一匹配),
|
||||
* 因此"班主任同时是科任老师"只会有一条教师记录,权限在 `resolveClassRole` 里合并。
|
||||
*/
|
||||
export async function commitClassTeachers(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
await assertImportAllowed(user, undefined, 'classTeachers');
|
||||
const buffer = decodeBase64(input.contentBase64);
|
||||
const parsed = parseTable(buffer, input.fileName);
|
||||
|
||||
const useTeacherNo = input.useTeacherNo !== false; // 默认开:有工号就优先按工号匹配
|
||||
|
||||
const errors: ImportRowError[] = [];
|
||||
const warnings: string[] = [];
|
||||
let inserted = 0;
|
||||
let updated = 0;
|
||||
let skipped = 0;
|
||||
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { archivedYearId: null },
|
||||
select: { id: true, name: true, teacherId: true },
|
||||
});
|
||||
const classByName = new Map<string, { id: string; name: string; teacherId: string }>();
|
||||
for (const item of classes) {
|
||||
const key = item.name.toLowerCase();
|
||||
if (classByName.has(key)) classByName.delete(key);
|
||||
else classByName.set(key, item);
|
||||
}
|
||||
|
||||
// 本次导入新建的教师账号(同一份表格里再遇到同名/同工号时复用,避免重复建号)
|
||||
const createdTeachers = new Map<string, string>();
|
||||
|
||||
for (const [index, row] of parsed.rows.entries()) {
|
||||
const rowNumber = index + 2;
|
||||
const className = cellValue(row, parsed.columns, input.mapping, 'className');
|
||||
const subjectName = cellValue(row, parsed.columns, input.mapping, 'subjectName');
|
||||
const teacherNo = cellValue(row, parsed.columns, input.mapping, 'teacherNo');
|
||||
const teacherName = cellValue(row, parsed.columns, input.mapping, 'teacherName');
|
||||
const roleCell = cellValue(row, parsed.columns, input.mapping, 'role');
|
||||
|
||||
if (!className || !teacherNo) {
|
||||
errors.push({ row: rowNumber, message: '班级与教师工号都必须填写' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const targetClass = classByName.get(className.toLowerCase());
|
||||
if (!targetClass) {
|
||||
errors.push({ row: rowNumber, message: `找不到班级「${className}」(可能已毕业归档或名称重复)` });
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = parseClassTeacherRole(roleCell);
|
||||
if (!role) {
|
||||
errors.push({ row: rowNumber, message: `角色「${roleCell}」无法识别,请填「班主任」或「科任」` });
|
||||
continue;
|
||||
}
|
||||
if (role === 'SUBJECT' && !subjectName) {
|
||||
errors.push({ row: rowNumber, message: '科任老师必须填写科目' });
|
||||
continue;
|
||||
}
|
||||
|
||||
// ---- 匹配或新建教师账号
|
||||
let teacherId = createdTeachers.get(teacherNo.toLowerCase());
|
||||
if (!teacherId) {
|
||||
if (useTeacherNo) {
|
||||
const byNo = await prisma.user.findUnique({
|
||||
where: { username: teacherNo },
|
||||
select: { id: true },
|
||||
});
|
||||
if (byNo) {
|
||||
teacherId = byNo.id;
|
||||
}
|
||||
} else if (teacherName) {
|
||||
const byName = await prisma.user.findMany({
|
||||
where: { name: teacherName, role: { in: ['TEACHER', 'ADMIN'] } },
|
||||
select: { id: true },
|
||||
take: 2,
|
||||
});
|
||||
if (byName.length === 1) {
|
||||
teacherId = byName[0]!.id;
|
||||
} else if (byName.length > 1) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: `姓名「${teacherName}」对应多个教师账号,无法确定是哪一个;请打开「使用工号匹配」再导入`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!teacherId) {
|
||||
if (!useTeacherNo) {
|
||||
errors.push({
|
||||
row: rowNumber,
|
||||
message: `按姓名找不到教师「${teacherName || teacherNo}」;请打开「使用工号匹配」以便按工号新建账号`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
username: teacherNo,
|
||||
name: teacherName || teacherNo,
|
||||
role: 'TEACHER',
|
||||
passwordHash: await hashPassword(env.defaultTeacherPassword),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
teacherId = created.id;
|
||||
inserted += 1;
|
||||
warnings.push(
|
||||
`第 ${rowNumber} 行:工号 ${teacherNo} 没有对应账号,已新建教师「${teacherName || teacherNo}」(初始密码见部署配置)`,
|
||||
);
|
||||
}
|
||||
createdTeachers.set(teacherNo.toLowerCase(), teacherId);
|
||||
|
||||
// ---- 写入角色
|
||||
if (role === 'HEAD') {
|
||||
if (targetClass.teacherId === teacherId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await prisma.class.update({ where: { id: targetClass.id }, data: { teacherId } });
|
||||
targetClass.teacherId = teacherId;
|
||||
updated += 1;
|
||||
} else {
|
||||
const existing = await prisma.course.findUnique({
|
||||
where: { classId_name: { classId: targetClass.id, name: subjectName } },
|
||||
select: { id: true, teacherId: true },
|
||||
});
|
||||
if (existing) {
|
||||
if (existing.teacherId === teacherId) {
|
||||
skipped += 1;
|
||||
continue;
|
||||
}
|
||||
await prisma.course.update({ where: { id: existing.id }, data: { teacherId } });
|
||||
} else {
|
||||
await prisma.course.create({
|
||||
data: { name: subjectName, classId: targetClass.id, teacherId },
|
||||
});
|
||||
}
|
||||
updated += 1;
|
||||
}
|
||||
}
|
||||
|
||||
const result: ImportResult = {
|
||||
kind: 'classTeachers',
|
||||
total: parsed.rows.length,
|
||||
inserted,
|
||||
updated,
|
||||
skipped,
|
||||
failed: errors.length,
|
||||
errors,
|
||||
warnings,
|
||||
};
|
||||
logger.info(
|
||||
`教师名单导入:模式=${input.mode} 新增=${inserted} 更新=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
`班级任课老师导入:用工号匹配=${useTeacherNo} 新建教师=${inserted} 更新关系=${updated} 跳过=${skipped} 失败=${errors.length}`,
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
if (input.kind === 'teachers') return commitTeachers(user, input);
|
||||
if (input.kind === 'classTeachers') return commitClassTeachers(user, input);
|
||||
if (input.kind === 'grades') return commitGrades(user, input);
|
||||
return commitStudents(user, input);
|
||||
}
|
||||
|
||||
/** 供预览接口复用的模板生成(Web 端「下载模板」) */
|
||||
export function templateFor(kind: TableImportKind): { csv: string; xlsx: string } {
|
||||
return { csv: buildTemplateCsv(kind), xlsx: buildTemplateXlsx(kind) };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 内部工具 */
|
||||
|
||||
/** 导入权限:与学生名单/成绩同一套口径(见 shared 的 canImport) */
|
||||
async function assertImportAllowed(
|
||||
user: TokenPayload,
|
||||
classId: string | undefined,
|
||||
kind: 'grades' | 'students' | 'teachers' | 'classTeachers' | 'scheduleTimes',
|
||||
): Promise<void> {
|
||||
const classRole = classId ? await resolveUserClassRole(user, classId) : 'NONE';
|
||||
if (canImport(user.role, classRole, kind)) return;
|
||||
if (kind === 'students' || kind === 'grades') {
|
||||
throw ApiError.forbidden(
|
||||
classRole === 'SUBJECT' || classRole === 'CLASS_DEVICE'
|
||||
? '科任老师与 ClassHelper 班级端不能导入名单或成绩,请联系班主任或管理员'
|
||||
: '只能导入自己担任班主任的班级的数据',
|
||||
);
|
||||
}
|
||||
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 ?? '';
|
||||
}
|
||||
|
||||
/** 角色列解析:只认「管理员」类词;其余(含空)返回 null 交给调用方取默认值 */
|
||||
function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
|
||||
const value = cell.trim().toUpperCase();
|
||||
@@ -508,8 +872,14 @@ function parseTeacherRole(cell: string): 'TEACHER' | 'ADMIN' | null {
|
||||
return 'TEACHER';
|
||||
}
|
||||
|
||||
export async function commitTable(user: TokenPayload, input: TableCommitInput): Promise<ImportResult> {
|
||||
if (input.kind === 'teachers') return commitTeachers(user, input);
|
||||
if (input.kind === 'grades') return commitGrades(user, input);
|
||||
return commitStudents(user, input);
|
||||
/** 班级任课老师的角色列:班主任 / 科任 */
|
||||
function parseClassTeacherRole(cell: string): 'HEAD' | 'SUBJECT' | null {
|
||||
const value = cell.trim();
|
||||
if (!value) return null;
|
||||
if (value.includes('班主任') || value.toUpperCase() === 'HEAD') return 'HEAD';
|
||||
if (value.includes('科任') || value.includes('任课') || value.toUpperCase() === 'SUBJECT') return 'SUBJECT';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 导出:把班级名与入学年份生成统一称呼(供未来导出功能复用) */
|
||||
export { formatClassName };
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { ApiError, sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedParams, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import * as gradeService from '../grades/grades.service.js';
|
||||
import { studentGradeQuerySchema } from '../grades/grades.schemas.js';
|
||||
import { authenticateDevice, assertDeviceEnabled, getDevice } from './device-auth.js';
|
||||
import {
|
||||
ackNotificationSchema,
|
||||
@@ -70,6 +72,38 @@ router.get('/classisland/class-plan', authenticateDevice(), async (req, res) =>
|
||||
sendOk(res, await integrationService.pullClassPlanForDevice(device), '获取课表成功');
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/integrations/classisland/student-grades?studentNo= - 班级端按学号查成绩明细
|
||||
*
|
||||
* 给"插件形态的 ClassHelper 班级端"留的通道:走设备令牌而不是 JWT。
|
||||
* 受**同一个班级开关**约束(`Class.studentGradeQueryEnabled`),与桌面客户端走的是同一条 service。
|
||||
*/
|
||||
router.get(
|
||||
'/classisland/student-grades',
|
||||
authenticateDevice(),
|
||||
validate({ query: studentGradeQuerySchema }),
|
||||
async (req, res) => {
|
||||
const device = getDevice(req);
|
||||
assertDeviceEnabled(device);
|
||||
const { studentNo } = validatedQuery<{ studentNo?: string }>(req);
|
||||
if (!studentNo) throw ApiError.badRequest('请提供学号(studentNo)');
|
||||
// 设备令牌没有"账号",构造一个等价于 ClassHelper 班级端的会话载荷
|
||||
const session = {
|
||||
sub: device.classId,
|
||||
username: '',
|
||||
name: '',
|
||||
role: 'CLASS_DEVICE' as const,
|
||||
classId: device.classId,
|
||||
classSession: true,
|
||||
};
|
||||
sendOk(
|
||||
res,
|
||||
await gradeService.getStudentGradeDetail(session, studentNo, device.classId),
|
||||
'获取学生成绩明细成功',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/* ================================================================== Web 管理端
|
||||
* 用 JWT 鉴权;权限与"课表管理 / 发通知"保持一致,由 service 内二次校验班级维度角色。
|
||||
*/
|
||||
@@ -91,6 +125,16 @@ router.get(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/integrations/classisland/status - **ClassHelper 班级端在线状态**(教师网页)
|
||||
*
|
||||
* 管理员看全部班级;普通教师看自己担任班主任的班级 ∪ 任课的班级。
|
||||
* 每条含:班级、设备名/设备号、在线/离线、最后在线时间、最后心跳时间。
|
||||
*/
|
||||
router.get('/classisland/status', requireRole('ADMIN', 'TEACHER'), async (req, res) => {
|
||||
sendOk(res, await integrationService.listClassHelperStatus(getAuthUser(req)), '获取在线状态成功');
|
||||
});
|
||||
|
||||
/** POST /api/integrations/devices - 新建设备并一次性返回明文令牌(管理员或本班班主任) */
|
||||
router.post(
|
||||
'/devices',
|
||||
@@ -120,6 +164,25 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/integrations/devices/:id/request-schedule - 「从教室机器获取一次课表」
|
||||
*
|
||||
* **这是现在取教室课表的唯一入口**(原来的自动回传已默认关闭,见
|
||||
* `IntegrationDevice.syncScheduleToServer`)。Web 端点击前会弹警告对话框,
|
||||
* 说明它会用教室机器的课表覆盖本班现有课表。
|
||||
* 置一个待办标记,插件在下一次心跳看到就立刻把课表推上来。
|
||||
*/
|
||||
router.post(
|
||||
'/devices/:id/request-schedule',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: deviceIdParamSchema }),
|
||||
async (req, res) => {
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const result = await integrationService.requestScheduleReport(getAuthUser(req), id);
|
||||
sendOk(res, result, result.nextHeartbeatHint);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/integrations/devices/:id/token - 重置令牌(旧令牌立即失效) */
|
||||
router.post(
|
||||
'/devices/:id/token',
|
||||
|
||||
@@ -22,9 +22,12 @@ export const createDeviceSchema = z.object({
|
||||
classId: z.string().min(1, '请选择班级'),
|
||||
name: z.string().trim().max(40).optional(),
|
||||
mode: integrationModeSchema.optional(),
|
||||
/** 是否允许插件把 ClassIsland 的课表回传到本服务 */
|
||||
/**
|
||||
* 是否允许插件把 ClassIsland 的课表**自动**回传到本服务。
|
||||
* 默认 **false**(课表以服务端为准);要取教室的课表用「从教室机器获取课表」这个手动动作。
|
||||
*/
|
||||
syncScheduleToServer: z.boolean().optional(),
|
||||
/** 是否允许把本班课表镜像回 ClassIsland */
|
||||
/** 是否允许把本班课表镜像回 ClassIsland。默认 **true** —— 这是现在课表同步的主方向。 */
|
||||
mirrorScheduleToClassIsland: z.boolean().optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import {
|
||||
CLASS_HELPER_ONLINE_WINDOW_MS,
|
||||
SOCKET_EVENTS,
|
||||
resolveCurrentWeek,
|
||||
weekDivFromParity,
|
||||
type ClassDto,
|
||||
type ClassHelperDeviceStatusDto,
|
||||
type ClassHelperStatusListDto,
|
||||
type ClassIslandPendingResult,
|
||||
type ClassIslandPushNotification,
|
||||
type ClassIslandReportResult,
|
||||
@@ -13,8 +15,8 @@ import {
|
||||
type TimeLayoutEntry,
|
||||
type NotificationDto,
|
||||
} from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { assertCanManageSchedule, assertCanPublishContent, resolveClassScope } from '../../lib/access.js';
|
||||
import { assertCanManageSchedule, assertCanPublishNotification, resolveClassScope } from '../../lib/access.js';
|
||||
import { loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
@@ -54,7 +56,13 @@ import {
|
||||
|
||||
/* ------------------------------------------------------------------ 设备管理(Web 端,JWT 鉴权) */
|
||||
|
||||
/** 设备记录 -> DTO(tokenHash 永不出库) */
|
||||
/**
|
||||
* 设备记录 -> DTO(tokenHash 永不出库)。
|
||||
*
|
||||
* `online` 由**最后一次心跳**(`lastHeartbeatAt`)与共享的
|
||||
* `CLASS_HELPER_ONLINE_WINDOW_MS`(60 秒)算出,而不是最后一次带状态的上报 ——
|
||||
* 插件每 60 秒打一次心跳,因此"最近一分钟内有心跳"才是最贴合的口径。
|
||||
*/
|
||||
function toDeviceDto(record: {
|
||||
id: string;
|
||||
classId: string;
|
||||
@@ -64,9 +72,11 @@ function toDeviceDto(record: {
|
||||
enabled: boolean;
|
||||
syncScheduleToServer: boolean;
|
||||
mirrorScheduleToClassIsland: boolean;
|
||||
requestScheduleReport: boolean;
|
||||
classIslandVersion: string | null;
|
||||
pluginVersion: string | null;
|
||||
lastSeenAt: Date | null;
|
||||
lastHeartbeatAt: Date | null;
|
||||
classPlanLoaded: boolean;
|
||||
currentSubject: string | null;
|
||||
currentTimeState: string | null;
|
||||
@@ -78,6 +88,7 @@ function toDeviceDto(record: {
|
||||
updatedAt: Date;
|
||||
class?: { name: string } | null;
|
||||
}): IntegrationDeviceDto {
|
||||
const heartbeat = record.lastHeartbeatAt ?? record.lastSeenAt;
|
||||
return {
|
||||
id: record.id,
|
||||
classId: record.classId,
|
||||
@@ -88,9 +99,12 @@ function toDeviceDto(record: {
|
||||
enabled: record.enabled,
|
||||
syncScheduleToServer: record.syncScheduleToServer,
|
||||
mirrorScheduleToClassIsland: record.mirrorScheduleToClassIsland,
|
||||
requestScheduleReport: record.requestScheduleReport,
|
||||
classIslandVersion: record.classIslandVersion,
|
||||
pluginVersion: record.pluginVersion,
|
||||
lastSeenAt: record.lastSeenAt ? record.lastSeenAt.toISOString() : null,
|
||||
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
||||
online: Boolean(record.enabled && heartbeat && Date.now() - heartbeat.getTime() < CLASS_HELPER_ONLINE_WINDOW_MS),
|
||||
classPlanLoaded: record.classPlanLoaded,
|
||||
currentSubject: record.currentSubject,
|
||||
currentTimeState: record.currentTimeState,
|
||||
@@ -103,6 +117,74 @@ function toDeviceDto(record: {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/integrations/classisland/status —— **ClassHelper 班级端在线状态**(教师网页)。
|
||||
*
|
||||
* 范围:管理员看全部班级,普通教师看自己担任班主任的班级 ∪ 任课(有 Course)的班级。
|
||||
* 这是「教师网页可查看班级 ClassHelper 在线状态」这条需求的落点。
|
||||
*/
|
||||
export async function listClassHelperStatus(user: TokenPayload): Promise<ClassHelperStatusListDto> {
|
||||
const scope = await resolveClassScope(user);
|
||||
const classes = await prisma.class.findMany({
|
||||
where: {
|
||||
...(scope.mode === 'all' ? {} : { id: { in: scope.classIds } }),
|
||||
// 归档班级的班级端已经停用,不列出来
|
||||
archivedYearId: null,
|
||||
},
|
||||
orderBy: [{ enrollmentYear: 'desc' }, { classIndex: 'asc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
grade: true,
|
||||
studentGradeQueryEnabled: true,
|
||||
integrationDevices: {
|
||||
where: { mode: 'plugin' },
|
||||
orderBy: [{ lastHeartbeatAt: 'desc' }],
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
deviceKey: true,
|
||||
enabled: true,
|
||||
lastSeenAt: true,
|
||||
lastHeartbeatAt: true,
|
||||
pluginVersion: true,
|
||||
classIslandVersion: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
return {
|
||||
serverTime: new Date().toISOString(),
|
||||
onlineWindowMs: CLASS_HELPER_ONLINE_WINDOW_MS,
|
||||
classes: classes.map((item) => {
|
||||
const devices: ClassHelperDeviceStatusDto[] = item.integrationDevices.map((device) => {
|
||||
const heartbeat = device.lastHeartbeatAt ?? device.lastSeenAt;
|
||||
return {
|
||||
deviceId: device.id,
|
||||
deviceName: device.name,
|
||||
deviceKey: device.deviceKey,
|
||||
enabled: device.enabled,
|
||||
online: Boolean(device.enabled && heartbeat && now - heartbeat.getTime() < CLASS_HELPER_ONLINE_WINDOW_MS),
|
||||
lastSeenAt: device.lastSeenAt ? device.lastSeenAt.toISOString() : null,
|
||||
lastHeartbeatAt: heartbeat ? heartbeat.toISOString() : null,
|
||||
pluginVersion: device.pluginVersion,
|
||||
classIslandVersion: device.classIslandVersion,
|
||||
};
|
||||
});
|
||||
return {
|
||||
classId: item.id,
|
||||
className: item.name,
|
||||
grade: item.grade,
|
||||
devices,
|
||||
online: devices.some((device) => device.online),
|
||||
studentGradeQueryEnabled: item.studentGradeQueryEnabled ?? true,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listDevices(user: TokenPayload, classId?: string): Promise<IntegrationDeviceDto[]> {
|
||||
// 管理员不传 classId 时看全部;教师只看自己可访问的班级
|
||||
const scope = await resolveClassScope(user, classId);
|
||||
@@ -134,13 +216,15 @@ export async function createDevice(
|
||||
const created = await prisma.integrationDevice.create({
|
||||
data: {
|
||||
classId: input.classId,
|
||||
name: input.name?.trim() || `${classRecord.name} ClassIsland 设备`,
|
||||
name: input.name?.trim() || `${classRecord.name} ClassHelper 班级端`,
|
||||
deviceKey: `pending-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
|
||||
mode: input.mode ?? 'plugin',
|
||||
tokenHash: hashDeviceToken(token),
|
||||
tokenHint: tokenHintOf(token),
|
||||
syncScheduleToServer: input.syncScheduleToServer !== false,
|
||||
mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland === true,
|
||||
// 同步方向(2026-10-06 起):课表以服务端为准,**服务端 → 教室自动下发**;
|
||||
// 「把教室课表传上来」改成 Web 端的一次性人工动作,所以默认关掉自动回传。
|
||||
syncScheduleToServer: input.syncScheduleToServer === true,
|
||||
mirrorScheduleToClassIsland: input.mirrorScheduleToClassIsland !== false,
|
||||
},
|
||||
include: { class: { select: { name: true } } },
|
||||
});
|
||||
@@ -153,10 +237,11 @@ async function findDeviceOrFail(id: string): Promise<{
|
||||
id: string;
|
||||
classId: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
}> {
|
||||
const record = await prisma.integrationDevice.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, classId: true, name: true },
|
||||
select: { id: true, classId: true, name: true, enabled: true },
|
||||
});
|
||||
if (!record) throw ApiError.notFound('设备不存在或已被删除');
|
||||
return record;
|
||||
@@ -189,6 +274,37 @@ export async function updateDevice(
|
||||
return toDeviceDto(updated);
|
||||
}
|
||||
|
||||
/**
|
||||
* 「**从教室机器获取一次课表**」的待办(Web 端手动动作)。
|
||||
*
|
||||
* 为什么是"待办"而不是直接拉:课表由**插件主动上报**,服务端没有反向通道去催它。
|
||||
* 所以这里只置一个标记,插件在下一次上报(默认 60 秒一次的心跳)看到
|
||||
* `settings.scheduleRequested = true` 就立刻把当前课表推上来并完成待办。
|
||||
*
|
||||
* 它独立于 `syncScheduleToServer`:后者是"允许自动回传",现在默认**关闭**
|
||||
* (自动回传会在老师手排课之后被教室的旧课表悄悄覆盖);
|
||||
* 管理员显式点"获取课表"时仍然应当能取到一次。
|
||||
*/
|
||||
export async function requestScheduleReport(
|
||||
user: TokenPayload,
|
||||
deviceId: string,
|
||||
): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> {
|
||||
const current = await findDeviceOrFail(deviceId);
|
||||
await assertCanManageSchedule(user, current.classId);
|
||||
if (!current.enabled) throw ApiError.badRequest('该设备已停用,请先启用后再获取课表');
|
||||
|
||||
await prisma.integrationDevice.update({
|
||||
where: { id: deviceId },
|
||||
data: { requestScheduleReport: true },
|
||||
});
|
||||
logger.info(`已请求设备上报课表:${current.name}(classId=${current.classId})由 ${user.name} 发起`);
|
||||
return {
|
||||
requested: true,
|
||||
deviceName: current.name,
|
||||
nextHeartbeatHint: '教室机器会在下一次心跳(约 1 分钟内)把当前课表上报上来',
|
||||
};
|
||||
}
|
||||
|
||||
/** 重置令牌:旧令牌立即失效(插件侧需要重新填写) */
|
||||
export async function resetDeviceToken(
|
||||
user: TokenPayload,
|
||||
@@ -251,14 +367,14 @@ function toPushDto(
|
||||
*
|
||||
* - 落库(ClassIslandPush)→ 在线设备通过 Socket.IO 立即收到;离线设备重连后走
|
||||
* `GET /api/integrations/classisland/pending` 补齐,保证"老师按了发送就一定送到";
|
||||
* - `saveToNotifications` 默认 true:同时写一条班级通知,学生端通知中心/灵动岛也能看到,
|
||||
* - `saveToNotifications` 默认 true:同时写一条班级通知,ClassHelper 班级端通知中心/灵动岛也能看到,
|
||||
* 避免"只在 ClassIsland 上弹了一下,事后查无此事"。
|
||||
*/
|
||||
export async function sendNotification(
|
||||
user: TokenPayload,
|
||||
input: SendNotificationInput,
|
||||
): Promise<SendClassIslandNotificationResult> {
|
||||
await assertCanPublishContent(user, input.classId);
|
||||
await assertCanPublishNotification(user, input.classId);
|
||||
|
||||
const teacher = await prisma.user.findUnique({
|
||||
where: { id: user.sub },
|
||||
@@ -286,8 +402,8 @@ export async function sendNotification(
|
||||
include: { creator: { select: { id: true, name: true, username: true } }, reads: true },
|
||||
});
|
||||
notificationId = created.id;
|
||||
const dto: NotificationDto = toNotificationDto(created, { userId: user.sub, withStatus: true });
|
||||
// 学生端(桌面客户端 / 班级设备)照旧收到通知中心的实时推送
|
||||
const dto: NotificationDto = toNotificationDto(created, { withStatus: true });
|
||||
// ClassHelper 班级端照旧收到通知中心的实时推送
|
||||
emitToClass(input.classId, SOCKET_EVENTS.notificationNew, dto);
|
||||
}
|
||||
|
||||
@@ -420,17 +536,30 @@ export async function applyReport(
|
||||
input: ClassIslandReportInput,
|
||||
): Promise<ClassIslandReportResult> {
|
||||
const now = new Date();
|
||||
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||
// 周次口径按**本班**算(管理员可以给每个班单独配开学日期与逐周区间)
|
||||
const week = resolveCurrentWeek(now, await loadTermContext(device.classId));
|
||||
|
||||
// 0) 机器码回填(设备创建时只能用 pending-… 占位,首次上报才知道它到底是哪台机器)
|
||||
if (input.deviceKey) await syncDeviceKey(device, input.deviceKey);
|
||||
|
||||
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
|
||||
//
|
||||
// 心跳与"带状态的上报"分开记:`lastHeartbeatAt` 每次上报都刷(在线判定看它),
|
||||
// `lastSeenAt` 只在确实带了运行状态时刷 —— 这样"连得上但一直没上报状态"的设备
|
||||
// 不会被误判成一切正常。
|
||||
const state = input.state;
|
||||
const currentDevice = await prisma.integrationDevice.findUnique({
|
||||
where: { id: device.deviceId },
|
||||
select: { requestScheduleReport: true },
|
||||
});
|
||||
const scheduleRequested = currentDevice?.requestScheduleReport === true;
|
||||
await prisma.integrationDevice.update({
|
||||
where: { id: device.deviceId },
|
||||
data: {
|
||||
lastSeenAt: now,
|
||||
lastHeartbeatAt: now,
|
||||
// 这次真的带了课表上来 → 待办完成
|
||||
...(input.schedule ? { requestScheduleReport: false } : {}),
|
||||
...(state ? { lastSeenAt: now } : {}),
|
||||
...(input.pluginVersion ? { pluginVersion: input.pluginVersion } : {}),
|
||||
...(input.classIslandVersion ? { classIslandVersion: input.classIslandVersion } : {}),
|
||||
...(state
|
||||
@@ -516,6 +645,10 @@ export async function applyReport(
|
||||
settings: {
|
||||
mirrorScheduleToClassIsland: device.mirrorScheduleToClassIsland,
|
||||
enabled: device.enabled,
|
||||
// 「从教室机器取一次课表」的待办:Web 端点过之后置位,插件看到就立刻推一次课表上来。
|
||||
// 注意它**独立于** syncScheduleToServer —— 后者是"允许自动回传",现在默认关闭,
|
||||
// 但管理员显式点"获取课表"时仍然应当能取到。
|
||||
scheduleRequested: scheduleRequested,
|
||||
},
|
||||
pendingNotification: pending,
|
||||
};
|
||||
@@ -588,7 +721,7 @@ export async function listPendingNotifications(device: DeviceContext): Promise<C
|
||||
return {
|
||||
notifications: records.map((item) => toPushDto(item)),
|
||||
serverTime: now.toISOString(),
|
||||
week: resolveCurrentWeek(env.termStartDate, now),
|
||||
week: resolveCurrentWeek(now, await loadTermContext(device.classId)),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -613,14 +746,18 @@ export async function ackNotification(device: DeviceContext, id: string): Promis
|
||||
export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
|
||||
classPlan: MirrorClassPlan;
|
||||
week: number;
|
||||
/** 学期周次区间(管理员逐周配置的);空数组 = 按学期开始日期线性推算 */
|
||||
weekRanges: Array<{ weekNumber: number; startDate: string; endDate: string }>;
|
||||
serverTime: string;
|
||||
}> {
|
||||
const now = new Date();
|
||||
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||
// 周次口径按**本班**算:管理员可以给每个班单独配开学日期与逐周区间(调休、错峰开学)
|
||||
const termContext = await loadTermContext(device.classId);
|
||||
const week = resolveCurrentWeek(now, termContext);
|
||||
|
||||
const classRecord = await prisma.class.findUnique({
|
||||
where: { id: device.classId },
|
||||
select: { id: true, name: true, termWeeks: true },
|
||||
select: { id: true, name: true, termWeeks: true, termStartDate: true },
|
||||
});
|
||||
if (!classRecord) throw ApiError.notFound('班级不存在');
|
||||
|
||||
@@ -638,7 +775,7 @@ export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
|
||||
|
||||
// 节次时间表:优先用班级里已导入的 ClassIsland 时间表,其次按课表的开始时间合成
|
||||
const layoutId = `ch-${device.classId}`;
|
||||
const layoutName = timeLayouts[0]?.name ?? '班级小助手时间表';
|
||||
const layoutName = timeLayouts[0]?.name ?? 'ClassHelper时间表';
|
||||
let layouts: { startTime: string; endTime: string; timeType: number }[] = [];
|
||||
if (timeLayouts.length > 0 && timeLayouts[0]) {
|
||||
layouts = parseStoredTimeLayout(timeLayouts[0].items).map((item) => ({
|
||||
@@ -669,14 +806,31 @@ export async function pullClassPlanForDevice(device: DeviceContext): Promise<{
|
||||
classPlan: {
|
||||
entries,
|
||||
timeLayouts: [{ id: layoutId, name: layoutName, layouts }],
|
||||
profileName: `班级小助手-${classRecord.name}`,
|
||||
termStartDate: env.termStartDate,
|
||||
profileName: `ClassHelper-${classRecord.name}`,
|
||||
// 下发本班实际生效的开学日期(管理员可以为某个班单独配,优先级高于全局 .env)
|
||||
termStartDate: termContext.termStartDate,
|
||||
},
|
||||
week,
|
||||
weekRanges: termContext.weeks,
|
||||
serverTime: now.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 「课表/周次变了,请重新镜像」的轻量通知。
|
||||
*
|
||||
* 插件是**轮询**镜像的(不订阅 Socket.IO),所以这里只做两件事:
|
||||
* 广播给 Web 端让页面刷新,以及把该班的设备标记为"有新版本"。
|
||||
* 真正的下发发生在插件下一次拉取课表时 —— 它会把新的开学日期与周次区间一并带走。
|
||||
*/
|
||||
export async function notifyScheduleChanged(classId: string): Promise<void> {
|
||||
emitToRooms([SOCKET_ROOMS.class(classId), SOCKET_ROOMS.teachers], SOCKET_EVENTS.scheduleUpdated, {
|
||||
classId,
|
||||
action: 'imported',
|
||||
});
|
||||
logger.info(`课表/周次已变更,等待教室机器下次镜像时下发:classId=${classId}`);
|
||||
}
|
||||
|
||||
/** 解析库里存的节次 JSON(容错:坏数据不抛异常,退化为空) */
|
||||
function parseStoredTimeLayout(raw: string): { startTime: string; endTime: string; type: string }[] {
|
||||
try {
|
||||
@@ -702,7 +856,7 @@ function parseStoredTimeLayout(raw: string): { startTime: string; endTime: strin
|
||||
* 没有导入节次时间表时,用课表里出现过的起止时间合成一份(去重、按时间排序)。
|
||||
*
|
||||
* **每节课之间补一个课间**(timeType=1:上一节下课 → 下一节上课),最后一节之后不补(放学)。
|
||||
* 班级小助手这边的节次时间表与 ClassIsland 档案保持同一口径,镜像过去才不会缺课间。
|
||||
* ClassHelper这边的节次时间表与 ClassIsland 档案保持同一口径,镜像过去才不会缺课间。
|
||||
*/
|
||||
function synthesizeTimeLayout(
|
||||
schedules: { startTime: string; endTime: string }[],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { SOCKET_EVENTS, type NotificationDto } from '@classhelper/shared';
|
||||
import {
|
||||
assertClassAccess,
|
||||
assertCanPublishContent,
|
||||
assertCanPublishNotification,
|
||||
classScopeWhere,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
@@ -52,7 +52,7 @@ export async function listNotifications(
|
||||
take: 200,
|
||||
});
|
||||
|
||||
return notifications.map((item) => toNotificationDto(item, { userIds: personalIds, withStatus: isStaff }));
|
||||
return notifications.map((item) => toNotificationDto(item, { studentIds: personalIds, withStatus: isStaff }));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,7 +66,7 @@ export async function createNotification(
|
||||
user: TokenPayload,
|
||||
input: CreateNotificationInput,
|
||||
): Promise<NotificationDto> {
|
||||
await assertCanPublishContent(user, input.classId);
|
||||
await assertCanPublishNotification(user, input.classId);
|
||||
|
||||
const priority = input.priority ?? 'NORMAL';
|
||||
if (priority === 'URGENT' && input.confirmDuringClass !== true) {
|
||||
@@ -96,7 +96,7 @@ export async function createNotification(
|
||||
include: { creator: creatorSelect, reads: true },
|
||||
});
|
||||
|
||||
const dto = toNotificationDto(created, { userId: user.sub, withStatus: true });
|
||||
const dto = toNotificationDto(created, { withStatus: true });
|
||||
emitToClass(created.classId, SOCKET_EVENTS.notificationNew, dto);
|
||||
|
||||
// 同一条通知也推到教室的 ClassIsland 上("老师发通知 → 教室大屏弹出"不该只在联动页生效)。
|
||||
@@ -152,7 +152,7 @@ async function pushToClassIslandIfEnabled(input: {
|
||||
export async function deleteNotification(user: TokenPayload, notificationId: string): Promise<void> {
|
||||
const current = await prisma.notification.findUnique({ where: { id: notificationId } });
|
||||
if (!current) throw ApiError.notFound('通知不存在');
|
||||
await assertCanPublishContent(user, current.classId);
|
||||
await assertCanPublishNotification(user, current.classId);
|
||||
await prisma.notification.delete({ where: { id: notificationId } });
|
||||
}
|
||||
|
||||
@@ -172,15 +172,15 @@ export async function markAsRead(
|
||||
// 先过滤已读过的记录再创建:SQLite 下 createMany 不支持 skipDuplicates,
|
||||
// 直接 createMany 遇到重复会整体失败。
|
||||
const existing = await prisma.notificationRead.findMany({
|
||||
where: { notificationId, userId: { in: personalIds } },
|
||||
select: { userId: true },
|
||||
where: { notificationId, studentId: { in: personalIds } },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const have = new Set(existing.map((item) => item.userId));
|
||||
const missing = personalIds.filter((userId) => !have.has(userId));
|
||||
const have = new Set(existing.map((item) => item.studentId));
|
||||
const missing = personalIds.filter((studentId) => !have.has(studentId));
|
||||
|
||||
if (missing.length > 0) {
|
||||
await prisma.notificationRead.createMany({
|
||||
data: missing.map((userId) => ({ notificationId, userId })),
|
||||
data: missing.map((studentId) => ({ notificationId, studentId })),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -201,15 +201,15 @@ export async function markAllAsRead(user: TokenPayload, classId?: string): Promi
|
||||
let marked = 0;
|
||||
for (const item of pending) {
|
||||
const existing = await prisma.notificationRead.findMany({
|
||||
where: { notificationId: item.id, userId: { in: personalIds } },
|
||||
select: { userId: true },
|
||||
where: { notificationId: item.id, studentId: { in: personalIds } },
|
||||
select: { studentId: true },
|
||||
});
|
||||
const have = new Set(existing.map((row) => row.userId));
|
||||
const missing = personalIds.filter((userId) => !have.has(userId));
|
||||
const have = new Set(existing.map((row) => row.studentId));
|
||||
const missing = personalIds.filter((studentId) => !have.has(studentId));
|
||||
if (missing.length === 0) continue;
|
||||
|
||||
await prisma.notificationRead.createMany({
|
||||
data: missing.map((userId) => ({ notificationId: item.id, userId })),
|
||||
data: missing.map((studentId) => ({ notificationId: item.id, studentId })),
|
||||
});
|
||||
marked += missing.length;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { archivesModule } from './archives/archives.module.js';
|
||||
import { authModule } from './auth/auth.module.js';
|
||||
import { callsModule } from './calls/calls.module.js';
|
||||
import { classesModule } from './classes/classes.module.js';
|
||||
@@ -12,6 +13,7 @@ import { notificationsModule } from './notifications/notifications.module.js';
|
||||
import { schedulesModule } from './schedules/schedules.module.js';
|
||||
import { studentsModule } from './students/students.module.js';
|
||||
import { teachersModule } from './teachers/teachers.module.js';
|
||||
import { termModule } from './term/term.module.js';
|
||||
import { updateModule } from './update/update.module.js';
|
||||
import type { ApiModule } from './module.types.js';
|
||||
|
||||
@@ -35,6 +37,8 @@ export const apiModules: ApiModule[] = [
|
||||
gradesModule,
|
||||
studentsModule,
|
||||
teachersModule,
|
||||
archivesModule,
|
||||
termModule,
|
||||
dashboardModule,
|
||||
databaseModule,
|
||||
updateModule,
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import { Router } from 'express';
|
||||
import { resolveCurrentWeek } from '@classhelper/shared';
|
||||
import { env } from '../../config/env.js';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
@@ -35,7 +33,8 @@ router.get('/grid', validate({ query: gridQuerySchema }), async (req, res) => {
|
||||
res,
|
||||
await scheduleService.getScheduleGrid(user, {
|
||||
classId,
|
||||
week: week ?? resolveCurrentWeek(env.termStartDate),
|
||||
// 不传 week 就取「本班口径」的当前周次(由 service 内按班级配置解析)
|
||||
week,
|
||||
}),
|
||||
'获取周视图成功',
|
||||
);
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
SOCKET_EVENTS,
|
||||
buildScheduleWeekView,
|
||||
resolveClassStatus,
|
||||
resolveCurrentWeek,
|
||||
weekParityOf,
|
||||
type ClassStatusDto,
|
||||
type ScheduleDto,
|
||||
@@ -12,12 +11,13 @@ import {
|
||||
assertClassAccess,
|
||||
assertCanManageSchedule,
|
||||
classScopeWhere,
|
||||
isStudent,
|
||||
requireStudentClassId,
|
||||
isClassDevice,
|
||||
requireClassDeviceClassId,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
// 周次口径按**班级**取(管理员可以为每个班单独配开学日期与逐周区间)
|
||||
import { loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toScheduleDto } from '../../lib/mappers.js';
|
||||
@@ -60,7 +60,8 @@ export async function getScheduleGrid(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string; week?: number },
|
||||
): Promise<ScheduleWeekView> {
|
||||
const week = options.week ?? resolveCurrentWeek(env.termStartDate);
|
||||
// 当前周次按**本班**的口径算(管理员可以为每个班单独配开学日期与逐周区间)
|
||||
const week = options.week ?? resolveCurrentWeek(new Date(), await loadTermContext(options.classId));
|
||||
const items = await listSchedules(user, { classId: options.classId, week });
|
||||
return buildScheduleWeekView(items, week);
|
||||
}
|
||||
@@ -71,7 +72,7 @@ export async function getScheduleGrid(
|
||||
*/
|
||||
export async function computeClassStatus(classId: string, at?: Date): Promise<ClassStatusDto> {
|
||||
const now = at ?? new Date();
|
||||
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||
const week = resolveCurrentWeek(now, await loadTermContext(classId));
|
||||
const schedules = await prisma.schedule.findMany({
|
||||
where: { classId },
|
||||
include: { course: courseSelect },
|
||||
@@ -98,7 +99,7 @@ export async function getClassStatus(
|
||||
user: TokenPayload,
|
||||
options: { classId?: string; at?: Date } = {},
|
||||
): Promise<ClassStatusDto> {
|
||||
const classId = isStudent(user) ? requireStudentClassId(user) : options.classId;
|
||||
const classId = isClassDevice(user) ? requireClassDeviceClassId(user) : options.classId;
|
||||
if (!classId) throw ApiError.badRequest('请指定班级(classId)');
|
||||
await assertClassAccess(user, classId);
|
||||
return computeClassStatus(classId, options.at);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Router } from 'express';
|
||||
import { Router } from 'express';
|
||||
import { sendCreated, sendOk } from '../../lib/http.js';
|
||||
import { idParamSchema } from '../../lib/schemas.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
@@ -7,41 +7,114 @@ import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
createStudentSchema,
|
||||
listStudentsQuerySchema,
|
||||
listTransfersQuerySchema,
|
||||
transferOutStudentsSchema,
|
||||
transferStudentsSchema,
|
||||
updateStudentSchema,
|
||||
type CreateStudentInput,
|
||||
type ListStudentsQuery,
|
||||
type ListTransfersQuery,
|
||||
type TransferOutStudentsInput,
|
||||
type TransferStudentsInput,
|
||||
type UpdateStudentInput,
|
||||
} from './students.schemas.js';
|
||||
import * as studentService from './students.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* GET /api/students?classId=&keyword= - 学生名单
|
||||
* 学生名单。
|
||||
*
|
||||
* 注意是**仅管理员**(不是教师/管理员):学生名单自带用户名,而学生初始密码是统一的默认值,
|
||||
* 因此名单属于管理员专属数据;教师查看本班学生请用 `GET /api/classes/:id/students`
|
||||
* (那条按班级权限校验,班级设备也能读)。
|
||||
* - 学生**没有账号**:这里是"名单记录"的增删改,没有任何密码/角色字段;
|
||||
* - 读:教师/管理员按自己可访问的班级过滤(服务层 `resolveClassScope`);
|
||||
* 教室机器读本班名单请用 `GET /api/classes/:id/students`;
|
||||
* - 写:管理员或**本班班主任**(服务层逐班判权限),调班与转出仅管理员。
|
||||
*/
|
||||
router.get('/', requireRole('ADMIN'), validate({ query: listStudentsQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<{ classId?: string; keyword?: string }>(req);
|
||||
sendOk(res, await studentService.listStudents(user, query), '获取学生列表成功');
|
||||
});
|
||||
router.get(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ query: listStudentsQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<ListStudentsQuery>(req);
|
||||
sendOk(res, await studentService.listStudents(user, query), '获取学生列表成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/students - 新建学生账号 */
|
||||
router.post('/', requireRole('ADMIN'), validate({ body: createStudentSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(
|
||||
res,
|
||||
await studentService.createStudent(user, req.body as CreateStudentInput),
|
||||
'学生账号创建成功',
|
||||
);
|
||||
});
|
||||
/** GET /api/students/transfers - 调班 / 转出历史(管理员看全部,也可按学生或班级过滤) */
|
||||
router.get(
|
||||
'/transfers',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ query: listTransfersQuerySchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const query = validatedQuery<ListTransfersQuery>(req);
|
||||
sendOk(res, await studentService.listTransfers(user, query), '获取调班历史成功');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/students/transfer - 调班(单个与批量同一接口)
|
||||
*
|
||||
* 只改学生当前班级:**学号不变、不创建账号**,原班级/新班级/操作人/时间记入历史;
|
||||
* 历史作业与成绩保留原归属。
|
||||
*/
|
||||
router.post(
|
||||
'/transfer',
|
||||
requireRole('ADMIN'),
|
||||
validate({ body: transferStudentsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await studentService.transferStudents(user, req.body as TransferStudentsInput);
|
||||
sendOk(res, result, `已调动 ${result.moved} 名学生`);
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/students/transfer-out - 学生转出(学籍离开本校,不是调班) */
|
||||
router.post(
|
||||
'/transfer-out',
|
||||
requireRole('ADMIN'),
|
||||
validate({ body: transferOutStudentsSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const result = await studentService.transferOutStudents(user, req.body as TransferOutStudentsInput);
|
||||
sendOk(res, result, `已办理 ${result.transferred} 名学生转出`);
|
||||
},
|
||||
);
|
||||
|
||||
/** GET /api/students/:id/transfers - 单个学生的调班 / 转出历史 */
|
||||
router.get(
|
||||
'/:id/transfers',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
const result = await studentService.listTransfers(user, { studentId: id, pageSize: 200 });
|
||||
sendOk(res, result, '获取该生调班历史成功');
|
||||
},
|
||||
);
|
||||
|
||||
/** POST /api/students - 新建学生(名单记录,不是账号) */
|
||||
router.post(
|
||||
'/',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ body: createStudentSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendCreated(
|
||||
res,
|
||||
await studentService.createStudent(user, req.body as CreateStudentInput),
|
||||
'学生已加入名单',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/** PATCH /api/students/:id - 修改学生信息 / 调班 */
|
||||
router.patch(
|
||||
'/:id',
|
||||
requireRole('ADMIN'),
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema, body: updateStudentSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
@@ -54,13 +127,18 @@ router.patch(
|
||||
},
|
||||
);
|
||||
|
||||
/** DELETE /api/students - 删除学生名单记录 */
|
||||
router.delete('/:id', requireRole('ADMIN'), validate({ params: idParamSchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await studentService.deleteStudent(user, id);
|
||||
sendOk(res, { id }, '学生账号已删除');
|
||||
});
|
||||
/** DELETE /api/students/:id - 删除学生名单记录 */
|
||||
router.delete(
|
||||
'/:id',
|
||||
requireRole('ADMIN', 'TEACHER'),
|
||||
validate({ params: idParamSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { id } = validatedParams<{ id: string }>(req);
|
||||
await studentService.deleteStudent(user, id);
|
||||
sendOk(res, { id }, '学生记录已删除');
|
||||
},
|
||||
);
|
||||
|
||||
export const studentsModule = defineModule({
|
||||
name: 'students',
|
||||
|
||||
@@ -1,24 +1,80 @@
|
||||
import { z } from 'zod';
|
||||
import { keywordSchema } from '../../lib/schemas.js';
|
||||
import {
|
||||
enrollmentYearSchema,
|
||||
guardianPhoneSchema,
|
||||
keywordSchema,
|
||||
studentGenderSchema,
|
||||
studentNameSchema,
|
||||
studentNoSchema,
|
||||
studentStatusSchema,
|
||||
} from '../../lib/schemas.js';
|
||||
|
||||
export const listStudentsQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
keyword: keywordSchema,
|
||||
/** 只看某个状态(默认不筛选;`active` = 只看在读) */
|
||||
status: studentStatusSchema,
|
||||
/** 是否包含已归档(毕业/转出)的学生,默认 false */
|
||||
includeArchived: z
|
||||
.union([z.boolean(), z.string()])
|
||||
.optional()
|
||||
.transform((value) => value === true || value === 'true' || value === '1'),
|
||||
});
|
||||
|
||||
// 学生只有"名单"没有"账号":不设密码、不能登录(2026-10-01 起学生个人账号已全部清理,
|
||||
// 学生端统一用班级码 + 班级密码),因此这里没有也不该有任何密码字段。
|
||||
// 学生只有「名单」没有「账号」:没有密码字段、没有角色字段,也不能登录。
|
||||
// 学号是学生的唯一标识与查询键,`studentNoSchema` 负责归一化与格式校验。
|
||||
export const createStudentSchema = z.object({
|
||||
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
||||
name: z.string().trim().min(1, '请输入姓名').max(32),
|
||||
studentNo: studentNoSchema,
|
||||
name: studentNameSchema,
|
||||
classId: z.string().min(1).nullish(),
|
||||
gender: studentGenderSchema,
|
||||
guardianPhone: guardianPhoneSchema,
|
||||
status: studentStatusSchema,
|
||||
});
|
||||
|
||||
export const updateStudentSchema = z.object({
|
||||
username: z.string().trim().min(3).max(32).optional(),
|
||||
name: z.string().trim().min(1).max(32).optional(),
|
||||
studentNo: studentNoSchema.optional(),
|
||||
name: studentNameSchema.optional(),
|
||||
classId: z.string().min(1).nullish(),
|
||||
gender: studentGenderSchema,
|
||||
guardianPhone: guardianPhoneSchema,
|
||||
status: studentStatusSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* 调班:单个与批量共用。
|
||||
* 传一个 id 就是单个调班;`toClassId = null` 表示移出班级(回到「未分班」)。
|
||||
* **学号不变**,历史作业与成绩保留原归属。
|
||||
*/
|
||||
export const transferStudentsSchema = z.object({
|
||||
studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500),
|
||||
toClassId: z.string().min(1).nullable(),
|
||||
note: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
/** 转出(学籍离开本校,不是调班):班级归属保留作历史 */
|
||||
export const transferOutStudentsSchema = z.object({
|
||||
studentIds: z.array(z.string().min(1)).min(1, '请至少选择一名学生').max(500),
|
||||
note: z.string().trim().max(200).optional(),
|
||||
});
|
||||
|
||||
/** 调班历史查询 */
|
||||
export const listTransfersQuerySchema = z.object({
|
||||
studentId: z.string().min(1).optional(),
|
||||
classId: z.string().min(1).optional(),
|
||||
page: z.coerce.number().int().min(1).optional(),
|
||||
pageSize: z.coerce.number().int().min(1).max(200).optional(),
|
||||
});
|
||||
|
||||
/** 学生名单导入时的班级归属(按班级名解析,重名报错) */
|
||||
export const studentClassHintSchema = z.object({
|
||||
className: z.string().trim().max(64).optional(),
|
||||
enrollmentYear: enrollmentYearSchema.optional(),
|
||||
});
|
||||
|
||||
export type CreateStudentInput = z.infer<typeof createStudentSchema>;
|
||||
export type UpdateStudentInput = z.infer<typeof updateStudentSchema>;
|
||||
export type TransferStudentsInput = z.infer<typeof transferStudentsSchema>;
|
||||
export type TransferOutStudentsInput = z.infer<typeof transferOutStudentsSchema>;
|
||||
export type ListStudentsQuery = z.infer<typeof listStudentsQuerySchema>;
|
||||
export type ListTransfersQuery = z.infer<typeof listTransfersQuerySchema>;
|
||||
@@ -1,126 +1,349 @@
|
||||
import type { StudentDto } from '@classhelper/shared';
|
||||
import { assertCanManageRoster, classScopeWhere, isAdmin, resolveClassScope } from '../../lib/access.js';
|
||||
import type { PaginatedResult, StudentDto, StudentTransferDto } from '@classhelper/shared';
|
||||
import { normalizeStudentNo } from '@classhelper/shared';
|
||||
import {
|
||||
assertCanManageRoster,
|
||||
classScopeWhere,
|
||||
isAdmin,
|
||||
resolveClassScope,
|
||||
} from '../../lib/access.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { toStudentDto } from '../../lib/mappers.js';
|
||||
import type { CreateStudentInput, UpdateStudentInput } from './students.schemas.js';
|
||||
import { toStudentDto, toStudentTransferDto } from '../../lib/mappers.js';
|
||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import type {
|
||||
CreateStudentInput,
|
||||
ListStudentsQuery,
|
||||
ListTransfersQuery,
|
||||
TransferOutStudentsInput,
|
||||
TransferStudentsInput,
|
||||
UpdateStudentInput,
|
||||
} from './students.schemas.js';
|
||||
|
||||
export interface ListStudentOptions {
|
||||
classId?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
/**
|
||||
* 学生是**班级名单里的记录**,不是账号:
|
||||
* 没有密码、不能登录、没有个人入口。`studentNo`(学号)是唯一标识与查询键。
|
||||
*
|
||||
* 本模块负责名单的增删改与**调班 / 转出**;学生的个人数据(作业完成、通知已读、成绩)
|
||||
* 挂在 `studentId` 上,因此调班只改 `Student.classId` ——
|
||||
* 历史作业与成绩保留原归属,后续数据按新班级生成。
|
||||
*/
|
||||
|
||||
const withClass = { class: { select: { name: true, grade: true } } } as const;
|
||||
|
||||
/** 学生名单(按班级权限过滤) */
|
||||
export async function listStudents(user: TokenPayload, options: ListStudentOptions): Promise<StudentDto[]> {
|
||||
export async function listStudents(
|
||||
user: TokenPayload,
|
||||
options: ListStudentsQuery,
|
||||
): Promise<StudentDto[]> {
|
||||
const scope = await resolveClassScope(user, options.classId);
|
||||
|
||||
const students = await prisma.user.findMany({
|
||||
const students = await prisma.student.findMany({
|
||||
where: {
|
||||
role: 'STUDENT',
|
||||
...classScopeWhere(scope),
|
||||
...(options.status ? { status: options.status } : {}),
|
||||
// 默认不显示已归档(毕业 / 转出)的学生:他们属于归档页,不该混在在读名单里
|
||||
...(options.includeArchived ? {} : { archivedYearId: null }),
|
||||
...(options.keyword
|
||||
? {
|
||||
OR: [{ name: { contains: options.keyword } }, { username: { contains: options.keyword } }],
|
||||
}
|
||||
? { OR: [{ name: { contains: options.keyword } }, { studentNo: { contains: options.keyword } }] }
|
||||
: {}),
|
||||
},
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
orderBy: [{ classId: 'asc' }, { username: 'asc' }],
|
||||
take: 500,
|
||||
include: withClass,
|
||||
orderBy: [{ classId: 'asc' }, { studentNo: 'asc' }],
|
||||
take: 1000,
|
||||
});
|
||||
|
||||
return students.map(toStudentDto);
|
||||
}
|
||||
|
||||
/** 新建学生(名单实体,可选直接分班)。学生不设密码:登录一律走班级账号,见 createStudentSchema 的说明 */
|
||||
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
|
||||
if (input.classId) assertCanManageRoster(user);
|
||||
|
||||
const created = await prisma.user.create({
|
||||
data: {
|
||||
username: input.username,
|
||||
name: input.name,
|
||||
role: 'STUDENT',
|
||||
classId: input.classId ?? null,
|
||||
// User.passwordHash 是必填列,学生用空串占位(登录的 STUDENT 403 判定在密码校验之前,永远不会用到它)
|
||||
passwordHash: '',
|
||||
},
|
||||
/** 按学号取学生(学生没账号,因此没有"按 id 取自己"的入口) */
|
||||
export async function findStudentByNo(studentNo: string, classId?: string): Promise<StudentDto | null> {
|
||||
const record = await prisma.student.findFirst({
|
||||
where: { studentNo: normalizeStudentNo(studentNo), ...(classId ? { classId } : {}) },
|
||||
include: withClass,
|
||||
});
|
||||
|
||||
if (input.classId) {
|
||||
await prisma.enrollment.upsert({
|
||||
where: { userId_classId: { userId: created.id, classId: input.classId } },
|
||||
create: { userId: created.id, classId: input.classId },
|
||||
update: {},
|
||||
});
|
||||
}
|
||||
|
||||
const student = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: created.id },
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
});
|
||||
return toStudentDto(student);
|
||||
return record ? toStudentDto(record) : null;
|
||||
}
|
||||
|
||||
/** 修改学生信息 / 调班 */
|
||||
/** 新建学生(名单记录,可选直接分班)。学生不设密码、没有账号属性 */
|
||||
export async function createStudent(user: TokenPayload, input: CreateStudentInput): Promise<StudentDto> {
|
||||
await assertCanManageRoster(user, input.classId ?? undefined);
|
||||
await assertStudentNoFree(input.studentNo);
|
||||
if (input.classId) await assertClassExists(input.classId);
|
||||
|
||||
const created = await prisma.student.create({
|
||||
data: {
|
||||
studentNo: input.studentNo,
|
||||
name: input.name,
|
||||
classId: input.classId ?? null,
|
||||
gender: input.gender ?? '',
|
||||
guardianPhone: input.guardianPhone ?? '',
|
||||
status: input.status ?? 'active',
|
||||
},
|
||||
include: withClass,
|
||||
});
|
||||
|
||||
if (created.classId) {
|
||||
emitToClass(created.classId, SOCKET_EVENTS.classUpdated, {
|
||||
classId: created.classId,
|
||||
action: 'updated',
|
||||
});
|
||||
}
|
||||
return toStudentDto(created);
|
||||
}
|
||||
|
||||
/** 修改学生信息(含调班;走本接口的单个调班同样会记入调班历史) */
|
||||
export async function updateStudent(
|
||||
user: TokenPayload,
|
||||
studentId: string,
|
||||
input: UpdateStudentInput,
|
||||
): Promise<StudentDto> {
|
||||
const student = await prisma.user.findUnique({ where: { id: studentId } });
|
||||
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
|
||||
const student = await prisma.student.findUnique({ where: { id: studentId } });
|
||||
if (!student) throw ApiError.notFound('学生不存在');
|
||||
|
||||
if (student.classId) {
|
||||
assertCanManageRoster(user);
|
||||
} else if (!isAdmin(user)) {
|
||||
throw ApiError.forbidden('该学生尚未分班,仅管理员可以直接修改');
|
||||
await assertCanManageRoster(user, student.classId ?? undefined);
|
||||
if (input.studentNo && input.studentNo !== student.studentNo) {
|
||||
await assertStudentNoFree(input.studentNo, studentId);
|
||||
}
|
||||
|
||||
const nextClassId = input.classId === undefined ? student.classId : (input.classId ?? null);
|
||||
if (nextClassId && nextClassId !== student.classId) {
|
||||
assertCanManageRoster(user);
|
||||
}
|
||||
const classChanged = nextClassId !== student.classId;
|
||||
if (classChanged) await assertCanManageRoster(user, nextClassId ?? undefined);
|
||||
|
||||
const updated = await prisma.user.update({
|
||||
const updated = await prisma.student.update({
|
||||
where: { id: studentId },
|
||||
data: {
|
||||
...(input.studentNo ? { studentNo: input.studentNo } : {}),
|
||||
...(input.name ? { name: input.name } : {}),
|
||||
...(input.username ? { username: input.username } : {}),
|
||||
...(input.gender !== undefined ? { gender: input.gender } : {}),
|
||||
...(input.guardianPhone !== undefined ? { guardianPhone: input.guardianPhone } : {}),
|
||||
...(input.status !== undefined ? { status: input.status } : {}),
|
||||
classId: nextClassId,
|
||||
},
|
||||
include: withClass,
|
||||
});
|
||||
|
||||
// 同步 Enrollment:换班时清理旧记录
|
||||
if (nextClassId) {
|
||||
await prisma.enrollment.deleteMany({ where: { userId: studentId, classId: { not: nextClassId } } });
|
||||
await prisma.enrollment.upsert({
|
||||
where: { userId_classId: { userId: studentId, classId: nextClassId } },
|
||||
create: { userId: studentId, classId: nextClassId },
|
||||
update: {},
|
||||
});
|
||||
} else {
|
||||
await prisma.enrollment.deleteMany({ where: { userId: studentId } });
|
||||
// 换班要留痕(单个调班与批量调班都写同一种历史)
|
||||
if (classChanged) {
|
||||
await recordTransfer(user, [updated], student.classId, nextClassId, 'single', '');
|
||||
}
|
||||
|
||||
const result = await prisma.user.findUniqueOrThrow({
|
||||
where: { id: updated.id },
|
||||
include: { class: { select: { name: true, grade: true } } },
|
||||
});
|
||||
return toStudentDto(result);
|
||||
return toStudentDto(updated);
|
||||
}
|
||||
|
||||
/** 删除学生账号(级联删除其作业状态、成绩、已读记录) */
|
||||
/** 删除学生记录(级联删除其作业状态、成绩、已读记录与调班历史) */
|
||||
export async function deleteStudent(user: TokenPayload, studentId: string): Promise<void> {
|
||||
const student = await prisma.user.findUnique({ where: { id: studentId } });
|
||||
if (!student || student.role !== 'STUDENT') throw ApiError.notFound('学生不存在');
|
||||
const student = await prisma.student.findUnique({ where: { id: studentId } });
|
||||
if (!student) throw ApiError.notFound('学生不存在');
|
||||
await assertCanManageRoster(user, student.classId ?? undefined);
|
||||
|
||||
if (student.classId) {
|
||||
assertCanManageRoster(user);
|
||||
} else if (!isAdmin(user)) {
|
||||
throw ApiError.forbidden('该学生尚未分班,仅管理员可以删除');
|
||||
await prisma.student.delete({ where: { id: studentId } });
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 调班 / 转出 */
|
||||
|
||||
/**
|
||||
* 调班:单个与批量共用(传一个 id 就是单个)。
|
||||
*
|
||||
* 规则(需求「管理员调班」):
|
||||
* - 只改学生**当前班级**,学号不变、不创建任何账号;
|
||||
* - 原班级、新班级、操作人、时间写入 `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 } } });
|
||||
if (students.length !== input.studentIds.length) {
|
||||
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.user.delete({ where: { id: studentId } });
|
||||
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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* 学生转出:学籍离开本校(**不是调班**)。
|
||||
*
|
||||
* 班级归属保留作历史(成绩/作业/已读都还在),状态置为 `transferred`,
|
||||
* 并写一条 `mode = 'transfer-out'` 的历史,归档页据此统计"当年转出的学生"。
|
||||
*/
|
||||
export async function transferOutStudents(
|
||||
user: TokenPayload,
|
||||
input: TransferOutStudentsInput,
|
||||
): Promise<{ transferred: number; transfers: StudentTransferDto[] }> {
|
||||
if (!isAdmin(user)) throw ApiError.forbidden('只有管理员可以办理学生转出');
|
||||
|
||||
const students = await prisma.student.findMany({ where: { id: { in: input.studentIds } } });
|
||||
if (students.length !== input.studentIds.length) {
|
||||
throw ApiError.badRequest('部分学生不存在,请刷新后重试');
|
||||
}
|
||||
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 ?? '',
|
||||
})),
|
||||
});
|
||||
|
||||
notifyClassChange(pending.map((item) => item.classId), null);
|
||||
|
||||
const created = await prisma.studentClassTransfer.findMany({
|
||||
where: { studentId: { in: pending.map((item) => item.id) }, mode: 'transfer-out' },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: pending.length,
|
||||
});
|
||||
return { transferred: pending.length, transfers: created.map(toStudentTransferDto) };
|
||||
}
|
||||
|
||||
/** 调班 / 转出历史(单个学生或某个班级,管理员看全部) */
|
||||
export async function listTransfers(
|
||||
user: TokenPayload,
|
||||
options: ListTransfersQuery,
|
||||
): Promise<PaginatedResult<StudentTransferDto>> {
|
||||
if (!isAdmin(user) && !options.classId && !options.studentId) {
|
||||
throw ApiError.forbidden('查看全部调班历史需要管理员权限');
|
||||
}
|
||||
|
||||
const where = {
|
||||
...(options.studentId ? { studentId: options.studentId } : {}),
|
||||
...(options.classId
|
||||
? { OR: [{ fromClassId: options.classId }, { toClassId: options.classId }] }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const page = options.page ?? 1;
|
||||
const pageSize = options.pageSize ?? 50;
|
||||
const [items, total] = await Promise.all([
|
||||
prisma.studentClassTransfer.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.studentClassTransfer.count({ where }),
|
||||
]);
|
||||
|
||||
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 */
|
||||
async function assertStudentNoFree(studentNo: string, exceptId?: string): Promise<void> {
|
||||
const existing = await prisma.student.findUnique({
|
||||
where: { studentNo },
|
||||
select: { id: true, name: true, classId: true },
|
||||
});
|
||||
if (existing && existing.id !== exceptId) {
|
||||
throw ApiError.conflict(`学号 ${studentNo} 已被学生「${existing.name}」占用`);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ router.use(authenticate());
|
||||
|
||||
/**
|
||||
* 教师账号管理:**全部接口仅管理员**。
|
||||
* 学生端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
|
||||
* ClassHelper 班级端主体是班级,但教师账号决定谁是哪班班主任、谁能录课表/成绩,属于系统级配置,
|
||||
* 因此录入/修改/重置密码/删除都只开放给 ADMIN(路由层 + 前端菜单双重收敛)。
|
||||
*/
|
||||
|
||||
|
||||
@@ -5,18 +5,25 @@ export const listTeachersQuerySchema = z.object({
|
||||
keyword: keywordSchema,
|
||||
});
|
||||
|
||||
/**
|
||||
* 录入教师账号。
|
||||
* `username` 对教师而言就是**工号**(界面上也这么叫),也是登录名。
|
||||
*/
|
||||
export const createTeacherSchema = z.object({
|
||||
username: z.string().trim().min(3, '用户名至少 3 位').max(32),
|
||||
username: z.string().trim().min(3, '工号至少 3 位').max(32),
|
||||
name: z.string().trim().min(1, '请输入姓名').max(32),
|
||||
/** 手机号(可空) */
|
||||
phone: z.string().trim().max(32).optional(),
|
||||
/** 留空则用默认初始密码(DEFAULT_TEACHER_PASSWORD) */
|
||||
password: z.string().min(6, '密码至少 6 位').max(128).optional(),
|
||||
role: z.enum(['TEACHER', 'ADMIN']).optional(),
|
||||
});
|
||||
|
||||
/** 编辑教师:姓名 / 用户名 / 角色(角色可升为管理员,也可降回教师) */
|
||||
/** 编辑教师:姓名 / 工号 / 手机号 / 角色(角色可升为管理员,也可降回教师) */
|
||||
export const updateTeacherSchema = z.object({
|
||||
username: z.string().trim().min(3).max(32).optional(),
|
||||
name: z.string().trim().min(1).max(32).optional(),
|
||||
phone: z.string().trim().max(32).optional(),
|
||||
role: z.enum(['TEACHER', 'ADMIN']).optional(),
|
||||
});
|
||||
|
||||
|
||||
@@ -104,9 +104,8 @@ export async function resetTeacherPassword(
|
||||
export async function deleteTeacher(userId: string): Promise<void> {
|
||||
const teacher = await findTeacherOrThrow(userId);
|
||||
|
||||
const [ownedClasses, assignedClasses, courses, homeworks, notifications] = await Promise.all([
|
||||
const [ownedClasses, courses, homeworks, notifications] = await Promise.all([
|
||||
prisma.class.count({ where: { teacherId: teacher.id } }),
|
||||
prisma.classTeacher.count({ where: { teacherId: teacher.id } }),
|
||||
prisma.course.count({ where: { teacherId: teacher.id } }),
|
||||
prisma.homework.count({ where: { createdBy: teacher.id } }),
|
||||
prisma.notification.count({ where: { createdBy: teacher.id } }),
|
||||
@@ -122,9 +121,9 @@ export async function deleteTeacher(userId: string): Promise<void> {
|
||||
'删除账号会连带删除这些内容,请先删除内容或改为保留账号',
|
||||
);
|
||||
}
|
||||
if (assignedClasses > 0 || courses > 0) {
|
||||
if (courses > 0) {
|
||||
throw ApiError.conflict(
|
||||
`「${teacher.name}」还有 ${assignedClasses} 条班级分配 / ${courses} 门课程,请先解除后再删除`,
|
||||
`「${teacher.name}」还是 ${courses} 门课的任课老师,请先在班级管理里改掉该科的任课老师再删除`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { Router } from 'express';
|
||||
import { sendOk } from '../../lib/http.js';
|
||||
import { authenticate, getAuthUser, requireRole } from '../../middleware/auth.js';
|
||||
import { validate, validatedBody, validatedQuery } from '../../middleware/validate.js';
|
||||
import { defineModule } from '../module.types.js';
|
||||
import {
|
||||
autoTermWeeksSchema,
|
||||
holidayQuerySchema,
|
||||
termQuerySchema,
|
||||
updateTermWeeksSchema,
|
||||
type AutoTermWeeksInput,
|
||||
type UpdateTermWeeksInput,
|
||||
} from './term.schemas.js';
|
||||
import * as termService from './term.service.js';
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticate());
|
||||
|
||||
/**
|
||||
* 学期周次。
|
||||
*
|
||||
* - **读**:教师/管理员都能看自己可访问班级的周次(课表页要显示"第几周");
|
||||
* - **写**:仅管理员 —— 周次是全校口径,改错会让所有班的课表整体错位;
|
||||
* - `classId` 留空 = 全校默认;某个班有自己的配置时优先用它。
|
||||
*/
|
||||
router.get('/', requireRole('ADMIN', 'TEACHER'), validate({ query: termQuerySchema }), async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
const { classId } = validatedQuery<{ classId?: string }>(req);
|
||||
sendOk(res, await termService.getTermWeeks(user, classId), '获取学期周次成功');
|
||||
});
|
||||
|
||||
/** PUT /api/term - 保存逐周区间(整表覆盖,仅管理员) */
|
||||
router.put(
|
||||
'/',
|
||||
requireRole('ADMIN'),
|
||||
validate({ body: updateTermWeeksSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(
|
||||
res,
|
||||
await termService.updateTermWeeks(user, validatedBody<UpdateTermWeeksInput>(req)),
|
||||
'学期周次已保存,变更会随课表下发给教室的 ClassHelper 班级端',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/term/auto - 按学期开始日期一键生成逐周区间(仅管理员)
|
||||
*
|
||||
* 配置的起点:先生成一份"标准学期",再把调休那几周改掉。
|
||||
*/
|
||||
router.post(
|
||||
'/auto',
|
||||
requireRole('ADMIN'),
|
||||
validate({ body: autoTermWeeksSchema }),
|
||||
async (req, res) => {
|
||||
const user = getAuthUser(req);
|
||||
sendOk(
|
||||
res,
|
||||
await termService.autoTermWeeks(user, validatedBody<AutoTermWeeksInput>(req)),
|
||||
'已按学期开始日期生成逐周区间,可继续手动微调',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/term/holidays?year= - 联网获取法定节假日安排(给建议,不改数据)
|
||||
*
|
||||
* **机房没有外网时 `ok:false` 是正常结果**,前端按"暂时取不到"呈现即可。
|
||||
*/
|
||||
router.get(
|
||||
'/holidays',
|
||||
requireRole('ADMIN'),
|
||||
validate({ query: holidayQuerySchema }),
|
||||
async (req, res) => {
|
||||
const { year } = validatedQuery<{ year?: number }>(req);
|
||||
const result = await termService.suggestHolidays(year);
|
||||
sendOk(res, result, result.ok ? '已获取当年的放假安排' : (result.error ?? '暂时无法获取放假安排'));
|
||||
},
|
||||
);
|
||||
|
||||
export const termModule = defineModule({
|
||||
name: 'term',
|
||||
basePath: '/term',
|
||||
router,
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { z } from 'zod';
|
||||
import { isDayKey } from '@classhelper/shared';
|
||||
|
||||
/** 一个教学周的日期区间 */
|
||||
const termWeekItemSchema = z.object({
|
||||
weekNumber: z.coerce.number().int().min(1).max(60),
|
||||
startDate: z.string().trim().refine(isDayKey, { message: '开始日期格式应为 YYYY-MM-DD' }),
|
||||
endDate: z.string().trim().refine(isDayKey, { message: '结束日期格式应为 YYYY-MM-DD' }),
|
||||
note: z.string().trim().max(64).optional(),
|
||||
});
|
||||
|
||||
const termStartDateSchema = z.string().trim().refine(isDayKey, { message: '日期格式应为 YYYY-MM-DD' });
|
||||
|
||||
/** `classId` 为空表示全校默认 */
|
||||
export const termQuerySchema = z.object({
|
||||
classId: z.string().min(1).optional(),
|
||||
});
|
||||
|
||||
export const updateTermWeeksSchema = z.object({
|
||||
classId: z.string().min(1).nullish(),
|
||||
termStartDate: termStartDateSchema.optional(),
|
||||
/** 整表覆盖:传空数组 = 清空逐周配置,回到按开学日期线性推算 */
|
||||
weeks: z.array(termWeekItemSchema).max(60),
|
||||
});
|
||||
|
||||
export const autoTermWeeksSchema = z.object({
|
||||
classId: z.string().min(1).nullish(),
|
||||
termStartDate: termStartDateSchema.optional(),
|
||||
maxWeek: z.coerce.number().int().min(1).max(60).optional(),
|
||||
});
|
||||
|
||||
export const holidayQuerySchema = z.object({
|
||||
year: z.coerce.number().int().min(2000).max(2100).optional(),
|
||||
});
|
||||
|
||||
export type UpdateTermWeeksInput = z.infer<typeof updateTermWeeksSchema>;
|
||||
export type AutoTermWeeksInput = z.infer<typeof autoTermWeeksSchema>;
|
||||
@@ -0,0 +1,239 @@
|
||||
import {
|
||||
buildAutoTermWeeks,
|
||||
validateTermWeeks,
|
||||
type HolidaySuggestionDto,
|
||||
type TermWeeksDto,
|
||||
} from '@classhelper/shared';
|
||||
import { assertCanManageClasses, assertClassAccess } from '../../lib/access.js';
|
||||
import { env } from '../../config/env.js';
|
||||
import { prisma } from '../../lib/db.js';
|
||||
import { ApiError } from '../../lib/http.js';
|
||||
import type { TokenPayload } from '../../lib/jwt.js';
|
||||
import { logger } from '../../lib/logger.js';
|
||||
import { MAX_TERM_WEEK, loadTermContext, resolveCurrentWeek } from '../../lib/term.js';
|
||||
import { emitToClass } from '../../realtime/bus.js';
|
||||
import { SOCKET_EVENTS } from '@classhelper/shared';
|
||||
import { notifyScheduleChanged } from '../integrations/integrations.service.js';
|
||||
import type { AutoTermWeeksInput, UpdateTermWeeksInput } from './term.schemas.js';
|
||||
|
||||
/**
|
||||
* 学期周次(仅管理员可写)。
|
||||
*
|
||||
* 为什么需要逐周配置:`TERM_START_DATE + 每周七天`的线性推算遇到法定节假日调休、
|
||||
* 周末补课、错峰开学就会**整体错位**——而课表、作业、成绩都按周次组织,错一周就全错。
|
||||
* 因此管理员可以:
|
||||
* 1. 先按开学日期一键生成逐周区间(`autoTermWeeks`);
|
||||
* 2. 再逐周微调(调休那一周拉长、开学前那几天并进第 1 周…);
|
||||
* 3. 也可以直接改某个班的开学日期(`Class.termStartDate`)。
|
||||
*
|
||||
* `classId = ''` 表示**全校默认**;某个班有自己的配置时优先用它。
|
||||
* 改完之后会把新的课表口径(含周次区间)**下发给该班所有在线的 ClassIsland**。
|
||||
*/
|
||||
|
||||
/** 把 null/undefined 归一成空串(空串 = 全校默认) */
|
||||
function scopeOf(classId?: string | null): string {
|
||||
return classId ?? '';
|
||||
}
|
||||
|
||||
/** 读取某个范围的周次配置 */
|
||||
export async function getTermWeeks(user: TokenPayload, classId?: string | null): Promise<TermWeeksDto> {
|
||||
const scope = scopeOf(classId);
|
||||
if (scope) await assertClassAccess(user, scope);
|
||||
|
||||
const [weeks, cls] = await Promise.all([
|
||||
prisma.termWeek.findMany({ where: { classId: scope }, orderBy: { weekNumber: 'asc' } }),
|
||||
scope
|
||||
? prisma.class.findUnique({ where: { id: scope }, select: { termStartDate: true, termWeeks: true } })
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
const context = await loadTermContext(scope || null);
|
||||
return {
|
||||
classId: scope,
|
||||
termStartDate: context.termStartDate || env.termStartDate,
|
||||
currentWeek: resolveCurrentWeek(new Date(), context),
|
||||
maxWeek: cls?.termWeeks && cls.termWeeks > 0 ? cls.termWeeks : MAX_TERM_WEEK,
|
||||
configured: weeks.length > 0,
|
||||
weeks: weeks.map((item) => ({
|
||||
weekNumber: item.weekNumber,
|
||||
startDate: item.startDate,
|
||||
endDate: item.endDate,
|
||||
note: item.note,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存逐周配置(整表覆盖)。仅管理员。
|
||||
*
|
||||
* 校验:日期格式、起止顺序、**区间不重叠**(重叠会让"当前第几周"出现歧义)。
|
||||
* 保存后把新的周次口径下发给该班所有在线设备。
|
||||
*/
|
||||
export async function updateTermWeeks(user: TokenPayload, input: UpdateTermWeeksInput): Promise<TermWeeksDto> {
|
||||
assertCanManageClasses(user);
|
||||
const scope = scopeOf(input.classId);
|
||||
|
||||
const errors = validateTermWeeks(input.weeks);
|
||||
if (errors.length > 0) throw ApiError.badRequest(errors[0]!, { errors });
|
||||
|
||||
// 重复的周次号会让唯一约束直接抛 500,这里给一句人话
|
||||
const seen = new Set<number>();
|
||||
for (const week of input.weeks) {
|
||||
if (seen.has(week.weekNumber)) {
|
||||
throw ApiError.badRequest(`第 ${week.weekNumber} 周出现了两次`);
|
||||
}
|
||||
seen.add(week.weekNumber);
|
||||
}
|
||||
|
||||
if (scope) {
|
||||
const exists = await prisma.class.findUnique({ where: { id: scope }, select: { id: true } });
|
||||
if (!exists) throw ApiError.notFound('班级不存在');
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.termWeek.deleteMany({ where: { classId: scope } }),
|
||||
...(input.weeks.length > 0
|
||||
? [
|
||||
prisma.termWeek.createMany({
|
||||
data: input.weeks.map((week) => ({
|
||||
classId: scope,
|
||||
weekNumber: week.weekNumber,
|
||||
startDate: week.startDate,
|
||||
endDate: week.endDate,
|
||||
note: week.note ?? '',
|
||||
})),
|
||||
}),
|
||||
]
|
||||
: []),
|
||||
...(input.termStartDate && scope
|
||||
? [prisma.class.update({ where: { id: scope }, data: { termStartDate: input.termStartDate } })]
|
||||
: []),
|
||||
]);
|
||||
|
||||
logger.info(
|
||||
`学期周次已更新:范围=${scope || '全校默认'} 周数=${input.weeks.length} 由 ${user.name} 执行`,
|
||||
);
|
||||
await publishTermChange(scope);
|
||||
return getTermWeeks(user, scope || null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按学期开始日期**一键生成**逐周区间(管理员再逐周微调)。仅管理员。
|
||||
*
|
||||
* 这是配置的起点:先生成一份"标准学期",再把调休那几周改掉,
|
||||
* 比从空白开始一周周填要快得多。
|
||||
*/
|
||||
export async function autoTermWeeks(user: TokenPayload, input: AutoTermWeeksInput): Promise<TermWeeksDto> {
|
||||
assertCanManageClasses(user);
|
||||
const scope = scopeOf(input.classId);
|
||||
const context = await loadTermContext(scope || null);
|
||||
const termStartDate = input.termStartDate || context.termStartDate || env.termStartDate;
|
||||
const maxWeek = input.maxWeek ?? context.maxWeek;
|
||||
|
||||
const weeks = buildAutoTermWeeks(termStartDate, maxWeek);
|
||||
if (weeks.length === 0) throw ApiError.badRequest('请先填写学期开始日期(第 1 教学周的周一)');
|
||||
|
||||
return updateTermWeeks(user, { classId: scope || null, termStartDate, weeks });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 调休建议(联网,可选) */
|
||||
|
||||
/** 建议结果的缓存(1 小时):节假日数据一年才变几次,不必每次点都打外网 */
|
||||
const holidayCache = new Map<number, { at: number; data: HolidaySuggestionDto }>();
|
||||
const HOLIDAY_CACHE_TTL_MS = 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* 联网拉取法定节假日安排,给出「哪几天不上课」的建议。
|
||||
*
|
||||
* **`ok:false` 是设计内的结果**:机房与教室机器常常只有内网,离线 / 超时 / 限流
|
||||
* 一律收敛成 `ok:false` + 一句人话,绝不抛 500(与更新检查同一种处理思路)。
|
||||
* 而且它只**给建议**,不会自动改任何数据 —— 管理员确认后才应用到周次上。
|
||||
*/
|
||||
export async function suggestHolidays(year?: number): Promise<HolidaySuggestionDto> {
|
||||
const targetYear = year ?? new Date().getFullYear();
|
||||
const cached = holidayCache.get(targetYear);
|
||||
if (cached && Date.now() - cached.at < HOLIDAY_CACHE_TTL_MS) return cached.data;
|
||||
|
||||
const fallback = (error: string): HolidaySuggestionDto => ({
|
||||
ok: false,
|
||||
error,
|
||||
year: targetYear,
|
||||
holidays: [],
|
||||
checkedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
const response = await fetch(`https://timor.tech/api/holiday/year/${targetYear}`, {
|
||||
signal: controller.signal,
|
||||
headers: { 'user-agent': 'ClassHelper-TermWeeks' },
|
||||
}).finally(() => clearTimeout(timer));
|
||||
|
||||
if (!response.ok) {
|
||||
const data = fallback(`节假日服务返回 ${response.status}`);
|
||||
holidayCache.set(targetYear, { at: Date.now(), data });
|
||||
return data;
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as {
|
||||
holiday?: Record<string, { holiday: boolean; name: string; date: string }>;
|
||||
};
|
||||
const holidays = Object.values(payload.holiday ?? {})
|
||||
.filter((item) => item.holiday === true)
|
||||
.map((item) => ({
|
||||
name: item.name,
|
||||
startDate: item.date,
|
||||
endDate: item.date,
|
||||
}))
|
||||
.sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
|
||||
const data: HolidaySuggestionDto = {
|
||||
ok: true,
|
||||
error: null,
|
||||
year: targetYear,
|
||||
holidays,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
holidayCache.set(targetYear, { at: Date.now(), data });
|
||||
return data;
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error && error.name === 'AbortError'
|
||||
? '请求超时(服务器可能没有外网)'
|
||||
: `无法获取节假日数据:${error instanceof Error ? error.message : String(error)}`;
|
||||
const data = fallback(reason);
|
||||
// 失败结果同样进缓存:否则断网时点一次按钮就要白等一个超时
|
||||
holidayCache.set(targetYear, { at: Date.now(), data });
|
||||
return data;
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ 内部工具 */
|
||||
|
||||
/**
|
||||
* 周次变了要把新的口径**下发给教室的 ClassIsland**(需求:「对所有 ClassIsland 下发修改」)。
|
||||
*
|
||||
* 逐周区间没法直接塞进 ClassIsland 的模型(它按"教学周序号 + 单双周"组织课表),
|
||||
* 因此实际下发的是**学期开始日期**与**各周次的起止**,随镜像课表一起走:
|
||||
* 插件据此校正"现在是第几周",并让课表的周次口径与教室里的实际日历对齐。
|
||||
*/
|
||||
async function publishTermChange(scope: string): Promise<void> {
|
||||
if (scope) {
|
||||
emitToClass(scope, SOCKET_EVENTS.classUpdated, { classId: scope, action: 'updated' });
|
||||
await notifyScheduleChanged(scope).catch((error) => {
|
||||
logger.warn(`周次变更后下发课表失败(数据已保存):${String(error)}`);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 全校默认:把每个在读班级都刷一遍
|
||||
const classes = await prisma.class.findMany({
|
||||
where: { archivedYearId: null },
|
||||
select: { id: true },
|
||||
});
|
||||
for (const item of classes) {
|
||||
emitToClass(item.id, SOCKET_EVENTS.classUpdated, { classId: item.id, action: 'updated' });
|
||||
await notifyScheduleChanged(item.id).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,12 @@ export function emitToClass(classId: string, event: string, payload: unknown): v
|
||||
emitToRooms([SOCKET_ROOMS.class(classId)], event, payload);
|
||||
}
|
||||
|
||||
/** 定向推送给单个用户 */
|
||||
export function emitToUser(userId: string, event: string, payload: unknown): void {
|
||||
emitToRooms([SOCKET_ROOMS.user(userId)], event, payload);
|
||||
/**
|
||||
* 定向推送给单个会话。
|
||||
*
|
||||
* 会话 id:教师/管理员是自己的账号 id,ClassHelper 班级端是**班级 id**
|
||||
* (见 socket.ts 的握手逻辑)。学生不是账号,因此没有"学生房间"。
|
||||
*/
|
||||
export function emitToSession(sessionId: string, event: string, payload: unknown): void {
|
||||
emitToRooms([SOCKET_ROOMS.session(sessionId)], event, payload);
|
||||
}
|
||||
@@ -52,8 +52,8 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
return;
|
||||
}
|
||||
const payload = verifyToken(token);
|
||||
// 班级账号(班级设备):socket.data.user.id 用班级 id,据此加入 user:{classId} 房间,
|
||||
// "叫人"等定向消息因此能直达班级设备(见 calls.service)。
|
||||
// ClassHelper 班级端:socket.data.user.id 用**班级 id**,据此加入 session:{classId} 房间,
|
||||
// 「叫人」等定向消息因此能直达教室机器(见 calls.service)。
|
||||
socket.data.user = {
|
||||
id: payload.classSession ? (payload.classId ?? payload.sub) : payload.sub,
|
||||
username: payload.classSession ? (payload.classCode ?? payload.username) : payload.username,
|
||||
@@ -70,7 +70,7 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
io.on('connection', (socket) => {
|
||||
const user = socket.data.user;
|
||||
void (async () => {
|
||||
const rooms = [SOCKET_ROOMS.user(user.id), SOCKET_ROOMS.role(user.role)];
|
||||
const rooms = [SOCKET_ROOMS.session(user.id), SOCKET_ROOMS.role(user.role)];
|
||||
|
||||
try {
|
||||
const classIds = await getAccessibleClassIds({
|
||||
@@ -88,11 +88,13 @@ export function initRealtime(httpServer: HttpServer): RealtimeServer {
|
||||
logger.warn(`加入班级房间失败(user=${user.id})`, error);
|
||||
}
|
||||
|
||||
await socket.join(user.role === 'STUDENT' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers);
|
||||
rooms.push(user.role === 'STUDENT' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers);
|
||||
// ClassHelper 班级端归入 students 房间(它代表全班),教师/管理员归入 teachers
|
||||
const audienceRoom = user.role === 'CLASS_DEVICE' ? SOCKET_ROOMS.students : SOCKET_ROOMS.teachers;
|
||||
await socket.join(audienceRoom);
|
||||
rooms.push(audienceRoom);
|
||||
|
||||
logger.info(`实时连接建立:${user.name}(${user.role}) sid=${socket.id}`);
|
||||
socket.emit(SOCKET_EVENTS.connected, { userId: user.id, role: user.role, rooms });
|
||||
socket.emit(SOCKET_EVENTS.connected, { sessionId: user.id, role: user.role, rooms });
|
||||
})();
|
||||
|
||||
// 客户端可显式订阅某个班级(服务端二次校验权限)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"name": "@classhelper/shared",
|
||||
"version": "1.1.2",
|
||||
"version": "1.2.0",
|
||||
"private": true,
|
||||
"description": "班级小助手 - 三端共享的类型、常量与工具函数",
|
||||
"description": "ClassHelper - 三端共享的类型、常量与工具函数",
|
||||
"type": "module",
|
||||
"main": "./dist/index.js",
|
||||
"types": "./dist/index.d.ts",
|
||||
|
||||
Loaded 100 of 172 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user