Files
ClassHelper/packages/web-admin/src/views/IntegrationsView.vue
T
LoveChengke d0830f6830 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)。
2026-09-27 14:59:12 +08:00

653 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>