diff --git a/AGENTS.md b/AGENTS.md index 25ebb56d1..b9fadf5b4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -175,7 +175,7 @@ Keep this map short and stable. When code moves, AI agents should re-read the mo - Full IPC/URL compatibility is app-layer routed through `ProtocolCommandRouter`: structured current-user named-pipe IPC is additive to legacy `ShowMainWindow`/`Restart`/`Url:` delivery, and all external mutations must use `ISecurityService`. `data/*` queries must use non-mutating profile snapshots, never active-profile loading APIs. - `HistoryItem.DrawRoundId` identifies every record committed by one logical draw. Populate it for new history writes; IPC history projections group by it and must never expose internal `RecordId`. - History record groups (历史记录组) are an optional second history dimension beside `HistoryItem.CourseName`, never a replacement: on the record `HistoryGroup` (empty marker = the default group) is set when the record is written and both filters apply together, so a gated draw reads and writes the intersection. The scope parameter is deliberately **tri-state** (`string? historyGroup`): `null` means do not filter on the group axis at all (feature off or selector hidden = the pre-#294 behaviour, every record counts), `""` means the default group only (records whose marker is empty, which is what the panel's 默认记录组 option selects), and a name means that one group. Collapsing `null` and `""` re-introduces the original unfairness in the other direction, because the option labelled 默认记录组 would then count records that were explicitly sorted into another group. `DrawEngine`'s history caches, `IProfileService.Record*History`, `IDrawCommitService` commit records, and `VerificationDrawCoordinator` carry the value as a trailing optional parameter — nothing in the weight calculator changes, because filtering the cache already yields the group-local counts. Deliberate limits: temporary draw records stay one bucket per student list (splitting them per group would let switching groups reset the repeat limit), the group name is the identity (renaming rewrites markers, and `HistoryGroupName` rejects blank/over-long/control/reserved names such as the `__break__` course sentinel), and the group *selection* is transient UI state — it is never persisted, and hiding the selector always means "no group scope" (`null`, i.e. every record counts again), so a stale selection can never silently keep filtering. The switch and the group create/rename/delete management live on the history-management settings page (`SecRandom/Views/SettingsPages/History/HistoryManagementSettingsPage.axaml(.cs)`), not on a draw-settings page, and the main panels expose only the selector. Moving records between groups goes through `IProfileCatalogManager.Move{Student,Prize}HistoryGroup` (marker-only, no new record, no `DrawRoundId`) and never through `IDrawCommitService`. Enabling the feature is not a protected operation, the switch is readable-but-not-writable through the control plane, and downgrading to a build without it silently folds grouped records back into the global history. -- Draw commits must go through `IDrawCommitService` (`DrawCommitCoordinator`): one logical draw gets exactly one `DrawRoundId` (a caller may supply one), temporary records commit before persistent history, a mid-commit failure rolls back through snapshot compensation, and commits serialize behind the coordinator gate. Never reintroduce bare two-step writes of `IProfileService.Record*History` plus temporary-record calls; the optional `drawRoundId` parameter (and `drawMethod` on `RecordPrizeHistory`) exists for coordinator use. +- Draw commits must go through `IDrawCommitService` (`DrawCommitCoordinator`): one logical draw gets exactly one `DrawRoundId` (a caller may supply one), temporary records commit before persistent history, a mid-commit failure rolls back through snapshot compensation, and commits serialize behind the coordinator gate. Never reintroduce bare two-step writes of `IProfileService.Record*History` plus temporary-record calls; the optional `drawRoundId` parameter (and `drawMethod` on `RecordPrizeHistory`) exists for coordinator use. Preview frames write no temporary record and no history: the coordinator is the only writer of the fairness surface, and the weight/filter/verification layers read persistent history only, so an animation that flickers through a hundred names can neither record a hit nor shift a weight. A preview shuffles with `Random.Shared` while the draw samples through the injected `IRandomSource` (`CryptoRandomSource`), so animation frames cannot consume or bias the draw's random stream. `SecRandom.Core.Tests/PreviewAnimationFairnessTests.cs` pins all three claims, including a reflection guard that the presentation contracts expose no commit or temporary-record surface. - File persistence saves are atomic replacements: `FileConfigService.SaveConfig` and `DrawTemporaryRecordService.SaveState` write a temporary file and atomically replace the target; new file-persistence code must follow the same pattern instead of overwriting files in place. - Draw proofs use the SecRandom v3 algorithm ID and an `algorithmEngineVersion`. Engine v3.1 commits the sampling mode into the request: student draws use history-balanced weighted sampling, count-based lottery inventory uses equal-probability partial permutation when no internal rule applies, and pan or internal-rule lottery draws use weighted sampling without replacement. Keep the verifier's replay logic byte-compatible with each supported proof engine version. Engine v3.4.0 does not change the replay mathematics at all — it only marks that the `fairness` block may carry `historyScope` — but the service side must be deployed **before** a client build sends that generation, exactly like a new audit field: a 3.3-generation verifier replays the same proof and reads the narrowed per-candidate `historyCount` values as global history. `fair.sectl.cn` accepts 3.4/3.3/3.2 as the current algorithm family, 3.1 as the previous family, and 3.0 as the oldest, and keeps replaying every previously issued generation. - Ordinary draws immediately save an `OfflineReproducible` proof, then submit that newly generated proof to `fair.sectl.cn` for background replay/signing. A submission must use the proof object captured at draw time and never a re-read of the proof file: `DrawProofAttestationService` keeps the pending item in memory, persists only its path, proof ID, and canonical proof hash to `data/proofs/attestation-queue.json`, retries failures with bounded exponential backoff, resumes queued items after a restart, and refuses plus reports an item whose file no longer matches the recorded hash (it then keeps the receipt in the queue instead of overwriting the changed file). Startup must never scan or re-attest historical proof files that were never queued. A current server receipt signs a canonical hash of all proof fields except the receipt itself, plus the proof ID, input hash, payload hash, anonymous audit-payload hash, and mode; public sharing requires that receipt. This detects changes after the signed submission, but it does not attest to an unmodified local executable, an authentic real-world candidate pool, or absence of pre-draw result selection. Do not call it a pre-draw server witness. Local proof retention applies both the configured age and total storage limits; after every new save, delete oldest `.srproof.json` files until the configured storage cap is met. diff --git a/SecRandom.Core.Tests/PreviewAnimationFairnessTests.cs b/SecRandom.Core.Tests/PreviewAnimationFairnessTests.cs new file mode 100644 index 000000000..38563312a --- /dev/null +++ b/SecRandom.Core.Tests/PreviewAnimationFairnessTests.cs @@ -0,0 +1,278 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using SecRandom.Core.Abstraction.Services; +using SecRandom.Core.Abstraction.Services.Presentation; +using SecRandom.Core.Services; +using SecRandom.Core.Services.Config; +using SecRandom.Core.Services.Draw; +using SecRandom.Shared; +using SecRandom.Shared.Models.Profile; + +namespace SecRandom.Core.Tests; + +/// +/// 预览(滚动)动画的公平性防线。 +/// +/// 预览帧在 QuickDrawPageViewModel.ShowPreviewAsync 里只做 +/// candidates.OrderBy(_ => Random.Shared.Next()).Take(count) 这类内存洗牌,渲染只经 +/// DataTemplate / RenderTransform;写临时记录的入口只有 DrawCommitCoordinator +/// (RecordStudents / RecordPrizes),而它只在真实一轮抽取结束后被调用一次。 +/// 因此反复预览既不抬高临时记录计数,也不写历史,后续权重与「是否已抽到」判定保持不变。 +/// +/// +/// 这些测试用真实 DI 运行时(AddCoreRuntimeServices)+ 临时 data root,避免 fake 把结论证成同义反复; +/// 同时保留一个「提交确实会改变同一测量面」的对照,证明断言灵敏。 +/// +/// +public sealed class PreviewAnimationFairnessTests : IDisposable +{ + private const int PreviewFrameCount = 40; + private const int SampledPerFrame = 2; + + private readonly string _dataRoot = Path.Combine( + Path.GetTempPath(), + "SecRandom", + "preview-animation-fairness", + Guid.NewGuid().ToString("N")); + + public PreviewAnimationFairnessTests() + { + ResetDataRoot(); + ConfigureDataRoot(_dataRoot); + } + + [Fact] + public void PreviewFrames_OnlyReshuffleTheFrozenPool_AndWriteNoFairnessState() + { + using var provider = CreateProvider(); + var profile = provider.GetRequiredService(); + var students = SeedStudents(profile, 6); + var listName = profile.StudentListConfig!.Name; + var temporary = provider.GetRequiredService(); + + var frames = SimulatePreviewFrames(students, SampledPerFrame, listName); + + Assert.Equal(PreviewFrameCount, frames.Count); + Assert.All(frames, frame => Assert.Equal(SampledPerFrame, frame.Count)); + // 每一帧都只能来自抽取开始前冻结的候选池,永远不会包含池外记录。 + Assert.All(frames, frame => Assert.All(frame, student => Assert.Contains(student, students))); + + // 临时记录 = 公平计数(重复限制 / 平均间隔 / 权重都经它或历史读取),预览后必须仍是空的。 + Assert.Empty(temporary.GetStudentCounts(listName, string.Empty, string.Empty)); + // 持久历史同样一条都不能有。 + Assert.Empty(profile.CurrentStudentHistory!.Students); + Assert.Equal(0, profile.CurrentStudentHistory.TotalRounds); + Assert.Equal(0, profile.CurrentStudentHistory.TotalStats); + } + + [Fact] + public void RepeatedPreviewFrames_LeaveFairnessWeightsBitIdentical() + { + using var provider = CreateProvider(); + var profile = provider.GetRequiredService(); + var students = SeedStudents(profile, 5); + var listName = profile.StudentListConfig!.Name; + var engine = CreateEngine(provider); + + var before = engine.CalculateStudentWeight(students); + SimulatePreviewFrames(students, SampledPerFrame, listName); + var after = engine.CalculateStudentWeight(students); + + Assert.Equal(before.Count, after.Count); + for (var index = 0; index < before.Count; index++) + { + Assert.Same(before[index].Candidate, after[index].Candidate); + // 精确相等:权重是历史上计数的纯函数,预览没有可写入的路径。 + Assert.Equal(before[index].Weight, after[index].Weight); + } + + Assert.All(after, weighted => Assert.True(weighted.Weight > 0)); + } + + [Fact] + public void OneRealCommit_DoesMoveTheSameWeightSurface_SoTheGuardIsSensitive() + { + using var provider = CreateProvider(); + var profile = provider.GetRequiredService(); + var students = SeedStudents(profile, 5); + var listName = profile.StudentListConfig!.Name; + var engine = CreateEngine(provider); + var temporary = provider.GetRequiredService(); + var winner = students[0]; + + var before = engine.CalculateStudentWeight(students); + var roundId = provider.GetRequiredService() + .CommitStudentDraw(new StudentDrawCommit([winner], DateTime.Now, 1, listName)); + var after = engine.CalculateStudentWeight(students); + + Assert.False(string.IsNullOrWhiteSpace(roundId)); + var beforeWinner = before.Single(weighted => ReferenceEquals(weighted.Candidate, winner)).Weight; + var afterWinner = after.Single(weighted => ReferenceEquals(weighted.Candidate, winner)).Weight; + Assert.True(afterWinner < beforeWinner, + $"一次真实提交必须降低中签者权重(before={beforeWinner}, after={afterWinner}),否则上一条断言没有灵敏度。"); + + Assert.Equal(1, temporary.GetStudentCounts(listName, string.Empty, string.Empty)[winner.RecordId.ToString("D")]); + Assert.Equal(1, profile.CurrentStudentHistory!.TotalRounds); + } + + [Fact] + public void PreviewPresentationRequests_CarryNoRoundIdentity_AndThePhaseEnumHasNoCommitPhase() + { + // 相位只有「滚动中」与「已提交结果」两种:不存在“预览已提交”这种半状态。 + Assert.Equal([DrawPresentationPhase.Preview, DrawPresentationPhase.Reveal], Enum.GetValues()); + + var student = new Student { Name = "Ada", RecordId = Guid.NewGuid() }; + var preview = new DrawPresentationRequest + { + Channel = DrawPresentationChannel.QuickDraw, + Phase = DrawPresentationPhase.Preview, + Students = [student], + ListName = "class-a", + RequestedCount = 1 + }; + + Assert.True(preview.IsPreview); + Assert.Equal(1, preview.ItemCount); + // 预览请求天然没有轮次身份(ResultPresentationBridge.BuildRequest 从不填这两个字段), + // 呈现器即使想“按轮次反查历史”也无从下手。 + Assert.Equal(Guid.Empty, preview.ProofId); + Assert.Equal(string.Empty, preview.DrawRoundId); + + var revealed = preview with + { + Phase = DrawPresentationPhase.Reveal, + ProofId = Guid.NewGuid(), + DrawRoundId = "round-1" + }; + Assert.False(revealed.IsPreview); + Assert.True(preview.IsPreview); // with 表达式产出新快照,不改原件 + + // init-only:呈现器拿到的快照无法在构造后被逐属性改写(init 访问器带 IsExternalInit 修饰符,不算可变 setter)。 + Assert.True(typeof(DrawPresentationRequest).IsSealed); + var mutableSetters = typeof(DrawPresentationRequest) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(property => property.SetMethod is { IsPublic: true } && + !property.SetMethod.ReturnParameter.GetRequiredCustomModifiers() + .Contains(typeof(IsExternalInit))) + .Select(property => property.Name) + .ToArray(); + Assert.Empty(mutableSetters); + } + + [Fact] + public void PresentationContracts_ExposeNoCommitOrTemporaryRecordSurface() + { + var presenterTypes = typeof(IDrawResultPresenter) + .GetMethods() + .SelectMany(method => method.GetParameters() + .Select(parameter => parameter.ParameterType) + .Append(method.ReturnType)) + .ToArray(); + + Assert.DoesNotContain(typeof(IDrawCommitService), presenterTypes); + Assert.DoesNotContain(typeof(IDrawTemporaryRecordService), presenterTypes); + + var requestTypes = typeof(DrawPresentationRequest) + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Select(property => property.PropertyType) + .ToArray(); + + Assert.DoesNotContain(typeof(IDrawCommitService), requestTypes); + Assert.DoesNotContain(typeof(IDrawTemporaryRecordService), requestTypes); + } + + public void Dispose() + { + ResetDataRoot(); + if (Directory.Exists(_dataRoot)) + Directory.Delete(_dataRoot, recursive: true); + } + + /// + /// 复刻 QuickDrawPageViewModel.ShowPreviewAsync 的帧循环(洗牌冻结池 + 生成 Preview 请求), + /// 但不触碰任何服务:这正是产品代码里预览分支的全部行为。 + /// + private static List> SimulatePreviewFrames( + IReadOnlyList frozenPool, + int count, + string listName, + int frames = PreviewFrameCount) + { + var rendered = new List>(frames); + var previewAnimationRevision = 0; + + for (var frame = 0; frame < frames; frame++) + { + var frameStudents = frozenPool.OrderBy(_ => Random.Shared.Next()).Take(count).ToList(); + rendered.Add(frameStudents); + + var request = new DrawPresentationRequest + { + Channel = DrawPresentationChannel.QuickDraw, + Phase = DrawPresentationPhase.Preview, + Students = frameStudents, + ListName = listName, + RequestedCount = count + }; + Assert.True(request.IsPreview); + + previewAnimationRevision++; + } + + Assert.Equal(frames, previewAnimationRevision); + return rendered; + } + + private static List SeedStudents(IProfileService profile, int count) + { + var students = Enumerable.Range(1, count) + .Select(index => new Student + { + Name = $"Preview-{index:00}", + Id = index.ToString("00"), + RecordId = Guid.NewGuid() + }) + .ToList(); + + foreach (var student in students) + profile.CurrentStudentList!.Students.Add(student); + + profile.SaveProfile(); + return students; + } + + private static DrawEngine CreateEngine(ServiceProvider provider) + { + return new DrawEngine( + provider.GetRequiredService(), + provider.GetRequiredService(), + NullLogger.Instance); + } + + private static ServiceProvider CreateProvider() + { + var services = new ServiceCollection(); + services.AddLogging(builder => builder.SetMinimumLevel(LogLevel.None)); + services.AddCoreRuntimeServices(); + return services.BuildServiceProvider(); + } + + private static void ConfigureDataRoot(string dataRoot) + { + GetUtilsMethod("ConfigureDataRoot").Invoke(null, [dataRoot]); + } + + private static void ResetDataRoot() + { + GetUtilsMethod("ResetDataRootForTests").Invoke(null, null); + } + + private static MethodInfo GetUtilsMethod(string name) + { + return typeof(Utils).GetMethod(name, BindingFlags.Static | BindingFlags.NonPublic) + ?? throw new InvalidOperationException($"Utils.{name} was not found."); + } +} diff --git a/SecRandom.Core/Models/SubConfigs/FloatingWindowSettingsConfig.cs b/SecRandom.Core/Models/SubConfigs/FloatingWindowSettingsConfig.cs index f0df49454..3e432dee7 100644 --- a/SecRandom.Core/Models/SubConfigs/FloatingWindowSettingsConfig.cs +++ b/SecRandom.Core/Models/SubConfigs/FloatingWindowSettingsConfig.cs @@ -22,6 +22,11 @@ public partial class FloatingWindowSettingsConfig : ObservableObject [ObservableProperty] private int _dockedWindowSize = 32; [ObservableProperty] private bool _draggable = true; [ObservableProperty] private int _floatingWindowSize = 56; + // 悬浮窗自定义文字:默认关闭;空字符串等于不显示,旧配置缺字段时按默认值加载 + [ObservableProperty] private bool _enableFloatingWindowText; + [ObservableProperty] private string _floatingWindowText = string.Empty; + // 自定义文字字号(像素):0 表示跟随悬浮窗尺寸自动缩放,与按钮字号保持同一比例 + [ObservableProperty] private int _floatingWindowTextFontSize; [ObservableProperty] private int _longPressDuration = 500; [ObservableProperty] private bool _doNotStealFocus = true; [ObservableProperty] private bool _hideOnForeground = false; diff --git a/SecRandom.Core/Models/SubConfigs/Picking/QuickDrawSettingsConfig.cs b/SecRandom.Core/Models/SubConfigs/Picking/QuickDrawSettingsConfig.cs index af2092e16..6db88bff4 100644 --- a/SecRandom.Core/Models/SubConfigs/Picking/QuickDrawSettingsConfig.cs +++ b/SecRandom.Core/Models/SubConfigs/Picking/QuickDrawSettingsConfig.cs @@ -12,6 +12,17 @@ public partial class QuickDrawSettingsConfig : OverridableDrawSettings [ObservableProperty] private string _defaultClass = string.Empty; [ObservableProperty] private int _disableAfterClick = 1; + /// + /// Whether the take-over window shows the "drawing" status line while a draw is running. + /// + /// + /// The animation itself already tells the class that a draw is in progress, so a text line on top of it + /// costs a re-layout in the middle of a lesson. Turning it off keeps the overlay steady from the moment + /// the button is pressed until the result lands; every other status (no members, draw failed, result + /// count) stays as it is, because those carry information the animation cannot. + /// + [ObservableProperty] private bool _showDrawingStatus = true; + /// /// The history record group Quick Draw uses by default (an empty string means "use global history"). /// diff --git a/SecRandom.Core/Services/ControlNode/ControlSettingsLabels.cs b/SecRandom.Core/Services/ControlNode/ControlSettingsLabels.cs index 854322d5a..d9a4cb3ed 100644 --- a/SecRandom.Core/Services/ControlNode/ControlSettingsLabels.cs +++ b/SecRandom.Core/Services/ControlNode/ControlSettingsLabels.cs @@ -340,6 +340,7 @@ private static readonly (string Key, CultureInfo[] Fallback)[] PublishedLanguage ["default_class"] = R(Picking, "S_DefaultClass"), ["default_pool"] = R(Picking, "S_DefaultPool"), ["disable_after_click"] = R(Picking, "S_DisableAfterClick"), + ["show_drawing_status"] = R(Picking, "S_ShowDrawingStatus"), ["use_global_font"] = R(Picking, "S_FontSource"), ["custom_font"] = R(Picking, "S_CustomFont"), ["font_size"] = R(Picking, "S_FontSize"), @@ -436,6 +437,13 @@ private static readonly (string Key, CultureInfo[] Fallback)[] PublishedLanguage ["hide_on_foreground"] = R(FloatingWindow, "S_Interaction_HideOnForeground"), ["hide_on_foreground_window_titles"] = R(FloatingWindow, "S_Interaction_HideOnForegroundWindowTitles"), ["hide_on_foreground_process_names"] = R(FloatingWindow, "S_Interaction_HideOnForegroundProcessNames"), + // 自定义文字:内容没有设置页标题可借,故就地三语;开关与字号复用设置页资源。 + ["enable_floating_window_text"] = R(FloatingWindow, "S_Display_CustomText"), + ["floating_window_text"] = L( + "悬浮窗自定义文字", "Floating window custom text", "フローティングウィンドウのカスタムテキスト", + "显示在悬浮窗上的自定义文字内容", "Custom text shown on the floating window", + "フローティングウィンドウに表示するカスタムテキスト"), + ["floating_window_text_font_size"] = R(FloatingWindow, "S_Display_CustomTextSize"), // ---------------- 联动 ["data_source"] = R(Linkage, "S_External_DataSource"), diff --git a/SecRandom/AGENTS.md b/SecRandom/AGENTS.md index 329077984..273ad9079 100644 --- a/SecRandom/AGENTS.md +++ b/SecRandom/AGENTS.md @@ -176,9 +176,14 @@ SecRandom/ - More settings includes roll-call and lottery page management options for the control panel side and per-control visibility; wire built-in draw pages through `MainConfigModel.MoreSettings` instead of duplicating local UI flags. - History record groups (历史记录组, issue #294) add a fourth selector to the roll-call and lottery control panels. `RollCallPageViewModel`/`LotteryPageViewModel` own `SelectedHistoryGroup`, `HistoryGroupOptions`, and the derived `CurrentHistoryGroup`, and they freeze it at the same moment the candidate pool is snapshotted (`StartDrawCoreAsync`) so a mid-draw change cannot split weights from the record marker. Two rules are load-bearing: (1) `CurrentHistoryGroup` is a **tri-state** `string?` and returns `null` whenever the selector is not visible (`Config.General.HistoryGroups.Enabled` off, or the per-page `MoreSettings.{RollCall,Lottery}HistoryGroupSelector` off), so hiding the control can never leave a stale hidden value silently scoping draws — while the panel's first option (默认记录组) maps to the empty string, i.e. "only records that carry no group marker", and a name maps to that one group; (2) selecting a group stays an ordinary page state and is deliberately **not** a `SecurityOperation` — it is the same class of action as switching list/group/gender, and the feature only changes which history the fairness weights read. The main panels deliberately show the selector and nothing else: no locked-group chip, no "this group has no records yet" hint, and no clear-selection button, so picking a group is a plain `ComboBox` choice. The lottery page scopes the *student* history axis (`HistoryGroupKind.RollCall`) because prize history does not feed draw weights on the production path; the chosen name is still stamped onto the prize records for viewing/export. Group management (create/rename/two-step delete) lives on the history-management settings page (`Views/SettingsPages/History/HistoryManagementSettingsPage`), not in the main pages and not in the draw settings, which is also why its search entries are no longer in `SettingsSearchService.PickingPageSettingIds`. - Quick Draw has no group selector of its own, so `QuickDrawSettingsConfig.HistoryGroup` is its *default* record group: `QuickDrawPageViewModel.ResolveQuickDrawHistoryGroup()` re-validates that name on every draw against `IHistoryGroupCatalog.GetGroupNames(HistoryGroupKind.RollCall)` and returns a **tri-state** `string?`: `null` (no group scope at all) when the feature switch is off or the configured name was renamed/deleted, `""` (the default record group) when no group is configured, and the name otherwise — never draw against a ghost group name, because that would silently equalize weights while the user believes the lock is on, and never turn "feature off" into `""`, because that would silently narrow the weights to ungrouped records. The resolved value is frozen next to the candidate snapshot and flows into both `DrawStudentsAsync` and `StudentDrawCommit`. `QuickDrawPage.axaml` has no draw button (the floating window generates it), so the lock is disclosed as a small hint line under the result area, and `QuickDrawSettingsPage` shows the picker plus an explanation instead of hiding the whole card when the feature switch is off. +- The quick-draw window re-centers on every size change, not just when it opens. `QuickDrawPage` raises `ResultLayoutChanged` after each result layout pass and `App` answers it with `RequestQuickDrawWindowCentering`; `SizeChanged` repositions from `e.NewSize` (window `Bounds` lags the layout that raised it) and `_positioningQuickDrawWindow` keeps a position write from re-entering. Never write `Position` when the computed target already equals the current one, and never subscribe `PositionChanged` back into this path — both turn a one-shot correction into a jitter loop. The window is `SizeToContent` driven, so any fixed `Width`/`Height` write conflicts with it; the result area must also be width-filling with a single-card `MaxWidth`, otherwise a long name widens the whole row and visually drags the group off center. +- The quick-draw window never takes focus. It is constructed with `ShowActivated = false` and, once its handle exists, requests `WindowFeatures.NoActivate` through `ApplyPlatformFeatures` — the same mechanism `FloatingWindow` uses. The former `Show()`/`Activate()` calls on this path were removed on purpose; re-adding one steals the teacher's focus while they are mid-sentence, and the window needs no keyboard input (only clicks: 连续点击关闭). +- Quick-draw status text is optional. `QuickDrawSettingsConfig.ShowDrawingStatus` (default on) gates only the in-progress «正在抽取» line, so a class-time draw does not spend a second on decoration; the committed-result, cancelled, and error lines must keep showing regardless. +- Floating-window custom text size is user-owned. `FloatingWindowTextFontSize == 0` means auto (`FloatingWindowSize * 0.25`, clamped 11–20), any positive value is the pixel size (clamped 8–72); it reaches an open window through the existing `FloatingWindowSettings.PropertyChanged` → `RefreshItems` path, so do not add a second apply route. +- A new user-facing setting needs the full quartet: the `ObservableProperty` on the config model, three-language keys in `Langs/SettingsPages/**` **plus** the hand-written designer property, a search-registration entry when that page's ids are whitelisted (`SettingsSearchService.PickingPageSettingIds`; excluded-mode pages need none), and a `ControlSettingsLabels` row when the value should carry a name in the remote control catalog. - Global shortcuts are an app-layer hosted service. `GlobalShortcutService` registers the nine V2-parity actions through Windows `RegisterHotKey`, dispatches them to Avalonia's UI thread, and degrades without registrations on other platforms. Changes to `MoreSettings.EnableShortcut` or a binding apply without restarting. -- Floating window settings are live-applied to an open `FloatingWindow`: button selection, layout, display style, size, opacity, topmost mode, and dragging must not require reopening the window. -- Course linkage is app-layer only. `CourseLinkageHostedService` is the sole course-boundary scheduler; CSES parsing/file access stays behind `ICsesScheduleStore`, and ClassIsland types stay inside `ClassIslandScheduleSource`. Use `LinkageDrawCoordinator` for every local, protocol, or shortcut draw/reset path so confirmed non-class restrictions cannot be bypassed. Unknown/unavailable sources are permissive. +- Floating window settings are live-applied to an open `FloatingWindow`: button selection, layout, display style, size, opacity, topmost mode, custom text, and dragging must not require reopening the window. `FloatingWindowSettingsConfig.EnableFloatingWindowText`/`FloatingWindowText` drive one non-hit-testable line above `ButtonsPanel`; the text is folded to a single line, trimmed with `CharacterEllipsis`, sized from `FloatingWindowSize`, and lives inside `ExpandedContent` so the docked handle never shows it. +- Course linkage is app-layer only. `CourseLinkageHostedService` is the sole course-boundary scheduler; CSES parsing/file access stays behind `ICsesScheduleStore`, and ClassIsland types stay inside `ClassIslandScheduleSource`. Use `LinkageDrawCoordinator` for every local, protocol, or shortcut draw/reset path so confirmed non-class restrictions cannot be bypassed. Unknown/unavailable sources are permissive. A local refusal must explain itself: the `VerificationRequired == false` branch (there is no prompt to carry the reason) calls `ClassTimeDrawRestrictionNotice.Show`, a throttled toast built from `M_ClassTimeDrawBlocked`/`M_ClassTimeDrawBlockedGeneric`, while `EvaluateGate` stays silent because a remote `draw.trigger` is answered in its receipt and an unattended classroom machine must not collect dialogs. - The control node (集控, `docs/client-protocol.md`) is the only remote-control ingress. Its device-owned switch, node identity, group id, endpoint, applied `desired_state` revision, and draw lock live in `data/config/control/node-state.json` through `IControlNodeStateStore` — never in `settings.json`, because a settings import must not be able to enable remote control. `LinkageDrawCoordinator.AuthorizeAsync` refuses every draw while that gate is locked, and `EvaluateGate` answers "would this raise a local password prompt?" so a remote command can be refused instead of leaving an unattended classroom machine waiting on a dialog. Read the endpoint through `ControlEndpointPolicy` (wss, or plaintext ws only to loopback). Declared capabilities must stay honest: only declare what `ControlCommandDispatcher` implements, and never implement `node.restart` (the service refuses it on purpose). A phone is only the console side (`MobileRemoteDrawViewModel` sends `draw.trigger` to nodes) and has no outbound node channel, so its branch of `BuildHost()` registers the neutral `UnlockedControlDrawGate` instead. That registration is mandatory, not cosmetic — `LinkageDrawCoordinator` is shared by both hosts, and a missing gate fails inside `Host.StartAsync` (it constructs `GlobalShortcutService`, whose draw-page ViewModels reach the gate) and takes the whole mobile host down. Do not register the unlocked gate for desktop: a real gate that disappears must fail loudly instead of silently unlocking every draw. **Which host runs the node is decided in exactly one place**, `AppHostShape.Resolve(isMobile, MobilePlatformServiceRoot.UsesDesktopMainView)` in `Mobile/AppHostShape.cs`, and `BuildHost()` reads only its result — the node runs on desktop **or tablet** (`RunsControlNode`), while a phone (`UsesUnlockedDrawGate`) has no outbound node channel. A tablet is both sides: it registers the **real** `ControlDrawGateService` plus the node services and the `settings.general.control` page, and reaches the *same* `MobileRemoteDrawViewModel` as the phone — desktop and tablet register the **desktop-layout view** as a `MainView` sidebar entry (`AddMainPage`), the phone keeps its keyed bottom-bar registration of `MobileRemoteDrawPage` (`MobilePageIds.RemoteDraw`), and **one ViewModel serves both views** (never a second VM; see the 远程抽取页的宿主矩阵 bullet below). Never give a tablet the unlocked gate (its remote lock would silently stop working and local draws would be let through), and never give a phone the real one. Remote draws on a tablet have no `MainWindow`: `App.ShowMainWindowCoreAsync` only creates a desktop window when `App.IsDesktop`, and `RemoteDrawFocusPlan.Resolve` returns `RemoteDrawSurface.SingleViewHost` so no window code runs — the target page is selected in `MainView` instead. Raising the app from the background is an OS restriction on Android/iPadOS, not something the client can do. - Floating-window course hiding records linkage-owned prior visibility and current user intent. Do not restore a window that the user hid manually while a course break was active. - **远程抽取页的宿主矩阵(一份 VM + 两个视图)**:`MobileRemoteDrawViewModel` 是**唯一一份** ViewModel(设备列表、读名单/奖池、条件、抽取、重置、回执解析与四态文案)——协议逻辑分叉就是这个项目吃过两次亏的地方,谁都不许为某个宿主复制一份。**UI 则按宿主分叉**:桌面/平板用桌面主页面版式的 `Views/MainPages/RemoteDrawPage.axaml(.cs)`(左侧结果区 + 右侧控制区,与点名/抽奖同一套观感),手机保持 `Views/Mobile/MobileRemoteDrawPage.axaml(.cs)` 的竖排表单。视图只做展示与绑定:需要的新展示态加进共享 VM(例如 `DrawnCountText`),绝不在视图里自己算,否则两侧同一天会说两句话。手机页的版式是**上结果、下控制**:结果/失败文案只出现在顶部结果区,抽取按钮下方只留"正在下发"这类临时进行态;结果区封顶 200 且内部可滚动,未抽时空态只有两行,不能把控制项顶出屏幕。**设备列表要标出本机**:`DeviceRow.IsSelf` 由 `DeviceRow.IsSameNode(本机 node_id, 行 node_id)` 在共享 VM 里算出(大小写与空白差异不算两台;本机没配置身份、或手机没有节点时一台都不标),行内给一个次级样式的徽章,选中本机时给出说明但**不禁用**选择,且本机**不得从列表里过滤掉**。**设备加载态只能在共享 VM 里收口**(`BeginDeviceLoad`/`EndDeviceLoad` + 序号取代):成功、失败、0 台、异常、被更新的请求取代都必须清掉 `IsLoadingDevices` **以及那句"正在读取设备"进度文案**——这条流程曾在成功路径上只清布尔量,把"正在读取设备"的残影留在状态行上(三端同错,因为状态在共享层)。**结果区只显示远端回执里真的返回了什么**(`detail.drawn` → `DrawnMembers`):没有回执就不列成员,只显示状态文案(还没抽/失败/超时/被拒),前端不造结果。`AppHostShape` 负责回答"怎么挂":桌面与平板没有底部导航栏,注册为 **`MainView` 侧栏主页面**(`UsesRemoteDrawSidebarPage` → `AddMainPage`),手机注册为 **底部第 5 档 keyed 页面**(`UsesRemoteDrawBottomTab` → `AddKeyedTransient(MobilePageIds.RemoteDraw)`),两者恰好一个为真,两个视图共用同一个页面 id `main.remoteDraw`。**侧栏顺序**:桌面/平板上「远程抽取」必须排在**「抽奖」下面**——顺序由 `AddMainPage` 的注册顺序 + `PageLocation` 决定(桌面主页都在 `Bottom` 组,`Top` 组是空的),因此那条注册紧跟在 `AddMainPage` 之后、`AddMainPage` 之前,并由 `TabletControlHostTests.侧栏顺序_远程抽取紧跟在抽奖之后` 钉住;手机底部顺序不受此影响。 diff --git a/SecRandom/App.axaml.cs b/SecRandom/App.axaml.cs index 36abc6160..1c8792ef2 100644 --- a/SecRandom/App.axaml.cs +++ b/SecRandom/App.axaml.cs @@ -79,6 +79,7 @@ using SecRandom.Services.Desktop; using SecRandom.Services.Draw; using SecRandom.Services.Notification; +using SecRandom.Services.Platform; using SecRandom.Services.Profiles; using SecRandom.Services.RosterTransfer; using SecRandom.Services.Ipc; @@ -140,6 +141,7 @@ public partial class App : Application private static FloatingWindow? _floatingWindow; private static Window? _quickDrawWindow; private static NotificationChannelSettings? _quickDrawNotificationSettings; + private static bool _positioningQuickDrawWindow; private static MainWindow? _mainWindow; private static MainWindow? _settingsWindow; private static Task? _runtimeServicesStartupTask; @@ -2580,9 +2582,9 @@ public static Task ShowQuickDrawNotificationPreviewWindowAsync(NotificationChann quickDraw.NotificationOpacity = Math.Clamp(windowSettings.Transparency, 20, 100); var window = GetOrCreateQuickDrawWindow(); window.Opacity = 1; + // 不 Activate:闪抽是覆盖在课堂画面之上的提示,抢焦点会把 PPT/播放器顶到后台 if (!window.IsVisible) window.Show(); - window.Activate(); Dispatcher.UIThread.Post( () => PositionQuickDrawNotificationWindow(window, windowSettings), DispatcherPriority.Render); @@ -2611,9 +2613,9 @@ private static async Task ShowQuickDrawNotificationWindowAsync( quickDraw.NotificationOpacity = Math.Clamp(windowSettings.Transparency, 20, 100); var window = GetOrCreateQuickDrawWindow(); window.Opacity = 1; + // 同上:结果窗只做视觉提示,激活它会打断正在演示的前台程序 if (!window.IsVisible) window.Show(); - window.Activate(); if (showPreview) { quickDraw.ShowNotificationPreview(items, preserveQuickDrawResult); @@ -2655,8 +2657,7 @@ private static async Task ShowQuickDrawWindowAsync() if (!quickDraw.IsDrawing) await quickDraw.StartAuthorizedTriggeredDrawAsync(); - else if (_quickDrawWindow is { IsVisible: true }) - _quickDrawWindow.Activate(); + // 重复触发时只把已有结果窗留在原位,不再 Activate 夺走前台焦点 transaction?.Finish(SpanStatus.Ok); } catch (Exception ex) @@ -2672,14 +2673,18 @@ private static Window GetOrCreateQuickDrawWindow() if (_quickDrawWindow is { IsLoaded: true }) return _quickDrawWindow; + var quickDrawPage = IAppHost.GetService(); _quickDrawWindow = new Window { - Content = IAppHost.GetService(), + Content = quickDrawPage, Title = @"SecRandom", MinWidth = 280, MinHeight = 160, SizeToContent = SizeToContent.WidthAndHeight, Topmost = true, + // 闪抽窗是被悬浮窗按钮叫出来的结果提示,绝不能把前台窗口(PPT、播放器)顶下去: + // 首次 Show 不激活,窗口特性里再补 NoActivate,连后续 Show/Hide 循环也不抢焦点。 + ShowActivated = false, WindowStartupLocation = WindowStartupLocation.CenterScreen, WindowDecorations = WindowDecorations.None, CanMinimize = false, @@ -2689,41 +2694,76 @@ private static Window GetOrCreateQuickDrawWindow() Background = Brushes.Transparent, TransparencyLevelHint = [WindowTransparencyLevel.Transparent] }; - _quickDrawWindow.Loaded += (_, _) => + // 结果卡片随名字长短变化时窗口会自适应缩放;每次尺寸变化都必须按新尺寸重新居中, + // 否则窗口以左上角为锚点扩张,视觉中心就会漂移——这正是"名字长短不一画面就跑偏"的根因。 + EventHandler resultLayoutChanged = (_, _) => RequestQuickDrawWindowCentering(_quickDrawWindow); + quickDrawPage.ResultLayoutChanged += resultLayoutChanged; + _quickDrawWindow.Opened += (_, _) => { - _quickDrawWindow.Width = 280; + // ShowActivated 只作用于首次显示;原生句柄就绪后补上 NoActivate, + // 之后每次复用窗口(Hide 再 Show)都不会再夺焦点。 + _quickDrawWindow.ApplyPlatformFeatures(WindowFeatures.NoActivate, enabled: true); + ApplyQuickDrawWindowBounds(_quickDrawWindow); + RequestQuickDrawWindowCentering(_quickDrawWindow); }; - _quickDrawWindow.Opened += (_, _) => + _quickDrawWindow.SizeChanged += (_, e) => RepositionQuickDrawWindow(_quickDrawWindow, e.NewSize); + _quickDrawWindow.ScalingChanged += (_, _) => { ApplyQuickDrawWindowBounds(_quickDrawWindow); - Dispatcher.UIThread.Post( - () => PositionOrCenterQuickDrawWindow(_quickDrawWindow), - DispatcherPriority.Render); + RequestQuickDrawWindowCentering(_quickDrawWindow); }; - _quickDrawWindow.SizeChanged += (_, _) => Dispatcher.UIThread.Post( - () => PositionOrCenterQuickDrawWindow(_quickDrawWindow), - DispatcherPriority.Render); - _quickDrawWindow.PositionChanged += (_, _) => ApplyQuickDrawWindowBounds(_quickDrawWindow); - _quickDrawWindow.ScalingChanged += (_, _) => ApplyQuickDrawWindowBounds(_quickDrawWindow); _quickDrawWindow.Closed += (_, _) => { + quickDrawPage.ResultLayoutChanged -= resultLayoutChanged; _quickDrawWindow = null; _quickDrawNotificationSettings = null; }; return _quickDrawWindow; } - private static void PositionOrCenterQuickDrawWindow(Window? window) + /// + /// 结果区布局稳定后请求一次居中:窗口尺寸在动画/换行过程中会分几步落定, + /// 单靠 SizeChanged 可能在最后一帧之后还有一次排版变化,因此在 Loaded 优先级补一次。 + /// + private static void RequestQuickDrawWindowCentering(Window? window) { - if (_quickDrawNotificationSettings is { } settings) - PositionQuickDrawNotificationWindow(window, settings); - else - CenterQuickDrawWindow(window); + if (window is null) + return; + + Dispatcher.UIThread.Post( + () => + { + if (!ReferenceEquals(window, _quickDrawWindow)) + return; + + RepositionQuickDrawWindow(window, window.Bounds.Size); + }, + DispatcherPriority.Loaded); + } + + private static void RepositionQuickDrawWindow(Window? window, Size logicalSize) + { + if (window is null || _positioningQuickDrawWindow) + return; + + try + { + _positioningQuickDrawWindow = true; + if (_quickDrawNotificationSettings is { } settings) + PositionQuickDrawNotificationWindow(window, settings, logicalSize); + else + CenterQuickDrawWindow(window, logicalSize); + } + finally + { + _positioningQuickDrawWindow = false; + } } private static void PositionQuickDrawNotificationWindow( Window? window, - NotificationChannelSettings settings) + NotificationChannelSettings settings, + Size? logicalSize = null) { if (window is null) return; @@ -2747,8 +2787,9 @@ private static void PositionQuickDrawNotificationWindow( return; var scale = window.RenderScaling; - var width = (int)Math.Round(window.Bounds.Width * scale); - var height = (int)Math.Round(window.Bounds.Height * scale); + var size = logicalSize ?? window.Bounds.Size; + var width = (int)Math.Round(size.Width * scale); + var height = (int)Math.Round(size.Height * scale); if (width <= 0 || height <= 0) return; @@ -2767,9 +2808,11 @@ private static void PositionQuickDrawNotificationWindow( 3 or 4 => area.Center.Y - height / 2, _ => area.Center.Y - height / 2 }; - window.Position = new PixelPoint( + var target = new PixelPoint( Math.Clamp(x + settings.HorizontalOffset, area.X, Math.Max(area.X, area.Right - width)), Math.Clamp(y + settings.VerticalOffset, area.Y, Math.Max(area.Y, area.Bottom - height))); + if (window.Position != target) + window.Position = target; } private static void ApplyQuickDrawWindowBounds(Window? window) @@ -2787,25 +2830,28 @@ private static void ApplyQuickDrawWindowBounds(Window? window) window.MaxHeight = Math.Max(window.MinHeight, screen.WorkingArea.Height / scale - edgePadding); } - private static void CenterQuickDrawWindow(Window? window) + private static void CenterQuickDrawWindow(Window? window, Size? logicalSize = null) { if (window is null) return; - var screen = window.Screens.ScreenFromWindow(window); + var screen = window.Screens.ScreenFromWindow(window) ?? window.Screens.Primary; if (screen is null) return; var scale = window.RenderScaling; - var width = (int)Math.Round(window.Bounds.Width * scale); - var height = (int)Math.Round(window.Bounds.Height * scale); + var size = logicalSize ?? window.Bounds.Size; + var width = (int)Math.Round(size.Width * scale); + var height = (int)Math.Round(size.Height * scale); if (width <= 0 || height <= 0) return; var area = screen.WorkingArea; - window.Position = new PixelPoint( + var target = new PixelPoint( area.X + (area.Width - width) / 2, area.Y + (area.Height - height) / 2); + if (window.Position != target) + window.Position = target; } #endregion diff --git a/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.Designer.cs b/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.Designer.cs index 0b3ef1aa6..128c68ca7 100644 --- a/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.Designer.cs +++ b/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.Designer.cs @@ -634,5 +634,41 @@ public static string O_StickDisplay_Arrow { return ResourceManager.GetString("O_StickDisplay_Arrow", resourceCulture); } } + + /// + /// Looks up a localized string similar to 自定义文字. + /// + public static string S_Display_CustomText { + get { + return ResourceManager.GetString("S_Display_CustomText", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 在悬浮窗按钮上方显示一行自定义文字,过长时自动省略. + /// + public static string S_Display_CustomText_D { + get { + return ResourceManager.GetString("S_Display_CustomText_D", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 自定义文字字号. + /// + public static string S_Display_CustomTextSize { + get { + return ResourceManager.GetString("S_Display_CustomTextSize", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 自定义文字的像素字号,0 表示跟随悬浮窗尺寸自动缩放. + /// + public static string S_Display_CustomTextSize_D { + get { + return ResourceManager.GetString("S_Display_CustomTextSize_D", resourceCulture); + } + } } } diff --git a/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.en-US.resx b/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.en-US.resx index 421946582..2cd4f26ab 100644 --- a/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.en-US.resx +++ b/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.en-US.resx @@ -51,5 +51,5 @@ Collapsed handle style Choose a roll-call icon, the word “Draw,” or a direction arrow for the edge handle Edge CollapseHandle sizeAdjust the square side length of the edge handle - IconTextArrow + IconTextArrowCustom textShow one line of custom text above the floating buttons, long text is trimmed to fit (120 characters max)Custom text sizeFont size of the custom text in pixels, 0 scales with the floating window diff --git a/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.ja-JP.resx b/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.ja-JP.resx index 64722778a..f063eed58 100644 --- a/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.ja-JP.resx +++ b/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.ja-JP.resx @@ -3,5 +3,5 @@ フローティングウィンドウウィンドウ表示起動時に表示起動後にフローティングウィンドウを自動で開きます不透明度メインとクイック抽選の両方に適用します最前面表示モード他のウィンドウより前に表示するか選択しますフローティングウィンドウの最前面方法を選択します、UIAccess は再起動時に管理者権限を要求し、取り消しまたは失敗した場合は今回の起動で通常の最前面表示を使用します最前面にしない最前面UIAccess 最前面 フローティング操作点呼点呼の入口を表示しますクイック抽選クイック抽選の入口を表示します抽選抽選の入口を表示しますタイマー 移動と端への吸着端に吸着ドラッグ終了時に現在の画面の左右どちらか近い端へ吸着しますドラッグ可能無効にすると本体とハンドルをドラッグできませんフォーカスを奪わないクリック時に現在のウィンドウからフォーカスを奪わないようにしますセミコロン ; で区切り、プロセス名が一致したら非表示にします(例: WeChat.exe)長押し判定時間前面プロセス名前面ウィンドウのタイトルまたはプロセス名が一致したら自動で非表示にします自動収納までの遅延長押し操作に必要なミリ秒数前面ウィンドウで非表示吸着後に収納するまでの秒数で、0 で展開したままにしますセミコロン ; で区切り、タイトルに一致する文字があれば非表示にします前面ウィンドウタイトル - 表示する操作メインウィンドウに表示する機能を選択します操作の配置矩形は2列、縦と横は1列で配置します矩形縦横表示形式アイコン、文字、または両方を選択しますアイコンと文字アイコンのみ文字のみ操作のサイズ各操作の正方形サイズを調整します極小とても小さい小中大とても大きい極大収納ハンドルの表示点呼アイコン、「抽選」の文字、または矢印を選択します端への収納ハンドルサイズ収納ハンドルの正方形サイズを調整しますアイコン文字矢印 + 表示する操作メインウィンドウに表示する機能を選択します操作の配置矩形は2列、縦と横は1列で配置します矩形縦横表示形式アイコン、文字、または両方を選択しますアイコンと文字アイコンのみ文字のみ操作のサイズ各操作の正方形サイズを調整します極小とても小さい小中大とても大きい極大収納ハンドルの表示点呼アイコン、「抽選」の文字、または矢印を選択します端への収納ハンドルサイズ収納ハンドルの正方形サイズを調整しますアイコン文字矢印カスタムテキストフローティングウィンドウのボタン上に 1 行のカスタムテキストを表示し、長い場合は省略します(最大 120 文字)カスタムテキストの文字サイズカスタムテキストのピクセル文字サイズで、0 はフローティングウィンドウのサイズに合わせて自動調整します diff --git a/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.resx b/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.resx index f32517258..c1b8dc5d4 100644 --- a/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.resx +++ b/SecRandom/Langs/SettingsPages/FloatingWindow/Resources.resx @@ -213,4 +213,16 @@ 箭头 + + 自定义文字 + + + 在悬浮窗按钮上方显示一行自定义文字,过长时自动省略(最多 120 字) + + + 自定义文字字号 + + + 自定义文字的像素字号,0 表示跟随悬浮窗尺寸自动缩放 + diff --git a/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx b/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx index b959fe0dd..eae12f3d8 100644 --- a/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx +++ b/SecRandom/Langs/SettingsPages/Linkage/Resources.en-US.resx @@ -10,13 +10,13 @@ Allow verified continuation - When off-class restrictions are enabled, allow temporary drawing or reset after configured security verification; otherwise block directly + When off-class restrictions are enabled, allow temporary drawing or reset after configured security verification; when disabled, breaks are blocked outright with a notice Restrict off-class operations - Restrict drawing and reset when course data confirms a break or non-class period; disable for no restriction + Block drawing and reset in roll-call, quick draw and lottery while the course source confirms a break or after-class period; an unavailable or unknown source is not blocked; turn off for no restriction Data source @@ -123,6 +123,8 @@ Enable operations this many seconds before class + Drawing is blocked during breaks and after class; the next class “{0}” starts in {1} minutes + Drawing is blocked during breaks and after class {0} classes, from {1:HH:mm} to {2:HH:mm} The CSES timetable file is empty The CSES timetable file root must be an object diff --git a/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx b/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx index d7c086966..cf8740b5c 100644 --- a/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx +++ b/SecRandom/Langs/SettingsPages/Linkage/Resources.ja-JP.resx @@ -10,13 +10,13 @@ 認証後の続行を許可 - 授業外制限が有効な場合、設定したセキュリティ認証後に一時的な抽選やリセットを許可します + 授業外制限が有効な場合、設定したセキュリティ認証後に一時的な抽選やリセットを許可します(オフの場合は休み時間を直接阻止して通知を表示します) 授業外の操作を制限 - 休み時間や授業外と確認された場合に抽選とリセットを制限します + データソースが休み時間や下校後と確認している間は点名・即時抽選・抽選の開始とリセットを禁止します(データソースが切断または状態不明の場合は制限しません/オフにすると制限しません) データソース @@ -123,6 +123,8 @@ 授業の何秒前に制限を解除するか指定します + 休み時間または下校後のため抽選できません。次の「{0}」まであと {1} 分です + 休み時間または下校後のため抽選できません 授業は {0} 件({1:HH:mm}~{2:HH:mm})です CSES 時間割ファイルが空です CSES 時間割ファイルのルートはオブジェクトである必要があります diff --git a/SecRandom/Langs/SettingsPages/Linkage/Resources.resx b/SecRandom/Langs/SettingsPages/Linkage/Resources.resx index 2acaa5e58..3a220f463 100644 --- a/SecRandom/Langs/SettingsPages/Linkage/Resources.resx +++ b/SecRandom/Langs/SettingsPages/Linkage/Resources.resx @@ -25,13 +25,13 @@ 允许验证后继续 - 仅在已启用非上课时段限制时生效;开启后,通过已配置的安全验证可以临时继续抽取或重置;关闭后将直接阻止 + 仅在已启用非上课时段限制时生效;开启后,通过已配置的安全验证可以临时继续抽取或重置;关闭后课间直接阻止并给出提示 限制非上课时段操作 - 课程数据源明确显示处于课间或非上课时段时,限制抽取和重置;关闭后不作限制 + 课程数据源确认处于课间或下课期间时,禁止点名、即时抽取与抽奖的抽取和重置;数据源断开或状态未知时不拦截;关闭后不作限制 数据源 @@ -138,6 +138,8 @@ 上课前多少秒提前解除课间禁用 + 当前处于课间或下课期间,暂时不能抽取;下一节“{0}”还有 {1} 分钟 + 当前处于课间或下课期间,暂时不能抽取 共 {0} 节课,最早 {1:HH:mm},最晚 {2:HH:mm} CSES 课程表文件为空 CSES 课程表文件的根节点必须是对象 diff --git a/SecRandom/Langs/SettingsPages/Picking/Resources.Designer.cs b/SecRandom/Langs/SettingsPages/Picking/Resources.Designer.cs index 34e282171..e17bcca74 100644 --- a/SecRandom/Langs/SettingsPages/Picking/Resources.Designer.cs +++ b/SecRandom/Langs/SettingsPages/Picking/Resources.Designer.cs @@ -743,6 +743,24 @@ public static string S_DisableAfterClick_D { } } + /// + /// Looks up a localized string similar to 显示抽取中状态. + /// + public static string S_ShowDrawingStatus { + get { + return ResourceManager.GetString("S_ShowDrawingStatus", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to 在闪抽窗口显示“正在抽取”,关闭后抽取期间画面保持上一轮内容,结果落定才刷新. + /// + public static string S_ShowDrawingStatus_D { + get { + return ResourceManager.GetString("S_ShowDrawingStatus_D", resourceCulture); + } + } + /// /// Looks up a localized string similar to 显示格式. /// diff --git a/SecRandom/Langs/SettingsPages/Picking/Resources.en-US.resx b/SecRandom/Langs/SettingsPages/Picking/Resources.en-US.resx index bb49106a0..2cea37733 100644 --- a/SecRandom/Langs/SettingsPages/Picking/Resources.en-US.resx +++ b/SecRandom/Langs/SettingsPages/Picking/Resources.en-US.resx @@ -31,6 +31,8 @@ The prize pool used by default when opening the lottery page Disable After Click Seconds to disable the button after a Quick Draw + Show drawing status + Show the drawing status line while a Quick Draw is running; when off the window stays steady until the result lands Font Source Follow the global font or specify a font for draw results Custom Font diff --git a/SecRandom/Langs/SettingsPages/Picking/Resources.ja-JP.resx b/SecRandom/Langs/SettingsPages/Picking/Resources.ja-JP.resx index 517f1224d..2865bf191 100644 --- a/SecRandom/Langs/SettingsPages/Picking/Resources.ja-JP.resx +++ b/SecRandom/Langs/SettingsPages/Picking/Resources.ja-JP.resx @@ -93,6 +93,12 @@ クイック抽選後にボタンを無効にする秒数 + + 抽選中のステータスを表示 + + + クイック抽選の実行中に「抽選中」を表示します。オフにすると結果が出るまで画面は前回の内容のまま変わりません + フォントの指定元 diff --git a/SecRandom/Langs/SettingsPages/Picking/Resources.resx b/SecRandom/Langs/SettingsPages/Picking/Resources.resx index cfd1cb8e9..8a2c81b39 100644 --- a/SecRandom/Langs/SettingsPages/Picking/Resources.resx +++ b/SecRandom/Langs/SettingsPages/Picking/Resources.resx @@ -41,6 +41,8 @@ 打开抽奖页时默认使用的奖池 点击后禁用时间 点击一次闪抽后禁用按钮的秒数 + 显示抽取中状态 + 在闪抽窗口显示“正在抽取”,关闭后抽取期间画面保持上一轮内容,结果落定才刷新 字体来源 跟随全局字体或为抽取结果单独指定字体 自定义字体 diff --git a/SecRandom/Services/Linkage/AGENTS.md b/SecRandom/Services/Linkage/AGENTS.md new file mode 100644 index 000000000..15b2c65a0 --- /dev/null +++ b/SecRandom/Services/Linkage/AGENTS.md @@ -0,0 +1,68 @@ +# SecRandom/Services/Linkage/ AGENTS.md + + + +## OVERVIEW + +课程联动运行层:CSES 课表解析/存储、ClassIsland IPC 适配、课程边界调度、 +抽取授权闸门、课前重置。**课间与课下的抽取禁用就在这一层判定**,页面只问 +`LinkageDrawCoordinator`,不自己读课表状态。 + +## STRUCTURE + +``` +SecRandom/Services/Linkage/ +|-- ICourseScheduleSource.cs # 课表源契约:快照 + StateChanged +|-- CourseScheduleMath.cs # 纯函数:按课表算当前/上节/下节与倒计时 +|-- CsesScheduleParser.cs # CSES YAML -> 课程列表(纯解析) +|-- CsesScheduleStore.cs # data/CSES/cses_schedule.yml 的读写与校验 +|-- CsesScheduleSource.cs # CSES 源:存储变更 -> 快照 +|-- ICsesScheduleStore.cs # CSES 存储契约(页面只认这个) +|-- ClassIslandIpcConnection.cs # 唯一 IPC 管道:课程查询 + 上下课通知 + 重连退避 +|-- ClassIslandScheduleSource.cs # ClassIsland -> CourseScheduleSnapshot +|-- CourseLinkageService.cs # 单例状态:快照、闸门判定、刷新调度、科目过滤 +|-- CourseLinkageHostedService.cs # 唯一课程边界调度器 + 课前重置执行 +|-- LinkageDrawCoordinator.cs # 抽取授权闸门(本地/协议/快捷键共用) +|-- ClassTimeDrawRestrictionNotice.cs # 课间被拦时的本地化 toast(5 秒节流) +``` + +## WHERE TO LOOK + +| Task | Location | Notes | +|------|----------|-------| +| 课间/课下禁令是否在生效 | `CourseLinkageService.IsConfirmedNonClassTime` | `InstantDrawDisable` 开 **且** `IsConfirmedBreakTime`(快照确认 `CourseTimeState.Breaking`、且不在课前解禁/课后延迟窗口内)。 | +| 数据源是否给不出判定 | `CourseScheduleSnapshot.IsAvailable` / `Unavailable(...)` | ClassIsland 断连、计时器停、课表未加载、CSES 课表缺失都会返回 `IsAvailable == false` 的快照;`IsConfirmedBreakTime` 因此恒为假,禁令自动放行。 | +| 为什么点不动 | `LinkageDrawCoordinator.AuthorizeAsync` | `VerificationRequired == false` 时课间是硬拒绝,拒绝前调 `ClassTimeDrawRestrictionNotice.Show(snapshot)` 发提示。 | +| 远程命令的判定 | `LinkageDrawCoordinator.EvaluateGate` | 无交互判定,**刻意不弹提示**:教室机可能无人,弹窗会让远程命令挂死。`BlockedByClassTime` 由 `ControlDrawTriggerHandler` 转成 `denied(blocked_by_class_time)`。 | +| 上下课事件 | `ClassIslandIpcConnection` | 订阅 `OnClassNotifyId` / `OnBreakingTimeNotifyId` / `OnAfterSchoolNotifyId`,`StateChanged` → 两个源 → `CourseLinkageService.RefreshAsync`。 | +| 课前重置 | `CourseLinkageHostedService.TryPerformPreClassReset` | `IsPreClassResetDue` 给出的 key 去重,清临时记录并让三个页面 `ResetForCourseLinkage()`。 | +| 提示节流 | `ClassTimeDrawRestrictionNotice.ThrottleWindow` | 5 秒一次,连点按钮不刷屏;`MainView.Current` 为空时静默跳过。 | + +## CONVENTIONS + +- 上课/下课/课间/放学的时间判定只在 `CourseScheduleMath`(CSES)与 + `ClassIslandScheduleSource`(IPC 状态映射)里做,别在调用点重算时间窗。 +- 课间禁令的开关是 `LinkageSettingsConfig.InstantDrawDisable`(默认 `false`), + 与之配套的 `VerificationRequired`(默认 `true`)决定课间是"验证后放行"还是"直接阻止"。 + 新增开关一律默认关闭,并在 `LinkageSettingsPage` 与三语资源同步暴露。 +- 数据源不可用(`CourseScheduleSnapshot.Unavailable`)必须放行。断连不是下课, + 把不可用当禁课会让人在教室外白等一整天——这条在 issue 里被反复踩过。 +- 用户可见的拦截文案放 `SecRandom/Langs/SettingsPages/Linkage/Resources*.resx`: + `M_ClassTimeDrawBlocked`(带下一节课与分钟数)与 `M_ClassTimeDrawBlockedGeneric`。 + 取值走 `Resources.ResourceManager.GetString(key, Resources.Culture)`,不要手改 + `Resources.Designer.cs`。 +- 快照比较只看语义字段(`SnapshotsEqual`),排除倒计时,否则每次刷新都会自激(issue #274)。 +- ClassIsland 类型只允许出现在 `ClassIslandScheduleSource` 与 `ClassIslandIpcConnection`; + 其他文件接触它就是把 IPC 依赖漏进业务层。 + +## ANTI-PATTERNS + +- 不要在页面 ViewModel 里写 `if (在课间) return;`:三个抽取入口(点名/即时抽取/抽奖) + 已经全部经过 `LinkageDrawCoordinator`,散写判断迟早漏掉协议与快捷键路径。 +- 不要用 `EvaluateGate` 代替 `AuthorizeAsync` 执行抽取:它只回答"会不会弹验证框"。 +- 不要因为 `PeerConnectionBroken` 就把状态钉成 `Breaking` 或永久禁用;重连退避是 + 5 秒到 5 分钟,期间的快照必须是 `Unavailable`。 +- 不要在这里直接调平台窗口 API(浮窗隐藏归浮窗服务,见 ../../AGENTS.md)。 diff --git a/SecRandom/Services/Linkage/ClassTimeDrawRestrictionNotice.cs b/SecRandom/Services/Linkage/ClassTimeDrawRestrictionNotice.cs new file mode 100644 index 000000000..31c7e7114 --- /dev/null +++ b/SecRandom/Services/Linkage/ClassTimeDrawRestrictionNotice.cs @@ -0,0 +1,73 @@ +using System; +using System.Globalization; +using FluentAvalonia.UI.Controls; +using SecRandom.Core.Models.Linkage; +using SecRandom.Views; +using LR = SecRandom.Langs.SettingsPages.Linkage.Resources; + +namespace SecRandom.Services.Linkage; + +/// +/// “课间与下课禁止抽取”被拦下时的用户提示。 +/// +/// +/// 点名、即时抽取、抽奖三处入口都只经过 这一道门, +/// 所以提示也只在这里发一次,不往各页面里散写判空。集控远程路径走 +/// ,它刻意不提示 —— 教室机可能没人, +/// 弹提示只会让远程命令一直挂在没人看的窗口上。 +/// +/// 没有活动主壳(手机单视图、浮窗单独抽、宿主尚未建窗口)时 为空, +/// 提示静默跳过,拦截结果不受影响; 内部会自己切到 UI 线程。 +/// +/// +/// 节流窗口 内的重复拦截只提示一次,按住按钮连点不会刷屏。 +/// +/// +internal static class ClassTimeDrawRestrictionNotice +{ + private static readonly TimeSpan ThrottleWindow = TimeSpan.FromSeconds(5); + private static readonly object ThrottleGate = new(); + private static long _lastShownAt = long.MinValue; + + /// 提示一次“当前不能抽取”,并带上下一节课的恢复时间。 + public static void Show(CourseScheduleSnapshot snapshot) + { + ArgumentNullException.ThrowIfNull(snapshot); + + if (!TryEnterThrottleWindow()) + return; + + MainView.ShowToast(BuildMessage(snapshot), FAInfoBarSeverity.Warning); + } + + internal static string BuildMessage(CourseScheduleSnapshot snapshot) + { + var nextCourseName = snapshot.NextCourse?.Name; + var minutesUntilNext = snapshot.TimeUntilNextCourse is { } untilNext && untilNext > TimeSpan.Zero + ? (int)Math.Ceiling(untilNext.TotalMinutes) + : 0; + + if (!string.IsNullOrWhiteSpace(nextCourseName) && minutesUntilNext > 0) + return string.Format(CultureInfo.CurrentCulture, Text("M_ClassTimeDrawBlocked"), nextCourseName, + minutesUntilNext); + + return Text("M_ClassTimeDrawBlockedGeneric"); + } + + private static string Text(string key) + { + return LR.ResourceManager.GetString(key, LR.Culture) ?? key; + } + + private static bool TryEnterThrottleWindow() + { + var now = Environment.TickCount64; + lock (ThrottleGate) + { + if (_lastShownAt != long.MinValue && now - _lastShownAt < (long)ThrottleWindow.TotalMilliseconds) + return false; + _lastShownAt = now; + return true; + } + } +} diff --git a/SecRandom/Services/Linkage/CourseLinkageService.cs b/SecRandom/Services/Linkage/CourseLinkageService.cs index 4028630ce..d171b40c7 100644 --- a/SecRandom/Services/Linkage/CourseLinkageService.cs +++ b/SecRandom/Services/Linkage/CourseLinkageService.cs @@ -105,6 +105,13 @@ private static bool SnapshotsEqual(CourseScheduleSnapshot a, CourseScheduleSnaps && _snapshot.State == CourseTimeState.Breaking && !IsWithinEnableWindow(_snapshot); + /// + /// “课间与下课禁止抽取”此刻是否正在生效:设置开启,快照确认处于课间,且不在课前解禁/课后延迟窗口内。 + /// + /// + /// 为假(ClassIsland 断连、计时器停、课表未加载、 + /// CSES 课表缺失)时这里恒为假——断连不是“下课”,不可用必须放行,否则会让人在教室外白等一整天。 + /// public bool IsConfirmedNonClassTime => Settings.InstantDrawDisable && IsConfirmedBreakTime; diff --git a/SecRandom/Services/Linkage/LinkageDrawCoordinator.cs b/SecRandom/Services/Linkage/LinkageDrawCoordinator.cs index 83413fa67..66021ae90 100644 --- a/SecRandom/Services/Linkage/LinkageDrawCoordinator.cs +++ b/SecRandom/Services/Linkage/LinkageDrawCoordinator.cs @@ -53,7 +53,12 @@ public async Task AuthorizeAsync( return await securityService.AuthorizeAsync(operations, action, cancellationToken).ConfigureAwait(false); if (!linkageService.Settings.VerificationRequired) + { + // 不允许验证绕过时这里是课间唯一的人机拒绝点:页面调用方拿到 false 只会静默收手, + // 所以提示必须在这里发,否则用户点了按钮毫无反应。 + ClassTimeDrawRestrictionNotice.Show(linkageService.Snapshot); return false; + } return await securityService.AuthorizeAsync( [.. operations, SecurityOperation.BypassClassTimeRestriction], diff --git a/SecRandom/Services/Settings/SettingsSearchService.cs b/SecRandom/Services/Settings/SettingsSearchService.cs index 9545fb111..82e87165d 100644 --- a/SecRandom/Services/Settings/SettingsSearchService.cs +++ b/SecRandom/Services/Settings/SettingsSearchService.cs @@ -88,7 +88,7 @@ public class SettingsSearchService ["settings.picking.quickDraw"] = new HashSet { "S_DrawMode", "S_HalfRepeat", "S_DrawType", "S_DefaultClass", "S_DisableAfterClick", - "S_QuickDrawHistoryGroup", "S_FontSource", + "S_ShowDrawingStatus", "S_QuickDrawHistoryGroup", "S_FontSource", "S_CustomFont", "S_FontSize", "S_DisplayFormat", "S_ShowTags", "S_Animation", "S_AnimationInterval", "S_AutoplayCount", "S_AnimationStyle", "S_ColorTheme", "S_FixedColor", "S_StudentImage", "S_StudentImagePosition", "S_StudentImageSize", "S_AnimationMusic", "S_ResultMusic", diff --git a/SecRandom/Services/Verification/DrawProofAttestationService.cs b/SecRandom/Services/Verification/DrawProofAttestationService.cs index 009238760..06764424c 100644 --- a/SecRandom/Services/Verification/DrawProofAttestationService.cs +++ b/SecRandom/Services/Verification/DrawProofAttestationService.cs @@ -37,6 +37,10 @@ public sealed class DrawProofAttestationService( private static readonly TimeSpan MaximumRetryDelay = TimeSpan.FromMinutes(15); private static readonly TimeSpan DisabledModePollInterval = TimeSpan.FromSeconds(30); + // SemaphoreSlim.WaitAsync(TimeSpan) only accepts -1ms, 0, or up to int.MaxValue milliseconds. + // A terminal queue item parks on DateTimeOffset.MaxValue, so the loop's own delay must be capped. + private static readonly TimeSpan MaximumWaitDelay = TimeSpan.FromDays(1); + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { WriteIndented = true, @@ -145,7 +149,7 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) while (!stoppingToken.IsCancellationRequested) { - var delay = GetNextDelay(); + var delay = ClampWaitDelay(GetNextDelay()); try { await _wake.WaitAsync(delay, stoppingToken).ConfigureAwait(false); @@ -172,11 +176,32 @@ private TimeSpan GetNextDelay() if (!IsEnabled) return DisabledModePollInterval; - var delay = _pending.Values.Min(item => item.NextAttemptUtc) - DateTimeOffset.UtcNow; + // Terminal items (retry exhausted / conflict) park on DateTimeOffset.MaxValue to mean + // "never retry automatically". Taking the minimum over them would otherwise ask the + // loop for a wait of roughly eight thousand years and take the whole host down. + var next = DateTimeOffset.MaxValue; + foreach (var item in _pending.Values) + { + if (item.NextAttemptUtc < next) + next = item.NextAttemptUtc; + } + + if (next == DateTimeOffset.MaxValue) + return Timeout.InfiniteTimeSpan; + + var delay = next - DateTimeOffset.UtcNow; return delay <= TimeSpan.Zero ? TimeSpan.Zero : delay; } } + private static TimeSpan ClampWaitDelay(TimeSpan delay) + { + if (delay == Timeout.InfiniteTimeSpan || delay <= MaximumWaitDelay) + return delay; + + return MaximumWaitDelay; + } + private async Task ProcessDueAsync(bool force, CancellationToken stoppingToken) { if (!IsEnabled) diff --git a/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs b/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs index 4680007da..881c96df9 100644 --- a/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs +++ b/SecRandom/ViewModels/MainPages/QuickDrawPageViewModel.cs @@ -237,7 +237,8 @@ private async Task StartDrawCoreAsync( const int count = 1; ResultItems.Clear(); IsResultVisible = false; - StatusText = QuickDrawResources.M_Drawing; + // 关闭后抽取期间不显示"正在抽取":动画已经在动,多一行字反而会在结果落定前跳一次版 + StatusText = Config.QuickDrawSettings.ShowDrawingStatus ? QuickDrawResources.M_Drawing : string.Empty; _notificationAutoCloseTime = null; SetDrawCommandRunning(true); try diff --git a/SecRandom/Views/FloatingWindow.axaml b/SecRandom/Views/FloatingWindow.axaml index bb9546786..d0e5090fd 100644 --- a/SecRandom/Views/FloatingWindow.axaml +++ b/SecRandom/Views/FloatingWindow.axaml @@ -30,6 +30,17 @@ + +