功能级准入改为「权限键」,`requireRole` 退成默认模板。 权限组 - 权限目录(39 键:键 / 中文名 / 分类 / 默认角色)收口到 shared,路由挂 requirePermission / requireAnyPermission - 有效权限每请求实时算,改完分组立刻生效:ADMIN 全集且 permission:manage、system:manage 不可剥夺; CLASS_DEVICE 空集(不受分组影响);有分组 = 并集(因此也能用来**收回**权限);无分组 = 角色默认模板 - 新增「权限组」页:组增删改、成员分配、生效权限预览 - 班级维度的科目规则(assertCanManageSubjectContent)故意不可被权限组覆盖 客户端设备(ClassHelper 桌面端) - 设备身份是 deviceKey(首次启动生成的 UUID,saveConfig 拒绝改写、退出登录也保留) - 心跳走「DB 记状态 + 客户端定时拉」,不引入新的长连接:30 秒一次,在线窗口 90 秒 - 远控命令 shutdown / restart / logoff / lock:服务端有效期 10 分钟,客户端弹默认选中「取消」的对话框、 60 秒未确认按取消处理,执行后必须回执(重复回执幂等),同一台机器同一条命令只弹一次 - 新增「客户端设备」页:版本一览、一键推送更新、下发远控 客户端安装包由服务端下载并缓存(教室机常常没有外网,浏览器也受 CSP 限制) - POST /api/update/client/fetch 由服务端下载到 data/client-releases/<版本>/ 并登记 sha256 + size - 自动安装三条护栏:没有 sha256 就拒收、必须等到非上课且主窗口隐藏满 5 分钟、冒烟模式永不安装 大模型助手:只解析、不落库 - POST /api/ai/interpret 返回**待确认**的结构化动作(建作业 / 发通知 / 叫人), 真正落库仍调既有端点 —— 权限断言、上课时段紧急通知保护、ClassIsland 推送全部自动复用 - 模型给的 params 用与手动入口**同一份** zod 再校验(模型输出不可信) - API Key 走 AES-256-GCM(密钥由 JWT_SECRET 经 scrypt 派生)加密存 AppSetting,只进不出 - provider=mock 是离线确定性解析,自动化验收一律用它;每次解析写一条 AiAudit(不含密钥) 系统与运维 - 开发者模式(服务端内存缓存开关,热路径不查库;开启后 500 才附 error.stack)、运行诊断(绝不回传密钥) - 开机自启动运行期可改:Windows 改 HKCU\...\Run、Linux systemctl enable/disable, **非安装形态一律返回 supported:false**,不往开发机的注册表里写自启项 - 服务端侧栏折叠只用 CSS 收拢(不重建 DOM),关怀模式只放大字号与控件、不动任何几何令牌 客户端 - 崩溃自愈:渲染进程挂掉 / 超 10 秒无响应先静默重建主窗口(10 分钟最多 3 次), 未捕获异常只在「没在上课、没人看着」时才重启进程;冒烟模式绝不重启(会杀掉验证实例) - 上线补弹离线期间收到的消息只做一次:起点只在「可达 → 不可达」的边界写,恢复时取最近 5 条按时间升序推 发布内容批量 - POST /notifications/bulk、POST /homeworks/bulk:**单班失败不影响整批**,逐班返回结果 - 作业的科目名跨班**按名匹配**已有课程,找不到就整班记失败,绝不自动建课 (自动建课会把创建者写成任课老师,等于越权改任教关系) - 通知与作业同口径按「所属日期」归档,新增 /notifications/days 与 /notifications/creators BREAKING CHANGE: 能不能做某件事不再看角色名,而是看权限键(GET /api/permissions/me)。 未分组的账号按角色默认模板生效,行为与 1.2.0 一致;分组之后生效集合只由组的并集决定。 服务端首次启动会自动应用 1 个数据库迁移(权限组 3 表 / AppSetting / 客户端设备·命令·安装包 / AiAudit / Notification.assignDate / User.careMode,历史通知按本地日期回填),升级前请先备份。
640 lines
26 KiB
C#
640 lines
26 KiB
C#
using System.ComponentModel;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Text;
|
||
using Avalonia.Threading;
|
||
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 秒,
|
||
/// 把"当前上什么课"与(可选的)全量课表推给 ClassHelper;
|
||
/// 2. <b>接收</b>:每次上报的返回值里带一条"尚未确认的下发提醒",
|
||
/// 交给 <see cref="ClassHelperNotificationProvider"/> 在 ClassIsland 上弹出;
|
||
/// 3. <b>镜像</b>:开启镜像开关时,把 ClassHelper上排好的课表写回 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;
|
||
/// <summary>
|
||
/// 上次成功镜像的课表内容指纹。内容没变就完全跳过镜像:既省掉每分钟一次的全量重建 + 落盘,
|
||
/// 也把"跨线程改档案"的窗口从"每分钟一次"降回"只在课表真的变化时"。
|
||
/// </summary>
|
||
private volatile string? _lastMirrorSignature;
|
||
|
||
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("ClassHelper 联动:已订阅课程事件");
|
||
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, bool forceSchedule = false)
|
||
{
|
||
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(),
|
||
// 机器码:设备创建时服务端只能用 pending-… 占位,靠这里回填,Web 端设备列表才能与教室机器对上
|
||
DeviceKey = _settings.DeviceKey,
|
||
State = BuildState(lessons),
|
||
};
|
||
|
||
var entryCount = 0;
|
||
var layoutCount = 0;
|
||
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
|
||
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
|
||
// forceSchedule:服务端显式点了「从教室机器获取课表」——这是一次人工请求,
|
||
// 不受本机「自动上报课表」开关影响(见下方 ReportSettingsDto.ScheduleRequested)
|
||
var uploadSchedule = forceSchedule || _settings.UploadSchedule;
|
||
|
||
if (includeSchedule && !uploadSchedule)
|
||
{
|
||
_logger.LogInformation(
|
||
"ClassHelper 联动:本机设置里「上报课表到 ClassHelper」是关闭的,本次只上报状态;" +
|
||
"需要把教室课表同步到 ClassHelper请打开该开关,或在 Web 端点「从教室机器获取课表」");
|
||
}
|
||
|
||
if (includeSchedule && uploadSchedule && profile is not null)
|
||
{
|
||
var entries = ScheduleMapper.MapAllClassPlans(profile, out var warnings);
|
||
foreach (var warning in warnings) _logger.LogWarning("ClassHelper 联动:{Warning}", warning);
|
||
entryCount = entries.Count;
|
||
request.Schedule = new SchedulePayloadDto { Mode = "merge", Entries = entries };
|
||
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
|
||
// 日志里只出现"仅状态"时,用户会以为上报失败,实际是 ClassIsland 里还没有课表。
|
||
if (entries.Count == 0)
|
||
{
|
||
request.Schedule = null;
|
||
_logger.LogWarning(
|
||
"ClassHelper 联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
|
||
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由 ClassHelper下发一份");
|
||
}
|
||
|
||
var layout = ResolvePrimaryTimeLayout(profile);
|
||
if (layout is not null)
|
||
{
|
||
var items = ScheduleMapper.MapTimeLayout(layout);
|
||
layoutCount = items.Count;
|
||
request.TimeLayout = new TimeLayoutPayloadDto
|
||
{
|
||
Name = ScheduleMapper.Truncate(
|
||
string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name,
|
||
ScheduleMapper.PlanNameMaxLength)!,
|
||
Mode = "replace",
|
||
Items = items,
|
||
};
|
||
}
|
||
}
|
||
|
||
var (result, data) = await _client.ReportAsync(_settings, request, CancellationToken.None)
|
||
.ConfigureAwait(false);
|
||
if (!result.Ok || data is null)
|
||
{
|
||
_logger.LogWarning("ClassHelper 联动上报失败({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(
|
||
"ClassHelper 联动:课表已同步(新增 {Created} / 更新 {Updated})",
|
||
data.ScheduleCreated, data.ScheduleUpdated);
|
||
}
|
||
|
||
var detail = entryCount > 0
|
||
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
||
: uploadSchedule
|
||
? "仅状态(ClassIsland 里还没有启用的课表)"
|
||
: "仅状态(本机「上报课表到 ClassHelper」开关已关闭)";
|
||
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);
|
||
}
|
||
|
||
// 服务端请求"立刻上报一次课表"(老师在 Web 端点了「从教室机器获取课表」)。
|
||
// 放在镜像之前:先把教室的现状推上去,再做服务端→教室的镜像,顺序反了会把刚拿到的课表盖掉。
|
||
if (data.Settings?.ScheduleRequested == true && !uploadSchedule)
|
||
{
|
||
_logger.LogInformation("ClassHelper 联动:服务端请求立即上报课表,正在补发一次…");
|
||
_ = ReportAsync("服务端请求课表", includeSchedule: true, forceSchedule: true);
|
||
}
|
||
|
||
if (_settings.MirrorSchedule)
|
||
{
|
||
await MirrorScheduleAsync().ConfigureAwait(false);
|
||
}
|
||
|
||
return true;
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
// ReportAsync 是通过 `_ = ReportAsync(reason)` 这种 fire-and-forget 方式发起的
|
||
// (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的
|
||
// "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有,
|
||
// 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。
|
||
_logger.LogError(exception, "ClassHelper 联动:上报异常({Reason})", reason);
|
||
SetState(false, $"上报异常:{exception.Message}", 0, 0, null);
|
||
return false;
|
||
}
|
||
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("ClassHelper 联动:拉取待提醒失败({Message})", result.Message);
|
||
return;
|
||
}
|
||
|
||
foreach (var item in items)
|
||
{
|
||
_logger.LogInformation("ClassHelper 联动:取到待弹出提醒「{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("ClassHelper 联动:已确认提醒 {Id}", id);
|
||
}
|
||
else
|
||
{
|
||
// 回执失败无妨:服务端会按 24 小时有效期补发,最坏是重复弹一次
|
||
_logger.LogWarning("ClassHelper 联动:确认提醒失败 {Message}", result.Message);
|
||
}
|
||
}
|
||
|
||
/// <summary>把 ClassHelper上排好的课表写回 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(
|
||
"ClassHelper 联动:跳过课表镜像({Message})。" +
|
||
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
|
||
result.Message);
|
||
return;
|
||
}
|
||
|
||
// 内容没变就直接返回:上报是"启动 + 每个课程事件 + 每 N 秒"触发的,原先每次都要
|
||
// 重建全部课表对象并整体落盘 —— 无谓的网络/磁盘 I/O,还会把档案页刷得不停重绘。
|
||
var signature = MirrorSignature(plan);
|
||
if (signature == _lastMirrorSignature)
|
||
{
|
||
_logger.LogDebug("ClassHelper 联动:课表内容与上次一致,跳过镜像");
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
// **必须在 UI 线程上改写档案**:ClassIsland 的档案是 ObservableDictionary /
|
||
// ObservableCollection,落盘是"属性变化即整体序列化 + FileStream 就地截断"。
|
||
// 而本方法是从线程池线程继续执行的(ReportAsync 里 ConfigureAwait(false) 之后,
|
||
// 定时器回调本就在线程池上),在线程池上改集合会与 UI 线程的保存/绑定并发:
|
||
// 轻则 InvalidOperationException(Collection was modified),重则两次写入互相截断,
|
||
// **把整个档案写成半截 JSON**(老师的课表/科目全丢,只能靠 .bak 兜底)。
|
||
var outcome = await Dispatcher.UIThread.InvokeAsync(() =>
|
||
{
|
||
var applied = ClassPlanWriter.Apply(profile, plan);
|
||
if (!applied.Applied)
|
||
{
|
||
return applied;
|
||
}
|
||
|
||
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
|
||
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
|
||
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
|
||
return applied;
|
||
});
|
||
|
||
if (!outcome.Applied)
|
||
{
|
||
_logger.LogInformation("ClassHelper 联动:课表镜像未生效({Reason})",
|
||
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
||
return;
|
||
}
|
||
|
||
_lastMirrorSignature = signature;
|
||
_logger.LogInformation("ClassHelper 联动:已把班级课表写入 ClassIsland({Plans})",
|
||
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
|
||
foreach (var warning in outcome.Warnings)
|
||
{
|
||
_logger.LogWarning("ClassHelper 联动镜像提示:{Warning}", warning);
|
||
}
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
_logger.LogError(exception, "ClassHelper 联动:课表镜像失败");
|
||
}
|
||
}
|
||
|
||
/// <summary>镜像载荷的内容指纹(只用于"内容没变就跳过",不参与任何业务判断)</summary>
|
||
private static string MirrorSignature(ClassPlanMirrorDto plan)
|
||
{
|
||
var builder = new StringBuilder();
|
||
foreach (var entry in plan.Entries)
|
||
{
|
||
builder.Append(entry.WeekDay).Append('|').Append(entry.WeekCountDiv).Append('|')
|
||
.Append(entry.WeekCountDivTotal).Append('|').Append(entry.Subject).Append('|')
|
||
.Append(entry.StartTime).Append('|').Append(entry.EndTime).Append(';');
|
||
}
|
||
foreach (var layout in plan.TimeLayouts)
|
||
{
|
||
builder.Append('#').Append(layout.Id).Append(':');
|
||
foreach (var item in layout.Layouts)
|
||
{
|
||
builder.Append(item.StartTime).Append('-').Append(item.EndTime).Append('@')
|
||
.Append(item.TimeType).Append(',');
|
||
}
|
||
}
|
||
return builder.ToString();
|
||
}
|
||
|
||
/* ---------------------------------------------------------------- 内部实现 */
|
||
|
||
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||
private const string PluginVersion = "1.3.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, "ClassHelper 联动:读取课程状态失败,本次只上报心跳");
|
||
return new StateDto { ClientTime = DateTimeOffset.Now.ToString("o") };
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 选一份"最有代表性"的时间表上报给 ClassHelper:**上课点最多的那一份**。
|
||
///
|
||
/// 为什么不按"当前课表"选:镜像会给每个(星期 + 单双周)建一份时间表,
|
||
/// 于是"今天"那一份可能只有一两节课 —— 按当前课表选就会把只有一节的时间表报上去,
|
||
/// 服务端那边的节次时间表就退化了。取上课点最多的那份,得到的是完整作息。
|
||
/// 并列时优先"每周"型(单双周的两份内容一致,每周型更通用)。
|
||
/// </summary>
|
||
private static ClassIsland.Shared.Models.Profile.TimeLayout? ResolvePrimaryTimeLayout(
|
||
ClassIsland.Shared.Models.Profile.Profile profile)
|
||
{
|
||
if (profile.TimeLayouts.Count == 0) return null;
|
||
|
||
var best = profile.TimeLayouts
|
||
.Select(pair => (pair.Value, ClassPoints: pair.Value.Layouts.Count(item => item.TimeType == 0)))
|
||
.Where(item => item.ClassPoints > 0)
|
||
.OrderByDescending(item => item.ClassPoints)
|
||
.ToList();
|
||
if (best.Count > 0) return best[0].Value;
|
||
|
||
// 兜底:档案里被激活的时间表,最后才是任意一个
|
||
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, "ClassHelper 联动:读取档案失败");
|
||
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();
|
||
}
|
||
} |