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

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@classhelper/desktop-client",
"version": "1.3.0",
"version": "1.3.1",
"private": true,
"description": "ClassHelper EXE 客户端(Electron + Vue 3 + Element Plus + Socket.IO + IndexedDB 离线缓存)",
"author": "Class Helper",
@@ -4,6 +4,8 @@ import {
type ClassDto,
type ClassIslandNotificationChannel,
type CourseDto,
type ExamDto,
type ExamRankingDto,
type HomeworkDaysDto,
type DashboardSummary,
type GradeDto,
@@ -136,6 +138,15 @@ export const gradeApi = {
*/
studentDetail: (studentNo: string): Promise<StudentGradeDetailDto> =>
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`),
/** 本班出现过的考试(含"这次考试算不算总分") */
exams: (classId: string): Promise<ExamDto[]> => api.get(`${API_PATHS.grades}/exams`, { classId }),
/**
* 一次考试的单科榜与总分榜。
* 总分榜只在老师把这次考试设为"所有科目相加排名"时才有内容;
* 整条通道与按学号查明细共用同一个按班开关(关闭时服务端 403)。
*/
ranking: (classId: string, examName: string): Promise<ExamRankingDto> =>
api.get(`${API_PATHS.grades}/ranking`, { classId, examName }),
};
/**
@@ -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 更新,等一拍再量
@@ -6,6 +6,8 @@ import {
formatDate,
gradeLevel,
gradePercent,
type ExamDto,
type ExamRankingDto,
type GradeDto,
type StudentGradeDetailDto,
} from '@classhelper/shared';
@@ -114,16 +116,69 @@ async function loadGrades(): Promise<void> {
}
}
/* ------------------------------------------------------------ 考试排名
*
* 需求:「服务端选择成绩查询可用时,客户端可以直接查看全部,并且自动排名;
* 单次考试可选是否把所有科目相加以总分排名,客户端也能看到总分排名和个别单科的排名」。
*
* 排名**一律由服务端算**(`GET /api/grades/ranking`):并列名次(1、2、2、4)、
* "同一学生同一科补录过多条只取最高分"这些口径写在服务端,三端才不会各算一套。
* 是否开放由班级上的 `studentGradeQueryEnabled` 决定(关闭时服务端 403),
* 而"这次考试算不算总分"由老师在 Web 端定(`Exam.countInTotal`)——
* 客户端只负责显示,两个开关都不在这里。
*/
const examList = ref<ExamDto[]>([]);
const selectedExam = ref('');
const ranking = ref<ExamRankingDto | null>(null);
const rankingLoading = ref(false);
const rankingError = ref('');
async function loadExams(): Promise<void> {
const classId = auth.user?.classId;
if (!classId) return;
try {
examList.value = await gradeApi.exams(classId);
if (!selectedExam.value && examList.value.length > 0) {
// 默认看最近一次:列表按名字排,取最后一条通常就是最新那场考试
selectedExam.value = examList.value[examList.value.length - 1]?.name ?? '';
}
await loadRanking();
} catch (error) {
rankingError.value = error instanceof Error ? error.message : '获取考试列表失败';
}
}
async function loadRanking(): Promise<void> {
const classId = auth.user?.classId;
if (!classId || !selectedExam.value) {
ranking.value = null;
return;
}
rankingLoading.value = true;
rankingError.value = '';
try {
ranking.value = await gradeApi.ranking(classId, selectedExam.value);
} catch (error) {
ranking.value = null;
rankingError.value = error instanceof Error ? error.message : '获取排名失败';
} finally {
rankingLoading.value = false;
}
}
function onGradeEvent(): void {
void loadGrades();
void loadExams();
}
function onRecovered(): void {
void loadGrades();
void loadExams();
}
onMounted(async () => {
await loadGrades();
await loadExams();
realtime.on(SOCKET_EVENTS.gradeUpdated, onGradeEvent);
appStore.onServerRecovered(onRecovered);
});
@@ -195,6 +250,87 @@ onUnmounted(() => {
</template>
</el-card>
<!-- 考试排名:总分榜(老师把这次考试设成"所有科目相加"时才有)+ 单科榜。
排名与"算不算总分"都由服务端说了算,这里只显示。 -->
<el-card v-if="auth.isClassSession" v-loading="rankingLoading" shadow="never" class="mt-16">
<template #header>
<div class="ranking-header">
<span>考试排名</span>
<el-select
v-model="selectedExam"
placeholder="选择考试"
size="small"
style="width: 200px"
:disabled="examList.length === 0"
@change="loadRanking"
>
<el-option
v-for="item in examList"
:key="item.name"
:label="`${item.name}(${item.gradeCount} 条)`"
:value="item.name"
/>
</el-select>
<el-button size="small" :icon="'Refresh'" @click="loadRanking">刷新</el-button>
</div>
</template>
<el-alert v-if="rankingError" type="warning" :closable="false" show-icon :title="rankingError" />
<el-empty
v-else-if="examList.length === 0"
description="还没有成绩记录,先在 Web 端录入或导入"
:image-size="60"
/>
<template v-else-if="ranking">
<el-alert v-if="!ranking.countInTotal" type="info" :closable="false" show-icon class="mb-12">
<template #title>这次考试没有开启总分排名</template>
<template #default>
语数外与音体美的分数量纲不同,合不合总分由老师决定 —— 需要总分榜请在 Web 端「成绩录入」
页把该次考试的「考试口径」打开。
</template>
</el-alert>
<el-table v-if="ranking.countInTotal" :data="ranking.total" size="small" max-height="320">
<el-table-column label="总分排名" width="96">
<template #default="{ row }">
<el-tag :type="row.rank <= 3 ? 'danger' : 'info'" effect="plain" size="small">
{{ row.rank }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="studentNo" label="学号" width="110" />
<el-table-column prop="studentName" label="姓名" width="110" />
<el-table-column label="总分" width="90">
<template #default="{ row }">{{ row.score }}</template>
</el-table-column>
<el-table-column label="科目数" width="90">
<template #default="{ row }">{{ row.subjectCount }}</template>
</el-table-column>
</el-table>
<el-divider v-if="ranking.countInTotal" content-position="left">单科排名</el-divider>
<el-tabs v-if="ranking.subjects.length > 0" type="border-card">
<el-tab-pane
v-for="subject in ranking.subjects"
:key="subject.courseName"
:label="subject.courseName"
>
<el-table :data="subject.rows" size="small" max-height="300">
<el-table-column label="名次" width="80">
<template #default="{ row }">{{ row.rank }}</template>
</el-table-column>
<el-table-column prop="studentNo" label="学号" width="110" />
<el-table-column prop="studentName" label="姓名" width="110" />
<el-table-column label="得分" width="120">
<template #default="{ row }">{{ row.score }} / {{ row.totalScore }}</template>
</el-table-column>
</el-table>
</el-tab-pane>
</el-tabs>
<el-empty v-else description="这次考试还没有单科成绩" :image-size="60" />
</template>
</el-card>
<el-row :gutter="12">
<el-col :xs="24" :md="8">
<div class="stat-card">
@@ -305,4 +441,14 @@ onUnmounted(() => {
gap: 6px;
margin-top: 8px;
}
.ranking-header {
display: flex;
align-items: center;
gap: 8px;
}
.mb-12 {
margin-bottom: 12px;
}
</style>
@@ -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>