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/web-admin",
|
||||
"version": "1.3.0",
|
||||
"version": "1.3.1",
|
||||
"private": true,
|
||||
"description": "ClassHelper Web 管理端(Vue 3 + Vite + Element Plus)",
|
||||
"type": "module",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user