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:
LoveChengke committed 2026-09-27 14:59:12 +08:00
1 parent fe41ebb7d6
commit d0830f6830
61 files changed
+7026 -93

No files matched your search

+25 -1
View File
@@ -1,7 +1,14 @@
import fs from 'node:fs';
import path from 'node:path';
import { app, safeStorage } from 'electron';
import { DEFAULT_ISLAND_APPEARANCE, DEFAULT_SERVER_URL, type IslandAppearance } from '@classhelper/shared';
import {
CLASSISLAND_NOTIFICATION_CHANNELS,
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
DEFAULT_ISLAND_APPEARANCE,
DEFAULT_SERVER_URL,
type ClassIslandNotificationChannel,
type IslandAppearance,
} from '@classhelper/shared';
import type { DesktopStoredConfig, HomeworkBoardSettings } from '../types/desktop.js';
/** 作业页展示偏好默认值(看板 + 不显示时间 + 15px) */
@@ -22,6 +29,8 @@ interface PersistedConfig {
island?: Partial<IslandAppearance>;
/** 作业页展示偏好(向后兼容:旧配置文件没有该字段时用默认值补齐) */
homeworkBoard?: Partial<HomeworkBoardSettings>;
/** 通知显示位置(向后兼容:旧配置文件没有该字段时按默认 both 补齐) */
notificationChannel?: ClassIslandNotificationChannel;
}
const DEFAULT_CONFIG: PersistedConfig = {
@@ -31,8 +40,18 @@ const DEFAULT_CONFIG: PersistedConfig = {
tokenEncrypted: false,
island: { ...DEFAULT_ISLAND_APPEARANCE },
homeworkBoard: { ...DEFAULT_HOMEWORK_BOARD },
notificationChannel: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
};
/** 通知显示位置的白名单校验:非法值一律回落到默认值,避免坏配置把提醒"静默吞掉" */
function normalizeNotificationChannel(
input?: ClassIslandNotificationChannel,
): ClassIslandNotificationChannel {
return CLASSISLAND_NOTIFICATION_CHANNELS.includes(input as ClassIslandNotificationChannel)
? (input as ClassIslandNotificationChannel)
: DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL;
}
/** 作业页偏好的合法化(模式白名单、字号夹紧) */
function normalizeHomeworkBoard(input?: Partial<HomeworkBoardSettings>): HomeworkBoardSettings {
const mode = input?.mode === 'list' ? 'list' : 'board';
@@ -126,6 +145,7 @@ function readPersisted(): PersistedConfig {
tokenEncrypted: parsed.tokenEncrypted === true,
island: normalizeIslandAppearance(parsed.island),
homeworkBoard: normalizeHomeworkBoard(parsed.homeworkBoard),
notificationChannel: normalizeNotificationChannel(parsed.notificationChannel),
};
} catch {
return { ...DEFAULT_CONFIG };
@@ -147,6 +167,7 @@ export function getConfig(): DesktopStoredConfig {
token: decryptToken(persisted),
island: normalizeIslandAppearance(persisted.island),
homeworkBoard: normalizeHomeworkBoard(persisted.homeworkBoard),
notificationChannel: normalizeNotificationChannel(persisted.notificationChannel),
};
}
@@ -169,6 +190,9 @@ export function saveConfig(patch: Partial<DesktopStoredConfig>): DesktopStoredCo
...patch.homeworkBoard,
});
}
if (patch.notificationChannel !== undefined) {
persisted.notificationChannel = normalizeNotificationChannel(patch.notificationChannel);
}
if (patch.token !== undefined) {
const encrypted = encryptToken(patch.token);
persisted.token = encrypted.token;
+67
View File
@@ -2536,6 +2536,69 @@ async function runSettingsPageAppearanceCheck(
return { ok: false, detail: result?.detail ?? '未执行' };
}
/**
* 设置页「通知显示位置」自检(需求:提醒弹在 ClassHelper 还是 ClassIsland,由客户端自己选)。
*
* 走真实 UI:切到设置页 → 点单选项 → 断言「本地配置已写入」,再切回去。
* 服务端同步(写回班级记录)由 verify:classisland 覆盖,这里只管客户端这一半。
*/
async function runSettingsPageChannelCheck(
win: BrowserWindow,
): Promise<{ ok: boolean; detail: string }> {
const result = (await win.webContents
.executeJavaScript(
`(async () => {
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const before = (await window.desktop.getConfig()).notificationChannel;
location.hash = '#/settings';
// 找到「通知显示位置」那张卡片里的单选组
let group = null;
for (let i = 0; i < 40 && !group; i += 1) {
await wait(100);
group = Array.from(document.querySelectorAll('.el-card')).find((card) =>
(card.querySelector('.el-card__header')?.textContent ?? '').includes('通知显示位置'),
);
}
if (!group) return { ok: false, detail: '设置页没有「通知显示位置」卡片' };
const radios = Array.from(group.querySelectorAll('.el-radio'));
// 注意:这段脚本本身是外层模板字符串,里面不能再出现反引号(会截断外层字符串)
if (radios.length < 3) return { ok: false, detail: '单选项只有 ' + radios.length + ' 个' };
// 点第二个(只在 ClassHelper 客户端弹),断言配置真的写下去了
radios[1].click();
await wait(600);
const afterClient = (await window.desktop.getConfig()).notificationChannel;
const clientChecked = radios[1].classList.contains('is-checked');
// 再点回第一个(两端都弹),避免影响后续用例
radios[0].click();
await wait(600);
const afterBoth = (await window.desktop.getConfig()).notificationChannel;
location.hash = '#/schedule';
return { ok: true, before, afterClient, afterBoth, clientChecked, labels: radios.map((n) => n.textContent.trim()) };
})()`,
)
.catch((error: unknown) => ({
ok: false as const,
detail: `执行失败:${error instanceof Error ? error.message : String(error)}`,
}))) as
| { ok: true; before: string; afterClient: string; afterBoth: string; clientChecked: boolean; labels: string[] }
| { ok: false; detail: string }
| null;
if (result?.ok) {
return {
ok: result.afterClient === 'client' && result.clientChecked && result.afterBoth === 'both',
detail:
`单选项=[${result.labels.join(' / ')}],` +
`选择「只在 ClassHelper 客户端弹」后配置 ${result.before} → ${result.afterClient}(选中=${result.clientChecked}),` +
`切回后 ${result.afterBoth}`,
};
}
return { ok: false, detail: result?.detail ?? '未执行' };
}
/**
* Electron 冒烟验证(ELECTRON_SMOKE_TEST=1 时触发,跑完自动退出)。
* 覆盖:preload 桥接、渲染进程挂载、登录页 DOM、IndexedDB 缓存读写、离线回退,
@@ -2732,6 +2795,10 @@ export async function runSmokeTest(win: BrowserWindow): Promise<void> {
const settingsAppearance = await runSettingsPageAppearanceCheck(win);
record('设置页真实 UI:拖拽滑块即时改变灵动岛外观', settingsAppearance.ok, settingsAppearance.detail);
// 通知显示位置(客户端自己选):提醒弹在 ClassHelper 还是 ClassIsland
const settingsChannel = await runSettingsPageChannelCheck(win);
record('设置页真实 UI:切换「通知显示位置」写回本地配置', settingsChannel.ok, settingsChannel.detail);
// 真实通知链路:教师发通知 → 客户端实时通道 → 灵动岛胶囊;
// 随后在灵动岛点"标为已读",验证通知中心同步为已读(修复"点了已读仍显示未读")
const realtime = await runIslandRealtimeCheck(win);
@@ -25,6 +25,14 @@ onMounted(async () => {
bootText.value = '正在恢复登录状态...';
const session = await auth.restore();
// 通知显示位置:先用本地配置兜底(断网也要按上次的选择决定本机弹不弹),
// 登录态就绪后再向服务器对齐一次(服务端才是单一事实来源)
const storedConfig = await window.desktop?.getConfig?.();
if (storedConfig?.notificationChannel) {
appStore.applyLocalNotificationChannel(storedConfig.notificationChannel);
}
void appStore.loadNotificationChannel(auth.classId);
// 连通性探测与健康轮询放到后台,不阻塞进入界面(离线时也能立刻看到缓存数据)
void appStore.init(session.serverUrl);
@@ -2,6 +2,7 @@ import {
API_PATHS,
type ClassDetailDto,
type ClassDto,
type ClassIslandNotificationChannel,
type DashboardSummary,
type GradeDto,
type HomeworkDto,
@@ -32,6 +33,22 @@ export const classApi = {
detail: (id: string): Promise<ClassDetailDto> => api.get(`${API_PATHS.classes}/${id}`),
};
/**
* 通知显示位置(both / client / classisland)。
*
* 由**这台教室机器自己**决定"提醒弹在 ClassHelper 还是 ClassIsland":
* 本地存一份用于本机的弹窗/上岛判断,同时写回班级记录,服务端据此决定要不要推 ClassIsland。
*/
export const classChannelApi = {
get: (classId: string): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
api.get(`${API_PATHS.classes}/${classId}/notification-channel`),
set: (
classId: string,
notificationChannel: ClassIslandNotificationChannel,
): Promise<{ notificationChannel: ClassIslandNotificationChannel }> =>
api.patch(`${API_PATHS.classes}/${classId}/notification-channel`, { notificationChannel }),
};
export const scheduleApi = {
list: (params: { classId?: string; week?: number }): Promise<ScheduleDto[]> =>
api.get(API_PATHS.schedules, params),
@@ -1,7 +1,13 @@
import { computed, ref } from 'vue';
import { defineStore } from 'pinia';
import { STORAGE_KEYS, formatDate } from '@classhelper/shared';
import {
DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL,
STORAGE_KEYS,
formatDate,
type ClassIslandNotificationChannel,
} from '@classhelper/shared';
import { pingHealth, setApiBaseUrl, setReachabilityReporter } from '../api/http.js';
import { classChannelApi } from '../api/index.js';
import { cacheClearAll, cacheStats, type CacheStoreStat } from '../cache/db.js';
import { DEFAULT_SERVER, normalizeServerUrl } from '../config.js';
@@ -19,6 +25,13 @@ export const useAppStore = defineStore('app', () => {
const currentWeek = ref(1);
const maxWeek = ref(20);
const initialized = ref(false);
/**
* 通知显示位置:这台教室机器把提醒弹在哪个端。
*
* 单一事实来源是**班级记录**(服务端),本地这份只是缓存:断网时也要能按上次的选择
* 决定本机弹不弹,所以启动时先读本地配置、登录后再向服务器对齐一次。
*/
const notificationChannel = ref<ClassIslandNotificationChannel>(DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL);
const offline = computed(() => !serverReachable.value);
const lastSyncText = computed(() => (lastSyncAt.value ? formatDate(lastSyncAt.value, true) : '尚未同步'));
@@ -108,6 +121,53 @@ export const useAppStore = defineStore('app', () => {
void refreshCacheStats();
}
/* ------------------------------------------------------------ 通知显示位置 */
/** 启动时用本地配置兜底(此时可能还没登录/断网) */
function applyLocalNotificationChannel(value: ClassIslandNotificationChannel): void {
notificationChannel.value = value;
}
/**
* 向服务器对齐显示位置。
* 登录后调用:以服务端为准(多台机器/老师改过之后,本地缓存可能已经过期)。
*/
async function loadNotificationChannel(classId: string | null): Promise<void> {
if (!classId) return;
try {
const result = await classChannelApi.get(classId);
notificationChannel.value = result.notificationChannel;
await window.desktop?.saveConfig({ notificationChannel: result.notificationChannel });
} catch {
// 离线/无权限:保留本地值,不影响展示
}
}
/**
* 改显示位置:先落本地(立刻生效),再写回班级记录。
* 服务端写失败时返回 false,由设置页提示"已保存在本机,联网后请重试"。
*/
async function setNotificationChannel(
classId: string | null,
value: ClassIslandNotificationChannel,
): Promise<boolean> {
notificationChannel.value = value;
await window.desktop?.saveConfig({ notificationChannel: value });
if (!classId) return false;
try {
const result = await classChannelApi.set(classId, value);
notificationChannel.value = result.notificationChannel;
return true;
} catch {
return false;
}
}
/** 本次提醒是否要在本机弹(弹窗 + 灵动岛);"只在 ClassIsland 上弹"时为 false */
function shouldPopupLocally(): boolean {
return notificationChannel.value !== 'classisland';
}
return {
serverUrl,
serverReachable,
@@ -119,9 +179,14 @@ export const useAppStore = defineStore('app', () => {
currentWeek,
maxWeek,
initialized,
notificationChannel,
init,
ping,
applyServerUrl,
applyLocalNotificationChannel,
loadNotificationChannel,
setNotificationChannel,
shouldPopupLocally,
refreshCacheStats,
clearCache,
markSynced,
@@ -12,6 +12,7 @@ import {
} from '@classhelper/shared';
import { socketUrlOf } from '../config.js';
import { pushCallToIsland, pushHomeworkToIsland, pushNotificationToIsland } from '../island/bridge.js';
import { useAppStore } from './app.js';
type RealtimeEventName = keyof ServerToClientEvents;
type EventHandler = (payload: unknown) => void;
@@ -22,6 +23,18 @@ type EventHandler = (payload: unknown) => void;
* 还会把数据写回 IndexedDB,保证断网时看到的是最后同步到的内容。
*/
export const useRealtimeStore = defineStore('realtime', () => {
const app = useAppStore();
/**
* 本机要不要弹这次提醒。
*
* 教室在设置页选了「只在 ClassIsland 上弹」时,客户端仍然把通知收进通知中心(列表里有记录、
* 未读红点照常),但**不再弹窗、也不上岛** —— 否则老师会看到两个地方同时炸出来。
*/
function popupLocally(): boolean {
return app.shouldPopupLocally();
}
const connected = ref(false);
const connecting = ref(false);
const lastEventName = ref<string | null>(null);
@@ -65,14 +78,16 @@ export const useRealtimeStore = defineStore('realtime', () => {
instance.on(SOCKET_EVENTS.notificationNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.notificationNew);
ElMessage({
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
type: payload.priority === 'URGENT' ? 'error' : 'info',
duration: 5000,
showClose: true,
});
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
pushNotificationToIsland(payload);
if (popupLocally()) {
ElMessage({
message: `【${PRIORITY_LABELS[payload.priority] ?? payload.priority}】${payload.title}`,
type: payload.priority === 'URGENT' ? 'error' : 'info',
duration: 5000,
showClose: true,
});
// 投递到桌面灵动岛:紧急通知立即展开;上课期间的非紧急通知会暂存,下课后自动弹出
pushNotificationToIsland(payload);
}
dispatch(SOCKET_EVENTS.notificationNew, payload);
});
@@ -86,14 +101,16 @@ export const useRealtimeStore = defineStore('realtime', () => {
instance.on(SOCKET_EVENTS.callNew, (payload: NotificationDto) => {
mark(SOCKET_EVENTS.callNew);
ElMessage({
message: payload.title,
type: 'warning',
duration: 8000,
showClose: true,
});
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
pushCallToIsland(payload);
if (popupLocally()) {
ElMessage({
message: payload.title,
type: 'warning',
duration: 8000,
showClose: true,
});
// 叫人:无论是否上课都立即展开(老师正在等这位同学)
pushCallToIsland(payload);
}
dispatch(SOCKET_EVENTS.callNew, payload);
});
@@ -68,6 +68,8 @@ async function submit(): Promise<void> {
try {
await auth.login(form.serverUrl, form.code.trim(), form.password);
await appStore.init(form.serverUrl);
// 登录后对齐"通知显示位置":这台机器上次选的是弹 ClassHelper 还是弹 ClassIsland
await appStore.loadNotificationChannel(auth.classId);
if (auth.token) realtime.connect(appStore.serverUrl, auth.token);
ElMessage.success(`已进入 ${auth.displayName}`);
await router.replace('/schedule');
@@ -3,6 +3,9 @@ import { computed, onMounted, ref } from 'vue';
import { useRouter } from 'vue-router';
import { ElMessage, ElMessageBox } from 'element-plus';
import {
CLASSISLAND_NOTIFICATION_CHANNEL_HINTS,
CLASSISLAND_NOTIFICATION_CHANNEL_LABELS,
CLASSISLAND_NOTIFICATION_CHANNELS,
DEFAULT_ISLAND_APPEARANCE,
ISLAND_APPEARANCE_RANGES as RANGES,
ISLAND_POSITION_LABELS,
@@ -10,11 +13,13 @@ import {
ISLAND_STYLES,
ISLAND_POSITIONS,
formatDate,
type ClassIslandNotificationChannel,
type IslandAppearance,
type IslandPosition,
type IslandStyle,
} from '@classhelper/shared';
import type { DesktopAppInfo } from '../../types/desktop.js';
import { classApi } from '../api/index.js';
import { pingHealth, setApiBaseUrl } from '../api/http.js';
import { normalizeServerUrl } from '../config.js';
import { useAppStore } from '../stores/app.js';
@@ -113,6 +118,51 @@ async function logout(): Promise<void> {
await router.replace('/login');
}
/* ------------------------------------------------------------ 通知显示位置 */
/**
* 通知显示位置:这台教室机器把提醒弹在哪个端。
*
* - both:ClassHelper 客户端(弹窗 + 灵动岛)与 ClassIsland 都弹(默认)
* - client:只弹 ClassHelper 客户端
* - classisland:只弹 ClassIsland(客户端只进通知中心,不弹窗、不上岛)
*
* 值会落本地配置(本机立刻生效)并写回班级记录(服务端据此决定要不要推 ClassIsland)。
*/
const channelSaving = ref(false);
/**
* 本班有没有接入 ClassIsland 设备。
*
* 只用来禁用「只在 ClassIsland 上弹」——没有设备时选它等于把提醒静默丢掉;
* 注意**不能**顺手把「两端都弹」也禁掉:那样教室一旦选过"只在客户端",没设备时就再也切不回来了。
*/
const classIslandConnected = ref(false);
const channelOptions = computed(() =>
CLASSISLAND_NOTIFICATION_CHANNELS.map((value) => ({
value,
label: CLASSISLAND_NOTIFICATION_CHANNEL_LABELS[value],
disabled: value === 'classisland' && !classIslandConnected.value,
})),
);
async function loadClassChannel(): Promise<void> {
const classId = auth.classId;
if (!classId) return;
await appStore.loadNotificationChannel(classId);
}
async function applyChannel(value: ClassIslandNotificationChannel): Promise<void> {
channelSaving.value = true;
try {
const saved = await appStore.setNotificationChannel(auth.classId, value);
if (saved) ElMessage.success('提醒显示位置已更新');
else ElMessage.warning('已保存在本机,但同步到服务器失败(离线或权限不足),联网后请再点一次');
} finally {
channelSaving.value = false;
}
}
/* ------------------------------------------------------------ 灵动岛个性化 */
const island = ref<IslandAppearance>({ ...DEFAULT_ISLAND_APPEARANCE });
@@ -184,6 +234,12 @@ onMounted(async () => {
await loadAppInfo();
await appStore.refreshCacheStats();
await loadIslandAppearance();
await loadClassChannel();
// 本班有没有 ClassIsland 设备:没接就禁用"弹在 ClassIsland"这一项,避免选了却没效果
const classList = await classApi.list().catch(() => []);
classIslandConnected.value = classList.some(
(item) => item.id === auth.classId && item.classIslandConnected === true,
);
});
</script>
@@ -365,6 +421,30 @@ onMounted(async () => {
title="拖动滑块即为实时预览;保存后写入本地配置,重启客户端仍然生效"
/>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>通知显示位置</span></template>
<p class="text-muted">
老师在班级小助手上发布通知 / 叫人时,提醒要弹在哪里由这台教室机器决定。选择会同步到班级,
服务端据此决定是否一并推送到教室的 ClassIsland。
</p>
<el-radio-group
:model-value="appStore.notificationChannel"
:disabled="channelSaving"
class="channel-group"
@change="(value: string | number | boolean | undefined) => applyChannel(value as ClassIslandNotificationChannel)"
>
<el-radio v-for="item in channelOptions" :key="item.value" :value="item.value" :disabled="item.disabled">
{{ item.label }}
</el-radio>
</el-radio-group>
<p class="text-muted channel-hint">
{{ CLASSISLAND_NOTIFICATION_CHANNEL_HINTS[appStore.notificationChannel] }}
<template v-if="!classIslandConnected">
<br />提示:本班还没有接入 ClassIsland 设备(在 Web 端「ClassIsland 联动」里接入后才能选"只在 ClassIsland 上弹")。
</template>
</p>
</el-card>
<el-card shadow="never" class="mt-12">
<template #header><span>账号信息</span></template>
<el-descriptions :column="1" border size="small">
@@ -425,6 +505,18 @@ onMounted(async () => {
</template>
<style scoped>
.channel-group {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
margin-top: 10px;
}
.channel-hint {
margin-top: 10px;
}
.ml-8 {
margin-left: 8px;
}
+13 -1
View File
@@ -2,7 +2,12 @@
* 主进程与渲染进程之间的桥接契约(preload 通过 contextBridge 暴露)。
* 渲染进程只依赖这些最小 API,不接触 Node.js。
*/
import type { IslandAppearance, IslandNotification, IslandState } from '@classhelper/shared';
import type {
ClassIslandNotificationChannel,
IslandAppearance,
IslandNotification,
IslandState,
} from '@classhelper/shared';
/** 作业页展示偏好(看板 / 列表,看板外观) */
export interface HomeworkBoardSettings {
@@ -27,6 +32,13 @@ export interface DesktopStoredConfig {
island: IslandAppearance;
/** 作业页展示偏好(看板/列表、显示时间、看板字号) */
homeworkBoard: HomeworkBoardSettings;
/**
* 通知显示位置(both / client / classisland)。
*
* 这台教室机器"提醒到底弹在哪个端"由它决定:值同时存本地(决定本机要不要弹窗/上岛)
* 与班级记录(服务端据此决定要不要推给 ClassIsland)。
*/
notificationChannel: ClassIslandNotificationChannel;
}
export interface DesktopAppInfo {