feat(homeworks,island,classisland,desktop,web,server): 作业按天查看与教室机器录入 + 提醒上课时段暂存规则 + 灵动岛边距/跟随鼠标屏幕
- 作业:新增 Homework.assignDate(本地日期 YYYY-MM-DD;UTC 切片在晚上会把今天的作业算成明天),列表按天过滤与排序,新增 GET /homeworks?date= 与 GET /homeworks/days(日期高亮)供客户端与 Web 共用;客户端作业页改为"看作业 + 录作业"(不再显示完成状态),支持快捷短语(存本机配置,「设置 → 作业录入」可增删/恢复默认) - 教室机器录入:POST /homeworks 去掉 requireRole,权限下沉到 homeworks.service(staff 或本班班级账号);班级账号的 sub 是班级 id,写 createdBy 前换成该班班主任,避免撞 User 外键 - 提醒类型:ClassIslandPush.kind(notification / call)。通知类上课时段暂存、下课补弹,叫人/紧急立即弹;客户端灵动岛与 ClassIsland 插件共用同一套规则 - 灵动岛:新增 marginX / marginY(默认仍 8px,不动老外观)与「跟随鼠标屏幕」;文案统一成"灵动岛 / ClassIsland" - 设置页:新增「ClassIsland 联动」(联动状态 + 通知模式)与「作业录入」分区 - 校验:verify:e2e 增补 assignDate 默认当天 / ?date= 过滤 / days / 非法日期 422 / 班级账号录入 201 与跨班 403;verify:classisland 与插件静态契约增补上课时段暂存断言(57/57) - 文档:README 与 AGENTS.md 同步(作业按天、上课时段策略、两个方向的开关表、新增踩坑) 门禁:pnpm lint 0 error、pnpm typecheck 四包通过、verify:classisland-plugin 57/57 通过。 未跑:verify:e2e / verify:classisland(需后端启动)、verify:web / verify:desktop(需先构建产物)。
This commit is contained in:
1 parent
d0830f6830
commit
7536abfc95
33 files changed
+1236
-162
No files matched your search
@@ -42,6 +42,17 @@
|
||||
设置保存在 `<插件配置目录>/Settings.json`(随 ClassIsland 档案备份走,升级插件不丢)。
|
||||
改动即时生效 —— 改地址 / 令牌 / 间隔会立刻重建上报定时器,不需要重启 ClassIsland。
|
||||
|
||||
> **上课时段策略(与 ClassHelper 客户端同一套规则)**:
|
||||
> 上课时段里,通知类提醒会**先暂存、下课后自动补弹**;**紧急提醒**与**叫人**属于「主动通知」,立刻弹。
|
||||
> 暂存期间不回执,服务端会保留这条待提醒。
|
||||
|
||||
> **两个方向各自需要的开关**:
|
||||
>
|
||||
> - 服务端 → ClassIsland(镜像课表):插件「把班级课表镜像回 ClassIsland」**且** Web 端该设备「镜像课表」都打开;
|
||||
> - ClassIsland → 服务端(上报课表):插件「上报课表到班级小助手」打开;
|
||||
> 关着时是正常行为(日志会写「仅状态(本机「上报课表到班级小助手」开关已关闭)」),不是上报失败;
|
||||
> - ClassIsland 里一张课表都没有时不会上报课表(日志会写「ClassIsland 里没有**启用的课表**」)。
|
||||
|
||||
> **提醒到底会不会弹,取决于两个开关:**
|
||||
>
|
||||
> 1. 教室机器的 **ClassHelper 客户端**在「设置 → 通知显示位置」里选了「只在 ClassHelper 客户端弹」时,
|
||||
|
||||
@@ -273,6 +273,21 @@ check(
|
||||
'ClassIsland 的占位科目(Subject.Fallback)折算成"没有科目"',
|
||||
mapperSource.includes('Subject.Fallback'),
|
||||
);
|
||||
check(
|
||||
'上课时段暂存非主动通知(与客户端灵动岛同一套规则)',
|
||||
providerSource.includes('_deferred') &&
|
||||
providerSource.includes('IsImmediate') &&
|
||||
providerSource.includes('FlushDeferred') &&
|
||||
providerSource.includes('OnBreakingTime'),
|
||||
);
|
||||
check(
|
||||
'主动通知(紧急 / 叫人)上课时段也立即弹',
|
||||
providerSource.includes("\"call\"") && providerSource.includes('notification.Urgent'),
|
||||
);
|
||||
check(
|
||||
'「上报课表」开关关闭时会在日志里说明(避免被当成"上报失败")',
|
||||
bridgeSource.includes('上报课表到班级小助手') && bridgeSource.includes('开关已关闭'),
|
||||
);
|
||||
check(
|
||||
'提醒轮询独立于上报间隔(老师发完通知不必等一个上报周期)',
|
||||
settingsSource.includes('NotificationPollSeconds') &&
|
||||
|
||||
@@ -103,6 +103,9 @@ public class PushNotificationDto
|
||||
[JsonPropertyName("speechContent")] public string? SpeechContent { get; set; }
|
||||
[JsonPropertyName("createdAt")] public string CreatedAt { get; set; } = "";
|
||||
[JsonPropertyName("urgent")] public bool Urgent { get; set; }
|
||||
|
||||
/// <summary>提醒类型:notification(默认)/ call(叫人,算"主动通知",上课时段也立刻弹)</summary>
|
||||
[JsonPropertyName("kind")] public string? Kind { get; set; }
|
||||
[JsonPropertyName("classId")] public string ClassId { get; set; } = "";
|
||||
[JsonPropertyName("className")] public string? ClassName { get; set; }
|
||||
[JsonPropertyName("teacherName")] public string? TeacherName { get; set; }
|
||||
|
||||
@@ -242,14 +242,30 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
|
||||
var entryCount = 0;
|
||||
var layoutCount = 0;
|
||||
// 开关关着时也要留下原因:否则日志只有"上报成功:仅状态",
|
||||
// 用户会以为"服务端拿不到 ClassIsland 课表"是插件坏了。
|
||||
if (includeSchedule && !_settings.UploadSchedule)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:本机设置里「上报课表到班级小助手」是关闭的,本次只上报状态;" +
|
||||
"需要把教室课表同步到班级小助手请打开该开关");
|
||||
}
|
||||
|
||||
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;
|
||||
// 课表为空时不发(否则会把服务端已有课表"合并"成空),但**必须把原因写清楚**:
|
||||
// 日志里只出现"仅状态"时,用户会以为上报失败,实际是 ClassIsland 里还没有课表。
|
||||
if (entries.Count == 0)
|
||||
{
|
||||
request.Schedule = null;
|
||||
_logger.LogWarning(
|
||||
"班级小助手联动:ClassIsland 里没有**启用的课表**(0 条),本次未上报课表。" +
|
||||
"请在 ClassIsland 里排好课表,或在 Web 端打开「镜像课表」由班级小助手下发一份");
|
||||
}
|
||||
|
||||
var layout = ResolvePrimaryTimeLayout(profile);
|
||||
if (layout is not null)
|
||||
@@ -285,7 +301,9 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
|
||||
var detail = entryCount > 0
|
||||
? $"课表 {entryCount} 节 / 节次 {layoutCount} 条"
|
||||
: "仅状态";
|
||||
: _settings.UploadSchedule
|
||||
? "仅状态(ClassIsland 里还没有启用的课表)"
|
||||
: "仅状态(本机「上报课表到班级小助手」开关已关闭)";
|
||||
SetState(true, $"上报成功({reason}):{detail},服务端第 {data.Week} 周",
|
||||
entryCount, layoutCount, data.Week);
|
||||
|
||||
@@ -371,8 +389,11 @@ public sealed class BridgeService : INotifyPropertyChanged, IDisposable
|
||||
var (result, plan) = await _client.PullClassPlanAsync(_settings, CancellationToken.None).ConfigureAwait(false);
|
||||
if (!result.Ok || plan is null)
|
||||
{
|
||||
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,只记信息
|
||||
_logger.LogInformation("班级小助手联动:跳过课表镜像({Message})", result.Message);
|
||||
// 服务端在"镜像开关没开"时会返回 data=null —— 这不是错误,但要告诉用户去哪儿开
|
||||
_logger.LogInformation(
|
||||
"班级小助手联动:跳过课表镜像({Message})。" +
|
||||
"需要在 Web 端「ClassIsland 联动」页给这台设备打开「镜像课表」,然后点一次「立即上报」或等下一个上报周期",
|
||||
result.Message);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Avalonia.Threading;
|
||||
using ClassHelper.ClassIslandPlugin.Interop;
|
||||
using ClassHelper.ClassIslandPlugin.Models;
|
||||
using ClassIsland.Core.Abstractions.Services;
|
||||
using ClassIsland.Shared;
|
||||
using ClassIsland.Shared.Enums;
|
||||
using ClassIsland.Core.Abstractions.Services.NotificationProviders;
|
||||
using ClassIsland.Core.Attributes;
|
||||
using ClassIsland.Core.Models.Notification;
|
||||
@@ -48,6 +51,19 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
/// </summary>
|
||||
private readonly HashSet<string> _playing = new();
|
||||
|
||||
/// <summary>
|
||||
/// 上课时段被暂存的提醒(与客户端灵动岛同一套规则)。
|
||||
///
|
||||
/// 为什么要有它:老师在**上课时段**发通知,教室里正上着课,直接全屏弹会打断课堂。
|
||||
/// 客户端的灵动岛就是"上课先收着、下课再弹",ClassIsland 这侧必须一致,
|
||||
/// 否则学生在客户端看不到、却在 ClassIsland 上被打断。
|
||||
///
|
||||
/// 例外("主动通知"):紧急提醒与**叫人**,老师正在等学生,必须立刻弹。
|
||||
/// </summary>
|
||||
private readonly List<PushNotificationDto> _deferred = new();
|
||||
|
||||
private ILessonsService? _lessons;
|
||||
|
||||
public ClassHelperNotificationProvider(
|
||||
BridgeService bridge,
|
||||
PluginSettings settings,
|
||||
@@ -57,6 +73,54 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
_settings = settings;
|
||||
_logger = logger;
|
||||
_bridge.NotificationReceived += OnNotificationReceived;
|
||||
|
||||
// 订阅课程事件:下课后把上课时段暂存的提醒补弹出来
|
||||
try
|
||||
{
|
||||
_lessons = IAppHost.TryGetService<ILessonsService>();
|
||||
if (_lessons is not null)
|
||||
{
|
||||
_lessons.OnBreakingTime += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||
_lessons.OnAfterSchool += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||
_lessons.CurrentTimeStateChanged += (_, _) => Dispatcher.UIThread.Post(FlushDeferred);
|
||||
}
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogWarning(exception, "班级小助手联动:订阅课程事件失败,上课暂存将退化为「立即弹出」");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>当前是否处于上课时段(读不到课程服务时按"不在上课"处理,宁可弹也不静默丢)</summary>
|
||||
private bool IsInClass()
|
||||
{
|
||||
try
|
||||
{
|
||||
return _lessons?.CurrentState == TimeState.OnClass;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 这条提醒是否必须立刻弹("主动通知"):
|
||||
/// 紧急提醒,或叫人(老师正在等学生)。
|
||||
/// </summary>
|
||||
private static bool IsImmediate(PushNotificationDto notification) =>
|
||||
notification.Urgent || string.Equals(notification.Kind, "call", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>下课后补弹暂存的提醒(按进入顺序)</summary>
|
||||
private void FlushDeferred()
|
||||
{
|
||||
if (IsInClass()) return; // 还在上课(例如只是状态在 OnClass 内部变化)
|
||||
if (_deferred.Count == 0) return;
|
||||
|
||||
var pending = _deferred.ToList();
|
||||
_deferred.Clear();
|
||||
_logger.LogInformation("班级小助手联动:下课了,补弹 {Count} 条上课时段暂存的提醒", pending.Count);
|
||||
foreach (var item in pending) ShowOnUiThread(item);
|
||||
}
|
||||
|
||||
private void OnNotificationReceived(object? sender, PushNotificationDto notification)
|
||||
@@ -83,6 +147,14 @@ public sealed class ClassHelperNotificationProvider : NotificationProviderBase
|
||||
{
|
||||
try
|
||||
{
|
||||
// 上课时段:非"主动通知"先收着(与客户端灵动岛同一套规则),下课后补弹
|
||||
if (IsInClass() && !IsImmediate(notification))
|
||||
{
|
||||
_deferred.Add(notification);
|
||||
_logger.LogInformation("班级小助手联动:上课中,「{Title}」已暂存,下课后弹出", notification.Title);
|
||||
return;
|
||||
}
|
||||
|
||||
var request = BuildRequest(notification);
|
||||
// 只有真正播完(或被用户关掉)才回执:中途退出 ClassIsland 时下次上报还会补发,不会静默丢失
|
||||
AckWhenFinished(request, notification);
|
||||
|
||||
Reference in new issue
Block a user