chore(release): 1.3.3 发布并退役旧联动

This commit is contained in:
LoveChengke committed 2026-10-17 03:06:46 +08:00
1 parent b7cdc7c9ea
commit c0ebf88138
184 files changed
+5450 -11705

No files matched your search

+8 -77
View File
@@ -11,7 +11,6 @@ import {
type ClassDetailDto,
type ClassDto,
type AutoTermWeeksRequest,
type ClassHelperStatusListDto,
type ClassPlanImportResultDto,
type ClassPlanPreviewDto,
type ClassStatusDto,
@@ -31,7 +30,6 @@ import {
type BulkPublishResult,
type NotificationCreatorsDto,
type NotificationDaysDto,
type CreateIntegrationDeviceRequest,
type CreateNotificationRequest,
type CreatePermissionGroupRequest,
type CreateScheduleRequest,
@@ -51,8 +49,6 @@ import {
type HomeworkDaysDto,
type HomeworkDto,
type HomeworkSubmissionsDto,
type IntegrationDeviceDto,
type IntegrationDeviceTokenDto,
type LoginRequest,
type LoginResponse,
type MyPermissionsDto,
@@ -64,8 +60,6 @@ import {
type PushClientUpdateRequest,
type ScheduleDto,
type ScheduleWeekView,
type SendClassIslandNotificationRequest,
type SendClassIslandNotificationResult,
type SessionUser,
type StudentDto,
type StudentGradeDetailDto,
@@ -84,7 +78,6 @@ import {
type UpdateClassAccountRequest,
type UpdateClassRequest,
type UpdateInfo,
type UpdateIntegrationDeviceRequest,
type UpdatePermissionGroupRequest,
type UpdateStudentRequest,
type UpdateTermWeeksRequest,
@@ -131,14 +124,10 @@ export const classApi = {
assignSubjectTeachers: (
id: string,
assignments: Array<{ courseId?: string | null; subjectName: string; teacherId?: string | null }>,
): Promise<SubjectTeacherDto[]> =>
api.put(`${API_PATHS.classes}/${id}/subject-teachers`, { assignments }),
): Promise<SubjectTeacherDto[]> => api.put(`${API_PATHS.classes}/${id}/subject-teachers`, { assignments }),
/** 设置 / 重置班级账号(班级码 + 班级密码):仅管理员 */
updateAccount: (id: string, payload: UpdateClassAccountRequest): Promise<ClassDto> =>
api.patch(`${API_PATHS.classes}/${id}/class-account`, payload),
/** 班级端按学号查成绩的开关 */
getGradeQuery: (id: string): Promise<{ studentGradeQueryEnabled: boolean }> =>
api.get(`${API_PATHS.classes}/${id}/student-grade-query`),
setGradeQuery: (id: string, enabled: boolean): Promise<{ studentGradeQueryEnabled: boolean }> =>
api.patch(`${API_PATHS.classes}/${id}/student-grade-query`, { enabled }),
/** 学年升级:只改年级,不归档、数据沿用 */
@@ -152,8 +141,6 @@ export const courseApi = {
list: (classId?: string): Promise<CourseDto[]> =>
api.get(API_PATHS.courses, classId ? { classId } : undefined),
create: (payload: CreateCourseRequest): Promise<CourseDto> => api.post(API_PATHS.courses, payload),
update: (id: string, payload: { name: string }): Promise<CourseDto> =>
api.patch(`${API_PATHS.courses}/${id}`, payload),
remove: (id: string): Promise<{ id: string }> => api.delete(`${API_PATHS.courses}/${id}`),
};
@@ -299,7 +286,10 @@ export const gradeApi = {
): Promise<{ updated: number }> => api.patch(`${API_PATHS.grades}/levels`, payload),
/** 按**学号**查某个学生的成绩明细(教师端"按学号查成绩") */
studentDetail: (studentNo: string, classId?: string): Promise<StudentGradeDetailDto> =>
api.get(`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`, classId ? { classId } : undefined),
api.get(
`${API_PATHS.grades}/student/${encodeURIComponent(studentNo)}`,
classId ? { classId } : undefined,
),
/** 某班出现过的考试(含"是否计入总分") */
listExams: (classId: string): Promise<ExamDto[]> => api.get(`${API_PATHS.grades}/exams`, { classId }),
/** 设置"这次考试是否把所有科目相加做总分排名" */
@@ -335,13 +325,6 @@ export const studentApi = {
/** 某个学生的调班 / 转出历史 */
transfersOf: (id: string): Promise<PaginatedResult<StudentTransferDto>> =>
api.get(`${API_PATHS.students}/${id}/transfers`),
/** 调班历史(可按学生或班级过滤) */
transfers: (params?: {
studentId?: string;
classId?: string;
page?: number;
pageSize?: number;
}): Promise<PaginatedResult<StudentTransferDto>> => api.get(`${API_PATHS.students}/transfers`, params),
};
/* ------------------------------------------------------------------ 仪表盘 */
@@ -495,54 +478,6 @@ export const importApi = {
}): Promise<ClassPlanImportResultDto> => api.post(`${API_PATHS.imports}/class-plan`, payload),
};
/* ------------------------------------------------------------------ ClassIsland 联动 */
/**
* 联动设备管理 + 提醒下发。
*
* 设备令牌(chci_...)只在**创建**与**重置**两个接口的返回值里出现一次,
* 其余接口永远只回 tokenHint(前缀提示),因此拿到 token 后要立刻提示老师复制保存。
*/
export const integrationApi = {
listDevices: (classId?: string): Promise<IntegrationDeviceDto[]> =>
api.get(`${API_PATHS.integrations}/devices`, classId ? { classId } : undefined),
createDevice: (payload: CreateIntegrationDeviceRequest): Promise<IntegrationDeviceTokenDto> =>
api.post(`${API_PATHS.integrations}/devices`, payload),
updateDevice: (id: string, payload: UpdateIntegrationDeviceRequest): Promise<IntegrationDeviceDto> =>
api.patch(`${API_PATHS.integrations}/devices/${id}`, payload),
/** 重置令牌:旧令牌立即失效,插件需要重新填写 */
resetToken: (id: string): Promise<IntegrationDeviceTokenDto> =>
api.post(`${API_PATHS.integrations}/devices/${id}/token`, {}),
removeDevice: (id: string): Promise<{ id: string }> =>
api.delete(`${API_PATHS.integrations}/devices/${id}`),
/**
* 「**从教室机器获取一次课表**」:置一个待办,插件在下一次心跳把当前课表推上来。
*
* 自动回传(`syncScheduleToServer`)现在默认关闭 —— 它会在老师手排课之后
* 被教室的旧课表悄悄覆盖。要取教室的课表就点这个按钮(前端会先弹警告)。
*/
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),
/**
* **ClassHelper 班级端在线状态**:管理员看全部班级,教师看自己班主任/任课的班级。
* 每条含班级、设备名/设备号、在线离线、最后在线时间、最后心跳时间。
*/
classHelperStatus: (): Promise<ClassHelperStatusListDto> =>
api.get(`${API_PATHS.integrations}/classisland/status`),
};
/* ------------------------------------------------------------------ 数据库管理(仅管理员) */
/**
@@ -629,7 +564,7 @@ export const permissionApi = {
* 「客户端设备」页的接口。
*
* 权限(与后端 `clients.module.ts` 逐个对齐):
* - `list` / `remove`:`client:manage`
* - `list`:查看、设备管理、远控、更新任一权限;`remove`:`client:manage`
* - `commands`:`remote:control`(关机 / 重启 / 注销 / 锁屏,机器上还会再弹一次确认框)
* - `pushUpdate`:`client:update-push`
*/
@@ -660,7 +595,7 @@ const CLIENT_RELEASE_FETCH_TIMEOUT_MS = 10 * 60 * 1000;
* 教室机器常常没有外网,所以安装包由**服务端替教室下载**再缓存:
* Web 端不直连 GitHub(CSP 的 connect-src 只放行 self 与 WebSocket,见 AGENTS.md §5 第 52 条)。
*
* 权限:`list` 要 `client:manage`,`fetch` / `register` / `remove` 要 `client:update-push`。
* 权限:`list` 要查看、设备管理或更新任一权限;`fetch` / `register` / `remove` 要 `client:update-push`。
*/
export const clientReleaseApi = {
/** 服务端已缓存的安装包列表 */
@@ -673,11 +608,7 @@ export const clientReleaseApi = {
* 就报"请求超时",用户会以为失败而重复点。这里与服务端取同一个上限。
*/
fetch: (version: string): Promise<ClientReleaseDto> =>
api.post(
`${API_PATHS.update}/client/fetch`,
{ version },
{ timeout: CLIENT_RELEASE_FETCH_TIMEOUT_MS },
),
api.post(`${API_PATHS.update}/client/fetch`, { version }, { timeout: CLIENT_RELEASE_FETCH_TIMEOUT_MS }),
/** 登记一份已经手工放进 `data/client-releases/<版本>/` 的安装包 */
register: (payload: { version: string; fileName?: string; notes?: string }): Promise<ClientReleaseDto> =>
api.post(`${API_PATHS.update}/client/releases`, payload),
@@ -70,7 +70,8 @@ const FIELDS: Record<Kind, FieldDef[]> = {
/** 各类导入的一句话说明(弹窗顶部提示) */
const KIND_HINTS: Record<Kind, string> = {
grades: '按**学号**匹配本班学生;同一学生 + 科目 + 考试重复导入会覆盖旧分数。',
students: '按**学号**去重;只生成班级名单,**不创建任何账号**(学生没有密码、不能登录)。班级列可留空,用上一步选的班级。',
students:
'按**学号**去重;只生成班级名单,**不创建任何账号**(学生没有密码、不能登录)。班级列可留空,用上一步选的班级。',
teachers: '按**工号**去重;工号就是登录名。',
classTeachers:
'导入「班级 + 科目 + 教师」的任课关系。角色填「班主任」或「科任」;' +
@@ -378,7 +379,9 @@ async function submit(): Promise<void> {
<el-form-item v-if="usesTeacherNo" label="教师匹配">
<el-switch v-model="useTeacherNo" />
<div class="mode-hint">
打开时优先按<b>工号</b>匹配已有教师账号(匹配不到会按工号新建);
打开时优先按
<b>工号</b>
匹配已有教师账号(匹配不到会按工号新建);
关闭时只按姓名匹配,匹配不到就报错。同一份表格里同一位老师出现多次只会有一个账号。
</div>
</el-form-item>
@@ -449,7 +452,7 @@ async function submit(): Promise<void> {
}
.required-hint {
margin-left: 8px;
color: #f56c6c;
color: var(--el-color-danger);
font-size: 12px;
}
.mode-hint {
@@ -464,13 +467,13 @@ async function submit(): Promise<void> {
font-size: 13px;
}
.result-stats .ok {
color: #67c23a;
color: var(--el-color-success);
}
.result-stats .warn {
color: #e6a23c;
color: var(--el-color-warning);
}
.result-stats .bad {
color: #f56c6c;
color: var(--el-color-danger);
}
.mb-8 {
margin-bottom: 8px;
+25 -22
View File
@@ -82,7 +82,6 @@ function openAssistant(): void {
* - 成绩录入:管理员 + 教师;页面内只对"管理员或所选班级的班主任"显示写入/导入按钮
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
* - 作业 / 通知:所有教师
* - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮)
* - 数据库管理:仅管理员(备份/恢复/导入导出/一键切换是最高危操作,后端同样 requireRole('ADMIN'))
* - 权限组管理 / 客户端设备:`roles` 只是粗筛(管理员 + 教师),真正的门控是**生效权限**
* (`permission:manage` / `client:manage`)—— 教师被某个权限组授予它之后同样该看见
@@ -102,6 +101,15 @@ const menuItems: MenuItem[] = [
{ path: '/classes', title: '班级管理', icon: 'School', roles: ['ADMIN'] },
{ path: '/students', title: '学生管理', icon: 'User', roles: ['ADMIN'] },
{ path: '/teachers', title: '教师管理', icon: 'UserFilled', roles: ['ADMIN'] },
{ path: '/term-weeks', title: '学期周次', icon: 'Calendar', roles: ['ADMIN'] },
{ path: '/archives', title: '毕业归档', icon: 'FolderOpened', roles: ['ADMIN'] },
{
path: '/permissions',
title: '权限组',
icon: 'Lock',
roles: ['ADMIN', 'TEACHER'],
permission: 'permission:manage',
},
{ path: '/schedules', title: '课表管理', icon: 'Calendar', roles: ['ADMIN', 'TEACHER'] },
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
@@ -119,20 +127,9 @@ const menuItems: MenuItem[] = [
path: '/clients',
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: 'classisland:notify',
permission: 'client:read',
},
{
path: '/ai-settings',
@@ -151,8 +148,7 @@ const menuItems: MenuItem[] = [
*
* 两道筛子:`roles` 是粗筛(角色级,与后端 `requireRole` 对齐),`permission` 是细筛
* (生效权限级)——「权限组」必须走后者:教师被权限组授予 `permission:manage` 后
* 角色还是 TEACHER,靠 `roles` 判断不出来。管理员在 `can()` 里短路为 true,
* 所以这里不会因为权限还没拉回来而少显示一项。
* 角色还是 TEACHER,靠 `roles` 判断不出来。所有角色都等待生效权限,加载失败时不放行。
*/
const visibleMenuItems = computed(() =>
menuItems.filter((item) => {
@@ -304,7 +300,7 @@ function bindMenuScroller(): void {
*/
onMounted(() => {
// 生效权限(`GET /permissions/me`):菜单里的「权限组」要等它回来才显示。
// 失败不阻塞布局(store 内已静默吞掉),管理员在 can() 里短路、不受这一拍影响。
// 失败不阻塞布局,带权限要求的入口保持关闭。
void auth.ensurePermissions();
syncIndicator();
bindMenuScroller();
@@ -370,6 +366,17 @@ if (import.meta.env.DEV) {
console.error(`[AdminLayout] 菜单项未注册对应路由:${item.path}`);
}
}
for (const item of router.getRoutes()) {
if (
item.path !== '/' &&
item.path !== '/login' &&
item.meta.title &&
!item.redirect &&
!menuItems.some((menu) => menu.path === item.path)
) {
console.error(`[AdminLayout] 页面缺少菜单入口:${item.path}`);
}
}
}
const connectionText = computed(() => {
@@ -536,11 +543,7 @@ const tourSteps = computed(() => [
外层裁剪框贴着**菜单视口**(菜单列可滚动,激活项滚出去时指示器要一起被裁掉),
pointer-events:none 是必需的 —— 它盖在菜单项上,否则会把点击吃掉。 -->
<div class="nav-indicator-clip" :style="indicatorClipStyle" aria-hidden="true">
<div
class="nav-indicator"
:class="{ 'is-following': indicatorFollowing }"
:style="indicatorStyle"
/>
<div class="nav-indicator" :class="{ 'is-following': indicatorFollowing }" :style="indicatorStyle" />
</div>
<el-menu :default-active="activeMenu" class="layout-menu" @select="handleMenuSelect">
<el-menu-item
+2 -26
View File
@@ -84,15 +84,6 @@ const routes: RouteRecordRaw[] = [
// 管理员把 ai:use 从某个权限组收回后,这一页同时从菜单与路由上消失
meta: { title: 'AI 助手', icon: 'MagicStick', permission: 'ai:use' },
},
{
path: 'integrations',
name: 'integrations',
component: () => import('@/views/IntegrationsView.vue'),
// **已从侧栏摘掉**(2026-10-11 合并进「客户端」):设备管理与课表下发都搬到那一页了,
// 这里只保留少数还没搬过去的东西(提醒下发、联动步骤说明),从「客户端 → 详情」进。
// 路由留着而不是删掉,是为了不把功能一起丢掉;`meta.hidden` 让侧栏不显示它。
meta: { title: 'ClassIsland 联动(旧)', icon: 'Connection', roles: ['ADMIN', 'TEACHER'], hidden: true },
},
{
path: 'ai-settings',
name: 'ai-settings',
@@ -118,25 +109,10 @@ 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'),
// **已从侧栏摘掉**(2026-10-11 合并进「客户端」):设备清单、远控、下发更新都在新页里,
// 这里只留新版还没搬过去的部分(客户端安装包缓存管理 —— 不先把包拉下来,就没有版本可下发)。
// 从「客户端 → 详情 → 安装包与设备记录」进。
meta: { title: '安装包与设备记录', icon: 'Monitor', permission: 'client:manage', hidden: true },
meta: { title: '客户端', icon: 'Monitor', permission: 'client:read' },
},
{ path: 'client-devices', redirect: '/clients' },
],
},
{
+3 -7
View File
@@ -41,13 +41,12 @@ export const useAuthStore = defineStore('auth', () => {
* {@link ensurePermissions} 补上。
*/
const permissions = ref<PermissionKey[]>([]);
/** 生效权限是否来自权限组(false = 没有任何组,走角色默认模板) */
/** 生效权限是否来自显式分组;没有显式分组时使用可编辑的角色默认组。 */
const permissionsFromGroups = ref(false);
const permissionsLoaded = ref(false);
const isAuthenticated = computed(() => Boolean(token.value));
const role = computed(() => user.value?.role ?? null);
const isStaff = computed(() => role.value === 'ADMIN' || role.value === 'TEACHER');
const isAdmin = computed(() => role.value === 'ADMIN');
const displayName = computed(() => user.value?.name ?? '未登录');
const roleLabel = computed(() => (role.value ? ROLE_LABELS[role.value] : '未登录'));
@@ -55,12 +54,10 @@ export const useAuthStore = defineStore('auth', () => {
/**
* 是否有某个生效权限。
*
* 管理员**短路为 true**:服务端 `loadEffectivePermissions` 对 ADMIN 直接给全集,
* 而权限是异步拉的 —— 不短路的话,管理员刚进页面时侧栏会先缺几项再补上(可见闪烁),
* 且 `/permissions/me` 偶发失败会把管理员挡在本来就有的功能之外。
* 管理员同样遵守生效集合,只保留与服务端一致的两个管理入口,避免锁死。
*/
function can(key: PermissionKey): boolean {
if (isAdmin.value) return true;
if (isAdmin.value && (key === 'permission:manage' || key === 'system:manage')) return true;
return hasPermission(permissions.value, key);
}
@@ -144,7 +141,6 @@ export const useAuthStore = defineStore('auth', () => {
permissionsFromGroups,
permissionsLoaded,
isAuthenticated,
isStaff,
isAdmin,
role,
displayName,
+4 -35
View File
@@ -5,8 +5,6 @@ import { io, type Socket } from 'socket.io-client';
import {
PRIORITY_LABELS,
SOCKET_EVENTS,
type ClassIslandNotificationEvent,
type ClassIslandStateEvent,
type GradeDto,
type HomeworkDto,
type NotificationDto,
@@ -18,17 +16,6 @@ import { SOCKET_URL } from '@/config';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
const EVENT_LABELS: Record<string, string> = {
[SOCKET_EVENTS.notificationNew]: '新通知',
[SOCKET_EVENTS.homeworkNew]: '新作业',
[SOCKET_EVENTS.homeworkUpdated]: '作业变更',
[SOCKET_EVENTS.gradeUpdated]: '成绩更新',
[SOCKET_EVENTS.scheduleUpdated]: '课表变更',
[SOCKET_EVENTS.homeworkStatus]: '作业状态',
[SOCKET_EVENTS.classislandState]: 'ClassIsland 状态',
[SOCKET_EVENTS.classislandNotification]: 'ClassIsland 提醒',
};
/**
* 实时通道 store:
* - 持有 Socket.IO 连接(自动重连)
@@ -75,6 +62,10 @@ export const useRealtimeStore = defineStore('realtime', () => {
if (eventCount.value === 0) notify('error', '实时通道连接失败', error.message);
});
instance.on(SOCKET_EVENTS.notificationDeleted, (payload) => {
markEvent(SOCKET_EVENTS.notificationDeleted);
dispatch(SOCKET_EVENTS.notificationDeleted, payload);
});
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
markEvent(SOCKET_EVENTS.notificationNew);
notify(
@@ -120,23 +111,6 @@ export const useRealtimeStore = defineStore('realtime', () => {
dispatch(SOCKET_EVENTS.scheduleUpdated, payload);
});
instance.on(SOCKET_EVENTS.classislandState, (payload: ClassIslandStateEvent) => {
markEvent(SOCKET_EVENTS.classislandState);
// 状态是"心跳"级事件(每次上报都会来),弹通知会刷屏,只静默分发给页面
dispatch(SOCKET_EVENTS.classislandState, payload);
});
instance.on(SOCKET_EVENTS.classislandNotification, (payload: ClassIslandNotificationEvent) => {
markEvent(SOCKET_EVENTS.classislandNotification);
if (payload.targetCount > 0) {
notify('success', '提醒已下发到 ClassIsland', payload.title);
} else {
// 没有已接入的设备时提醒只落库,老师需要知道"没送到教室"
notify('warning', '该班级还没有已接入的 ClassIsland 设备', payload.title);
}
dispatch(SOCKET_EVENTS.classislandNotification, payload);
});
instance.on(SOCKET_EVENTS.connected, (payload: { rooms: string[] }) => {
markEvent(SOCKET_EVENTS.connected);
console.info('[realtime] 已加入房间', payload.rooms);
@@ -180,17 +154,12 @@ export const useRealtimeStore = defineStore('realtime', () => {
handlers.get(event)?.delete(handler);
}
function eventLabel(): string {
return lastEventName.value ? (EVENT_LABELS[lastEventName.value] ?? lastEventName.value) : '';
}
return {
connected,
connecting,
lastEventName,
lastEventAt,
eventCount,
eventLabel,
connect,
disconnect,
on,
+1 -2
View File
@@ -220,8 +220,7 @@ html.care-mode .care-emphasis {
* 且不用 `!important`。Web 管理端目前没有 el-sub-menu,这里仍然写全:以后加设置分组时不用再踩一次。
*
* 行高 36px + 上下各 1px 外边距 = 每项 38px(展开态是 Element Plus 默认的 56px)。
* **只在折叠态生效**:导轨要"一眼看全" —— Web 端目前 14 个菜单项,
* 14 × 38 = 532px,在 1366×768 笔记本上那个约 540px 的菜单视口里刚好放得下
* **只在折叠态生效**:导轨要尽量展示全部菜单;菜单超过视口时由容器滚动。
* (1366×768 扣掉浏览器框、顶栏 56px、品牌区 56px)。展开态不动 —— 那是原有的观感。
* 这里的 `margin` 必须一起写:`.layout-menu :deep(.el-menu-item)` 的 `margin: 2px 8px`
* 是 AdminLayout 的 scoped 规则(0-3-0),本段是 0-5-0,靠特异度压住它。
@@ -143,6 +143,8 @@ const INTENT_LABELS: Record<string, string> = {
};
const STATUS_LABELS: Record<string, string> = {
interpreted: '已解析,待人工确认',
unrecognized: '未识别出可用动作',
pending: '已解析',
confirmed: '已发布',
cancelled: '已放弃',
@@ -246,7 +248,11 @@ onMounted(async () => {
<el-tabs v-model="activeTab" class="settings-tabs">
<el-tab-pane label="模型配置" name="model">
<el-card v-loading="loading" shadow="never">
<el-form :model="form" :label-width="isMobile ? undefined : '132px'" :label-position="isMobile ? 'top' : 'right'">
<el-form
:model="form"
:label-width="isMobile ? undefined : '132px'"
:label-position="isMobile ? 'top' : 'right'"
>
<el-form-item label="启用助手">
<el-switch v-model="form.enabled" data-test="ai-enabled" />
<span class="field-hint">关闭后教师在「AI 助手」页会看到"还没启用"的空态</span>
@@ -262,11 +268,19 @@ onMounted(async () => {
<template v-if="form.provider === 'openai'">
<el-form-item label="服务地址">
<el-input v-model="form.baseUrl" placeholder="https://api.openai.com/v1" class="field-input" />
<el-input
v-model="form.baseUrl"
placeholder="https://api.openai.com/v1"
class="field-input"
/>
</el-form-item>
<el-form-item label="模型名">
<el-input v-model="form.model" placeholder="gpt-4o-mini(留空即用默认)" class="field-input" />
<el-input
v-model="form.model"
placeholder="gpt-4o-mini(留空即用默认)"
class="field-input"
/>
</el-form-item>
<el-form-item label="API 密钥">
@@ -276,7 +290,9 @@ onMounted(async () => {
type="password"
show-password
class="field-input"
:placeholder="hasApiKey ? `已配置(尾号 ${keyHint}),留空表示不修改` : '粘贴密钥,保存后不再显示'"
:placeholder="
hasApiKey ? `已配置(尾号 ${keyHint}),留空表示不修改` : '粘贴密钥,保存后不再显示'
"
/>
<el-button v-if="hasApiKey" :loading="clearingKey" @click="clearApiKey">清除密钥</el-button>
</div>
@@ -366,15 +382,21 @@ onMounted(async () => {
<el-descriptions-item label="版本">{{ diagnostics?.version ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="Node">{{ diagnostics?.nodeVersion ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="平台">{{ diagnostics?.platform ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="已运行">{{ formatUptime(diagnostics?.uptimeSeconds) }}</el-descriptions-item>
<el-descriptions-item label="数据库">{{ diagnostics?.databaseProvider ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="已运行">
{{ formatUptime(diagnostics?.uptimeSeconds) }}
</el-descriptions-item>
<el-descriptions-item label="数据库">
{{ diagnostics?.databaseProvider ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="在线客户端">
{{ diagnostics?.onlineClientCount ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="已发布客户端版本">
{{ diagnostics?.clientReleaseCount ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="已加载模块">{{ diagnostics?.moduleCount ?? '—' }}</el-descriptions-item>
<el-descriptions-item label="已加载模块">
{{ diagnostics?.moduleCount ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="服务端目录" :span="isMobile ? 1 : 2">
<span class="cell-path">{{ diagnostics?.serverRoot ?? '—' }}</span>
</el-descriptions-item>
+28 -6
View File
@@ -91,7 +91,8 @@ const archivedYears = computed(() => new Set(years.value.map((item) => item.enro
function openCreate(): void {
const suggested = summary.value?.suggestedEnrollmentYears ?? [];
createForm.enrollmentYear = suggested.find((year) => !archivedYears.value.has(year)) ?? suggested[0] ?? createForm.enrollmentYear;
createForm.enrollmentYear =
suggested.find((year) => !archivedYears.value.has(year)) ?? createForm.enrollmentYear;
createForm.note = '';
createVisible.value = true;
}
@@ -137,7 +138,8 @@ onMounted(loadYears);
<h2 class="page-title">毕业归档</h2>
<p class="page-subtitle">
按年度归档毕业的班级与学生、当年转出的学生,以及毕业班级发过的作业与通知。
<b>归档不删数据</b>;未毕业而升级的班级不归档,数据继续沿用
<b>归档不删数据</b>
;未毕业而升级的班级不归档,数据继续沿用
</p>
</div>
<div class="toolbar">
@@ -210,7 +212,13 @@ onMounted(loadYears);
<!-- 届别详情:班级 / 毕业生 / 转出的学生 -->
<el-drawer v-model="detailVisible" size="68%" :title="detail ? `${detail.name} · 归档详情` : '归档详情'">
<div v-loading="detailLoading">
<el-alert v-if="detail?.note" type="info" :closable="false" style="margin-bottom: 12px" :title="detail.note" />
<el-alert
v-if="detail?.note"
type="info"
:closable="false"
style="margin-bottom: 12px"
:title="detail.note"
/>
<el-tabs v-model="activeTab">
<el-tab-pane label="班级" name="classes">
@@ -255,7 +263,12 @@ onMounted(loadYears);
</el-tab-pane>
<el-tab-pane :label="`转出学生(${detail?.transferred.length ?? 0})`" name="transferred">
<el-table :data="detail?.transferred ?? []" size="small" class="mt-12" empty-text="该届没有转出的学生">
<el-table
:data="detail?.transferred ?? []"
size="small"
class="mt-12"
empty-text="该届没有转出的学生"
>
<el-table-column prop="studentNo" label="学号" width="140" />
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="转出前班级" min-width="140">
@@ -272,7 +285,11 @@ onMounted(loadYears);
</el-drawer>
<!-- 归档班级的作业与通知(只读) -->
<el-drawer v-model="contentVisible" size="60%" :title="content ? `${content.className} · 作业与通知` : '作业与通知'">
<el-drawer
v-model="contentVisible"
size="60%"
:title="content ? `${content.className} · 作业与通知` : '作业与通知'"
>
<div v-loading="contentLoading">
<el-tabs>
<el-tab-pane :label="`作业(${content?.homeworks.length ?? 0})`">
@@ -288,7 +305,12 @@ onMounted(loadYears);
</el-table>
</el-tab-pane>
<el-tab-pane :label="`通知(${content?.notifications.length ?? 0})`">
<el-table :data="content?.notifications ?? []" size="small" class="mt-12" empty-text="没有通知记录">
<el-table
:data="content?.notifications ?? []"
size="small"
class="mt-12"
empty-text="没有通知记录"
>
<el-table-column prop="title" label="标题" min-width="180" show-overflow-tooltip />
<el-table-column prop="content" label="内容" min-width="220" show-overflow-tooltip />
<el-table-column label="发布时间" width="170">
+14 -13
View File
@@ -27,7 +27,7 @@ import UrgentClassWarning from '@/components/UrgentClassWarning.vue';
* 三条边界(与服务端 `modules/ai/ai.service.ts` 的设计一一对应):
* 1. 助手**只解析、不写业务表**:`POST /ai/interpret` 返回的是结构化参数与中文预览,
* 真正发布走的仍是既有端点(`POST /homeworks` / `/notifications` / `/calls`)——
* 于是科目权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用,不会另开一条旁路。
* 于是科目权限断言、上课时段紧急通知保护全部自动复用,不会另开一条旁路。
* 2. **参数可改**:模型的输出不可信(服务端自己也会用同一份 zod 再校验一遍),
* 所以解析结果一律填进表单让老师过目、修改,而不是直接提交。
* 3. **教师不读配置**:`GET /ai/settings` 要 `ai:manage`,教师读会 403 且响应拦截器会弹一条错误提示。
@@ -102,6 +102,7 @@ const rules = computed<FormRules>(() => {
}
if (action === 'call_student') {
base.studentId = [{ required: true, message: '请选择要叫的同学', trigger: 'change' }];
base.message = [{ required: true, whitespace: true, message: '请填写叫人消息', trigger: 'blur' }];
}
return base;
});
@@ -312,7 +313,7 @@ function onUrgentCancelled(): void {
publishError.value = '已取消:正在上课,紧急通知需要二次确认';
}
/** 真正落库:复用既有的发布端点(权限断言 / 上课保护 / ClassIsland 推送都在服务端) */
/** 真正落库:复用既有的发布端点(权限断言 / 上课保护 都在服务端) */
async function publishOnce(action: AiInterpretResult['action'], confirmDuringClass: boolean): Promise<void> {
if (action === 'create_homework') {
await homeworkApi.create({
@@ -430,7 +431,9 @@ function goSettings(): void {
<div>
<h2 class="page-title">AI 助手</h2>
<p class="page-subtitle">
用一句话说清要做什么,助手解析成可修改的草稿;<b>确认发布后</b>才会真正发到班级
用一句话说清要做什么,助手解析成可修改的草稿;
<b>确认发布后</b>
才会真正发到班级
</p>
</div>
</div>
@@ -469,9 +472,7 @@ function goSettings(): void {
<template #header>
<div class="result-header">
<span class="result-title">{{ actionLabel }}</span>
<el-tag :type="confidenceType" size="small" effect="light">
可信度 {{ confidencePercent }}
</el-tag>
<el-tag :type="confidenceType" size="small" effect="light">可信度 {{ confidencePercent }}</el-tag>
</div>
</template>
@@ -532,7 +533,12 @@ function goSettings(): void {
<template v-if="result.action === 'create_homework'">
<el-form-item label="科目">
<el-select v-model="form.courseId" clearable placeholder="不指定科目" class="result-field">
<el-option v-for="item in courseOptions" :key="item.id" :label="item.name" :value="item.id" />
<el-option
v-for="item in courseOptions"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
@@ -596,12 +602,7 @@ function goSettings(): void {
<div class="result-actions">
<el-button :disabled="publishing" @click="result = null">放弃</el-button>
<el-button
type="primary"
:loading="publishing"
data-test="ai-confirm"
@click="confirmPublish"
>
<el-button type="primary" :loading="publishing" data-test="ai-confirm" @click="confirmPublish">
确认发布
</el-button>
</div>
+79 -40
View File
@@ -22,6 +22,8 @@ import { classApi, courseApi, teacherApi } from '@/api';
import { useAuthStore } from '@/stores/auth';
import { useRealtimeStore } from '@/stores/realtime';
import { useResponsive } from '@/composables/useResponsive';
import TableImportDialog from '@/components/TableImportDialog.vue';
const relationImportVisible = ref(false);
const auth = useAuthStore();
const realtime = useRealtimeStore();
@@ -109,40 +111,47 @@ function openEdit(row: ClassDto): void {
formVisible.value = true;
}
const submitFormSaving = ref(false);
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (submitFormSaving.value) return;
submitFormSaving.value = true;
try {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (editingId.value) {
await classApi.update(editingId.value, {
grade: form.grade,
enrollmentYear: form.enrollmentYear,
classIndex: form.classIndex,
termWeeks: form.termWeeks,
});
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
if (isAdmin.value && form.headTeacherId) {
const target = classes.value.find((item) => item.id === editingId.value);
if (target && target.teacher?.id !== form.headTeacherId) {
await classApi.assignHeadTeacher(editingId.value, form.headTeacherId);
if (editingId.value) {
await classApi.update(editingId.value, {
grade: form.grade,
enrollmentYear: form.enrollmentYear,
classIndex: form.classIndex,
termWeeks: form.termWeeks,
});
// 编辑弹窗里也能直接换班主任(仅管理员;班主任决定谁能管这个班的课表与成绩)
if (isAdmin.value && form.headTeacherId) {
const target = classes.value.find((item) => item.id === editingId.value);
if (target && target.teacher?.id !== form.headTeacherId) {
await classApi.assignHeadTeacher(editingId.value, form.headTeacherId);
}
}
ElMessage.success('班级已更新');
} else {
await classApi.create({
grade: form.grade,
enrollmentYear: form.enrollmentYear,
classIndex: form.classIndex,
// 学期周数:新建时也要提交。原先只提交名称/年级/班级码,表单里填的周数被直接丢弃,
// 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。
termWeeks: form.termWeeks,
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
});
ElMessage.success(`班级 ${previewName.value} 创建成功`);
}
ElMessage.success('班级已更新');
} else {
await classApi.create({
grade: form.grade,
enrollmentYear: form.enrollmentYear,
classIndex: form.classIndex,
// 学期周数:新建时也要提交。原先只提交名称/年级/班级码,表单里填的周数被直接丢弃,
// 服务端落库恒为默认 20,用户看到的是"设置了没生效"且没有任何提示。
termWeeks: form.termWeeks,
...(form.code.trim() ? { code: form.code.trim().toUpperCase() } : {}),
...(isAdmin.value && form.headTeacherId ? { teacherId: form.headTeacherId } : {}),
});
ElMessage.success(`班级 ${previewName.value} 创建成功`);
formVisible.value = false;
await loadClasses();
} finally {
submitFormSaving.value = false;
}
formVisible.value = false;
await loadClasses();
}
/* ------------------------------------------------------------ 更换班主任(仅管理员) */
@@ -469,6 +478,13 @@ onUnmounted(() => {
@clear="loadClasses"
/>
<el-button :icon="'Search'" @click="loadClasses">查询</el-button>
<el-button
v-if="auth.can('import:class-teachers')"
:icon="'Upload'"
@click="relationImportVisible = true"
>
导入任课关系
</el-button>
<el-button v-if="isAdmin" type="primary" :icon="'Plus'" @click="openCreate">新建班级</el-button>
</div>
</div>
@@ -530,7 +546,9 @@ onUnmounted(() => {
<el-alert type="info" :closable="false" show-icon class="mb-12">
<template #title>
教室机器以「班级」为主体登录:班级码 + 班级密码。登录后本机代表整个班级,
作业完成、通知已读都会按全班记录。<b>学生没有个人账号,不能登录</b>。
作业完成、通知已读都会按全班记录。
<b>学生没有个人账号,不能登录</b>
。
</template>
</el-alert>
<el-form label-width="90px">
@@ -601,7 +619,7 @@ onUnmounted(() => {
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
<el-button type="primary" :loading="submitFormSaving" @click="submitForm">保存</el-button>
</template>
</el-dialog>
@@ -656,7 +674,9 @@ onUnmounted(() => {
<el-table-column prop="name" label="姓名" width="120" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain">{{ STUDENT_STATUS_LABELS[row.status as StudentStatus] }}</el-tag>
<el-tag size="small" effect="plain">
{{ STUDENT_STATUS_LABELS[row.status as StudentStatus] }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="加入时间" width="180">
@@ -692,11 +712,17 @@ onUnmounted(() => {
<el-tab-pane label="任课老师" name="subjects">
<el-alert type="info" :closable="false" class="mt-12">
这是「班级 + 科目 + 教师」的任课关系:<b>科任老师只能发布 / 修改 / 删除自己任教科目的作业和成绩</b>;
班主任若不教这一科,同样改不了这一科。同一位老师既可以是班主任、又可以教某一科 ——
这是「班级 + 科目 + 教师」的任课关系:
<b>科任老师只能发布 / 修改 / 删除自己任教科目的作业和成绩</b>
; 班主任若不教这一科,同样改不了这一科。同一位老师既可以是班主任、又可以教某一科 ——
系统里只有他一个账号,权限自动合并,不会重复建号。
</el-alert>
<el-table :data="detail?.subjectTeachers ?? []" size="small" class="mt-12" empty-text="还没有配置任课老师">
<el-table
:data="detail?.subjectTeachers ?? []"
size="small"
class="mt-12"
empty-text="还没有配置任课老师"
>
<el-table-column prop="subjectName" label="科目" width="140" />
<el-table-column label="任课老师" width="260">
<template #default="{ row }">
@@ -735,7 +761,12 @@ onUnmounted(() => {
>
<el-option v-for="name in assignableSubjects" :key="name" :label="name" :value="name" />
</el-select>
<el-select v-model="subjectDraft.teacherId" placeholder="选择任课老师" style="width: 220px" filterable>
<el-select
v-model="subjectDraft.teacherId"
placeholder="选择任课老师"
style="width: 220px"
filterable
>
<el-option
v-for="item in teachers"
:key="item.id"
@@ -743,7 +774,9 @@ onUnmounted(() => {
:value="item.id"
/>
</el-select>
<el-button type="primary" :loading="subjectSaving" @click="saveSubjectTeacher">设为任课老师</el-button>
<el-button type="primary" :loading="subjectSaving" @click="saveSubjectTeacher">
设为任课老师
</el-button>
</div>
</el-tab-pane>
@@ -770,12 +803,17 @@ onUnmounted(() => {
<div>
<div class="setting-title">学年升级</div>
<div class="text-muted">
把本班年级改成下一学年(例如高一 → 高二)。<b>升级不归档</b>:
班级记录、学生名单、学号、作业与成绩全部沿用,只动「年级」这一个字段。
把本班年级改成下一学年(例如高一 → 高二)。
<b>升级不归档</b>
: 班级记录、学生名单、学号、作业与成绩全部沿用,只动「年级」这一个字段。
</div>
</div>
<div class="toolbar">
<el-input v-model="promoteForm.grade" placeholder="升级后的年级,如 高二" style="width: 200px" />
<el-input
v-model="promoteForm.grade"
placeholder="升级后的年级,如 高二"
style="width: 200px"
/>
<el-button type="primary" :loading="promoteForm.saving" @click="promote">升级</el-button>
</div>
</div>
@@ -813,6 +851,7 @@ onUnmounted(() => {
</template>
</el-dialog>
</div>
<TableImportDialog v-model="relationImportVisible" kind="classTeachers" @imported="loadClasses" />
</template>
<style scoped>
@@ -1,762 +0,0 @@
<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 ClientCommandDto,
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();
}
/** 命令种类的中文名。包一层是因为表格插槽里的 `row` 是 any,直接索引常量表过不了类型检查 */
function commandLabel(command: ClientCommandDto): string {
return CLIENT_COMMAND_LABELS[command.kind] ?? command.kind;
}
/**
* 一条远控命令"现在究竟怎么样了"。
*
* 待执行的那条会在**取消 / 超时**之后从 `pendingCommand` 里消失,界面上不留痕迹 ——
* 而最常见的两种"点了没反应"恰恰是:本机没人确认(客户端 60 秒自动按取消)与设备当时离线。
* 服务端把最近一条也返回(`lastCommand`),这里把它翻成一句人话。
*/
function commandStateText(command: ClientCommandDto): string { if (command.result) return command.result;
if (command.ackedAt) return '已执行';
if (command.expiresAt && Date.parse(command.expiresAt) < Date.now()) {
return '已过期 —— 下达时机器不在线(命令有效期 10 分钟)';
}
return '待执行(等这台机器的下一次心跳)';
}
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} 台 —— ` +
'机器上会弹出默认选中「取消」的确认框,60 秒内没人点「确认执行」就按取消处理(详情表里能看到结果)',
);
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="上次指令" min-width="200">
<template #default="{ row }">
<span v-if="!row.lastCommand" class="row-value muted">—</span>
<span v-else>
{{ commandLabel(row.lastCommand) }} · {{ commandStateText(row.lastCommand) }}
</span>
</template>
</el-table-column>
<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>
+108 -27
View File
@@ -11,11 +11,10 @@
* - 客户端**每 30 秒**上报一次心跳(`CLIENT_HEARTBEAT_INTERVAL_MS`),本页也跟着自动刷新一次;
* - 命令 **10 分钟**内有效(`CLIENT_COMMAND_EXPIRE_MS`),过期自动失效,
* 所以"下发成功"不等于"已经执行"——它只是排进了队列;
* - 更新是**客户端自己挑时机装**的:先下载校验,只在该班不上课、且客户端窗口已隐藏 5 分钟后
* 才静默安装 —— 这正是"推送"与"立刻重启"必须分开的两个动作。
* - 更新先下载校验,再按服务端指定的立即 / 放学后 / 空闲五分钟策略安装。
*
* 权限:查看/删除 `client:manage`、更新相关 `client:update-push`、远控 `remote:control`。
* 教师默认没有这三个键(`lib/permissions.ts` 的默认模板只给 ADMIN),所以无权限的按钮是
* 权限:查看 `client:read`、删除 `client:manage`、更新 `client:update-push`、远控 `remote:control`。
* 教师默认可以查看;删除、更新和远控默认只给管理员。无权限的按钮是
* **禁用 + tooltip 说明**而不是直接隐藏:让老师知道"有这件事、需要权限",而不是以为系统没有。
*/
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
@@ -29,6 +28,7 @@ import {
compareVersions,
normalizeVersion,
relativeTime,
formatDate,
type ClassDto,
type ClientCommandKind,
type ClientDeviceDto,
@@ -227,15 +227,19 @@ const updateDialog = reactive({
version: '',
/** 清除服务端指定的版本(回到"不要求更新") */
clear: false,
mode: 'after-school' as 'idle' | 'immediate' | 'after-school',
afterSchoolTime: '17:00',
submitting: false,
});
/** 弹窗标题带上台数,确认前就能看出会影响多少台 */
const updateScopeHint = computed(() => {
if (updateDialog.clear) return '将清除下列范围设备的「指定版本」,已经下载好的包不会被删。';
if (updateDialog.clear) return '将清除下列范围设备的「指定版本」,已下载的包保留,但不再自动安装。';
if (updateDialog.scope === 'all') return `全部 ${devices.value.length} 台设备`;
if (updateDialog.scope === 'class') {
return updateDialog.classIds.length ? `所选 ${updateDialog.classIds.length} 个班级的在线与离线设备` : '请选择班级';
return updateDialog.classIds.length
? `所选 ${updateDialog.classIds.length} 个班级的在线与离线设备`
: '请选择班级';
}
return selectedCount.value ? `已勾选的 ${selectedCount.value} 台设备` : '请先在表格里勾选设备';
});
@@ -247,6 +251,8 @@ const updateSubmitDisabled = computed(() => {
if (updateDialog.scope === 'class' && updateDialog.classIds.length === 0) return true;
if (updateDialog.scope === 'selected' && selectedCount.value === 0) return true;
if (!updateDialog.clear && !updateDialog.version) return true;
if (!updateDialog.clear && updateDialog.mode === 'after-school' && !updateDialog.afterSchoolTime)
return true;
return false;
});
@@ -265,6 +271,7 @@ async function openReleasesIfNeeded(): Promise<void> {
if (releasesLoaded.value) return;
try {
await ensureReleases();
if (!updateDialog.version) updateDialog.version = readyReleases.value[0]?.version ?? '';
} catch (error) {
// 拦截器已经为"有响应"的错误弹过一次,这里只补"连响应都没有"的那类
toastError(error);
@@ -287,10 +294,13 @@ async function submitUpdate(): Promise<void> {
const version = updateDialog.clear ? '' : updateDialog.version;
const detail = updateDialog.clear
? '清除后这些设备不再被要求升级(已经下载好的安装包不会自动删除)。'
? '清除后这些设备不再被要求升级(已经下载好的包保留,但取消自动安装)。'
: `目标版本:v${normalizeVersion(version)}。\n\n` +
'客户端会先下载并校验安装包;只在该班不上课、且客户端窗口已隐藏 5 分钟后才静默安装。' +
'推送本身不会立刻重启任何一台机器。';
(updateDialog.mode === 'immediate'
? '下载并校验完成后立即安装并重启客户端。'
: updateDialog.mode === 'after-school'
? `等待今天放学后安装:不早于 ${updateDialog.afterSchoolTime},且不早于本班最后一节课结束。`
: '等待不上课且窗口隐藏满 5 分钟后安装。');
try {
await ElMessageBox.confirm(detail, updateDialog.clear ? '确认清除更新指定' : '确认推送更新', {
@@ -310,6 +320,8 @@ async function submitUpdate(): Promise<void> {
...payload,
version: updateDialog.clear ? null : version,
clear: updateDialog.clear,
mode: updateDialog.mode,
afterSchoolTime: updateDialog.afterSchoolTime,
});
updateDialog.visible = false;
reportPushResult(updateDialog.clear ? '清除更新指定' : `推送更新 v${normalizeVersion(version)}`, result);
@@ -515,6 +527,7 @@ async function openReleasesDrawer(): Promise<void> {
releasesDrawer.visible = true;
try {
await ensureReleases();
if (!updateDialog.version) updateDialog.version = readyReleases.value[0]?.version ?? '';
} catch (error) {
toastError(error);
}
@@ -542,6 +555,7 @@ async function fetchRelease(): Promise<void> {
ElMessage.success(`已下载并登记 v${normalizeVersion(release.version)}(${formatSize(release.size)})`);
releasesDrawer.fetchVersion = '';
await ensureReleases();
if (!updateDialog.version) updateDialog.version = readyReleases.value[0]?.version ?? '';
} catch (error) {
// 服务端把"GitHub 上没有这个版本 / 下载失败"都做成了 400 + 中文说明,拦截器已经弹过一次
toastError(error);
@@ -564,14 +578,13 @@ async function registerRelease(): Promise<void> {
try {
const release = await clientReleaseApi.register({
version,
...(releasesDrawer.registerFileName.trim()
? { fileName: releasesDrawer.registerFileName.trim() }
: {}),
...(releasesDrawer.registerFileName.trim() ? { fileName: releasesDrawer.registerFileName.trim() } : {}),
});
ElMessage.success(`已登记 v${normalizeVersion(release.version)}(${release.fileName})`);
releasesDrawer.registerVersion = '';
releasesDrawer.registerFileName = '';
await ensureReleases();
if (!updateDialog.version) updateDialog.version = readyReleases.value[0]?.version ?? '';
} catch (error) {
toastError(error);
} finally {
@@ -685,12 +698,16 @@ const deviceEmptyText = computed(() =>
</el-tooltip>
<el-tooltip
:disabled="can('client:manage')"
content="当前账号没有「管理客户端设备」权限(client:manage)"
:disabled="can('client:update-push')"
content="当前账号没有「管理安装包」权限(client:update-push)"
placement="top"
>
<span>
<el-button :icon="'FolderOpened'" :disabled="!can('client:manage')" @click="openReleasesDrawer">
<el-button
:icon="'FolderOpened'"
:disabled="!can('client:update-push')"
@click="openReleasesDrawer"
>
安装包管理
</el-button>
</span>
@@ -772,6 +789,33 @@ const deviceEmptyText = computed(() =>
</template>
</el-table-column>
<el-table-column label="安装时机" min-width="170">
<template #default="{ row }">
{{
row.updateTargetVersion
? { immediate: '立即更新', 'after-school': '放学后更新', idle: '空闲时更新' }[
row.updateMode as 'immediate' | 'after-school' | 'idle'
]
: '—'
}}
<div v-if="row.updateNotBefore" class="text-muted">
{{ formatDate(row.updateNotBefore, true) }}
</div>
</template>
</el-table-column>
<el-table-column label="最近指令" min-width="200">
<template #default="{ row }">
<template v-if="row.lastCommand">
{{ commandLabel(row.lastCommand.kind) }}:{{
row.lastCommand.result ||
(row.lastCommand.expiresAt && Date.parse(row.lastCommand.expiresAt) < Date.now()
? '已过期(设备未回执)'
: '等待设备接收或本机确认')
}}
</template>
<span v-else>—</span>
</template>
</el-table-column>
<el-table-column label="更新" min-width="150">
<template #default="{ row }">
<template v-if="row.pendingVersion">
@@ -854,10 +898,29 @@ const deviceEmptyText = computed(() =>
<el-form-item label="清除服务端指定的版本">
<el-switch v-model="updateDialog.clear" />
<span class="text-muted client-switch-hint">
打开后不再要求这批设备升级(已经下载好的安装包不会自动删除)。
打开后不再要求这批设备升级(已经下载好的包保留,但取消自动安装)。
</span>
</el-form-item>
<el-form-item v-if="!updateDialog.clear" label="安装时机">
<el-radio-group v-model="updateDialog.mode">
<el-radio value="immediate">立即更新</el-radio>
<el-radio value="after-school">放学后更新</el-radio>
<el-radio value="idle">空闲时更新</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item v-if="!updateDialog.clear && updateDialog.mode === 'after-school'" label="最早安装时间">
<el-time-select
v-model="updateDialog.afterSchoolTime"
start="00:00"
end="23:45"
step="00:15"
:clearable="false"
/>
<span class="text-muted">
按服务器今天的日期;若课表结束更晚,等待最后一节课结束。离线设备上线后补执行。
</span>
</el-form-item>
<el-form-item v-if="!updateDialog.clear" label="目标版本">
<el-select
v-model="updateDialog.version"
@@ -873,10 +936,11 @@ const deviceEmptyText = computed(() =>
/>
</el-select>
<p v-if="readyReleases.length === 0" class="client-note text-muted">
服务端还没有可用的安装包,请先去「安装包管理」从 GitHub 拉取一份(由服务器代替教室下载,教室机器不需要外网)。
服务端还没有可用的安装包,请先去「安装包管理」从 GitHub
拉取一份(由服务器代替教室下载,教室机器不需要外网)。
</p>
<p class="client-note text-muted">
客户端会先下载并校验;只在该班不上课、且客户端窗口已隐藏 5 分钟后才静默安装。
客户端会先下载并校验,再按所选时机安装;立即更新会重启客户端,请先确认课堂使用情况。
</p>
</el-form-item>
@@ -927,7 +991,13 @@ const deviceEmptyText = computed(() =>
<el-form-item label="目标设备">
<div class="client-target-list">
<el-tag v-for="device in selectedDevices" :key="device.id" class="client-tag" size="small" effect="plain">
<el-tag
v-for="device in selectedDevices"
:key="device.id"
class="client-tag"
size="small"
effect="plain"
>
{{ device.name }}
</el-tag>
<span v-if="selectedDevices.length === 0" class="text-muted">未勾选设备</span>
@@ -961,7 +1031,13 @@ const deviceEmptyText = computed(() =>
<!-- ------------------------------------------------ 批量结果明细 -->
<el-dialog v-model="resultDialog.visible" :title="resultDialog.title" :width="isMobile ? '92%' : '640px'">
<el-alert class="client-banner" type="warning" :closable="false" show-icon :title="resultDialog.summary" />
<el-alert
class="client-banner"
type="warning"
:closable="false"
show-icon
:title="resultDialog.summary"
/>
<el-table :data="resultDialog.failures" max-height="320" empty-text="没有失败项">
<el-table-column label="设备" min-width="160">
<template #default="{ row }">{{ failureDeviceName(row) }}</template>
@@ -976,11 +1052,7 @@ const deviceEmptyText = computed(() =>
</el-dialog>
<!-- ------------------------------------------------ 安装包管理 -->
<el-drawer
v-model="releasesDrawer.visible"
:size="isMobile ? '94%' : '720px'"
title="客户端安装包管理"
>
<el-drawer v-model="releasesDrawer.visible" :size="isMobile ? '94%' : '720px'" title="客户端安装包管理">
<p class="client-note text-muted">
安装包缓存在服务端上。教室机器常常没有外网,因此由服务器替教室从 GitHub 下载;
客户端更新时也是从本服务器下载,教室机器不需要外网。
@@ -1007,7 +1079,11 @@ const deviceEmptyText = computed(() =>
</el-button>
<p class="client-note text-muted">
由服务器代为下载
{{ releasesDrawer.fetchVersion.trim() ? clientReleaseFileName(releasesDrawer.fetchVersion) : '安装包' }}
{{
releasesDrawer.fetchVersion.trim()
? clientReleaseFileName(releasesDrawer.fetchVersion)
: '安装包'
}}
,可能要几分钟;期间按钮会一直转圈,请不要重复点击。
</p>
</div>
@@ -1073,7 +1149,12 @@ const deviceEmptyText = computed(() =>
>
设为更新目标
</el-button>
<el-button link type="danger" :disabled="!can('client:update-push')" @click="removeRelease(row)">
<el-button
link
type="danger"
:disabled="!can('client:update-push')"
@click="removeRelease(row)"
>
删除
</el-button>
</template>
+143 -74
View File
@@ -62,7 +62,8 @@ const averagePercent = computed(() => {
return Math.round((sum / grades.value.length) * 10) / 10;
});
const examOptions = computed(() => [...new Set(grades.value.map((item) => item.examName))]);
const exams = ref<Array<{ name: string; countInTotal: boolean }>>([]);
const examOptions = computed(() => exams.value.map((item) => item.name));
/* ------------------------------------------------------------ 考试口径(计入总分)
@@ -75,26 +76,45 @@ const examOptions = computed(() => [...new Set(grades.value.map((item) => item.e
*/
const examCountInTotal = ref(false);
const examSaving = ref(false);
const examLoading = ref(false);
let examRequest = 0;
async function loadExamFlag(): Promise<void> {
if (!filter.classId || !filter.examName) {
const request = ++examRequest;
const { classId, examName } = filter;
if (!classId) {
exams.value = [];
examCountInTotal.value = false;
return;
}
const list = await gradeApi.listExams(filter.classId).catch(() => []);
examCountInTotal.value = list.find((item) => item.name === filter.examName)?.countInTotal === true;
examLoading.value = true;
try {
const list = await gradeApi.listExams(classId);
if (request !== examRequest || classId !== filter.classId || examName !== filter.examName) return;
exams.value = list;
examCountInTotal.value = list.find((item) => item.name === examName)?.countInTotal === true;
} catch {
if (request === examRequest) {
exams.value = [];
examCountInTotal.value = false;
}
} finally {
if (request === examRequest) examLoading.value = false;
}
}
async function setExamCountInTotal(value: boolean): Promise<void> {
if (!filter.classId || !filter.examName) return;
if (examSaving.value || examLoading.value || !filter.classId || !filter.examName) return;
const { classId, examName } = filter;
examSaving.value = true;
try {
const saved = await gradeApi.setExam({
classId: filter.classId,
name: filter.examName,
classId,
name: examName,
countInTotal: value,
});
examCountInTotal.value = saved.countInTotal;
if (classId === filter.classId && examName === filter.examName)
examCountInTotal.value = saved.countInTotal;
} catch {
// 失败时回读真实值,避免开关停在一个假状态上(拦截器已经弹过原因)
await loadExamFlag();
@@ -104,7 +124,11 @@ async function setExamCountInTotal(value: boolean): Promise<void> {
}
// 换了班级或考试就重新读一次口径(加载成绩的 watch 只管列表)
watch(() => [filter.classId, filter.examName], () => void loadExamFlag(), { immediate: true });
watch(
() => [filter.classId, filter.examName],
() => void loadExamFlag(),
{ immediate: true },
);
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
@@ -237,25 +261,32 @@ function openSingle(): void {
singleVisible.value = true;
}
const submitSingleSaving = ref(false);
async function submitSingle(): Promise<void> {
if (!singleForm.studentId || !singleForm.examName.trim()) {
ElMessage.warning('请选择学生并填写考试名称');
return;
if (submitSingleSaving.value) return;
submitSingleSaving.value = true;
try {
if (!singleForm.studentId || !singleForm.examName.trim()) {
ElMessage.warning('请选择学生并填写考试名称');
return;
}
await gradeApi.create({
classId: filter.classId,
courseId: await resolveCourseId(singleForm.courseId),
studentId: singleForm.studentId,
examName: singleForm.examName.trim(),
score: singleForm.score,
totalScore: singleForm.totalScore,
levelType: singleForm.levelType,
// percent 口径留空时由服务端按得分率换算,所以这里只传用户真的填了的值
...(singleForm.level.trim() ? { level: singleForm.level.trim() } : {}),
});
ElMessage.success('成绩已录入,已实时推送给教室端');
singleVisible.value = false;
await loadGrades();
} finally {
submitSingleSaving.value = false;
}
await gradeApi.create({
classId: filter.classId,
courseId: await resolveCourseId(singleForm.courseId),
studentId: singleForm.studentId,
examName: singleForm.examName.trim(),
score: singleForm.score,
totalScore: singleForm.totalScore,
levelType: singleForm.levelType,
// percent 口径留空时由服务端按得分率换算,所以这里只传用户真的填了的值
...(singleForm.level.trim() ? { level: singleForm.level.trim() } : {}),
});
ElMessage.success('成绩已录入,已实时推送给教室端');
singleVisible.value = false;
await loadGrades();
}
/* ------------------------------------------------------------ 批量录入 */
@@ -267,7 +298,9 @@ const bulkForm = reactive({
totalScore: 100,
levelType: 'percent' as GradeLevelType,
});
const bulkRows = ref<Array<{ studentId: string; name: string; studentNo: string; score: number | null; level: string }>>([]);
const bulkRows = ref<
Array<{ studentId: string; name: string; studentNo: string; score: number | null; level: string }>
>([]);
function openBulk(): void {
if (students.value.length === 0) {
@@ -288,35 +321,42 @@ function openBulk(): void {
bulkVisible.value = true;
}
const submitBulkSaving = ref(false);
async function submitBulk(): Promise<void> {
const items = bulkRows.value
.filter((row) => row.score !== null && row.score !== undefined)
.map((row) => ({
studentId: row.studentId,
score: Number(row.score),
...(row.level.trim() ? { level: row.level.trim() } : {}),
}));
if (submitBulkSaving.value) return;
submitBulkSaving.value = true;
try {
const items = bulkRows.value
.filter((row) => row.score !== null && row.score !== undefined)
.map((row) => ({
studentId: row.studentId,
score: Number(row.score),
...(row.level.trim() ? { level: row.level.trim() } : {}),
}));
if (items.length === 0) {
ElMessage.warning('至少录入一位学生的成绩');
return;
}
if (!bulkForm.examName.trim()) {
ElMessage.warning('请填写考试名称');
return;
}
if (items.length === 0) {
ElMessage.warning('至少录入一位学生的成绩');
return;
}
if (!bulkForm.examName.trim()) {
ElMessage.warning('请填写考试名称');
return;
}
const result = await gradeApi.bulkCreate({
classId: filter.classId,
courseId: await resolveCourseId(bulkForm.courseId),
examName: bulkForm.examName.trim(),
totalScore: bulkForm.totalScore,
levelType: bulkForm.levelType,
items,
});
ElMessage.success(`已录入 ${result.count} 条成绩并广播到班级`);
bulkVisible.value = false;
await loadGrades();
const result = await gradeApi.bulkCreate({
classId: filter.classId,
courseId: await resolveCourseId(bulkForm.courseId),
examName: bulkForm.examName.trim(),
totalScore: bulkForm.totalScore,
levelType: bulkForm.levelType,
items,
});
ElMessage.success(`已录入 ${result.count} 条成绩并广播到班级`);
bulkVisible.value = false;
await loadGrades();
} finally {
submitBulkSaving.value = false;
}
}
/* ------------------------------------------------------------ 等级:单个 / 批量编辑 */
@@ -361,7 +401,9 @@ async function submitLevelBatch(): Promise<void> {
courseId: await resolveCourseId(levelBatchForm.courseId),
levelType: levelBatchForm.levelType,
});
ElMessage.success(`已把 ${result.updated} 条成绩改成「${GRADE_LEVEL_TYPE_LABELS[levelBatchForm.levelType]}」`);
ElMessage.success(
`已把 ${result.updated} 条成绩改成「${GRADE_LEVEL_TYPE_LABELS[levelBatchForm.levelType]}」`,
);
levelBatchVisible.value = false;
await loadGrades();
} finally {
@@ -486,8 +528,11 @@ onUnmounted(() => {
</div>
<el-alert type="info" :closable="false" style="margin-top: 8px">
成绩按<b>科目</b>授权:科任老师只能录入 / 修改 / 删除<b>自己任教科目</b>的成绩;
班主任若不教这一科,同样改不了这一科(不指定科目的内容归班主任与管理员)。
成绩按
<b>科目</b>
授权:科任老师只能录入 / 修改 / 删除
<b>自己任教科目</b>
的成绩; 班主任若不教这一科,同样改不了这一科(不指定科目的内容归班主任与管理员)。
</el-alert>
<!-- 考试口径:这次考试要不要把所有科目相加做总分排名(按「班级 + 考试名」记,
@@ -498,7 +543,8 @@ onUnmounted(() => {
<el-tag effect="plain">{{ filter.examName }}</el-tag>
<el-switch
:model-value="examCountInTotal"
:loading="examSaving"
:loading="examSaving || examLoading"
:disabled="examSaving || examLoading"
data-test="exam-count-in-total"
active-text="所有科目相加,排总分榜"
inactive-text="只排单科,不合总分"
@@ -564,7 +610,9 @@ onUnmounted(() => {
<el-table-column label="等级" width="180">
<template #default="{ row }">
<div class="level-cell">
<el-tag size="small" effect="plain">{{ GRADE_LEVEL_TYPE_LABELS[row.levelType as GradeLevelType] }}</el-tag>
<el-tag size="small" effect="plain">
{{ GRADE_LEVEL_TYPE_LABELS[row.levelType as GradeLevelType] }}
</el-tag>
<!-- 单个编辑:percent 口径下相当于手动覆盖自动换算的结果 -->
<el-select
v-if="canManageGrades"
@@ -580,7 +628,9 @@ onUnmounted(() => {
:value="item"
/>
</el-select>
<span v-else>{{ row.level || `${gradeLevel(gradePercent(row.score, row.totalScore))} 等` }}</span>
<span v-else>
{{ row.level || `${gradeLevel(gradePercent(row.score, row.totalScore))} 等` }}
</span>
</div>
</template>
</el-table-column>
@@ -610,7 +660,12 @@ onUnmounted(() => {
</el-form-item>
<el-form-item label="科目">
<el-select v-model="singleForm.courseId" clearable filterable style="width: 100%">
<el-option v-for="item in ownCourses" :key="item.id" :label="`${item.name}(${item.teacher?.name ?? ''})`" :value="item.id" />
<el-option
v-for="item in ownCourses"
:key="item.id"
:label="`${item.name}(${item.teacher?.name ?? ''})`"
:value="item.id"
/>
<el-option-group v-if="canCreateCourse && subjectOptions.length" label="统一科目(自动建课)">
<el-option
v-for="name in subjectOptions"
@@ -649,14 +704,19 @@ onUnmounted(() => {
style="width: 100%"
>
<el-option v-for="item in DEFAULT_LETTER_LEVELS" :key="item" :label="item" :value="item" />
<el-option v-for="item in ['优', '良', '合格', '待提高']" :key="item" :label="item" :value="item" />
<el-option
v-for="item in ['优', '良', '合格', '待提高']"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
</el-form>
<div class="dialog-hint">{{ GRADE_LEVEL_TYPE_HINTS[singleForm.levelType] }}</div>
<template #footer>
<el-button @click="singleVisible = false">取消</el-button>
<el-button type="primary" @click="submitSingle">录入并推送</el-button>
<el-button type="primary" :loading="submitSingleSaving" @click="submitSingle">录入并推送</el-button>
</template>
</el-dialog>
@@ -665,12 +725,7 @@ onUnmounted(() => {
<el-form :model="bulkForm" label-width="90px" inline>
<el-form-item label="科目">
<el-select v-model="bulkForm.courseId" clearable filterable style="width: 150px">
<el-option
v-for="item in ownCourses"
:key="item.id"
:label="item.name"
:value="item.id"
/>
<el-option v-for="item in ownCourses" :key="item.id" :label="item.name" :value="item.id" />
<el-option-group v-if="canCreateCourse && subjectOptions.length" label="统一科目(自动建课)">
<el-option
v-for="name in subjectOptions"
@@ -709,9 +764,21 @@ onUnmounted(() => {
</el-table-column>
<el-table-column label="等级(留空则自动换算)">
<template #default="{ row }">
<el-select v-model="row.level" size="small" allow-create filterable clearable style="width: 140px">
<el-select
v-model="row.level"
size="small"
allow-create
filterable
clearable
style="width: 140px"
>
<el-option v-for="item in DEFAULT_LETTER_LEVELS" :key="item" :label="item" :value="item" />
<el-option v-for="item in ['优', '良', '合格', '待提高']" :key="item" :label="item" :value="item" />
<el-option
v-for="item in ['优', '良', '合格', '待提高']"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</template>
</el-table-column>
@@ -719,15 +786,17 @@ onUnmounted(() => {
<template #footer>
<el-button @click="bulkVisible = false">取消</el-button>
<el-button type="primary" @click="submitBulk">提交并广播</el-button>
<el-button type="primary" :loading="submitBulkSaving" @click="submitBulk">提交并广播</el-button>
</template>
</el-dialog>
<!-- 批量改等级:把一个考试某门课整批改成另一种等级口径 -->
<el-dialog v-model="levelBatchVisible" title="批量修改等级" width="520px">
<el-alert type="info" :closable="false" style="margin-bottom: 12px">
把某个考试某门课的成绩<b>整批</b>换成另一种等级口径。百分制会按得分率重新换算 A~E,
等级制 / 自定义则直接套用所选口径(原有等级文本保留,可再到表格里逐条微调)。
把某个考试某门课的成绩
<b>整批</b>
换成另一种等级口径。百分制会按得分率重新换算 A~E, 等级制 /
自定义则直接套用所选口径(原有等级文本保留,可再到表格里逐条微调)。
</el-alert>
<el-form :model="levelBatchForm" label-width="90px">
<el-form-item label="科目">
@@ -751,7 +820,7 @@ onUnmounted(() => {
<el-form-item label="改为">
<el-select v-model="levelBatchForm.levelType" style="width: 100%">
<el-option
v-for="type in GRADE_LEVEL_TYPES"
v-for="type in GRADE_LEVEL_TYPES.filter((item) => item !== 'custom')"
:key="type"
:label="GRADE_LEVEL_TYPE_LABELS[type]"
:value="type"
+108 -95
View File
@@ -44,7 +44,7 @@ const current = ref<HomeworkDto | null>(null);
const attachmentHref = computed(() => {
const value = current.value?.attachmentUrl;
if (!value) return '';
return value.startsWith('/') || /^https?:\/\//i.test(value) ? value : '';
return (/^\/(?![\/\\])/.test(value) && !value.includes('\\')) || /^https?:\/\//i.test(value) ? value : '';
});
const classStudents = ref<Array<{ id: string; name: string }>>([]);
@@ -143,10 +143,15 @@ async function loadClasses(): Promise<void> {
}
async function loadCourses(): Promise<void> {
courses.value = filter.classId ? await courseApi.list(filter.classId) : [];
const classId = filter.classId;
courses.value = [];
const list = classId ? await courseApi.list(classId) : [];
if (classId === filter.classId) courses.value = list;
}
let homeworkRequest = 0;
async function loadHomeworks(): Promise<void> {
const request = ++homeworkRequest;
loading.value = true;
try {
const params: { classId?: string; courseId?: string; keyword?: string; date?: string } = {};
@@ -155,17 +160,17 @@ async function loadHomeworks(): Promise<void> {
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
// 按天查看:选了日期就只列那一天的作业
if (filter.date) params.date = filter.date;
homeworks.value = await homeworkApi.list(params);
const list = await homeworkApi.list(params);
if (request === homeworkRequest) homeworks.value = list;
} finally {
loading.value = false;
if (request === homeworkRequest) loading.value = false;
}
}
async function onClassChange(): Promise<void> {
homeworkRequest += 1;
filter.courseId = '';
await loadCourses();
await loadDayMarks();
await loadHomeworks();
await Promise.all([loadCourses(), loadDayMarks(), loadHomeworks()]);
}
/** 拉取"哪些天有作业"(日期选择器高亮);范围取今天前后各 45 天 */
@@ -238,17 +243,20 @@ const canCreateCourse = computed(() => {
});
async function loadFormCourses(): Promise<void> {
const classId = form.classId;
formCourses.value = [];
if (!form.classId) {
formCourses.value = [];
return;
}
formCoursesLoading.value = true;
try {
formCourses.value = await courseApi.list(form.classId);
const list = await courseApi.list(classId);
if (classId === form.classId) formCourses.value = list;
} catch {
formCourses.value = [];
if (classId === form.classId) formCourses.value = [];
} finally {
formCoursesLoading.value = false;
if (classId === form.classId) formCoursesLoading.value = false;
}
}
@@ -329,36 +337,43 @@ async function resolveCourseId(): Promise<string | null> {
}
}
const submitFormSaving = ref(false);
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
let courseId: string | null;
if (submitFormSaving.value) return;
submitFormSaving.value = true;
try {
courseId = await resolveCourseId();
} catch {
// 建课失败的提示已在 resolveCourseId 里给出,这里保持弹窗打开让老师改选
return;
}
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const payload = {
classId: form.classId,
courseId,
title: form.title.trim(),
content: form.content,
attachmentUrl: form.attachmentUrl.trim() || null,
assignDate: form.assignDate || dayKeyLocal(new Date()),
};
let courseId: string | null;
try {
courseId = await resolveCourseId();
} catch {
// 建课失败的提示已在 resolveCourseId 里给出,这里保持弹窗打开让老师改选
return;
}
if (editingId.value) {
await homeworkApi.update(editingId.value, payload);
ElMessage.success('作业已更新');
} else {
await homeworkApi.create(payload);
ElMessage.success('作业已发布,实时推送已发出');
const payload = {
classId: form.classId,
courseId,
title: form.title.trim(),
content: form.content,
attachmentUrl: form.attachmentUrl.trim() || null,
assignDate: form.assignDate || dayKeyLocal(new Date()),
};
if (editingId.value) {
await homeworkApi.update(editingId.value, payload);
ElMessage.success('作业已更新');
} else {
await homeworkApi.create(payload);
ElMessage.success('作业已发布,实时推送已发出');
}
formVisible.value = false;
await loadHomeworks();
} finally {
submitFormSaving.value = false;
}
formVisible.value = false;
await loadHomeworks();
}
/* ------------------------------------------------------------------ 批量布置(多班同发) */
@@ -396,51 +411,58 @@ function classNameOf(classId: string): string {
return classes.value.find((item) => item.id === classId)?.name ?? classId;
}
const submitBulkSaving = ref(false);
async function submitBulk(): Promise<void> {
if (bulkForm.classIds.length === 0) {
ElMessage.warning('请至少选择一个目标班级');
return;
}
if (!bulkForm.title.trim()) {
ElMessage.warning('请输入作业标题');
return;
}
if (!bulkForm.content.trim()) {
ElMessage.warning('请输入作业内容');
return;
}
bulkSending.value = true;
if (submitBulkSaving.value) return;
submitBulkSaving.value = true;
try {
const result = await homeworkApi.bulkCreate({
classIds: bulkForm.classIds,
subjectName: bulkForm.subjectName || null,
title: bulkForm.title.trim(),
content: bulkForm.content,
assignDate: bulkForm.assignDate || null,
attachmentUrl: bulkForm.attachmentUrl.trim() || null,
});
const failures = result.results.filter((item) => !item.ok);
if (failures.length === 0) {
ElMessage.success(`作业已发布到 ${result.succeeded} 个班级,实时推送已发出`);
} else {
const detail = failures
.map((item) => `${classNameOf(item.classId)}(${item.message ?? '失败'})`)
.join(';');
ElMessage({
type: result.succeeded > 0 ? 'warning' : 'error',
message: `成功 ${result.succeeded} 个班,失败 ${failures.length} 个班|${detail}`,
duration: 8000,
showClose: true,
});
if (bulkForm.classIds.length === 0) {
ElMessage.warning('请至少选择一个目标班级');
return;
}
if (!bulkForm.title.trim()) {
ElMessage.warning('请输入作业标题');
return;
}
if (!bulkForm.content.trim()) {
ElMessage.warning('请输入作业内容');
return;
}
// 全部失败时把弹窗留着,方便老师改科目/改班级后直接重试
bulkVisible.value = result.succeeded === 0;
if (result.succeeded > 0) await loadHomeworks();
bulkSending.value = true;
try {
const result = await homeworkApi.bulkCreate({
classIds: bulkForm.classIds,
subjectName: bulkForm.subjectName || null,
title: bulkForm.title.trim(),
content: bulkForm.content,
assignDate: bulkForm.assignDate || null,
attachmentUrl: bulkForm.attachmentUrl.trim() || null,
});
const failures = result.results.filter((item) => !item.ok);
if (failures.length === 0) {
ElMessage.success(`作业已发布到 ${result.succeeded} 个班级,实时推送已发出`);
} else {
const detail = failures
.map((item) => `${classNameOf(item.classId)}(${item.message ?? '失败'})`)
.join(';');
ElMessage({
type: result.succeeded > 0 ? 'warning' : 'error',
message: `成功 ${result.succeeded} 个班,失败 ${failures.length} 个班|${detail}`,
duration: 8000,
showClose: true,
});
}
// 全部失败时把弹窗留着,方便老师改科目/改班级后直接重试
bulkVisible.value = result.succeeded === 0;
if (result.succeeded > 0) await loadHomeworks();
} finally {
bulkSending.value = false;
}
} finally {
bulkSending.value = false;
submitBulkSaving.value = false;
}
}
@@ -572,12 +594,7 @@ onUnmounted(() => {
style="width: 100%"
>
<el-option-group v-if="ownFormCourses.length" label="我的科目">
<el-option
v-for="item in ownFormCourses"
:key="item.id"
:label="item.name"
:value="item.id"
/>
<el-option v-for="item in ownFormCourses" :key="item.id" :label="item.name" :value="item.id" />
</el-option-group>
<el-option-group
v-if="canCreateCourse && !formCoursesLoading && newSubjectOptions.length"
@@ -623,7 +640,9 @@ onUnmounted(() => {
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">{{ editingId ? '保存' : '发布' }}</el-button>
<el-button type="primary" :loading="submitFormSaving" @click="submitForm">
{{ editingId ? '保存' : '发布' }}
</el-button>
</template>
</el-dialog>
@@ -670,13 +689,7 @@ onUnmounted(() => {
<el-input v-model="bulkForm.title" maxlength="120" show-word-limit />
</el-form-item>
<el-form-item label="内容" required>
<el-input
v-model="bulkForm.content"
type="textarea"
:rows="5"
maxlength="5000"
show-word-limit
/>
<el-input v-model="bulkForm.content" type="textarea" :rows="5" maxlength="5000" show-word-limit />
</el-form-item>
<el-form-item label="附件链接">
<el-input v-model="bulkForm.attachmentUrl" placeholder="https://..." />
@@ -684,7 +697,7 @@ onUnmounted(() => {
</el-form>
<template #footer>
<el-button @click="bulkVisible = false">取消</el-button>
<el-button type="primary" :loading="bulkSending" @click="submitBulk">
<el-button type="primary" :loading="bulkSending || submitBulkSaving" @click="submitBulk">
发布到 {{ bulkForm.classIds.length }} 个班级
</el-button>
</template>
@@ -762,7 +775,11 @@ onUnmounted(() => {
description="该班还没有学生,请先在学生名单里添加"
/>
<el-checkbox-group v-model="notSubmittedIds">
<el-checkbox v-for="student in submissionStudents" :key="student.studentId" :value="student.studentId">
<el-checkbox
v-for="student in submissionStudents"
:key="student.studentId"
:value="student.studentId"
>
{{ student.name }}(学号 {{ student.studentNo }})
</el-checkbox>
</el-checkbox-group>
@@ -799,10 +816,6 @@ onUnmounted(() => {
font-size: 12px;
}
.text-danger {
color: #f56c6c;
}
.form-hint {
width: 100%;
font-size: 12px;
@@ -1,816 +0,0 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue';
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus';
import {
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
CLASSISLAND_NOTIFICATION_MAX_DURATION,
CLASS_HELPER_STATUS_REFRESH_SECONDS,
CLASSISLAND_TIME_STATE_LABELS,
NOTIFICATION_PRIORITIES,
PRIORITY_LABELS,
SOCKET_EVENTS,
formatDate,
relativeTime,
type ClassDto,
type ClassHelperStatusDto,
type ClassIslandNotificationChannel,
type ClassIslandStateEvent,
type IntegrationDeviceDto,
type NotificationPriority,
} from '@classhelper/shared';
import { classApi, integrationApi } from '@/api';
import { useResponsive } from '@/composables/useResponsive';
import { useRealtimeStore } from '@/stores/realtime';
/**
* ClassIsland 联动页。
*
* 两块内容:
* 1. **设备接入**:为每个班签发设备令牌(`chci_...`),老师把令牌填进教室机器的
* ClassIsland 插件设置,插件就会把该机课表/上课状态上报上来,并接收本页下发的提醒;
* 2. **下发提醒**:把一条提醒推到该班的 ClassIsland 上全屏弹出(ClassHelper 班级端通知中心同时留档)。
*
* 权限与后端对齐:本页只对 ADMIN / TEACHER 开放(路由 meta.roles),
* 具体到"能不能管这个班"由后端 assertCanManageSchedule / assertCanPublishContent 判定。
*/
const realtime = useRealtimeStore();
const { isMobile } = useResponsive();
const loading = ref(false);
const classes = ref<ClassDto[]>([]);
const devices = ref<IntegrationDeviceDto[]>([]);
const filter = reactive({ classId: '' });
/* ------------------------------------------------------------ ClassHelper 班级端在线状态
*
* 需求「教师网页支持查看班级 ClassHelper 在线状态」的落点:
* 管理员看全部班级,普通教师看自己担任班主任的班级 ∪ 任课的班级(后端按 classScope 过滤)。
* 在线判定统一是"最近 60 秒内有心跳"(共享常量 CLASS_HELPER_ONLINE_WINDOW_MS),
* 与设备列表里那个 3 分钟容差的展示窗口是两回事 —— 这里给的是准确口径。
*/
const statusList = ref<ClassHelperStatusDto[]>([]);
const statusLoading = ref(false);
const statusServerTime = ref<string>('');
const statusWindowMs = ref(60_000);
let statusTimer: ReturnType<typeof setInterval> | null = null;
async function loadStatus(): Promise<void> {
statusLoading.value = true;
try {
const result = await integrationApi.classHelperStatus();
statusList.value = result.classes;
statusServerTime.value = result.serverTime;
statusWindowMs.value = result.onlineWindowMs;
} finally {
statusLoading.value = false;
}
}
/** 在线班级数 / 总数(卡片标题上的概览) */
const onlineClassCount = computed(() => statusList.value.filter((item) => item.online).length);
/**
* 距今多久(按**服务器时间**算,不用本机时钟)。
* 教室机器常年不校时,本机与服务器可能差几分钟,用它算"距今"会得出负数或虚高的值。
*/
function sinceServer(value: string | null): string {
if (!value) return '从未';
const base = statusServerTime.value ? new Date(statusServerTime.value).getTime() : Date.now();
const diff = base - new Date(value).getTime();
return relativeTime(new Date(Date.now() - diff).toISOString());
}
/** deviceId → 最近一次 socket 推送的实时状态(页面停留期间最新) */
const liveStates = ref<Record<string, ClassIslandStateEvent>>({});
/** 设备在线判定窗口:插件默认 60 秒上报一次,给到 3 分钟容差(网络抖动/息屏唤醒) */
const ONLINE_WINDOW_MS = 3 * 60 * 1000;
const nowTick = ref(Date.now());
let tickTimer: ReturnType<typeof setInterval> | null = null;
const filteredDevices = computed(() =>
filter.classId ? devices.value.filter((item) => item.classId === filter.classId) : devices.value,
);
/** 当前选中班级下在线(最近有上报)的设备数 */
const onlineCount = computed(() => filteredDevices.value.filter((item) => isOnline(item)).length);
function isOnline(device: IntegrationDeviceDto): boolean {
if (!device.lastSeenAt) return false;
return nowTick.value - new Date(device.lastSeenAt).getTime() < ONLINE_WINDOW_MS;
}
/** 班级名(列表未加载完时退回 classId,避免出现空白) */
function classNameOf(classId: string): string {
return classes.value.find((item) => item.id === classId)?.name ?? classId;
}
/** 该班的通知显示位置(由教室的 ClassHelper 客户端在设置页里选) */
function channelOf(classId: string): ClassIslandNotificationChannel {
return classes.value.find((item) => item.id === classId)?.notificationChannel ?? 'both';
}
function channelLabelOf(classId: string): string {
return CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[channelOf(classId)] ?? '两端都弹';
}
/** 下发提醒弹窗里选中班级的显示位置:为 client 时提示"本次不会到 ClassIsland" */
const notifyChannel = computed(() => channelOf(notifyForm.classId));
/**
* 一台设备当前展示用的状态。
*
* 优先用 socket 推来的实时值(页面停留期间的更新),没有就用设备记录里
* 最后一次上报的快照 —— 这样"刚进页面"也能立刻看到教室在上什么课。
*/
function liveOf(device: IntegrationDeviceDto): {
subject: string | null;
nextSubject: string | null;
timeState: string | null;
period: string;
classPlanLoaded: boolean;
} {
const live = liveStates.value[device.id];
const subject = live?.subject ?? device.currentSubject ?? null;
const nextSubject = live?.nextSubject ?? device.nextSubject ?? null;
const timeState = live?.timeState ?? device.currentTimeState ?? null;
const start = live?.periodStart ?? device.currentPeriodStart ?? null;
const end = live?.periodEnd ?? device.currentPeriodEnd ?? null;
return {
subject,
nextSubject,
timeState,
period: start && end ? `${start} - ${end}` : '—',
classPlanLoaded: live?.classPlanLoaded ?? device.classPlanLoaded,
};
}
/** 实时状态的中文说明(映射表来自 shared,三端共用一份口径) */
function timeStateLabel(timeState: string | null): string {
if (!timeState) return '未知';
return CLASSISLAND_TIME_STATE_LABELS[timeState] ?? timeState;
}
/* ------------------------------------------------------------------ 数据加载 */
async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
if (!filter.classId && classes.value.length > 0) filter.classId = classes.value[0]?.id ?? '';
}
async function loadDevices(): Promise<void> {
devices.value = await integrationApi.listDevices(filter.classId || undefined);
}
async function reload(): Promise<void> {
loading.value = true;
try {
await loadClasses();
await loadDevices();
} finally {
loading.value = false;
}
}
function onClassChange(): void {
// 切班级要重新拉一次:设备列表按班级过滤,缓存下来的旧列表会串班
void loadDevices();
}
/* ------------------------------------------------------------------ 设备管理 */
const createVisible = ref(false);
const createRef = ref<FormInstance>();
const createForm = reactive<{
classId: string;
name: string;
syncScheduleToServer: boolean;
mirrorScheduleToClassIsland: boolean;
}>({
classId: '',
name: '',
syncScheduleToServer: true,
mirrorScheduleToClassIsland: false,
});
const createRules: FormRules = {
classId: [{ required: true, message: '请选择班级', trigger: 'change' }],
};
/** 一次性令牌弹窗:明文只在创建/重置接口返回一次,必须让老师当场复制走 */
const tokenVisible = ref(false);
const tokenValue = ref('');
const tokenHint = ref('');
function openCreate(): void {
createForm.classId = filter.classId || classes.value[0]?.id || '';
createForm.name = '';
createForm.syncScheduleToServer = true;
createForm.mirrorScheduleToClassIsland = false;
createVisible.value = true;
}
async function submitCreate(): Promise<void> {
const valid = await createRef.value?.validate().catch(() => false);
if (!valid) return;
const result = await integrationApi.createDevice({
classId: createForm.classId,
name: createForm.name.trim() || undefined,
syncScheduleToServer: createForm.syncScheduleToServer,
mirrorScheduleToClassIsland: createForm.mirrorScheduleToClassIsland,
});
createVisible.value = false;
showToken(result.token, `设备「${result.device.name}」`);
ElMessage.success('设备已创建,请把令牌填入教室机器的插件设置');
await loadDevices();
}
function showToken(token: string, hint: string): void {
tokenValue.value = token;
tokenHint.value = hint;
tokenVisible.value = true;
}
async function copyToken(): Promise<void> {
try {
await navigator.clipboard.writeText(tokenValue.value);
ElMessage.success('令牌已复制');
} catch {
// 非 HTTPS / 浏览器拒绝剪贴板权限时的兜底:提示老师手动全选复制
ElMessage.warning('浏览器拒绝了剪贴板访问,请手动选中复制');
}
}
async function toggleEnabled(device: IntegrationDeviceDto): Promise<void> {
const next = !device.enabled;
await integrationApi.updateDevice(device.id, { enabled: next });
ElMessage.success(next ? '设备已启用' : '设备已停用(插件上报与下发会被拒绝)');
await loadDevices();
}
async function toggleMirror(device: IntegrationDeviceDto): Promise<void> {
const next = !device.mirrorScheduleToClassIsland;
await integrationApi.updateDevice(device.id, { mirrorScheduleToClassIsland: next });
ElMessage.success(next ? '已开启:插件会把本班课表镜像回 ClassIsland' : '已关闭课表镜像');
await loadDevices();
}
/**
* 「从教室机器获取课表」——**现在取教室课表的唯一入口**。
*
* 自动回传已在 2026-10-06 起默认关闭:它会在老师手排课之后被教室的旧课表悄悄覆盖。
* 因此这里做成一次显式的人工动作,并且**点之前必须弹警告**说明后果。
*/
async function pullScheduleFromDevice(device: IntegrationDeviceDto): Promise<void> {
const confirmed = await ElMessageBox.confirm(
[
`将从「${device.name}」读取它当前的课表,并覆盖 / 合并到本班的课表。`,
'',
'本班在 ClassHelper 上手工排过的课可能被改动;教室里删掉或挪动的课会同步过来。',
'确认要从教室机器取一次课表吗?',
].join('\n'),
'从教室机器获取课表',
{ type: 'warning', confirmButtonText: '确认获取', cancelButtonText: '取消' },
).catch(() => null);
if (!confirmed) return;
const result = await integrationApi.requestSchedule(device.id);
ElMessage.success(result.nextHeartbeatHint);
await loadDevices();
}
async function resetToken(device: IntegrationDeviceDto): Promise<void> {
const confirmed = await ElMessageBox.confirm(
`重置后「${device.name}」的旧令牌立即失效,教室机器需要重新填写新令牌。确定继续?`,
'重置设备令牌',
{ type: 'warning', confirmButtonText: '重置令牌', cancelButtonText: '取消' },
).catch(() => false);
if (!confirmed) return;
const result = await integrationApi.resetToken(device.id);
showToken(result.token, `设备「${result.device.name}」`);
await loadDevices();
}
async function removeDevice(device: IntegrationDeviceDto): Promise<void> {
const confirmed = await ElMessageBox.confirm(
`删除后「${device.name}」将无法再上报课表或接收提醒(历史提醒记录保留)。确定删除?`,
'删除设备',
{ type: 'warning', confirmButtonText: '删除', cancelButtonText: '取消' },
).catch(() => false);
if (!confirmed) return;
await integrationApi.removeDevice(device.id);
ElMessage.success('设备已删除');
await loadDevices();
}
/* ------------------------------------------------------------------ 下发提醒 */
const notifyVisible = ref(false);
const notifyRef = ref<FormInstance>();
const notifyForm = reactive<{
classId: string;
title: string;
content: string;
durationSeconds: number;
speech: boolean;
speechContent: string;
saveToNotifications: boolean;
priority: NotificationPriority;
}>({
classId: '',
title: '',
content: '',
durationSeconds: CLASSISLAND_NOTIFICATION_DEFAULT_DURATION,
speech: false,
speechContent: '',
saveToNotifications: true,
priority: 'NORMAL',
});
const notifyRules: FormRules = {
classId: [{ required: true, message: '请选择目标班级', trigger: 'change' }],
title: [{ required: true, message: '请输入提醒标题', trigger: 'blur' }],
content: [{ required: true, message: '请输入提醒内容', trigger: 'blur' }],
};
const submitting = ref(false);
function openNotify(): void {
notifyForm.classId = filter.classId || classes.value[0]?.id || '';
notifyForm.title = '';
notifyForm.content = '';
notifyForm.durationSeconds = CLASSISLAND_NOTIFICATION_DEFAULT_DURATION;
notifyForm.speech = false;
notifyForm.speechContent = '';
notifyForm.saveToNotifications = true;
notifyForm.priority = 'NORMAL';
notifyVisible.value = true;
}
async function submitNotify(): Promise<void> {
const valid = await notifyRef.value?.validate().catch(() => false);
if (!valid) return;
submitting.value = true;
try {
const result = await integrationApi.notify({
classId: notifyForm.classId,
title: notifyForm.title.trim(),
content: notifyForm.content.trim(),
durationSeconds: notifyForm.durationSeconds,
speech: notifyForm.speech,
speechContent: notifyForm.speechContent.trim() || undefined,
saveToNotifications: notifyForm.saveToNotifications,
priority: notifyForm.priority,
});
// 先看服务端给的"为什么没送":教室选了「只在 ClassHelper 客户端显示」时也是
// targetCount=0,但原因与"没有接入设备"完全不同,提示错了会把老师引去查设备。
if (result.skipped === 'channel-client') {
ElMessage.warning(
'提醒已保存到通知中心,但该班教室已设置为「只在 ClassHelper 客户端显示」,本次未推送到 ClassIsland',
);
} else if (result.targetCount === 0) {
ElMessage.warning('提醒已保存,但该班级还没有已接入的 ClassIsland 设备');
} else {
ElMessage.success(`已下发到 ${result.delivered}/${result.targetCount} 台 ClassIsland 设备`);
}
notifyVisible.value = false;
} finally {
submitting.value = false;
}
}
/* ------------------------------------------------------------------ 实时通道 */
function handleState(payload: unknown): void {
const state = payload as ClassIslandStateEvent;
if (!state?.deviceId) return;
liveStates.value = { ...liveStates.value, [state.deviceId]: state };
}
onMounted(async () => {
await Promise.all([reload().catch(() => undefined), loadStatus().catch(() => undefined)]);
realtime.on(SOCKET_EVENTS.classislandState, handleState);
// 在线状态是"距今多久"的判断,需要定时重算,否则页面开着时间不会变
tickTimer = setInterval(() => {
nowTick.value = Date.now();
}, 30_000);
// ClassHelper 班级端在线状态另有自动刷新(需求:支持手动刷新和自动刷新)
statusTimer = setInterval(() => void loadStatus(), CLASS_HELPER_STATUS_REFRESH_SECONDS * 1000);
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.classislandState, handleState);
if (tickTimer) clearInterval(tickTimer);
tickTimer = null;
if (statusTimer) clearInterval(statusTimer);
statusTimer = null;
});
</script>
<template>
<div class="page">
<div class="page-header">
<div>
<h2 class="page-title">ClassHelper 联动</h2>
<p class="page-subtitle">
教室机器装上「ClassHelper 联动」插件后:课表与上课状态会自动上报到这里,这里下发的提醒会在 ClassIsland
上全屏弹出
</p>
</div>
<div class="toolbar">
<el-select
v-model="filter.classId"
placeholder="全部班级"
clearable
style="width: 170px"
@change="onClassChange"
>
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
<el-tag v-if="filteredDevices.length > 0" :type="onlineCount > 0 ? 'success' : 'info'" effect="light">
在线 {{ onlineCount }} / {{ filteredDevices.length }}
</el-tag>
<el-button :icon="'Refresh'" :loading="loading" @click="reload">刷新</el-button>
<el-button :icon="'Bell'" type="warning" @click="openNotify">下发提醒</el-button>
<el-button :icon="'Plus'" type="primary" @click="openCreate">接入新设备</el-button>
</div>
</div>
<!-- ClassHelper 班级端在线状态(管理员看全部班级;教师看自己班主任/任课的班级) -->
<el-card shadow="never" class="mb-16">
<template #header>
<div class="status-header">
<span>
ClassHelper 班级端在线状态
<el-tag size="small" :type="onlineClassCount > 0 ? 'success' : 'info'" effect="light" class="ml-8">
在线 {{ onlineClassCount }} / {{ statusList.length }} 个班
</el-tag>
</span>
<div class="toolbar">
<span class="text-muted">
判定口径:最近 {{ Math.round(statusWindowMs / 1000) }} 秒内有心跳即为在线 ·
每 {{ CLASS_HELPER_STATUS_REFRESH_SECONDS }} 秒自动刷新
</span>
<el-button size="small" :icon="'Refresh'" :loading="statusLoading" @click="loadStatus">
手动刷新
</el-button>
</div>
</div>
</template>
<el-table :data="statusList" size="small" empty-text="还没有接入 ClassHelper 班级端" max-height="320">
<el-table-column prop="className" label="班级" width="140" />
<el-table-column label="ClassHelper 设备" min-width="180">
<template #default="{ row }">
<template v-if="row.devices.length">
<div v-for="device in row.devices" :key="device.deviceId" class="device-line">
<span>{{ device.deviceName }}</span>
<span class="text-muted device-key">{{ device.deviceKey }}</span>
</div>
</template>
<span v-else class="text-muted">未接入</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag size="small" :type="row.online ? 'success' : 'danger'" effect="light">
{{ row.online ? '在线' : '离线' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="最后在线" width="140">
<template #default="{ row }">
{{ sinceServer(row.devices[0]?.lastSeenAt ?? null) }}
</template>
</el-table-column>
<el-table-column label="最后心跳" width="140">
<template #default="{ row }">
{{ sinceServer(row.devices[0]?.lastHeartbeatAt ?? null) }}
</template>
</el-table-column>
<el-table-column label="版本" min-width="160">
<template #default="{ row }">
<span v-if="row.devices[0]" class="text-muted">
插件 {{ row.devices[0].pluginVersion ?? '-' }} · ClassIsland
{{ row.devices[0].classIslandVersion ?? '-' }}
</span>
<span v-else class="text-muted">-</span>
</template>
</el-table-column>
<el-table-column label="班级端查成绩" width="130">
<template #default="{ row }">
<el-tag size="small" :type="row.studentGradeQueryEnabled ? 'success' : 'info'" effect="plain">
{{ row.studentGradeQueryEnabled ? '已开放' : '已关闭' }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
<el-alert
v-if="filteredDevices.length === 0"
type="info"
show-icon
:closable="false"
title="还没有接入 ClassIsland 设备"
description="在教室机器的 ClassIsland 上安装「ClassHelper 联动」插件并启用,然后点右上角「接入新设备」生成设备令牌,填入插件的「设置 → ClassHelper 联动」即可。"
/>
<template v-else>
<div class="device-grid">
<el-card v-for="device in filteredDevices" :key="device.id" shadow="never">
<div class="device-head">
<div>
<div class="device-name">{{ device.name }}</div>
<div class="device-sub">{{ classNameOf(device.classId) }}</div>
</div>
<el-tag :type="isOnline(device) ? 'success' : 'info'" effect="light">
{{ isOnline(device) ? '在线' : '离线' }}
</el-tag>
</div>
<el-descriptions :column="1" size="small" border>
<el-descriptions-item label="当前状态">
{{ timeStateLabel(liveOf(device).timeState) }}
</el-descriptions-item>
<el-descriptions-item label="正在上">
{{ liveOf(device).subject ?? (liveOf(device).classPlanLoaded ? '空课' : '未加载课表') }}
</el-descriptions-item>
<el-descriptions-item label="下一节">
{{ liveOf(device).nextSubject ?? '—' }}
</el-descriptions-item>
<el-descriptions-item label="当前节次">
{{ liveOf(device).period }}
</el-descriptions-item>
<el-descriptions-item label="最后上报">
{{ device.lastSeenAt ? relativeTime(device.lastSeenAt) : '从未上报' }}
</el-descriptions-item>
<el-descriptions-item label="提醒显示">
{{ channelLabelOf(device.classId) }}
</el-descriptions-item>
</el-descriptions>
</el-card>
</div>
<el-card shadow="never" class="table-card">
<el-table v-loading="loading" :data="filteredDevices" empty-text="暂无联动设备">
<el-table-column label="设备" min-width="180">
<template #default="{ row }">
<div>{{ row.name }}</div>
<div class="cell-sub">{{ classNameOf(row.classId) }}</div>
</template>
</el-table-column>
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'danger'" effect="light">
{{ row.enabled ? '已接入' : '已停用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="版本" width="170">
<template #default="{ row }">
<div class="cell-sub">插件 {{ row.pluginVersion ?? '—' }}</div>
<div class="cell-sub">ClassIsland {{ row.classIslandVersion ?? '—' }}</div>
</template>
</el-table-column>
<el-table-column label="回传课表" width="110">
<template #default="{ row }">
<el-tag :type="row.syncScheduleToServer ? 'success' : 'info'" effect="plain">
{{ row.syncScheduleToServer ? '允许' : '禁止' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="镜像课表" width="110">
<template #default="{ row }">
<el-tag :type="row.mirrorScheduleToClassIsland ? 'success' : 'info'" effect="plain">
{{ row.mirrorScheduleToClassIsland ? '开启' : '关闭' }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="tokenHint" label="令牌" width="150" />
<el-table-column label="最后上报" width="140">
<template #default="{ row }">
{{ row.lastSeenAt ? relativeTime(row.lastSeenAt) : '从未' }}
</template>
</el-table-column>
<el-table-column label="创建时间" width="170">
<template #default="{ row }">{{ formatDate(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="360" fixed="right">
<template #default="{ row }">
<el-button link type="primary" @click="toggleEnabled(row)">
{{ row.enabled ? '停用' : '启用' }}
</el-button>
<el-button link type="primary" @click="toggleMirror(row)">
{{ row.mirrorScheduleToClassIsland ? '关镜像' : '开镜像' }}
</el-button>
<el-button link type="success" @click="pullScheduleFromDevice(row)">获取课表</el-button>
<el-button link type="warning" @click="resetToken(row)">重置令牌</el-button>
<el-button link type="danger" @click="removeDevice(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</template>
<!-- 接入新设备 -->
<el-dialog
v-model="createVisible"
title="接入新设备"
:width="isMobile ? '92%' : '520px'"
destroy-on-close
>
<el-form ref="createRef" :model="createForm" :rules="createRules" label-width="110px">
<el-form-item label="班级" prop="classId">
<el-select v-model="createForm.classId" placeholder="选择班级" style="width: 100%">
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="设备名称">
<el-input v-model="createForm.name" placeholder="留空则用「班级名 + ClassIsland 设备」" />
</el-form-item>
<el-form-item label="回传课表">
<el-switch v-model="createForm.syncScheduleToServer" />
<span class="form-hint">允许插件把 ClassIsland 的课表同步到本系统</span>
</el-form-item>
<el-form-item label="镜像课表">
<el-switch v-model="createForm.mirrorScheduleToClassIsland" />
<span class="form-hint">允许把本班课表写回 ClassIsland(新建档案课表,不改老师原有课表)</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" @click="submitCreate">生成设备令牌</el-button>
</template>
</el-dialog>
<!-- 一次性令牌 -->
<el-dialog v-model="tokenVisible" title="设备令牌(只显示这一次)" :width="isMobile ? '92%' : '560px'">
<el-alert
type="warning"
show-icon
:closable="false"
title="请立即复制并填入插件设置"
description="服务端只保存令牌的哈希,关闭本窗口后无法再查看明文;只能重置(旧令牌立即失效)。"
/>
<el-input v-model="tokenValue" readonly type="textarea" :rows="3" class="token-box" />
<p class="form-hint">{{ tokenHint }} 的令牌</p>
<template #footer>
<el-button @click="tokenVisible = false">关闭</el-button>
<el-button type="primary" :icon="'CopyDocument'" @click="copyToken">复制令牌</el-button>
</template>
</el-dialog>
<!-- 下发提醒 -->
<el-dialog
v-model="notifyVisible"
title="下发提醒到 ClassIsland"
:width="isMobile ? '92%' : '600px'"
destroy-on-close
>
<el-alert
v-if="notifyChannel === 'client'"
type="info"
show-icon
:closable="false"
class="notify-channel-alert"
title="该班教室已设置为「只在 ClassHelper 客户端显示」"
description="本次提醒不会推送到 ClassIsland。如需改变,请在教室机器的 ClassHelper 客户端「设置 → 通知显示位置」里调整。"
/>
<el-form ref="notifyRef" :model="notifyForm" :rules="notifyRules" label-width="110px">
<el-form-item label="目标班级" prop="classId">
<el-select v-model="notifyForm.classId" placeholder="选择班级" style="width: 100%">
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="标题" prop="title">
<el-input
v-model="notifyForm.title"
maxlength="120"
show-word-limit
placeholder="例如:交作业提醒"
/>
</el-form-item>
<el-form-item label="内容" prop="content">
<el-input
v-model="notifyForm.content"
type="textarea"
:rows="3"
maxlength="2000"
show-word-limit
placeholder="会显示在 ClassIsland 的提醒正文里"
/>
</el-form-item>
<el-form-item label="显示时长">
<el-input-number
v-model="notifyForm.durationSeconds"
:min="1"
:max="CLASSISLAND_NOTIFICATION_MAX_DURATION"
:step="1"
/>
<span class="form-hint">秒(1 ~ {{ CLASSISLAND_NOTIFICATION_MAX_DURATION }})</span>
</el-form-item>
<el-form-item label="优先级">
<el-select v-model="notifyForm.priority" style="width: 100%">
<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" />
<span class="form-hint">按教室机器的 ClassIsland 语音设置朗读</span>
</el-form-item>
<el-form-item v-if="notifyForm.speech" label="朗读内容">
<el-input
v-model="notifyForm.speechContent"
maxlength="500"
placeholder="留空则朗读「标题 + 内容」"
/>
</el-form-item>
<el-form-item label="同步通知中心">
<el-switch v-model="notifyForm.saveToNotifications" />
<span class="form-hint">同时在班级通知中心留一条(ClassHelper 班级端也能事后查看)</span>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="notifyVisible = false">取消</el-button>
<el-button type="primary" :loading="submitting" @click="submitNotify">立即下发</el-button>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.status-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.device-line {
display: flex;
align-items: baseline;
gap: 8px;
}
.device-key {
font-size: 12px;
}
.device-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 14px;
margin-bottom: 16px;
}
.device-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.device-name {
font-weight: 600;
}
.device-sub,
.cell-sub {
opacity: 0.65;
font-size: 12px;
}
.form-hint {
margin-left: 10px;
opacity: 0.65;
font-size: 12px;
}
.token-box {
margin-top: 12px;
}
.notify-channel-alert {
margin-bottom: 12px;
}
/* 手机小屏:卡片内的描述表在窄屏下会自动换行,这里只把间距收紧 */
@media (max-width: 768px) {
.device-grid {
grid-template-columns: 1fr;
}
}
</style>
@@ -41,8 +41,12 @@ const filter = reactive({ classId: '', priority: '', keyword: '', date: '', crea
*/
const days = ref<NotificationDaySummary[]>([]);
const creators = ref<NotificationCreatorSummary[]>([]);
let archiveRequest = 0;
let refreshRequest = 0;
let notificationRequest = 0;
async function loadArchive(): Promise<void> {
const request = ++archiveRequest;
const base = {
...(filter.classId ? { classId: filter.classId } : {}),
...(filter.priority ? { priority: filter.priority } : {}),
@@ -53,13 +57,19 @@ async function loadArchive(): Promise<void> {
.creators({ ...base, ...(filter.date ? { date: filter.date } : {}) })
.catch(() => ({ creators: [] as NotificationCreatorSummary[] })),
]);
if (request !== archiveRequest) return;
days.value = dayList.days;
creators.value = creatorList.creators;
if (filter.createdBy && !creatorList.creators.some((item) => item.id === filter.createdBy))
filter.createdBy = '';
}
/** 筛选变化后统一刷新:先更新归档候选,再拉当前筛选下的列表 */
async function refreshList(): Promise<void> {
const request = ++refreshRequest;
notificationRequest += 1;
await loadArchive();
if (request !== refreshRequest) return;
await loadNotifications();
}
@@ -74,10 +84,10 @@ const isSingleClass = computed(() => Boolean(filter.classId));
* NotificationDto 只有 readCount(已读人数),没有 unreadCount / reads,
* 已读率的分母只能自己算,所以进入页面时按班级各取一次学生名单长度。
*/
const classStudentCounts = ref<Record<string, number>>({});
const classStudentCounts = ref<Record<string, number | null>>({});
function classSizeOf(classId: string): number {
return classStudentCounts.value[classId] ?? 0;
function classSizeOf(classId: string): number | null {
return classStudentCounts.value[classId] ?? null;
}
async function loadClasses(): Promise<void> {
@@ -89,14 +99,15 @@ async function loadClasses(): Promise<void> {
async function loadClassStudentCounts(): Promise<void> {
const entries = await Promise.all(
classes.value.map(async (item) => {
const students = await classApi.students(item.id).catch(() => []);
return [item.id, students.length] as const;
const students = await classApi.students(item.id).catch(() => null);
return [item.id, students?.length ?? null] as const;
}),
);
classStudentCounts.value = Object.fromEntries(entries);
}
async function loadNotifications(): Promise<void> {
const request = ++notificationRequest;
loading.value = true;
try {
const params: {
@@ -111,9 +122,10 @@ async function loadNotifications(): Promise<void> {
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
if (filter.date) params.date = filter.date;
if (filter.createdBy) params.createdBy = filter.createdBy;
notifications.value = await notificationApi.list(params);
const list = await notificationApi.list(params);
if (request === notificationRequest) notifications.value = list;
} finally {
loading.value = false;
if (request === notificationRequest) loading.value = false;
}
}
@@ -232,29 +244,36 @@ async function openCreate(): Promise<void> {
* 判据只留一份,别在这里再写一遍)。
*/
const submitFormSaving = ref(false);
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (submitFormSaving.value) return;
submitFormSaving.value = true;
try {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
const classIds = [...form.classIds];
if (classIds.length === 0) {
ElMessage.warning('请至少选择一个目标班级');
return;
}
// 上课时段发布紧急通知:先查询所有目标班级的上课状态,任一命中则弹全屏二次确认(3 秒倒计时)
if (form.priority === 'URGENT') {
const hit = await findClassInSession(classIds);
if (hit) {
urgentWarning.period = hit.period;
urgentWarning.title = form.title.trim();
urgentWarning.classIds = classIds;
urgentWarning.visible = true;
const classIds = [...form.classIds];
if (classIds.length === 0) {
ElMessage.warning('请至少选择一个目标班级');
return;
}
}
await doPublish(classIds, false);
// 上课时段发布紧急通知:先查询所有目标班级的上课状态,任一命中则弹全屏二次确认(3 秒倒计时)
if (form.priority === 'URGENT') {
const hit = await findClassInSession(classIds);
if (hit) {
urgentWarning.period = hit.period;
urgentWarning.title = form.title.trim();
urgentWarning.classIds = classIds;
urgentWarning.visible = true;
return;
}
}
await doPublish(classIds, false);
} finally {
submitFormSaving.value = false;
}
}
/** 单个班级的发布失败信息(用于汇总提示) */
@@ -270,50 +289,57 @@ interface PublishFailure {
* 真正提交:调用服务端的**批量发布**端点(`POST /api/notifications/bulk`)一次提交,
* 服务端逐班做权限断言与上课保护,并把每个班的结果回给我们。
*
* 为什么不再由前端对每个班各发一次请求:N 个班就是 N 次往返,权限/上课保护/ClassIsland 推送
* 为什么不再由前端对每个班各发一次请求:N 个班就是 N 次往返,权限/上课保护
* 分散在 N 个响应里,前端还得自己合并;服务端批量端点把这些收敛成一份逐班结果(见 AGENTS.md §5)。
* confirmDuringClass 为 true 表示教师已在上课警告里确认。
*/
const doPublishSaving = ref(false);
async function doPublish(classIds: string[], confirmDuringClass: boolean): Promise<void> {
if (classIds.length === 0) return;
if (doPublishSaving.value) return;
doPublishSaving.value = true;
try {
if (classIds.length === 0) return;
const result = await notificationApi.bulkCreate({
classIds,
title: form.title.trim(),
content: form.content,
priority: form.priority,
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
});
const result = await notificationApi.bulkCreate({
classIds,
title: form.title.trim(),
content: form.content,
priority: form.priority,
...(confirmDuringClass ? { confirmDuringClass: true } : {}),
});
const successCount = result.succeeded;
const failures: PublishFailure[] = result.results
.filter((item) => !item.ok)
.map((item) => ({
classId: item.classId,
reason: item.message ?? '发布失败',
urgentConflict: item.code === 'URGENT_DURING_CLASS',
period: ((item.details ?? {}) as { current?: ClassPeriod | null }).current ?? null,
}));
const successCount = result.succeeded;
const failures: PublishFailure[] = result.results
.filter((item) => !item.ok)
.map((item) => ({
classId: item.classId,
reason: item.message ?? '发布失败',
urgentConflict: item.code === 'URGENT_DURING_CLASS',
period: ((item.details ?? {}) as { current?: ClassPeriod | null }).current ?? null,
}));
// 兜底:并发场景下服务端仍可能拦截(例如刚好上课铃响),对这批班级重新走一次二次确认
const conflicted = failures.filter((item) => item.urgentConflict);
const pendingConfirm = !confirmDuringClass && conflicted.length > 0;
if (pendingConfirm) {
urgentWarning.period = conflicted[0]?.period ?? null;
urgentWarning.title = form.title.trim();
urgentWarning.classIds = conflicted.map((item) => item.classId);
urgentWarning.visible = true;
}
// 兜底:并发场景下服务端仍可能拦截(例如刚好上课铃响),对这批班级重新走一次二次确认
const conflicted = failures.filter((item) => item.urgentConflict);
const pendingConfirm = !confirmDuringClass && conflicted.length > 0;
if (pendingConfirm) {
urgentWarning.period = conflicted[0]?.period ?? null;
urgentWarning.title = form.title.trim();
urgentWarning.classIds = conflicted.map((item) => item.classId);
urgentWarning.visible = true;
}
const reported = confirmDuringClass ? failures : failures.filter((item) => !item.urgentConflict);
reportPublishResult(successCount, reported);
const reported = confirmDuringClass ? failures : failures.filter((item) => !item.urgentConflict);
reportPublishResult(successCount, reported);
if (successCount > 0) {
// 有班级发布成功:关掉弹窗并刷新列表;只有"还有班级等着二次确认"时才保留弹窗内的数据。
// 注意这里是 pendingConfirm 而不是 !pendingConfirm —— 写成取反会让弹窗在**发布成功后仍然开着**,
// 老师以为没发出去再点一次「立即发布」,同一批班级就重复收到通知(还会重复广播与推 ClassIsland)。
formVisible.value = pendingConfirm;
await refreshList();
if (successCount > 0) {
// 有班级发布成功:关掉弹窗并刷新列表;只有"还有班级等着二次确认"时才保留弹窗内的数据。
// 注意这里是 pendingConfirm 而不是 !pendingConfirm —— 写成取反会让弹窗在**发布成功后仍然开着**,
// 老师以为没发出去再点一次「立即发布」,同一批班级就重复收到通知(还会重复广播)。
formVisible.value = pendingConfirm;
await refreshList();
}
} finally {
doPublishSaving.value = false;
}
}
@@ -379,6 +405,7 @@ function rowClassName({ row }: { row: NotificationDto }): string {
}
onMounted(async () => {
realtime.on(SOCKET_EVENTS.notificationDeleted, onNotificationEvent);
await loadClasses();
await refreshList();
realtime.on(SOCKET_EVENTS.notificationNew, onNotificationEvent);
@@ -390,6 +417,7 @@ onMounted(async () => {
});
onUnmounted(() => {
realtime.off(SOCKET_EVENTS.notificationDeleted, onNotificationEvent);
realtime.off(SOCKET_EVENTS.notificationNew, onNotificationEvent);
});
</script>
@@ -516,13 +544,18 @@ onUnmounted(() => {
<el-tag :type="(row.readCount ?? 0) > 0 ? 'success' : 'info'" size="small" effect="light">
{{ (row.readCount ?? 0) > 0 ? `已读 ${row.readCount} 人` : '未读' }}
</el-tag>
<div class="text-muted">共 {{ classSizeOf(row.classId) }} 人</div>
<div class="text-muted">
{{
classSizeOf(row.classId) === null ? '班级人数暂不可用' : `共 ${classSizeOf(row.classId)} 人`
}}
</div>
</template>
<!-- 跨班视图(未选具体班级):显示已读率百分比,分母按该通知所属班级算 -->
<template v-else>
<template v-else-if="classSizeOf(row.classId) !== null">
<el-progress :percentage="readRate(row)" :stroke-width="10" />
<span class="text-muted">{{ row.readCount ?? 0 }}/{{ classSizeOf(row.classId) }} 人</span>
</template>
<span v-else class="text-muted">已读 {{ row.readCount ?? 0 }} 人 · 班级人数暂不可用</span>
</template>
</el-table-column>
<el-table-column label="操作" width="90" fixed="right">
@@ -564,7 +597,7 @@ onUnmounted(() => {
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">立即发布</el-button>
<el-button type="primary" :loading="submitFormSaving" @click="submitForm">立即发布</el-button>
</template>
</el-dialog>
@@ -254,7 +254,7 @@ const assignDialog = reactive({
});
function openAssign(member: PermissionMemberDto): void {
if (member.fixed) return; // 管理员固定拥有全部权限(服务端同样 400)
if (member.fixed) return;
assignDialog.member = member;
assignDialog.groupIds = [...member.groupIds];
assignDialog.visible = true;
@@ -301,12 +301,10 @@ async function submitAssign(): Promise<void> {
(服务端 lib/permissions.ts 的 loadEffectivePermissions)。
-->
<el-alert class="perm-banner" type="info" :closable="false" show-icon>
<template #title>
权限生效规则:有权限组时取各组的并集;没有任何组时使用角色默认模板。
</template>
<template #title>权限生效规则:有权限组时取各组的并集;没有任何组时使用角色默认模板。</template>
<template #default>
当前账号:{{ auth.roleLabel }}
<template v-if="myPermissionCount !== null"> · 生效权限 {{ myPermissionCount }} 项</template>
<template v-if="myPermissionCount !== null">· 生效权限 {{ myPermissionCount }} 项</template>
· {{ auth.permissionsFromGroups ? '权限来自权限组' : '未分配权限组(走角色默认模板)' }}
</template>
</el-alert>
@@ -324,7 +322,9 @@ async function submitAssign(): Promise<void> {
<el-tab-pane label="权限组" name="groups">
<div class="toolbar tab-toolbar">
<el-button type="primary" :icon="'Plus'" @click="openCreateGroup">新建权限组</el-button>
<span class="text-muted">内置权限组由系统预置,可以改名与调整权限,但不能删除。</span>
<span class="text-muted">
默认权限组可改名与调整权限,修改后对未分组账号立即生效;内置组不能删除。
</span>
</div>
<el-card shadow="never" class="table-card">
@@ -345,7 +345,9 @@ async function submitAssign(): Promise<void> {
<el-table-column label="类型" width="100">
<template #default="{ row }">
<el-tag size="small" effect="plain" :type="row.builtin ? 'info' : 'primary'">
{{ row.builtin ? '内置' : '自定义' }}
{{
row.defaultRole ? `${roleLabel(row.defaultRole)}默认` : row.builtin ? '内置' : '自定义'
}}
</el-tag>
</template>
</el-table-column>
@@ -370,7 +372,7 @@ async function submitAssign(): Promise<void> {
<el-tab-pane label="成员" name="members">
<div class="toolbar tab-toolbar">
<span class="text-muted">
只列出管理员与教师账号;管理员固定拥有全部权限,不能被分配权限组。
只列出管理员与教师账号;管理员始终保留权限与系统管理入口,可调整默认组或分配其他权限组。
</span>
</div>
@@ -402,7 +404,7 @@ async function submitAssign(): Promise<void> {
</el-table-column>
<el-table-column label="操作" width="130">
<template #default="{ row }">
<el-tooltip :disabled="!row.fixed" content="管理员固定拥有全部权限" placement="top">
<el-tooltip :disabled="!row.fixed" content="管理员始终保留权限与系统管理入口" placement="top">
<span>
<el-button link type="primary" :disabled="row.fixed" @click="openAssign(row)">
分配权限组
@@ -469,7 +471,8 @@ async function submitAssign(): Promise<void> {
</div>
</div>
<p class="perm-note text-muted">
保存时会整体替换该组的权限键(取消勾选 = 收回这项权限);不填任何键则该组成员没有任何权限。
保存时会整体替换该组的权限键(取消勾选 =
收回这项权限);不填任何键即收回组内权限;管理员始终保留权限与系统管理两个入口。
</p>
</el-form-item>
</el-form>
+38 -31
View File
@@ -224,41 +224,48 @@ function openEdit(item: ScheduleDto): void {
formVisible.value = true;
}
const submitFormSaving = ref(false);
async function submitForm(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (form.startTime >= form.endTime) {
ElMessage.warning('结束时间必须晚于开始时间');
return;
}
if (submitFormSaving.value) return;
submitFormSaving.value = true;
try {
const valid = await formRef.value?.validate().catch(() => false);
if (!valid) return;
if (form.startTime >= form.endTime) {
ElMessage.warning('结束时间必须晚于开始时间');
return;
}
const courseId = await resolveCourseId();
if (!courseId) {
ElMessage.warning('请选择科目');
return;
}
const courseId = await resolveCourseId();
if (!courseId) {
ElMessage.warning('请选择科目');
return;
}
const payload: CreateScheduleRequest = {
classId: query.classId,
courseId,
dayOfWeek: Number(form.dayOfWeek),
startTime: form.startTime,
endTime: form.endTime,
location: form.location || null,
weekStart: Number(form.weekStart),
weekEnd: Number(form.weekEnd),
weekParity: form.weekParity,
};
const payload: CreateScheduleRequest = {
classId: query.classId,
courseId,
dayOfWeek: Number(form.dayOfWeek),
startTime: form.startTime,
endTime: form.endTime,
location: form.location || null,
weekStart: Number(form.weekStart),
weekEnd: Number(form.weekEnd),
weekParity: form.weekParity,
};
if (editingId.value) {
await scheduleApi.update(editingId.value, payload);
ElMessage.success('课表已更新');
} else {
await scheduleApi.create(payload);
ElMessage.success('课表已新增');
if (editingId.value) {
await scheduleApi.update(editingId.value, payload);
ElMessage.success('课表已更新');
} else {
await scheduleApi.create(payload);
ElMessage.success('课表已新增');
}
formVisible.value = false;
await Promise.all([loadCourses(), loadSchedules()]);
} finally {
submitFormSaving.value = false;
}
formVisible.value = false;
await Promise.all([loadCourses(), loadSchedules()]);
}
async function removeSchedule(item: ScheduleDto): Promise<void> {
@@ -446,7 +453,7 @@ onUnmounted(() => {
</el-form>
<template #footer>
<el-button @click="formVisible = false">取消</el-button>
<el-button type="primary" @click="submitForm">保存</el-button>
<el-button type="primary" :loading="submitFormSaving" @click="submitForm">保存</el-button>
</template>
</el-dialog>
+26 -8
View File
@@ -30,16 +30,19 @@ async function loadClasses(): Promise<void> {
classes.value = await classApi.list();
}
let studentRequest = 0;
async function loadStudents(): Promise<void> {
const request = ++studentRequest;
loading.value = true;
try {
const params: { classId?: string; keyword?: string; status?: StudentStatus } = {};
if (filter.classId) params.classId = filter.classId;
if (filter.keyword.trim()) params.keyword = filter.keyword.trim();
if (filter.status) params.status = filter.status;
students.value = await studentApi.list(params);
const list = await studentApi.list(params);
if (request === studentRequest) students.value = list;
} finally {
loading.value = false;
if (request === studentRequest) loading.value = false;
}
}
@@ -263,7 +266,9 @@ async function submitCall(): Promise<void> {
<div>
<h2 class="page-title">学生管理</h2>
<p class="page-subtitle">
学生是班级名单里的记录:<b>没有个人账号、不能登录</b>,学号是唯一标识;教室机器用班级码 + 班级密码登录
学生是班级名单里的记录:
<b>没有个人账号、不能登录</b>
,学号是唯一标识;教室机器用班级码 + 班级密码登录
</p>
</div>
<div class="toolbar">
@@ -316,7 +321,8 @@ async function submitCall(): Promise<void> {
:disabled="selection.length === 0"
@click="openTransfer(selection)"
>
批量调班<span v-if="selection.length">({{ selection.length }})</span>
批量调班
<span v-if="selection.length">({{ selection.length }})</span>
</el-button>
<el-button
type="danger"
@@ -325,7 +331,8 @@ async function submitCall(): Promise<void> {
:disabled="selection.length === 0"
@click="transferOut(selection)"
>
批量转出<span v-if="selection.length">({{ selection.length }})</span>
批量转出
<span v-if="selection.length">({{ selection.length }})</span>
</el-button>
</div>
</div>
@@ -340,7 +347,9 @@ async function submitCall(): Promise<void> {
<el-table-column prop="studentNo" label="学号" width="130" />
<el-table-column prop="name" label="姓名" width="110" />
<el-table-column label="性别" width="80">
<template #default="{ row }">{{ STUDENT_GENDER_LABELS[row.gender as '' | 'MALE' | 'FEMALE'] }}</template>
<template #default="{ row }">
{{ STUDENT_GENDER_LABELS[row.gender as '' | 'MALE' | 'FEMALE'] }}
</template>
</el-table-column>
<el-table-column label="班级" width="140">
<template #default="{ row }">
@@ -384,12 +393,21 @@ async function submitCall(): Promise<void> {
<span>{{ transferTargets.length }} 名({{ transferTargets.map((s) => s.name).join('、') }})</span>
</el-form-item>
<el-form-item label="调入班级">
<el-select v-model="transferForm.toClassId" placeholder="留空 = 移出班级(未分班)" clearable style="width: 100%">
<el-select
v-model="transferForm.toClassId"
placeholder="留空 = 移出班级(未分班)"
clearable
style="width: 100%"
>
<el-option v-for="item in classOptions" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="transferForm.note" maxlength="200" placeholder="例如:按分班考试成绩调整(可选)" />
<el-input
v-model="transferForm.note"
maxlength="200"
placeholder="例如:按分班考试成绩调整(可选)"
/>
</el-form-item>
</el-form>
<template #footer>
+44 -11
View File
@@ -16,6 +16,8 @@ import { computed, onMounted, ref } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
MAX_TERM_WEEK,
validateTermWeeks,
isDayKey,
type ClassDto,
type HolidaySuggestionDto,
type TermWeekDto,
@@ -33,7 +35,9 @@ const scope = ref('');
const weeks = ref<TermWeekDto[]>([]);
const termStartDate = ref('');
const scopeLabel = computed(() => (scope.value ? (classes.value.find((item) => item.id === scope.value)?.name ?? scope.value) : '全校默认'));
const scopeLabel = computed(() =>
scope.value ? (classes.value.find((item) => item.id === scope.value)?.name ?? scope.value) : '全校默认',
);
/** 有没有改动(保存按钮的可用状态) */
const dirty = computed(() => {
@@ -91,6 +95,13 @@ async function autoGenerate(): Promise<void> {
}
async function save(): Promise<void> {
if (saving.value) return;
const errors = validateTermWeeks(weeks.value);
if (!isDayKey(termStartDate.value)) errors.unshift('请填写有效的学期开始日期');
if (errors.length) {
ElMessage.warning(errors[0]!);
return;
}
if (weeks.value.length === 0) {
await ElMessageBox.confirm(
'当前没有逐周区间,保存后会回到"按学期开始日期线性推算"的方式。确认继续?',
@@ -163,12 +174,19 @@ onMounted(async () => {
<div>
<h2 class="page-title">学期周次</h2>
<p class="page-subtitle">
逐周指定实际日期区间(调休、周末补课、错峰开学都能表达)。
保存后会随课表<b>下发给教室的 ClassHelper 班级端</b>,让教室里的"第几周"与这里一致
逐周指定实际日期区间(调休、周末补课、错峰开学都能表达)。 保存后会随课表
<b>下发给教室的 ClassHelper 班级端</b>
,让教室里的"第几周"与这里一致
</p>
</div>
<div class="toolbar">
<el-select v-model="scope" placeholder="全校默认" clearable style="width: 200px" @change="onScopeChange">
<el-select
v-model="scope"
placeholder="全校默认"
clearable
style="width: 200px"
@change="onScopeChange"
>
<el-option label="全校默认(所有班级)" value="" />
<el-option v-for="item in classes" :key="item.id" :label="item.name" :value="item.id" />
</el-select>
@@ -184,7 +202,7 @@ onMounted(async () => {
<template #title>
当前配置范围:{{ scopeLabel }} · 共 {{ weeks.length }} 周
<span v-if="term">(当前第 {{ term.currentWeek }} 教学周,学期周数上限 {{ term.maxWeek }})</span>
<span v-if="term && !term.configured"> · 尚未逐周配置,按学期开始日期线性推算</span>
<span v-if="term && !term.configured">· 尚未逐周配置,按学期开始日期线性推算</span>
</template>
<template #default>
留空某个班时,该班使用「全校默认」。班里有特殊安排(例如整周外出实习)时,再单独为它配一遍。
@@ -201,9 +219,7 @@ onMounted(async () => {
placeholder="第 1 教学周的周一"
style="width: 200px"
/>
<el-button type="primary" plain :loading="saving" @click="autoGenerate">
按开学日期生成
</el-button>
<el-button type="primary" plain :loading="saving" @click="autoGenerate">按开学日期生成</el-button>
<span class="hint">先生成一份标准学期,再把调休那几周改掉 —— 比一周周填快得多</span>
</div>
</el-card>
@@ -213,7 +229,13 @@ onMounted(async () => {
<template #header>
<span>{{ holiday.year }} 年放假安排(参考,不会自动改周次)</span>
</template>
<el-tag v-for="item in holiday.holidays" :key="item.startDate" size="small" effect="plain" class="holiday-tag">
<el-tag
v-for="item in holiday.holidays"
:key="item.startDate"
size="small"
effect="plain"
class="holiday-tag"
>
{{ item.name }} {{ item.startDate }}
</el-tag>
</el-card>
@@ -229,7 +251,13 @@ onMounted(async () => {
<el-table :data="weeks" empty-text="还没有逐周配置,可点「按开学日期生成」" max-height="560">
<el-table-column label="教学周" width="110">
<template #default="{ row }">
<el-input-number v-model="row.weekNumber" :min="1" :max="60" size="small" controls-position="right" />
<el-input-number
v-model="row.weekNumber"
:min="1"
:max="60"
size="small"
controls-position="right"
/>
</template>
</el-table-column>
<el-table-column label="日期区间" width="320">
@@ -256,7 +284,12 @@ onMounted(async () => {
</el-table-column>
<el-table-column label="备注" min-width="180">
<template #default="{ row }">
<el-input v-model="row.note" size="small" maxlength="64" placeholder="例如:国庆调休、期中考试周" />
<el-input
v-model="row.note"
size="small"
maxlength="64"
placeholder="例如:国庆调休、期中考试周"
/>
</template>
</el-table-column>
<el-table-column label="操作" width="90">