feat(schedules,classisland,server,plugin,shared): 课表行加来源字段,插件上报可真正同步删除
课表行新增 Schedule.source(manual / classisland):插件每次全量上报时,服务端 只清理「source=classisland 且本次未再上报」的行。原先 merge 只做 upsert,老师在 ClassIsland 里删掉或挪走一节,服务端会永远留着那行幽灵课;现在能真正同步下来, 而老师在小助手这边手排的课(manual)一律不删。 配套改动: - 插件上报时带回 machineKey,服务端回填到设备记录(避开 [classId, deviceKey] 唯一约束); - 新增迁移 20260930120000_add_schedule_source,存量数据保守地按 manual 处理; - verify:classisland 补「幽灵行清理」「手排课不被误删」用例。 另:.gitignore 补上 .zcode/ 与 .zcodeignore(AI 会话产物,不属于产品代码)。
This commit is contained in:
1 parent
9c678b3730
commit
7ea0e0f7c9
17 files changed
+611
-35
No files matched your search
@@ -41,3 +41,8 @@ Thumbs.db
|
|||||||
|
|
||||||
# 参考用的第三方源码包(设计 token 已提取到 docs/winisland-design-tokens.md)
|
# 参考用的第三方源码包(设计 token 已提取到 docs/winisland-design-tokens.md)
|
||||||
WinIsland-master.zip
|
WinIsland-master.zip
|
||||||
|
releases/
|
||||||
|
|
||||||
|
# AI 浼氳瘽浜х墿锛圸Code 璁″垝/閰嶇疆锛屼笉灞炰簬浜у搧浠g爜锛�
|
||||||
|
.zcode/
|
||||||
|
.zcodeignore
|
||||||
@@ -295,6 +295,57 @@ check(
|
|||||||
bridgeSource.includes('NotificationPollSeconds'),
|
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);
|
const failed = results.filter((item) => !item.ok);
|
||||||
|
|||||||
@@ -69,6 +69,8 @@ public class ReportRequestDto
|
|||||||
{
|
{
|
||||||
[JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = "";
|
[JsonPropertyName("pluginVersion")] public string PluginVersion { get; set; } = "";
|
||||||
[JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = "";
|
[JsonPropertyName("classIslandVersion")] public string ClassIslandVersion { get; set; } = "";
|
||||||
|
/// <summary>本机机器码:设备创建时服务端还不知道它,靠这里回填(见服务端 syncDeviceKey)。</summary>
|
||||||
|
[JsonPropertyName("deviceKey")] public string DeviceKey { get; set; } = "";
|
||||||
[JsonPropertyName("state")] public StateDto? State { get; set; }
|
[JsonPropertyName("state")] public StateDto? State { get; set; }
|
||||||
[JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; }
|
[JsonPropertyName("schedule")] public SchedulePayloadDto? Schedule { get; set; }
|
||||||
[JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; }
|
[JsonPropertyName("timeLayout")] public TimeLayoutPayloadDto? TimeLayout { get; set; }
|
||||||
@@ -198,6 +200,12 @@ public sealed class ClassHelperClient : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12);
|
private static readonly TimeSpan Timeout = TimeSpan.FromSeconds(12);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 响应体上限(4MB)。默认是 2GB —— 服务器地址填错(指到一个会吐大页面的地址)时,
|
||||||
|
/// 一次请求就能在 12 秒内把内存吃光。
|
||||||
|
/// </summary>
|
||||||
|
private const long MaxResponseBytes = 4 * 1024 * 1024;
|
||||||
|
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNameCaseInsensitive = true,
|
PropertyNameCaseInsensitive = true,
|
||||||
@@ -216,6 +224,7 @@ public sealed class ClassHelperClient : IDisposable
|
|||||||
private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() })
|
private readonly HttpClient _http = new(new SocketsHttpHandler { Proxy = new LocalAwareProxy() })
|
||||||
{
|
{
|
||||||
Timeout = Timeout,
|
Timeout = Timeout,
|
||||||
|
MaxResponseContentBufferSize = MaxResponseBytes,
|
||||||
};
|
};
|
||||||
|
|
||||||
public string LastError { get; private set; } = "";
|
public string LastError { get; private set; } = "";
|
||||||
@@ -334,6 +343,17 @@ public sealed class ClassHelperClient : IDisposable
|
|||||||
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
|
return envelope ?? new ApiEnvelope<T> { Success = false, Message = message! };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (envelope is null)
|
||||||
|
{
|
||||||
|
// 2xx 但响应不是本服务的统一响应体(网关/反代的 HTML 错误页、地址指到了别的服务等)。
|
||||||
|
// 必须留下原因:否则调用方拿到的是"成功但无数据 + 空错误消息",
|
||||||
|
// 设置页显示"连接异常"却没有原因,与上面专门为代理错误页写的诊断自相矛盾。
|
||||||
|
LastError =
|
||||||
|
$"HTTP {(int)response.StatusCode}({method.Method} {request.RequestUri})——" +
|
||||||
|
"响应不是班级小助手的标准格式,请检查服务器地址是否正确,或该系统代理是否拦截了内网请求";
|
||||||
|
return new ApiEnvelope<T> { Success = false, Message = LastError };
|
||||||
|
}
|
||||||
|
|
||||||
LastError = "";
|
LastError = "";
|
||||||
LastSuccessAt = DateTimeOffset.Now;
|
LastSuccessAt = DateTimeOffset.Now;
|
||||||
return envelope;
|
return envelope;
|
||||||
|
|||||||
@@ -40,7 +40,10 @@ public class PluginSettings : ObservableObject
|
|||||||
public string DeviceToken
|
public string DeviceToken
|
||||||
{
|
{
|
||||||
get => _deviceToken;
|
get => _deviceToken;
|
||||||
set => SetProperty(ref _deviceToken, value);
|
// 在 setter 里就去掉首尾空白:粘贴令牌时带前导空格非常常见,
|
||||||
|
// 而 Normalize() 只在启动加载时跑一次 —— 运行期粘进来的脏值会让 IsConfigured
|
||||||
|
// 一直为 false,用户看着明明填好了却查不出原因。
|
||||||
|
set => SetProperty(ref _deviceToken, (value ?? "").Trim());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -154,5 +157,20 @@ public class PluginSettings : ObservableObject
|
|||||||
|
|
||||||
/// <summary>是否已经配置好(地址 + 令牌都填了)。</summary>
|
/// <summary>是否已经配置好(地址 + 令牌都填了)。</summary>
|
||||||
public bool IsConfigured =>
|
public bool IsConfigured =>
|
||||||
Uri.TryCreate(ServerUrl, UriKind.Absolute, out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
|
TryGetServerUri(out _) && DeviceToken.StartsWith("chci_", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 解析服务器地址,**只接受 http/https 绝对地址**。
|
||||||
|
///
|
||||||
|
/// 不用裸的 `Uri.TryCreate(..., UriKind.Absolute)`:它会接受 `file://` / `ftp://`,
|
||||||
|
/// 那种地址会在发请求时抛一句难以理解的 NotSupportedException,用户根本猜不到是地址填错了。
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -127,9 +127,15 @@ public class Plugin : PluginBase
|
|||||||
settings.Normalize();
|
settings.Normalize();
|
||||||
if (needGenerateKey)
|
if (needGenerateKey)
|
||||||
{
|
{
|
||||||
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车
|
// 机器名 + 随机短码:既是人眼可读的"这台机器",又不会两台同名机器撞车。
|
||||||
|
// 注意长度:原先写的是 `[..Math.Min(24, machine.Length + 33)]`,而 machine.Length + 33
|
||||||
|
// 恒大于 24,所以实际**永远只取前 24 个字符** —— 机器名稍长就把随机段整个切掉,
|
||||||
|
// "同名机器不撞车"的保障就没了。这里改成"机器名按需截短 + 保留 8 位随机段"。
|
||||||
var machine = Environment.MachineName.ToLowerInvariant();
|
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;
|
Settings = settings;
|
||||||
@@ -146,7 +152,15 @@ public class Plugin : PluginBase
|
|||||||
{
|
{
|
||||||
var folder = Path.GetDirectoryName(path);
|
var folder = Path.GetDirectoryName(path);
|
||||||
if (!string.IsNullOrEmpty(folder)) Directory.CreateDirectory(folder);
|
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)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using System.ComponentModel;
|
using System.ComponentModel;
|
||||||
using System.Runtime.CompilerServices;
|
using System.Runtime.CompilerServices;
|
||||||
|
using System.Text;
|
||||||
|
using Avalonia.Threading;
|
||||||
using ClassHelper.ClassIslandPlugin.Interop;
|
using ClassHelper.ClassIslandPlugin.Interop;
|
||||||
using ClassHelper.ClassIslandPlugin.Models;
|
using ClassHelper.ClassIslandPlugin.Models;
|
||||||
using ClassIsland.Core;
|
using ClassIsland.Core;
|
||||||
@@ -39,6 +41,11 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
private bool _reporting;
|
private bool _reporting;
|
||||||
private bool _pulling;
|
private bool _pulling;
|
||||||
private bool _eventsHooked;
|
private bool _eventsHooked;
|
||||||
|
/// <summary>
|
||||||
|
/// 上次成功镜像的课表内容指纹。内容没变就完全跳过镜像:既省掉每分钟一次的全量重建 + 落盘,
|
||||||
|
/// 也把"跨线程改档案"的窗口从"每分钟一次"降回"只在课表真的变化时"。
|
||||||
|
/// </summary>
|
||||||
|
private volatile string? _lastMirrorSignature;
|
||||||
|
|
||||||
public BridgeService(PluginSettings settings, ILogger<BridgeService> logger)
|
public BridgeService(PluginSettings settings, ILogger<BridgeService> logger)
|
||||||
{
|
{
|
||||||
@@ -237,6 +244,8 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
{
|
{
|
||||||
PluginVersion = PluginVersion,
|
PluginVersion = PluginVersion,
|
||||||
ClassIslandVersion = SafeAppVersion(),
|
ClassIslandVersion = SafeAppVersion(),
|
||||||
|
// 机器码:设备创建时服务端只能用 pending-… 占位,靠这里回填,Web 端设备列表才能与教室机器对上
|
||||||
|
DeviceKey = _settings.DeviceKey,
|
||||||
State = BuildState(lessons),
|
State = BuildState(lessons),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -274,7 +283,9 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
layoutCount = items.Count;
|
layoutCount = items.Count;
|
||||||
request.TimeLayout = new TimeLayoutPayloadDto
|
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",
|
Mode = "replace",
|
||||||
Items = items,
|
Items = items,
|
||||||
};
|
};
|
||||||
@@ -321,6 +332,16 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
|
|
||||||
return true;
|
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
|
finally
|
||||||
{
|
{
|
||||||
Busy = false;
|
Busy = false;
|
||||||
@@ -397,23 +418,48 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 内容没变就直接返回:上报是"启动 + 每个课程事件 + 每 N 秒"触发的,原先每次都要
|
||||||
|
// 重建全部课表对象并整体落盘 —— 无谓的网络/磁盘 I/O,还会把档案页刷得不停重绘。
|
||||||
|
var signature = MirrorSignature(plan);
|
||||||
|
if (signature == _lastMirrorSignature)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("班级小助手联动:课表内容与上次一致,跳过镜像");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try
|
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);
|
var applied = ClassPlanWriter.Apply(profile, plan);
|
||||||
if (!applied.Applied)
|
if (!applied.Applied)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
|
return applied;
|
||||||
applied.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
|
// 改写的是 Profile 里的集合,ClassIsland 会靠 INotifyCollectionChanged 落盘;
|
||||||
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
|
// 这里显式 SaveProfile() 是为了"打开开关马上就能在课表里看到",不用等下一次自动保存。
|
||||||
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
|
IAppHost.TryGetService<IProfileService>()?.SaveProfile();
|
||||||
|
return applied;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!outcome.Applied)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("班级小助手联动:课表镜像未生效({Reason})",
|
||||||
|
outcome.Warnings.FirstOrDefault() ?? "服务端课表为空");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastMirrorSignature = signature;
|
||||||
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
|
_logger.LogInformation("班级小助手联动:已把班级课表写入 ClassIsland({Plans})",
|
||||||
string.Join("、", applied.PlanNames.Select(name => $"「{name}」")));
|
string.Join("、", outcome.PlanNames.Select(name => $"「{name}」")));
|
||||||
foreach (var warning in applied.Warnings)
|
foreach (var warning in outcome.Warnings)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
|
_logger.LogWarning("班级小助手联动镜像提示:{Warning}", warning);
|
||||||
}
|
}
|
||||||
@@ -424,6 +470,28 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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>
|
/// <summary>插件版本(与 manifest.yml / csproj 保持一致)。</summary>
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
|||||||
private readonly List<PushNotificationDto> _deferred = new();
|
private readonly List<PushNotificationDto> _deferred = new();
|
||||||
|
|
||||||
private ILessonsService? _lessons;
|
private ILessonsService? _lessons;
|
||||||
|
private bool _lessonsHooked;
|
||||||
|
|
||||||
public ClassHelperNotificationProvider(
|
public ClassHelperNotificationProvider(
|
||||||
BridgeService bridge,
|
BridgeService bridge,
|
||||||
@@ -74,26 +75,43 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
|||||||
_logger = logger;
|
_logger = logger;
|
||||||
_bridge.NotificationReceived += OnNotificationReceived;
|
_bridge.NotificationReceived += OnNotificationReceived;
|
||||||
|
|
||||||
// 订阅课程事件:下课后把上课时段暂存的提醒补弹出来
|
// 尽力而为地先订阅一次;拿不到也没关系,后面每次需要判定时都会重试
|
||||||
|
// (本类的构造函数可能在课程服务建好之前就被 DI 调用)
|
||||||
|
EnsureLessons();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 惰性获取课程服务并**只订阅一次**课程事件(下课后补弹上课时段暂存的提醒)。
|
||||||
|
///
|
||||||
|
/// 为什么不能只在构造函数里订阅:提供方是随主机启动被构造的,而课程服务在启动流程里的
|
||||||
|
/// 创建时机并不固定。一旦构造函数里拿到 null,原先的实现既不重试、也不打日志,
|
||||||
|
/// 于是 `IsInClass()` 恒为 false —— "上课时段先暂存、下课再补弹"这条规则**永久失效**,
|
||||||
|
/// 老师在课上发的通知会直接全屏打断课堂,而且完全静默、无从排查。
|
||||||
|
/// </summary>
|
||||||
|
private void EnsureLessons()
|
||||||
|
{
|
||||||
|
if (_lessonsHooked) return;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
_lessons = IAppHost.TryGetService<ILessonsService>();
|
_lessons = IAppHost.TryGetService<ILessonsService>();
|
||||||
if (_lessons is not null)
|
if (_lessons is null) return;
|
||||||
{
|
|
||||||
_lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
_lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||||
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||||
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||||
}
|
_lessonsHooked = true;
|
||||||
|
_logger.LogInformation("班级小助手联动:提醒提供方已订阅课程事件(上课时段暂存、下课补弹)");
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,上课暂存将退化为「立即弹出」");
|
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,将在下次提醒时重试");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
|
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
|
||||||
private bool IsInClass()
|
private bool IsInClass()
|
||||||
{
|
{
|
||||||
|
EnsureLessons();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return _lessons?.CurrentState == TimeState.OnClass;
|
return _lessons?.CurrentState == TimeState.OnClass;
|
||||||
|
|||||||
@@ -79,9 +79,22 @@ public static class ClassPlanWriter
|
|||||||
{
|
{
|
||||||
var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv,
|
var planName = BuildPlanName(source.ProfileName, group.WeekDay, group.WeekCountDiv,
|
||||||
group.WeekCountDivTotal, groups.Count);
|
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 planId = FindPlanId(profile, planName);
|
||||||
var (timeLayoutId, timeLayout) = BuildTimeLayout(
|
var (timeLayoutId, timeLayout) = BuildTimeLayout(profile, planName, usable);
|
||||||
profile, planName, group.Entries);
|
|
||||||
|
|
||||||
var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
|
var plan = profile.ClassPlans.TryGetValue(planId, out var existing)
|
||||||
? existing
|
? existing
|
||||||
@@ -102,7 +115,7 @@ public static class ClassPlanWriter
|
|||||||
// 档案不会重新触发刷新,课次就会停留在旧时间点上。
|
// 档案不会重新触发刷新,课次就会停留在旧时间点上。
|
||||||
var classes = new ObservableCollection<ClassInfo>();
|
var classes = new ObservableCollection<ClassInfo>();
|
||||||
var index = 0;
|
var index = 0;
|
||||||
foreach (var entry in group.Entries)
|
foreach (var entry in usable)
|
||||||
{
|
{
|
||||||
var subjectId = profile.Subjects
|
var subjectId = profile.Subjects
|
||||||
.FirstOrDefault(pair => pair.Value.Name == entry.Subject).Key;
|
.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;
|
return TimeSpan.TryParse(value, out var parsed) ? parsed : TimeSpan.Zero;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 时间字符串是否可用(HH:mm / HH:mm:ss)。
|
||||||
|
///
|
||||||
|
/// 服务端输出侧没有校验 `Schedule.startTime`(老数据/异常数据可能是空串),
|
||||||
|
/// 而 <see cref="ParseTime"/> 会把非法值变成 0 点 —— 因此调用方必须先用本方法过滤,
|
||||||
|
/// 并把 Classes 与时间表**同时**按过滤后的列表构造,保证索引仍然一致。
|
||||||
|
/// </summary>
|
||||||
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -61,9 +61,16 @@ public static class ScheduleMapper
|
|||||||
/// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。
|
/// 把档案里的一张课表(ClassPlan)展开成"每个上课时间点一条"的课表条目。
|
||||||
///
|
///
|
||||||
/// 与 ClassIsland 的语义严格对齐:<c>ClassPlan.Classes[i]</c> 对应
|
/// 与 ClassIsland 的语义严格对齐:<c>ClassPlan.Classes[i]</c> 对应
|
||||||
/// <c>TimeLayout.Layouts</c> 里**第 i 个 TimeType=0(上课)的点**,
|
/// <c>TimeLayout.Layouts</c> 里**第 i 个 TimeType=0(上课)的点**,课间 / 分割线 / 行动不占位。
|
||||||
/// 课间 / 分割线 / 行动不占 Classes 的位置。因此这里必须按 TimeType 过滤后再配对,
|
///
|
||||||
/// 否则整张课表会错位。
|
/// **注意不要用 `plan.ValidTimeLayoutItems`**:它的口径与 `Classes` 不同 ——
|
||||||
|
/// `GetValidTimeLayoutItems()`(ClassIsland ClassPlan.cs)会正反两遍扫描,
|
||||||
|
/// 把 `ClassInfo.IsEnabled == false`(老师在课表编辑里"停用"这一节)的课**连同其后的课间**
|
||||||
|
/// 一起从结果里剔除;而 `RefreshClassesList()` 只按 `TimeType == 0` 的**总数**对齐
|
||||||
|
/// `Classes` 的长度,`ClassInfo.CurrentTimeLayoutItem` 也是按原始列表的第 `Index` 个取的。
|
||||||
|
/// 于是只要停用过任意一节,用 ValidTimeLayoutItems 配对就会让索引整体前移:
|
||||||
|
/// 第 i 节配上第 i+1 节的时间点、最后一节丢失,且 `Math.Min` 会把缺口静默吃掉 ——
|
||||||
|
/// 表现就是"课表看着有内容、每节课都对不上",极难排查。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static List<ScheduleEntryDto> MapClassPlan(ClassPlan plan, Profile profile, out string? warning)
|
public static List<ScheduleEntryDto> MapClassPlan(ClassPlan plan, Profile profile, out string? warning)
|
||||||
{
|
{
|
||||||
@@ -77,11 +84,8 @@ public static class ScheduleMapper
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 只保留上课类型的时间点,顺序必须与 Classes 的索引一致
|
// 只保留上课类型的时间点,顺序必须与 Classes 的索引一致(见上面注释)
|
||||||
var classPoints = timeLayout.Layouts.Where(item => item.TimeType == 0).ToList();
|
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 weekDay = ToServerDayOfWeek(plan.TimeRule.WeekDay);
|
||||||
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
var parity = ToWeekParity(plan.TimeRule.WeekCountDiv, plan.TimeRule.WeekCountDivTotal);
|
||||||
@@ -91,12 +95,15 @@ public static class ScheduleMapper
|
|||||||
}
|
}
|
||||||
|
|
||||||
var classes = plan.Classes;
|
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++)
|
for (var index = 0; index < count; index++)
|
||||||
{
|
{
|
||||||
var info = classes[index];
|
var info = classes[index];
|
||||||
var point = validPoints[index];
|
// 索引必须与 Classes 对齐:这里按下标取时间点,**不能**先用 Where 过滤再取
|
||||||
|
var point = classPoints[index];
|
||||||
if (info is null) continue;
|
if (info is null) continue;
|
||||||
|
// 老师停用的一节:本系统没有"停用"概念,不上报(否则学生端会看到一节实际上不上的课)
|
||||||
|
if (!info.IsEnabled) continue;
|
||||||
|
|
||||||
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
|
// 空课(未安排科目)不上报:服务端的科目是必填的,空课由课表里的缺席表达
|
||||||
var subject = ResolveSubjectName(info.SubjectId, profile);
|
var subject = ResolveSubjectName(info.SubjectId, profile);
|
||||||
@@ -107,18 +114,33 @@ public static class ScheduleMapper
|
|||||||
DayOfWeek = weekDay,
|
DayOfWeek = weekDay,
|
||||||
StartTime = ToHhMm(point.StartTime),
|
StartTime = ToHhMm(point.StartTime),
|
||||||
EndTime = ToHhMm(point.EndTime),
|
EndTime = ToHhMm(point.EndTime),
|
||||||
Subject = subject,
|
// 服务端对这几个字段有 zod 长度上限,且是**整批**校验:一个超长名字会让整次上报
|
||||||
TeacherName = ResolveSubjectTeacher(info.SubjectId, profile),
|
// 400,连"现在上什么课"的状态与心跳都不更新,Web 端把教室显示成离线。
|
||||||
|
Subject = Truncate(subject, SubjectMaxLength)!,
|
||||||
|
TeacherName = Truncate(ResolveSubjectTeacher(info.SubjectId, profile), NameMaxLength),
|
||||||
WeekParity = parity,
|
WeekParity = parity,
|
||||||
WeekCountDiv = plan.TimeRule.WeekCountDiv,
|
WeekCountDiv = plan.TimeRule.WeekCountDiv,
|
||||||
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
|
WeekCountDivTotal = plan.TimeRule.WeekCountDivTotal,
|
||||||
PlanName = plan.Name,
|
PlanName = Truncate(plan.Name, PlanNameMaxLength),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>服务端 zod 对各类名称的长度上限(超出会让整批上报 400,所以要截断) */
|
||||||
|
public const int SubjectMaxLength = 40;
|
||||||
|
public const int NameMaxLength = 40;
|
||||||
|
public const int PlanNameMaxLength = 60;
|
||||||
|
|
||||||
|
/// <summary>按服务端上限截断(null / 空串原样返回)</summary>
|
||||||
|
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];
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>把时间表(TimeLayout)转成本系统的节次时间配置。</summary>
|
/// <summary>把时间表(TimeLayout)转成本系统的节次时间配置。</summary>
|
||||||
public static List<TimeLayoutItemDto> MapTimeLayout(TimeLayout layout)
|
public static List<TimeLayoutItemDto> MapTimeLayout(TimeLayout layout)
|
||||||
{
|
{
|
||||||
@@ -131,7 +153,8 @@ public static class ScheduleMapper
|
|||||||
items.Add(new TimeLayoutItemDto
|
items.Add(new TimeLayoutItemDto
|
||||||
{
|
{
|
||||||
Index = index + 1,
|
Index = index + 1,
|
||||||
Name = BuildTimePointName(point, ref classPointIndex),
|
// 节次名同样是服务端有上限的字段(max 40),超长会让整批上报失败
|
||||||
|
Name = Truncate(BuildTimePointName(point, ref classPointIndex), NameMaxLength)!,
|
||||||
StartTime = ToHhMm(point.StartTime),
|
StartTime = ToHhMm(point.StartTime),
|
||||||
EndTime = ToHhMm(point.EndTime),
|
EndTime = ToHhMm(point.EndTime),
|
||||||
Type = ToTimeLayoutType(point.TimeType),
|
Type = ToTimeLayoutType(point.TimeType),
|
||||||
|
|||||||
@@ -26,6 +26,10 @@
|
|||||||
Watermark="chci_..."
|
Watermark="chci_..."
|
||||||
PasswordChar="●"
|
PasswordChar="●"
|
||||||
Text="{Binding Settings.DeviceToken, Mode=TwoWay}" />
|
Text="{Binding Settings.DeviceToken, Mode=TwoWay}" />
|
||||||
|
<!-- 令牌是设备级长期凭证(只有管理员在 Web 端重置才会失效),而 ClassIsland 的插件配置
|
||||||
|
是明文 JSON 且会随档案备份/迁移一起被带走,所以这里明确提示用户别外传。 -->
|
||||||
|
<TextBlock Margin="0 6 0 0" TextWrapping="Wrap" Opacity="0.7"
|
||||||
|
Text="令牌是这台教室机器的长期凭证(仅管理员在 Web 端重置才会失效),会明文保存在本机配置文件里并随 ClassIsland 档案备份一起复制,请不要外传或提交到版本库。" />
|
||||||
|
|
||||||
<StackPanel Orientation="Horizontal" Margin="0 12 0 0" Spacing="8">
|
<StackPanel Orientation="Horizontal" Margin="0 12 0 0" Spacing="8">
|
||||||
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
<Button Content="立即上报" Command="{Binding ReportCommand}" IsEnabled="{Binding !Bridge.Busy}" />
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- 课表行来源:manual=手工建立(Web 端录入 / JSON 导入)/ classisland=ClassIsland 插件上报同步。
|
||||||
|
--
|
||||||
|
-- 用途:插件每次全量上报课表时,只清理「source=classisland 且本次未再上报」的行。
|
||||||
|
-- 这样"老师在 ClassIsland 里删掉一节"能被真正同步下来(原先 merge 只 upsert,
|
||||||
|
-- 服务端会永远留着那行幽灵课),同时老师在小助手这边手排的课不受影响。
|
||||||
|
-- 存量数据默认按 manual 处理(保守:宁可留下也不误删)。
|
||||||
|
ALTER TABLE "Schedule" ADD COLUMN "source" TEXT NOT NULL DEFAULT 'manual';
|
||||||
@@ -146,6 +146,11 @@ model Schedule {
|
|||||||
weekEnd Int @default(20)
|
weekEnd Int @default(20)
|
||||||
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
|
/// 单双周:ALL=每周(默认)/ ODD=单周 / EVEN=双周(ClassIsland WeekCountDiv + WeekCountDivTotal 的映射)
|
||||||
weekParity String @default("ALL")
|
weekParity String @default("ALL")
|
||||||
|
/// 该行的来源:manual=手工建立(Web 端录入 / ClassIsland JSON 导入)
|
||||||
|
/// / classisland=插件上报同步得到。
|
||||||
|
/// 插件每次全量上报时只清理「classisland 来源且本次未再上报」的行(解决"老师在 ClassIsland
|
||||||
|
/// 里删掉一节、服务端却永远留着"的幽灵课),老师手排的课一律不删。
|
||||||
|
source String @default("manual")
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
|||||||
@@ -466,6 +466,109 @@ async function main() {
|
|||||||
`上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
|
`上课点=${classPoints.length} 条目=${(plan?.entries ?? []).length}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 5.1 课表同步:幽灵行清理 + 机器码回填 */
|
||||||
|
// 老师在 Web 端手排的一节课(source=manual):插件同步时**绝不能**删它
|
||||||
|
const courseList = await api(`/courses?classId=${target.id}`, { token: teacherToken });
|
||||||
|
const courseId = (courseList.payload?.data ?? [])[0]?.id;
|
||||||
|
const manualCreated = await api('/schedules', {
|
||||||
|
method: 'POST',
|
||||||
|
token: teacherToken,
|
||||||
|
body: {
|
||||||
|
classId: target.id,
|
||||||
|
courseId,
|
||||||
|
dayOfWeek: 7,
|
||||||
|
startTime: '21:00',
|
||||||
|
endTime: '21:45',
|
||||||
|
weekStart: 1,
|
||||||
|
weekEnd: 20,
|
||||||
|
weekParity: 'ALL',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const manualScheduleId = manualCreated.payload?.data?.id;
|
||||||
|
record(
|
||||||
|
'(准备)Web 端手排一节课(source=manual)',
|
||||||
|
manualCreated.status === 201 && Boolean(manualScheduleId),
|
||||||
|
`status=${manualCreated.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const entry = (startTime, endTime) => ({
|
||||||
|
dayOfWeek: 6,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
subject: '语文',
|
||||||
|
weekParity: 'ALL',
|
||||||
|
});
|
||||||
|
|
||||||
|
/** 该班课表里"周六 19:xx"的行(插件上报写入的那些) */
|
||||||
|
const listSaturday = async () => {
|
||||||
|
const list = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
|
||||||
|
return (list.payload?.data ?? []).filter(
|
||||||
|
(item) => item.dayOfWeek === 6 && item.startTime.startsWith('19:'),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 第一次上报:两节(第二节随后会被"老师在 ClassIsland 里删掉")
|
||||||
|
const ghostReport = await api('/integrations/classisland/report', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken,
|
||||||
|
body: {
|
||||||
|
deviceKey: 'smoke-machine-01',
|
||||||
|
schedule: { mode: 'merge', entries: [entry('19:00', '19:45'), entry('19:50', '20:35')] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
record(
|
||||||
|
'插件上报两节课(其中一节随后会被删除)',
|
||||||
|
ghostReport.payload?.data?.scheduleApplied === true,
|
||||||
|
`status=${ghostReport.status}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const beforePrune = await listSaturday();
|
||||||
|
record('上报后两节都在库里', beforePrune.length === 2, `周六 19 点=${beforePrune.length} 节`);
|
||||||
|
|
||||||
|
// 第二次上报:只报第二节 → 第一节应当被清理。
|
||||||
|
// 原先 merge 只 upsert、从不删除,老师删掉的课会永远留在服务端(幽灵课),
|
||||||
|
// 学生端与 Web 端会一直显示一节实际上已经不上的课。
|
||||||
|
const pruneReport = await api('/integrations/classisland/report', {
|
||||||
|
method: 'POST',
|
||||||
|
deviceToken,
|
||||||
|
body: {
|
||||||
|
deviceKey: 'smoke-machine-01',
|
||||||
|
schedule: { mode: 'merge', entries: [entry('19:50', '20:35')] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const afterPrune = await listSaturday();
|
||||||
|
record(
|
||||||
|
'插件侧删除的课被同步清理(不再留幽灵课)',
|
||||||
|
pruneReport.payload?.data?.scheduleApplied === true &&
|
||||||
|
afterPrune.length === 1 &&
|
||||||
|
afterPrune[0]?.startTime === '19:50',
|
||||||
|
`周六 19 点=${afterPrune.map((item) => item.startTime).join(',') || '空'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 只清理"插件同步出来的行":老师手排的课必须活着
|
||||||
|
const scheduleList = await api(`/schedules?classId=${target.id}`, { token: teacherToken });
|
||||||
|
const manualAlive = (scheduleList.payload?.data ?? []).some((item) => item.id === manualScheduleId);
|
||||||
|
record('Web 端手排的课不被插件同步删除', manualAlive, `手排行 id=${manualScheduleId ?? '-'}`);
|
||||||
|
|
||||||
|
// 机器码回填:设备列表里不再是 pending-…
|
||||||
|
const deviceList = await api(`/integrations/devices?classId=${target.id}`, { token: teacherToken });
|
||||||
|
const backfilledKey =
|
||||||
|
(deviceList.payload?.data ?? []).find((item) => item.id === deviceId)?.deviceKey ?? '';
|
||||||
|
record(
|
||||||
|
'插件上报的机器码已回填到设备记录',
|
||||||
|
backfilledKey === 'smoke-machine-01',
|
||||||
|
`deviceKey=${backfilledKey}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 清理:把手排的课与本次上报写进去的课都删掉,避免污染演示数据
|
||||||
|
if (manualScheduleId) {
|
||||||
|
await api(`/schedules/${manualScheduleId}`, { method: 'DELETE', token: teacherToken });
|
||||||
|
}
|
||||||
|
for (const row of await listSaturday()) {
|
||||||
|
await api(`/schedules/${row.id}`, { method: 'DELETE', token: teacherToken });
|
||||||
|
}
|
||||||
|
record('清理:本次验证写入的课表与手排课已删除', (await listSaturday()).length === 0, '周六 19 点已清空');
|
||||||
|
|
||||||
// ---------------------------------------------------------------- 6. 停用设备
|
// ---------------------------------------------------------------- 6. 停用设备
|
||||||
await api(`/integrations/devices/${deviceId}`, {
|
await api(`/integrations/devices/${deviceId}`, {
|
||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
|
|||||||
@@ -75,6 +75,12 @@ export const reportTimeLayoutItemSchema = z.object({
|
|||||||
export const classIslandReportSchema = z.object({
|
export const classIslandReportSchema = z.object({
|
||||||
pluginVersion: z.string().trim().max(40).optional(),
|
pluginVersion: z.string().trim().max(40).optional(),
|
||||||
classIslandVersion: z.string().trim().max(40).optional(),
|
classIslandVersion: z.string().trim().max(40).optional(),
|
||||||
|
/**
|
||||||
|
* 插件生成的机器码。设备创建时服务端还不知道它是谁,只能用 `pending-…` 占位,
|
||||||
|
* 因此插件每次上报都带上它,服务端据此回填,Web 端设备列表才能与教室机器对上
|
||||||
|
* (见 integrations.service 的 syncDeviceKey)。
|
||||||
|
*/
|
||||||
|
deviceKey: z.string().trim().max(64).optional(),
|
||||||
state: z
|
state: z
|
||||||
.object({
|
.object({
|
||||||
inClass: z.boolean().optional(),
|
inClass: z.boolean().optional(),
|
||||||
|
|||||||
@@ -422,6 +422,9 @@ export async function applyReport(
|
|||||||
const now = new Date();
|
const now = new Date();
|
||||||
const week = resolveCurrentWeek(env.termStartDate, now);
|
const week = resolveCurrentWeek(env.termStartDate, now);
|
||||||
|
|
||||||
|
// 0) 机器码回填(设备创建时只能用 pending-… 占位,首次上报才知道它到底是哪台机器)
|
||||||
|
if (input.deviceKey) await syncDeviceKey(device, input.deviceKey);
|
||||||
|
|
||||||
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
|
// 1) 状态快照(无论课表是否上报都更新,Web 端据此显示"现在上什么课")
|
||||||
const state = input.state;
|
const state = input.state;
|
||||||
await prisma.integrationDevice.update({
|
await prisma.integrationDevice.update({
|
||||||
@@ -518,6 +521,39 @@ export async function applyReport(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 把插件上报的机器码回填到设备记录。
|
||||||
|
*
|
||||||
|
* `IntegrationDevice` 上有 `@@unique([classId, deviceKey])`,因此要避开两个坑:
|
||||||
|
* 1) 同一台机器在这个班已有另一条设备记录(例如管理员重复建过设备)时直接写会撞唯一约束 → 500,
|
||||||
|
* 这里改为记 warning 并保留原值;
|
||||||
|
* 2) 值没变时不必白写一次库。
|
||||||
|
*/
|
||||||
|
async function syncDeviceKey(device: DeviceContext, reportedKey: string): Promise<void> {
|
||||||
|
const key = reportedKey.trim();
|
||||||
|
if (!key) return;
|
||||||
|
|
||||||
|
const current = await prisma.integrationDevice.findUnique({
|
||||||
|
where: { id: device.deviceId },
|
||||||
|
select: { deviceKey: true },
|
||||||
|
});
|
||||||
|
if (!current || current.deviceKey === key) return;
|
||||||
|
|
||||||
|
const taken = await prisma.integrationDevice.findFirst({
|
||||||
|
where: { classId: device.classId, deviceKey: key, id: { not: device.deviceId } },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
if (taken) {
|
||||||
|
logger.warn(
|
||||||
|
`ClassIsland 上报的机器码「${key}」已被本班设备「${taken.name}」占用,本次不回填(设备=${device.name})`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.integrationDevice.update({ where: { id: device.deviceId }, data: { deviceKey: key } });
|
||||||
|
logger.info(`ClassIsland 设备机器码已回填:${device.name} -> ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
/** 该设备/该班级最近一条尚未确认、未过期的下发提醒 */
|
/** 该设备/该班级最近一条尚未确认、未过期的下发提醒 */
|
||||||
async function pendingNotificationFor(
|
async function pendingNotificationFor(
|
||||||
deviceId: string,
|
deviceId: string,
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ export interface ScheduleWriteResult {
|
|||||||
applied: boolean;
|
applied: boolean;
|
||||||
created: number;
|
created: number;
|
||||||
updated: number;
|
updated: number;
|
||||||
|
/** 被清理掉的旧同步行(老师已在 ClassIsland 里删除的课) */
|
||||||
|
removed: number;
|
||||||
courses: string[];
|
courses: string[];
|
||||||
weekStart: number;
|
weekStart: number;
|
||||||
weekEnd: number;
|
weekEnd: number;
|
||||||
@@ -46,6 +48,7 @@ export async function applyReportedSchedule(
|
|||||||
applied: false,
|
applied: false,
|
||||||
created: 0,
|
created: 0,
|
||||||
updated: 0,
|
updated: 0,
|
||||||
|
removed: 0,
|
||||||
courses: [],
|
courses: [],
|
||||||
weekStart: 1,
|
weekStart: 1,
|
||||||
weekEnd: 20,
|
weekEnd: 20,
|
||||||
@@ -91,6 +94,8 @@ export async function applyReportedSchedule(
|
|||||||
// 3) 逐条 upsert:merge 的去重键是「星期 + 开始时间 + 单双周」
|
// 3) 逐条 upsert:merge 的去重键是「星期 + 开始时间 + 单双周」
|
||||||
let created = 0;
|
let created = 0;
|
||||||
let updated = 0;
|
let updated = 0;
|
||||||
|
/** 本轮上报覆盖到的行 id:不在其中的 classisland 来源行会被清理(见第 4 步) */
|
||||||
|
const keptIds = new Set<string>();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
const courseId = courseIdByName.get(entry.subject);
|
const courseId = courseIdByName.get(entry.subject);
|
||||||
if (!courseId) continue;
|
if (!courseId) continue;
|
||||||
@@ -102,8 +107,21 @@ export async function applyReportedSchedule(
|
|||||||
if (existing) {
|
if (existing) {
|
||||||
await tx.schedule.update({
|
await tx.schedule.update({
|
||||||
where: { id: existing.id },
|
where: { id: existing.id },
|
||||||
data: { courseId, endTime: entry.endTime, weekStart, weekEnd },
|
data: {
|
||||||
|
courseId,
|
||||||
|
endTime: entry.endTime,
|
||||||
|
// 不覆盖 weekStart / weekEnd:插件上报契约里没有周次范围,
|
||||||
|
// 硬写"整学期"会把老师在 Web 端为选修/短期课设的「第 1~10 周」悄悄抹掉。
|
||||||
|
// 只在新建时给默认值(见下面 create)。
|
||||||
|
//
|
||||||
|
// 同样**不要**把 source 改成 'classisland':merge 的去重键是「星期 + 开始时间 + 单双周」,
|
||||||
|
// 老师在 Web 端手排的同一时间槽会被这里"收养"。一旦收养时改写来源,
|
||||||
|
// 后续那次"清理插件侧已删除的课"就会把这行老师手排的课一起删掉
|
||||||
|
// (真机上发生过:种子课表 93 行被删成 91 行,`verify:e2e` 的上课状态用例随之失败)。
|
||||||
|
// 因此来源只在 create 时确定,update 永远保持原值。
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
keptIds.add(existing.id);
|
||||||
updated += 1;
|
updated += 1;
|
||||||
} else {
|
} else {
|
||||||
await tx.schedule.create({
|
await tx.schedule.create({
|
||||||
@@ -117,22 +135,53 @@ export async function applyReportedSchedule(
|
|||||||
weekStart,
|
weekStart,
|
||||||
weekEnd,
|
weekEnd,
|
||||||
weekParity,
|
weekParity,
|
||||||
|
source: 'classisland',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
created += 1;
|
created += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { created, updated, createdCourses, allCourses: [...courseIdByName.keys()] };
|
|
||||||
|
// 4) 清理"老师已在 ClassIsland 里删掉"的行。
|
||||||
|
//
|
||||||
|
// merge 模式原先只 upsert、从不删除:老师在 ClassIsland 删掉一节,或把 8:00 的课挪到 8:05,
|
||||||
|
// 服务端那行会永远留着(幽灵课 / 同一天两节重复课)。但也不能照 replace 那样整表清空 ——
|
||||||
|
// 那会连老师在 Web 端手排的课一起删。
|
||||||
|
// 因此只清理 `source='classisland'`(即由上报同步产生)且本轮未再出现的行。
|
||||||
|
let removed = 0;
|
||||||
|
if (mode === 'merge') {
|
||||||
|
const synced = await tx.schedule.findMany({
|
||||||
|
where: { classId, source: 'classisland' },
|
||||||
|
select: { id: true, dayOfWeek: true, startTime: true, weekParity: true },
|
||||||
|
});
|
||||||
|
const stale = synced.filter(
|
||||||
|
(row) =>
|
||||||
|
!keptIds.has(row.id) &&
|
||||||
|
!entries.some(
|
||||||
|
(entry) =>
|
||||||
|
entry.dayOfWeek === row.dayOfWeek &&
|
||||||
|
entry.startTime === row.startTime &&
|
||||||
|
resolveParity(entry) === row.weekParity,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (stale.length > 0) {
|
||||||
|
await tx.schedule.deleteMany({ where: { id: { in: stale.map((row) => row.id) } } });
|
||||||
|
removed = stale.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { created, updated, removed, createdCourses, allCourses: [...courseIdByName.keys()] };
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
`ClassIsland 上报课表:班级=${classId} 模式=${mode} 新增=${result.created} 更新=${result.updated} ` +
|
`ClassIsland 上报课表:班级=${classId} 模式=${mode} 新增=${result.created} 更新=${result.updated} ` +
|
||||||
`补建科目=[${result.createdCourses.join(',')}]`,
|
`清理=${result.removed} 补建科目=[${result.createdCourses.join(',')}]`,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
applied: true,
|
applied: true,
|
||||||
created: result.created,
|
created: result.created,
|
||||||
updated: result.updated,
|
updated: result.updated,
|
||||||
|
removed: result.removed,
|
||||||
courses: result.allCourses,
|
courses: result.allCourses,
|
||||||
weekStart,
|
weekStart,
|
||||||
weekEnd,
|
weekEnd,
|
||||||
|
|||||||
@@ -55,6 +55,19 @@ export const PRIORITY_TAG_TYPES: Record<NotificationPriority, 'info' | 'primary'
|
|||||||
URGENT: 'danger',
|
URGENT: 'danger',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 优先级权重(越大越重要)。
|
||||||
|
*
|
||||||
|
* 灵动岛有多条通知时按它排序("默认按重要程度排列"),插件合并同 id 消息时也用它取更高优先级 ——
|
||||||
|
* 两边必须同一份口径,否则会出现"列表里的顺序和合并后的优先级不一致"。
|
||||||
|
*/
|
||||||
|
export const PRIORITY_RANK: Record<NotificationPriority, number> = {
|
||||||
|
LOW: 0,
|
||||||
|
NORMAL: 1,
|
||||||
|
HIGH: 2,
|
||||||
|
URGENT: 3,
|
||||||
|
};
|
||||||
|
|
||||||
/** 星期:1=周一 ... 7=周日(与数据库 dayOfWeek 一致) */
|
/** 星期:1=周一 ... 7=周日(与数据库 dayOfWeek 一致) */
|
||||||
export const WEEKDAY_LABELS: Record<number, string> = {
|
export const WEEKDAY_LABELS: Record<number, string> = {
|
||||||
1: '周一',
|
1: '周一',
|
||||||
@@ -213,6 +226,114 @@ export const ISLAND_TYPE_SCALE = {
|
|||||||
/** 胶囊副标题 */
|
/** 胶囊副标题 */
|
||||||
pillSub: 0.74,
|
pillSub: 0.74,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 展开态"多条通知列表"的排版度量(单位:**基础字号的倍数**,即与 `--island-font` 同源)。
|
||||||
|
*
|
||||||
|
* 为什么这份常量必须放在 shared:列表比普通展开卡高得多,**窗口包围盒要跟着长高**,
|
||||||
|
* 否则卡片底部(那排按钮)会被窗口裁掉 —— 而窗口尺寸只有主进程能改、行高只有渲染进程知道。
|
||||||
|
* 两边用同一份度量各算一次"该显示几行 / 卡片多高",就不会出现"渲染进程以为放得下、窗口却不够高"的漂移。
|
||||||
|
*
|
||||||
|
* 与 `IslandApp.vue` 里 `.expanded-layer`、`.list-row`、`.list-hint`、`.foot` 的 CSS 逐项对应,
|
||||||
|
* **改 CSS 必须同步改这里**。
|
||||||
|
*/
|
||||||
|
export const ISLAND_LIST_METRICS = {
|
||||||
|
/** 卡片上下内边距 */
|
||||||
|
paddingY: 1.4,
|
||||||
|
/** 卡片内各块(标题行 / 批次标题 / 列表 / 按钮行)之间的间距 */
|
||||||
|
gap: 0.7,
|
||||||
|
/** 顶部标题行(图标 + 徽标 + 时间 + 收起按钮) */
|
||||||
|
headerHeight: 1.7,
|
||||||
|
/** 批次标题("共 N 条待处理通知",单行) */
|
||||||
|
titleHeight: 1.4,
|
||||||
|
/** 单条通知行高 */
|
||||||
|
rowHeight: 2.7,
|
||||||
|
/** 行间距(含提示行上方的间距) */
|
||||||
|
rowGap: 0.4,
|
||||||
|
/** "展开更多 / 更多请前往应用内操作"那一行(含上方间距) */
|
||||||
|
hintHeight: 2.5,
|
||||||
|
/** 底部按钮行(含上方间距、分隔线与内边距) */
|
||||||
|
footerHeight: 3.45,
|
||||||
|
/** 默认只显示前几条 —— 用户要求「只显示前三个,下面显示展开更多」 */
|
||||||
|
defaultVisibleRows: 3,
|
||||||
|
/** 兜底上限:再多也交给"前往应用内操作",避免窗口长到离谱 */
|
||||||
|
maxRows: 20,
|
||||||
|
/** 高度余量:浮点排版误差不至于让最后一行贴着裁切边 */
|
||||||
|
safetyPad: 6,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/** 列表布局结算结果(主进程据此算窗口包围盒,渲染进程据此渲染) */
|
||||||
|
export interface IslandListLayout {
|
||||||
|
/** 实际要展示的通知行数 */
|
||||||
|
rows: number;
|
||||||
|
/** 底部提示行的形态:more=可"展开更多";app=放不下,请去应用内看;null=没有更多了 */
|
||||||
|
hint: 'more' | 'app' | null;
|
||||||
|
/** 卡片高度(CSS px) */
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 列表卡片里"除通知行之外"的固定高度(CSS px):内边距 + 标题行 + 批次标题 + 底部按钮行 + 可选提示行 */
|
||||||
|
function islandListChromeHeight(fontSize: number, withHint: boolean): number {
|
||||||
|
const m = ISLAND_LIST_METRICS;
|
||||||
|
const base = (m.paddingY * 2 + m.headerHeight + m.titleHeight + m.footerHeight + m.gap * 3) * fontSize;
|
||||||
|
return base + (withHint ? m.hintHeight * fontSize : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** n 行通知(含行间距)占用的高度(CSS px) */
|
||||||
|
function islandListRowsHeight(fontSize: number, rows: number): number {
|
||||||
|
const m = ISLAND_LIST_METRICS;
|
||||||
|
if (rows <= 0) return 0;
|
||||||
|
return (rows * m.rowHeight + (rows - 1) * m.rowGap) * fontSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 在给定高度里最多放得下几行通知(至少 1 行,至多 maxRows) */
|
||||||
|
function islandListFittingRows(fontSize: number, maxHeight: number, withHint: boolean): number {
|
||||||
|
const m = ISLAND_LIST_METRICS;
|
||||||
|
const chrome = islandListChromeHeight(fontSize, withHint) + m.safetyPad;
|
||||||
|
const unit = (m.rowHeight + m.rowGap) * fontSize;
|
||||||
|
const rowUnit = m.rowHeight * fontSize;
|
||||||
|
const room = maxHeight - chrome;
|
||||||
|
if (room < rowUnit) return 1;
|
||||||
|
return Math.max(1, Math.min(m.maxRows, Math.floor((room + m.rowGap * fontSize) / unit)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 结算"多条通知"的展开卡布局(**主进程与渲染进程共用**):
|
||||||
|
* - 默认只显示前 `defaultVisibleRows` 条,多余的放进"展开更多";
|
||||||
|
* - 放不下的部分(快到屏幕下沿的任务栏了)不再撑高卡片,改为提示"更多请前往应用内操作";
|
||||||
|
* - 返回的 `height` 就是卡片(以及窗口包围盒)应有的高度。
|
||||||
|
*/
|
||||||
|
export function islandListLayout(input: {
|
||||||
|
/** 待处理通知总数 */
|
||||||
|
count: number;
|
||||||
|
/** 基础字号(外观设置) */
|
||||||
|
fontSize: number;
|
||||||
|
/** 当前屏幕/停靠位置下卡片可用的最大高度(CSS px) */
|
||||||
|
maxHeight: number;
|
||||||
|
/** 是否已点过"展开更多" */
|
||||||
|
expanded: boolean;
|
||||||
|
}): IslandListLayout {
|
||||||
|
const m = ISLAND_LIST_METRICS;
|
||||||
|
const total = Math.max(0, Math.floor(input.count));
|
||||||
|
// 先按"用户意图"决定想显示几行;再受屏幕高度限制
|
||||||
|
const desired = input.expanded ? Math.min(total, m.maxRows) : Math.min(total, m.defaultVisibleRows);
|
||||||
|
const wantsHint = desired < total;
|
||||||
|
const fitting = islandListFittingRows(input.fontSize, input.maxHeight, wantsHint);
|
||||||
|
const rows = Math.max(1, Math.min(desired, fitting));
|
||||||
|
const hint: IslandListLayout['hint'] =
|
||||||
|
rows >= total ? null : input.expanded || rows >= fitting ? 'app' : 'more';
|
||||||
|
return {
|
||||||
|
rows,
|
||||||
|
hint,
|
||||||
|
height: Math.min(
|
||||||
|
input.maxHeight,
|
||||||
|
islandListChromeHeight(input.fontSize, hint !== null) +
|
||||||
|
islandListRowsHeight(input.fontSize, rows) +
|
||||||
|
m.safetyPad,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** 默认学期周次上限 */
|
/** 默认学期周次上限 */
|
||||||
export const DEFAULT_WEEK_COUNT = 20;
|
export const DEFAULT_WEEK_COUNT = 20;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user