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

+64
View File
@@ -23,6 +23,8 @@ export const API_PATHS = {
teachers: '/teachers',
/** 导入:模板下载 / 表格导入 / ClassIsland 课表时间配置 */
imports: '/imports',
/** ClassIsland 联动:设备接入、状态上报、通知下发 */
integrations: '/integrations',
dashboard: '/dashboard/summary',
} as const;
@@ -214,9 +216,71 @@ export const SOCKET_EVENTS = {
classUpdated: 'class:updated',
/** "叫人":老师点名让某位同学去找他(定向到 user:{studentId} 房间) */
callNew: 'call:new',
/** ClassIsland 联动:设备状态上报(Web 端实时展示当前课程) */
classislandState: 'classisland:state',
/** ClassIsland 联动:教师发起的提醒已下发(插件据此全屏弹出) */
classislandNotification: 'classisland:notification',
connected: 'connected',
} as const;
/** ClassIsland 联动:设备接入令牌的前缀(便于识别与日志排查) */
export const CLASSISLAND_DEVICE_TOKEN_PREFIX = 'chci_';
/** ClassIsland 联动:ClassIsland 的时间点类型(与 TimeLayoutItem.TimeType 一致) */
export const CLASSISLAND_TIME_TYPES = {
class: 0,
break: 1,
divider: 2,
action: 3,
} as const;
/**
* ClassIsland 的时间点类型 -> 本系统的节次类型。
* 3(行动)在本系统里按"分割线"处理:它不占课时,只做视觉分隔。
*/
export const CLASSISLAND_TIME_TYPE_LABELS: Record<number, string> = {
0: '上课',
1: '课间',
2: '分割线',
3: '行动',
};
/** ClassIsland TimeState 字符串 -> 中文(插件上报 → Web 端展示) */
export const CLASSISLAND_TIME_STATE_LABELS: Record<string, string> = {
None: '空闲',
OnClass: '上课中',
PrepareOnClass: '预备铃',
Breaking: '课间休息',
AfterSchool: '已放学',
};
/**
* 通知的显示位置(由**教室的班级客户端**在设置页里选,存在班级上)。
*
* - `both`:ClassHelper 客户端弹(弹窗 + 灵动岛),同时推给教室的 ClassIsland;
* - `client`:只在 ClassHelper 客户端弹,不打扰 ClassIsland;
* - `classisland`:只在 ClassIsland 上弹(客户端只进通知中心,不弹窗、不上岛)。
*/
export const CLASSISLAND_NOTIFICATION_CHANNELS = ['both', 'client', 'classisland'] as const;
export const CLASSISLAND_NOTIFICATION_CHANNEL_LABELS: Record<string, string> = {
both: 'ClassHelper 与 ClassIsland 都弹',
client: '只在 ClassHelper 客户端弹',
classisland: '只在 ClassIsland 上弹',
};
export const CLASSISLAND_NOTIFICATION_CHANNEL_HINTS: Record<string, string> = {
both: '教室的 ClassHelper 客户端与 ClassIsland 都会提醒(默认)',
client: '提醒只在 ClassHelper 客户端显示,不推送到 ClassIsland',
classisland: '提醒只推送到 ClassIsland,ClassHelper 客户端只留记录、不弹窗',
};
/** 默认显示位置:两端都弹 */
export const DEFAULT_CLASSISLAND_NOTIFICATION_CHANNEL = 'both';
/** 默认提醒显示时长(秒),后端与插件共用同一口径 */
export const CLASSISLAND_NOTIFICATION_DEFAULT_DURATION = 8;
export const CLASSISLAND_NOTIFICATION_MAX_DURATION = 120;
/**
* "叫人"快捷短语(Web 管理端一键选择,学生端灵动岛同步展示)。
* 自定义消息会替换/补全这些短语;`message` 为空时用短语本身。
+288 -1
View File
@@ -106,6 +106,10 @@ export interface ClassDto {
hasPassword?: boolean;
/** 本学期教学周数(班主任可调,默认 20):课表周次选择与默认 weekEnd 都用它 */
termWeeks: number;
/** 是否已有启用中的 ClassIsland 联动设备(班级列表/课表页显示「已接入」徽标) */
classIslandConnected?: boolean;
/** 通知显示位置(由教室的班级客户端设置,见 CLASSISLAND_NOTIFICATION_CHANNELS) */
notificationChannel?: ClassIslandNotificationChannel;
}
export interface ClassTeacherBrief {
@@ -592,10 +596,15 @@ export interface ServerToClientEvents {
'grade:updated': (payload: GradeDto) => void;
'schedule:updated': (payload: {
classId: string;
action: 'created' | 'updated' | 'deleted';
/** imported = 由 ClassIsland 插件上报后整体刷新课表 */
action: 'created' | 'updated' | 'deleted' | 'imported';
schedule?: ScheduleDto;
}) => void;
'class:updated': (payload: { classId: string; action: 'created' | 'updated' | 'deleted' }) => void;
/** ClassIsland 联动:设备上报了当前上课状态(Web 端实时展示「现在上什么课」) */
'classisland:state': (payload: ClassIslandStateEvent) => void;
/** ClassIsland 联动:老师发的提醒已下发给该班设备(Web 端据此提示「已送达」) */
'classisland:notification': (payload: ClassIslandNotificationEvent) => void;
connected: (payload: { userId: string; role: UserRole; rooms: string[] }) => void;
}
@@ -670,6 +679,284 @@ export interface TimeLayoutImportResult {
replaced: number;
}
/* ------------------------------------------------------------------ ClassIsland 联动(集成) */
/**
* 通知的显示位置(存在班级上,由教室的班级客户端在设置页里选)。
* 服务端据此决定「发通知时要不要同时推给教室的 ClassIsland」。
*/
export type ClassIslandNotificationChannel = 'both' | 'client' | 'classisland';
/**
* 联动方式:
* - `plugin`:安装「班级小助手联动插件」,由插件把 ClassIsland 的当前状态与课表推给本服务;
* - `import`:只把 ClassIsland 导出的档案 JSON 导入本服务(一次性,无实时联动)。
*/
export type IntegrationMode = 'plugin' | 'import';
/** 设备(一台安装了 ClassIsland 插件的机器)与班级的绑定关系 */
export interface IntegrationDeviceDto {
id: string;
classId: string;
className?: string | null;
/** 设备显示名(默认取机器名) */
name: string;
/** 设备唯一标识(插件上报的机器码) */
deviceKey: string;
/** ClassIsland 版本,例如 2.1.0.0 */
classIslandVersion: string | null;
/** 插件版本 */
pluginVersion: string | null;
mode: IntegrationMode;
/** 是否接入:接入后插件才能上报状态/课表并接收通知 */
enabled: boolean;
/** 是否允许插件把 ClassIsland 的课表回传到本服务(覆盖/合并本班课表) */
syncScheduleToServer: boolean;
/** 是否允许把班级课表镜像回 ClassIsland(由插件执行) */
mirrorScheduleToClassIsland: boolean;
/** 最后一次成功上报的时间 */
lastSeenAt: string | null;
/** 最后一次上报里 ClassIsland 是否已加载课表 */
classPlanLoaded: boolean;
/** 最后一次上报里的当前科目 */
currentSubject: string | null;
/** 最后一次上报里的当前时间点状态(OnClass / Breaking / AfterSchool / None / PrepareOnClass) */
currentTimeState: string | null;
/** 最后一次上报里的当前节次起止时间(HH:mm) */
currentPeriodStart: string | null;
currentPeriodEnd: string | null;
/** 最后一次上报里的下一节课科目 */
nextSubject: string | null;
/** 令牌前缀提示(chci_xxxxxxxx…):设备列表里用于区分多台设备各自用的令牌 */
tokenHint?: string;
/** 创建/重置接口一次性下发的纯文本设备令牌(其他接口永不返回) */
token?: string;
createdAt: string;
updatedAt: string;
}
/** 创建/重置设备令牌的返回(token 只在这一次返回) */
export interface IntegrationDeviceTokenDto {
device: IntegrationDeviceDto;
token: string;
}
export interface CreateIntegrationDeviceRequest {
classId: string;
name?: string;
mode?: IntegrationMode;
syncScheduleToServer?: boolean;
mirrorScheduleToClassIsland?: boolean;
}
export interface UpdateIntegrationDeviceRequest {
name?: string;
enabled?: boolean;
syncScheduleToServer?: boolean;
mirrorScheduleToClassIsland?: boolean;
mode?: IntegrationMode;
}
/** 通知下发:把一条通知推给某班级的 ClassIsland 设备(在 ClassIsland 上全屏提醒) */
export interface SendClassIslandNotificationRequest {
classId: string;
title: string;
content: string;
/** 显示时长(秒,1~120,默认 8):映射到 ClassIsland 的 NotificationContent.Duration */
durationSeconds?: number;
/** 是否语音朗读(默认 false) */
speech?: boolean;
/** 语音朗读内容(留空则朗读 title + content) */
speechContent?: string;
/** 是否同时落库到班级通知中心(默认 true) */
saveToNotifications?: boolean;
/** 优先级(落库时使用,默认 NORMAL) */
priority?: NotificationPriority;
}
export interface SendClassIslandNotificationResult {
/** 已下发(在线)的设备数 */
delivered: number;
/** 目标设备总数 */
targetCount: number;
/** 一并落库的通知 id(saveToNotifications=false 时为 null) */
notificationId: string | null;
/**
* 本次被跳过的原因(没有跳过时为 null/undefined)。
* `channel-client` = 该班教室在客户端里选了"只在 ClassHelper 客户端显示",
* 因此没有推送到 ClassIsland —— 让老师知道"不是没送达,是教室没要"。
*/
skipped?: 'channel-client' | null;
}
/* ------------------ 插件上报(设备令牌鉴权,不是 JWT 会话) ------------------ */
/** 插件上报的课表条目(已按本系统口径归一化) */
export interface ClassIslandScheduleEntry {
/** 1=周一 … 7=周日(插件已从 ClassIsland 的 0=周日 转换) */
dayOfWeek: number;
startTime: string;
endTime: string;
subject: string;
teacherName?: string | null;
weekParity: WeekParity;
weekCountDiv: number;
weekCountDivTotal: number;
planName: string;
}
/** 插件上报的节次时间(时间表) */
export interface ClassIslandTimeLayoutEntry {
index: number;
name: string;
startTime: string;
endTime: string;
type: 'class' | 'break' | 'divider' | 'action';
skipped: boolean;
}
/** POST /api/integrations/classisland/report —— 插件的一次上报 */
export interface ClassIslandReportRequest {
/** 插件/应用版本,便于排查 */
pluginVersion?: string;
classIslandVersion?: string;
/** 本机运行状态 */
state?: {
inClass: boolean;
subject: string | null;
nextSubject: string | null;
/** 当前时间点状态字符串(ClassIsland TimeState) */
timeState: string | null;
periodStart: string | null;
periodEnd: string | null;
week: number | null;
classPlanLoaded: boolean;
/** 客户端本机时间,用于诊断时钟偏差 */
clientTime: string;
};
/** 课表(仅在 syncScheduleToServer 打开时有意义;不传表示本次不上报课表) */
schedule?: {
/** replace 覆盖本班课表 / merge 合并(默认) */
mode: 'replace' | 'merge';
entries: ClassIslandScheduleEntry[];
} | null;
/** 节次时间表 */
timeLayout?: {
name?: string;
mode: 'replace' | 'merge';
items: ClassIslandTimeLayoutEntry[];
} | null;
}
/** POST /api/integrations/classisland/report 的返回 */
export interface ClassIslandReportResult {
/** 服务端是否接受并应用了课表 */
scheduleApplied: boolean;
scheduleCreated: number;
scheduleUpdated: number;
/** 自动补建/命中的课程名 */
courses: string[];
timeLayoutApplied: boolean;
/** 服务端当前教学周(插件可用来校正"第几周") */
week: number;
/** 服务端时间(插件可用来校正时钟) */
serverTime: string;
/** 回传给插件的外观/行为设置:本轮需要下发的通知已随实时事件推送 */
settings: {
mirrorScheduleToClassIsland: boolean;
enabled: boolean;
};
/** 尚未在 ClassIsland 上确认过的一条提醒(插件可立即弹出) */
pendingNotification?: ClassIslandPushNotification | null;
}
/** 下发给 ClassIsland 的提醒(教师发通知 → 学生机器全屏弹出) */
export interface ClassIslandPushNotification {
id: string;
title: string;
content: string;
/** 显示时长(秒) */
durationSeconds: number;
/** 语音朗读内容;null = 不朗读 */
speechContent: string | null;
createdAt: string;
/** 是否紧急(紧急时插件用更强的遮罩与更长的时长) */
urgent: boolean;
classId: string;
className?: string | null;
teacherName?: string | null;
}
/** GET /api/integrations/classisland/pending 的返回(插件重连后补齐漏掉的通知) */
export interface ClassIslandPendingResult {
notifications: ClassIslandPushNotification[];
serverTime: string;
week: number;
}
/** 插件拉取本班的整份课表(mirrorScheduleToClassIsland 打开时,插件把课表写回 ClassIsland) */
export interface ClassIslandClassPlanPull {
/** ClassIsland 的口径:0=周日、1=周一 … 6=周六 */
classPlan: {
entries: {
/** ClassIsland 的 WeekDay:0=周日 … 6=周六 */
weekDay: number;
weekCountDiv: number;
weekCountDivTotal: number;
/** 科目名 */
subject: string;
teacherName: string | null;
/**
* 该课目的起止时间(HH:mm)。
*
* ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里第 i 个上课点对齐,
* 因此插件要按这些时间重建时间表 —— 缺了它们整份镜像课表都会错位。
*/
startTime: string;
endTime: string;
timeLayoutId: string;
}[];
timeLayouts: {
id: string;
name: string;
layouts: {
startTime: string;
endTime: string;
/** ClassIsland TimeType:0=上课、1=课间、2=分割线、3=行动 */
timeType: number;
}[];
}[];
/** 建议的档案名 */
profileName: string;
/** 教学周起始日期(用于插件设置 ClassIsland 的周次口径) */
termStartDate: string;
};
week: number;
serverTime: string;
}
/** Socket.IO 事件:ClassIsland 设备状态变化(Web 管理端实时展示) */
export interface ClassIslandStateEvent {
classId: string;
deviceId: string;
deviceName: string;
inClass: boolean;
subject: string | null;
nextSubject: string | null;
timeState: string | null;
periodStart: string | null;
periodEnd: string | null;
week: number | null;
classPlanLoaded: boolean;
at: string;
}
/** Socket.IO 事件:有新提醒下发到 ClassIsland(插件与 Web 端都可据此刷新) */
export interface ClassIslandNotificationEvent extends ClassIslandPushNotification {
/** 是下发给本班级设备,还是仅通知 Web 端展示 */
targetCount: number;
}
/** 客户端 -> 服务端事件名 */
export interface ClientToServerEvents {
'class:join': (classId: string, ack?: (result: { ok: boolean; message?: string }) => void) => void;
+31
View File
@@ -65,6 +65,37 @@ export function weekParityOf(week: number): Exclude<WeekParity, 'ALL'> {
return week % 2 === 0 ? 'EVEN' : 'ODD';
}
/**
* ClassIsland 的 `WeekCountDiv` / `WeekCountDivTotal` → 本系统的单双周。
*
* 语义(与 ClassIsland 源码一致):`WeekCountDivTotal = n` 时,第 `WeekCountDiv` 周启用(1-based);
* `WeekCountDiv <= 0` 或 `Total <= 1` 表示每周都上。
*
* 注意:本系统只用单双周(2 周循环),3 周及以上的循环**无法表达**,此时降级为「每周」
* 并由调用方向用户提示(导入模块记 warning)。这里集中为一处,避免导入与插件上报各写一套。
*/
export function weekParityFromDiv(weekCountDiv: number, weekCountDivTotal: number): WeekParity {
if (weekCountDiv <= 0 || weekCountDivTotal <= 1) return 'ALL';
const position = ((weekCountDiv - 1) % weekCountDivTotal) + 1;
if (weekCountDivTotal === 2) return position === 1 ? 'ODD' : 'EVEN';
return 'ALL';
}
/** `weekParityFromDiv` 的逆运算:本系统的单双周 → ClassIsland 的 Div/Total */
export function weekDivFromParity(parity: WeekParity | null | undefined): {
weekCountDiv: number;
weekCountDivTotal: number;
} {
if (parity === 'ODD') return { weekCountDiv: 1, weekCountDivTotal: 2 };
if (parity === 'EVEN') return { weekCountDiv: 2, weekCountDivTotal: 2 };
return { weekCountDiv: 0, weekCountDivTotal: 0 };
}
/** 多周循环(3 周及以上)无法用单双周表达:告知调用方是否被降级 */
export function isWeekCycleDegraded(weekCountDiv: number, weekCountDivTotal: number): boolean {
return weekCountDivTotal > 2;
}
/** 判断某个课表条目在第 week 周是否上课(周次区间 + 单双周) */
export function isScheduleActiveInWeek(
schedule: Pick<ScheduleDto, 'weekStart' | 'weekEnd'> & { weekParity?: WeekParity | null },