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

+19
View File
@@ -42,6 +42,8 @@ import {
type DatabaseConnectionTestDto,
type DatabaseStatusDto,
type DatabaseSwitchJobDto,
type ExamDto,
type ExamRankingDto,
type GradeDto,
type GradeLevelType,
type GradeStats,
@@ -298,6 +300,14 @@ export const gradeApi = {
/** 按**学号**查某个学生的成绩明细(教师端"按学号查成绩") */
studentDetail: (studentNo: string, classId?: string): Promise<StudentGradeDetailDto> =>
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`, classId ? { classId } : undefined),
/** 某班出现过的考试(含"是否计入总分") */
listExams: (classId: string): Promise<ExamDto[]> => api.get(`${API_PATHS.grades}/exams`, { classId }),
/** 设置"这次考试是否把所有科目相加做总分排名" */
setExam: (payload: { classId: string; name: string; countInTotal: boolean }): Promise<ExamDto> =>
api.put(`${API_PATHS.grades}/exams`, payload),
/** 一次考试的单科榜与总分榜 */
ranking: (params: { classId: string; examName: string }): Promise<ExamRankingDto> =>
api.get(`${API_PATHS.grades}/ranking`, params),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.grades}/${id}`),
};
@@ -513,6 +523,15 @@ export const integrationApi = {
*/
requestSchedule: (id: string): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> =>
api.post(`${API_PATHS.integrations}/devices/${id}/request-schedule`, {}),
/**
* 「**下发一次课表到教室**」:置一个待办,插件在下一次心跳拉取本班课表并写进 ClassIsland,
* 同时把同一天同一单双周相位下冲突的旧课表**停用**(只停用、不删除)。
*
* 与 `requestSchedule` 反方向、同样是一次性动作 —— 课表同步不再有"一直同步"的开关
* (那会让教室课表被反复重写,见 AGENTS.md §5 第 59 条与插件侧说明)。
*/
mirrorSchedule: (id: string): Promise<{ requested: boolean; deviceName: string; nextHeartbeatHint: string }> =>
api.post(`${API_PATHS.integrations}/devices/${id}/mirror-schedule`, {}),
/** 把一条提醒下发到该班的 ClassIsland 设备(ClassIsland 上全屏弹出) */
notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> =>
api.post(`${API_PATHS.integrations}/classisland/notify`, payload),
@@ -0,0 +1,26 @@
import type { ClassPeriod } from '@classhelper/shared';
import { scheduleApi } from '@/api';
/**
* 「这几个班里有没有正在上课的」——全站共用的同一条判据。
*
* 为什么抽出来:上课时段发**紧急通知**要先弹全屏二次确认(`UrgentClassWarning`,3 秒倒计时),
* 而「发通知」这件事在本项目里有**两个入口** —— 通知发布页,以及大模型助手确认后走同一批端点。
* 判据各写一份的结果是:助手那条路少了一次检查,老师在上课时用助手发紧急通知就**没有任何警告**
* (2026-10-11 用户实测反馈),而手动那条路有。凡是"发布前要问一句"的入口都必须用它。
*
* 逐班查询 `GET /api/schedules/current?classId=...`,任一班级正在上课即返回命中的那个。
* 查询失败按"没在上课"处理(不能让一次网络抖动把发布整条路堵死),由服务端的
* 409 `URGENT_DURING_CLASS` 兜底再问一次。
*/
export async function findClassInSession(
classIds: string[],
): Promise<{ classId: string; period: ClassPeriod | null } | null> {
const results = await Promise.all(
classIds.map(async (classId) => {
const status = await scheduleApi.classStatus(classId).catch(() => null);
return status?.inClass ? { classId, period: status.current } : null;
}),
);
return results.find((item) => item !== null) ?? null;
}
+12 -10
View File
@@ -115,22 +115,24 @@ const menuItems: MenuItem[] = [
roles: ['ADMIN', 'TEACHER'],
permission: 'ai:use',
},
{ path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
{
path: '/permissions',
title: '权限组',
icon: 'Lock',
roles: ['ADMIN', 'TEACHER'],
permission: 'permission:manage',
},
{
path: '/clients',
title: '客户端设备',
title: '客户端',
icon: 'Monitor',
// 与「权限组」同样按**生效权限**门控:教师默认没有 client:manage(见 shared/permissions.ts
// 的默认模板只给 ADMIN),所以这里不能只写 roles —— 被权限组授予后教师也该看见。
//
// 2026-10-11 起这一项**合并了原来的「ClassIsland 联动」**:两页讲的是同一件事的两半
// (同一个教室里,插件管大屏、桌面端管灵动岛),分开只会让人"先想清楚该进哪一页",
// 批量下发更是无从下手。页内按 device:manage / client:update-push / remote:control
// 分别显示或禁用按钮,与后端三个接口的权限一一对应。
// 门控用 `classisland:notify` 而不是 `client:manage`:这一页同时承载「向教室下发提醒」,
// 而那个权限默认给 ADMIN + TEACHER(`client:manage` 只给 ADMIN)。用后者会把教师
// 挡在门外,等于把「联动页发提醒」这条老师日常要用的入口一并收走。
// 页内的设备管理 / 远控 / 下发更新仍各自要 device:manage / remote:control /
// client:update-push,与后端的三个接口一一对应。
roles: ['ADMIN', 'TEACHER'],
permission: 'client:manage',
permission: 'classisland:notify',
},
{
path: '/ai-settings',
+21 -7
View File
@@ -88,8 +88,10 @@ const routes: RouteRecordRaw[] = [
path: 'integrations',
name: 'integrations',
component: () => import('@/views/IntegrationsView.vue'),
// 与后端 requireRole('ADMIN','TEACHER') 对齐:这一页含"班级端在线状态",教师要看自己的班
meta: { title: 'ClassHelper 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
// **已从侧栏摘掉**(2026-10-11 合并进「客户端」):设备管理与课表下发都搬到那一页了,
// 这里只保留少数还没搬过去的东西(提醒下发、联动步骤说明),从「客户端 → 详情」进。
// 路由留着而不是删掉,是为了不把功能一起丢掉;`meta.hidden` 让侧栏不显示它。
meta: { title: 'ClassIsland 联动(旧)', icon: 'Connection', roles: ['ADMIN', 'TEACHER'], hidden: true },
},
{
path: 'ai-settings',
@@ -116,12 +118,24 @@ const routes: RouteRecordRaw[] = [
{
path: 'clients',
name: 'clients',
component: () => import('@/views/ClientHubView.vue'),
// 「客户端」= 原来的「ClassIsland 联动」+「客户端设备」合并后的一页(2026-10-11):
// 一个班一张卡片,两条链路的状态放在一起,勾选可批量下发课表 / 更新,点卡片进详情。
//
// 门控与侧栏菜单保持一致:`classisland:notify`(默认 ADMIN + TEACHER)——
// 这一页同时承载「向教室下发提醒」,教师要用;设备管理类按钮在页内按
// device:manage / client:update-push / remote:control 各自显示或禁用,
// 与后端三个接口的权限一一对应(前端隐藏不等于权限)。
meta: { title: '客户端', icon: 'Monitor', permission: 'classisland:notify' },
},
{
path: 'client-devices',
name: 'client-devices',
component: () => import('@/views/ClientsView.vue'),
// 这一页同样**按生效权限**门控:远控关机、统一下发更新都是高危操作,
// 后端三个接口分别要 client:manage / client:update-push / remote:control,
// 但教师默认三个都没有(见 shared/permissions.ts 的默认模板),所以这里用
// 最基础的 client:manage 当入口门禁,页面内的按钮再各自按权限显示/禁用。
meta: { title: '客户端设备', icon: 'Monitor', permission: 'client:manage' },
// **已从侧栏摘掉**(2026-10-11 合并进「客户端」):设备清单、远控、下发更新都在新页里,
// 这里只留新版还没搬过去的部分(客户端安装包缓存管理 —— 不先把包拉下来,就没有版本可下发)。
// 从「客户端 → 详情 → 安装包与设备记录」进。
meta: { title: '安装包与设备记录', icon: 'Monitor', permission: 'client:manage', hidden: true },
},
],
},
+65 -3
View File
@@ -10,6 +10,7 @@ import {
type AiInterpretResult,
type ApiErrorResponse,
type ClassDto,
type ClassPeriod,
type CourseDto,
type NotificationPriority,
type StudentDto,
@@ -17,6 +18,8 @@ import {
import { aiApi, callApi, classApi, courseApi, homeworkApi, notificationApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useResponsive } from '@/composables/useResponsive';
import { findClassInSession } from '@/composables/useClassInSession';
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
/**
* AI 助手:**一句话 → 待确认的草稿 → 老师确认后才落库**。
@@ -54,6 +57,20 @@ const result = ref<AiInterpretResult | null>(null);
const publishError = ref('');
const publishing = ref(false);
/**
* 上课时段发布紧急通知的全屏二次确认(3 秒倒计时)。
*
* **这里曾经缺失**:助手发布走的是既有端点,服务端的 409 `URGENT_DURING_CLASS` 兜底还在,
* 但「手动发布页」在提交前会先查一遍上课状态、命中就弹这个全屏警告,而助手这条路径直接提交 ——
* 于是老师在上课时用助手发紧急通知**一次警告都没有**(2026-10-11 用户实测反馈)。
* 判据与手动页共用 `composables/useClassInSession.ts`,两边必须同时命中才谈得上"一致"。
*/
const urgentWarning = reactive({
visible: false,
period: null as ClassPeriod | null,
title: '',
});
/* -------------------------------------------------------------- 表单 */
const classes = ref<ClassDto[]>([]);
@@ -247,18 +264,54 @@ async function confirmPublish(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const action = current.action;
// 上课时段的紧急通知:先查上课状态,命中就弹全屏二次确认(与手动发布页完全同一条判据)
if (action === 'create_notification' && form.priority === 'URGENT') {
publishing.value = true;
try {
const hit = await findClassInSession([form.classId]);
if (hit) {
urgentWarning.period = hit.period;
urgentWarning.title = form.title.trim();
urgentWarning.visible = true;
return;
}
} finally {
publishing.value = false;
}
}
await runPublish(action, false);
}
/** 真正提交并把结果落到结果卡片上(成功 / 失败两条路都收口在这里) */
async function runPublish(action: AiInterpretResult['action'], confirmDuringClass: boolean): Promise<void> {
publishing.value = true;
publishError.value = '';
try {
await publishOnce(current.action, false);
finishSuccess(current.action);
await publishOnce(action, confirmDuringClass);
finishSuccess(action);
} catch (error) {
await handlePublishError(error, current.action);
await handlePublishError(error, action);
} finally {
publishing.value = false;
}
}
/** 全屏警告里点了「仍然发布」 */
async function onUrgentConfirmed(): Promise<void> {
urgentWarning.visible = false;
const current = result.value;
if (!current || current.action === 'unknown') return;
await runPublish(current.action, true);
}
function onUrgentCancelled(): void {
urgentWarning.visible = false;
publishError.value = '已取消:正在上课,紧急通知需要二次确认';
}
/** 真正落库:复用既有的发布端点(权限断言 / 上课保护 / ClassIsland 推送都在服务端) */
async function publishOnce(action: AiInterpretResult['action'], confirmDuringClass: boolean): Promise<void> {
if (action === 'create_homework') {
@@ -555,6 +608,15 @@ function goSettings(): void {
</template>
</el-card>
</template>
<!-- 上课时段发布紧急通知的全屏二次确认(3 秒倒计时)——与手动发布页同一个组件 -->
<UrgentClassWarning
:visible="urgentWarning.visible"
:period="urgentWarning.period"
:pending-title="urgentWarning.title"
@cancel="onUrgentCancelled"
@confirm="onUrgentConfirmed"
/>
</div>
</template>
@@ -0,0 +1,731 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
CLIENT_COMMAND_KINDS,
CLIENT_COMMAND_LABELS,
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
SOCKET_EVENTS,
relativeTime,
type ClassDto,
type ClassHelperStatusDto,
type ClientCommandKind,
type ClientDeviceDto,
type IntegrationDeviceDto,
type NotificationPriority,
} from '@classhelper/shared';
import { classApi, clientApi, clientReleaseApi, integrationApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
/**
* 「客户端」——**把原来的「ClassHelper 联动」与「客户端设备」两页合成一页**。
*
* 为什么合并:这两页讲的是同一件事的两半 —— 同一个教室里,ClassIsland 插件负责大屏、
* ClassHelper 桌面端负责灵动岛,老师要做的动作(下发课表、推送更新、看看这台机器还在不在线)
* 也都跨两者。分成两页的结果是"要给一个班做一件事,得先想清楚该进哪一页",
* 批量操作更是无从下手(一页只有插件设备、一页只有桌面端)。
*
* 结构:
* 1. **班级卡片**(一屏看全):每张卡把该班两条链路的状态放在一起,可勾选;
* 2. **批量动作**:勾几台就下发几台(课表 / 客户端更新);
* 3. **点卡片进详情**:该班的设备清单与逐台操作(签发令牌、重置、远控……)。
*
* 权限:入口要 `client:manage`(与旧的两页最基础的那个门禁一致),页内按钮再各自按
* `device:manage` / `client:update-push` / `remote:control` 显示或禁用 —— 后端同样是这么拦的。
*/
const auth = useAuthStore();
const router = useRouter();
const realtime = useRealtimeStore();
const canManageDevices = computed(() => auth.can('device:manage'));
const canNotify = computed(() => auth.can('classisland:notify'));
const canPushUpdate = computed(() => auth.can('client:update-push'));
const canRemote = computed(() => auth.can('remote:control'));
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const integrations = ref<IntegrationDeviceDto[]>([]);
const clients = ref<ClientDeviceDto[]>([]);
const statuses = ref<ClassHelperStatusDto[]>([]);
/** 服务端已缓存的客户端安装包版本(批量下发更新时选目标版本) */
const releaseVersions = ref<string[]>([]);
const targetVersion = ref('');
/** 一张卡 = 一个班 */
interface ClassCard {
classId: string;
className: string;
grade: string;
/** 该班的 ClassIsland 插件设备(可能多台:讲台机 + 备用机) */
devices: IntegrationDeviceDto[];
/** 该班的 ClassHelper 桌面端 */
clients: ClientDeviceDto[];
/** 班级端在线状态(服务端 `classisland/status`) */
status: ClassHelperStatusDto | null;
}
const cards = computed<ClassCard[]>(() =>
classes.value.map((item) => {
const status = statuses.value.find((entry) => entry.classId === item.id) ?? null;
return {
classId: item.id,
className: item.name,
grade: item.grade,
devices: integrations.value.filter((entry) => entry.classId === item.id),
clients: clients.value.filter((entry) => entry.classId === item.id),
status,
};
}),
);
/** 勾选的班级(批量动作按班级走:一台机器都没有的班会跳过并说明原因) */
const selected = ref<string[]>([]);
const allSelected = computed({
get: () => cards.value.length > 0 && selected.value.length === cards.value.length,
set: (value: boolean) => {
selected.value = value ? cards.value.map((item) => item.classId) : [];
},
});
async function loadAll(): Promise<void> {
loading.value = true;
try {
const [classList, deviceList, clientList, statusList, releases] = await Promise.all([
classApi.list(),
integrationApi.listDevices().catch(() => []),
clientApi.devices().catch(() => []),
integrationApi.classHelperStatus().catch(() => null),
clientReleaseApi.list().catch(() => []),
]);
classes.value = classList;
integrations.value = deviceList;
clients.value = clientList;
statuses.value = statusList?.classes ?? [];
releaseVersions.value = releases
.map((item) => item.version)
.filter((version, index, all) => version && all.indexOf(version) === index);
if (!targetVersion.value && releaseVersions.value.length > 0) {
targetVersion.value = releaseVersions.value[0] ?? '';
}
} finally {
loading.value = false;
}
}
/* ------------------------------------------------------------ 批量动作 */
const batchBusy = ref(false);
/** 批量下发课表:给每个勾选班级的**每一台** ClassIsland 设备置一次待办 */
async function batchMirrorSchedule(): Promise<void> {
const picked = cards.value.filter((item) => selected.value.includes(item.classId));
const devices = picked.flatMap((item) => item.devices.filter((device) => device.enabled));
if (devices.length === 0) {
ElMessage.warning('勾选的班级里没有可下发的 ClassIsland 设备(未接入或已停用)');
return;
}
await ElMessageBox.confirm(
`向 ${devices.length} 台设备下发课表?教室的课表会被 ClassHelper 的课表**强制替换**:` +
'同一天同一单双周相位下冲突的旧课表会被**停用**(只停用、不删除,可在 ClassIsland 里重新启用)。',
'下发课表到教室',
{ type: 'warning', confirmButtonText: '下发', cancelButtonText: '取消' },
).catch(() => 'cancel');
batchBusy.value = true;
try {
const results = await Promise.allSettled(
devices.map((device) => integrationApi.mirrorSchedule(device.id)),
);
const ok = results.filter((item) => item.status === 'fulfilled').length;
ElMessage.success(`已向 ${ok}/${devices.length} 台设备下发,教室会在下一次心跳(约 1 分钟内)拉取`);
await loadAll();
} finally {
batchBusy.value = false;
}
}
/** 批量下发客户端更新:按班级下发,服务端会给这些班的每台桌面端置目标版本 */
async function batchPushUpdate(): Promise<void> {
if (selected.value.length === 0) {
ElMessage.warning('请先勾选班级');
return;
}
const hint = targetVersion.value ? `目标版本 v${targetVersion.value}` : '服务端缓存的最新版本';
await ElMessageBox.confirm(
`向 ${selected.value.length} 个班的 ClassHelper 客户端下发更新(${hint})?\n` +
'客户端会在**不上课且主窗口隐藏满 5 分钟**后才安装,下载随时可能发生。',
'下发客户端更新',
{ type: 'warning', confirmButtonText: '下发', cancelButtonText: '取消' },
).catch(() => 'cancel');
batchBusy.value = true;
try {
const result = await clientApi.pushUpdate({
classIds: selected.value,
version: targetVersion.value || null,
});
ElMessage.success(`已下发:成功 ${result.succeeded} / 共 ${result.total} 台`);
await loadAll();
} finally {
batchBusy.value = false;
}
}
/* ------------------------------------------------------------ 详情抽屉 */
const detailVisible = ref(false);
const detailClassId = ref('');
const detail = computed(() => cards.value.find((item) => item.classId === detailClassId.value) ?? null);
function openDetail(classId: string): void {
detailClassId.value = classId;
detailVisible.value = true;
}
const createForm = reactive({ name: '' });
const creating = ref(false);
/** 为当前详情班级签发一台新的 ClassIsland 设备(令牌只在返回值里出现一次) */
async function createDevice(): Promise<void> {
const current = detail.value;
if (!current) return;
creating.value = true;
try {
const created = await integrationApi.createDevice({
classId: current.classId,
name: createForm.name.trim() || undefined,
});
await ElMessageBox.alert(
`设备令牌(只显示这一次,请立刻填进教室的 ClassIsland 插件设置里):\n\n${created.token ?? ''}`,
'设备令牌',
{ confirmButtonText: '我已保存' },
).catch(() => undefined);
createForm.name = '';
await loadAll();
} finally {
creating.value = false;
}
}
async function toggleDevice(device: IntegrationDeviceDto): Promise<void> {
await integrationApi.updateDevice(device.id, { enabled: !device.enabled });
ElMessage.success(device.enabled ? '已停用该设备' : '已启用该设备');
await loadAll();
}
async function allowMirror(device: IntegrationDeviceDto): Promise<void> {
await integrationApi.updateDevice(device.id, {
mirrorScheduleToClassIsland: !device.mirrorScheduleToClassIsland,
});
ElMessage.success(device.mirrorScheduleToClassIsland ? '已禁止向该设备下发课表' : '已允许向该设备下发课表');
await loadAll();
}
async function mirrorOne(device: IntegrationDeviceDto): Promise<void> {
await ElMessageBox.confirm(
`把 ClassHelper 的课表下发到「${device.name}」?教室里同一天同一单双周相位下冲突的旧课表会被停用。`,
'下发课表',
{ type: 'warning', confirmButtonText: '下发', cancelButtonText: '取消' },
).catch(() => 'cancel');
const result = await integrationApi.mirrorSchedule(device.id);
ElMessage.success(result.nextHeartbeatHint);
await loadAll();
}
async function pullOne(device: IntegrationDeviceDto): Promise<void> {
await ElMessageBox.confirm(
`从「${device.name}」获取课表?教室机器的课表会**合并进本班课表**,可能覆盖老师手排的课。`,
'从教室机器获取课表',
{ type: 'warning', confirmButtonText: '获取', cancelButtonText: '取消' },
).catch(() => 'cancel');
const result = await integrationApi.requestSchedule(device.id);
ElMessage.success(result.nextHeartbeatHint);
await loadAll();
}
async function resetToken(device: IntegrationDeviceDto): Promise<void> {
await ElMessageBox.confirm(
`重置「${device.name}」的令牌?旧令牌会立刻失效,教室的插件需要重新填写。`,
'重置令牌',
{ type: 'warning', confirmButtonText: '重置', cancelButtonText: '取消' },
).catch(() => 'cancel');
const result = await integrationApi.resetToken(device.id);
await ElMessageBox.alert(`新令牌(只显示这一次):\n\n${result.token ?? ''}`, '设备令牌', {
confirmButtonText: '我已保存',
}).catch(() => undefined);
await loadAll();
}
async function removeDevice(device: IntegrationDeviceDto): Promise<void> {
await ElMessageBox.confirm(
`删除「${device.name}」?删除后它无法再上报课表或接收提醒(历史提醒记录保留)。`,
'删除设备',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
).catch(() => 'cancel');
await integrationApi.removeDevice(device.id);
ElMessage.success('设备已删除');
await loadAll();
}
async function pushUpdateToClass(): Promise<void> {
const current = detail.value;
if (!current) return;
const result = await clientApi.pushUpdate({
classIds: [current.classId],
version: targetVersion.value || null,
});
ElMessage.success(`已下发:成功 ${result.succeeded} / 共 ${result.total} 台`);
await loadAll();
}
async function sendCommand(kind: ClientCommandKind): Promise<void> {
const current = detail.value;
if (!current || current.clients.length === 0) return;
const label = CLIENT_COMMAND_LABELS[kind];
await ElMessageBox.confirm(
`向「${current.className}」的 ${current.clients.length} 台机器下发「${label}」?\n` +
'机器上会弹出**默认选中「取消」**的对话框,60 秒未确认按取消处理。',
`下发${label}`,
{ type: 'warning', confirmButtonText: `确认${label}`, cancelButtonText: '取消' },
).catch(() => 'cancel');
const result = await clientApi.commands({
deviceIds: current.clients.map((item) => item.id),
kind,
note: `由 ${auth.user?.name ?? '管理员'} 下达`,
});
ElMessage.success(`已下发:成功 ${result.succeeded} / 共 ${result.total} 台`);
await loadAll();
}
async function removeClient(device: ClientDeviceDto): Promise<void> {
await ElMessageBox.confirm(
`移除「${device.name}」的设备记录?只删记录,不影响那台机器本身(它下次心跳会重新登记)。`,
'移除设备记录',
{ type: 'warning', confirmButtonText: '移除', cancelButtonText: '取消' },
).catch(() => 'cancel');
await clientApi.removeDevice(device.id);
ElMessage.success('已移除');
await loadAll();
}
/* ------------------------------------------------------------ 下发提醒(教室大屏全屏弹出)
*
* 这一块是从「ClassIsland 联动」页搬过来的 —— 两页合并时不能只搬设备管理:
* 「向教室下发提醒」是老师日常要用的入口(权限 `classisland:notify` 默认给教师),
* 只留设备清单的话,教师会平白少一个功能。
*/
const notifyVisible = ref(false);
const notifySending = ref(false);
const notifyForm = reactive({
classId: '',
title: '',
content: '',
durationSeconds: 8,
speech: false,
priority: 'NORMAL' as NotificationPriority,
});
function openNotify(classId: string): void {
notifyForm.classId = classId;
notifyForm.title = '';
notifyForm.content = '';
notifyForm.durationSeconds = 8;
notifyForm.speech = false;
notifyForm.priority = 'NORMAL';
notifyVisible.value = true;
}
async function submitNotify(): Promise<void> {
if (!notifyForm.title.trim()) {
ElMessage.warning('请填写标题');
return;
}
notifySending.value = true;
try {
const result = await integrationApi.notify({
classId: notifyForm.classId,
title: notifyForm.title.trim(),
content: notifyForm.content,
durationSeconds: notifyForm.durationSeconds,
speech: notifyForm.speech,
priority: notifyForm.priority,
});
if (result.skipped === 'channel-client') {
ElMessage.warning('该班教室选择了「只在 ClassHelper 客户端显示」,本次没有推到 ClassIsland');
} else {
ElMessage.success(`已下发到 ${result.delivered}/${result.targetCount} 台教室设备`);
}
notifyVisible.value = false;
await loadAll();
} finally {
notifySending.value = false;
}
}
/* ------------------------------------------------------------ 生命周期 */
function refresh(): void {
void loadAll();
}
onMounted(() => {
void loadAll();
// 设备在线状态、客户端版本都是"心跳推进"的,跟着实时事件刷新即可
realtime.on(SOCKET_EVENTS.classislandState, refresh);
realtime.on(SOCKET_EVENTS.classUpdated, refresh);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.classislandState, refresh);
realtime.off(SOCKET_EVENTS.classUpdated, refresh);
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">客户端</h2>
<p class="page-subtitle">
一个班的两条链路:ClassIsland 插件(教室大屏)与 ClassHelper 客户端(灵动岛)。
勾选班级可批量下发课表 / 更新,点卡片进详情做逐台操作
</p>
</div>
<div class="toolbar">
<el-select
v-if="canPushUpdate"
v-model="targetVersion"
placeholder="最新版本"
clearable
style="width: 150px"
>
<el-option v-for="item in releaseVersions" :key="item" :label="`v${item}`" :value="item" />
</el-select>
<el-button
v-if="canManageDevices"
type="warning"
:icon="'Upload'"
:loading="batchBusy"
:disabled="selected.length === 0"
@click="batchMirrorSchedule"
>
批量下发课表
</el-button>
<el-button
v-if="canPushUpdate"
type="primary"
:icon="'Refresh'"
:loading="batchBusy"
:disabled="selected.length === 0"
@click="batchPushUpdate"
>
批量下发更新
</el-button>
<el-button :icon="'Refresh'" :loading="loading" @click="loadAll">刷新</el-button>
</div>
</div>
<el-alert type="info" :closable="false" show-icon style="margin-bottom: 12px">
<template #title>课表的两个方向都是「按需」的</template>
<template #default>
<b>下发课表</b>:把 ClassHelper 排好的课表写进教室,并停用同一天同一单双周相位下冲突的旧课表(只停用、不删除)。
<b>获取课表</b>:把教室机器里启用的课表合并进本班课表。两个动作都只在你点按钮时发生 ——
教室与这里不会互相自动覆盖。
</template>
</el-alert>
<div class="select-bar">
<el-checkbox v-model="allSelected" :indeterminate="selected.length > 0 && !allSelected">
全选({{ selected.length }}/{{ cards.length }})
</el-checkbox>
</div>
<div v-loading="loading" class="card-grid">
<div v-for="card in cards" :key="card.classId" class="class-card">
<div class="card-head">
<el-checkbox v-model="selected" :value="card.classId" />
<span class="card-title">{{ card.className }}</span>
<el-tag size="small" effect="plain">{{ card.grade }}</el-tag>
<el-tag
size="small"
:type="card.status?.online || card.clients.some((item) => item.online) ? 'success' : 'info'"
effect="plain"
>
{{ card.status?.online || card.clients.some((item) => item.online) ? '在线' : '离线' }}
</el-tag>
</div>
<div class="card-row">
<span class="row-label">ClassIsland</span>
<template v-if="card.devices.length > 0">
<span class="row-value">
{{ card.devices.length }} 台 ·
{{ card.devices.some((item) => item.online) ? '在线' : '离线' }} ·
{{ card.devices[0]?.currentSubject ?? '当前无课' }}
</span>
</template>
<span v-else class="row-value muted">未接入</span>
</div>
<div class="card-row">
<span class="row-label">客户端</span>
<template v-if="card.clients.length > 0">
<span class="row-value">
{{ card.clients.length }} 台 ·
{{ card.clients.some((item) => item.online) ? '在线' : '离线' }} ·
v{{ card.clients[0]?.clientVersion ?? '?' }}
</span>
</template>
<span v-else class="row-value muted">还没有机器上报</span>
</div>
<div class="card-foot">
<el-button link type="primary" @click="openDetail(card.classId)">详情</el-button>
</div>
</div>
<el-empty v-if="!loading && cards.length === 0" description="还没有班级" />
</div>
<!-- 详情:该班的两条链路与逐台操作 -->
<el-drawer v-model="detailVisible" :title="detail ? `${detail.className} · 详情` : '详情'" size="60%">
<template v-if="detail">
<div class="detail-section">
<div class="section-head">
<span>ClassIsland 教室端({{ detail.devices.length }} 台)</span>
<div v-if="canManageDevices || canNotify" class="section-actions">
<el-button v-if="canNotify" size="small" type="primary" @click="openNotify(detail.classId)">
下发提醒
</el-button>
<el-input v-model="createForm.name" placeholder="设备名(默认取机器名)" size="small" style="width: 180px" />
<el-button size="small" type="primary" :loading="creating" @click="createDevice">
新增设备
</el-button>
</div>
</div>
<el-table :data="detail.devices" size="small" empty-text="该班还没有 ClassIsland 设备">
<el-table-column prop="name" label="设备" min-width="140" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.online ? 'success' : 'info'" effect="plain">
{{ row.online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" width="130">
<template #default="{ row }">CI {{ row.classIslandVersion ?? '-' }} / 插件 {{ row.pluginVersion ?? '-' }}</template>
</el-table-column>
<el-table-column label="最后心跳" width="120">
<template #default="{ row }">{{ row.lastHeartbeatAt ? relativeTime(row.lastHeartbeatAt) : '从未' }}</template>
</el-table-column>
<el-table-column label="允许下发" width="90">
<template #default="{ row }">
<el-switch
:model-value="row.mirrorScheduleToClassIsland"
:disabled="!canManageDevices"
size="small"
@update:model-value="() => allowMirror(row)"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button link type="primary" :disabled="!canManageDevices" @click="mirrorOne(row)">下发课表</el-button>
<el-button link type="warning" :disabled="!canManageDevices" @click="pullOne(row)">获取课表</el-button>
<el-button link :disabled="!canManageDevices" @click="toggleDevice(row)">
{{ row.enabled ? '停用' : '启用' }}
</el-button>
<el-button link :disabled="!canManageDevices" @click="resetToken(row)">重置令牌</el-button>
<el-button link type="danger" :disabled="!canManageDevices" @click="removeDevice(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</div>
<div class="detail-section">
<div class="section-head">
<span>ClassHelper 客户端({{ detail.clients.length }} 台)</span>
<div v-if="canPushUpdate" class="section-actions">
<el-button size="small" type="primary" :loading="batchBusy" @click="pushUpdateToClass">
给本班下发更新
</el-button>
</div>
</div>
<el-table :data="detail.clients" size="small" empty-text="该班还没有客户端上报过">
<el-table-column prop="name" label="设备" min-width="140" />
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tag size="small" :type="row.online ? 'success' : 'info'" effect="plain">
{{ row.online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" width="110">
<template #default="{ row }">
v{{ row.clientVersion }}
<el-tag v-if="row.updateTargetVersion" size="small" type="warning" effect="plain">
→ v{{ row.updateTargetVersion }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最后心跳" width="120">
<template #default="{ row }">
{{ row.lastHeartbeatAt ? relativeTime(row.lastHeartbeatAt) : '从未' }}
</template>
</el-table-column>
<el-table-column prop="stateNote" label="状态说明" min-width="140" />
<el-table-column label="操作" width="90" fixed="right">
<template #default="{ row }">
<el-button link type="danger" :disabled="!canManageDevices" @click="removeClient(row)">
移除
</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="canRemote" class="remote-bar">
<span class="row-label">远程控制</span>
<el-button
v-for="kind in CLIENT_COMMAND_KINDS"
:key="kind"
size="small"
:disabled="detail.clients.length === 0"
@click="sendCommand(kind)"
>
{{ CLIENT_COMMAND_LABELS[kind] }}
</el-button>
</div>
</div>
<!--
两页旧版入口:合并过来的是**常用动作**,还有两处没搬 ——
「提醒下发 / 联动步骤说明」与「客户端安装包缓存管理」(不先把包拉下来就没有版本可下发)。
路由还留着(侧栏已隐藏),别的地方找不到它们,就在这里给一条路。
-->
<div class="detail-section legacy-links">
<el-button link type="primary" @click="router.push('/integrations')">
提醒下发与联动步骤 →
</el-button>
<el-button link type="primary" @click="router.push('/client-devices')">
安装包与设备记录 →
</el-button>
</div>
</template>
</el-drawer>
<!-- 下发提醒到教室大屏(ClassIsland 全屏弹出)——从「ClassIsland 联动」页搬过来 -->
<el-dialog v-model="notifyVisible" title="下发提醒到 ClassIsland" width="520px">
<el-form label-width="96px">
<el-form-item label="目标班级">
<el-input :model-value="cards.find((item) => item.classId === notifyForm.classId)?.className" disabled />
</el-form-item>
<el-form-item label="标题">
<el-input v-model="notifyForm.title" maxlength="60" show-word-limit placeholder="例如:放学前交作业" />
</el-form-item>
<el-form-item label="内容">
<el-input v-model="notifyForm.content" type="textarea" :rows="3" maxlength="500" show-word-limit />
</el-form-item>
<el-form-item label="显示时长">
<el-input-number v-model="notifyForm.durationSeconds" :min="1" :max="120" /> 秒
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="notifyForm.priority" style="width: 160px">
<el-option
v-for="item in NOTIFICATION_PRIORITIES"
:key="item"
:label="PRIORITY_LABELS[item]"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item label="语音朗读">
<el-switch v-model="notifyForm.speech" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="notifyVisible = false">取消</el-button>
<el-button type="primary" :loading="notifySending" @click="submitNotify">立即下发</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.select-bar {
margin-bottom: 8px;
}
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 12px;
min-height: 120px;
}
.class-card {
border: 1px solid var(--el-border-color-lighter);
border-radius: var(--ch-radius-lg);
padding: 12px 14px;
background: var(--ch-surface);
}
.card-head {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 8px;
}
.card-title {
font-weight: 600;
}
.card-row {
display: flex;
gap: 8px;
font-size: var(--el-font-size-small);
line-height: 1.8;
}
.row-label {
flex: 0 0 76px;
color: var(--el-text-color-secondary);
}
.row-value.muted {
color: var(--el-text-color-placeholder);
}
.card-foot {
margin-top: 8px;
text-align: right;
}
.detail-section + .detail-section {
margin-top: 20px;
}
.section-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
font-weight: 600;
}
.section-actions {
display: flex;
gap: 8px;
}
.remote-bar {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
}
</style>
@@ -64,6 +64,48 @@ const averagePercent = computed(() => {
const examOptions = computed(() => [...new Set(grades.value.map((item) => item.examName))]);
/* ------------------------------------------------------------ 考试口径(计入总分)
* 需求:「单次考试(如月考、期中考),在导入成绩时可选是否将所有科目相加以总分排名,
* 客户端也可查看到总分排名和个别单科的排名」。
*
* 语数外 150 分、音体美 100 分,量纲不一样,"这次考试要不要合总分"是学校口径 ——
* 所以按「班级 + 考试名」存一条(服务端 `Exam` 表),客户端据它决定显不显示总分榜。
* 开关就摆在导入按钮旁边:老师导完一次考试,顺手就能把口径定下来。
*/
const examCountInTotal = ref(false);
const examSaving = ref(false);
async function loadExamFlag(): Promise<void> {
if (!filter.classId || !filter.examName) {
examCountInTotal.value = false;
return;
}
const list = await gradeApi.listExams(filter.classId).catch(() => []);
examCountInTotal.value = list.find((item) => item.name === filter.examName)?.countInTotal === true;
}
async function setExamCountInTotal(value: boolean): Promise<void> {
if (!filter.classId || !filter.examName) return;
examSaving.value = true;
try {
const saved = await gradeApi.setExam({
classId: filter.classId,
name: filter.examName,
countInTotal: value,
});
examCountInTotal.value = saved.countInTotal;
} catch {
// 失败时回读真实值,避免开关停在一个假状态上(拦截器已经弹过原因)
await loadExamFlag();
} finally {
examSaving.value = false;
}
}
// 换了班级或考试就重新读一次口径(加载成绩的 watch 只管列表)
watch(() => [filter.classId, filter.examName], () => void loadExamFlag(), { immediate: true });
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
@@ -448,6 +490,26 @@ onUnmounted(() => {
班主任若不教这一科,同样改不了这一科(不指定科目的内容归班主任与管理员)。
</el-alert>
<!-- 考试口径:这次考试要不要把所有科目相加做总分排名(按「班级 + 考试名」记,
客户端据此显示总分榜)。导入成绩时顺手就能定下来。 -->
<el-card v-if="canManageGrades && filter.classId && filter.examName" shadow="never" class="mt-12">
<div class="toolbar">
<span class="exam-label">考试口径</span>
<el-tag effect="plain">{{ filter.examName }}</el-tag>
<el-switch
:model-value="examCountInTotal"
:loading="examSaving"
data-test="exam-count-in-total"
active-text="所有科目相加,排总分榜"
inactive-text="只排单科,不合总分"
@change="(value: string | number | boolean) => setExamCountInTotal(value === true)"
/>
<span class="exam-hint">
语数外 150 分、音体美 100 分不是一个量纲,合不合总分由这里决定;客户端会按这个设置显示总分榜
</span>
</div>
</el-card>
<el-row :gutter="12">
<el-col :xs="24" :md="6">
<div class="stat-card">
@@ -773,4 +835,13 @@ onUnmounted(() => {
padding-left: 90px;
margin-top: -6px;
}
.exam-label {
font-weight: 600;
}
.exam-hint {
color: var(--el-text-color-secondary);
font-size: var(--el-font-size-extra-small);
}
</style>
@@ -19,8 +19,9 @@ import {
type NotificationPriority,
type StudentDto,
} from '@classhelper/shared';
import { callApi, classApi, notificationApi, scheduleApi } from '@/api';
import { callApi, classApi, notificationApi } from '@/api';
import { useRealtimeStore } from '@/stores/realtime';
import { findClassInSession } from '@/composables/useClassInSession';
import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
const realtime = useRealtimeStore();
@@ -227,20 +228,9 @@ async function openCreate(): Promise<void> {
}
/**
* 上课时段检查:多班发布时逐班查询(GET /api/schedules/current?classId=...),
* 任一班级正在上课就需要二次确认;返回命中的班级与时段。
* 上课时段检查见 `composables/useClassInSession.ts`(大模型助手那条发布路径也用它,
* 判据只留一份,别在这里再写一遍)。
*/
async function findClassInSession(
classIds: string[],
): Promise<{ classId: string; period: ClassPeriod | null } | null> {
const results = await Promise.all(
classIds.map(async (classId) => {
const status = await scheduleApi.classStatus(classId).catch(() => null);
return status?.inClass ? { classId, period: status.current } : null;
}),
);
return results.find((item) => item !== null) ?? null;
}
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);