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
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user