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

@@ -167,9 +167,7 @@ async function resetPhrases(): Promise<void> {
* 里手动禁用了它,这时候配置写着 true 也不该显示成已开启。
*/
const autoLaunch = ref(false);
const autoUpdate = ref(true);
const savingLaunch = ref(false);
const savingUpdate = ref(false);
/** 服务端联动状态(心跳上报 / 目标版本 / 待安装版本) */
const agent = ref<ClientAgentStatus | null>(null);
@@ -202,7 +200,6 @@ const lastErrorText = computed(() => agent.value?.lastError?.trim() || '无');
async function loadLaunchSettings(): Promise<void> {
if (!window.desktop?.getConfig) return;
const config = await window.desktop.getConfig();
autoUpdate.value = config.autoUpdate !== false;
autoLaunch.value = (await window.desktop.getAutoLaunch?.()) ?? config.autoLaunch === true;
const info = await window.desktop.getAppInfo?.().catch(() => null);
if (info?.appVersion) appVersion.value = info.appVersion;
@@ -227,52 +224,7 @@ async function toggleAutoLaunch(value: string | number | boolean): Promise<void>
}
}
async function toggleAutoUpdate(value: string | number | boolean): Promise<void> {
if (!window.desktop?.saveConfig) return;
const wanted = value === true;
autoUpdate.value = wanted;
savingUpdate.value = true;
try {
// 注意:必须传纯布尔值 —— 跨 contextBridge 传响应式对象会在进入 preload 前就被结构化克隆拒绝
const saved = await window.desktop.saveConfig({ autoUpdate: wanted });
autoUpdate.value = saved.autoUpdate !== false;
ElMessage.success(wanted ? '已开启自动更新' : '已关闭自动更新(仍可在「关于」页手动更新)');
} catch (error) {
autoUpdate.value = !wanted;
ElMessage.error(error instanceof Error ? error.message : '保存自动更新设置失败');
} finally {
savingUpdate.value = false;
}
}
/* ------------------------------------------------------------ 关怀模式 / 开发者模式 */
/**
* 关怀模式(需求 14):放大**正文与控件**字号。
*
* 这里只挂 `<html>.care-mode` 类并落盘,放大规则全在 `styles/index.css` 末尾那个集中覆盖块里
* (`--ch-font-scale` + 少数几个常用类),不在 JS 里逐个元素改字号。
*
* 开关直接读 ui store 的状态,**不用本地 ref 复制一份**:侧栏、路由守卫、主题页读的都是它,
* 复制就会出现"两个开关各说各话"。`ui.init()` 在首屏渲染前已从主进程配置恢复过。
*
* 关于"与助手联动":开启关怀模式会放大 AI 助手的按钮 —— 这条联动**只存在于 Web 管理端**,
* 本客户端目前没有助手入口,所以这里不放大任何按钮,也不为了"看起来对称"去造一个入口。
*/
const savingCareMode = ref(false);
async function toggleCareMode(value: string | number | boolean): Promise<void> {
const wanted = value === true;
savingCareMode.value = true;
try {
await ui.setCareMode(wanted);
ElMessage.success(wanted ? '已开启关怀模式:正文与控件字号已放大' : '已关闭关怀模式');
} catch (error) {
ElMessage.error(error instanceof Error ? error.message : '保存关怀模式设置失败');
} finally {
savingCareMode.value = false;
}
}
/* ------------------------------------------------------------ 开发者模式 */
/**
* 开发者模式(需求 10):显示调试页面与详细日志,仅排查问题时打开。
@@ -448,13 +400,15 @@ onBeforeUnmount(() => {
<strong>不上课且窗口已隐藏 5 分钟后</strong>
进行,下载随时可能发生。
</p>
<p class="text-muted">服务端可以统一要求更新,关掉它只会把安装推迟到手动处理。</p>
<!--
这里**刻意没有开关**(需求:「客户端无权选择是否要自动更新」)。
教室机器是统一的投放终端,版本一致性由管理员在服务端「客户端设备」页决定;
留一个本地开关只会让"为什么这台还没升"变成无解的排查题。
客户端配置里的 `autoUpdate` 仍然照读(`autoUpdate !== false` 才自动装),
因为服务端下发的是一台机器的目标版本,不是"允许不允许更新"。
-->
<p class="text-muted">更新由管理员在服务端统一推送,教室机器不提供本地开关。</p>
</div>
<el-switch
:model-value="autoUpdate"
:loading="savingUpdate"
@update:model-value="toggleAutoUpdate"
/>
</div>
<div class="toolbar mt-12">
<el-button @click="openLogDir">打开日志目录</el-button>
@@ -464,23 +418,13 @@ onBeforeUnmount(() => {
<el-card shadow="never" class="mt-12">
<template #header><span>显示与调试</span></template>
<div class="switch-row">
<div class="switch-main">
<span>关怀模式</span>
<p class="text-muted">
放大正文与控件字号(页面标题、表格、表单、卡片标题一起变大);圆角、投影与侧栏宽度等几何尺寸保持原样,侧栏图标导轨也不放大。
</p>
<p class="text-muted">
「开启后同时放大 AI 助手按钮」这一条只在 Web 管理端生效 —— 本客户端目前没有助手入口。
</p>
</div>
<el-switch
:model-value="ui.careMode"
:loading="savingCareMode"
data-test="care-mode-switch"
@update:model-value="toggleCareMode"
/>
</div>
<!--
关怀模式**只保留在 Web 管理端**(需求:「客户端无需关怀模式」)。
客户端是投在教室大屏上的固定界面,字号由「外观」页的看板/岛设置管,
再来一套全站放大只会和那些设置互相打架,所以这里不再提供开关。
`stores/ui.ts` 与 `styles/index.css` 里的那套管道暂时保留(配置里还有这个字段,
删掉会让老 config.json 反序列化时多一条归一化分支),但界面上不再暴露。
-->
<div class="switch-row">
<div class="switch-main">
<span>开发者模式</span>
@@ -37,13 +37,13 @@ const classIslandStatus = ref<{
classIslandVersion: string | null;
} | null>(null);
/* ------------------------------------------------------------ 班级端成绩查询开关
/* ------------------------------------------------------------ 班级端成绩查询状态(只读)
*
* 「是否允许学生通过班级端查看本人明细,由教师或管理员开关控制」——
* 教室机器在这里决定,值存在班级上;Web 端「班级管理」里也能改同一个开关。
* 「是否允许班级端查看成绩」是**服务端口径**:管理员在 Web 端「班级管理」里开关,
* 这台机器只负责把状态显示出来(需求:「成绩查询由服务端控制,客户端只读」)。
* 曾经这里有个开关 —— 教室机器能自己开成绩查询,等于把"学校说不给看"变成了一台机器说了算。
*/
const gradeQueryEnabled = ref(true);
const gradeQuerySaving = ref(false);
async function loadGradeQuery(): Promise<void> {
const classId = auth.user?.classId;
@@ -56,19 +56,6 @@ async function loadGradeQuery(): Promise<void> {
}
}
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 '未接入';
@@ -154,21 +141,24 @@ onMounted(async () => {
</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)"
/>
<p class="text-muted">
本开关由管理员在服务端「班级管理」和「客户端设备」里统一设置,教室机器不提供本地开关。
</p>
<el-tag :type="gradeQueryEnabled ? 'success' : 'info'" effect="plain">
{{ gradeQueryEnabled ? '当前:已允许查看成绩与排名' : '当前:已由管理员关闭' }}
</el-tag>
<div class="toolbar mt-8">
<el-button size="small" :icon="'Refresh'" @click="loadGradeQuery">刷新状态</el-button>
</div>
</el-card>
</div>
</template>