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:
1 parent
3236d3ad83
commit
227117ccb3
49 files changed
+2025
-281
No files matched your search
@@ -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>
|
||||
Reference in new issue
Block a user