feat(shared,server,web,client,plugin)!: 角色与权限重构 + 学期周次 + 课表方向反转(1.2.0)
学生、任课关系、周次口径三处长期靠约定维持的事情,改为由数据模型保证。 学生不是账号 - 拆出独立 Student 表,没有 passwordHash / role / 任何登录字段 - 「学生不能登录」由表结构保证,不再靠「登录前判 role 再 403」的代码分支 - 三张个人数据表外键 userId → studentId;删掉 Enrollment 与 ClassTeacher - 学生一律称「学号」、教师一律称「工号」,「学生账号」这个概念消失 任课关系唯一来源 = Course(班级 + 科目 + 教师) - 班主任 = Class.teacherId,科任 = Course.teacherId,同一人两处指向同一账号、不重复建号 - 作业与成绩按科目再收一道:挂科目只由该科任课老师改,没挂科目归班主任 / 管理员 - 新增「任课教师分配」与「班级教师批量导入」(带「使用工号匹配」开关) 成绩与名单 - 成绩等级:百分制 / 等级制 A·B·C·D / 自定义,支持单个与批量改等级 - 按学号查成绩明细(教师端 + 班级端 + 设备令牌),受按班开关控制 - 调班 / 转出(单个与批量)+ 可查历史;学号不变、历史作业成绩留原归属 - 毕业归档页(按年度记录毕业班级 / 毕业生 / 转出学生 / 该届通知与作业) - 学年升级(未毕业不归档、数据沿用);班级称呼改为「2026级1班」 ClassHelper 班级端在线状态 - Web 端显示 班级 / 设备名 / 设备号 / 在线离线 / 最后在线 / 最后心跳 - lastHeartbeatAt 驱动 60 秒在线窗口,与只表示「带状态上报」的 lastSeenAt 分开 学期周次 - 新增 TermWeek(classId 空串 = 全校默认),管理员可用日期范围选择器逐周配置 - 一键按开学日期生成整学期;联网拉取调休建议(只给建议、不改数据,无外网失败属正常) - 改周次后随课表下发给教室的 ClassHelper 班级端 课表方向反转 - 主方向改为「服务端 → 教室」自动下发;教室不再自动回传 - 取教室课表改为手动动作(点击前弹警告),插件按待办标记在下一次心跳补报 改名 - 班级小助手 → ClassHelper:界面、窗口标题、托盘、灵动岛、EXE、安装包、 安装目录、systemd 描述、插件清单与设置页、官网、文档站、README BREAKING CHANGE: 产品名与 EXE 名变更(班级小助手.exe → ClassHelper.exe), 安装目录随之改变,不支持覆盖安装,需先卸载旧版。 服务端首次启动会自动应用 6 个数据库迁移(学生行按 id 原样搬迁,历史数据不丢), 升级前请先备份。
This commit is contained in:
1 parent
17f55864bf
commit
9d5ac7adf9
172 files changed
+8761
-1896
No files matched your search
@@ -1,15 +1,15 @@
|
||||
# 班级小助手联动插件
|
||||
# ClassHelper 联动插件
|
||||
|
||||
把 [ClassIsland](https://classisland.tech) 与 [班级小助手](https://github.com/LoveChengke/ClassHelper) 连起来:
|
||||
让班级小助手**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
|
||||
把 [ClassIsland](https://classisland.tech) 与 [ClassHelper](https://github.com/LoveChengke/ClassHelper) 连起来:
|
||||
让 ClassHelper**读到教室真实的课表与上课状态**,也让老师的提醒**在教室的 ClassIsland 上弹出来**。
|
||||
|
||||
## 它能做什么
|
||||
|
||||
| 方向 | 说明 |
|
||||
| -------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| ClassIsland → 班级小助手 | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
|
||||
| 班级小助手 → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
|
||||
| 班级小助手 → ClassIsland(可选) | 把班级小助手上排好的课表**镜像**回 ClassIsland(新建 `班级小助手-<班级名>` 档案课表,不改动老师原有的课表) |
|
||||
| ClassIsland → ClassHelper | 上报课表与节次时间、上报上课状态(当前科目 / 下一节 / 本节起止时间) |
|
||||
| ClassHelper → ClassIsland | 老师在 Web 端下发提醒 → 教室的 ClassIsland 上**全屏弹出**(可语音朗读),播完回执、不重复弹 |
|
||||
| ClassHelper → ClassIsland(可选) | 把 ClassHelper上排好的课表**镜像**回 ClassIsland(新建 `ClassHelper-<班级名>` 档案课表,不改动老师原有的课表) |
|
||||
|
||||
## 安装
|
||||
|
||||
@@ -17,23 +17,23 @@
|
||||
- 用打包好的 `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_...`,只显示一次)。
|
||||
2. 重启 ClassIsland,在「设置 → 插件」里确认「ClassHelper 联动」已启用。
|
||||
3. 打开「设置 → ClassHelper 联动」,填入:
|
||||
- **服务器地址**:ClassHelper后端地址,例如 `http://127.0.0.1:4000`;
|
||||
- **设备令牌**:在 ClassHelper Web 端「ClassIsland 联动」页面为本班新建一台设备后复制(形如 `chci_...`,只显示一次)。
|
||||
4. 点「立即上报」验证连接状态;状态卡片显示「已连接」即接入成功。
|
||||
|
||||
## 配置项(全部在「设置 → 班级小助手联动」里)
|
||||
## 配置项(全部在「设置 → ClassHelper 联动」里)
|
||||
|
||||
| 配置项 | 说明 |
|
||||
| ---------------------------- | -------------------------------------------------------------------------------------------------- |
|
||||
| 服务器地址 | 班级小助手后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
|
||||
| 服务器地址 | ClassHelper后端地址(含端口;末尾斜杠与多余的 `/api` 后缀都会被自动容错) |
|
||||
| 设备令牌 | 由 Web 端「ClassIsland 联动」页生成;服务端只存 sha256,明文只在页面上出现一次 |
|
||||
| 上报课表到班级小助手 | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
|
||||
| 上报课表到 ClassHelper | 把 ClassIsland 里**已启用**的课表同步过去(合并写入:同星期同时间更新,不产生重复行) |
|
||||
| 把班级课表镜像回 ClassIsland | 反方向同步,新建带前缀的档案课表;关闭后仍会照常上报「现在上什么课」 |
|
||||
| 自动上报 | 启动 / 课程事件(上课、下课、放学、状态变化)/ 定时各上报一次;关闭后只能用「立即上报」手动触发 |
|
||||
| 上报间隔(秒) | 定时上报间隔,最小 15 秒,教室机器建议 60 秒 |
|
||||
| 接收班级小助手提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
|
||||
| 接收 ClassHelper提醒 | **本机总闸**:关掉后本机忽略老师下发的提醒(提醒仍留在班级通知中心) |
|
||||
| 语音朗读 | 是否允许本机朗读(老师下发时也勾了「语音朗读」才会念,两个条件都满足才出声) |
|
||||
| 默认显示时长(秒) | 老师未指定时长时用这个值;单条提醒可以在 Web 端单独指定更长的时长 |
|
||||
| 提醒轮询间隔(秒) | **老师按下发送 → 教室弹出之间的最大延迟**(插件不持有长连接,是轮询取回的),默认 10 秒、最小 5 秒 |
|
||||
@@ -48,20 +48,20 @@
|
||||
|
||||
> **课间**:镜像回 ClassIsland 的课表会自动补上课间 —— **每两节之间**一段
|
||||
> (上一节下课 → 下一节上课),**最后一节之后不补**(放学)。课间由插件按课目时间算出来,
|
||||
> 不依赖班级小助手那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
|
||||
> 不依赖 ClassHelper那边是否维护节次时间表,因此 ClassIsland 档案里一定有完整作息。
|
||||
|
||||
> **两个方向各自需要的开关**:
|
||||
>
|
||||
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
|
||||
> - ClassIsland → 服务端(上报课表):插件「上报课表到班级小助手」打开;
|
||||
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到班级小助手」开关已关闭)」),不是上报失败;
|
||||
> - ClassIsland → 服务端(上报课表):插件「上报课表到 ClassHelper」打开;
|
||||
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到 ClassHelper」开关已关闭)」),不是上报失败;
|
||||
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
|
||||
|
||||
> **提醒到底会不会弹,取决于两个开关:**
|
||||
>
|
||||
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
|
||||
> 服务端根本不会推过来(此时本插件收不到东西,属正常);
|
||||
> 2. 本插件的「接收班级小助手提醒」关掉时,即使推过来也不弹。
|
||||
> 2. 本插件的「接收 ClassHelper提醒」关掉时,即使推过来也不弹。
|
||||
>
|
||||
> 两者都开着,提醒才会在 ClassIsland 上出现。
|
||||
|
||||
@@ -75,7 +75,7 @@
|
||||
补上「教室机器关机期间老师发的通知」。上报间隔本身就是心跳,插件因此少一个长连接故障面。
|
||||
- **提醒必须由「提醒提供方」发出**:只有通过 `NotificationProviderBase` 发出的提醒,
|
||||
才会走 ClassIsland 主界面的全屏特效 / 语音 / 音效那一整套流程,因此插件注册了
|
||||
「班级小助手提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
|
||||
「ClassHelper提醒」提供方(可以在 ClassIsland 的「提醒」设置里整体开关它)。
|
||||
- **回执时机 = 真的播完**:回执挂在提醒的 `Completed` / `Canceled` 上,
|
||||
中途退出 ClassIsland 不会回执,下次上报会重发 —— 既不静默丢提醒,也不会重复弹。
|
||||
- **镜像时重建时间点**:ClassIsland 的 `ClassPlan.Classes[i]` 必须与时间表里
|
||||
@@ -85,7 +85,7 @@
|
||||
- **单双周口径与三端一致**:`WeekCountDiv/WeekCountDivTotal` ⇄ `ALL/ODD/EVEN`
|
||||
与 `@classhelper/shared` 的 `weekParityFromDiv` / `weekDivFromParity` 互逆;
|
||||
3 周及以上的轮换本系统无法表达,会降级为「每周」并在日志里提示。
|
||||
- **非破坏性**:镜像只创建 / 更新名字以 `班级小助手-` 开头的课表与时间表;
|
||||
- **非破坏性**:镜像只创建 / 更新名字以 `ClassHelper-` 开头的课表与时间表;
|
||||
老师手动建的课表不删不改名,同一星期已有启用课表时会在日志里给出重叠提示。
|
||||
|
||||
## 上机联调记录(ClassIsland 2.1.0.1 实测)
|
||||
@@ -129,7 +129,7 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
|
||||
|
||||
## 常见问题
|
||||
|
||||
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是班级小助手的标准格式)**
|
||||
**填好令牌后点「立即上报」,提示 `HTTP 404`(响应不是 ClassHelper的标准格式)**
|
||||
这台机器上的**系统代理拦了内网请求**。Windows 的代理设置里 `127.0.0.1` 并不在默认绕过列表里,
|
||||
于是浏览器/插件对「本地服务」的请求也被送到代理,拿到的是**代理自己的 404 错误页**。
|
||||
插件已对本地/内网地址(localhost、回环、10/172.16-31/192.168、链路本地、无点主机名)**强制直连、绕开系统代理**;
|
||||
@@ -151,7 +151,7 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
|
||||
2. 确认 Web 端「ClassIsland 联动」页面上该设备是「已接入」状态(停用后服务端会直接 403);
|
||||
3. 提醒会在 24 小时后过期,避免教室机器一开机弹出一堆旧通知。
|
||||
|
||||
**课表上报了但班级小助手上没变化**
|
||||
**课表上报了但 ClassHelper上没变化**
|
||||
确认插件设置里打开了「上报课表到服务端」,且 Web 端该设备的「回传课表」开关也是允许状态。
|
||||
|
||||
**镜像开关打开了,但 ClassIsland 的课表没变**
|
||||
@@ -160,4 +160,4 @@ pnpm verify:classisland-plugin # 45 项静态契约校验(清单一致性
|
||||
|
||||
## 许可
|
||||
|
||||
与班级小助手仓库一致。
|
||||
与 ClassHelper仓库一致。
|
||||
@@ -1,19 +1,19 @@
|
||||
# 班级小助手 ClassIsland 联动插件 —— 清单文件
|
||||
# ClassHelper ClassIsland 联动插件 —— 清单文件
|
||||
#
|
||||
# 字段说明见 https://docs.classisland.tech/dev/plugins/create-project/
|
||||
# apiVersion 指向 ClassIsland 2.0.0.0:本插件只用到 2.0 就有的插件 / 提醒 / 课程服务 API。
|
||||
|
||||
id: classhelper.classisland.bridge
|
||||
name: 班级小助手联动
|
||||
name: ClassHelper 联动
|
||||
description: |-
|
||||
把 ClassIsland 的课表与上课状态同步到班级小助手,并在 ClassIsland 上显示老师下发的提醒。
|
||||
把 ClassIsland 的课表与上课状态同步到 ClassHelper,并在 ClassIsland 上显示老师下发的提醒。
|
||||
功能:
|
||||
1. 上报当前课表与节次时间(班级小助手 Web 端与桌面客户端据此显示课程);
|
||||
1. 上报当前课表与节次时间(ClassHelper Web 端与桌面客户端据此显示课程);
|
||||
2. 上报"正在上哪节课 / 下一节是什么 / 还有多久下课"的实时状态;
|
||||
3. 接收老师在班级小助手上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
|
||||
4. 可选:把班级小助手上排好的课表镜像回 ClassIsland。
|
||||
3. 接收老师在 ClassHelper上发布的提醒,在 ClassIsland 上全屏弹出(支持语音朗读);
|
||||
4. 可选:把 ClassHelper上排好的课表镜像回 ClassIsland。
|
||||
entranceAssembly: 'ClassHelper.ClassIslandPlugin.dll'
|
||||
url: https://github.com/LoveChengke/ClassHelper
|
||||
version: 1.1.2.0
|
||||
version: 1.2.0.0
|
||||
apiVersion: 2.0.0.0
|
||||
author: 班级小助手
|
||||
author: ClassHelper
|
||||
@@ -101,7 +101,7 @@ check(
|
||||
|
||||
check(
|
||||
'设置页带 [SettingsPageInfo] 且声明了页面 id',
|
||||
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"班级小助手联动"/.test(settingsPageSource),
|
||||
/\[SettingsPageInfo\(/.test(settingsPageSource) && /"ClassHelper 联动"/.test(settingsPageSource),
|
||||
);
|
||||
check(
|
||||
'设置页 XAML 的 x:Class 与代码后置类一致',
|
||||
@@ -255,6 +255,34 @@ check(
|
||||
);
|
||||
check('失败信息带上请求 URL(便于定位代理/网关拦截)', /request\.RequestUri/.test(clientSource));
|
||||
|
||||
// 3.10 课表同步方向(2026-10-06 反转):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
|
||||
// 「把教室课表传上来」改成 Web 端的一次性人工请求。这几条盯着反转后的契约,别删。
|
||||
check(
|
||||
'默认「上报课表到 ClassHelper」关闭、镜像到 ClassIsland 开启(新方向)',
|
||||
/_uploadSchedule\s*=\s*false/.test(settingsSource) && /_mirrorSchedule\s*=\s*true/.test(settingsSource),
|
||||
);
|
||||
check(
|
||||
'响应 DTO 里有 scheduleRequested(服务端请求立刻上报一次课表)',
|
||||
/scheduleRequested/.test(clientSource),
|
||||
);
|
||||
check(
|
||||
'收到 scheduleRequested 会补发一次带课表的上报(且不受本机自动上报开关限制)',
|
||||
/ScheduleRequested\s*==\s*true/.test(bridgeSource) &&
|
||||
/forceSchedule:\s*true/.test(bridgeSource),
|
||||
);
|
||||
check(
|
||||
'服务端请求课表不依赖「自动回传」开关(forceSchedule 或上开关二者取一)',
|
||||
/forceSchedule\s*\|\|\s*_settings\.UploadSchedule/.test(bridgeSource),
|
||||
);
|
||||
check(
|
||||
'补发课表发生在镜像之前(顺序反了会把刚拿到的课表盖掉)',
|
||||
bridgeSource.indexOf('ScheduleRequested == true') < bridgeSource.indexOf('MirrorScheduleAsync()'),
|
||||
);
|
||||
check(
|
||||
'课表拉取 DTO 带 weekRanges(管理员逐周配的学期周次区间)',
|
||||
/weekRanges/.test(clientSource) && /TermWeekRangeDto/.test(clientSource),
|
||||
);
|
||||
|
||||
// 3.9 提醒显示链路:下面几条全是"在真机上跑出来的",回归时别删
|
||||
check(
|
||||
'提醒在 UI 线程构造并显示(否则 Avalonia 直接抛 Call from invalid thread)',
|
||||
@@ -286,7 +314,7 @@ check(
|
||||
);
|
||||
check(
|
||||
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
|
||||
bridgeSource.includes('上报课表到班级小助手') && bridgeSource.includes('开关已关闭'),
|
||||
bridgeSource.includes('上报课表到 ClassHelper') && bridgeSource.includes('开关已关闭'),
|
||||
);
|
||||
check(
|
||||
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
||||
@@ -330,7 +358,7 @@ check(
|
||||
);
|
||||
check(
|
||||
'2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小',
|
||||
clientSource.includes('响应不是班级小助手的标准格式') &&
|
||||
clientSource.includes('响应不是 ClassHelper的标准格式') &&
|
||||
clientSource.includes('MaxResponseContentBufferSize'),
|
||||
);
|
||||
check(
|
||||
|
||||
@@ -80,6 +80,14 @@ public class ReportSettingsDto
|
||||
{
|
||||
[JsonPropertyName("mirrorScheduleToClassIsland")] public bool MirrorScheduleToClassIsland { get; set; }
|
||||
[JsonPropertyName("enabled")] public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 服务端请求**立刻上报一次课表**(老师在 Web 端点了「从教室机器获取课表」)。
|
||||
///
|
||||
/// 它与本机的「上报课表到 ClassHelper」开关无关:那个开关管的是"要不要**自动**回传",
|
||||
/// 现在是默认关闭的;而这是一次显式的人工请求,看到就要照做。
|
||||
/// </summary>
|
||||
[JsonPropertyName("scheduleRequested")] public bool ScheduleRequested { get; set; }
|
||||
}
|
||||
|
||||
public class ReportResultDto
|
||||
@@ -157,10 +165,32 @@ public class ClassPlanMirrorDto
|
||||
[JsonPropertyName("termStartDate")] public string TermStartDate { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>服务端逐周配的学期周次区间(管理员在「学期周次」里维护)。</summary>
|
||||
public class TermWeekRangeDto
|
||||
{
|
||||
[JsonPropertyName("weekNumber")] public int WeekNumber { get; set; }
|
||||
/// <summary>YYYY-MM-DD</summary>
|
||||
[JsonPropertyName("startDate")] public string StartDate { get; set; } = "";
|
||||
/// <summary>YYYY-MM-DD</summary>
|
||||
[JsonPropertyName("endDate")] public string EndDate { get; set; } = "";
|
||||
}
|
||||
|
||||
public class PullResultDto
|
||||
{
|
||||
[JsonPropertyName("classPlan")] public ClassPlanMirrorDto? ClassPlan { get; set; }
|
||||
[JsonPropertyName("week")] public int Week { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 学期逐周区间;空 = 服务端只按学期开始日期线性推算。
|
||||
///
|
||||
/// ClassIsland 自身只认"教学周序号 + 单双周",没有"按日期区间定义某一周"的模型
|
||||
/// (本机装的 2.1.0.1 没有该字段),所以这份区间在插件侧只用于:
|
||||
/// 1) 校正"现在是第几周"(调休会让线性推算整体错位);
|
||||
/// 2) 在设置页里显示教室这台机器当前处在哪一段日期区间,方便老师核对。
|
||||
/// 课表本身的周次口径仍由 <see cref="ClassPlanMirrorDto.TermStartDate"/> 与单双周表达。
|
||||
/// </summary>
|
||||
[JsonPropertyName("weekRanges")] public List<TermWeekRangeDto> WeekRanges { get; set; } = new();
|
||||
|
||||
[JsonPropertyName("serverTime")] public string ServerTime { get; set; } = "";
|
||||
}
|
||||
|
||||
@@ -174,7 +204,7 @@ internal class ApiEnvelope<T>
|
||||
[JsonPropertyName("code")] public string? Code { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>调用班级小助手后端的结果(不抛异常,便于设置页展示原因)。</summary>
|
||||
/// <summary>调用 ClassHelper后端的结果(不抛异常,便于设置页展示原因)。</summary>
|
||||
public class ApiCallResult
|
||||
{
|
||||
public bool Ok { get; init; }
|
||||
@@ -187,7 +217,7 @@ public class ApiCallResult
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 班级小助手后端客户端。
|
||||
/// ClassHelper后端客户端。
|
||||
///
|
||||
/// 只用 <see cref="HttpClient"/> + System.Text.Json:不引入额外依赖包,
|
||||
/// 避免插件加载时因为"插件程序集隔离"缺少依赖而无法启动
|
||||
@@ -214,7 +244,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 与班级小助手通信的 HttpClient。
|
||||
/// 与 ClassHelper通信的 HttpClient。
|
||||
///
|
||||
/// **必须显式处理代理**:教室机器(以及开发机)常常配了系统代理,
|
||||
/// 而 Windows 的代理设置默认连 `127.0.0.1` 也会走代理 —— 于是"填写令牌后立刻 HTTP 404",
|
||||
@@ -335,7 +365,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
// 老师截图给我们就能一眼定位。
|
||||
message = envelope is null
|
||||
? $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
||||
"响应不是班级小助手的标准格式,请检查服务器地址是否正确、" +
|
||||
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确、" +
|
||||
"或该系统代理是否拦截了内网请求"
|
||||
: $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})";
|
||||
}
|
||||
@@ -350,7 +380,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
|
||||
LastError =
|
||||
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
||||
"响应不是班级小助手的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
|
||||
"响应不是 ClassHelper的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
|
||||
return new ApiEnvelope<T> { Success = false, Message = LastError };
|
||||
}
|
||||
|
||||
@@ -365,7 +395,7 @@ public sealed class ClassHelperClient : IDisposable
|
||||
}
|
||||
catch (HttpRequestException exception)
|
||||
{
|
||||
LastError = $"无法连接班级小助手服务:{exception.Message}";
|
||||
LastError = $"无法连接 ClassHelper服务:{exception.Message}";
|
||||
return null;
|
||||
}
|
||||
catch (Exception exception)
|
||||
|
||||
@@ -17,8 +17,11 @@ public class PluginSettings : ObservableObject
|
||||
private string _deviceKey = "";
|
||||
private bool _autoReport = true;
|
||||
private int _reportIntervalSeconds = 60;
|
||||
private bool _uploadSchedule = true;
|
||||
private bool _mirrorSchedule = false;
|
||||
// 同步方向(2026-10-06 起):课表以 ClassHelper为准,**服务端 → 教室自动下发**;
|
||||
// 「把教室课表传上去」改为 Web 端的一次性人工动作(点之前会弹警告)。
|
||||
// 自动回传会悄悄盖掉老师刚手排的课,所以默认关闭。
|
||||
private bool _uploadSchedule = false;
|
||||
private bool _mirrorSchedule = true;
|
||||
private bool _receiveNotifications = true;
|
||||
private int _notificationDurationSeconds = 8;
|
||||
private int _notificationPollSeconds = 10;
|
||||
@@ -26,7 +29,7 @@ public class PluginSettings : ObservableObject
|
||||
private bool _showDebugInfo = false;
|
||||
|
||||
/// <summary>
|
||||
/// 班级小助手后端地址,例如 <c>http://127.0.0.1:4000</c>。
|
||||
/// ClassHelper后端地址,例如 <c>http://127.0.0.1:4000</c>。
|
||||
/// </summary>
|
||||
public string ServerUrl
|
||||
{
|
||||
@@ -35,7 +38,7 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设备令牌(形如 <c>chci_...</c>):在班级小助手 Web 端「ClassIsland 联动」页面生成。
|
||||
/// 设备令牌(形如 <c>chci_...</c>):在 ClassHelper Web 端「ClassIsland 联动」页面生成。
|
||||
/// </summary>
|
||||
public string DeviceToken
|
||||
{
|
||||
@@ -74,8 +77,13 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否把 ClassIsland 的课表同步到班级小助手。
|
||||
/// 关闭后仍会上报"当前上什么课"的状态,只是不再写课表。
|
||||
/// 是否把 ClassIsland 的课表**自动**同步到 ClassHelper。
|
||||
///
|
||||
/// **默认关闭**:课表以 ClassHelper为准,教室这边改完课表不会自己传上去
|
||||
/// (否则老师刚在 ClassHelper上排好的课会被教室的旧课表悄悄覆盖)。
|
||||
/// 要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,
|
||||
/// 与本开关无关(见 <c>ReportSettingsDto.ScheduleRequested</c>)。
|
||||
/// 关闭后仍会上报"当前上什么课"的状态。
|
||||
/// </summary>
|
||||
public bool UploadSchedule
|
||||
{
|
||||
@@ -84,8 +92,10 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否把班级小助手上排好的课表镜像回 ClassIsland
|
||||
/// (新建一份名为「班级小助手-<班级名>」的档案课表,不覆盖老师原有的课表)。
|
||||
/// 是否把 ClassHelper上排好的课表镜像回 ClassIsland
|
||||
/// (新建一份名为「ClassHelper-<班级名>」的档案课表,不覆盖老师原有的课表)。
|
||||
///
|
||||
/// **默认开启**:这是现在课表同步的主方向。
|
||||
/// </summary>
|
||||
public bool MirrorSchedule
|
||||
{
|
||||
@@ -94,7 +104,7 @@ public class PluginSettings : ObservableObject
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否接收老师在班级小助手上发布的提醒,并在 ClassIsland 上弹出。
|
||||
/// 是否接收老师在 ClassHelper上发布的提醒,并在 ClassIsland 上弹出。
|
||||
/// </summary>
|
||||
public bool ReceiveNotifications
|
||||
{
|
||||
|
||||
@@ -54,7 +54,7 @@ public class Plugin : PluginBase
|
||||
var application = AppBase.Current;
|
||||
if (application is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到 ClassIsland 应用实例,联动未启动");
|
||||
Logger?.LogError("ClassHelper 联动:拿不到 ClassIsland 应用实例,联动未启动");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -72,14 +72,14 @@ public class Plugin : PluginBase
|
||||
var lessons = IAppHost.TryGetService<ILessonsService>();
|
||||
if (lessons is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到课程服务,联动已停用");
|
||||
Logger?.LogError("ClassHelper 联动:拿不到课程服务,联动已停用");
|
||||
return;
|
||||
}
|
||||
|
||||
var bridge = IAppHost.TryGetService<BridgeService>();
|
||||
if (bridge is null)
|
||||
{
|
||||
Logger?.LogError("班级小助手联动:拿不到联动服务,联动已停用");
|
||||
Logger?.LogError("ClassHelper 联动:拿不到联动服务,联动已停用");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,12 +89,12 @@ public class Plugin : PluginBase
|
||||
// 应用刚起来时还没有第一个上报点,先主动拉一次未弹出的提醒(离线期间老师发的通知):
|
||||
// 不这么做的话,最长要等一个上报间隔才会弹出来
|
||||
_ = bridge.PullPendingAsync();
|
||||
Logger?.LogInformation("班级小助手联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
|
||||
Logger?.LogInformation("ClassHelper 联动:已接入课程服务(服务器 {Server})", Settings.ServerUrl);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 联动失败绝不能把 ClassIsland 本体拖下水
|
||||
Logger?.LogError(exception, "班级小助手联动:初始化联动服务失败");
|
||||
Logger?.LogError(exception, "ClassHelper 联动:初始化联动服务失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -120,7 +120,7 @@ public class Plugin : PluginBase
|
||||
{
|
||||
// 配置损坏不该拦住插件加载:用默认值继续,原文件保留供排查
|
||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||
"班级小助手联动:读取设置失败,已回退到默认设置({Path})", path);
|
||||
"ClassHelper 联动:读取设置失败,已回退到默认设置({Path})", path);
|
||||
}
|
||||
|
||||
var needGenerateKey = string.IsNullOrWhiteSpace(settings.DeviceKey);
|
||||
@@ -165,7 +165,7 @@ public class Plugin : PluginBase
|
||||
catch (Exception exception)
|
||||
{
|
||||
IAppHost.TryGetService<ILogger<Plugin>>()?.LogWarning(exception,
|
||||
"班级小助手联动:保存设置失败({Path})", path);
|
||||
"ClassHelper 联动:保存设置失败({Path})", path);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,10 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
/// <summary>
|
||||
/// 联动主循环。三件事:
|
||||
/// 1. <b>上报</b>:应用启动后、课程事件(上课/下课/放学/状态变化)发生时、以及每 N 秒,
|
||||
/// 把"当前上什么课"与(可选的)全量课表推给班级小助手;
|
||||
/// 把"当前上什么课"与(可选的)全量课表推给 ClassHelper;
|
||||
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
|
||||
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
|
||||
/// 3. <b>镜像</b>:开启镜像开关时,把班级小助手上排好的课表写回 ClassIsland 档案。
|
||||
/// 3. <b>镜像</b>:开启镜像开关时,把 ClassHelper上排好的课表写回 ClassIsland 档案。
|
||||
///
|
||||
/// 为什么用"上报即拉取"而不是长连接:
|
||||
/// 插件不需要额外的 Socket/WebSocket 依赖(避免程序集隔离带来的加载问题),
|
||||
@@ -174,7 +174,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
}
|
||||
|
||||
Raise(nameof(IsAttached));
|
||||
_logger.LogInformation("班级小助手联动:已订阅课程事件");
|
||||
_logger.LogInformation("ClassHelper 联动:已订阅课程事件");
|
||||
RequestReport("应用启动");
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
/// 执行一次上报并返回是否真的发出去了。
|
||||
/// <paramref name="includeSchedule"/> 为 true 时带上全量课表与节次时间。
|
||||
/// </summary>
|
||||
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true)
|
||||
public async Task<bool> ReportAsync(string reason, bool includeSchedule = true, bool forceSchedule = false)
|
||||
{
|
||||
if (_disposed) return false;
|
||||
|
||||
@@ -253,17 +253,21 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
var layoutCount = 0;
|
||||
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
|
||||
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
|
||||
if (includeSchedule && !_settings.UploadSchedule)
|
||||
// forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求,
|
||||
// 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested)
|
||||
var uploadSchedule = forceSchedule || _settings.UploadSchedule;
|
||||
|
||||
if (includeSchedule && !uploadSchedule)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:本机设置里「上报课表到班级小助手」是关闭的,本次只上报状态;" +
|
||||
"需要把教室课表同步到班级小助手请打开该开关");
|
||||
"ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" +
|
||||
"需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」");
|
||||
}
|
||||
|
||||
if (includeSchedule && _settings.UploadSchedule && profile is not null)
|
||||
if (includeSchedule && uploadSchedule && profile is not null)
|
||||
{
|
||||
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
|
||||
foreach (var warning in warnings) _logger.LogWarning("班级小助手联动:{Warning}", warning);
|
||||
foreach (var warning in warnings) _logger.LogWarning("ClassHelper 联动:{Warning}", warning);
|
||||
entryCount = entries.Count;
|
||||
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
|
||||
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
|
||||
@@ -272,8 +276,8 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
request.Schedule = null;
|
||||
_logger.LogWarning(
|
||||
"班级小助手联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
|
||||
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由班级小助手下发一份");
|
||||
"ClassHelper 联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
|
||||
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由 ClassHelper下发一份");
|
||||
}
|
||||
|
||||
var layout = ResolvePrimaryTimeLayout(profile);
|
||||
@@ -296,7 +300,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
.ConfigureAwait(false);
|
||||
if (!result.Ok || data is null)
|
||||
{
|
||||
_logger.LogWarning("班级小助手联动上报失败({Reason}):{Message}", reason, result.Message);
|
||||
_logger.LogWarning("ClassHelper 联动上报失败({Reason}):{Message}", reason, result.Message);
|
||||
SetState(false, result.Message, entryCount, layoutCount, null);
|
||||
return false;
|
||||
}
|
||||
@@ -306,15 +310,15 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
if (data.ScheduleApplied)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
||||
"ClassHelper 联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
||||
data.ScheduleCreated, data.ScheduleUpdated);
|
||||
}
|
||||
|
||||
var detail = entryCount > 0
|
||||
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
||||
: _settings.UploadSchedule
|
||||
: uploadSchedule
|
||||
? "仅状态(ClassIsland 里还没有启用的课表)"
|
||||
: "仅状态(本机「上报课表到班级小助手」开关已关闭)";
|
||||
: "仅状态(本机「上报课表到 ClassHelper」开关已关闭)";
|
||||
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
|
||||
entryCount, layoutCount, data.Week);
|
||||
|
||||
@@ -325,6 +329,14 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
NotificationReceived?.Invoke(this, pending);
|
||||
}
|
||||
|
||||
// 服务端请求"立刻上报一次课表"(老师在 Web 端点了「从教室机器获取课表」)。
|
||||
// 放在镜像之前:先把教室的现状推上去,再做服务端→教室的镜像,顺序反了会把刚拿到的课表盖掉。
|
||||
if (data.Settings?.ScheduleRequested == true && !uploadSchedule)
|
||||
{
|
||||
_logger.LogInformation("ClassHelper 联动:服务端请求立即上报课表,正在补发一次…");
|
||||
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
|
||||
}
|
||||
|
||||
if (_settings.MirrorSchedule)
|
||||
{
|
||||
await MirrorScheduleAsync().ConfigureAwait(false);
|
||||
@@ -338,7 +350,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
|
||||
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
|
||||
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
|
||||
_logger.LogError(exception, "班级小助手联动:上报异常({Reason})", reason);
|
||||
_logger.LogError(exception, "ClassHelper 联动:上报异常({Reason})", reason);
|
||||
SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
|
||||
return false;
|
||||
}
|
||||
@@ -369,13 +381,13 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
var (result, items) = await _client.PendingAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!result.Ok)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:拉取待提醒失败({Message})", result.Message);
|
||||
_logger.LogInformation("ClassHelper 联动:拉取待提醒失败({Message})", result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var item in items)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
|
||||
_logger.LogInformation("ClassHelper 联动:取到待弹出提醒「{Title}」(id={Id})", item.Title, item.Id);
|
||||
NotificationReceived?.Invoke(this, item);
|
||||
}
|
||||
}
|
||||
@@ -392,16 +404,16 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
if (result.Ok)
|
||||
{
|
||||
NotificationCount += 1;
|
||||
_logger.LogInformation("班级小助手联动:已确认提醒 {Id}", id);
|
||||
_logger.LogInformation("ClassHelper 联动:已确认提醒 {Id}", id);
|
||||
}
|
||||
else
|
||||
{
|
||||
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
|
||||
_logger.LogWarning("班级小助手联动:确认提醒失败 {Message}", result.Message);
|
||||
_logger.LogWarning("ClassHelper 联动:确认提醒失败 {Message}", result.Message);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>把班级小助手上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
|
||||
/// <summary>把 ClassHelper上排好的课表写回 ClassIsland(新建带前缀的档案课表,不动老师原有的课表)。</summary>
|
||||
public async Task MirrorScheduleAsync()
|
||||
{
|
||||
var profile = ResolveProfile();
|
||||
@@ -412,7 +424,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
{
|
||||
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:跳过课表镜像({Message})。" +
|
||||
"ClassHelper 联动:跳过课表镜像({Message})。" +
|
||||
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
|
||||
result.Message);
|
||||
return;
|
||||
@@ -423,7 +435,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
var signature = MirrorSignature(plan);
|
||||
if (signature == _lastMirrorSignature)
|
||||
{
|
||||
_logger.LogDebug("班级小助手联动:课表内容与上次一致,跳过镜像");
|
||||
_logger.LogDebug("ClassHelper 联动:课表内容与上次一致,跳过镜像");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -451,22 +463,22 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
|
||||
if (!outcome.Applied)
|
||||
{
|
||||
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
|
||||
_logger.LogInformation("ClassHelper 联动:课表镜像未生效({Reason})",
|
||||
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
||||
return;
|
||||
}
|
||||
|
||||
_lastMirrorSignature = signature;
|
||||
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
|
||||
_logger.LogInformation("ClassHelper 联动:已把班级课表写入 ClassIsland({Plans})",
|
||||
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
|
||||
foreach (var warning in outcome.Warnings)
|
||||
{
|
||||
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
|
||||
_logger.LogWarning("ClassHelper 联动镜像提示:{Warning}", warning);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogError(exception, "班级小助手联动:课表镜像失败");
|
||||
_logger.LogError(exception, "ClassHelper 联动:课表镜像失败");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -495,7 +507,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
/* ---------------------------------------------------------------- 内部实现 */
|
||||
|
||||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||
private const string PluginVersion = "1.1.2.0";
|
||||
private const string PluginVersion = "1.2.0.0";
|
||||
|
||||
/// <summary>ClassIsland 版本读不到时不该让上报失败(开发版可能为 null)。</summary>
|
||||
private static string SafeAppVersion()
|
||||
@@ -524,13 +536,13 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
catch (Exception exception)
|
||||
{
|
||||
// 课程服务在"应用启动中/课表未加载"时可能抛异常,这里降级为只有心跳的上报
|
||||
_logger.LogWarning(exception, "班级小助手联动:读取课程状态失败,本次只上报心跳");
|
||||
_logger.LogWarning(exception, "ClassHelper 联动:读取课程状态失败,本次只上报心跳");
|
||||
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 选一份"最有代表性"的时间表上报给班级小助手:**上课点最多的那一份**。
|
||||
/// 选一份"最有代表性"的时间表上报给 ClassHelper:**上课点最多的那一份**。
|
||||
///
|
||||
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
|
||||
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
|
||||
@@ -578,7 +590,7 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "班级小助手联动:读取档案失败");
|
||||
_logger.LogWarning(exception, "ClassHelper 联动:读取档案失败");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,14 +12,14 @@ using Microsoft.Extensions.Logging;
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 提醒提供方:把老师在班级小助手上发布的提醒显示到 ClassIsland 上。
|
||||
/// 提醒提供方:把老师在 ClassHelper上发布的提醒显示到 ClassIsland 上。
|
||||
///
|
||||
/// 与 <see cref="BridgeService"/> 的分工:
|
||||
/// - <see cref="BridgeService"/> 负责"拉"(上报时顺路把待提醒带回来)与"回执"(ack);
|
||||
/// - 本类只负责"显示"——提醒必须由提醒提供方发出,ClassIsland 才会走主界面的
|
||||
/// 全屏特效 / 语音 / 音效那一整套流程。
|
||||
///
|
||||
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「班级小助手联动」设置页里,
|
||||
/// 为什么不给这个提供方再配一套"提醒设置":本插件的开关全在「ClassHelper 联动」设置页里,
|
||||
/// 再在「提醒」设置页放第二份开关只会让老师困惑(两处都能关,到底以哪个为准)。
|
||||
/// 因此这里用无设置的 <see cref="NotificationProviderBase"/>,读的是同一份 <see cref="PluginSettings"/>。
|
||||
/// </summary>
|
||||
@@ -27,9 +27,9 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
// 不是 "fluent(...)" 表达式):E8BD 是喇叭字形。
|
||||
[NotificationProviderInfo(
|
||||
ProviderGuidValue,
|
||||
"班级小助手提醒",
|
||||
"ClassHelper提醒",
|
||||
"\uE8BD",
|
||||
"显示老师从班级小助手下发的提醒。")]
|
||||
"显示老师从 ClassHelper下发的提醒。")]
|
||||
public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
{
|
||||
/// <summary>
|
||||
@@ -100,11 +100,11 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||
_lessonsHooked = true;
|
||||
_logger.LogInformation("班级小助手联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
|
||||
_logger.LogInformation("ClassHelper 联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,将在下次提醒时重试");
|
||||
_logger.LogWarning(exception, "ClassHelper 联动:订阅课程事件失败,将在下次提醒时重试");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,7 +137,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
|
||||
var pending = _deferred.ToList();
|
||||
_deferred.Clear();
|
||||
_logger.LogInformation("班级小助手联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
|
||||
_logger.LogInformation("ClassHelper 联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
|
||||
foreach (var item in pending) ShowOnUiThread(item);
|
||||
}
|
||||
|
||||
@@ -151,7 +151,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
{
|
||||
if (!_playing.Add(notification.Id))
|
||||
{
|
||||
_logger.LogDebug("班级小助手联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
|
||||
_logger.LogDebug("ClassHelper 联动:提醒 {Id} 正在播放,跳过重复投递", notification.Id);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -169,7 +169,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
if (IsInClass() && !IsImmediate(notification))
|
||||
{
|
||||
_deferred.Add(notification);
|
||||
_logger.LogInformation("班级小助手联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
|
||||
_logger.LogInformation("ClassHelper 联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -177,7 +177,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
|
||||
AckWhenFinished(request, notification);
|
||||
ShowNotification(request);
|
||||
_logger.LogInformation("班级小助手联动:已弹出提醒「{Title}」({Seconds} 秒)",
|
||||
_logger.LogInformation("ClassHelper 联动:已弹出提醒「{Title}」({Seconds} 秒)",
|
||||
notification.Title, notification.DurationSeconds);
|
||||
}
|
||||
catch (Exception exception)
|
||||
@@ -185,7 +185,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
// 显示失败不能让 ClassIsland 崩掉:记日志,且**不回执**,服务端下次轮询还会重发。
|
||||
// 同时要把 id 从"正在播放"里放出来,否则本次失败后再也不会重试。
|
||||
lock (_playing) _playing.Remove(notification.Id);
|
||||
_logger.LogError(exception, "班级小助手联动:显示提醒「{Title}」失败", notification.Title);
|
||||
_logger.LogError(exception, "ClassHelper 联动:显示提醒「{Title}」失败", notification.Title);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
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 title = string.IsNullOrWhiteSpace(notification.Title) ? "ClassHelper" : notification.Title;
|
||||
var content = string.IsNullOrWhiteSpace(notification.Content) ? title : notification.Content;
|
||||
|
||||
// 语音:老师勾了朗读 + 本机允许朗读,两者都满足才念(本机开关是给学生机的总闸)
|
||||
@@ -240,7 +240,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
return new NotificationRequest { MaskContent = mask, OverlayContent = overlay };
|
||||
}
|
||||
|
||||
/// <summary>提醒播完 / 被关掉后回执给班级小助手,服务端不再补发。</summary>
|
||||
/// <summary>提醒播完 / 被关掉后回执给 ClassHelper,服务端不再补发。</summary>
|
||||
private void AckWhenFinished(NotificationRequest request, PushNotificationDto notification)
|
||||
{
|
||||
void Handler(object? sender, EventArgs args)
|
||||
|
||||
@@ -5,10 +5,10 @@ using ClassIsland.Shared.Models.Profile;
|
||||
namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 把班级小助手拉回来的课表写进 ClassIsland 档案。
|
||||
/// 把 ClassHelper拉回来的课表写进 ClassIsland 档案。
|
||||
///
|
||||
/// **核心约束**:ClassIsland 的一份 <see cref="ClassPlan"/> 只带**一个** <see cref="TimeRule"/>
|
||||
/// (即一个星期几 + 一个单双周相位),而班级小助手的课表同时覆盖 7 天与单双周。
|
||||
/// (即一个星期几 + 一个单双周相位),而 ClassHelper的课表同时覆盖 7 天与单双周。
|
||||
/// 因此这里把服务端的条目**按(星期几 + 单双周)分组**,每一组生成一份独立的档案课表。
|
||||
///
|
||||
/// **时间点的构造方式**:ClassIsland 的 <c>ClassPlan.Classes[i]</c> 必须与
|
||||
@@ -25,10 +25,10 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
public static class ClassPlanWriter
|
||||
{
|
||||
/// <summary>识别前缀:只有名字带此前缀的课表才由本插件创建与维护。</summary>
|
||||
public const string PlanNamePrefix = "班级小助手-";
|
||||
public const string PlanNamePrefix = "ClassHelper-";
|
||||
|
||||
/// <summary>时间表名字后缀。</summary>
|
||||
public const string TimeLayoutNameSuffix = "(班级小助手)";
|
||||
public const string TimeLayoutNameSuffix = "(ClassHelper)";
|
||||
|
||||
/// <summary>一次镜像的结果(写入的课表名与冲突提示)。</summary>
|
||||
public sealed class ApplyResult
|
||||
@@ -146,7 +146,7 @@ public static class ClassPlanWriter
|
||||
{
|
||||
result.Warnings.Add(
|
||||
$"星期 {group.WeekDay} 已有启用的课表({string.Join("、", conflicts)})," +
|
||||
"两者重叠时以班级小助手同步的课表为准");
|
||||
"两者重叠时以 ClassHelper同步的课表为准");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ public static class ClassPlanWriter
|
||||
/// 按分组内的条目重建时间表:**每节课之间补一个课间**(最后一节之后不补)。
|
||||
///
|
||||
/// 课间 = 「上一节下课」到「下一节上课」之间的那段空隙,直接由两条课目的时间算出来,
|
||||
/// 不依赖服务端有没有给课间点 —— 这样无论班级小助手那边是否维护节次时间表,
|
||||
/// 不依赖服务端有没有给课间点 —— 这样无论 ClassHelper那边是否维护节次时间表,
|
||||
/// ClassIsland 档案里的课表都会有一致的课间(需求:每节课的间隔都是课间,放学后除外)。
|
||||
/// </summary>
|
||||
private static (Guid Id, TimeLayout Layout) BuildTimeLayout(
|
||||
@@ -226,7 +226,7 @@ public static class ClassPlanWriter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 课表名:单组时用「班级小助手-<班级名>」;多组时补上星期与单双周以免互相覆盖。
|
||||
/// 课表名:单组时用「ClassHelper-<班级名>」;多组时补上星期与单双周以免互相覆盖。
|
||||
/// </summary>
|
||||
private static string BuildPlanName(
|
||||
string profileName,
|
||||
@@ -235,7 +235,7 @@ public static class ClassPlanWriter
|
||||
int weekCountDivTotal,
|
||||
int groupCount)
|
||||
{
|
||||
var baseName = string.IsNullOrWhiteSpace(profileName) ? "班级小助手课表" : profileName.Trim();
|
||||
var baseName = string.IsNullOrWhiteSpace(profileName) ? "ClassHelper课表" : profileName.Trim();
|
||||
if (!baseName.StartsWith(PlanNamePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
baseName = PlanNamePrefix + baseName;
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ClassHelper.ClassIslandPlugin.Services;
|
||||
/// <summary>
|
||||
/// 课表 / 状态 → 上报载荷 的换算层。
|
||||
///
|
||||
/// 这里集中全部"ClassIsland 口径 ↔ 班级小助手口径"的转换,保证只有一处需要跟着
|
||||
/// 这里集中全部"ClassIsland 口径 ↔ ClassHelper口径"的转换,保证只有一处需要跟着
|
||||
/// ClassIsland 的模型变化而修改:
|
||||
/// - 星期:ClassIsland <c>WeekDay</c> 是 0=周日、1=周一…6=周六;本系统是 1=周一…7=周日;
|
||||
/// - 单双周:ClassIsland <c>WeekCountDiv</c>/<c>WeekCountDivTotal</c>;本系统 ALL/ODD/EVEN;
|
||||
@@ -91,7 +91,7 @@ public static class ScheduleMapper
|
||||
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
||||
if (plan.TimeRule.WeekCountDivTotal > 2)
|
||||
{
|
||||
warning = "存在 3 周以上的轮换课表,班级小助手只支持单双周,已按「每周」处理";
|
||||
warning = "存在 3 周以上的轮换课表,ClassHelper只支持单双周,已按「每周」处理";
|
||||
}
|
||||
|
||||
var classes = plan.Classes;
|
||||
@@ -102,7 +102,7 @@ public static class ScheduleMapper
|
||||
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
|
||||
var point = classPoints[index];
|
||||
if (info is null) continue;
|
||||
// 老师停用的一节:本系统没有"停用"概念,不上报(否则学生端会看到一节实际上不上的课)
|
||||
// 老师停用的一节:本系统没有"停用"概念,不上报(否则 ClassHelper 班级端会看到一节实际上不上的课)
|
||||
if (!info.IsEnabled) continue;
|
||||
|
||||
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
|
||||
|
||||
@@ -15,10 +15,10 @@
|
||||
<ScrollViewer>
|
||||
<StackPanel Classes="settings-container animated-intro" Spacing="0">
|
||||
<!-- 连接 -->
|
||||
<TextBlock Text="连接班级小助手"
|
||||
<TextBlock Text="连接 ClassHelper"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="填入班级小助手后端的地址与设备令牌。设备令牌在班级小助手 Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
|
||||
Text="填入 ClassHelper后端的地址与设备令牌。设备令牌在 ClassHelper Web 端「ClassIsland 联动」页面里为本班新建设备后生成,只显示一次。" />
|
||||
|
||||
<TextBox Watermark="http://127.0.0.1:4000"
|
||||
Text="{Binding Settings.ServerUrl, Mode=TwoWay}" />
|
||||
@@ -53,10 +53,10 @@
|
||||
<TextBlock Margin="0 24 0 0" Text="同步内容"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="课表以「合并」方式写入班级小助手:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
||||
Text="课表以「合并」方式写入 ClassHelper:同一星期、同一时间的课目会被更新,不会产生重复行。" />
|
||||
|
||||
<fa:SettingsExpander Header="上报课表到班级小助手"
|
||||
Description="把 ClassIsland 里启用的课表同步给班级小助手,Web 端与桌面客户端即可看到课程表。">
|
||||
<fa:SettingsExpander Header="自动上报课表到 ClassHelper"
|
||||
Description="把 ClassIsland 里启用的课表**自动**同步给 ClassHelper。默认关闭:课表以 ClassHelper 为准,自动回传会在老师手排课之后被教室的旧课表悄悄覆盖。要取教室的课表,请在 Web 端点「从教室机器获取课表」——那是一次显式的人工请求,与本开关无关。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
@@ -66,7 +66,7 @@
|
||||
</fa:SettingsExpander>
|
||||
|
||||
<fa:SettingsExpander Header="把班级课表镜像回 ClassIsland"
|
||||
Description="反方向同步:把班级小助手上排好的课表写回 ClassIsland,会新建一份以「班级小助手-」开头的档案课表,不会修改老师原有的课表。">
|
||||
Description="反方向同步:把 ClassHelper上排好的课表写回 ClassIsland,会新建一份以「ClassHelper-」开头的档案课表,不会修改老师原有的课表。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
@@ -101,10 +101,10 @@
|
||||
<TextBlock Margin="0 24 0 0" Text="提醒"
|
||||
Theme="{StaticResource SubtitleTextBlockStyle}" />
|
||||
<TextBlock Margin="0 6 0 12" TextWrapping="Wrap"
|
||||
Text="老师在班级小助手上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
|
||||
Text="老师在 ClassHelper上发布的提醒会以 ClassIsland 的全屏提醒呈现;未弹出的提醒在应用重开后会自动补发,确认弹出后不再重复。" />
|
||||
|
||||
<fa:SettingsExpander Header="接收班级小助手提醒"
|
||||
Description="关闭后本机会忽略老师在班级小助手上发布的提醒(提醒仍会留在班级通知中心)。">
|
||||
<fa:SettingsExpander Header="接收 ClassHelper提醒"
|
||||
Description="关闭后本机会忽略老师在 ClassHelper上发布的提醒(提醒仍会留在班级通知中心)。">
|
||||
<fa:SettingsExpander.IconSource>
|
||||
<ci:FluentIconSource Glyph="" />
|
||||
</fa:SettingsExpander.IconSource>
|
||||
@@ -165,7 +165,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<TextBlock Margin="0 24 0 0" Opacity="0.6" TextWrapping="Wrap"
|
||||
Text="提示:班级小助手 Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
|
||||
Text="提示:ClassHelper Web 端 →「ClassIsland 联动」可以新建/重置设备令牌、开关同步项,并实时看到本机当前上的课。" />
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</controls:SettingsPageBase>
|
||||
@@ -11,7 +11,7 @@ using CommunityToolkit.Mvvm.Input;
|
||||
namespace ClassHelper.ClassIslandPlugin.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 「班级小助手联动」设置页。
|
||||
/// 「ClassHelper 联动」设置页。
|
||||
///
|
||||
/// 页面上的绑定源就是本页自己(<c>DataContext = this</c>):<see cref="Settings"/> 暴露全部配置项,
|
||||
/// <see cref="Bridge"/> 暴露运行状态。这样 XAML 里能直接写 <c>{Binding Settings.Xxx}</c>,
|
||||
@@ -20,7 +20,7 @@ namespace ClassHelper.ClassIslandPlugin.Views;
|
||||
// 图标用 Fluent 字体的码位(与 ClassIsland 内置设置页写法一致):未选中/选中同一枚插头图标
|
||||
[SettingsPageInfo(
|
||||
"classhelper.classisland.bridge.settings",
|
||||
"班级小助手联动",
|
||||
"ClassHelper 联动",
|
||||
"\uE71B",
|
||||
"\uE71B",
|
||||
SettingsPageCategory.External)]
|
||||
|
||||
Reference in new issue
Block a user