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,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"),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user