diff --git a/SecRandom.Core.Tests/ClassIslandScheduleSourceTests.cs b/SecRandom.Core.Tests/ClassIslandScheduleSourceTests.cs index f5280f093..5873ef0f0 100644 --- a/SecRandom.Core.Tests/ClassIslandScheduleSourceTests.cs +++ b/SecRandom.Core.Tests/ClassIslandScheduleSourceTests.cs @@ -168,6 +168,58 @@ public async Task Refresh_WhenPreClassEnableWindowOpens_UnrestrictsWithoutNotify Assert.Equal(notifications, harness.StateChangedCount); } + [Fact] + public async Task OnClass_OutdoorSubject_RestrictsWhenEnabled() + { + var harness = new LinkageHarness(instantDrawDisable: false, disableOnOutdoorCourse: true); + harness.Lessons.CurrentState = TimeState.OnClass; + harness.Lessons.CurrentSubject = new Subject { Name = "体育", IsOutDoor = true }; + await harness.RefreshAsync(); + + Assert.True(harness.Snapshot.IsAvailable, $"error={harness.Snapshot.Error}"); + Assert.True(harness.Snapshot.IsOutdoorCourse); + Assert.True(harness.OutdoorRestricted); + // 非上课时段限制与户外课程限制是两个独立开关 + Assert.False(harness.Restricted); + } + + [Fact] + public async Task OnClass_IndoorSubject_NotRestricted() + { + var harness = new LinkageHarness(instantDrawDisable: false, disableOnOutdoorCourse: true); + harness.Lessons.CurrentState = TimeState.OnClass; + harness.Lessons.CurrentSubject = new Subject { Name = "数学", IsOutDoor = false }; + await harness.RefreshAsync(); + + Assert.False(harness.Snapshot.IsOutdoorCourse); + Assert.False(harness.OutdoorRestricted); + } + + [Fact] + public async Task OnClass_OutdoorSubject_SettingOff_NotRestricted() + { + var harness = new LinkageHarness(); + harness.Lessons.CurrentState = TimeState.OnClass; + harness.Lessons.CurrentSubject = new Subject { Name = "体育", IsOutDoor = true }; + await harness.RefreshAsync(); + + Assert.True(harness.Snapshot.IsOutdoorCourse); + Assert.False(harness.OutdoorRestricted); + } + + [Fact] + public async Task Break_OutdoorFlagFromBreakLabel_NotRestricted() + { + var harness = new LinkageHarness(instantDrawDisable: false, disableOnOutdoorCourse: true); + harness.Lessons.CurrentState = TimeState.Breaking; + harness.Lessons.CurrentSubject = new Subject { Name = "课间休息", IsOutDoor = true }; + await harness.RefreshAsync(); + + // 课间的 CurrentSubject 是课间标签而非科目,户外标记必须忽略 + Assert.False(harness.Snapshot.IsOutdoorCourse); + Assert.False(harness.OutdoorRestricted); + } + private static TimeLayoutItem ClassTime(DateTime now, TimeSpan startedAgo, TimeSpan endedAgo) => new() { TimeType = 0, @@ -180,11 +232,13 @@ private sealed class LinkageHarness public LinkageHarness( int preClassEnableSeconds = 0, int postClassDelaySeconds = 0, - bool instantDrawDisable = true) + bool instantDrawDisable = true, + bool disableOnOutdoorCourse = false) { var config = new MainConfigModel(); config.LinkageSettings.DataSource = LinkageDataSource.ClassIsland; config.LinkageSettings.InstantDrawDisable = instantDrawDisable; + config.LinkageSettings.DisableOnOutdoorCourse = disableOnOutdoorCourse; config.LinkageSettings.PreClassEnableTime = preClassEnableSeconds; config.LinkageSettings.PostClassDisableDelay = postClassDelaySeconds; @@ -215,6 +269,7 @@ public LinkageHarness( public int StateChangedCount { get; private set; } public CourseScheduleSnapshot Snapshot => Service.Snapshot; public bool Restricted => Service.IsConfirmedNonClassTime; + public bool OutdoorRestricted => Service.IsConfirmedOutdoorCourse; public Task RefreshAsync() => Service.RefreshAsync(); } diff --git a/SecRandom.Core/Models/Linkage/CourseModels.cs b/SecRandom.Core/Models/Linkage/CourseModels.cs index 374d0e661..202f7fe61 100644 --- a/SecRandom.Core/Models/Linkage/CourseModels.cs +++ b/SecRandom.Core/Models/Linkage/CourseModels.cs @@ -1,4 +1,4 @@ -namespace SecRandom.Core.Models.Linkage; +namespace SecRandom.Core.Models.Linkage; public enum CourseTimeState { @@ -26,6 +26,7 @@ public sealed record CourseScheduleSnapshot( TimeSpan? TimeSincePreviousCourseEnd, string Source, string Version, + bool IsOutdoorCourse = false, string? Error = null) { public static CourseScheduleSnapshot Unavailable(string source, string? error = null) => new( @@ -39,5 +40,6 @@ public sealed record CourseScheduleSnapshot( null, source, string.Empty, + false, error); } diff --git a/SecRandom.Core/Models/SubConfigs/LinkageSettingsConfig.cs b/SecRandom.Core/Models/SubConfigs/LinkageSettingsConfig.cs index 1ef30bc49..882d8029d 100644 --- a/SecRandom.Core/Models/SubConfigs/LinkageSettingsConfig.cs +++ b/SecRandom.Core/Models/SubConfigs/LinkageSettingsConfig.cs @@ -7,8 +7,10 @@ public partial class LinkageSettingsConfig : ObservableObject { [ObservableProperty] private bool _verificationRequired = true; [ObservableProperty] private bool _instantDrawDisable = false; + [ObservableProperty] private bool _disableOnOutdoorCourse = false; [ObservableProperty] private LinkageDataSource _dataSource = LinkageDataSource.Off; [ObservableProperty] private bool _hideFloatingWindowOnClassEnd = false; + [ObservableProperty] private bool _hideFloatingWindowOnOutdoorCourse = false; [ObservableProperty] private bool _preClassResetEnabled = false; [ObservableProperty] private int _preClassResetTime = 120; [ObservableProperty] private int _preClassEnableTime = 0; diff --git a/SecRandom.Core/Services/ControlNode/ControlSettingsLabels.cs b/SecRandom.Core/Services/ControlNode/ControlSettingsLabels.cs index 854322d5a..ccfe769eb 100644 --- a/SecRandom.Core/Services/ControlNode/ControlSettingsLabels.cs +++ b/SecRandom.Core/Services/ControlNode/ControlSettingsLabels.cs @@ -442,6 +442,8 @@ private static readonly (string Key, CultureInfo[] Fallback)[] PublishedLanguage ["instant_draw_disable"] = R(Linkage, "S_External_InstantDrawDisable"), ["verification_required"] = R(Linkage, "S_External_VerificationRequired"), ["hide_floating_window_on_class_end"] = R(Linkage, "S_ClassTime_HideFloatingWindow"), + ["hide_floating_window_on_outdoor_course"] = R(Linkage, "S_ClassTime_HideFloatingWindowOnOutdoorCourse"), + ["disable_on_outdoor_course"] = R(Linkage, "S_External_OutdoorCourse"), ["pre_class_reset_enabled"] = R(Linkage, "S_ClassTime_PreClassReset"), ["pre_class_reset_time"] = R(Linkage, "S_ClassTime_PreClassResetTime"), ["post_class_disable_delay"] = R(Linkage, "S_ClassTime_PostClassDisableDelay"), diff --git a/SecRandom/Langs/SettingsPages/Linkage/Resources.Designer.cs b/SecRandom/Langs/SettingsPages/Linkage/Resources.Designer.cs index 44afca204..dd460e1b1 100644 --- a/SecRandom/Langs/SettingsPages/Linkage/Resources.Designer.cs +++ b/SecRandom/Langs/SettingsPages/Linkage/Resources.Designer.cs @@ -1,4 +1,4 @@ -//------------------------------------------------------------------------------ +//------------------------------------------------------------------------------ // // This code was generated by a tool. // @@ -238,7 +238,25 @@ public static string S_ClassTime_HideFloatingWindow_D { return ResourceManager.GetString("S_ClassTime_HideFloatingWindow_D", resourceCulture); } } - + + /// + /// Looks up a localized string similar to 户外课程隐藏悬浮窗. + /// + public static string S_ClassTime_HideFloatingWindowOnOutdoorCourse { + get { + return ResourceManager.GetString("S_ClassTime_HideFloatingWindowOnOutdoorCourse", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 上课时段当前课程为户外课程时自动隐藏悬浮窗. + /// + public static string S_ClassTime_HideFloatingWindowOnOutdoorCourse_D { + get { + return ResourceManager.GetString("S_ClassTime_HideFloatingWindowOnOutdoorCourse_D", resourceCulture); + } + } + /// /// Looks up a localized string similar to 课后禁用延迟. /// @@ -365,6 +383,24 @@ public static string S_External_InstantDrawDisable_D { } } + /// + /// Looks up a localized string similar to 户外课程上课时段禁用. + /// + public static string S_External_OutdoorCourse { + get { + return ResourceManager.GetString("S_External_OutdoorCourse", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 读取 ClassIsland 课表科目的户外课程标记,当前课程被标记为户外课程时,即使处于上课时段也限制抽取和重置. + /// + public static string S_External_OutdoorCourse_D { + get { + return ResourceManager.GetString("S_External_OutdoorCourse_D", resourceCulture); + } + } + /// /// Looks up a localized string similar to 允许验证后继续. /// diff --git a/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx b/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx index b959fe0dd..93deed341 100644 --- a/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx +++ b/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx @@ -18,6 +18,12 @@ Restrict drawing and reset when course data confirms a break or non-class period; disable for no restriction + + Disable draws during outdoor courses + + + Reads each subject's outdoor-course flag from the ClassIsland timetable: while the current course is marked outdoor, drawing and resetting stay limited even during class; shares the verification exception with the non-class-time restriction + Data source @@ -75,6 +81,12 @@ Automatically hide the floating window when class ends + + Hide floating window on outdoor courses + + + Automatically hide the floating window when the current course is marked as an outdoor course + Reset before class diff --git a/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx b/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx index d7c086966..7397f6165 100644 --- a/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx +++ b/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx @@ -18,6 +18,12 @@ 休み時間や授業外と確認された場合に抽選とリセットを制限します + + 授業時間中は屋外科目で抽選を無効化 + + + ClassIsland 時間割の科目についている屋外フラグを読み取り、現在の授業が屋外とマークされている場合は授業時間中でも抽選とリセットを制限します。検証の例外は「授業時間外の操作制限」と共通です + データソース @@ -75,6 +81,12 @@ 授業終了時に自動で非表示にします + + 屋外授業中は非表示 + + + 現在の授業が屋外授業の場合、フローティングウィンドウを自動で非表示にします + 授業前にリセット diff --git a/SecRandom/Langs/SettingsPages/Linkage/Resources.resx b/SecRandom/Langs/SettingsPages/Linkage/Resources.resx index 2acaa5e58..7d62d7de3 100644 --- a/SecRandom/Langs/SettingsPages/Linkage/Resources.resx +++ b/SecRandom/Langs/SettingsPages/Linkage/Resources.resx @@ -33,6 +33,12 @@ 课程数据源明确显示处于课间或非上课时段时,限制抽取和重置;关闭后不作限制 + + 户外课程上课时段禁用 + + + 读取 ClassIsland 课表科目的户外课程标记,当前课程被标记为户外课程时,即使处于上课时段也限制抽取和重置 + 数据源 @@ -90,6 +96,12 @@ 课程结束时自动隐藏悬浮窗 + + 户外课程隐藏悬浮窗 + + + 上课时段当前课程为户外课程时自动隐藏悬浮窗 + 课前重置 diff --git a/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs b/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs index d00bd8589..3b080dd9c 100644 --- a/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs +++ b/SecRandom/Services/Linkage/ClassIslandScheduleSource.cs @@ -53,6 +53,10 @@ public async Task GetSnapshotAsync(CancellationToken can var currentName = state == CourseTimeState.OnClass ? NormalizeSubjectName(lessons.CurrentSubject?.Name) : string.Empty; + // 户外课程标记只在真实科目上有效;课间(CurrentSubject 是课间标签)与后备科目都不算 + var isOutdoorCourse = state == CourseTimeState.OnClass + && !string.IsNullOrEmpty(currentName) + && lessons.CurrentSubject?.IsOutDoor == true; var nextName = NormalizeSubjectName(lessons.NextClassSubject?.Name); var now = DateTime.Now; if (!string.IsNullOrEmpty(currentName)) @@ -101,7 +105,8 @@ public async Task GetSnapshotAsync(CancellationToken can nextCourseIn, sincePreviousEnd, SourceName, - $"{lessons.CurrentSelectedIndex}:{lessons.CurrentState}"); + $"{lessons.CurrentSelectedIndex}:{lessons.CurrentState}", + isOutdoorCourse); } catch (Exception exception) { diff --git a/SecRandom/Services/Linkage/CourseLinkageService.cs b/SecRandom/Services/Linkage/CourseLinkageService.cs index 4028630ce..570525a9e 100644 --- a/SecRandom/Services/Linkage/CourseLinkageService.cs +++ b/SecRandom/Services/Linkage/CourseLinkageService.cs @@ -95,6 +95,7 @@ private static bool SnapshotsEqual(CourseScheduleSnapshot a, CourseScheduleSnaps && a.State == b.State && a.Source == b.Source && a.Version == b.Version + && a.IsOutdoorCourse == b.IsOutdoorCourse && a.CurrentCourse?.Name == b.CurrentCourse?.Name && a.PreviousCourse?.Name == b.PreviousCourse?.Name && a.NextCourse?.Name == b.NextCourse?.Name @@ -108,6 +109,22 @@ private static bool SnapshotsEqual(CourseScheduleSnapshot a, CourseScheduleSnaps public bool IsConfirmedNonClassTime => Settings.InstantDrawDisable && IsConfirmedBreakTime; + /// + /// 户外课程上课时段禁用:只有 ClassIsland 数据源携带科目的户外课程标记。开启后当前 + /// 课程被标记为户外课程时,即使处于上课时段也限制抽取和重置,验证例外与 + /// 共用。 + /// + public bool IsConfirmedOutdoorCourse => Settings.DisableOnOutdoorCourse + && IsOutdoorCourseOnClass; + + /// + /// 当前处于上课时段且课程被标记为户外课程的纯状态判断,不依赖任何开关; + /// 抽取限制看 ,悬浮窗隐藏由调用方结合自己的开关判断。 + /// + public bool IsOutdoorCourseOnClass => _snapshot.IsAvailable + && _snapshot.State == CourseTimeState.OnClass + && _snapshot.IsOutdoorCourse; + public string GetSubjectFilter() { if (!Settings.SubjectHistoryFilterEnabled || !_snapshot.IsAvailable) diff --git a/SecRandom/Services/Linkage/LinkageDrawCoordinator.cs b/SecRandom/Services/Linkage/LinkageDrawCoordinator.cs index 83413fa67..223a1ca4b 100644 --- a/SecRandom/Services/Linkage/LinkageDrawCoordinator.cs +++ b/SecRandom/Services/Linkage/LinkageDrawCoordinator.cs @@ -49,7 +49,7 @@ public async Task AuthorizeAsync( if (controlDrawGate.IsDrawLocked) return false; - if (!linkageService.IsConfirmedNonClassTime) + if (!IsLinkageRestricted) return await securityService.AuthorizeAsync(operations, action, cancellationToken).ConfigureAwait(false); if (!linkageService.Settings.VerificationRequired) @@ -79,7 +79,7 @@ public LinkageDrawGate EvaluateGate(IReadOnlyCollection opera if (controlDrawGate.IsDrawLocked) return LinkageDrawGate.BlockedByRemoteLock; - if (!linkageService.IsConfirmedNonClassTime) + if (!IsLinkageRestricted) return operations.Any(securityService.RequiresVerification) ? LinkageDrawGate.RequiresLocalVerification : LinkageDrawGate.Allowed; @@ -87,9 +87,18 @@ public LinkageDrawGate EvaluateGate(IReadOnlyCollection opera if (!linkageService.Settings.VerificationRequired) return LinkageDrawGate.BlockedByClassTime; - // 确认非上课时间后的抽取要额外过 BypassClassTimeRestriction,而它永远需要当场验证。 + // 联动限制后的抽取要额外过 BypassClassTimeRestriction,而它永远需要当场验证。 + // 户外课程限制沿用 BlockedByClassTime:它本就发生在上课时段,远程控制台的 + // "上课时间"文案仍然准确。 return LinkageDrawGate.RequiresLocalVerification; } + /// + /// 联动闸门是否在当前时刻限制抽取:非上课时段限制与上课时段户外课程限制共用同一套 + /// 验证例外()。 + /// + private bool IsLinkageRestricted => linkageService.IsConfirmedNonClassTime + || linkageService.IsConfirmedOutdoorCourse; + public string GetCourseName() => linkageService.GetSubjectFilter(); } diff --git a/SecRandom/Views/FloatingWindow.axaml.cs b/SecRandom/Views/FloatingWindow.axaml.cs index 566e71377..9ab80d509 100644 --- a/SecRandom/Views/FloatingWindow.axaml.cs +++ b/SecRandom/Views/FloatingWindow.axaml.cs @@ -229,8 +229,11 @@ public void SetUserVisibilityIntent(bool visible) private void ApplyCourseLinkageVisibility() { - var shouldHide = ViewModel.Config.LinkageSettings.HideFloatingWindowOnClassEnd - && _linkageService.IsConfirmedBreakTime; + var linkageSettings = ViewModel.Config.LinkageSettings; + var shouldHide = (linkageSettings.HideFloatingWindowOnClassEnd + && _linkageService.IsConfirmedBreakTime) + || (linkageSettings.HideFloatingWindowOnOutdoorCourse + && _linkageService.IsOutdoorCourseOnClass); if (shouldHide == _hiddenByCourseLinkage) return; diff --git a/SecRandom/Views/SettingsPages/Linkage/LinkageSettingsPage.axaml b/SecRandom/Views/SettingsPages/Linkage/LinkageSettingsPage.axaml index fa905da44..1da07c675 100644 --- a/SecRandom/Views/SettingsPages/Linkage/LinkageSettingsPage.axaml +++ b/SecRandom/Views/SettingsPages/Linkage/LinkageSettingsPage.axaml @@ -37,6 +37,16 @@ + + + + + + + + + + + + CsesSummaryProperty = AvaloniaProperty.Register(nameof(CsesSummary)); + public static readonly StyledProperty IsOutdoorCourseRowVisibleProperty = + AvaloniaProperty.Register(nameof(IsOutdoorCourseRowVisible)); public LinkageSettingsPage() { @@ -34,6 +37,7 @@ public LinkageSettingsPage() DataContext = this; InitializeComponent(); RefreshCsesSummary(); + IsOutdoorCourseRowVisible = Settings.DataSource == LinkageDataSource.ClassIsland; } public ViewModelBase ViewModel { get; } = IAppHost.GetService(); @@ -44,6 +48,13 @@ public string CsesSummary private set => SetValue(CsesSummaryProperty, value); } + /// 户外课程标记只有 ClassIsland 数据源提供,CSES 数据源下隐藏该行。 + public bool IsOutdoorCourseRowVisible + { + get => GetValue(IsOutdoorCourseRowVisibleProperty); + private set => SetValue(IsOutdoorCourseRowVisibleProperty, value); + } + private MainConfigHandler ConfigHandler { get; } = IAppHost.GetService(); private ICsesScheduleStore CsesStore { get; } = IAppHost.GetService(); @@ -68,6 +79,7 @@ private void OnUnloaded(object? sender, RoutedEventArgs e) private void SettingsOnPropertyChanged(object? sender, PropertyChangedEventArgs e) { + IsOutdoorCourseRowVisible = Settings.DataSource == LinkageDataSource.ClassIsland; ConfigHandler.Save(); _ = IAppHost.GetService().RefreshAsync(); }