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

@@ -0,0 +1,30 @@
-- 成绩:按考试记住「是否把所有科目相加以总分排名」
--
-- 需求:「单次考试(如月考,期中考),在导入成绩时可选是否将所有科目相加以总分排名,
-- 客户端也可查看到总分排名和个别单科的排名」。
--
-- `Grade.examName` 一直是自由文本,服务端光看它不知道这次考试算不算总分;而客户端要显示
-- 总分榜就必须知道,所以按「班级 + 考试名」单独记一条 `Exam`。
--
-- 与 `Grade` 是**弱关联**:两边只按名字匹配,不建外键。成绩删光不会连带删掉设置,
-- 补录同一场考试时设置还在。
--
-- 注意:本仓库的 SQLite / MySQL 双库约束是「只用 String / Int / Float / Boolean / DateTime」,
-- 因此这里没有 enum、没有 @db.*。
-- CreateTable
CREATE TABLE "Exam" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"countInTotal" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Exam_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "Exam_classId_name_key" ON "Exam"("classId", "name");
-- CreateIndex
CREATE INDEX "Exam_classId_idx" ON "Exam"("classId");
@@ -0,0 +1,16 @@
-- 课表下发改成「按按钮才发生」
--
-- 需求:「ClassIsland 联动改为下发或获取课表,获取课表时点击获取才会获取,而不是一直获取」。
--
-- 起因(2026-10-11 实测):插件每 `ReportIntervalSeconds`(默认 15)秒上报一次,且
-- 本机 `UploadSchedule` 开着时**每次都带全量课表**、`MirrorSchedule` 开着时**每次上报完
-- 还顺手镜像一遍**。于是教室的课表每 15 秒被重写一次,服务端这边也每 15 秒
-- merge + 清理一次幽灵行 —— 老师看到的就是"课表被反复删改"。
--
-- 现在两个方向都只由一次性待办触发:
-- · 教室 → 服务端:`requestScheduleReport`(已有,「从教室机器获取课表」按钮)
-- · 服务端 → 教室:`requestMirrorSchedule`(本次新增,「下发课表到教室」按钮)
-- `mirrorScheduleToClassIsland` 的含义同时收窄为「这台设备接不接受下发」,不再是自动同步开关。
-- AlterTable
ALTER TABLE "IntegrationDevice" ADD COLUMN "requestMirrorSchedule" BOOLEAN NOT NULL DEFAULT false;
+38 -1
View File
@@ -241,6 +241,7 @@ model Class {
homeworks Homework[]
notifications Notification[]
grades Grade[]
exams Exam[]
timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
@@ -465,11 +466,23 @@ model IntegrationDevice {
/// 「从教室机器取一次课表」改成 Web 端的手动动作(点击前会弹警告),
/// 因为自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。
syncScheduleToServer Boolean @default(false)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。**默认开启** —— 这是现在的主方向。
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)。
///
/// **2026-10-11 起含义收窄为"允许下发"**:不再是"自动同步的主方向" —— 插件以前每 15 秒
/// 上报一次、每次上报完都顺手镜像一遍(`MirrorSchedule` 开着时),教室的课表被反复重写;
/// 现在镜像只由 `requestMirrorSchedule` 这个一次性待办触发,这里只表示"这台设备接不接受下发"。
mirrorScheduleToClassIsland Boolean @default(true)
/// 待办的「立即上报一次课表」请求:Web 端点「从教室机器获取课表」时置位,
/// 插件在下一次心跳看到它就把当前课表推上来并清除标记。
requestScheduleReport Boolean @default(false)
/// 待办的「立即下发一次课表」请求:Web 端点「下发课表到教室」时置位,
/// 插件在下一次心跳看到它就去拉本班课表并**强制替换**写进 ClassIsland,
/// 拉取成功即由服务端清除标记(见 `pullClassPlanForDevice`)。
///
/// 为什么要一次性待办而不是"开关":教室机常常离线,一个实时下发的长连接握手不值得,
/// 而后台定时镜像会与老师在教室本地做的调整互相打架(2026-10-11 实测:
/// 插件每 15 秒重写一遍课表,服务端那边看起来就是课表被反复删改)。
requestMirrorSchedule Boolean @default(false)
classIslandVersion String?
pluginVersion String?
/// 最后一次**带运行状态**的上报时间
@@ -696,3 +709,27 @@ model AiAudit {
@@index([userId, createdAt])
}
/// 一次考试(月考 / 期中考…)在本班的设置。
///
/// 需求:「单次考试在导入成绩时可选是否将所有科目相加以总分排名,客户端也能看到总分排名」。
/// `Grade.examName` 是自由文本,服务端光看它不知道这次考试算不算总分,而客户端要显示总分榜
/// 就必须知道 —— 于是按「班级 + 考试名」记一条。
///
/// 与 `Grade` 是**弱关联**(两边只按名字匹配,不建外键):成绩删光也不会连带删掉设置,
/// 补录同一场考试时设置还在;反过来留一条没有成绩的设置是无害的。
model Exam {
id String @id @default(cuid())
classId String
/// 考试名,与 `Grade.examName` 同口径(如「第一次月考」)
name String
/// 是否把所有科目(本次考试已录入的)相加做总分排名
countInTotal Boolean @default(false)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
@@unique([classId, name])
@@index([classId])
}