fix(integrations,grades,clients,web,client,plugin): 课表同步改为按需(修「课表被反复删改」)+ 客户端页合并 + 成绩排名(1.3.1)

线上问题的根因
- ClassIsland 插件默认每 15 秒上报一次,每次都带全量课表,上报完还顺手把课表镜像回教室
  (本机 `UploadSchedule` / `MirrorSchedule` 两个开关默认都是开)。于是教室的课表每 15 秒被重写、
  服务端也每 15 秒 merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。
- 现在两个方向都只由 Web 端的一次性待办触发,插件侧那两个开关整体删除;
  定时上报只报**运行状态**(当前上什么课),不带课表。

课表同步(#3)
- 新增 `IntegrationDevice.requestMirrorSchedule` + `POST /integrations/devices/:id/mirror-schedule`
  (「下发课表」),与既有的 `request-schedule`(「获取课表」)对称;拉取成功即清标记
- 插件 `ClassPlanWriter.Apply(..., force: true)`:停用同一天同一单双周相位下冲突的旧课表
  —— **只停用不删除**,内容原样留在档案里,老师随时能重新启用
- 插件设置页删掉「自动上报课表」「镜像回 ClassIsland」两个开关,改为一句说明

「客户端」页(#4)
- 把「ClassIsland 联动」与「客户端设备」合成一页 `ClientHubView.vue`:一个班一张卡片,
  两条链路状态放一起,勾选可批量下发课表 / 更新,点卡片进详情做逐台操作
- 「下发提醒」也搬了过来;入口权限用 `classisland:notify`(默认 ADMIN + TEACHER),
  页内按钮再各自按 device:manage / remote:control / client:update-push 显示或禁用
- 两个旧页面 `meta.hidden: true` **保留路由**(安装包缓存管理、联动步骤说明仍在),
  从详情抽屉里给链接 —— 不删功能

成绩(#5/#6/#7)
- 新增 `Exam`(班级 + 考试名 + `countInTotal`)与迁移;`GET/PUT /api/grades/exams`、
  `GET /api/grades/ranking`(两条都**不挂权限键**,班级端的有效权限是空集,边界在 service 里)
- 排名口径写在服务端:**同一学生同一科目只取最高分**(补录 / 重考会多行,不去重等于"总分多一科")、
  并列同名次(1、2、2、4)
- 「成绩录入」页加「考试口径」开关(按班级 + 考试名记住);客户端「成绩」页新增总分榜 + 单科榜
- `updateStudentGradeQuery` 改成只有教师 / 管理员能改 —— 原先班级端自己也能改
- 客户端的成绩查询开关改为只读(只显示服务端下发的状态)

其他(#2/#5/#8/#9)
- 全校学期开始日期改成 `AppSetting` 的 `term.startDate`,管理员在「学期周次」页直接改、立刻重算周次
  (原先这一级只能来自 `.env`)
- 大模型助手发紧急通知补上「上课时段 3 秒全屏警告」:判据抽成 `composables/useClassInSession.ts`,
  与手动发布页共用
- 客户端去掉「关怀模式」「自动更新」两个开关(教室机器是统一投放的终端)
- 修掉「选中设置二级菜单后收起分组、高亮块仍留在原地」:底片只在路由 / 滚动 / 缩放时重测,
   而收起分组动不了 `.aside` 的盒子 —— 补上"子项被 overflow:hidden 裁掉"的判据 + 观察分组 `<ul>` 高度
- `src/types/express.d.ts` 里 `req.device` 改成引用 `DeviceContext`:原先手抄了一份结构,
  往接口加字段时两边就漂了,而报错会落在 `getDevice()` 的 `return device;` 上,看不出真正原因

验收:`lint` / `typecheck` / `check:icons` / `docs:check` 全绿;
后端端到端 **247/247**、联动链路 **44/44**、插件静态契约 **76/76**、
Web 真实点击 **34/34**(含新增的「客户端」页用例)、客户端冒烟 **90/90**。
This commit is contained in:
LoveChengke committed 2026-10-11 16:21:38 +08:00
1 parent 3236d3ad83
commit 227117ccb3
49 files changed
+2025 -281

No files matched your search

+59 -7
View File
@@ -47,9 +47,10 @@ ClassIsland(教室机器)──► 联动插件 ──► /api/integrations/
- [4.12 学期周次](#412-学期周次可逐周覆盖仅管理员)
- [4.13 课表同步方向](#413-课表同步方向服务端--教室)
- [4.14 权限组管理(角色只是默认模板)](#414-权限组管理角色只是默认模板)
- [4.15 客户端设备统一更新与远控](#415-客户端设备统一更新与远控)
- [4.15 客户端(合并页):统一更新与远控](#415-客户端合并了classisland-联动客户端设备统一更新与远控)
- [4.16 通知按天归档与批量发布](#416-通知按天归档与批量发布)
- [4.17 大模型助手与开发者模式 / 关怀模式](#417-大模型助手与开发者模式--关怀模式)
- [4.18 考试成绩:单科排名与总分排名](#418-考试成绩单科排名与总分排名)
- [5. 技术参考](#5-技术参考)
- [5.1 目录结构](#51-目录结构)
- [5.2 技术栈](#52-技术栈)
@@ -698,9 +699,23 @@ POST /api/notifications { …, priority: "NORMAL" }(上课时段)
| 方向 | 做什么 | 老师在哪看 |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -------------------------------------- |
| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止时间),写入本班课表 | Web 端「ClassIsland 联动」实时状态卡片 |
| ClassHelper → ClassIsland | 老师在 Web 端发一条**提醒** → 落库 → 教室机器的插件取回 → ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不会重复弹 | Web 端「ClassIsland 联动 → 下发提醒」 |
| (可选)班级 → ClassIsland | 把 ClassHelper上排好的课表**镜像**回 ClassIsland:新建一份 `ClassHelper-<班级名>` 档案课表,不动老师原有的课表 | 同上页面的「开镜像」开关 |
| ClassIsland → ClassHelper | 上报**课表 + 节次时间 + 上课状态**(现在上什么 / 下一节 / 本节起止时间),写入本班课表 | 「客户端」页的班级卡片与详情 |
| ClassHelper → ClassIsland | 老师在 Web 端发一条**提醒** → 落库 → 教室机器的插件取回 → ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不会重复弹 | 「客户端」页 → 「下发提醒」 |
| ClassHelper → ClassIsland(课表) | 把本班课表**下发**到教室:写一份 `ClassHelper-<班级名>` 档案课表,并**停用**同一天同一单双周相位下冲突的旧课表(只停用、不删除) | 「客户端」页 → 「下发课表」 |
#### 课表同步:两个方向都「按需」
**没有"一直同步"的开关**,两个方向都只在你点按钮时发生一次:
| 动作 | 从哪点 | 插件做什么 |
| ------------------------ | ------------------------------------- | ---------------------------------------------------------------- |
| **下发课表**(→ 教室) | 「客户端」页的卡片 / 详情里的「下发课表」 | 拉本班课表 → 写进 ClassIsland → **停用**冲突的旧课表(强制替换) |
| **获取课表**(→ 服务端) | 同上的「获取课表」 | 把 ClassIsland 里**启用的**课表合并进本班课表 |
> **为什么不做自动同步**:插件原先默认每 15 秒上报一次、每次上报都带全量课表、上报完还顺手镜像一遍 ——
> 教室的课表每 15 秒被重写、服务端也每 15 秒 merge + 清理一次幽灵行,老师看到的是「课表被反复删改」。
> 现在定时上报**只报运行状态**(当前上什么课),课表永远只在点击时双向走一次。
> 「强制替换」也**只停用不删除**:冲突的课表内容原样留在 ClassIsland 档案里,随时可以重新启用。
#### 接入流程(三步)
@@ -875,9 +890,14 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
| 拉取放假安排 | 联网获取当年的法定节假日,**只给建议、不会自动改数据** |
| 保存 | 校验区间不重叠(重叠会让"现在第几周"出现歧义),然后**下发给所有 ClassHelper 班级端** |
- 存储:`TermWeek`(`classId = ''` 表示全校默认)+ `Class.termStartDate`;
- 判定:`lib/term.ts` 的 `loadTermContext()` 做「班级覆盖 → 全校默认 → 纯线性」三级回落,
- 存储:`TermWeek`(`classId = ''` 表示全校默认)+ `Class.termStartDate`(班级)+ `AppSetting`
的 `term.startDate`(全校默认的开学日期);
- 判定:`lib/term.ts` 的 `loadTermContext()` 做三级回落 ——
开学日期按「`Class.termStartDate` → 全校设置 → `.env` 的 `TERM_START_DATE`」,
逐周区间按「班级 → 全校默认 → 纯线性」,
`resolveCurrentWeek(now, context)` 优先按区间判定;
- **开学日期在页面上就能改**(2026-10-11 起):原先全校默认那一级只能来自 `.env`,填错了得动文件 +
重启;现在管理员在「学期周次」页填完保存即可,**当前周次立刻按新日期重算**,随时可以纠正。
- 机房/服务器**没有外网时,"拉取放假安排"返回 `ok:false` 是正常结果**(不是错误),
失败结果同样进缓存,避免断网点一次要白等一个超时。
@@ -922,7 +942,16 @@ MySQL 的连接测试 / 建表 / 写入依赖随包内置的 `@prisma/adapter-ma
- 判定规则与完整矩阵见[角色与权限](dev/permissions.md);**服务端始终是唯一拦截点**,
前端只按 `GET /permissions/me` 隐藏入口,别把它当安全边界。
### 4.15 客户端设备统一更新与远控
### 4.15 客户端(合并了「ClassIsland 联动」+「客户端设备」,统一更新与远控)
入口:Web 管理端 →「客户端」。**一个班一张卡片**,卡片上同时看两条链路的状态
(ClassIsland 插件 = 教室大屏;ClassHelper 客户端 = 灵动岛),勾选可**批量下发课表 / 更新**,
点卡片进详情做逐台操作(签发令牌、重置、下发 / 获取课表、远控、移除记录)。
> 2026-10-11 合并:原先分成「ClassIsland 联动」与「客户端设备」两页,讲的却是同一个教室的两半 ——
> 要给一个班做一件事,得先想清楚该进哪一页,批量操作更是无从下手。
> 入口权限用 `classisland:notify`(默认 ADMIN + TEACHER,教师要用「下发提醒」),
> 页内的设备管理 / 远控 / 下发更新各自再要 `device:manage` / `remote:control` / `client:update-push`。
每台教室机器的心跳间隔 `CLIENT_HEARTBEAT_INTERVAL_MS` = **30 秒**,一次请求把两件事带回:
「要不要更新 / 装哪个版本」与「有没有待执行的远控命令」(`POST /clients/heartbeat`,**不挂权限键**)。
@@ -1007,6 +1036,29 @@ BulkPublishClassResult { classId, ok, id?, code?, message?, details? }
侧栏折叠同样是本机偏好。`User.careMode` 列已经建好,但**服务端目前没有读写它** ——
别把它当成"已经有服务端开关"。
### 4.18 考试成绩:单科排名与总分排名
一次考试(月考 / 期中考…)在本系统里就是一组同名 `examName` 的成绩。除了单科分数,还能看**排名**:
| 能力 | 在哪 | 说明 |
| ---------------- | ------------------------------------------- | -------------------------------------------------------------------- |
| 单科排名 | 「成绩录入」页 / 教室「客户端」的成绩页 | 按得分率排;并列同名次(1、2、2、4) |
| **总分排名** | 同上 | 把这次考试的**所有科目相加**,按总分排 |
| 考试口径开关 | 「成绩录入」页 → 筛选出某个「考试名」后出现 | 「所有科目相加,排总分榜」/「只排单科,不合总分」 |
| 班级端成绩与排名 | 教室机器的「成绩」页 | 由**服务端**决定开不开(`Class.studentGradeQueryEnabled`),客户端只读 |
两条口径写死在服务端(`modules/grades`),三端共用:
- **总分是"考试"的属性,不是"分数"的属性** —— 语数外 150 分、音体美 100 分不是一个量纲,
合不合总分由学校决定,所以按「班级 + 考试名」存一条 `Exam.countInTotal`;
- **同一学生同一科目只取最高分**:老师补录 / 重考会产生多行,不去重的话"补录一条"就变成
"总分多一科",排名直接失真;
- 总分的**科目数**会一并返回(`subjectCount`),便于识别"只录了两科也上榜"的情况。
教室机器能不能看由 `Class.studentGradeQueryEnabled` 控制(默认开启);**关掉后客户端一律 403**。
这个开关**只有教师 / 管理员能在 Web 端改**(2026-10-11 起)—— 原先教室机器自己也能改,
等于把"学校说不给看"变成了一台机器说了算。
## 5. 技术参考
### 5.1 目录结构