feat(integrations,classisland,server,web,desktop): ClassIsland 联动插件——上报课表与上课状态、下发提醒并回执、教室端推送通道
教室机器上的 ClassIsland 现在可以直接和班级小助手互通:插件走 /api/integrations/classisland/*,用设备令牌(X-ClassIsland-Token,库里只存 sha256)独立鉴权,不占用 JWT 通道。 - 服务端:新增 integrations 模块(上报 / 待弹出 / 回执 / 课表镜像 / 回写),两个迁移(设备令牌表、Class.notificationChannel);通知、叫人、联动页下发统一复用 pushToClassIsland,避免"某个页面发的通知不联动" - 插件:packages/classisland-plugin(.NET 8,独立于 pnpm workspace)——BridgeService 上报与提醒轮询、ScheduleMapper/ClassPlanWriter 课表镜像、Avalonia 设置页、ClassHelperNotificationProvider 在 ClassIsland 上弹出提醒;本地/内网地址强制直连,躲开系统代理返回的假 404 - 客户端:设置页可选提醒通道(both / client / classisland),并订阅相应实时事件 - Web 端:新增「ClassIsland 联动」页(设备管理与提醒下发) - 校验与打包:verify:classisland(需后端)、verify:classisland-plugin(静态契约 54 项)、build/dist:classisland-plugin 产出 .cipx - 文档:README 与 AGENTS.md 同步新增联动说明,docs/screenshots/classisland 留三张真机截图 门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 54/54 通过。运行期仍需在装了 ClassIsland 的机器上实测(步骤见 AGENTS.md §8)。
This commit is contained in:
1 parent
fe41ebb7d6
commit
d0830f6830
61 files changed
+7026
-93
No files matched your search
@@ -11,6 +11,7 @@ import {
|
||||
type CreateCourseRequest,
|
||||
type CreateGradeRequest,
|
||||
type CreateHomeworkRequest,
|
||||
type CreateIntegrationDeviceRequest,
|
||||
type CreateNotificationRequest,
|
||||
type CreateScheduleRequest,
|
||||
type DashboardSummary,
|
||||
@@ -18,11 +19,15 @@ import {
|
||||
type GradeStats,
|
||||
type HomeworkDto,
|
||||
type HomeworkSubmissionsDto,
|
||||
type IntegrationDeviceDto,
|
||||
type IntegrationDeviceTokenDto,
|
||||
type LoginRequest,
|
||||
type LoginResponse,
|
||||
type NotificationDto,
|
||||
type ScheduleDto,
|
||||
type ScheduleWeekView,
|
||||
type SendClassIslandNotificationRequest,
|
||||
type SendClassIslandNotificationResult,
|
||||
type StudentDto,
|
||||
type TableImportPreview,
|
||||
type TableImportResult,
|
||||
@@ -31,6 +36,7 @@ import {
|
||||
type TimeLayoutParsePreview,
|
||||
type UpdateClassAccountRequest,
|
||||
type UpdateClassRequest,
|
||||
type UpdateIntegrationDeviceRequest,
|
||||
type UserDto,
|
||||
} from '@classhelper/shared';
|
||||
import { api } from './http';
|
||||
@@ -292,3 +298,28 @@ export const importApi = {
|
||||
payload: unknown;
|
||||
}): 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}`),
|
||||
/** 把一条提醒下发到该班的 ClassIsland 设备(ClassIsland 上全屏弹出) */
|
||||
notify: (payload: SendClassIslandNotificationRequest): Promise<SendClassIslandNotificationResult> =>
|
||||
api.post(`${API_PATHS.integrations}/classisland/notify`, payload),
|
||||
};
|
||||
@@ -21,6 +21,7 @@ const { isMobile } = useResponsive();
|
||||
* - 成绩录入:管理员 + 教师;页面内只对"管理员或所选班级的班主任"显示写入/导入按钮
|
||||
* (需求 6:成绩与表格导入老师端可用且不越权;科任老师只能看,写入会被后端 403)
|
||||
* - 作业 / 通知:所有教师
|
||||
* - ClassIsland 联动:管理员 + 教师(设备令牌按班签发,页面内再按"能否管理该班"控制按钮)
|
||||
*/
|
||||
const menuItems = [
|
||||
{ path: '/dashboard', title: '仪表盘', icon: 'Odometer', roles: ['ADMIN', 'TEACHER'] },
|
||||
@@ -31,6 +32,7 @@ const menuItems = [
|
||||
{ path: '/homeworks', title: '作业发布', icon: 'Notebook', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/notifications', title: '通知发布', icon: 'Bell', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/grades', title: '成绩录入', icon: 'Trophy', roles: ['ADMIN', 'TEACHER'] },
|
||||
{ path: '/integrations', title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
];
|
||||
|
||||
/** 当前账号可见的菜单(班级/学生管理仅管理员可见) */
|
||||
|
||||
@@ -61,6 +61,13 @@ const routes: RouteRecordRaw[] = [
|
||||
component: () => import('@/views/GradesView.vue'),
|
||||
meta: { title: '成绩录入', icon: 'Trophy' },
|
||||
},
|
||||
{
|
||||
path: 'integrations',
|
||||
name: 'integrations',
|
||||
component: () => import('@/views/IntegrationsView.vue'),
|
||||
// 与后端 requireRole('ADMIN','TEACHER') 对齐:学生角色不该看到这一页
|
||||
meta: { title: 'ClassIsland 联动', icon: 'Connection', roles: ['ADMIN', 'TEACHER'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@ import { io, type Socket } from 'socket.io-client';
|
||||
import {
|
||||
PRIORITY_LABELS,
|
||||
SOCKET_EVENTS,
|
||||
type ClassIslandNotificationEvent,
|
||||
type ClassIslandStateEvent,
|
||||
type GradeDto,
|
||||
type HomeworkDto,
|
||||
type NotificationDto,
|
||||
@@ -23,6 +25,8 @@ const EVENT_LABELS: Record<string, string> = {
|
||||
[SOCKET_EVENTS.gradeUpdated]: '成绩更新',
|
||||
[SOCKET_EVENTS.scheduleUpdated]: '课表变更',
|
||||
[SOCKET_EVENTS.homeworkStatus]: '作业状态',
|
||||
[SOCKET_EVENTS.classislandState]: 'ClassIsland 状态',
|
||||
[SOCKET_EVENTS.classislandNotification]: 'ClassIsland 提醒',
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -116,6 +120,23 @@ 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);
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
<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,
|
||||
CLASSISLAND_TIME_STATE_LABELS,
|
||||
NOTIFICATION_PRIORITIES,
|
||||
PRIORITY_LABELS,
|
||||
SOCKET_EVENTS,
|
||||
formatDate,
|
||||
relativeTime,
|
||||
type ClassDto,
|
||||
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 上全屏弹出(学生端通知中心同时留档)。
|
||||
*
|
||||
* 权限与后端对齐:本页只对 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: '' });
|
||||
|
||||
/** 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();
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 reload().catch(() => undefined);
|
||||
realtime.on(SOCKET_EVENTS.classislandState, handleState);
|
||||
// 在线状态是"距今多久"的判断,需要定时重算,否则页面开着时间不会变
|
||||
tickTimer = setInterval(() => {
|
||||
nowTick.value = Date.now();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
realtime.off(SOCKET_EVENTS.classislandState, handleState);
|
||||
if (tickTimer) clearInterval(tickTimer);
|
||||
tickTimer = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2 class="page-title">ClassIsland 联动</h2>
|
||||
<p class="page-subtitle">
|
||||
教室机器装上「班级小助手联动」插件后:课表与上课状态会自动上报到这里,这里下发的提醒会在 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>
|
||||
|
||||
<el-alert
|
||||
v-if="filteredDevices.length === 0"
|
||||
type="info"
|
||||
show-icon
|
||||
:closable="false"
|
||||
title="还没有接入 ClassIsland 设备"
|
||||
description="在教室机器的 ClassIsland 上安装「班级小助手联动」插件并启用,然后点右上角「接入新设备」生成设备令牌,填入插件的「设置 → 班级小助手联动」即可。"
|
||||
/>
|
||||
|
||||
<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="290" 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="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">同时在班级通知中心留一条(学生端也能事后查看)</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>
|
||||
.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>
|
||||
Reference in new issue
Block a user