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

@@ -0,0 +1,58 @@
-- ClassIsland 联动:设备接入 + 提醒下发
--
-- 说明:
-- 1) IntegrationDevice 保存「班级 ↔ 一台装有联动插件的机器」的绑定关系,
-- 令牌只存 sha256 哈希,明文仅在创建/重置接口返回一次。
-- 2) 同一班级内 deviceKey 唯一:插件重装后带同一机器码会重绑原记录,避免设备列表堆重复项。
-- 3) ClassIslandPush 落库是为了「设备离线时通知不丢」:插件重连后拉取未确认的一条立即弹出。
-- 4) 本文件重复执行是安全的(逐条执行、忽略"已存在"错误)。
CREATE TABLE "IntegrationDevice" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"deviceKey" TEXT NOT NULL,
"mode" TEXT NOT NULL DEFAULT 'plugin',
"enabled" BOOLEAN NOT NULL DEFAULT true,
"tokenHash" TEXT NOT NULL,
"tokenHint" TEXT NOT NULL,
"syncScheduleToServer" BOOLEAN NOT NULL DEFAULT true,
"mirrorScheduleToClassIsland" BOOLEAN NOT NULL DEFAULT false,
"classIslandVersion" TEXT,
"pluginVersion" TEXT,
"lastSeenAt" DATETIME,
"classPlanLoaded" BOOLEAN NOT NULL DEFAULT false,
"currentSubject" TEXT,
"currentTimeState" TEXT,
"currentPeriodStart" TEXT,
"currentPeriodEnd" TEXT,
"nextSubject" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "IntegrationDevice_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
CREATE UNIQUE INDEX "IntegrationDevice_classId_deviceKey_key" ON "IntegrationDevice"("classId", "deviceKey");
CREATE INDEX "IntegrationDevice_classId_idx" ON "IntegrationDevice"("classId");
CREATE INDEX "IntegrationDevice_tokenHash_idx" ON "IntegrationDevice"("tokenHash");
CREATE TABLE "ClassIslandPush" (
"id" TEXT NOT NULL PRIMARY KEY,
"classId" TEXT NOT NULL,
"deviceId" TEXT,
"title" TEXT NOT NULL,
"content" TEXT NOT NULL,
"durationSeconds" INTEGER NOT NULL DEFAULT 8,
"speechContent" TEXT,
"urgent" BOOLEAN NOT NULL DEFAULT false,
"createdBy" TEXT,
"notificationId" TEXT,
"ackedAt" DATETIME,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expiresAt" DATETIME NOT NULL,
CONSTRAINT "ClassIslandPush_classId_fkey" FOREIGN KEY ("classId") REFERENCES "Class" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "ClassIslandPush_deviceId_fkey" FOREIGN KEY ("deviceId") REFERENCES "IntegrationDevice" ("id") ON DELETE SET NULL ON UPDATE CASCADE
);
CREATE INDEX "ClassIslandPush_classId_createdAt_idx" ON "ClassIslandPush"("classId", "createdAt");
CREATE INDEX "ClassIslandPush_deviceId_ackedAt_idx" ON "ClassIslandPush"("deviceId", "ackedAt");
@@ -0,0 +1,3 @@
-- 通知显示位置(由教室的班级客户端设置):both / client / classisland
-- 旧数据默认 both:行为与之前一致(客户端照常收,同时推给 ClassIsland)
ALTER TABLE "Class" ADD COLUMN "notificationChannel" TEXT NOT NULL DEFAULT 'both';
+82
View File
@@ -60,6 +60,9 @@ model Class {
passwordHash String?
/// 本学期教学周数(班主任可调,默认 20):课表周次选择与 weekEnd 默认值都用它
termWeeks Int @default(20)
/// 通知显示位置(由教室的班级客户端在设置页里选):
/// both=客户端与 ClassIsland 都弹 / client=只弹客户端 / classisland=只弹 ClassIsland
notificationChannel String @default("both")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -73,6 +76,8 @@ model Class {
grades Grade[]
enrollments Enrollment[]
timeLayouts TimeLayout[]
integrationDevices IntegrationDevice[]
classIslandPushes ClassIslandPush[]
@@index([teacherId])
}
@@ -262,3 +267,80 @@ model Grade {
@@index([classId, examName])
@@index([courseId])
}
/// ClassIsland 联动设备(一台装有联动插件的机器 = 某个班级的接入端)
///
/// 说明:
/// 1) 令牌是「设备级」的,不是用户级:插件在本机保存明文,服务端只存 sha256 哈希 + 前缀提示;
/// 2) 同一班级下按 deviceKey(机器码)唯一:插件重装后带同一 deviceKey 会自动重绑到原记录,
/// 避免设备列表里堆满重复项;
/// 3) 状态字段是「最后一次上报的快照」,Web 端据此展示"某班现在上什么课"。
model IntegrationDevice {
id String @id @default(cuid())
classId String
/// 设备显示名(默认取机器名)
name String
/// 设备唯一标识(插件上报的机器码)
deviceKey String
/// 联动方式:plugin=实时插件联动 / import=仅一次性导入
mode String @default("plugin")
/// 是否接入(关闭后插件上报与下发一律 403)
enabled Boolean @default(true)
/// 设备令牌的 sha256(明文只在创建/重置时返回一次)
tokenHash String
/// 令牌前缀(chci_xxxxxxxx),设备列表里用于人眼识别与排查
tokenHint String
/// 是否允许插件把 ClassIsland 的课表回传到本服务
syncScheduleToServer Boolean @default(true)
/// 是否允许把本班课表镜像回 ClassIsland(由插件执行)
mirrorScheduleToClassIsland Boolean @default(false)
classIslandVersion String?
pluginVersion String?
lastSeenAt DateTime?
/// 以下为最后一次上报的快照
classPlanLoaded Boolean @default(false)
currentSubject String?
currentTimeState String?
currentPeriodStart String?
currentPeriodEnd String?
nextSubject String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
pushes ClassIslandPush[]
@@unique([classId, deviceKey])
@@index([classId])
@@index([tokenHash])
}
/// 下发给 ClassIsland 的提醒:教师发通知 → 学生机器全屏弹出。
/// 落库是为了「设备离线时通知不丢」——插件重连后拉取未确认的一条立即弹出。
model ClassIslandPush {
id String @id @default(cuid())
classId String
deviceId String?
title String
content String
/// 显示时长(秒):映射到 ClassIsland 的 NotificationContent.Duration
durationSeconds Int @default(8)
/// 语音朗读内容;null = 不朗读
speechContent String?
urgent Boolean @default(false)
/// 发起人的用户 id(教师/管理员)
createdBy String?
/// 同时落库到通知中心的通知 id(saveToNotifications 时写入)
notificationId String?
/// 插件确认弹出的时间(确认后不再重复下发)
ackedAt DateTime?
createdAt DateTime @default(now())
/// 过期时间:超过后不再补发(避免学生几天后突然弹出旧提醒)
expiresAt DateTime
class Class @relation(fields: [classId], references: [id], onDelete: Cascade)
device IntegrationDevice? @relation(fields: [deviceId], references: [id], onDelete: SetNull)
@@index([classId, createdAt])
@@index([deviceId, ackedAt])
}