From 7ea0e0f7c96046bfa12238d37a938b88361c7606 Mon Sep 17 00:00:00 2001 From: LoveChengke Date: Fri, 2 Oct 2026 00:24:58 +0800 Subject: [PATCH] =?UTF-8?q?feat(schedules,classisland,server,plugin,shared?= =?UTF-8?q?):=20=E8=AF=BE=E8=A1=A8=E8=A1=8C=E5=8A=A0=E6=9D=A5=E6=BA=90?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=EF=BC=8C=E6=8F=92=E4=BB=B6=E4=B8=8A=E6=8A=A5?= =?UTF-8?q?=E5=8F=AF=E7=9C=9F=E6=AD=A3=E5=90=8C=E6=AD=A5=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 课表行新增 Schedule.source(manual / classisland):插件每次全量上报时,服务端 只清理「source=classisland 且本次未再上报」的行。原先 merge 只做 upsert,老师在 ClassIsland 里删掉或挪走一节,服务端会永远留着那行幽灵课;现在能真正同步下来, 而老师在小助手这边手排的课(manual)一律不删。 配套改动: - 插件上报时带回 machineKey,服务端回填到设备记录(避开 [classId, deviceKey] 唯一约束); - 新增迁移 20260930120000_add_schedule_source,存量数据保守地按 manual 处理; - verify:classisland 补「幽灵行清理」「手排课不被误删」用例。 另:.gitignore 补上 .zcode/ 与 .zcodeignore(AI 会话产物,不属于产品代码)。 --- .gitignore | 5 + .../classisland-plugin/scripts/verify.mjs | 51 ++++++++ .../src/Interop/ClassHelperClient.cs | 20 +++ .../src/Models/PluginSettings.cs | 22 +++- packages/classisland-plugin/src/Plugin.cs | 20 ++- .../src/Services/BridgeService.cs | 88 +++++++++++-- .../ClassHelperNotificationProvider.cs | 34 +++-- .../src/Services/ClassPlanWriter.cs | 34 ++++- .../src/Services/ScheduleMapper.cs | 49 +++++-- .../src/Views/BridgeSettingsPage.axaml | 4 + .../migration.sql | 7 + packages/server/prisma/schema.prisma | 9 +- .../server/scripts/verify-classisland.mjs | 103 +++++++++++++++ .../integrations/integrations.schemas.ts | 6 + .../integrations/integrations.service.ts | 36 ++++++ .../integrations/write-back.service.ts | 55 +++++++- packages/shared/src/constants.ts | 121 ++++++++++++++++++ 17 files changed, 620 insertions(+), 44 deletions(-) create mode 100644 packages/server/prisma/migrations/20260930120000_add_schedule_source/migration.sql diff --git a/.gitignore b/.gitignore index 5373576..6954ea5 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,8 @@ Thumbs.db # ²ο¼ÓõĵÚÈý·½ԴÂë°ü£¨Éè¼Æ token ÒÑÌáȡµ½ docs/winisland-design-tokens.md£© WinIsland-master.zip +releases/ + +# AI 会话产物(ZCode 计划/配置,不属于产品代码) +.zcode/ +.zcodeignore diff --git a/packages/classisland-plugin/scripts/verify.mjs b/packages/classisland-plugin/scripts/verify.mjs index e954376..a2cd289 100644 --- a/packages/classisland-plugin/scripts/verify.mjs +++ b/packages/classisland-plugin/scripts/verify.mjs @@ -295,6 +295,57 @@ check( bridgeSource.includes('NotificationPollSeconds'), ); +/* ------------------------------------------------------------ 8. 回归护栏:本次修复过的坑 */ + +check( + '课表配对用原始 TimeType==0 列表,**不用** ValidTimeLayoutItems(后者会剔除被停用的节次,导致整份课表错位)', + // 只查代码形态(`.Count`),因为源码注释里特意点名了这个属性来说明"为什么不能用它" + mapperSource.includes('classPoints[index]') && !mapperSource.includes('.ValidTimeLayoutItems.Count'), +); +check( + '上报字段按服务端 zod 上限截断(一个超长名字会让整批上报 400,连状态与心跳一起丢掉)', + mapperSource.includes('Truncate(') && + mapperSource.includes('SubjectMaxLength') && + mapperSource.includes('PlanNameMaxLength'), +); +check( + '课表镜像在 UI 线程改写档案(线程池上改 Observable 集合会与落盘并发,可能写坏档案)', + bridgeSource.includes('Dispatcher.UIThread.InvokeAsync') && bridgeSource.includes('SaveProfile()'), +); +check( + '镜像内容未变化时跳过(否则每次上报都重建课表并全量落盘)', + bridgeSource.includes('_lastMirrorSignature') && bridgeSource.includes('MirrorSignature('), +); +check( + '上报异常有 catch(fire-and-forget 的异常会被静默吞掉)', + bridgeSource.includes('catch (Exception exception)') && bridgeSource.includes('上报异常'), +); +check( + '镜像前过滤时间非法的条目(避免档案里出现 00:00 的课次,且必须与 Classes 同步过滤)', + read('src/Services/ClassPlanWriter.cs').includes('IsValidTime'), +); +check( + '上报体带上 deviceKey,且服务端 schema 也声明了该字段(回填机器码的契约两端一致)', + clientSource.includes('"deviceKey"') && serverSchemas.includes('deviceKey:'), +); +check( + '2xx 但不是本服务响应体时给出原因(而不是"成功但无数据 + 空消息"),并限制响应体大小', + clientSource.includes('响应不是班级小助手的标准格式') && + clientSource.includes('MaxResponseContentBufferSize'), +); +check( + '提醒提供方惰性获取课程服务并重试(拿不到服务时"上课暂存"会永久失效且静默)', + providerSource.includes('EnsureLessons') && providerSource.includes('_lessonsHooked'), +); +check( + '设置落盘先写临时文件再原子替换(逐字符写盘 + 截断式写入会留下坏 JSON)', + pluginSource.includes('File.Move(temp, path, overwrite: true)'), +); +check( + '服务器地址只接受 http/https(裸 Uri.TryCreate 会放过 file:// / ftp://)', + settingsSource.includes('TryGetServerUri') && settingsSource.includes('UriSchemeHttp'), +); + /* ------------------------------------------------------------ 结果 */ const failed = results.filter((item) => !item.ok); diff --git a/packages/classisland-plugin/src/Interop/ClassHelperClient.cs b/packages/classisland-plugin/src/Interop/ClassHelperClient.cs index 54c391d..9e85bbb 100644 --- a/packages/classisland-plugin/src/Interop/ClassHelperClient.cs +++ b/packages/classisland-plugin/src/Interop/ClassHelperClient.cs @@ -69,6 +69,8 @@ public class ReportRequestDto { [JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = ""; [JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = ""; + /// 本机机器码:设备创建时服务端还不知道它,靠这里回填(见服务端 syncDeviceKey)。 + [JsonPropertyName("deviceKey")] public string DeviceKey { get; set; } = ""; [JsonPropertyName("state")] public StateDto? State { get; set; } [JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; } [JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; } @@ -198,6 +200,12 @@ public sealed class ClassHelperClient : IDisposable /// private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12); + /// + /// 响应体上限(4MB)。默认是 2GB —— 服务器地址填错(指到一个会吐大页面的地址)时, + /// 一次请求就能在 12 秒内把内存吃光。 + /// + private const long MaxResponseBytes = 4 * 1024 * 1024; + private static readonly JsonSerializerOptions JsonOptions = new() { PropertyNameCaseInsensitive = true, @@ -216,6 +224,7 @@ public sealed class ClassHelperClient : IDisposable private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() }) { Timeout = Timeout, + MaxResponseContentBufferSize = MaxResponseBytes, }; public string LastError { get; private set; } = ""; @@ -334,6 +343,17 @@ public sealed class ClassHelperClient : IDisposable return envelope ?? new ApiEnvelope { Success = false, Message = message! }; } + if (envelope is null) + { + // 2xx 但响应不是本服务的统一响应体(网关/反代的 HTML 错误页、地址指到了别的服务等)。 + // 必须留下原因:否则调用方拿到的是"成功但无数据 + 空错误消息", + // 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。 + LastError = + $"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" + + "响应不是班级小助手的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求"; + return new ApiEnvelope { Success = false, Message = LastError }; + } + LastError = ""; LastSuccessAt = DateTimeOffset.Now; return envelope; diff --git a/packages/classisland-plugin/src/Models/PluginSettings.cs b/packages/classisland-plugin/src/Models/PluginSettings.cs index 34dfb7d..37c5b8f 100644 --- a/packages/classisland-plugin/src/Models/PluginSettings.cs +++ b/packages/classisland-plugin/src/Models/PluginSettings.cs @@ -40,7 +40,10 @@ public class PluginSettings : ObservableObject public string DeviceToken { get => _deviceToken; - set => SetProperty(ref _deviceToken, value); + // 在 setter 里就去掉首尾空白:粘贴令牌时带前导空格非常常见, + // 而 Normalize() 只在启动加载时跑一次 —— 运行期粘进来的脏值会让 IsConfigured + // 一直为 false,用户看着明明填好了却查不出原因。 + set => SetProperty(ref _deviceToken, (value ?? "").Trim()); } /// @@ -154,5 +157,20 @@ public class PluginSettings : ObservableObject /// 是否已经配置好(地址 + 令牌都填了)。 public bool IsConfigured => - Uri.TryCreate(ServerUrl, UriKind.Absolute, out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal); + TryGetServerUri(out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal); + + /// + /// 解析服务器地址,**只接受 http/https 绝对地址**。 + /// + /// 不用裸的 `Uri.TryCreate(..., UriKind.Absolute)`:它会接受 `file://` / `ftp://`, + /// 那种地址会在发请求时抛一句难以理解的 NotSupportedException,用户根本猜不到是地址填错了。 + /// + public bool TryGetServerUri(out Uri uri) + { + uri = null!; + if (!Uri.TryCreate((ServerUrl ?? "").Trim(), UriKind.Absolute, out var parsed)) return false; + if (parsed.Scheme != Uri.UriSchemeHttp && parsed.Scheme != Uri.UriSchemeHttps) return false; + uri = parsed; + return true; + } } diff --git a/packages/classisland-plugin/src/Plugin.cs b/packages/classisland-plugin/src/Plugin.cs index 6726bb6..000416c 100644 --- a/packages/classisland-plugin/src/Plugin.cs +++ b/packages/classisland-plugin/src/Plugin.cs @@ -127,9 +127,15 @@ public class Plugin : PluginBase settings.Normalize(); if (needGenerateKey) { - // 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车 + // 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车。 + // 注意长度:原先写的是 `[..Math.Min(24, machine.Length + 33)]`,而 machine.Length + 33 + // 恒大于 24,所以实际**永远只取前 24 个字符** —— 机器名稍长就把随机段整个切掉, + // "同名机器不撞车"的保障就没了。这里改成"机器名按需截短 + 保留 8 位随机段"。 var machine = Environment.MachineName.ToLowerInvariant(); - settings.DeviceKey = $"{machine}-{Guid.NewGuid():N}"[..Math.Min(24, machine.Length + 33)]; + var suffix = Guid.NewGuid().ToString("N")[..8]; + var maxMachineLength = Math.Max(4, 24 - suffix.Length - 1); + if (machine.Length > maxMachineLength) machine = machine[..maxMachineLength]; + settings.DeviceKey = $"{machine}-{suffix}"; } Settings = settings; @@ -146,7 +152,15 @@ public class Plugin : PluginBase { var folder = Path.GetDirectoryName(path); if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder); - File.WriteAllText(path, JsonSerializer.Serialize(settings, JsonOptions)); + // 先写临时文件再原子替换。 + // + // 设置页的地址/令牌输入框是 TwoWay 绑定(逐字符提交),直接 `File.WriteAllText` + // 会就地截断原文件;一旦此刻进程被杀或断电,留下的是半截 JSON,而 LoadSettings 的 + // catch 会**静默回退到默认设置** —— 用户看到的是"令牌和地址莫名其妙没了", + // 而且原文件已被覆盖,无从恢复。 + var temp = path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(settings, JsonOptions)); + File.Move(temp, path, overwrite: true); } catch (Exception exception) { diff --git a/packages/classisland-plugin/src/Services/BridgeService.cs b/packages/classisland-plugin/src/Services/BridgeService.cs index 8bf97e7..90d6a71 100644 --- a/packages/classisland-plugin/src/Services/BridgeService.cs +++ b/packages/classisland-plugin/src/Services/BridgeService.cs @@ -1,5 +1,7 @@ using System.ComponentModel; using System.Runtime.CompilerServices; +using System.Text; +using Avalonia.Threading; using ClassHelper.ClassIslandPlugin.Interop; using ClassHelper.ClassIslandPlugin.Models; using ClassIsland.Core; @@ -39,6 +41,11 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable private bool _reporting; private bool _pulling; private bool _eventsHooked; + /// + /// 上次成功镜像的课表内容指纹。内容没变就完全跳过镜像:既省掉每分钟一次的全量重建 + 落盘, + /// 也把"跨线程改档案"的窗口从"每分钟一次"降回"只在课表真的变化时"。 + /// + private volatile string? _lastMirrorSignature; public BridgeService(PluginSettings settings, ILogger logger) { @@ -237,6 +244,8 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable { PluginVersion = PluginVersion, ClassIslandVersion = SafeAppVersion(), + // 机器码:设备创建时服务端只能用 pending-… 占位,靠这里回填,Web 端设备列表才能与教室机器对上 + DeviceKey = _settings.DeviceKey, State = BuildState(lessons), }; @@ -274,7 +283,9 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable layoutCount = items.Count; request.TimeLayout = new TimeLayoutPayloadDto { - Name = string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name, + Name = ScheduleMapper.Truncate( + string.IsNullOrWhiteSpace(layout.Name) ? "ClassIsland 时间表" : layout.Name, + ScheduleMapper.PlanNameMaxLength)!, Mode = "replace", Items = items, }; @@ -321,6 +332,16 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable return true; } + catch (Exception exception) + { + // ReportAsync 是通过 `_ = ReportAsync(reason)` 这种 fire-and-forget 方式发起的 + // (事件回调与定时器都是),没有这个 catch,中途抛出的异常(例如跨线程改档案时的 + // "Collection was modified")会被 unobserved task 静默吞掉:日志里一条都没有, + // 而 LastReportMessage 还停在上一次的成功结果,用户以为一切正常。 + _logger.LogError(exception, "班级小助手联动:上报异常({Reason})", reason); + SetState(false, $"上报异常:{exception.Message}", 0, 0, null); + return false; + } finally { Busy = false; @@ -397,23 +418,48 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable return; } + // 内容没变就直接返回:上报是"启动 + 每个课程事件 + 每 N 秒"触发的,原先每次都要 + // 重建全部课表对象并整体落盘 —— 无谓的网络/磁盘 I/O,还会把档案页刷得不停重绘。 + var signature = MirrorSignature(plan); + if (signature == _lastMirrorSignature) + { + _logger.LogDebug("班级小助手联动:课表内容与上次一致,跳过镜像"); + return; + } + try { - var applied = ClassPlanWriter.Apply(profile, plan); - if (!applied.Applied) + // **必须在 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()?.SaveProfile(); + return applied; + }); + + if (!outcome.Applied) { _logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})", - applied.Warnings.FirstOrDefault() ?? "服务端课表为空"); + outcome.Warnings.FirstOrDefault() ?? "服务端课表为空"); return; } - // 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘; - // 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。 - IAppHost.TryGetService()?.SaveProfile(); - + _lastMirrorSignature = signature; _logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})", - string.Join("、", applied.PlanNames.Select(name => $"「{name}」"))); - foreach (var warning in applied.Warnings) + string.Join("、", outcome.PlanNames.Select(name => $"「{name}」"))); + foreach (var warning in outcome.Warnings) { _logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning); } @@ -424,6 +470,28 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable } } + /// 镜像载荷的内容指纹(只用于"内容没变就跳过",不参与任何业务判断) + 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(); + } + /* ---------------------------------------------------------------- 内部实现 */ /// 插件版本(与 manifest.yml / csproj 保持一致)。 diff --git a/packages/classisland-plugin/src/Services/ClassHelperNotificationProvider.cs b/packages/classisland-plugin/src/Services/ClassHelperNotificationProvider.cs index 0bfeb65..85a04f1 100644 --- a/packages/classisland-plugin/src/Services/ClassHelperNotificationProvider.cs +++ b/packages/classisland-plugin/src/Services/ClassHelperNotificationProvider.cs @@ -63,6 +63,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase private readonly List _deferred = new(); private ILessonsService? _lessons; + private bool _lessonsHooked; public ClassHelperNotificationProvider( BridgeService bridge, @@ -74,26 +75,43 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase _logger = logger; _bridge.NotificationReceived += OnNotificationReceived; - // 订阅课程事件:下课后把上课时段暂存的提醒补弹出来 + // 尽力而为地先订阅一次;拿不到也没关系,后面每次需要判定时都会重试 + // (本类的构造函数可能在课程服务建好之前就被 DI 调用) + EnsureLessons(); + } + + /// + /// 惰性获取课程服务并**只订阅一次**课程事件(下课后补弹上课时段暂存的提醒)。 + /// + /// 为什么不能只在构造函数里订阅:提供方是随主机启动被构造的,而课程服务在启动流程里的 + /// 创建时机并不固定。一旦构造函数里拿到 null,原先的实现既不重试、也不打日志, + /// 于是 `IsInClass()` 恒为 false —— "上课时段先暂存、下课再补弹"这条规则**永久失效**, + /// 老师在课上发的通知会直接全屏打断课堂,而且完全静默、无从排查。 + /// + private void EnsureLessons() + { + if (_lessonsHooked) return; try { _lessons = IAppHost.TryGetService(); - if (_lessons is not null) - { - _lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); - _lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); - _lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); - } + if (_lessons is null) return; + + _lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); + _lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); + _lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred); + _lessonsHooked = true; + _logger.LogInformation("班级小助手联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)"); } catch (Exception exception) { - _logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,上课暂存将退化为「立即弹出」"); + _logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,将在下次提醒时重试"); } } /// 当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢) private bool IsInClass() { + EnsureLessons(); try { return _lessons?.CurrentState == TimeState.OnClass; diff --git a/packages/classisland-plugin/src/Services/ClassPlanWriter.cs b/packages/classisland-plugin/src/Services/ClassPlanWriter.cs index a09759e..8d0f62e 100644 --- a/packages/classisland-plugin/src/Services/ClassPlanWriter.cs +++ b/packages/classisland-plugin/src/Services/ClassPlanWriter.cs @@ -79,9 +79,22 @@ public static class ClassPlanWriter { var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv, group.WeekCountDivTotal, groups.Count); + + // 时间非法的条目必须**在这里**就整批丢掉:`Classes` 与时间表里的上课点必须等长且同序, + // 只在一处跳过会让整份课表错位。原先 ParseTime 把无法解析的值悄悄变成 00:00, + // 于是档案里凭空出现 00:00-00:00 的课次(时间轴显示与当前节次判断都会乱),还没有任何日志。 + var usable = group.Entries + .Where(entry => IsValidTime(entry.StartTime) && IsValidTime(entry.EndTime)) + .ToList(); + if (usable.Count < group.Entries.Count) + { + result.Warnings.Add( + $"「{planName}」有 {group.Entries.Count - usable.Count} 节的时间无法解析(需要 HH:mm),已跳过"); + } + if (usable.Count == 0) continue; + var planId = FindPlanId(profile, planName); - var (timeLayoutId, timeLayout) = BuildTimeLayout( - profile, planName, group.Entries); + var (timeLayoutId, timeLayout) = BuildTimeLayout(profile, planName, usable); var plan = profile.ClassPlans.TryGetValue(planId, out var existing) ? existing @@ -102,7 +115,7 @@ public static class ClassPlanWriter // 档案不会重新触发刷新,课次就会停留在旧时间点上。 var classes = new ObservableCollection(); var index = 0; - foreach (var entry in group.Entries) + foreach (var entry in usable) { var subjectId = profile.Subjects .FirstOrDefault(pair => pair.Value.Name == entry.Subject).Key; @@ -251,4 +264,19 @@ public static class ClassPlanWriter { return TimeSpan.TryParse(value, out var parsed) ? parsed : TimeSpan.Zero; } + + /// + /// 时间字符串是否可用(HH:mm / HH:mm:ss)。 + /// + /// 服务端输出侧没有校验 `Schedule.startTime`(老数据/异常数据可能是空串), + /// 而 会把非法值变成 0 点 —— 因此调用方必须先用本方法过滤, + /// 并把 Classes 与时间表**同时**按过滤后的列表构造,保证索引仍然一致。 + /// + private static bool IsValidTime(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + return TimeSpan.TryParse(value, out var parsed) + && parsed >= TimeSpan.Zero + && parsed < TimeSpan.FromDays(1); + } } diff --git a/packages/classisland-plugin/src/Services/ScheduleMapper.cs b/packages/classisland-plugin/src/Services/ScheduleMapper.cs index 3282b6f..335c0bc 100644 --- a/packages/classisland-plugin/src/Services/ScheduleMapper.cs +++ b/packages/classisland-plugin/src/Services/ScheduleMapper.cs @@ -61,9 +61,16 @@ public static class ScheduleMapper /// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。 /// /// 与 ClassIsland 的语义严格对齐:ClassPlan.Classes[i] 对应 - /// TimeLayout.Layouts 里**第 i 个 TimeType=0(上课)的点**, - /// 课间 / 分割线 / 行动不占 Classes 的位置。因此这里必须按 TimeType 过滤后再配对, - /// 否则整张课表会错位。 + /// TimeLayout.Layouts 里**第 i 个 TimeType=0(上课)的点**,课间 / 分割线 / 行动不占位。 + /// + /// **注意不要用 `plan.ValidTimeLayoutItems`**:它的口径与 `Classes` 不同 —— + /// `GetValidTimeLayoutItems()`(ClassIsland ClassPlan.cs)会正反两遍扫描, + /// 把 `ClassInfo.IsEnabled == false`(老师在课表编辑里"停用"这一节)的课**连同其后的课间** + /// 一起从结果里剔除;而 `RefreshClassesList()` 只按 `TimeType == 0` 的**总数**对齐 + /// `Classes` 的长度,`ClassInfo.CurrentTimeLayoutItem` 也是按原始列表的第 `Index` 个取的。 + /// 于是只要停用过任意一节,用 ValidTimeLayoutItems 配对就会让索引整体前移: + /// 第 i 节配上第 i+1 节的时间点、最后一节丢失,且 `Math.Min` 会把缺口静默吃掉 —— + /// 表现就是"课表看着有内容、每节课都对不上",极难排查。 /// public static List MapClassPlan(ClassPlan plan, Profile profile, out string? warning) { @@ -77,11 +84,8 @@ public static class ScheduleMapper return entries; } - // 只保留上课类型的时间点,顺序必须与 Classes 的索引一致 + // 只保留上课类型的时间点,顺序必须与 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); @@ -91,12 +95,15 @@ public static class ScheduleMapper } var classes = plan.Classes; - var count = Math.Min(classes.Count, validPoints.Count); + var count = Math.Min(classes.Count, classPoints.Count); for (var index = 0; index < count; index++) { var info = classes[index]; - var point = validPoints[index]; + // 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取 + var point = classPoints[index]; if (info is null) continue; + // 老师停用的一节:本系统没有"停用"概念,不上报(否则学生端会看到一节实际上不上的课) + if (!info.IsEnabled) continue; // 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达 var subject = ResolveSubjectName(info.SubjectId, profile); @@ -107,18 +114,33 @@ public static class ScheduleMapper DayOfWeek = weekDay, StartTime = ToHhMm(point.StartTime), EndTime = ToHhMm(point.EndTime), - Subject = subject, - TeacherName = ResolveSubjectTeacher(info.SubjectId, profile), + // 服务端对这几个字段有 zod 长度上限,且是**整批**校验:一个超长名字会让整次上报 + // 400,连"现在上什么课"的状态与心跳都不更新,Web 端把教室显示成离线。 + Subject = Truncate(subject, SubjectMaxLength)!, + TeacherName = Truncate(ResolveSubjectTeacher(info.SubjectId, profile), NameMaxLength), WeekParity = parity, WeekCountDiv = plan.TimeRule.WeekCountDiv, WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal, - PlanName = plan.Name, + PlanName = Truncate(plan.Name, PlanNameMaxLength), }); } return entries; } + /// 服务端 zod 对各类名称的长度上限(超出会让整批上报 400,所以要截断) */ + public const int SubjectMaxLength = 40; + public const int NameMaxLength = 40; + public const int PlanNameMaxLength = 60; + + /// 按服务端上限截断(null / 空串原样返回) + public static string? Truncate(string? value, int max) + { + if (string.IsNullOrEmpty(value)) return value; + var trimmed = value.Trim(); + return trimmed.Length <= max ? trimmed : trimmed[..max]; + } + /// 把时间表(TimeLayout)转成本系统的节次时间配置。 public static List MapTimeLayout(TimeLayout layout) { @@ -131,7 +153,8 @@ public static class ScheduleMapper items.Add(new TimeLayoutItemDto { Index = index + 1, - Name = BuildTimePointName(point, ref classPointIndex), + // 节次名同样是服务端有上限的字段(max 40),超长会让整批上报失败 + Name = Truncate(BuildTimePointName(point, ref classPointIndex), NameMaxLength)!, StartTime = ToHhMm(point.StartTime), EndTime = ToHhMm(point.EndTime), Type = ToTimeLayoutType(point.TimeType), diff --git a/packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml b/packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml index 862f109..8103e67 100644 --- a/packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml +++ b/packages/classisland-plugin/src/Views/BridgeSettingsPage.axaml @@ -26,6 +26,10 @@ Watermark="chci_..." PasswordChar="●" Text="{Binding Settings.DeviceToken, Mode=TwoWay}" /> + +