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:
1 parent
fe41ebb7d6
commit
d0830f6830
61 files changed
+7026
-93
No files matched your search
@@ -0,0 +1,4 @@
|
||||
# .NET 构建产物(与仓库根的 .gitignore 分开:根 .gitignore 是 GBK 编码,apply_patch 读不了)
|
||||
bin/
|
||||
obj/
|
||||
cipx/
|
||||
@@ -0,0 +1,45 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
ClassIsland 2.0 / 2.1.0.x 面向 net8.0;2.1.1 起改为 net10.0。
|
||||
本插件按 net8.0 交付,因此 SDK 版本固定到最后一个 net8.0 的版本(2.1.0.1),
|
||||
这样 ClassIsland 2.0.x 与 2.1.0.x 都能加载(apiVersion 见 manifest.yml)。
|
||||
-->
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>12</LangVersion>
|
||||
<RootNamespace>ClassHelper.ClassIslandPlugin</RootNamespace>
|
||||
<AssemblyName>ClassHelper.ClassIslandPlugin</AssemblyName>
|
||||
<ClassIslandPluginSdkVersion>2.1.0.1</ClassIslandPluginSdkVersion>
|
||||
<!-- 插件由 ClassIsland 加载,不需要生成自带运行时(ExcludeAssets 见下) -->
|
||||
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
|
||||
<!-- 设置页是 .axaml:需要让 Avalonia 的默认项把 **/*.axaml 收成 AvaloniaXaml 并编译 -->
|
||||
<EnableDefaultItems>true</EnableDefaultItems>
|
||||
<!-- 绑定走运行时解析:插件与主程序各自编译,编译期绑定会因看不到对方的类型而失败 -->
|
||||
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
|
||||
<NoWarn>$(NoWarn);CS1591</NoWarn>
|
||||
<GenerateDocumentationFile>false</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<!-- 运行时与原生资源由 ClassIsland 本体提供,插件只编译期依赖 -->
|
||||
<PackageReference Include="ClassIsland.PluginSdk" Version="$(ClassIslandPluginSdkVersion)">
|
||||
<ExcludeAssets>runtime;native</ExcludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="manifest.yml">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="README.md">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="icon.png">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"profiles": {
|
||||
"ClassIsland 插件": {
|
||||
"commandName": "Executable",
|
||||
"executablePath": "$(ClassIsland_DebugBinaryFile)",
|
||||
"commandLineArgs": "-epp $(TargetDir)",
|
||||
"workingDirectory": "$(ClassIsland_DebugBinaryDirectory)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
# 班级小助手联动插件
|
||||
|
||||
把 [ClassIsland](https://classisland.tech) 与 [班级小助手](https://github.com/LoveChengke/ClassHelper) 连起来:
|
||||
让班级小助手**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
|
||||
|
||||
## 它能做什么
|
||||
|
||||
| 方向 | 说明 |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| ClassIsland → 班级小助手 | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
|
||||
| 班级小助手 → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
|
||||
| 班级小助手 → ClassIsland(可选) | 把班级小助手上排好的课表**镜像**回 ClassIsland(新建 `班级小助手-<班级名>` 档案课表,不改动老师原有的课表) |
|
||||
|
||||
## 安装
|
||||
|
||||
1. 把插件放进 ClassIsland 的插件目录,二选一:
|
||||
- 用打包好的 `ClassHelper.ClassIslandPlugin.cipx`:ClassIsland →「设置 → 插件 → 从文件安装」;
|
||||
- 或直接把 `ClassHelper.ClassIslandPlugin/` 目录(含 `manifest.yml` 与 dll)丢进插件目录
|
||||
(Windows 默认 `%APPDATA%\ClassIsland\Plugins`,便携版在安装目录下的 `Plugins`)。
|
||||
2. 重启 ClassIsland,在「设置 → 插件」里确认「班级小助手联动」已启用。
|
||||
3. 打开「设置 → 班级小助手联动」,填入:
|
||||
- **服务器地址**:班级小助手后端地址,例如 `http://127.0.0.1:4000`;
|
||||
- **设备令牌**:在班级小助手 Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
|
||||
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
|
||||
|
||||
## 配置项(全部在「设置 → 班级小助手联动」里)
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 服务器地址 | 班级小助手后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
|
||||
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
|
||||
| 上报课表到班级小助手 | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
|
||||
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
|
||||
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
|
||||
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
|
||||
| 接收班级小助手提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
|
||||
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
|
||||
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
|
||||
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
|
||||
| 显示调试信息 | 展开设备标识、与服务端的时钟差、配置目录等,排查「为什么同步不上」时打开 |
|
||||
|
||||
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
|
||||
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
|
||||
|
||||
> **提醒到底会不会弹,取决于两个开关:**
|
||||
>
|
||||
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
|
||||
> 服务端根本不会推过来(此时本插件收不到东西,属正常);
|
||||
> 2. 本插件的「接收班级小助手提醒」关掉时,即使推过来也不弹。
|
||||
>
|
||||
> 两者都开着,提醒才会在 ClassIsland 上出现。
|
||||
|
||||
## 工作原理(为什么这么写)
|
||||
|
||||
- **不用跨进程 IPC,插件内直接读课程服务**:ClassIsland 的 IPC 暴露的是缓存数据,不足以还原课表;
|
||||
插件直接读 `ILessonsService`(当前科目 / 状态 / 时间点)与 `IProfileService`(档案里的课表、时间表、科目),
|
||||
拿到的才是「教室此刻真实的状态」。
|
||||
- **「上报即拉取」,不引入 Socket 依赖**:每次上报的返回值里会顺带带一条「尚未确认的提醒」,
|
||||
插件收到就交给提醒提供方显示;应用启动时额外拉一次 `pending`,
|
||||
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
|
||||
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
|
||||
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
|
||||
「班级小助手提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
|
||||
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
|
||||
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
|
||||
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
|
||||
**第 i 个 `TimeType == 0`(上课)的时间点**一一对应,
|
||||
因此镜像接口为每条课目带上 `startTime` / `endTime`,插件据此重建时间表
|
||||
(课间点从服务端的时间表里按起始时刻对齐)。少一个时间点,整份课表就会错位。
|
||||
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
|
||||
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
|
||||
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
|
||||
- **非破坏性**:镜像只创建 / 更新名字以 `班级小助手-` 开头的课表与时间表;
|
||||
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
|
||||
|
||||
## 上机联调记录(ClassIsland 2.1.0.1 实测)
|
||||
|
||||
下面几条都是把插件装进真实 ClassIsland 跑出来的问题与结论,改动理由就写在这里,避免以后被“顺手优化”回去:
|
||||
|
||||
1. **提醒内容必须在 UI 线程构造并显示。** 上报回调在线程池上,而提醒模板里的图标是 Avalonia 对象
|
||||
(`LucideIconSource → IconSource → AvaloniaObject` 的构造函数会 `Dispatcher.VerifyAccess()`),
|
||||
在线程池上直接构造会抛 `Call from invalid thread`,表现就是“ClassHelper 客户端收到了、ClassIsland 毫无反应”。
|
||||
现在统一用 `Dispatcher.UIThread.Post` 切回 UI 线程。
|
||||
2. **同一条提醒在播放期间要按 id 去重。** 服务端在收到回执前会一直把提醒算作“待弹出”,
|
||||
而轮询间隔(默认 10 秒)可能小于提醒时长(最长 120 秒)—— 不去重就会一遍遍重复弹。
|
||||
只有播完(`Completed`/`Canceled`)才把 id 从“正在播放”里移除并回执。
|
||||
3. **不要自己拼图标串。** 双图标遮罩模板的默认图标由模板自己创建(`new LucideIconSource("\ue0ff")` 这种**字形字符**),
|
||||
自己传 `"fluent(...)"` 之类既可能格式不对、也可能字形不存在。紧急提醒改用 ClassIsland 自带的**滚动文本**正文来表达。
|
||||
4. **`Subject.Fallback` 是占位符(名字就是 `???`)。** 没有课表时 ClassIsland 会把它挂到
|
||||
`CurrentSubject`/`NextClassSubject` 上,直接上报会让 Web 端显示“正在上 ???”,因此上报前归一化成“没有科目”。
|
||||
|
||||
实测截图(真机弹出提醒的遮罩):[普通提醒](../../docs/screenshots/classisland/02-normal-mask.png)、
|
||||
[紧急提醒](../../docs/screenshots/classisland/01-urgent-mask.png)。
|
||||
|
||||
## 构建与打包
|
||||
|
||||
```bash
|
||||
pnpm build:classisland-plugin # Release 构建(产物:packages/classisland-plugin/bin/Release)
|
||||
pnpm dist:classisland-plugin # 追加打包 .cipx 并归集到 releases/classisland-plugin/
|
||||
pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性 / 注册完整性 / DTO ↔ 服务端契约)
|
||||
```
|
||||
|
||||
构建脚本会把 NuGet 缓存与临时目录指到仓库内的 `.cache/`(避免写满 C 盘),
|
||||
并在打包后自检「清单 / 程序集名 / 版本号」三者一致。
|
||||
|
||||
## 开发提示
|
||||
|
||||
- 需要 **.NET 8 SDK**;SDK 包固定 `ClassIsland.PluginSdk 2.1.0.1`(2.1.1 起改为 net10.0,
|
||||
本插件按 net8.0 交付,因此 2.0.x / 2.1.0.x 都能加载,见 `manifest.yml` 的 `apiVersion`)。
|
||||
- 设置页是 Avalonia 的 `.axaml`(继承 `SettingsPageBase`);绑定走运行时解析
|
||||
(`AvaloniaUseCompiledBindingsByDefault=false`),因为插件与主程序各自编译,编译期绑定看不到对方类型。
|
||||
- **开发机没有安装 ClassIsland 本体**,所以插件只能做到编译 + 打包 + 静态校验;
|
||||
运行期行为需要在装了 ClassIsland 的机器上按上面的「安装」步骤实测。
|
||||
|
||||
## 常见问题
|
||||
|
||||
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是班级小助手的标准格式)**
|
||||
这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里,
|
||||
于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。
|
||||
插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**;
|
||||
如果仍然报 404,请依次检查:
|
||||
|
||||
1. 服务器地址是否写成了别的端口 / 别的机器(提示里会带上实际请求的完整 URL,照着核对最快);
|
||||
2. 该地址在浏览器里打开 `<服务器地址>/api/health` 是否返回 `{"success":true,...}`;
|
||||
3. 反向代理(Nginx 等)是否把 `/api/integrations` 也转发到了后端。
|
||||
|
||||
**上报失败,提示「设备令牌无效」**
|
||||
在 Web 端重置该设备的令牌,然后把新令牌填回插件设置(旧令牌会立即失效)。
|
||||
|
||||
**发布提醒后 ClassIsland 没反应,或者要等一会儿才弹**
|
||||
插件不持有长连接,提醒是**轮询取回**的:默认最多 10 秒(设置页「提醒轮询间隔」可调到 5 秒)。超过这个时间还没弹,按下面逐条排查。
|
||||
|
||||
**ClassIsland 上完全不弹提醒**
|
||||
|
||||
1. 确认插件设置里打开了「接收提醒」;
|
||||
2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403);
|
||||
3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。
|
||||
|
||||
**课表上报了但班级小助手上没变化**
|
||||
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
|
||||
|
||||
**镜像开关打开了,但 ClassIsland 的课表没变**
|
||||
镜像需要 Web 端该设备开启「镜像课表」且班级里已有课表;插件只在**上报时**顺路拉取镜像,
|
||||
所以打开开关后点一下「立即上报」或等一个上报间隔即可。镜像不会覆盖老师原有的课表。
|
||||
|
||||
## 许可
|
||||
|
||||
与班级小助手仓库一致。
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.2 KiB |
@@ -0,0 +1,19 @@
|
||||
# 班级小助手 ClassIsland 联动插件 —— 清单文件
|
||||
#
|
||||
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
|
||||
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
|
||||
|
||||
id: classhelper.classisland.bridge
|
||||
name: 班级小助手联动
|
||||
description: |-
|
||||
把 ClassIsland 的课表与上课状态同步到班级小助手,并在 ClassIsland 上显示老师下发的提醒。
|
||||
功能:
|
||||
1. 上报当前课表与节次时间(班级小助手 Web 端与桌面客户端据此显示课程);
|
||||
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
|
||||
3. 接收老师在班级小助手上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
|
||||
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
|
||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||
url: https://github.com/LoveChengke/ClassHelper
|
||||
version: 0.1.0.0
|
||||
apiVersion: 2.0.0.0
|
||||
author: 班级小助手
|
||||
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* ClassIsland 联动插件构建脚本:`pnpm build:classisland-plugin` / `pnpm dist:classisland-plugin`
|
||||
*
|
||||
* 用法:
|
||||
* node scripts/build.mjs # Release 构建,产物在 packages/classisland-plugin/bin/Release
|
||||
* node scripts/build.mjs --cipx # 追加打包 .cipx(ClassIsland 插件包)并归集到 releases/classisland-plugin/
|
||||
* 额外参数会原样透传给 `dotnet build`(例如 `-p:ClassIslandPluginSdkVersion=2.1.1.1`)
|
||||
*
|
||||
* 为什么需要这层包装(本机踩过的坑,写在这里免得换台机器又踩一遍):
|
||||
* 1. `dotnet` 默认把 NuGet 缓存与临时文件写在 C 盘(`%USERPROFILE%\.nuget`、`%TEMP%`),
|
||||
* 本机 C 盘紧张时会直接构建失败。这里统一改到仓库内 `.cache/`(已 gitignore)。
|
||||
* 2. 插件包(.cipx)的最后一步要调 PowerShell 生成哈希清单,SDK 默认用 `pwsh`;
|
||||
* 没有 pwsh 的机器会失败,因此这里探测一次并显式传给 MSBuild。
|
||||
* 3. 构建完顺手校验"清单 / 程序集 / 版本号"三者一致,避免打出装不上的包。
|
||||
*/
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = path.resolve(pluginDir, '..', '..');
|
||||
const cacheDir = path.join(repoRoot, '.cache');
|
||||
const releasesDir = path.join(repoRoot, 'releases', 'classisland-plugin');
|
||||
|
||||
const argv = process.argv.slice(2);
|
||||
const withCipx = argv.includes('--cipx');
|
||||
/** `--` 之后的参数原样透传给 dotnet build;其余以 - 开头的参数同样透传 */
|
||||
const passthrough = argv.filter((item) => item !== '--cipx' && item !== '--');
|
||||
|
||||
const log = (message) => console.log(`[classisland-plugin] ${message}`);
|
||||
|
||||
/* ------------------------------------------------------------ 1. 环境准备 */
|
||||
|
||||
const env = { ...process.env };
|
||||
for (const sub of ['nuget', 'tmp', 'dotnet-home']) {
|
||||
fs.mkdirSync(path.join(cacheDir, sub), { recursive: true });
|
||||
}
|
||||
const setDefault = (key, value) => {
|
||||
if (!env[key]) env[key] = value;
|
||||
};
|
||||
setDefault('NUGET_PACKAGES', path.join(cacheDir, 'nuget'));
|
||||
setDefault('TEMP', path.join(cacheDir, 'tmp'));
|
||||
setDefault('TMP', env.TEMP);
|
||||
setDefault('DOTNET_CLI_HOME', path.join(cacheDir, 'dotnet-home'));
|
||||
env.DOTNET_NOLOGO = '1';
|
||||
env.DOTNET_CLI_TELEMETRY_OPTOUT = '1';
|
||||
|
||||
/** 可执行文件探测:先看显式配置,再问 PATH,最后试常见安装路径 */
|
||||
function resolveExecutable(envKey, names, fallbacks) {
|
||||
const candidates = [];
|
||||
if (env[envKey]) candidates.push(env[envKey]);
|
||||
const finder = process.platform === 'win32' ? 'where' : 'which';
|
||||
for (const name of names) {
|
||||
const found = spawnSync(finder, [name], { encoding: 'utf8', env });
|
||||
if (found.status === 0) {
|
||||
for (const line of String(found.stdout ?? '').split(/\r?\n/)) candidates.push(line.trim());
|
||||
}
|
||||
}
|
||||
candidates.push(...fallbacks);
|
||||
for (const candidate of candidates) {
|
||||
if (!candidate) continue;
|
||||
try {
|
||||
if (fs.statSync(candidate).isFile()) return candidate;
|
||||
} catch {
|
||||
/* 继续试下一个 */
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const dotnet = resolveExecutable(
|
||||
'DOTNET_EXE',
|
||||
['dotnet'],
|
||||
process.platform === 'win32'
|
||||
? [path.join(env.ProgramFiles ?? 'C:\\Program Files', 'dotnet', 'dotnet.exe')]
|
||||
: ['/usr/local/bin/dotnet', '/usr/bin/dotnet'],
|
||||
);
|
||||
if (!dotnet) {
|
||||
throw new Error('未找到 dotnet SDK(需要 .NET 8 SDK)。安装后用 DOTNET_EXE 指定完整路径。');
|
||||
}
|
||||
|
||||
const powerShell = resolveExecutable(
|
||||
'POWERSHELL_EXE',
|
||||
['pwsh', 'powershell'],
|
||||
process.platform === 'win32'
|
||||
? [path.join(env.SystemRoot ?? 'C:\\Windows', 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe')]
|
||||
: [],
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------ 2. 构建 */
|
||||
|
||||
const buildArgs = ['build', '-c', 'Release'];
|
||||
if (withCipx) {
|
||||
buildArgs.push('-p:CreateCipx=true');
|
||||
// SDK 的 cipx 目标默认调 pwsh;这里按探测结果显式指定,避免"没有 pwsh 就打包失败"
|
||||
if (powerShell) buildArgs.push(`-p:PowershellBinaryName=${powerShell}`);
|
||||
}
|
||||
buildArgs.push(...passthrough);
|
||||
|
||||
log(`dotnet build(缓存目录 ${path.relative(repoRoot, cacheDir)})`);
|
||||
const build = spawnSync(dotnet, buildArgs, { cwd: pluginDir, stdio: 'inherit', env });
|
||||
if (build.error) throw new Error(`无法启动 dotnet:${build.error.message}`);
|
||||
if (build.status !== 0) throw new Error(`dotnet build 失败(退出码 ${build.status})`);
|
||||
|
||||
/* ------------------------------------------------------------ 3. 产物自检 */
|
||||
|
||||
const outputDir = path.join(pluginDir, 'bin', 'Release');
|
||||
const manifestPath = path.join(outputDir, 'manifest.yml');
|
||||
const assemblyPath = path.join(outputDir, 'ClassHelper.ClassIslandPlugin.dll');
|
||||
|
||||
for (const required of [manifestPath, assemblyPath, path.join(outputDir, 'icon.png')]) {
|
||||
if (!fs.existsSync(required)) {
|
||||
throw new Error(`构建产物缺少 ${path.relative(pluginDir, required)},ClassIsland 会加载失败`);
|
||||
}
|
||||
}
|
||||
|
||||
const manifest = fs.readFileSync(manifestPath, 'utf8');
|
||||
// prettier 会把 YAML 字符串统一成单引号,因此两种引号都要能剥掉
|
||||
const entrance = /^entranceAssembly:\s*(.+?)\s*$/m.exec(manifest)?.[1]?.replace(/^['"]|['"]$/g, '');
|
||||
if (entrance !== path.basename(assemblyPath)) {
|
||||
throw new Error(
|
||||
`manifest.yml 的 entranceAssembly(${entrance})与程序集(${path.basename(assemblyPath)})不一致`,
|
||||
);
|
||||
}
|
||||
const manifestVersion = /^version:\s*([\d.]+)/m.exec(manifest)?.[1];
|
||||
const bridgeSource = fs.readFileSync(path.join(pluginDir, 'src', 'Services', 'BridgeService.cs'), 'utf8');
|
||||
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
|
||||
if (!manifestVersion || manifestVersion !== codeVersion) {
|
||||
throw new Error(
|
||||
`版本号不一致:manifest.yml=${manifestVersion ?? '缺失'},BridgeService.cs=${codeVersion ?? '缺失'}`,
|
||||
);
|
||||
}
|
||||
log(`产物自检通过(版本 ${manifestVersion},入口程序集 ${entrance})`);
|
||||
|
||||
/* ------------------------------------------------------------ 4. 打包归集 */
|
||||
|
||||
if (!withCipx) {
|
||||
log('完成。加 --cipx 可同时打包成 ClassIsland 插件包(.cipx)');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const cipxDir = path.join(pluginDir, 'cipx');
|
||||
const cipxFile = path.join(cipxDir, 'ClassHelper.ClassIslandPlugin.cipx');
|
||||
if (!fs.existsSync(cipxFile)) {
|
||||
throw new Error(`未生成插件包:${path.relative(pluginDir, cipxFile)}`);
|
||||
}
|
||||
|
||||
// 归集到 releases/classisland-plugin/(与客户端、服务端交付产物同级;releases/ 不入库)
|
||||
fs.rmSync(releasesDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(releasesDir, { recursive: true });
|
||||
fs.copyFileSync(cipxFile, path.join(releasesDir, path.basename(cipxFile)));
|
||||
|
||||
// 免安装目录:解压即用,直接放进 ClassIsland 的 Plugins 目录也能加载
|
||||
const portableDir = path.join(releasesDir, 'ClassHelper.ClassIslandPlugin');
|
||||
fs.mkdirSync(portableDir, { recursive: true });
|
||||
for (const name of fs.readdirSync(outputDir)) {
|
||||
const from = path.join(outputDir, name);
|
||||
if (!fs.statSync(from).isFile()) continue;
|
||||
// 依赖清单与调试符号对加载没影响,但会让老师误以为"要装一堆东西",因此只带最小集合
|
||||
if (!['ClassHelper.ClassIslandPlugin.dll', 'manifest.yml', 'icon.png', 'README.md'].includes(name)) {
|
||||
continue;
|
||||
}
|
||||
fs.copyFileSync(from, path.join(portableDir, name));
|
||||
}
|
||||
|
||||
for (const file of fs.readdirSync(cipxDir)) {
|
||||
fs.copyFileSync(path.join(cipxDir, file), path.join(releasesDir, file));
|
||||
}
|
||||
|
||||
log(`插件包已归集到 ${path.relative(repoRoot, releasesDir)}`);
|
||||
for (const file of fs.readdirSync(releasesDir)) {
|
||||
log(` ${file}`);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
/**
|
||||
* ClassIsland 联动插件静态校验:`pnpm verify:classisland-plugin`
|
||||
*
|
||||
* 为什么是"静态"校验:插件要跑起来必须有 ClassIsland 本体,而本机(以及 CI)都没有,
|
||||
* 所以这里校验"装不上 / 对不上"的那几类问题 —— 它们不需要运行就能发现:
|
||||
*
|
||||
* 1. 清单三件套一致性:manifest.yml ↔ csproj ↔ 代码里的版本号;
|
||||
* 2. 注册完整性:插件入口、提醒提供方、设置页是否都挂上了(挂漏了功能会"静默消失");
|
||||
* 3. **接口契约**:C# DTO 的 JSON 字段名 ↔ 服务端路由 / 请求字段,
|
||||
* 这类错误(比如 dayOfWeek 写成 dayofweek)在运行时只会表现为"上报成功但课表是空的",
|
||||
* 最难排查,所以专门盯住。
|
||||
*/
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
||||
const repoRoot = path.resolve(pluginDir, '..', '..');
|
||||
|
||||
const results = [];
|
||||
const check = (name, ok, detail = '') => results.push({ name, ok: Boolean(ok), detail });
|
||||
const read = (relative) => fs.readFileSync(path.join(pluginDir, relative), 'utf8');
|
||||
const readRepo = (relative) => fs.readFileSync(path.join(repoRoot, relative), 'utf8');
|
||||
|
||||
const manifest = read('manifest.yml');
|
||||
const csproj = read('ClassHelper.ClassIslandPlugin.csproj');
|
||||
const pluginSource = read('src/Plugin.cs');
|
||||
const providerSource = read('src/Services/ClassHelperNotificationProvider.cs');
|
||||
const bridgeSource = read('src/Services/BridgeService.cs');
|
||||
const clientSource = read('src/Interop/ClassHelperClient.cs');
|
||||
const settingsPageSource = read('src/Views/BridgeSettingsPage.axaml.cs');
|
||||
const settingsPageXaml = read('src/Views/BridgeSettingsPage.axaml');
|
||||
const mapperSource = read('src/Services/ScheduleMapper.cs');
|
||||
const settingsSource = read('src/Models/PluginSettings.cs');
|
||||
const serverModule = readRepo('packages/server/src/modules/integrations/integrations.module.ts');
|
||||
const serverSchemas = readRepo('packages/server/src/modules/integrations/integrations.schemas.ts');
|
||||
const serverService = readRepo('packages/server/src/modules/integrations/integrations.service.ts');
|
||||
const sharedTypes = readRepo('packages/shared/src/types.ts');
|
||||
const sharedConstants = readRepo('packages/shared/src/constants.ts');
|
||||
|
||||
/* ------------------------------------------------------------ 1. 清单一致性 */
|
||||
|
||||
/** 取 YAML 里某个顶层字段的值(prettier 会把字符串统一成单引号,两种引号都要能剥掉) */
|
||||
function field(name) {
|
||||
const raw = new RegExp('^' + name + ':\\s*(.+?)\\s*$', 'm').exec(manifest)?.[1];
|
||||
return raw?.replace(/^['"]|['"]$/g, '');
|
||||
}
|
||||
|
||||
check('manifest.yml 有插件 id', Boolean(field('id')));
|
||||
check('manifest.yml 声明了入口程序集', Boolean(field('entranceAssembly')));
|
||||
check('manifest.yml 声明了 apiVersion', /\d+\.\d+\.\d+\.\d+/.test(field('apiVersion') ?? ''));
|
||||
|
||||
const assemblyName = /<AssemblyName>([^<]+)<\/AssemblyName>/.exec(csproj)?.[1];
|
||||
check(
|
||||
'entranceAssembly 与 csproj 的 AssemblyName 一致',
|
||||
field('entranceAssembly') === assemblyName + '.dll',
|
||||
'entranceAssembly=' + field('entranceAssembly') + ' AssemblyName=' + assemblyName,
|
||||
);
|
||||
|
||||
const manifestVersion = field('version');
|
||||
const codeVersion = /PluginVersion = "([\d.]+)"/.exec(bridgeSource)?.[1];
|
||||
check(
|
||||
'插件版本号在清单与代码里一致',
|
||||
Boolean(manifestVersion) && manifestVersion === codeVersion,
|
||||
'manifest=' + manifestVersion + ' code=' + codeVersion,
|
||||
);
|
||||
|
||||
check(
|
||||
'图标与自述文件按清单约定命名(icon.png / README.md)',
|
||||
fs.existsSync(path.join(pluginDir, 'icon.png')) && fs.existsSync(path.join(pluginDir, 'README.md')),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------ 2. 注册完整性 */
|
||||
|
||||
check(
|
||||
'插件入口带 [PluginEntrance] 且继承 PluginBase',
|
||||
/\[PluginEntrance\]/.test(pluginSource) && /:\s*PluginBase/.test(pluginSource),
|
||||
);
|
||||
check(
|
||||
'初始化时注册了提醒提供方',
|
||||
/AddNotificationProvider<\s*ClassHelperNotificationProvider\s*>/.test(pluginSource),
|
||||
);
|
||||
check('初始化时注册了设置页', /AddSettingsPage<\s*BridgeSettingsPage\s*>/.test(pluginSource));
|
||||
check('初始化时把联动服务注册为单例', /AddSingleton<\s*BridgeService\s*>/.test(pluginSource));
|
||||
check('应用启动后才接课程服务(不能更早)', /AppStarted/.test(pluginSource));
|
||||
check(
|
||||
'设置以 JSON 持久化到插件配置目录',
|
||||
/PluginConfigFolder/.test(pluginSource) && /Settings\.json/.test(pluginSource),
|
||||
);
|
||||
|
||||
const providerGuid = /ProviderGuidValue = "([0-9A-Fa-f-]{36})"/.exec(providerSource)?.[1];
|
||||
check('提醒提供方声明了合法 GUID', Boolean(providerGuid));
|
||||
check(
|
||||
'提醒提供方属性引用同一个 GUID 常量',
|
||||
/NotificationProviderInfo\(\s*ProviderGuidValue/.test(providerSource),
|
||||
);
|
||||
check(
|
||||
'提醒提供方会在播完后回执给服务端',
|
||||
/Completed/.test(providerSource) && /AckAsync/.test(providerSource),
|
||||
);
|
||||
|
||||
check(
|
||||
'设置页带 [SettingsPageInfo] 且声明了页面 id',
|
||||
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"班级小助手联动"/.test(settingsPageSource),
|
||||
);
|
||||
check(
|
||||
'设置页 XAML 的 x:Class 与代码后置类一致',
|
||||
settingsPageXaml.includes('x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"') &&
|
||||
/namespace ClassHelper\.ClassIslandPlugin\.Views;/.test(settingsPageSource),
|
||||
);
|
||||
check('设置页 XAML 继承 ClassIsland 的设置页基类', /SettingsPageBase/.test(settingsPageXaml));
|
||||
|
||||
/* ------------------------------------------------------------ 3. 接口契约 */
|
||||
|
||||
// 3.1 路由:插件调用的路径必须都在服务端注册过
|
||||
const clientPaths = [...clientSource.matchAll(/"(\/classisland\/[a-z-]+)"/g)].map((item) => item[1]);
|
||||
const registeredPaths = new Set(
|
||||
[...serverModule.matchAll(/router\.(?:get|post|patch|delete)\(\s*'(\/classisland\/[a-z-]+)'/g)].map(
|
||||
(item) => item[1],
|
||||
),
|
||||
);
|
||||
for (const route of [
|
||||
'/classisland/report',
|
||||
'/classisland/pending',
|
||||
'/classisland/ack',
|
||||
'/classisland/class-plan',
|
||||
]) {
|
||||
check('服务端注册了 ' + route, registeredPaths.has(route));
|
||||
}
|
||||
check(
|
||||
'插件调用的路由都存在于服务端',
|
||||
clientPaths.length > 0 && clientPaths.every((item) => registeredPaths.has(item)),
|
||||
'插件调用=[' + clientPaths.join(',') + ']',
|
||||
);
|
||||
check('插件把接口前缀拼成 /api/integrations', clientSource.includes('/api/integrations'));
|
||||
check('插件用设备令牌头鉴权', clientSource.includes('X-ClassIsland-Token'));
|
||||
|
||||
// 3.2 上报请求字段:服务端 zod schema 里出现的字段名,插件必须真的会发
|
||||
const reportBlock = /export const classIslandReportSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
||||
const requestFields = [
|
||||
'pluginVersion',
|
||||
'classIslandVersion',
|
||||
'state',
|
||||
'schedule',
|
||||
'timeLayout',
|
||||
'inClass',
|
||||
'subject',
|
||||
'nextSubject',
|
||||
'timeState',
|
||||
'periodStart',
|
||||
'periodEnd',
|
||||
'classPlanLoaded',
|
||||
'clientTime',
|
||||
'mode',
|
||||
'entries',
|
||||
'items',
|
||||
];
|
||||
const missingRequestFields = requestFields.filter(
|
||||
(name) => reportBlock.includes(name) && !new RegExp('\\b' + name + '\\b').test(clientSource),
|
||||
);
|
||||
check(
|
||||
'上报请求字段都出现在插件 DTO 里',
|
||||
missingRequestFields.length === 0,
|
||||
missingRequestFields.length > 0 ? '缺少=' + missingRequestFields.join(',') : '',
|
||||
);
|
||||
|
||||
// 3.3 课表条目字段:这些是"上报成功但课表为空"的典型来源
|
||||
const entrySchema = /export const reportScheduleEntrySchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
||||
const entryFields = [
|
||||
'dayOfWeek',
|
||||
'startTime',
|
||||
'endTime',
|
||||
'subject',
|
||||
'teacherName',
|
||||
'weekParity',
|
||||
'weekCountDiv',
|
||||
'weekCountDivTotal',
|
||||
'planName',
|
||||
];
|
||||
const csharpJsonNames = new Set(
|
||||
[...clientSource.matchAll(/\[JsonPropertyName\("([^"]+)"\)\]/g)].map((item) => item[1]),
|
||||
);
|
||||
const missingEntryFields = entryFields.filter(
|
||||
(name) => entrySchema.includes(name) && !csharpJsonNames.has(name),
|
||||
);
|
||||
check(
|
||||
'课表条目字段服务端 zod 与插件 DTO 对齐',
|
||||
missingEntryFields.length === 0,
|
||||
missingEntryFields.length > 0 ? '插件缺少=' + missingEntryFields.join(',') : '',
|
||||
);
|
||||
|
||||
// 3.4 插件 DTO 里的 JSON 字段名必须能在共享契约里找到(防错拼)
|
||||
const knownLocalOnly = new Set(['data', 'success', 'message', 'code']);
|
||||
const unknownJsonNames = [...csharpJsonNames].filter((name) => {
|
||||
if (knownLocalOnly.has(name)) return false;
|
||||
return !new RegExp('\\b' + name + '\\b').test(sharedTypes);
|
||||
});
|
||||
check(
|
||||
'插件 DTO 的 JSON 字段名都能在 @classhelper/shared 里找到',
|
||||
unknownJsonNames.length === 0,
|
||||
unknownJsonNames.length > 0 ? '疑似拼写错误=' + unknownJsonNames.join(',') : '',
|
||||
);
|
||||
|
||||
// 3.5 课表镜像:服务端给了 Classes[i] 对齐所需的时间,插件必须读
|
||||
for (const name of ['startTime', 'endTime', 'timeLayouts', 'profileName', 'weekDay', 'weekCountDivTotal']) {
|
||||
check('镜像契约字段 ' + name + ' 在插件侧存在', csharpJsonNames.has(name));
|
||||
}
|
||||
|
||||
// 3.6 提醒下发字段(Web 端 → 服务端 → 插件)
|
||||
const notifySchema = /export const sendNotificationSchema[\s\S]*?\n\}\);/.exec(serverSchemas)?.[0] ?? '';
|
||||
for (const name of [
|
||||
'classId',
|
||||
'title',
|
||||
'content',
|
||||
'durationSeconds',
|
||||
'speech',
|
||||
'speechContent',
|
||||
'saveToNotifications',
|
||||
'priority',
|
||||
]) {
|
||||
check('下发提醒字段 ' + name + ' 在服务端 schema 里', notifySchema.includes(name));
|
||||
}
|
||||
check(
|
||||
'提醒时长有上界(避免老师发出永不消失的提醒)',
|
||||
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(serverSchemas) &&
|
||||
/CLASSISLAND_NOTIFICATION_MAX_DURATION/.test(sharedConstants),
|
||||
);
|
||||
|
||||
// 3.7 实时事件:Web 端订阅的事件名必须在共享契约里声明过
|
||||
check(
|
||||
'共享契约声明了 classisland 实时事件',
|
||||
sharedTypes.includes("'classisland:state'") && sharedTypes.includes("'classisland:notification'"),
|
||||
);
|
||||
check(
|
||||
'服务端广播事件名取自共享常量(不是手写字符串)',
|
||||
/SOCKET_EVENTS\.classislandState/.test(serverService),
|
||||
);
|
||||
|
||||
// 3.8 网络层:教室机器(以及开发机)常配了系统代理,而 Windows 的代理设置默认连 127.0.0.1
|
||||
// 也会走代理,表现为"填了令牌立刻 HTTP 404"(其实是代理自己的错误页,跟本服务无关)。
|
||||
// 插件必须对本地/内网地址绕开系统代理,否则内网部署必然失败。
|
||||
check(
|
||||
'HttpClient 显式配置代理(不裸用默认行为)',
|
||||
/SocketsHttpHandler\s*\{[^}]*Proxy\s*=/.test(clientSource),
|
||||
);
|
||||
check(
|
||||
'本地/内网地址绕开系统代理',
|
||||
/class LocalAwareProxy/.test(clientSource) && /IsBypassed\(Uri host\)/.test(clientSource),
|
||||
);
|
||||
check(
|
||||
'代理判定覆盖回环与内网网段',
|
||||
/IsLoopback/.test(clientSource) &&
|
||||
/10\.0\.0\.0\/8/.test(clientSource) &&
|
||||
/192\.168\.0\.0\/16/.test(clientSource),
|
||||
);
|
||||
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
|
||||
|
||||
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
|
||||
check(
|
||||
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
|
||||
providerSource.includes('Dispatcher.UIThread.Post'),
|
||||
);
|
||||
check(
|
||||
'同一条提醒在播放期间按 id 去重(否则轮询会让它反复弹)',
|
||||
providerSource.includes('_playing') && providerSource.includes('HashSet<string>'),
|
||||
);
|
||||
check(
|
||||
'不再自造图标串(用模板默认图标,紧急用滚动文本表达)',
|
||||
// 不再给 CreateTwoIconsMask 传 leftIcon(自造图标串会踩格式/字形坑)
|
||||
!providerSource.includes('leftIcon') && providerSource.includes('CreateRollingTextContent'),
|
||||
);
|
||||
check(
|
||||
'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"',
|
||||
mapperSource.includes('Subject.Fallback'),
|
||||
);
|
||||
check(
|
||||
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
||||
settingsSource.includes('NotificationPollSeconds') &&
|
||||
bridgeSource.includes('_notifyTimer') &&
|
||||
bridgeSource.includes('NotificationPollSeconds'),
|
||||
);
|
||||
|
||||
/* ------------------------------------------------------------ 结果 */
|
||||
|
||||
const failed = results.filter((item) => !item.ok);
|
||||
for (const item of results) {
|
||||
console.log((item.ok ? '√ ' : '× ') + item.name + (item.detail ? ' (' + item.detail + ')' : ''));
|
||||
}
|
||||
console.log('');
|
||||
console.log(
|
||||
'ClassIsland 联动插件静态校验:' + (results.length - failed.length) + '/' + results.length + ' 项通过',
|
||||
);
|
||||
if (failed.length > 0) {
|
||||
console.log('未通过:\n - ' + failed.map((item) => item.name).join('\n - '));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Sockets;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using ClassHelper.ClassIslandPlugin.Models;
|
||||
|
||||
namespace ClassHelper.ClassIslandPlugin.Interop;
|
||||
|
||||
/* ---------------------------------------------------------------- 服务端契约(与 packages/shared 保持一致) */
|
||||
|
||||
/// <summary>插件上报的课表条目。</summary>
|
||||
public class ScheduleEntryDto
|
||||
{
|
||||
[JsonPropertyName("dayOfWeek")] public int DayOfWeek { get; set; }
|
||||
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
||||
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
||||
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
|
||||
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
||||
/// <summary>ALL / ODD / EVEN;留空则由服务端按 weekCountDiv 推导。</summary>
|
||||
[JsonPropertyName("weekParity")] public string? WeekParity { get; set; }
|
||||
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
|
||||
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
|
||||
[JsonPropertyName("planName")] public string? PlanName { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>插件上报的节次时间。</summary>
|
||||
public class TimeLayoutItemDto
|
||||
{
|
||||
[JsonPropertyName("index")] public int Index { get; set; }
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
||||
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
||||
/// <summary>class / break / divider / action</summary>
|
||||
[JsonPropertyName("type")] public string Type { get; set; } = "class";
|
||||
[JsonPropertyName("skipped")] public bool Skipped { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>本机运行状态快照。</summary>
|
||||
public class StateDto
|
||||
{
|
||||
[JsonPropertyName("inClass")] public bool InClass { get; set; }
|
||||
[JsonPropertyName("subject")] public string? Subject { get; set; }
|
||||
[JsonPropertyName("nextSubject")] public string? NextSubject { get; set; }
|
||||
[JsonPropertyName("timeState")] public string? TimeState { get; set; }
|
||||
[JsonPropertyName("periodStart")] public string? PeriodStart { get; set; }
|
||||
[JsonPropertyName("periodEnd")] public string? PeriodEnd { get; set; }
|
||||
[JsonPropertyName("week")] public int? Week { get; set; }
|
||||
[JsonPropertyName("classPlanLoaded")] public bool ClassPlanLoaded { get; set; }
|
||||
[JsonPropertyName("clientTime")] public string ClientTime { get; set; } = "";
|
||||
}
|
||||
|
||||
public class SchedulePayloadDto
|
||||
{
|
||||
[JsonPropertyName("mode")] public string Mode { get; set; } = "merge";
|
||||
[JsonPropertyName("entries")] public List<ScheduleEntryDto> Entries { get; set; } = new();
|
||||
}
|
||||
|
||||
public class TimeLayoutPayloadDto
|
||||
{
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "ClassIsland 时间表";
|
||||
[JsonPropertyName("mode")] public string Mode { get; set; } = "replace";
|
||||
[JsonPropertyName("items")] public List<TimeLayoutItemDto> Items { get; set; } = new();
|
||||
}
|
||||
|
||||
public class ReportRequestDto
|
||||
{
|
||||
[JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = "";
|
||||
[JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = "";
|
||||
[JsonPropertyName("state")] public StateDto? State { get; set; }
|
||||
[JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; }
|
||||
[JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; }
|
||||
}
|
||||
|
||||
public class ReportSettingsDto
|
||||
{
|
||||
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
|
||||
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
|
||||
}
|
||||
|
||||
public class ReportResultDto
|
||||
{
|
||||
[JsonPropertyName("scheduleApplied")] public bool ScheduleApplied { get; set; }
|
||||
[JsonPropertyName("scheduleCreated")] public int ScheduleCreated { get; set; }
|
||||
[JsonPropertyName("scheduleUpdated")] public int ScheduleUpdated { get; set; }
|
||||
[JsonPropertyName("courses")] public List<string> Courses { get; set; } = new();
|
||||
[JsonPropertyName("timeLayoutApplied")] public bool TimeLayoutApplied { get; set; }
|
||||
[JsonPropertyName("week")] public int Week { get; set; }
|
||||
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
||||
[JsonPropertyName("settings")] public ReportSettingsDto? Settings { get; set; }
|
||||
[JsonPropertyName("pendingNotification")] public PushNotificationDto? PendingNotification { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>服务端下发的提醒。</summary>
|
||||
public class PushNotificationDto
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("title")] public string Title { get; set; } = "";
|
||||
[JsonPropertyName("content")] public string Content { get; set; } = "";
|
||||
[JsonPropertyName("durationSeconds")] public int DurationSeconds { get; set; } = 8;
|
||||
[JsonPropertyName("speechContent")] public string? SpeechContent { get; set; }
|
||||
[JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = "";
|
||||
[JsonPropertyName("urgent")] public bool Urgent { get; set; }
|
||||
[JsonPropertyName("classId")] public string ClassId { get; set; } = "";
|
||||
[JsonPropertyName("className")] public string? ClassName { get; set; }
|
||||
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
||||
}
|
||||
|
||||
public class PendingResultDto
|
||||
{
|
||||
[JsonPropertyName("notifications")] public List<PushNotificationDto> Notifications { get; set; } = new();
|
||||
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
||||
[JsonPropertyName("week")] public int Week { get; set; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 课表镜像(服务端 → ClassIsland) */
|
||||
|
||||
public class ClassPlanEntryMirrorDto
|
||||
{
|
||||
[JsonPropertyName("weekDay")] public int WeekDay { get; set; }
|
||||
[JsonPropertyName("weekCountDiv")] public int WeekCountDiv { get; set; }
|
||||
[JsonPropertyName("weekCountDivTotal")] public int WeekCountDivTotal { get; set; }
|
||||
[JsonPropertyName("subject")] public string Subject { get; set; } = "";
|
||||
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
||||
/// <summary>起止时间(HH:mm)。Classes[i] 必须与第 i 个上课时间点对齐,靠它重建时间表。</summary>
|
||||
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
||||
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
||||
[JsonPropertyName("timeLayoutId")] public string TimeLayoutId { get; set; } = "";
|
||||
}
|
||||
|
||||
public class TimeLayoutMirrorDto
|
||||
{
|
||||
[JsonPropertyName("id")] public string Id { get; set; } = "";
|
||||
[JsonPropertyName("name")] public string Name { get; set; } = "";
|
||||
[JsonPropertyName("layouts")] public List<TimeLayoutItemMirrorDto> Layouts { get; set; } = new();
|
||||
}
|
||||
|
||||
public class TimeLayoutItemMirrorDto
|
||||
{
|
||||
[JsonPropertyName("startTime")] public string StartTime { get; set; } = "";
|
||||
[JsonPropertyName("endTime")] public string EndTime { get; set; } = "";
|
||||
[JsonPropertyName("timeType")] public int TimeType { get; set; }
|
||||
}
|
||||
|
||||
public class ClassPlanMirrorDto
|
||||
{
|
||||
[JsonPropertyName("entries")] public List<ClassPlanEntryMirrorDto> Entries { get; set; } = new();
|
||||
[JsonPropertyName("timeLayouts")] public List<TimeLayoutMirrorDto> TimeLayouts { get; set; } = new();
|
||||
[JsonPropertyName("profileName")] public string ProfileName { get; set; } = "";
|
||||
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
|
||||
}
|
||||
|
||||
public class PullResultDto
|
||||
{
|
||||
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
|
||||
[JsonPropertyName("week")] public int Week { get; set; }
|
||||
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 统一响应体 */
|
||||
|
||||
internal class ApiEnvelope<T>
|
||||
{
|
||||
[JsonPropertyName("success")] public bool Success { get; set; }
|
||||
[JsonPropertyName("data")] public T? Data { get; set; }
|
||||
[JsonPropertyName("message")] public string Message { get; set; } = "";
|
||||
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>调用班级小助手后端的结果(不抛异常,便于设置页展示原因)。</summary>
|
||||
public class ApiCallResult
|
||||
{
|
||||
public bool Ok { get; init; }
|
||||
public string Message { get; init; } = "";
|
||||
public bool NetworkError { get; init; }
|
||||
|
||||
public static ApiCallResult Success(string message) => new() { Ok = true, Message = message };
|
||||
public static ApiCallResult Failure(string message) => new() { Ok = false, Message = message };
|
||||
public static ApiCallResult Offline(string message) => new() { Ok = false, Message = message, NetworkError = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 班级小助手后端客户端。
|
||||
///
|
||||
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
|
||||
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
|
||||
/// (见 https://docs.classisland.tech/dev/plugins/basics.html 的程序集隔离说明)。
|
||||
/// </summary>
|
||||
public sealed class ClassHelperClient : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// 单次请求超时。教室机器可能是内网慢链路,但也不该卡住上报线程太久。
|
||||
/// </summary>
|
||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12);
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNameCaseInsensitive = true,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 与班级小助手通信的 HttpClient。
|
||||
///
|
||||
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
|
||||
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
|
||||
/// 那个 404 其实是**代理自己的错误页**,跟本服务毫无关系。
|
||||
/// 因此这里用 <see cref="LocalAwareProxy"/>:本地/内网地址直连,公网地址照旧走系统代理。
|
||||
/// </summary>
|
||||
private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() })
|
||||
{
|
||||
Timeout = Timeout,
|
||||
};
|
||||
|
||||
public string LastError { get; private set; } = "";
|
||||
public DateTimeOffset? LastSuccessAt { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 包装系统代理:**本地/内网地址标记为"绕过"**,其余交给系统代理决定。
|
||||
///
|
||||
/// 判定为本地/内网的情形:localhost、回环地址、IPv4/IPv6 私有段与链路本地段,
|
||||
/// 以及不含点的主机名(`server` / `classhelper` 这类校内机器名,DNS 也可能解析不出来)。
|
||||
/// 判定为公网时保持原行为,避免"云服务器 + 公司代理"这种场景被我们改坏。
|
||||
/// </summary>
|
||||
private sealed class LocalAwareProxy : IWebProxy
|
||||
{
|
||||
private readonly IWebProxy _inner = HttpClient.DefaultProxy;
|
||||
|
||||
// 只读透传即可:HttpClient.DefaultProxy 是全局单例,改它的凭据会波及宿主进程里的其它请求
|
||||
public ICredentials? Credentials { get; set; }
|
||||
|
||||
public Uri? GetProxy(Uri destination) => _inner.GetProxy(destination);
|
||||
|
||||
public bool IsBypassed(Uri host) => IsLocalHost(host) || _inner.IsBypassed(host);
|
||||
|
||||
/// <summary>该地址是否属于"不该走代理"的本地/内网范围。</summary>
|
||||
internal static bool IsLocalHost(Uri uri)
|
||||
{
|
||||
var host = uri.Host;
|
||||
if (host.Equals("localhost", StringComparison.OrdinalIgnoreCase)) return true;
|
||||
// 不含点的主机名(校内机器名)走直连:解析成内网 IP 的可能性远大于公网
|
||||
if (!host.Contains('.')) return true;
|
||||
|
||||
if (!IPAddress.TryParse(host, out var address)) return false;
|
||||
if (IPAddress.IsLoopback(address)) return true;
|
||||
|
||||
if (address.AddressFamily == AddressFamily.InterNetwork)
|
||||
{
|
||||
var bytes = address.GetAddressBytes();
|
||||
return bytes[0] == 10 // 10.0.0.0/8
|
||||
|| (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) // 172.16.0.0/12
|
||||
|| (bytes[0] == 192 && bytes[1] == 168) // 192.168.0.0/16
|
||||
|| (bytes[0] == 169 && bytes[1] == 254); // 169.254.0.0/16(链路本地)
|
||||
}
|
||||
|
||||
// IPv6:fc00::/7(唯一本地地址)与 fe80::/10(链路本地)
|
||||
var first = address.GetAddressBytes()[0];
|
||||
return (first & 0xFE) == 0xFC || (first & 0xC0) == 0x80;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>拼接 API 地址,容忍用户填了带/不带末尾斜杠、甚至带 /api 后缀的地址。</summary>
|
||||
private static string BuildUrl(string serverUrl, string path)
|
||||
{
|
||||
var baseUrl = (serverUrl ?? "").Trim().TrimEnd('/');
|
||||
if (baseUrl.EndsWith("/api", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
baseUrl = baseUrl[..^4];
|
||||
}
|
||||
return $"{baseUrl}/api/integrations{path}";
|
||||
}
|
||||
|
||||
private HttpRequestMessage BuildRequest(HttpMethod method, string serverUrl, string path, string token)
|
||||
{
|
||||
var request = new HttpRequestMessage(method, BuildUrl(serverUrl, path));
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-ClassIsland-Token", token);
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
private async Task<ApiEnvelope<T>?> SendAsync<T>(
|
||||
HttpMethod method,
|
||||
string serverUrl,
|
||||
string path,
|
||||
string token,
|
||||
object? body,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var request = BuildRequest(method, serverUrl, path, token);
|
||||
if (body is not null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(body, JsonOptions);
|
||||
request.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
using var response = await _http.SendAsync(request, cancellationToken).ConfigureAwait(false);
|
||||
var text = await response.Content.ReadAsStringAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
ApiEnvelope<T>? envelope = null;
|
||||
try
|
||||
{
|
||||
envelope = JsonSerializer.Deserialize<ApiEnvelope<T>>(text, JsonOptions);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// 非 JSON 响应(例如被网关拦截)——交给下面的兜底分支
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var message = envelope?.Message;
|
||||
if (string.IsNullOrWhiteSpace(message))
|
||||
{
|
||||
// 响应不是本服务的统一响应体:最常见的原因就是被系统代理 / 网关拦下来
|
||||
// (典型表现:HTTP 404 且 body 是一张 HTML 错误页)。把 URL 一起报出来,
|
||||
// 老师截图给我们就能一眼定位。
|
||||
message = envelope is null
|
||||
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
||||
"响应不是班级小助手的标准格式,请检查服务器地址是否正确、" +
|
||||
"或该系统代理是否拦截了内网请求"
|
||||
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
|
||||
}
|
||||
LastError = message!;
|
||||
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
|
||||
}
|
||||
|
||||
LastError = "";
|
||||
LastSuccessAt = DateTimeOffset.Now;
|
||||
return envelope;
|
||||
}
|
||||
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
LastError = "请求超时(请检查服务器地址与网络)";
|
||||
return null;
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
LastError = $"无法连接班级小助手服务:{exception.Message}";
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
LastError = exception.Message;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>上报状态 +(可选)课表。</summary>
|
||||
public async Task<(ApiCallResult Result, ReportResultDto? Data)> ReportAsync(
|
||||
PluginSettings settings,
|
||||
ReportRequestDto payload,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var envelope = await SendAsync<ReportResultDto>(
|
||||
HttpMethod.Post, settings.ServerUrl, "/classisland/report", settings.DeviceToken, payload, cancellationToken);
|
||||
if (envelope is null)
|
||||
{
|
||||
return (ApiCallResult.Offline(LastError), null);
|
||||
}
|
||||
if (!envelope.Success || envelope.Data is null)
|
||||
{
|
||||
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
|
||||
}
|
||||
return (ApiCallResult.Success(envelope.Message), envelope.Data);
|
||||
}
|
||||
|
||||
/// <summary>拉取尚未确认的下发提醒(插件启动/重连时补齐)。</summary>
|
||||
public async Task<(ApiCallResult Result, List<PushNotificationDto> Items)> PendingAsync(
|
||||
PluginSettings settings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var envelope = await SendAsync<PendingResultDto>(
|
||||
HttpMethod.Get, settings.ServerUrl, "/classisland/pending", settings.DeviceToken, null, cancellationToken);
|
||||
if (envelope is null) return (ApiCallResult.Offline(LastError), new List<PushNotificationDto>());
|
||||
if (!envelope.Success || envelope.Data is null)
|
||||
{
|
||||
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError),
|
||||
new List<PushNotificationDto>());
|
||||
}
|
||||
return (ApiCallResult.Success(envelope.Message), envelope.Data.Notifications);
|
||||
}
|
||||
|
||||
/// <summary>确认提醒已弹出(之后服务端不再补发)。</summary>
|
||||
public async Task<ApiCallResult> AckAsync(PluginSettings settings, string id, CancellationToken cancellationToken)
|
||||
{
|
||||
var envelope = await SendAsync<JsonElement>(
|
||||
HttpMethod.Post, settings.ServerUrl, "/classisland/ack", settings.DeviceToken, new { id }, cancellationToken);
|
||||
if (envelope is null) return ApiCallResult.Offline(LastError);
|
||||
return envelope.Success
|
||||
? ApiCallResult.Success("已确认")
|
||||
: ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError);
|
||||
}
|
||||
|
||||
/// <summary>拉取班级课表(用于镜像回 ClassIsland)。未开启镜像时服务端返回 data=null。</summary>
|
||||
public async Task<(ApiCallResult Result, ClassPlanMirrorDto? Plan)> PullClassPlanAsync(
|
||||
PluginSettings settings,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var envelope = await SendAsync<PullResultDto>(
|
||||
HttpMethod.Get, settings.ServerUrl, "/classisland/class-plan", settings.DeviceToken, null, cancellationToken);
|
||||
if (envelope is null) return (ApiCallResult.Offline(LastError), null);
|
||||
if (!envelope.Success)
|
||||
{
|
||||
return (ApiCallResult.Failure(envelope.Message.Length > 0 ? envelope.Message : LastError), null);
|
||||
}
|
||||
if (envelope.Data?.ClassPlan is null)
|
||||
{
|
||||
return (ApiCallResult.Failure(envelope.Message.Length > 0
|
||||
? envelope.Message
|
||||
: "该设备未开启「把班级课表镜像到 ClassIsland」"), null);
|
||||
}
|
||||
return (ApiCallResult.Success(envelope.Message), envelope.Data.ClassPlan);
|
||||
}
|
||||
|
||||
public void Dispose() => _http.Dispose();
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
using CommunityToolkit.Mvvm.ComponentModel;
|
||||
|
||||
namespace ClassHelper.ClassIslandPlugin.Models;
|
||||
|
||||
/// <summary>
|
||||
/// 插件设置。
|
||||
///
|
||||
/// 存储由 ClassIsland 负责:插件入口把它保存在 <c>PluginConfigFolder/Settings.json</c>
|
||||
/// (**不要**放在插件安装目录,那里不会随档案备份、升级时还会被清掉)。
|
||||
/// 因此这里必须继承 <see cref="ObservableObject"/> 并让每个属性都走 <c>SetProperty</c>:
|
||||
/// ClassIsland 通过监听 <c>PropertyChanged</c> 来即时保存。
|
||||
/// </summary>
|
||||
public class PluginSettings : ObservableObject
|
||||
{
|
||||
private string _serverUrl = "http://127.0.0.1:4000";
|
||||
private string _deviceToken = "";
|
||||
private string _deviceKey = "";
|
||||
private bool _autoReport = true;
|
||||
private int _reportIntervalSeconds = 60;
|
||||
private bool _uploadSchedule = true;
|
||||
private bool _mirrorSchedule = false;
|
||||
private bool _receiveNotifications = true;
|
||||
private int _notificationDurationSeconds = 8;
|
||||
private int _notificationPollSeconds = 10;
|
||||
private bool _speechEnabled = false;
|
||||
private bool _showDebugInfo = false;
|
||||
|
||||
/// <summary>
|
||||
/// 班级小助手后端地址,例如 <c>http://127.0.0.1:4000</c>。
|
||||
/// </summary>
|
||||
public string ServerUrl
|
||||
{
|
||||
get => _serverUrl;
|
||||
set => SetProperty(ref _serverUrl, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设备令牌(形如 <c>chci_...</c>):在班级小助手 Web 端「ClassIsland 联动」页面生成。
|
||||
/// </summary>
|
||||
public string DeviceToken
|
||||
{
|
||||
get => _deviceToken;
|
||||
set => SetProperty(ref _deviceToken, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 本机标识(首次生成后固定):服务端据此把同一台机器识别为同一设备,重装插件不会产生重复设备。
|
||||
/// </summary>
|
||||
public string DeviceKey
|
||||
{
|
||||
get => _deviceKey;
|
||||
set => SetProperty(ref _deviceKey, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否自动上报:启动后、课表变化时、以及每隔 <see cref="ReportIntervalSeconds"/> 秒各上报一次。
|
||||
/// </summary>
|
||||
public bool AutoReport
|
||||
{
|
||||
get => _autoReport;
|
||||
set => SetProperty(ref _autoReport, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 定时上报间隔(秒),最小 15 秒。
|
||||
/// </summary>
|
||||
public int ReportIntervalSeconds
|
||||
{
|
||||
get => _reportIntervalSeconds;
|
||||
set => SetProperty(ref _reportIntervalSeconds, Math.Clamp(value, 15, 3600));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否把 ClassIsland 的课表同步到班级小助手。
|
||||
/// 关闭后仍会上报"当前上什么课"的状态,只是不再写课表。
|
||||
/// </summary>
|
||||
public bool UploadSchedule
|
||||
{
|
||||
get => _uploadSchedule;
|
||||
set => SetProperty(ref _uploadSchedule, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否把班级小助手上排好的课表镜像回 ClassIsland
|
||||
/// (新建一份名为「班级小助手-<班级名>」的档案课表,不覆盖老师原有的课表)。
|
||||
/// </summary>
|
||||
public bool MirrorSchedule
|
||||
{
|
||||
get => _mirrorSchedule;
|
||||
set => SetProperty(ref _mirrorSchedule, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否接收老师在班级小助手上发布的提醒,并在 ClassIsland 上弹出。
|
||||
/// </summary>
|
||||
public bool ReceiveNotifications
|
||||
{
|
||||
get => _receiveNotifications;
|
||||
set => SetProperty(ref _receiveNotifications, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提醒轮询间隔(秒),最小 5 秒。
|
||||
///
|
||||
/// 插件与后端之间没有长连接(刻意不引入 Socket 依赖),提醒是"轮询取回"的,
|
||||
/// 因此这个值就是**老师按下发送到教室弹出之间的最大延迟**。
|
||||
/// 单独一个轻量轮询(只查待提醒,不上传课表)比把上报间隔改小划算得多。
|
||||
/// </summary>
|
||||
public int NotificationPollSeconds
|
||||
{
|
||||
get => _notificationPollSeconds;
|
||||
set => SetProperty(ref _notificationPollSeconds, Math.Clamp(value, 5, 600));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 默认提醒显示时长(秒);老师在下发时可以单独指定更长的时长。
|
||||
/// </summary>
|
||||
public int NotificationDurationSeconds
|
||||
{
|
||||
get => _notificationDurationSeconds;
|
||||
set => SetProperty(ref _notificationDurationSeconds, Math.Clamp(value, 1, 120));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 提醒弹出时是否语音朗读(沿用 ClassIsland 的语音设置与语音提供方)。
|
||||
/// </summary>
|
||||
public bool SpeechEnabled
|
||||
{
|
||||
get => _speechEnabled;
|
||||
set => SetProperty(ref _speechEnabled, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否在设置页显示调试信息(最后一次上报结果、课表条目数等),排查问题时打开。
|
||||
/// </summary>
|
||||
public bool ShowDebugInfo
|
||||
{
|
||||
get => _showDebugInfo;
|
||||
set => SetProperty(ref _showDebugInfo, value);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 兼容旧配置:反序列化后把 DeviceKey 补齐(首次运行时由服务生成)。
|
||||
/// </summary>
|
||||
public void Normalize()
|
||||
{
|
||||
ServerUrl = (ServerUrl ?? "").Trim().TrimEnd('/');
|
||||
DeviceToken = (DeviceToken ?? "").Trim();
|
||||
DeviceKey = (DeviceKey ?? "").Trim();
|
||||
ReportIntervalSeconds = Math.Clamp(ReportIntervalSeconds, 15, 3600);
|
||||
NotificationDurationSeconds = Math.Clamp(NotificationDurationSeconds, 1, 120);
|
||||
NotificationPollSeconds = Math.Clamp(NotificationPollSeconds, 5, 600);
|
||||
}
|
||||
|
||||
/// <summary>是否已经配置好(地址 + 令牌都填了)。</summary>
|
||||
public bool IsConfigured =>
|
||||
Uri.TryCreate(ServerUrl, UriKind.Absolute, out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
using System.Text.Json;
|
||||
using ClassHelper.ClassIslandPlugin.Models;
|
||||
using ClassHelper.ClassIslandPlugin.Services;
|
||||
using ClassHelper.ClassIslandPlugin.Views;
|
||||
using ClassIsland.Core;
|
||||
using ClassIsland.Core.Abstractions;
|
||||
using ClassIsland.Core.Abstractions.Services;
|
||||
using ClassIsland.Core.Attributes;
|
||||
using ClassIsland.Core.Extensions.Registry;
|
||||
using ClassIsland.Shared;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClassHelper.ClassIslandPlugin;
|
||||
|
||||
/// <summary>
|
||||
/// 插件入口。
|
||||
///
|
||||
/// 职责只有三件:读配置 → 注册扩展(提醒提供方 / 设置页 / 联动服务)→ 在应用启动后接上课程服务。
|
||||
/// 真正的联动逻辑都在 <see cref="BridgeService"/> 里,入口保持"看一眼就知道装了哪些东西"。
|
||||
/// </summary>
|
||||
[PluginEntrance]
|
||||
public class Plugin : PluginBase
|
||||
{
|
||||
/// <summary>插件设置文件名。存在 <see cref="PluginBase.PluginConfigFolder"/> 下:随档案备份走,升级插件不丢。</summary>
|
||||
private const string SettingsFileName = "Settings.json";
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
|
||||
};
|
||||
|
||||
/// <summary>插件设置。注册成单例后,设置页与 <see cref="BridgeService"/> 拿到的是同一个对象。</summary>
|
||||
public PluginSettings Settings { get; private set; } = new();
|
||||
|
||||
/// <summary>联动只接一次(AppStarted 与"运行中启用"两条路都会走到这里)</summary>
|
||||
private bool _bridgeStarted;
|
||||
|
||||
public override void Initialize(HostBuilderContext context, IServiceCollection services)
|
||||
{
|
||||
Settings = LoadSettings();
|
||||
|
||||
// 设置页与联动服务共用一个实例:设置页改了值,服务立刻能看到(不需要重启 ClassIsland)
|
||||
services.AddSingleton(Settings);
|
||||
services.AddSingleton<BridgeService>();
|
||||
|
||||
services.AddSettingsPage<BridgeSettingsPage>();
|
||||
services.AddNotificationProvider<ClassHelperNotificationProvider>();
|
||||
|
||||
// 初始化阶段课程服务还没准备好(它在主机启动流程里才被创建),
|
||||
// 因此这里先挂一个"应用已启动"的回调,等到那时再取 ILessonsService 并开始上报。
|
||||
var application = AppBase.Current;
|
||||
if (application is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到 ClassIsland 应用实例,联动未启动");
|
||||
return;
|
||||
}
|
||||
|
||||
application.AppStarted += (_, _) => StartBridge();
|
||||
|
||||
// 插件是"运行中才被启用"时,AppStarted 早就过去了 —— 主窗口已经存在就直接接一次
|
||||
if (application.MainWindow is not null) StartBridge();
|
||||
}
|
||||
|
||||
private void StartBridge()
|
||||
{
|
||||
if (_bridgeStarted) return;
|
||||
try
|
||||
{
|
||||
var lessons = IAppHost.TryGetService<ILessonsService>();
|
||||
if (lessons is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到课程服务,联动已停用");
|
||||
return;
|
||||
}
|
||||
|
||||
var bridge = IAppHost.TryGetService<BridgeService>();
|
||||
if (bridge is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到联动服务,联动已停用");
|
||||
return;
|
||||
}
|
||||
|
||||
_bridgeStarted = true;
|
||||
bridge.Attach(lessons);
|
||||
bridge.RestartTimer();
|
||||
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
|
||||
// 不这么做的话,最长要等一个上报间隔才会弹出来
|
||||
_ = bridge.PullPendingAsync();
|
||||
Logger?.LogInformation("班级小助手联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 联动失败绝不能把 ClassIsland 本体拖下水
|
||||
Logger?.LogError(exception, "班级小助手联动:初始化联动服务失败");
|
||||
}
|
||||
}
|
||||
|
||||
private ILogger<Plugin>? Logger => IAppHost.TryGetService<ILogger<Plugin>>();
|
||||
|
||||
/// <summary>
|
||||
/// 读取设置。首次运行时 <c>DeviceKey</c> 为空,这里补一个随机值并立即落盘 ——
|
||||
/// 服务端用它把同一台机器识别为同一设备,重装插件不会堆出重复设备。
|
||||
/// </summary>
|
||||
private PluginSettings LoadSettings()
|
||||
{
|
||||
var path = Path.Combine(PluginConfigFolder, SettingsFileName);
|
||||
var settings = new PluginSettings();
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
var loaded = JsonSerializer.Deserialize<PluginSettings>(File.ReadAllText(path), JsonOptions);
|
||||
if (loaded is not null) settings = loaded;
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
|
||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||
"班级小助手联动:读取设置失败,已回退到默认设置({Path})", path);
|
||||
}
|
||||
|
||||
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
|
||||
settings.Normalize();
|
||||
if (needGenerateKey)
|
||||
{
|
||||
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车
|
||||
var machine = Environment.MachineName.ToLowerInvariant();
|
||||
settings.DeviceKey = $"{machine}-{Guid.NewGuid():N}"[..Math.Min(24, machine.Length + 33)];
|
||||
}
|
||||
|
||||
Settings = settings;
|
||||
// 落盘:一是固定首次生成的 DeviceKey,二是把缺字段的旧配置补齐成完整文件
|
||||
SaveSettings(settings, path);
|
||||
// 设置变化时即时保存
|
||||
settings.PropertyChanged += (_, _) => SaveSettings(settings, path);
|
||||
return settings;
|
||||
}
|
||||
|
||||
private static void SaveSettings(PluginSettings settings, string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
var folder = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
|
||||
File.WriteAllText(path, JsonSerializer.Serialize(settings, JsonOptions));
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||
"班级小助手联动:保存设置失败({Path})", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,537 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using ClassHelper.ClassIslandPlugin.Interop;
|
||||
using ClassHelper.ClassIslandPlugin.Models;
|
||||
using ClassIsland.Core;
|
||||
using ClassIsland.Core.Abstractions.Services;
|
||||
using ClassIsland.Core.Models.Notification;
|
||||
using ClassIsland.Shared;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 联动主循环。三件事:
|
||||
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
|
||||
/// 把"当前上什么课"与(可选的)全量课表推给班级小助手;
|
||||
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
|
||||
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
|
||||
/// 3. <b>镜像</b>:开启镜像开关时,把班级小助手上排好的课表写回 ClassIsland 档案。
|
||||
///
|
||||
/// 为什么用"上报即拉取"而不是长连接:
|
||||
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
|
||||
/// 而教室机器的上报间隔本身就是心跳 —— 顺路把待提醒带回来,依赖更少、故障面更小。
|
||||
///
|
||||
/// 线程约定:HttpClient 回调在线程池上,因此所有跨线程可见的状态都用 <c>lock</c> 或
|
||||
/// 赋值引用(<c>string</c> / <c>int</c>)保护,属性变化通过 <see cref="PropertyChanged"/> 通知设置页。
|
||||
/// </summary>
|
||||
public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
private readonly PluginSettings _settings;
|
||||
private readonly ILogger<BridgeService> _logger;
|
||||
private readonly ClassHelperClient _client = new();
|
||||
private readonly object _gate = new();
|
||||
|
||||
private ILessonsService? _lessons;
|
||||
private Timer? _timer;
|
||||
private Timer? _notifyTimer;
|
||||
private bool _disposed;
|
||||
private bool _reporting;
|
||||
private bool _pulling;
|
||||
private bool _eventsHooked;
|
||||
|
||||
public BridgeService(PluginSettings settings, ILogger<BridgeService> logger)
|
||||
{
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public event PropertyChangedEventHandler? PropertyChanged;
|
||||
|
||||
/// <summary>上报结果变化时触发(设置页据此刷新)。</summary>
|
||||
public event EventHandler? StateChanged;
|
||||
|
||||
/// <summary>需要把提醒丢给提醒提供方去显示。</summary>
|
||||
public event EventHandler<PushNotificationDto>? NotificationReceived;
|
||||
|
||||
/* ---------------------------------------------------------------- 供设置页绑定的状态 */
|
||||
|
||||
private string _lastReportMessage = "尚未上报";
|
||||
|
||||
/// <summary>最近一次上报的结果描述。</summary>
|
||||
public string LastReportMessage
|
||||
{
|
||||
get => _lastReportMessage;
|
||||
private set => SetField(ref _lastReportMessage, value);
|
||||
}
|
||||
|
||||
private bool _lastReportOk;
|
||||
public bool LastReportOk
|
||||
{
|
||||
get => _lastReportOk;
|
||||
private set => SetField(ref _lastReportOk, value);
|
||||
}
|
||||
|
||||
private DateTimeOffset? _lastReportAt;
|
||||
public DateTimeOffset? LastReportAt
|
||||
{
|
||||
get => _lastReportAt;
|
||||
private set
|
||||
{
|
||||
if (SetField(ref _lastReportAt, value)) Raise(nameof(LastReportAtText));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>供界面直接显示的"最后上报时间"。</summary>
|
||||
public string LastReportAtText => LastReportAt is null
|
||||
? "从未"
|
||||
: LastReportAt.Value.LocalDateTime.ToString("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private int _lastScheduleEntryCount;
|
||||
/// <summary>最近一次上报里的课表条目数。</summary>
|
||||
public int LastScheduleEntryCount
|
||||
{
|
||||
get => _lastScheduleEntryCount;
|
||||
private set => SetField(ref _lastScheduleEntryCount, value);
|
||||
}
|
||||
|
||||
private int _lastTimeLayoutItemCount;
|
||||
/// <summary>最近一次上报里的节次数量。</summary>
|
||||
public int LastTimeLayoutItemCount
|
||||
{
|
||||
get => _lastTimeLayoutItemCount;
|
||||
private set => SetField(ref _lastTimeLayoutItemCount, value);
|
||||
}
|
||||
|
||||
private int? _lastServerWeek;
|
||||
/// <summary>服务端返回的教学周。</summary>
|
||||
public int? LastServerWeek
|
||||
{
|
||||
get => _lastServerWeek;
|
||||
private set
|
||||
{
|
||||
if (SetField(ref _lastServerWeek, value)) Raise(nameof(ClientTimeOffsetText));
|
||||
}
|
||||
}
|
||||
|
||||
private int _notificationCount;
|
||||
/// <summary>已弹出并回执的提醒数量。</summary>
|
||||
public int NotificationCount
|
||||
{
|
||||
get => _notificationCount;
|
||||
private set => SetField(ref _notificationCount, value);
|
||||
}
|
||||
|
||||
private bool _busy;
|
||||
/// <summary>是否正在上报("立即上报"按钮据此禁用)。</summary>
|
||||
public bool Busy
|
||||
{
|
||||
get => _busy;
|
||||
private set => SetField(ref _busy, value);
|
||||
}
|
||||
|
||||
private TimeSpan? _serverTimeOffset;
|
||||
/// <summary>服务端与本机的时钟差(诊断用:差得多说明教室机器时间不对)。</summary>
|
||||
public string ClientTimeOffsetText => _serverTimeOffset is null
|
||||
? "—"
|
||||
: $"{_serverTimeOffset.Value.TotalSeconds:+0;-0;0} 秒";
|
||||
|
||||
/// <summary>当前配置是否完整(设置页据此提示"还没填令牌")。</summary>
|
||||
public bool IsConfigured => _settings.IsConfigured;
|
||||
|
||||
/// <summary>是否已接入课程服务(拿到 ILessonsService 才算真正跑起来)。</summary>
|
||||
public bool IsAttached
|
||||
{
|
||||
get { lock (_gate) return _lessons is not null; }
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 生命周期 */
|
||||
|
||||
/// <summary>接入课程服务并订阅课程事件(由插件入口在 AppStarted 时调用)。</summary>
|
||||
public void Attach(ILessonsService lessons)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_lessons = lessons;
|
||||
}
|
||||
|
||||
if (!_eventsHooked)
|
||||
{
|
||||
// 上课 / 下课 / 放学 / 状态变化时立刻上报一次,不等到下一个定时点。
|
||||
// 这些事件在 ClassIsland 的公开约定里是在**主线程**触发的,可以直接订阅。
|
||||
lessons.OnClass += (_, _) => RequestReport("上课");
|
||||
lessons.OnBreakingTime += (_, _) => RequestReport("下课");
|
||||
lessons.OnAfterSchool += (_, _) => RequestReport("放学");
|
||||
lessons.CurrentTimeStateChanged += (_, _) => RequestReport("状态变化");
|
||||
_eventsHooked = true;
|
||||
}
|
||||
|
||||
Raise(nameof(IsAttached));
|
||||
_logger.LogInformation("班级小助手联动:已订阅课程事件");
|
||||
RequestReport("应用启动");
|
||||
}
|
||||
|
||||
/// <summary>按设置重建定时器(设置页改间隔后调用)。</summary>
|
||||
public void RestartTimer()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
_notifyTimer?.Dispose();
|
||||
_notifyTimer = null;
|
||||
|
||||
if (!_settings.IsConfigured) return;
|
||||
|
||||
if (_settings.AutoReport)
|
||||
{
|
||||
var interval = TimeSpan.FromSeconds(Math.Clamp(_settings.ReportIntervalSeconds, 15, 3600));
|
||||
_timer = new Timer(_ => RequestReport("定时"), null, interval, interval);
|
||||
}
|
||||
|
||||
// 提醒单独用一个轻量轮询(只查待提醒,不上传课表):
|
||||
// 老师按下发送到教室弹出之间的延迟就是这个间隔,因此默认 10 秒,不跟随上报间隔。
|
||||
if (_settings.ReceiveNotifications)
|
||||
{
|
||||
var poll = TimeSpan.FromSeconds(Math.Clamp(_settings.NotificationPollSeconds, 5, 600));
|
||||
_notifyTimer = new Timer(_ => _ = PullPendingAsync(), null, poll, poll);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>发起一次上报(事件驱动:已有上报在跑时直接丢弃本次,避免事件风暴打出并发请求)。</summary>
|
||||
public void RequestReport(string reason)
|
||||
{
|
||||
_ = ReportAsync(reason);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 执行一次上报并返回是否真的发出去了。
|
||||
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
|
||||
/// </summary>
|
||||
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true)
|
||||
{
|
||||
if (_disposed) return false;
|
||||
|
||||
ILessonsService? lessons;
|
||||
lock (_gate) lessons = _lessons;
|
||||
if (lessons is null) return false;
|
||||
|
||||
if (!_settings.IsConfigured)
|
||||
{
|
||||
SetState(false, "尚未配置服务器地址或设备令牌", 0, 0, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
if (_reporting) return false;
|
||||
_reporting = true;
|
||||
}
|
||||
Busy = true;
|
||||
|
||||
try
|
||||
{
|
||||
var profile = ResolveProfile();
|
||||
var request = new ReportRequestDto
|
||||
{
|
||||
PluginVersion = PluginVersion,
|
||||
ClassIslandVersion = SafeAppVersion(),
|
||||
State = BuildState(lessons),
|
||||
};
|
||||
|
||||
var entryCount = 0;
|
||||
var layoutCount = 0;
|
||||
if (includeSchedule && _settings.UploadSchedule && profile is not null)
|
||||
{
|
||||
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
|
||||
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning);
|
||||
entryCount = entries.Count;
|
||||
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
|
||||
// 补上 endTime 之外的字段由服务端按需处理;课表为空时不发,免得把服务端已有课表"合并"没了
|
||||
if (entries.Count == 0) request.Schedule = null;
|
||||
|
||||
var layout = ResolvePrimaryTimeLayout(profile);
|
||||
if (layout is not null)
|
||||
{
|
||||
var items = ScheduleMapper.MapTimeLayout(layout);
|
||||
layoutCount = items.Count;
|
||||
request.TimeLayout = new TimeLayoutPayloadDto
|
||||
{
|
||||
Name = string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name,
|
||||
Mode = "replace",
|
||||
Items = items,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
var (result, data) = await _client.ReportAsync(_settings, request, CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
if (!result.Ok || data is null)
|
||||
{
|
||||
_logger.LogWarning("班级小助手联动上报失败({Reason}):{Message}", reason, result.Message);
|
||||
SetState(false, result.Message, entryCount, layoutCount, null);
|
||||
return false;
|
||||
}
|
||||
|
||||
LastServerWeek = data.Week;
|
||||
UpdateClockOffset(data.ServerTime);
|
||||
if (data.ScheduleApplied)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
||||
data.ScheduleCreated, data.ScheduleUpdated);
|
||||
}
|
||||
|
||||
var detail = entryCount > 0
|
||||
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
||||
: "仅状态";
|
||||
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
|
||||
entryCount, layoutCount, data.Week);
|
||||
|
||||
// 顺路把"还没弹出过的"提醒带回来
|
||||
var pending = data.PendingNotification;
|
||||
if (pending is not null && _settings.ReceiveNotifications)
|
||||
{
|
||||
NotificationReceived?.Invoke(this, pending);
|
||||
}
|
||||
|
||||
if (_settings.MirrorSchedule)
|
||||
{
|
||||
await MirrorScheduleAsync().ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Busy = false;
|
||||
lock (_gate) _reporting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 拉取本班尚未弹出过的提醒并逐条交给提醒提供方(用于"应用刚起来还没到第一个上报点"的场景)。
|
||||
/// </summary>
|
||||
public async Task PullPendingAsync()
|
||||
{
|
||||
if (_disposed) return;
|
||||
if (!_settings.IsConfigured || !_settings.ReceiveNotifications) return;
|
||||
|
||||
// 轮询与"上报顺路拉取"可能同时到达:同一时刻只放一个请求过去
|
||||
lock (_gate)
|
||||
{
|
||||
if (_pulling) return;
|
||||
_pulling = true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!result.Ok)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:拉取待提醒失败({Message})", result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
|
||||
NotificationReceived?.Invoke(this, item);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_gate) _pulling = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把服务端的一条提醒标记为已弹出。</summary>
|
||||
public async Task AckAsync(string id)
|
||||
{
|
||||
var result = await _client.AckAsync(_settings, id, CancellationToken.None).ConfigureAwait(false);
|
||||
if (result.Ok)
|
||||
{
|
||||
NotificationCount += 1;
|
||||
_logger.LogInformation("班级小助手联动:已确认提醒 {Id}", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
|
||||
_logger.LogWarning("班级小助手联动:确认提醒失败 {Message}", result.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把班级小助手上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
|
||||
public async Task MirrorScheduleAsync()
|
||||
{
|
||||
var profile = ResolveProfile();
|
||||
if (profile is null) return;
|
||||
|
||||
var (result, plan) = await _client.PullClassPlanAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!result.Ok || plan is null)
|
||||
{
|
||||
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,只记信息
|
||||
_logger.LogInformation("班级小助手联动:跳过课表镜像({Message})", result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var applied = ClassPlanWriter.Apply(profile, plan);
|
||||
if (!applied.Applied)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
|
||||
applied.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
||||
return;
|
||||
}
|
||||
|
||||
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
|
||||
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
|
||||
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
|
||||
|
||||
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
|
||||
string.Join("、", applied.PlanNames.Select(name => $"「{name}」")));
|
||||
foreach (var warning in applied.Warnings)
|
||||
{
|
||||
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "班级小助手联动:课表镜像失败");
|
||||
}
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 内部实现 */
|
||||
|
||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||
private const string PluginVersion = "0.1.0.0";
|
||||
|
||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||
private static string SafeAppVersion()
|
||||
{
|
||||
try
|
||||
{
|
||||
return AppBase.AppVersion ?? "";
|
||||
}
|
||||
catch
|
||||
{
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 取得当前状态快照。
|
||||
/// <c>CurrentTimeLayoutItem</c> 在"没有课表"时是 <see cref="ClassIsland.Shared.Models.Profile.TimeLayoutItem.Empty"/>,
|
||||
/// 语义上等价于"没有节次",因此要转成 null 而不是 00:00。
|
||||
/// </summary>
|
||||
private StateDto BuildState(ILessonsService lessons)
|
||||
{
|
||||
try
|
||||
{
|
||||
return ScheduleMapper.BuildState(lessons, LastServerWeek);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
|
||||
_logger.LogWarning(exception, "班级小助手联动:读取课程状态失败,本次只上报心跳");
|
||||
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 优先用"当前正在生效"的课表对应的时间表;取不到再退回档案里的第一个。
|
||||
/// 有多个时间表时选错会把节次时间报错,所以先按当前课表找。
|
||||
/// </summary>
|
||||
private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout(
|
||||
ClassIsland.Shared.Models.Profile.Profile profile)
|
||||
{
|
||||
if (profile.TimeLayouts.Count == 0) return null;
|
||||
|
||||
var current = profile.SelectedClassPlanGroupId != Guid.Empty
|
||||
? CurrentClassPlan(profile)
|
||||
: null;
|
||||
if (current is not null)
|
||||
{
|
||||
var layout = ScheduleMapper.ResolveTimeLayout(current, profile);
|
||||
if (layout is not null) return layout;
|
||||
}
|
||||
|
||||
// 退而求其次:档案里被激活的时间表,最后才是任意一个
|
||||
return profile.TimeLayouts.Values.FirstOrDefault(item => item.IsActivated)
|
||||
?? profile.TimeLayouts.Values.First();
|
||||
}
|
||||
|
||||
private static ClassIsland.Shared.Models.Profile.ClassPlan? CurrentClassPlan(
|
||||
ClassIsland.Shared.Models.Profile.Profile profile)
|
||||
{
|
||||
// 用课程服务拿"今天该上哪张课表"最准
|
||||
try
|
||||
{
|
||||
var lessons = IAppHost.TryGetService<ILessonsService>();
|
||||
return lessons?.CurrentClassPlan;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private ClassIsland.Shared.Models.Profile.Profile? ResolveProfile()
|
||||
{
|
||||
try
|
||||
{
|
||||
// 档案由 ProfileService 持有;这里用 TryGetService 避免服务未注册时抛异常
|
||||
return IAppHost.TryGetService<IProfileService>()?.Profile;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "班级小助手联动:读取档案失败");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>用服务端时间校准本机时钟差(只影响设置页展示,不改系统时间)。</summary>
|
||||
private void UpdateClockOffset(string serverTime)
|
||||
{
|
||||
if (!DateTimeOffset.TryParse(serverTime, out var parsed)) return;
|
||||
_serverTimeOffset = parsed - DateTimeOffset.Now;
|
||||
Raise(nameof(ClientTimeOffsetText));
|
||||
}
|
||||
|
||||
private void SetState(bool ok, string message, int entryCount, int layoutCount, int? week)
|
||||
{
|
||||
LastReportOk = ok;
|
||||
LastReportMessage = message;
|
||||
LastReportAt = DateTimeOffset.Now;
|
||||
LastScheduleEntryCount = entryCount;
|
||||
LastTimeLayoutItemCount = layoutCount;
|
||||
if (week.HasValue) LastServerWeek = week;
|
||||
StateChanged?.Invoke(this, EventArgs.Empty);
|
||||
}
|
||||
|
||||
private void Raise(string propertyName) =>
|
||||
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
|
||||
private bool SetField<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
|
||||
{
|
||||
if (EqualityComparer<T>.Default.Equals(field, value)) return false;
|
||||
field = value;
|
||||
Raise(propertyName!);
|
||||
return true;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
lock (_gate)
|
||||
{
|
||||
_timer?.Dispose();
|
||||
_timer = null;
|
||||
_notifyTimer?.Dispose();
|
||||
_notifyTimer = null;
|
||||
}
|
||||
_client.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using Avalonia.Threading;
|
||||
using ClassHelper.ClassIslandPlugin.Interop;
|
||||
using ClassHelper.ClassIslandPlugin.Models;
|
||||
using ClassIsland.Core.Abstractions.Services.NotificationProviders;
|
||||
using ClassIsland.Core.Attributes;
|
||||
using ClassIsland.Core.Models.Notification;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 提醒提供方:把老师在班级小助手上发布的提醒显示到 ClassIsland 上。
|
||||
///
|
||||
/// 与 <see cref="BridgeService"/> 的分工:
|
||||
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
|
||||
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
|
||||
/// 全屏特效 / 语音 / 音效那一整套流程。
|
||||
///
|
||||
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「班级小助手联动」设置页里,
|
||||
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
|
||||
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
|
||||
/// </summary>
|
||||
// 第 3 个参数是 Fluent 图标字体里的字形字符(ClassIsland 内置提醒提供方用的也是字形字符,
|
||||
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
|
||||
[NotificationProviderInfo(
|
||||
ProviderGuidValue,
|
||||
"班级小助手提醒",
|
||||
"\uE8BD",
|
||||
"显示老师从班级小助手下发的提醒。")]
|
||||
public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
{
|
||||
/// <summary>
|
||||
/// 提醒提供方 GUID(全局唯一,别改:改了等于换了一个提供方,用户原有的提醒设置会失联)。
|
||||
/// 名字带 Value 后缀是为了不和基类的 <see cref="NotificationProviderBase.ProviderGuid"/> 属性撞名。
|
||||
/// </summary>
|
||||
public const string ProviderGuidValue = "7C4B1F6E-2A85-4A1F-9E3C-5B7D0C1A9E42";
|
||||
|
||||
private readonly BridgeService _bridge;
|
||||
private readonly PluginSettings _settings;
|
||||
private readonly ILogger<ClassHelperNotificationProvider> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// 正在播放(尚未回执)的提醒 id。
|
||||
///
|
||||
/// 服务端在提醒被回执前会**一直**把它算作"待弹出",而插件是按固定间隔轮询取回的,
|
||||
/// 因此时长超过轮询间隔的提醒会被反复取到 —— 没有这层去重就会一遍遍重复弹。
|
||||
/// 只有播完(或用户关掉)才从集合里移除,那一刻才回执。
|
||||
/// </summary>
|
||||
private readonly HashSet<string> _playing = new();
|
||||
|
||||
public ClassHelperNotificationProvider(
|
||||
BridgeService bridge,
|
||||
PluginSettings settings,
|
||||
ILogger<ClassHelperNotificationProvider> logger)
|
||||
{
|
||||
_bridge = bridge;
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_bridge.NotificationReceived += OnNotificationReceived;
|
||||
}
|
||||
|
||||
private void OnNotificationReceived(object? sender, PushNotificationDto notification)
|
||||
{
|
||||
if (!_settings.ReceiveNotifications) return;
|
||||
if (string.IsNullOrWhiteSpace(notification.Id)) return;
|
||||
|
||||
// 同一条提醒在播放期间会被后续轮询反复取回,这里按 id 去重(详见 _playing 的说明)
|
||||
lock (_playing)
|
||||
{
|
||||
if (!_playing.Add(notification.Id))
|
||||
{
|
||||
_logger.LogDebug("班级小助手联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 必须切回 UI 线程:提醒内容里的图标、以及提醒主机本身都是 Avalonia 的 UI 对象,
|
||||
// 而本方法是从上报回调(线程池)里被调用的 —— 在线程池上直接显示会静默失败/抛异常。
|
||||
Dispatcher.UIThread.Post(() => ShowOnUiThread(notification));
|
||||
}
|
||||
|
||||
private void ShowOnUiThread(PushNotificationDto notification)
|
||||
{
|
||||
try
|
||||
{
|
||||
var request = BuildRequest(notification);
|
||||
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
|
||||
AckWhenFinished(request, notification);
|
||||
ShowNotification(request);
|
||||
_logger.LogInformation("班级小助手联动:已弹出提醒「{Title}」({Seconds} 秒)",
|
||||
notification.Title, notification.DurationSeconds);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
|
||||
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
|
||||
lock (_playing) _playing.Remove(notification.Id);
|
||||
_logger.LogError(exception, "班级小助手联动:显示提醒「{Title}」失败", notification.Title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把服务端下发的提醒转成 ClassIsland 的提醒请求。
|
||||
///
|
||||
/// 时长口径:老师指定的时长给**正文**(正文才是持续显示的那一段),遮罩只停留几秒 ——
|
||||
/// 遮罩占满整个时长会把正文压没,不如"遮罩几秒 + 正文 N 秒"清楚。
|
||||
/// </summary>
|
||||
private NotificationRequest BuildRequest(PushNotificationDto notification)
|
||||
{
|
||||
var seconds = Math.Clamp(
|
||||
notification.DurationSeconds > 0 ? notification.DurationSeconds : _settings.NotificationDurationSeconds,
|
||||
1,
|
||||
120);
|
||||
var duration = TimeSpan.FromSeconds(seconds);
|
||||
var maskDuration = TimeSpan.FromSeconds(Math.Min(notification.Urgent ? 5 : 3, seconds));
|
||||
|
||||
var title = string.IsNullOrWhiteSpace(notification.Title) ? "班级小助手" : notification.Title;
|
||||
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
|
||||
|
||||
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
|
||||
var speech = _settings.SpeechEnabled ? notification.SpeechContent : null;
|
||||
var speechEnabled = !string.IsNullOrWhiteSpace(speech);
|
||||
|
||||
// 遮罩用双图标模板的**默认图标**:这两个图标由模板自己创建(LucideIconSource),
|
||||
// 自己塞图标串很容易踩到"图标表达式格式不对/字形不存在",没必要冒这个险。
|
||||
var mask = NotificationContent.CreateTwoIconsMask(title, factory: content_ =>
|
||||
{
|
||||
content_.Duration = maskDuration;
|
||||
content_.SpeechContent = speech ?? string.Empty;
|
||||
// 没勾朗读时明确关掉,否则 ClassIsland 会按默认设置把标题也念出来
|
||||
content_.IsSpeechEnabled = speechEnabled;
|
||||
});
|
||||
|
||||
// 正文:普通提醒用静态文本;紧急提醒改用**滚动文本**(ClassIsland 自带的抢注意力模板),
|
||||
// 这样"紧急"在视觉上真的有区别,而不是只靠一个可能不存在的图标。
|
||||
NotificationContent overlay = notification.Urgent
|
||||
? NotificationContent.CreateRollingTextContent(content, factory: content_ =>
|
||||
{
|
||||
content_.Duration = duration;
|
||||
content_.SpeechContent = speech ?? string.Empty;
|
||||
content_.IsSpeechEnabled = speechEnabled;
|
||||
})
|
||||
: NotificationContent.CreateSimpleTextContent(content, factory: content_ =>
|
||||
{
|
||||
content_.Duration = duration;
|
||||
content_.SpeechContent = speech ?? string.Empty;
|
||||
content_.IsSpeechEnabled = speechEnabled;
|
||||
});
|
||||
|
||||
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
|
||||
}
|
||||
|
||||
/// <summary>提醒播完 / 被关掉后回执给班级小助手,服务端不再补发。</summary>
|
||||
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
|
||||
{
|
||||
void Handler(object? sender, EventArgs args)
|
||||
{
|
||||
request.Completed -= Handler;
|
||||
request.Canceled -= Handler;
|
||||
|
||||
// 播完再放行:即使回执失败(例如断网)也移除,让后续轮询能重新投递一次,
|
||||
// 避免"回执丢了 + 不再重试"把提醒彻底吞掉
|
||||
lock (_playing) _playing.Remove(notification.Id);
|
||||
_ = _bridge.AckAsync(notification.Id);
|
||||
}
|
||||
|
||||
request.Completed += Handler;
|
||||
request.Canceled += Handler;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClassHelper.ClassIslandPlugin.Interop;
|
||||
using ClassIsland.Shared.Models.Profile;
|
||||
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 把班级小助手拉回来的课表写进 ClassIsland 档案。
|
||||
///
|
||||
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
|
||||
/// (即一个星期几 + 一个单双周相位),而班级小助手的课表同时覆盖 7 天与单双周。
|
||||
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
|
||||
///
|
||||
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
|
||||
/// <c>TimeLayout.Layouts</c> 里第 i 个 <c>TimeType == 0</c>(上课)的点一一对应。
|
||||
/// 与其去"匹配服务端时间表里的时间点"(一旦对不齐整份课表就错位),
|
||||
/// 这里**按分组内的条目顺序直接重建时间点**:每条课目生成一个上课点,
|
||||
/// 并在其结束后接上服务端时间表里同一起始时刻的课间点。这样索引关系天然成立。
|
||||
///
|
||||
/// **非破坏性**:
|
||||
/// - 只创建/更新名字带 <see cref="PlanNamePrefix"/> 前缀的课表与时间表;
|
||||
/// - 老师手动建的课表一律不删除、不改名(同名星期的冲突会在日志里警告);
|
||||
/// - 重复镜像时原地更新,不会越积越多。
|
||||
/// </summary>
|
||||
public static class ClassPlanWriter
|
||||
{
|
||||
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
|
||||
public const string PlanNamePrefix = "班级小助手-";
|
||||
|
||||
/// <summary>时间表名字后缀。</summary>
|
||||
public const string TimeLayoutNameSuffix = "(班级小助手)";
|
||||
|
||||
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
|
||||
public sealed class ApplyResult
|
||||
{
|
||||
public List<string> PlanNames { get; } = new();
|
||||
public List<string> Warnings { get; } = new();
|
||||
public bool Applied { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 应用服务端下发的课表。
|
||||
/// </summary>
|
||||
public static ApplyResult Apply(Profile profile, ClassPlanMirrorDto source)
|
||||
{
|
||||
var result = new ApplyResult();
|
||||
if (source.Entries.Count == 0 || source.TimeLayouts.Count == 0)
|
||||
{
|
||||
result.Warnings.Add("服务端返回的课表为空,未改动 ClassIsland 档案");
|
||||
return result;
|
||||
}
|
||||
|
||||
var layout = source.TimeLayouts[0];
|
||||
// 课间 / 分割线 / 行动点:按起始时间索引,供"每条课目后接上对应课间"使用
|
||||
var breakPoints = layout.Layouts
|
||||
.Where(item => item.TimeType != 0)
|
||||
.GroupBy(item => item.StartTime)
|
||||
.ToDictionary(group => group.Key, group => group.First());
|
||||
|
||||
// 1) 补齐科目(同名的复用,避免每次镜像堆出新的 Subject)
|
||||
EnsureSubjects(profile, source);
|
||||
|
||||
// 2) 按(星期几 + 单双周)分组,每组一份课表
|
||||
var groups = source.Entries
|
||||
.GroupBy(entry => new
|
||||
{
|
||||
entry.WeekDay,
|
||||
entry.WeekCountDiv,
|
||||
entry.WeekCountDivTotal,
|
||||
})
|
||||
.Select(group => new
|
||||
{
|
||||
group.Key.WeekDay,
|
||||
group.Key.WeekCountDiv,
|
||||
group.Key.WeekCountDivTotal,
|
||||
// 时间点顺序 = 课表顺序:`Classes[i]` 靠这个顺序与上课时间点对齐,
|
||||
// 「HH:mm」是定长字符串,字典序就是时间序
|
||||
Entries = group.OrderBy(entry => entry.StartTime, StringComparer.Ordinal).ToList(),
|
||||
})
|
||||
.OrderBy(group => group.WeekDay)
|
||||
.ToList();
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv,
|
||||
group.WeekCountDivTotal, groups.Count);
|
||||
var planId = FindPlanId(profile, planName);
|
||||
var (timeLayoutId, timeLayout) = BuildTimeLayout(
|
||||
profile, planName, group.Entries, breakPoints);
|
||||
|
||||
var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
|
||||
? existing
|
||||
: new ClassPlan();
|
||||
plan.Name = planName;
|
||||
plan.TimeLayoutId = timeLayoutId;
|
||||
plan.TimeRule = new TimeRule
|
||||
{
|
||||
WeekDay = group.WeekDay,
|
||||
WeekCountDiv = group.WeekCountDiv,
|
||||
WeekCountDivTotal = group.WeekCountDivTotal,
|
||||
};
|
||||
|
||||
// Classes[i] ↔ 第 i 个上课点:按分组内条目的时间顺序直接构造,索引天然一致
|
||||
// ClassIsland 的 `ClassInfo.CurrentTimeLayoutItem` = CurrentTimeLayout 里第 Index 个上课点,
|
||||
// 因此这里必须显式把 Index 与 CurrentTimeLayout 一起写上 —— 只在构造时依赖
|
||||
// `RefreshClassesList()` 是不够的:更新已有课表时 `TimeLayouts` 引用没变,
|
||||
// 档案不会重新触发刷新,课次就会停留在旧时间点上。
|
||||
var classes = new ObservableCollection<ClassInfo>();
|
||||
var index = 0;
|
||||
foreach (var entry in group.Entries)
|
||||
{
|
||||
var subjectId = profile.Subjects
|
||||
.FirstOrDefault(pair => pair.Value.Name == entry.Subject).Key;
|
||||
classes.Add(new ClassInfo
|
||||
{
|
||||
Index = index++,
|
||||
CurrentTimeLayout = timeLayout,
|
||||
SubjectId = subjectId,
|
||||
IsEnabled = true,
|
||||
});
|
||||
}
|
||||
plan.Classes = classes;
|
||||
plan.IsEnabled = true;
|
||||
profile.ClassPlans[planId] = plan;
|
||||
result.PlanNames.Add(planName);
|
||||
|
||||
// 冲突提示:同一星期几 + 同一单双周相位下老师还开着别的课表
|
||||
var conflicts = profile.ClassPlans
|
||||
.Where(pair => pair.Key != planId
|
||||
&& pair.Value.IsEnabled
|
||||
&& !pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)
|
||||
&& pair.Value.TimeRule.WeekDay == group.WeekDay
|
||||
&& pair.Value.TimeRule.WeekCountDivTotal == group.WeekCountDivTotal
|
||||
&& pair.Value.TimeRule.WeekCountDiv == group.WeekCountDiv)
|
||||
.Select(pair => pair.Value.Name)
|
||||
.ToList();
|
||||
if (conflicts.Count > 0)
|
||||
{
|
||||
result.Warnings.Add(
|
||||
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
|
||||
"两者重叠时以班级小助手同步的课表为准");
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 停用上一次镜像留下来的、本次不再需要的课表(只动自己创建的)
|
||||
var currentNames = new HashSet<string>(result.PlanNames);
|
||||
foreach (var pair in profile.ClassPlans)
|
||||
{
|
||||
if (!pair.Value.Name.StartsWith(PlanNamePrefix, StringComparison.Ordinal)) continue;
|
||||
if (currentNames.Contains(pair.Value.Name)) continue;
|
||||
pair.Value.IsEnabled = false;
|
||||
}
|
||||
|
||||
result.Applied = true;
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void EnsureSubjects(Profile profile, ClassPlanMirrorDto source)
|
||||
{
|
||||
foreach (var name in source.Entries.Select(entry => entry.Subject).Distinct())
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(name)) continue;
|
||||
if (profile.Subjects.Any(pair => pair.Value.Name == name)) continue;
|
||||
profile.Subjects[Guid.NewGuid()] = new Subject { Name = name };
|
||||
}
|
||||
}
|
||||
|
||||
private static Guid FindPlanId(Profile profile, string planName)
|
||||
{
|
||||
var existing = profile.ClassPlans.FirstOrDefault(pair => pair.Value.Name == planName);
|
||||
return existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 按分组内的条目重建时间表:每条课目前后按需插入服务端的课间点。
|
||||
/// 返回写入的时间表 GUID。
|
||||
/// </summary>
|
||||
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
|
||||
Profile profile,
|
||||
string planName,
|
||||
IReadOnlyList<ClassPlanEntryMirrorDto> entries,
|
||||
IReadOnlyDictionary<string, TimeLayoutItemMirrorDto> breakPoints)
|
||||
{
|
||||
var layoutName = planName + TimeLayoutNameSuffix;
|
||||
var existing = profile.TimeLayouts.FirstOrDefault(pair => pair.Value.Name == layoutName);
|
||||
var id = existing.Key != Guid.Empty ? existing.Key : Guid.NewGuid();
|
||||
|
||||
var layout = new TimeLayout { Name = layoutName, IsActivated = true };
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
layout.Layouts.Add(new TimeLayoutItem
|
||||
{
|
||||
TimeType = 0, // 上课
|
||||
StartTime = ParseTime(entry.StartTime),
|
||||
EndTime = ParseTime(entry.EndTime),
|
||||
});
|
||||
// 该课目结束后紧接着的课间(服务端时间表里有同起始时刻的课间点才插入)
|
||||
if (breakPoints.TryGetValue(entry.EndTime, out var brk))
|
||||
{
|
||||
layout.Layouts.Add(new TimeLayoutItem
|
||||
{
|
||||
TimeType = brk.TimeType,
|
||||
StartTime = ParseTime(brk.StartTime),
|
||||
EndTime = ParseTime(brk.EndTime),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
profile.TimeLayouts[id] = layout;
|
||||
return (id, layout);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 课表名:单组时用「班级小助手-<班级名>」;多组时补上星期与单双周以免互相覆盖。
|
||||
/// </summary>
|
||||
private static string BuildPlanName(
|
||||
string profileName,
|
||||
int weekDay,
|
||||
int weekCountDiv,
|
||||
int weekCountDivTotal,
|
||||
int groupCount)
|
||||
{
|
||||
var baseName = string.IsNullOrWhiteSpace(profileName) ? "班级小助手课表" : profileName.Trim();
|
||||
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
baseName = PlanNamePrefix + baseName;
|
||||
}
|
||||
if (groupCount <= 1) return baseName;
|
||||
|
||||
var weekday = weekDay switch
|
||||
{
|
||||
0 => "周日",
|
||||
1 => "周一",
|
||||
2 => "周二",
|
||||
3 => "周三",
|
||||
4 => "周四",
|
||||
5 => "周五",
|
||||
6 => "周六",
|
||||
_ => $"星期{weekDay}",
|
||||
};
|
||||
var parity = weekCountDivTotal <= 1
|
||||
? "每周"
|
||||
: $"第{weekCountDiv}/{weekCountDivTotal}周";
|
||||
return $"{baseName} · {weekday} · {parity}";
|
||||
}
|
||||
|
||||
/// <summary>"HH:mm" / "HH:mm:ss" → TimeSpan(解析失败回退到 0 点)。</summary>
|
||||
private static TimeSpan ParseTime(string value)
|
||||
{
|
||||
return TimeSpan.TryParse(value, out var parsed) ? parsed : TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using ClassHelper.ClassIslandPlugin.Interop;
|
||||
using ClassIsland.Core.Abstractions.Services;
|
||||
using ClassIsland.Shared;
|
||||
using ClassIsland.Shared.Enums;
|
||||
using ClassIsland.Shared.Models.Profile;
|
||||
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 课表 / 状态 → 上报载荷 的换算层。
|
||||
///
|
||||
/// 这里集中全部"ClassIsland 口径 ↔ 班级小助手口径"的转换,保证只有一处需要跟着
|
||||
/// ClassIsland 的模型变化而修改:
|
||||
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
|
||||
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
|
||||
/// - 节次:ClassIsland <c>TimeLayoutItem.TimeType</c> 0=上课 1=课间 2=分割线 3=行动。
|
||||
/// </summary>
|
||||
public static class ScheduleMapper
|
||||
{
|
||||
/// <summary>
|
||||
/// ClassIsland 的 WeekDay(0=周日…6=周六)→ 本系统 dayOfWeek(1=周一…7=周日)。
|
||||
/// </summary>
|
||||
public static int ToServerDayOfWeek(int weekDay)
|
||||
{
|
||||
var normalized = ((weekDay % 7) + 7) % 7;
|
||||
return normalized == 0 ? 7 : normalized;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// ClassIsland 的 WeekCountDiv/Total → 本系统单双周。
|
||||
/// 与 <c>packages/shared/src/utils.ts</c> 的 <c>weekParityFromDiv</c> 完全一致:
|
||||
/// 3 周及以上的循环本系统无法表达,降级为「每周」。
|
||||
/// </summary>
|
||||
public static string ToWeekParity(int weekCountDiv, int weekCountDivTotal)
|
||||
{
|
||||
if (weekCountDiv <= 0 || weekCountDivTotal <= 1) return "ALL";
|
||||
var position = ((weekCountDiv - 1) % weekCountDivTotal) + 1;
|
||||
if (weekCountDivTotal == 2) return position == 1 ? "ODD" : "EVEN";
|
||||
return "ALL";
|
||||
}
|
||||
|
||||
/// <summary>ClassIsland TimeType → 本系统节次类型。</summary>
|
||||
public static string ToTimeLayoutType(int timeType) => timeType switch
|
||||
{
|
||||
1 => "break",
|
||||
2 => "divider",
|
||||
3 => "action",
|
||||
_ => "class",
|
||||
};
|
||||
|
||||
/// <summary>TimeSpan → "HH:mm"(跨天的时间点做钳制,避免出现 24:xx)。</summary>
|
||||
public static string ToHhMm(TimeSpan time)
|
||||
{
|
||||
var totalMinutes = (int)Math.Round(time.TotalMinutes);
|
||||
totalMinutes = Math.Clamp(totalMinutes, 0, 24 * 60 - 1);
|
||||
return $"{totalMinutes / 60:D2}:{totalMinutes % 60:D2}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。
|
||||
///
|
||||
/// 与 ClassIsland 的语义严格对齐:<c>ClassPlan.Classes[i]</c> 对应
|
||||
/// <c>TimeLayout.Layouts</c> 里**第 i 个 TimeType=0(上课)的点**,
|
||||
/// 课间 / 分割线 / 行动不占 Classes 的位置。因此这里必须按 TimeType 过滤后再配对,
|
||||
/// 否则整张课表会错位。
|
||||
/// </summary>
|
||||
public static List<ScheduleEntryDto> MapClassPlan(ClassPlan plan, Profile profile, out string? warning)
|
||||
{
|
||||
warning = null;
|
||||
var entries = new List<ScheduleEntryDto>();
|
||||
|
||||
var timeLayout = ResolveTimeLayout(plan, profile);
|
||||
if (timeLayout is null)
|
||||
{
|
||||
warning = $"课表「{plan.Name}」找不到对应的时间表,已跳过";
|
||||
return entries;
|
||||
}
|
||||
|
||||
// 只保留上课类型的时间点,顺序必须与 Classes 的索引一致
|
||||
var classPoints = timeLayout.Layouts.Where(item => item.TimeType == 0).ToList();
|
||||
var validPoints = plan.ValidTimeLayoutItems.Count > 0
|
||||
? plan.ValidTimeLayoutItems.Where(item => item.TimeType == 0).ToList()
|
||||
: classPoints;
|
||||
|
||||
var weekDay = ToServerDayOfWeek(plan.TimeRule.WeekDay);
|
||||
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
||||
if (plan.TimeRule.WeekCountDivTotal > 2)
|
||||
{
|
||||
warning = "存在 3 周以上的轮换课表,班级小助手只支持单双周,已按「每周」处理";
|
||||
}
|
||||
|
||||
var classes = plan.Classes;
|
||||
var count = Math.Min(classes.Count, validPoints.Count);
|
||||
for (var index = 0; index < count; index++)
|
||||
{
|
||||
var info = classes[index];
|
||||
var point = validPoints[index];
|
||||
if (info is null) continue;
|
||||
|
||||
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
|
||||
var subject = ResolveSubjectName(info.SubjectId, profile);
|
||||
if (string.IsNullOrWhiteSpace(subject)) continue;
|
||||
|
||||
entries.Add(new ScheduleEntryDto
|
||||
{
|
||||
DayOfWeek = weekDay,
|
||||
StartTime = ToHhMm(point.StartTime),
|
||||
EndTime = ToHhMm(point.EndTime),
|
||||
Subject = subject,
|
||||
TeacherName = ResolveSubjectTeacher(info.SubjectId, profile),
|
||||
WeekParity = parity,
|
||||
WeekCountDiv = plan.TimeRule.WeekCountDiv,
|
||||
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
|
||||
PlanName = plan.Name,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
/// <summary>把时间表(TimeLayout)转成本系统的节次时间配置。</summary>
|
||||
public static List<TimeLayoutItemDto> MapTimeLayout(TimeLayout layout)
|
||||
{
|
||||
var items = new List<TimeLayoutItemDto>();
|
||||
// ClassIsland 的上课时间点没有独立名称,这里按"它之前有几个上课点"编出"第 N 节"
|
||||
var classPointIndex = 0;
|
||||
for (var index = 0; index < layout.Layouts.Count; index++)
|
||||
{
|
||||
var point = layout.Layouts[index];
|
||||
items.Add(new TimeLayoutItemDto
|
||||
{
|
||||
Index = index + 1,
|
||||
Name = BuildTimePointName(point, ref classPointIndex),
|
||||
StartTime = ToHhMm(point.StartTime),
|
||||
EndTime = ToHhMm(point.EndTime),
|
||||
Type = ToTimeLayoutType(point.TimeType),
|
||||
Skipped = false,
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 节次名称:ClassIsland 只有课间有名字(BreakName),上课时间点没有独立名称,
|
||||
/// 因此按类型生成"第 N 节 / 课间 / 分割线",保证本系统的时间轴可读。
|
||||
/// </summary>
|
||||
private static string BuildTimePointName(TimeLayoutItem point, ref int classPointIndex)
|
||||
{
|
||||
switch (point.TimeType)
|
||||
{
|
||||
case 1:
|
||||
return string.IsNullOrWhiteSpace(point.BreakName) ? "课间" : point.BreakName!;
|
||||
case 2:
|
||||
return "分割线";
|
||||
case 3:
|
||||
return "行动";
|
||||
default:
|
||||
classPointIndex++;
|
||||
return $"第 {classPointIndex} 节";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>取出课表使用的时间表:优先按 TimeLayoutId,其次用档案里第一个。</summary>
|
||||
public static TimeLayout? ResolveTimeLayout(ClassPlan plan, Profile profile)
|
||||
{
|
||||
if (profile.TimeLayouts.Count == 0) return null;
|
||||
if (plan.TimeLayoutId != Guid.Empty && profile.TimeLayouts.TryGetValue(plan.TimeLayoutId, out var byId))
|
||||
{
|
||||
return byId;
|
||||
}
|
||||
return plan.TimeLayout ?? profile.TimeLayouts.Values.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>科目名(找不到时返回空字符串,由调用方决定是否跳过)。</summary>
|
||||
public static string? ResolveSubjectName(Guid subjectId, Profile profile)
|
||||
{
|
||||
if (subjectId == Guid.Empty) return null;
|
||||
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.Name : null;
|
||||
}
|
||||
|
||||
/// <summary>科目任课老师(ClassIsland 在 Subject 上记录 TeacherName)。</summary>
|
||||
public static string? ResolveSubjectTeacher(Guid subjectId, Profile profile)
|
||||
{
|
||||
if (subjectId == Guid.Empty) return null;
|
||||
return profile.Subjects.TryGetValue(subjectId, out var subject) ? subject.TeacherName : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 收集档案里今天(按当前周次与星期)应该上的课表,
|
||||
/// 用于"只上报今天"这类轻量场景;当前实现为上报全部课表(服务端按周次并存)。
|
||||
/// </summary>
|
||||
public static List<ScheduleEntryDto> MapAllClassPlans(Profile profile, out List<string> warnings)
|
||||
{
|
||||
warnings = new List<string>();
|
||||
var all = new List<ScheduleEntryDto>();
|
||||
foreach (var plan in profile.ClassPlans.Values)
|
||||
{
|
||||
if (!plan.IsEnabled) continue;
|
||||
if (plan.IsOverlay) continue; // 临时层课表不下发,避免把临时调整固化到班级课表
|
||||
var entries = MapClassPlan(plan, profile, out var warning);
|
||||
if (!string.IsNullOrWhiteSpace(warning) && !warnings.Contains(warning!))
|
||||
{
|
||||
warnings.Add(warning!);
|
||||
}
|
||||
all.AddRange(entries);
|
||||
}
|
||||
return all;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 科目名归一化。
|
||||
///
|
||||
/// 没有课表 / 没有当前科目时,ClassIsland 会把 <see cref="Subject.Fallback"/> 挂到
|
||||
/// `CurrentSubject` / `NextClassSubject` 上(见 LessonsService:
|
||||
/// `CurrentSubject = currentSubject ?? Subject.Fallback`),而它的名字就是占位符本身。
|
||||
/// 直接上报会让 Web 端显示成"正在上 ???",因此这里统一折算成"没有科目"。
|
||||
/// </summary>
|
||||
private static string? NormalizeSubjectName(Subject? subject)
|
||||
{
|
||||
if (subject is null) return null;
|
||||
if (ReferenceEquals(subject, Subject.Fallback)) return null;
|
||||
|
||||
var name = subject.Name?.Trim();
|
||||
if (string.IsNullOrEmpty(name)) return null;
|
||||
// 兜底:万一拿到的是另一个同名占位符实例(不是同一个引用)
|
||||
if (string.Equals(name, Subject.Fallback.Name, StringComparison.Ordinal)) return null;
|
||||
return name;
|
||||
}
|
||||
|
||||
/// <summary>当前状态快照:服务端据此在 Web 端展示"现在上什么课"。</summary>
|
||||
public static StateDto BuildState(ILessonsService lessons, int? currentWeek)
|
||||
{
|
||||
var current = lessons.CurrentTimeLayoutItem;
|
||||
var state = lessons.CurrentState;
|
||||
return new StateDto
|
||||
{
|
||||
InClass = state == TimeState.OnClass,
|
||||
Subject = NormalizeSubjectName(lessons.CurrentSubject),
|
||||
NextSubject = NormalizeSubjectName(lessons.NextClassSubject),
|
||||
TimeState = state.ToString(),
|
||||
PeriodStart = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.StartTime),
|
||||
PeriodEnd = current is null || current == TimeLayoutItem.Empty ? null : ToHhMm(current.EndTime),
|
||||
Week = currentWeek,
|
||||
ClassPlanLoaded = lessons.IsClassPlanLoaded,
|
||||
ClientTime = DateTimeOffset.Now.ToString("o"),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
<controls:SettingsPageBase x:Class="ClassHelper.ClassIslandPlugin.Views.BridgeSettingsPage"
|
||||
xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:local="clr-namespace:ClassHelper.ClassIslandPlugin.Views"
|
||||
xmlns:controls="clr-namespace:ClassIsland.Core.Abstractions.Controls;assembly=ClassIsland.Core"
|
||||
xmlns:ci="clr-namespace:ClassIsland.Core.Controls;assembly=ClassIsland.Core"
|
||||
xmlns:fa="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
|
||||
mc:Ignorable="d"
|
||||
d:DesignHeight="450" d:DesignWidth="800"
|
||||
d:DataContext="{d:DesignInstance local:BridgeSettingsPage}"
|
||||
Loaded="OnLoaded"
|
||||
Unloaded="OnUnloaded">
|
||||
<ScrollViewer>
|
||||
<StackPanel Classes="settings-container animated-intro" Spacing="0">
|
||||
<!-- 连接 -->
|
||||
<TextBlock Text="连接班级小助手"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="填入班级小助手后端的地址与设备令牌。设备令牌在班级小助手 Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
|
||||
|
||||
<TextBox Watermark="http://127.0.0.1:4000"
|
||||
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
|
||||
<TextBox Margin="0 8 0 0"
|
||||
Watermark="chci_..."
|
||||
PasswordChar="●"
|
||||
Text="{Binding Settings.DeviceToken, Mode=TwoWay}" />
|
||||
|
||||
<StackPanel Orientation="Horizontal" Margin="0 12 0 0" Spacing="8">
|
||||
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||
<Button Content="检查连接" Command="{Binding TestCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||
<Button Content="拉取待提醒" Command="{Binding PullCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||
<Button Content="重建档案课表" Command="{Binding MirrorCommand}"
|
||||
IsEnabled="{Binding Settings.MirrorSchedule}" />
|
||||
</StackPanel>
|
||||
|
||||
<!-- 状态 -->
|
||||
<Border Margin="0 16 0 0" Padding="12" CornerRadius="4"
|
||||
Background="{DynamicResource SystemFillColorNeutralBackgroundBrush}">
|
||||
<StackPanel Spacing="4">
|
||||
<TextBlock Text="{Binding StatusText}" TextWrapping="Wrap" FontWeight="SemiBold" />
|
||||
<TextBlock Text="{Binding Bridge.LastReportMessage}" TextWrapping="Wrap" />
|
||||
<TextBlock Text="{Binding DetailText}" TextWrapping="Wrap" Opacity="0.8" />
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
<!-- 同步 -->
|
||||
<TextBlock Margin="0 24 0 0" Text="同步内容"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="课表以「合并」方式写入班级小助手:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
||||
|
||||
<fa:SettingsExpander Header="上报课表到班级小助手"
|
||||
Description="把 ClassIsland 里启用的课表同步给班级小助手,Web 端与桌面客户端即可看到课程表。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
<fa:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding Settings.UploadSchedule, Mode=TwoWay}" />
|
||||
</fa:SettingsExpander.Footer>
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
|
||||
Description="反方向同步:把班级小助手上排好的课表写回 ClassIsland,会新建一份以「班级小助手-」开头的档案课表,不会修改老师原有的课表。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
<fa:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding Settings.MirrorSchedule, Mode=TwoWay}" />
|
||||
</fa:SettingsExpander.Footer>
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<fa:SettingsExpander Header="自动上报"
|
||||
Description="应用启动、课表变化以及每隔一段时间各上报一次;关闭后只能用上面的「立即上报」手动触发。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
<fa:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding Settings.AutoReport, Mode=TwoWay}" />
|
||||
</fa:SettingsExpander.Footer>
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="上报间隔(秒)" />
|
||||
<TextBlock Text="最小 15 秒;教室机器建议 60 秒。" Opacity="0.7" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<NumericUpDown Grid.Column="1" Width="140"
|
||||
Minimum="15" Maximum="3600" Increment="15"
|
||||
FormatString="0"
|
||||
IsEnabled="{Binding Settings.AutoReport}"
|
||||
Value="{Binding Settings.ReportIntervalSeconds, Mode=TwoWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- 提醒 -->
|
||||
<TextBlock Margin="0 24 0 0" Text="提醒"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="老师在班级小助手上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
|
||||
|
||||
<fa:SettingsExpander Header="接收班级小助手提醒"
|
||||
Description="关闭后本机会忽略老师在班级小助手上发布的提醒(提醒仍会留在班级通知中心)。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
<fa:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding Settings.ReceiveNotifications, Mode=TwoWay}" />
|
||||
</fa:SettingsExpander.Footer>
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<fa:SettingsExpander Header="语音朗读"
|
||||
Description="提醒弹出时按 ClassIsland 的语音设置为全机朗读提醒内容。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
<fa:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding Settings.SpeechEnabled, Mode=TwoWay}" />
|
||||
</fa:SettingsExpander.Footer>
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="默认显示时长(秒)" />
|
||||
<TextBlock Text="老师在下发单条提醒时可以另外指定更长的时长。" Opacity="0.7" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<NumericUpDown Grid.Column="1" Width="140"
|
||||
Minimum="1" Maximum="120" Increment="1"
|
||||
FormatString="0"
|
||||
Value="{Binding Settings.NotificationDurationSeconds, Mode=TwoWay}" />
|
||||
</Grid>
|
||||
|
||||
<Grid Margin="0 8 0 0" ColumnDefinitions="*,Auto">
|
||||
<StackPanel>
|
||||
<TextBlock Text="提醒轮询间隔(秒)" />
|
||||
<TextBlock Text="老师按下发送到教室弹出之间的最大延迟(插件不持有长连接,是轮询取回提醒)。默认 10 秒。"
|
||||
Opacity="0.7" TextWrapping="Wrap" />
|
||||
</StackPanel>
|
||||
<NumericUpDown Grid.Column="1" Width="140"
|
||||
Minimum="5" Maximum="600" Increment="5"
|
||||
FormatString="0"
|
||||
IsEnabled="{Binding Settings.ReceiveNotifications}"
|
||||
Value="{Binding Settings.NotificationPollSeconds, Mode=TwoWay}" />
|
||||
</Grid>
|
||||
|
||||
<!-- 调试 -->
|
||||
<TextBlock Margin="0 24 0 0" Text="调试"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<fa:SettingsExpander Header="显示调试信息"
|
||||
Description="显示设备标识、时钟差等细节,排查「为什么同步不上」时打开。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
<fa:SettingsExpander.Footer>
|
||||
<ToggleSwitch IsChecked="{Binding Settings.ShowDebugInfo, Mode=TwoWay}" />
|
||||
</fa:SettingsExpander.Footer>
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<StackPanel Margin="0 8 0 0" Spacing="2" IsVisible="{Binding Settings.ShowDebugInfo}">
|
||||
<TextBlock Text="{Binding DebugText}" TextWrapping="Wrap" FontFamily="Consolas,monospace" />
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="提示:班级小助手 Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</controls:SettingsPageBase>
|
||||
@@ -0,0 +1,198 @@
|
||||
using System.ComponentModel;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Avalonia.Threading;
|
||||
using ClassHelper.ClassIslandPlugin.Models;
|
||||
using ClassHelper.ClassIslandPlugin.Services;
|
||||
using ClassIsland.Core.Abstractions.Controls;
|
||||
using ClassIsland.Core.Attributes;
|
||||
using ClassIsland.Core.Enums.SettingsWindow;
|
||||
using CommunityToolkit.Mvvm.Input;
|
||||
|
||||
namespace ClassHelper.ClassIslandPlugin.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 「班级小助手联动」设置页。
|
||||
///
|
||||
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
|
||||
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
|
||||
/// 不需要再套一层 ViewModel,改配置项时也少一处"两边同步"的机会。
|
||||
/// </summary>
|
||||
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
|
||||
[SettingsPageInfo(
|
||||
"classhelper.classisland.bridge.settings",
|
||||
"班级小助手联动",
|
||||
"\uE71B",
|
||||
"\uE71B",
|
||||
SettingsPageCategory.External)]
|
||||
public partial class BridgeSettingsPage : SettingsPageBase, INotifyPropertyChanged
|
||||
{
|
||||
public BridgeSettingsPage(PluginSettings settings, BridgeService bridge)
|
||||
{
|
||||
Settings = settings;
|
||||
Bridge = bridge;
|
||||
|
||||
ReportCommand = new AsyncRelayCommand(ReportAsync);
|
||||
TestCommand = new AsyncRelayCommand(TestAsync);
|
||||
PullCommand = new AsyncRelayCommand(PullAsync);
|
||||
MirrorCommand = new AsyncRelayCommand(MirrorAsync);
|
||||
|
||||
InitializeComponent();
|
||||
DataContext = this;
|
||||
Raise(nameof(StatusText));
|
||||
}
|
||||
|
||||
public PluginSettings Settings { get; }
|
||||
|
||||
public BridgeService Bridge { get; }
|
||||
|
||||
public IAsyncRelayCommand ReportCommand { get; }
|
||||
public IAsyncRelayCommand TestCommand { get; }
|
||||
public IAsyncRelayCommand PullCommand { get; }
|
||||
public IAsyncRelayCommand MirrorCommand { get; }
|
||||
|
||||
private PropertyChangedEventHandler? _propertyChanged;
|
||||
|
||||
/// <summary>
|
||||
/// 显式实现 <see cref="INotifyPropertyChanged"/>:<c>AvaloniaObject</c> 自带一个同名但类型不同的事件,
|
||||
/// 直接声明会遮蔽基类成员。显式实现既不影响 Avalonia 的绑定查找(它按接口取),也不会有歧义。
|
||||
/// </summary>
|
||||
event PropertyChangedEventHandler? INotifyPropertyChanged.PropertyChanged
|
||||
{
|
||||
add => _propertyChanged += value;
|
||||
remove => _propertyChanged -= value;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 绑定用的展示文本 */
|
||||
|
||||
/// <summary>一句话结论:还没配 / 已配置 / 已连接 / 连接异常。</summary>
|
||||
public string StatusText
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!Settings.IsConfigured) return "尚未配置:请填写服务器地址与设备令牌";
|
||||
if (!Bridge.IsAttached) return "已填写配置,等待 ClassIsland 课程服务就绪";
|
||||
return Bridge.LastReportOk ? "已连接" : "连接异常";
|
||||
}
|
||||
}
|
||||
|
||||
public string DetailText
|
||||
{
|
||||
get
|
||||
{
|
||||
var week = Bridge.LastServerWeek?.ToString() ?? "—";
|
||||
return $"最后上报:{Bridge.LastReportAtText} 教学周:{week} " +
|
||||
$"课表 {Bridge.LastScheduleEntryCount} 节 / 节次 {Bridge.LastTimeLayoutItemCount} 条 " +
|
||||
$"已弹出提醒:{Bridge.NotificationCount} 条";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>调试信息(设置页里打开"显示调试信息"才可见)。</summary>
|
||||
public string DebugText
|
||||
{
|
||||
get
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
$"服务器地址:{(string.IsNullOrWhiteSpace(Settings.ServerUrl) ? "(未填)" : Settings.ServerUrl)}",
|
||||
$"设备令牌:{(string.IsNullOrWhiteSpace(Settings.DeviceToken) ? "(未填)" : Mask(Settings.DeviceToken))}",
|
||||
$"设备标识:{(string.IsNullOrWhiteSpace(Settings.DeviceKey) ? "(未生成)" : Settings.DeviceKey)}",
|
||||
$"课程服务:{(Bridge.IsAttached ? "已接入" : "未接入")}",
|
||||
$"与服务端时钟差:{Bridge.ClientTimeOffsetText}",
|
||||
$"ClassIsland 版本:{SafeVersion()}",
|
||||
$"配置目录:{PluginConfigFolder}",
|
||||
};
|
||||
return string.Join(Environment.NewLine, lines);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>设置页所在的插件配置目录(从插件入口拿;拿不到时留空)。</summary>
|
||||
private string PluginConfigFolder =>
|
||||
ClassIsland.Shared.IAppHost.TryGetService<ClassIsland.Core.Abstractions.PluginBase>()?.PluginConfigFolder ?? "—";
|
||||
|
||||
private static string SafeVersion()
|
||||
{
|
||||
try { return ClassIsland.Core.AppBase.AppVersion ?? "—"; }
|
||||
catch { return "—"; }
|
||||
}
|
||||
|
||||
/// <summary>令牌只露头尾,避免设置页截图时把可用凭证泄露出去。</summary>
|
||||
private static string Mask(string token) =>
|
||||
token.Length <= 14 ? token : $"{token[..12]}…{token[^4..]}";
|
||||
|
||||
/* ---------------------------------------------------------------- 交互 */
|
||||
|
||||
private async Task ReportAsync()
|
||||
{
|
||||
await Bridge.ReportAsync("手动").ConfigureAwait(true);
|
||||
}
|
||||
|
||||
/// <summary>不带课表上报一次:用来快速验证"地址 + 令牌"是否可用,不会因为课表为空而出错。</summary>
|
||||
private async Task TestAsync()
|
||||
{
|
||||
await Bridge.ReportAsync("连接检查", includeSchedule: false).ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async Task PullAsync()
|
||||
{
|
||||
await Bridge.PullPendingAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private async Task MirrorAsync()
|
||||
{
|
||||
await Bridge.MirrorScheduleAsync().ConfigureAwait(true);
|
||||
}
|
||||
|
||||
private void OnSettingsChanged(object? sender, PropertyChangedEventArgs args)
|
||||
{
|
||||
// 影响上报链路的改动需要立刻重建定时器;纯界面项(如"显示调试信息")不用管
|
||||
if (args.PropertyName is nameof(PluginSettings.ServerUrl)
|
||||
or nameof(PluginSettings.DeviceToken)
|
||||
or nameof(PluginSettings.AutoReport)
|
||||
or nameof(PluginSettings.ReportIntervalSeconds)
|
||||
or nameof(PluginSettings.ReceiveNotifications)
|
||||
or nameof(PluginSettings.NotificationPollSeconds))
|
||||
{
|
||||
Bridge.RestartTimer();
|
||||
}
|
||||
|
||||
Raise(nameof(StatusText));
|
||||
Raise(nameof(DebugText));
|
||||
}
|
||||
|
||||
private void OnBridgeChanged(object? sender, PropertyChangedEventArgs args)
|
||||
{
|
||||
// Bridge 的属性变化可能来自线程池(HttpClient 回调),绑定必须在 UI 线程上更新
|
||||
if (Dispatcher.UIThread.CheckAccess())
|
||||
{
|
||||
RaiseStateProperties();
|
||||
}
|
||||
else
|
||||
{
|
||||
Dispatcher.UIThread.Post(RaiseStateProperties);
|
||||
}
|
||||
}
|
||||
|
||||
private void RaiseStateProperties()
|
||||
{
|
||||
Raise(nameof(StatusText));
|
||||
Raise(nameof(DetailText));
|
||||
Raise(nameof(DebugText));
|
||||
}
|
||||
|
||||
private void OnLoaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
||||
{
|
||||
// 改完地址/令牌/间隔要重建定时器,否则"改了设置但上报节奏还是旧的"
|
||||
Settings.PropertyChanged += OnSettingsChanged;
|
||||
// 运行状态(上一次上报结果 / 待弹出提醒数)变了要刷新状态卡片
|
||||
Bridge.PropertyChanged += OnBridgeChanged;
|
||||
}
|
||||
|
||||
private void OnUnloaded(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
||||
{
|
||||
Settings.PropertyChanged -= OnSettingsChanged;
|
||||
Bridge.PropertyChanged -= OnBridgeChanged;
|
||||
}
|
||||
|
||||
private void Raise([CallerMemberName] string? propertyName = null) =>
|
||||
_propertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
|
||||
}
|
||||
Reference in new issue
Block a user