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

@@ -125,12 +125,27 @@ function syncIndicator(): void {
return;
}
const rect = active.getBoundingClientRect();
/**
* 「看得见」不等于「宽高 > 0」。
*
* 分组(`.el-sub-menu`)收起时,Element Plus 是把它那个 `<ul>` 的**高度**动画到 0,
* 再配 `overflow: hidden` 把内容裁掉 —— 子项**自身的 rect 仍在原位**(布局压根没变)。
* 不补这一条,底片就会停在那个已经被裁掉的子项上:用户看到的是"设置分组收起来了,
* 但选择块还留在那儿"(2026-10-11 反馈,图一那个空的蓝块就是它)。
* 判据取"子项是否落在父 `<ul>` 的可见高度里",收起动画过程中也成立(逐帧为假)。
*/
const listRect = active.parentElement?.getBoundingClientRect();
const insideList =
!listRect ||
(listRect.height > 0.5 && rect.top >= listRect.top - 0.5 && rect.bottom <= listRect.bottom + 0.5);
indicator.value = {
x: rect.left - boxRect.left,
y: rect.top - boxRect.top,
width: rect.width,
height: rect.height,
visible: rect.width > 0 && rect.height > 0,
visible: rect.width > 0 && rect.height > 0 && insideList,
};
}
@@ -142,6 +157,16 @@ const indicatorStyle = computed(() => ({
}));
let asideObserver: ResizeObserver | null = null;
/**
* 分组(`.el-sub-menu`)展开 / 收起的观察器。
*
* 为什么 `asideObserver` 不够:收起分组改的是它内部 `<ul>` 的**高度**,`.aside` 与 `.menu`
* 这两个盒子的尺寸都不变(栏宽没动、滚动容器也还是那么高),于是 ResizeObserver 收不到回调,
* 底片就停在刚才那个子项的位置上(见 `syncIndicator()` 里那段注释)。
* 直接观察每个分组的 `<ul>`,它的高度一动就重新量。
*/
let submenuObserver: ResizeObserver | null = null;
let indicatorFrame = 0;
/**
@@ -205,6 +230,15 @@ onMounted(() => {
if (aside && typeof ResizeObserver !== 'undefined') {
asideObserver = new ResizeObserver(scheduleIndicatorSync);
asideObserver.observe(aside);
// 分组的展开 / 收起(见 submenuObserver 的注释);等一拍再挂,确保 el-menu 已经把
// 各分组的 <ul> 渲染出来 —— onMounted 这一刻子组件的菜单项可能还没落位
void nextTick(() => {
submenuObserver = new ResizeObserver(scheduleIndicatorSync);
document
.querySelectorAll<HTMLElement>('.aside.ch-nav .el-sub-menu > .el-menu')
.forEach((el) => submenuObserver?.observe(el));
});
}
});
@@ -217,6 +251,8 @@ onUnmounted(() => {
menuScroller = null;
asideObserver?.disconnect();
asideObserver = null;
submenuObserver?.disconnect();
submenuObserver = null;
});
// 路由变化后菜单的 active 类由 Element Plus 更新,等一拍再量