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

@@ -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,