## 8. 自动化(触发器 / 行动 / 规则集) 本节规定「自动化(Automation)」系统的完整实现:配置模型、加载与生命周期、触发与恢复语义、全部内置触发器、全部内置行动、规则集求值与全部内置规则、时间点行动,以及设置页 UI 与安全提示。自动化必须建立在第 6 节的课程服务与第 7 节的提醒之上,**不得**为其另建并行状态。 ### 8.1 概念与总开关 - **工作流(Workflow)** = 一组触发器(Triggers) + 可选规则集(Ruleset) + 一个行动组(ActionSet)。 - **触发器(Trigger)** = 事件源,触发时发「执行」信号,恢复时发「恢复」信号。 - **行动(Action)** = 可执行单元,具备 `Invoke` 与(可选)`Revert`。 - **规则集(Ruleset)** = 布尔表达式,决定这次触发是否真的执行。 - **自动化配置方案(Automation Config)**:一组工作流存成一个 JSON,位于 `Automations/{方案名}.json`,默认方案名 `Default`(`repos/ClassIsland/ClassIsland/Services/AutomationService.cs:27-33`;`repos/ClassIsland/ClassIsland/Models/Settings.cs:162`:`_currentAutomationConfig = "Default"`)。 - 总开关 `Settings.IsAutomationEnabled`,默认 **`false`**(Settings.cs:160);首次进入设置页必须展示「使用须知」页(`Settings.IsAutomationWarningVisible` 默认 **`true`**,Settings.cs:161),用户点「同意并开始使用自动化」后写入 `IsAutomationEnabled=true` 且 `IsAutomationWarningVisible=false`(`repos/ClassIsland/ClassIsland/Views/SettingPages/AutomationSettingsPage.axaml.cs:161-166`)。 - **安全模式(Safe Mode)下禁止加载与执行自动化**:`AutomationService` 构造函数中 `if (App.ApplicationCommand.Safe) return;` 之后的代码(时间点行动订阅、规则集订阅)都不执行,`LoadConfig` 同样提前返回,即只读配置不装载触发器(AutomationService.cs:76-82,193-201)。实现时必须有等价的 `--safe-mode` 分支。 ### 8.2 数据结构(必须逐字段实现) #### 8.2.1 工作流 Workflow ``` { "Triggers": [ TriggerSettings, ... ], // 默认 [] "IsConditionEnabled": false, // 是否启用规则集,默认 false "Ruleset": Ruleset, // 默认 new Ruleset() "ActionSet": ActionSet // 默认 new ActionSet() } ``` 证据:`repos/ClassIsland/ClassIsland.Core/Models/Automation/Workflow.cs:14-29`。三者都是可观察对象,UI 改动立即反映到模型并触发保存。 #### 8.2.2 触发器设置 TriggerSettings `{ "Id": string, "Settings": object|null }`(`repos/ClassIsland/ClassIsland.Core/Models/Automation/TriggerSettings.cs:14-35`)。`Id` 变化时必须**卸载旧触发器实例并重新加载**(AutomationService.cs:360-369 的 `TriggerOnPropertyChanged`),因为不同触发器类型的设置类型不同。`AssociatedTriggerInfo` 与 `TriggerInstance` 是 `[JsonIgnore]` 运行期字段。 #### 8.2.3 行动组 ActionSet | 字段 | 类型 | 默认值 | 说明 | |---|---|---|---| | `Name` | string | `"新行动组"` | 名称/备注 | | `Actions` | ActionItem[] | `[]` | 注意 JSON 键名是 **`Actions`**(`[JsonPropertyName("Actions")]`),不是 `ActionItems` | | `IsEnabled` | bool | `true` | 行动组启用 | | `IsRevertEnabled` | bool | **`false`** | 是否启用恢复;关闭时若状态是 `IsOn` 立即回 `Normal` | | `Status` | `Normal/Invoking/IsOn/Reverting` | `Normal` | 运行状态(自定义 JSON 转换器,序列化为可读字符串) | | `Guid` | Guid | `Guid.NewGuid()` | 用于「设置叠层」标识(见 8.5.5) | 证据:`repos/ClassIsland/ClassIsland.Shared/Models/Automation/ActionSet.cs:77-128`;状态枚举 `repos/ClassIsland/ClassIsland.Shared/Enums/ActionSetStatus.cs:6-24`;`IsWorking = Status ∈ {Invoking, Reverting}`(ActionSet.cs:110)。 `ActionSet` 的生命周期方法(必须实现,行动服务会调用): - `SetStartRunning(isInvoke)`:取消并释放上一次的 `InterruptCts`、把上一次 `RunningTcs` 置为取消;新建二者;`Status = isInvoke ? Invoking : Reverting`;把所有行动项 `IsCompleted=false`。 - `SetEndRunning(isInvoke)`:读取「是否被中断」→ 状态迁移: - 未中断:`isInvoke && IsRevertEnabled ? IsOn : Normal`; - 被中断且原状态 `Invoking` → `Normal`; - 被中断且原状态 `Reverting` → `IsOn`; - 其他保持。 释放 `InterruptCts`,`RunningTcs.TrySetResult`。 (ActionSet.cs:20-70。这两处状态迁移必须逐字实现,否则「恢复」会错乱。) #### 8.2.4 行动项 ActionItem `{ "Id": string, "Settings": object|null }`,运行期字段:`IsCompleted`、`IsWorking`、`Exception`、`Progress`(0~100, null 表示未报告)。`IsRevertEnabled` 恒为 `true`、`IsRevertActionItem` 恒为 `false`(v2 已弃用这两个字段的语义,源码用 `[JsonIgnore]` 固定返回,见 `repos/ClassIsland/ClassIsland.Shared/Models/Automation/ActionItem.cs:54-99`)。实现时必须保留 `Actions` 数组顺序 = 执行顺序。 #### 8.2.5 规则集 Ruleset / 规则组 RuleGroup / 规则 Rule ``` Ruleset { "Mode": "Or" | "And", // 默认 "Or" "IsReversed": false, // 取反,默认 false "Groups": [ RuleGroup ] // 默认 [ { "Rules": [ {} ], "Mode": "And", "IsEnabled": true } ] } RuleGroup { "Rules": [ Rule ], "Mode": "And"|"Or"(默认 And), "IsReversed": false, "IsEnabled": true } Rule { "Id": "", "Settings": null, "IsReversed": false } ``` 证据:`repos/ClassIsland/ClassIsland.Core/Models/Ruleset/Ruleset.cs:14-17,22-59`、`RuleGroup.cs:13-73`、`Rule.cs:11-56`、`repos/ClassIsland/ClassIsland.Core/Enums/RulesetLogicalMode.cs:6-15`。`State`(0=未判定、1=假、2=真)是 `[JsonIgnore]` 运行期字段,用于 UI 高亮。 ### 8.3 加载与生命周期(必须实现资源纪律) `AutomationService` 的加载规则(`AutomationService.cs:173-202,237-436`): 1. **读配置**:配置不存在 → 用空工作流数组新建并立即保存(`note="新建自动化配置"`)。 2. **安全模式**:直接返回,不装载触发器。 3. 逐个 `LoadWorkflow`:订阅 `Triggers.CollectionChanged`、`ActionSet.PropertyChanged`、`Unloading`;对每个触发器 `LoadTrigger`。 4. **卸载**(`UnloadWorkflow`/`UnloadTrigger`):必须调用触发器的 `UnLoaded()`、退订全部事件、把 `TriggerInstance` 置 null。源码注释明确「否则可能造成内存泄漏」(`repos/ClassIsland/ClassIsland.Core/Abstractions/Automation/TriggerBase.cs:35-39`)。 5. 触发器 `Id` 改变 → 卸载再加载;`Workflows` 集合增删 → 增量加载/卸载。 6. **切换配置方案**:`Settings.CurrentAutomationConfig` 变化时先 `InterruptAllWorkflows()`(对每个工作流 `InterruptActionSetAsync`)再保存旧配置,然后加载新配置(`AutomationService.cs:59-74`)。 7. **关闭自动化总开关**:`IsAutomationEnabled` 变 false 时 `InterruptAllWorkflows()`(71-73)。 8. **行动组状态变化**:`ActionSet.Status` 变化且该组不在运行中时保存配置(`ActionSetOnPropertyChanged`,218-223),保证 `IsOn` 状态跨重启可恢复。 9. 触发器实例化:按 `Id` 从 DI 的键控服务取 `TriggerBase`;若触发器带设置控件且控件基类的泛型参数为设置类型,则把 JSON `JsonElement` 反序列化为该类型实例后注入(`ActivateTrigger`,394-436)。实现时等价于「`actionId → { providerClass, settingsSchema }` 注册表 + 反序列化」。 ### 8.4 执行语义(触发 / 规则 / 恢复 / 中断) #### 8.4.1 触发路径(必须逐步实现,`AutomationService.cs:117-133`) ``` onTrigger(trigger): if (!Settings.IsAutomationEnabled) return workflow = trigger.AssociatedWorkflow if (!workflow.ActionSet.IsEnabled) return if (workflow.ActionSet.IsRevertEnabled && workflow.ActionSet.Status != Normal) return // 已在运行/已开启则不重复执行 if (workflow.IsConditionEnabled && !RulesetService.IsRulesetSatisfied(workflow.Ruleset)) return ActionService.InvokeActionSetAsync(workflow.ActionSet) ``` #### 8.4.2 恢复路径(触发器主动恢复,`AutomationService.cs:138-151`) ``` onTriggerRevert(trigger): if (!Settings.IsAutomationEnabled) return if (workflow.ActionSet.Status != IsOn) return ActionService.RevertActionSetAsync(workflow.ActionSet) ``` #### 8.4.3 规则集变化导致的自动恢复(`AutomationService.cs:156-167`) 监听 `RulesetService.StatusUpdated`(由任意规则相关状态变化触发,见 8.4.5):对所有满足 `ActionSet.Status==IsOn && ActionSet.IsRevertEnabled && IsConditionEnabled` 且**当前规则集不再满足**的工作流执行 `RevertActionSetAsync`。 #### 8.4.4 行动执行的顺序与容错(`repos/ClassIsland/ClassIsland/Services/ActionService.cs:20-135`) `InvokeActionSetAsync(actionSet, isRevertable = true)`: 1. 若状态是 `Reverting` → 先 `InterruptActionSetAsync`。 2. 若 `IsWorking` → 直接返回(**不排队**)。 3. `MigrateActionSet`(迁移旧格式,见 8.5.6)。 4. `SetStartRunning(true)`。 5. 逐项执行 `ActionItems.Where(x => !x.IsRevertActionItem)`,每项 `InvokeActionItemAsync(x, actionSet, isRevertable && actionSet.IsRevertEnabled)`。 6. `finally { SetEndRunning(true) }`。 `RevertActionSetAsync(actionSet)`: 1. 若状态是 `Invoking` → 先中断。 2. 若 `IsWorking` → 返回。 3. `SetStartRunning(false)`。 4. 逐项执行 `ActionItems.Where(x => x.IsRevertActionItem || x.IsRevertEnabled)`:`IsRevertActionItem` 的项**执行其 Invoke**(它是「恢复专用行动项」),`IsRevertEnabled` 的项执行 `RevertActionItemAsync`。 5. `finally { SetEndRunning(false) }`。 `InvokeActionItemAsync` 的守卫(93-112): - 行动组已被取消(`InterruptCts.Token.IsCancellationRequested != false`)→ 跳过。 - 取不到提供方实例 / 找不到 `ActionInfos[Id]` → 跳过(不打断后续项)。 - `try { await provider.InvokeAsync(...) } catch { LogError }` —— **单个行动失败不得中断整个行动组**。 - `RevertActionItemAsync` 额外要求 `actionInfo.IsRevertable == true`,否则跳过(114-135)。 `ChangeableListForEachAsync`(137-173):执行过程中**允许行动列表被用户实时改动**。实现要求:每次迭代后重新取列表;列表发生变化时按「当前项在新列表中的索引 + 1」继续;当前项被删除则继续执行下一个;被取消则立即停止。这一步不能简单用 `for (i...)`,否则用户删除项会导致跳项或越界。 `InterruptActionSetAsync`(77-91):`await InterruptCts.CancelAsync()`,再 `await RunningTcs.Task`(等运行真正退出),异常只记日志。 **行动提供方的运行契约**(`repos/ClassIsland/ClassIsland.Core/Abstractions/Automation/ActionBase.cs:20-148`): - `OnInvoke()` / `OnRevert()` 必须先 `await base.OnInvoke()` / `base.OnRevert()`。 - 执行期间由框架设置 `ActionItem`、`ActionSet`、`IsRevertable`、`SettingsInternal`、`InterruptCts`;`SetStartRunning()` 清空异常与进度并置 `IsWorking=true`,`SetEndRunning()` 置 `IsCompleted=true`。 - 可长时运行的行动必须用 `InterruptCancellationToken` 响应中断,并实现 `OnInterrupted()`(或等价取消)。 - 行动抛出的异常会被记录到 `ActionItem.Exception`(供 UI 显示)并继续向外抛,由 `ActionService` 捕获记日志。 - 行动结束(含异常)后 `Progress` 必须清空。 #### 8.4.5 规则状态何时通知 任何影响规则结果的状态变化都必须调用 `NotifyStatusChanged()` 触发 `RulesetService.StatusUpdated`。源码中的触发点:课程服务(`CurrentTimeStateChanged` 与 `CurrentSubject` 变化,`repos/ClassIsland/ClassIsland/Services/LessonsService.cs:238,268-273`)、天气刷新完成(`WeatherService.cs:399`)、前台窗口变化(`WindowRuleService.cs:31`)。实现时必须覆盖「时间流逝导致的规则变化」(例如每 tick 比较当前课表/科目/时间状态,变化才通知),否则「规则不再满足时恢复行动」不会及时生效。 ### 8.5 内置行动(逐个实现,共 8 个) 注册表证据:`repos/ClassIsland/ClassIsland/App.Services.xaml.cs:269-276`。 #### 8.5.1 广播信号 `classisland.broadcastSignal` - 名称「广播信号」,图标 `\uE561`,设置类型 `SignalTriggerSettings`。 - 设置:`{ "SignalName": "", "IsRevert": false }`(`repos/ClassIsland/ClassIsland\Models\Automation\Triggers\SignalTriggerSettings.cs:7-31`)。 - 行为:发信号 `SignalName`;`IsRevert=false` 时发「执行信号」(接收方 `Trigger`),`IsRevert=true` 时发「恢复信号」(接收方 `TriggerRevert`)。恢复时**取反**发信号(`SignalTriggerHandlerService.cs:17-33`)。 - 边界:`SignalName` 为空时仍会广播空名信号,等于无效,建议 UI 校验非空。 #### 8.5.2 运行 `classisland.os.run` - 名称「运行」,图标 `\uec2e`,`addDefaultToMenu: false`(菜单项由菜单树手工提供)。 - 设置:`{ "RunType": "Application|Command|File|Folder|Url", "Value": "", "Args": "" }`,`RunType` 用字符串枚举序列化(`repos/ClassIsland/ClassIsland/Models/Actions/RunActionSettings.cs:10-49`)。 - 行为(`repos/ClassIsland/ClassIsland/Services/Automation/Actions/RunAction.cs:15-78`): | RunType | 行为 | |---|---| | `Application` | `Process.Start({ FileName = Value, Arguments = Args, UseShellExecute = true })` | | `File` / `Folder` | `Process.Start({ FileName = Value, UseShellExecute = true })`(不支持 Args) | | `Url` | 若 Value 非空白且不含 `:` 且不以 `\` 开头 → 自动补 `https://`;Windows 直接 `Process.Start(UseShellExecute)`,Linux `xdg-open`,macOS `open` | | `Command` | 用 `cmd.exe /c "{command}"`(Windows)或 `/bin/bash -c "{command}"`(其他)执行;`CreateNoWindow=true`、重定向 stdout/stderr、**最大捕获 2000 字符**,超出追加 `"…(已截断)"` | - `Command` 的失败语义:非 0 退出码 → 抛 `InvalidOperationException("命令执行失败 (退出代码: N)。\n标准输出:…\n错误输出:…")`;被中断 → 杀整棵进程树(`Kill(entireProcessTree: true)`)并抛「命令执行被中断」异常(含已捕获输出)。UI 必须能把该异常文本展示出来。 #### 8.5.3 显示提醒 `classisland.showNotification` - 名称「显示提醒」,图标 `\ue02b`,`addDefaultToMenu: false`。 - 设置默认值(`repos/ClassIsland/ClassIsland/Models/Actions/NotificationActionSettings.cs:7-18`): | 字段 | 默认 | |---|---| | `Content` | `""` | | `Mask` | `""` | | `IsContentSpeechEnabled` | `true` | | `IsMaskSpeechEnabled` | `true` | | `IsSoundEffectEnabled` | `true` | | `IsTopmostEnabled` | `true` | | `CustomSoundEffectPath` | `""` | | `MaskDurationSeconds` | `5.0` | | `ContentDurationSeconds` | `10.0` | | `IsEffectEnabled` | `true` | | `IsAdvancedSettingsEnabled` | **`false`** | | `IsWaitForCompleteEnabled` | **`false`** | - 行为(`NotificationAction.cs:23-108`):构造 `NotificationRequest`—— - `MaskContent = CreateTwoIconsMask(Mask, hasRightIcon:false, factory: 时长=MaskDurationSeconds, IsSpeechEnabled=IsMaskSpeechEnabled)`; - `OverlayContent`:`Content` 为空或 `ContentDurationSeconds<=0` → `null`,否则 `CreateSimpleTextContent(Content, 时长=ContentDurationSeconds, 语种开关=IsContentSpeechEnabled)`; - `RequestNotificationSettings = { IsSettingsEnabled = IsAdvancedSettingsEnabled, IsNotificationEffectEnabled = IsEffectEnabled, IsNotificationSoundEnabled = IsSoundEffectEnabled, IsNotificationTopmostEnabled = IsTopmostEnabled, NotificationSoundPath = CustomSoundEffectPath, IsSpeechEnabled = true }`。 - 通过「行动提醒」提供者(ActionNotificationProvider)发送。 - `IsWaitForCompleteEnabled=true` 时**等待提醒播放完成**再继续执行后续行动;等待期间若用户把该开关改为 false 必须立即解除等待(37-57);行动被中断时必须取消提醒(61-69)。 - 菜单提供两项:`"显示提醒…"`(等待=false)、`"显示提醒并等待…"`(等待=true)(`ActionService.cs:238-241`)。 #### 8.5.4 显示天气提醒 `classisland.notification.weather` - 名称「显示天气提醒」,图标 `\uf44f`,`addDefaultToMenu: false`。 - 设置:`{ "NotificationKind": 0 }`(`Models/Actions/WeatherNotificationActionSettings.cs:7`)。 - `NotificationKind`:0=三天天气预报(`ShowWeatherForecastCore`)、1=气象预警(`ShowAlertsNotificationCore`)、2=逐小时预报(`ShowWeatherForecastHourlyCore`);必须在 UI 线程调用(`WeatherNotificationAction.cs:19-37`)。 - 菜单提供三项:`"三天天气预报"`、`"气象预警"`、`"逐小时天气预报"`(ActionService.cs:242-247)。 #### 8.5.5 应用设置 `classisland.settings` - 名称「应用设置」,图标 `\uef27`,`addDefaultToMenu: false`。 - 设置:`{ "Name": "", "Value": null, "Mode": 0 }`(`Models/Actions/ModifyAppSettingsActionSettings.cs:10-40`)。 - 行为(`ModifyAppSettingsAction.cs:24-61`): 1. `Name` 为空 → 直接返回(不报错)。 2. 通过 `SettingsService.GetPropertyInfoByName(Name)` 反射取属性;找不到 → 抛 `KeyNotFoundException("应用设置中找不到属性“{Name}”。")`。 3. `Value == null` → 抛 `ArgumentNullException`。 4. 值转换 `ConvertToAssignableToSettingsType`:支持 `string/int/double/bool/Color` 与枚举直接转换;其余类型走 JSON 反序列化(容忍注释、尾逗号、大小写、数字字符串、枚举字符串)。若转换结果为 null → 抛异常。 5. **在 UI 线程执行**:若 `IsRevertable` 为真(行动组启用了恢复)→ `SettingsService.AddSettingsOverlay(ActionSet.Guid, Name, value)`(叠层,不覆盖原值);否则直接 `propertyInfo.SetValue`。 6. `OnRevert`:在 UI 线程 `RemoveSettingsOverlay(ActionSet.Guid, Name)`。 - 语义要求:叠层机制必须实现为「按行动组 GUID 分层覆盖」,读取设置值时取最上层有效值;行动组恢复后底层值自动复原。这是「临时改主题/改窗口位置,恢复后回到原值」的关键。 #### 8.5.6 等待时长 `classisland.action.sleep` - 名称「等待时长」,图标 `\ue9a8`。 - 设置:`{ "Value": 5 }`(秒,double;`Models/Actions/SleepActionSettings.cs:6-16`)。 - 行为(`SleepAction.cs:16-68`):循环等待直到 `Value*1000` 毫秒;进度 0~100 实时上报(首次为 `10/Value`,之后为 `elapsed*100/target`);每段 `Task.Delay` 最多 1000ms 以保证 1 秒内响应中断与设置变更;`Value` 变化时立即按新目标重算进度;被取消则静默退出。 #### 8.5.7 退出 `classisland.app.quit` / 重启 `classisland.app.restart` - 退出:「退出 ClassIsland」,图标 `\uE0DF`,无设置;行为 `AppBase.Current.Stop()`(`AppQuitAction.cs:7-13`)。 - 重启:「重启 ClassIsland」,图标 `\ue0bd`;设置 `{ "Value": false }`(`Value=true` 表示重启到安全模式【推断:源码只透传给 `AppBase.Current.Restart(Settings.Value)`,参数含义未在本文件注明】);行为 `AppBase.Current.Restart(Settings.Value)`(`AppRestartAction.cs:8-15`、`AppRestartActionSettings.cs:5-16`)。 - 两者 `addDefaultToMenu: false`;菜单项在「ClassIsland」组内手工提供(ActionService.cs:250-254)。 #### 8.5.8 迁移(必须实现,保证旧配置可用) `MigrateActionItem` 必须把旧式「应用设置」行动 `classisland.settings.{PropertyName}` 迁移为新行动(`ActionService.cs:179-203`): 1. `Id` 以 `classisland.settings.` 开头才迁移。 2. 取后缀,首字母大写 → 新 `ModifyAppSettingsActionSettings.Name`。 3. `Id = "classisland.settings"`。 4. 若旧 `Settings` 是 JSON 且有 `Value` 属性 → 写入 `settings.Value = value.ToString()`。 `MigrateUnknownActionItem` 在 `ActionBase.GetInstance` 取不到提供方时被调用(ActionBase.cs:118-127),因此**未知 ID 必须先尝试迁移再判定失败**。 ### 8.6 内置触发器(逐个实现,共 12 个) 注册表:`App.Services.xaml.cs:240-251`。触发器基类契约:`Loaded()` 订阅资源、`UnLoaded()` 退订;`Trigger()`/`TriggerRevert()` 由子类调用(`TriggerBase.cs:16-48`)。 | # | ID | 名称 | 图标 | 设置 | 触发条件 | |---|---|---|---|---|---| | 1 | `classisland.lifetime.startup` | 应用启动时 | `\ue067` | 无 | `Loaded()` 时若应用生命周期 `< Running` 立即 `Trigger()` | | 2 | `classisland.lifetime.stopping` | 应用退出时 | `\ue0df` | 无 | 订阅 `AppStopping` 事件 | | 3 | `classisland.lessons.onClass` | 上课时 | `\uE47A` | 无 | 订阅 `ILessonsService.OnClass` | | 4 | `classisland.lessons.onBreakingTime` | 课间休息时 | `\ue4c4` | 无 | 订阅 `OnBreakingTime` | | 5 | `classisland.lessons.onAfterSchool` | 放学时 | `\ued35` | 无 | 订阅 `OnAfterSchool` | | 6 | `classisland.lessons.currentTimeStateChanged` | 当前时间状态变化时 | `\ue4d2` | 无 | 订阅 `CurrentTimeStateChanged` | | 7 | `classisland.lessons.preTimePoint` | 特定时间点前 | `\ue4c6` | 见下 | 每 tick 计算「目标时刻 - 提前量」是否被跨过 | | 8 | `classisland.cron` | cron | `\ue125` | 见下 | cron 表达式到点 | | 9 | `classisland.signal` | 收到信号时 | `\ue40e` | 见下 | 收到同名信号 | | 10 | `classisland.trayMenu` | 从托盘菜单运行时 | `\ue075` | 见下 | 托盘子菜单项被点击 | | 11 | `classisland.uri` | 调用 Uri 时 | `\ueab0` | 见下 | 应用被特定 URI 调起 | | 12 | `classisland.ruleSet.rulesetChanged` | 规则集更新时 | `\uf17e` | 无 | 订阅 `IRulesetService.StatusUpdated` | 证据:`repos/ClassIsland/ClassIsland/Services/Automation/Triggers/` 下各文件(AppStartupTrigger.cs:10-23、AppStoppingTrigger.cs:8-25、OnClassTrigger.cs:8-26、OnBreakingTimeTrigger.cs:8-26、OnAfterSchoolTrigger.cs:8-26、CurrentTimeStateChangedTrigger.cs:8-26、PreTimePointTrigger.cs:13-83、CronTrigger.cs:15-84、SignalTrigger.cs:8-37、TrayMenuTrigger.cs:11-52、UriTrigger.cs:8-40、RulesetChangedTrigger.cs:8-27)。 #### 8.6.1 特定时间点前 `classisland.lessons.preTimePoint` 设置(`Models/Automation/Triggers/PreTimePointTriggerSettings.cs:7-12`): - `TargetState`:`0=None,1=OnClass,2=PrepareOnClass,3=Breaking,4=AfterSchool`,默认 **`OnClass`(1)**。 - `TimeSeconds`:提前秒数,默认 **`60`**,可为负数(负数直接不触发)。 算法(`PreTimePointTrigger.cs:33-82`,必须逐条实现): 1. `Loaded()` 时记录 `LastCheckTime = now`,订阅 `PostMainTimerTicked`。 2. 每 tick:若 `CurrentState == TargetState` → `TriggerRevert()` 并结束(**状态已到,恢复**)。 3. `TimeSeconds < 0` → 返回。 4. 求目标时刻 `targetStartTime`: - `TargetState == AfterSchool`:取有效时间点中最后一个 `TimeType∈{0,1}` 的 `EndTime`;无 → 返回。 - `OnClass`:`NextClassTimeLayoutItem.StartTime`;`Breaking`:`NextBreakingTimeLayoutItem.StartTime`;其他 → `TimeLayoutItem.Empty`;为空 → 返回。 5. `targetTime = targetStartTime - TimeSeconds` 秒,拼成今天的时刻。 6. **跨点判定**:`LastCheckTime < targetTime && targetTime <= now` 才 `Trigger()`(保证只触发一次,且应用在跨点后才启动时不会误触发)。 7. `finally { LastCheckTime = now }`。 #### 8.6.2 cron `classisland.cron` 设置:`{ "CronExpression": "* * * * *" }`(`CronTriggerSettings.cs:7`)。行为(`CronTrigger.cs:25-84`): 1. `Loaded()` 时订阅设置变化并 `LoadCron()`。 2. `LoadCron()`:取消旧 worker → 解析表达式 → 记日志「cron 表达式解析成功,下次触发时间:…」→ 起一个 LongRunning 后台任务。 3. Worker 循环:`sleep = crontab.GetSleepTimeSpan(now)`;`await Task.Delay(sleep, token)`;到点在 UI 线程 `Trigger()`;直到被取消。 4. 解析失败(`TimeCrontabException`)→ 记 Warning 并停止该触发器,不崩溃。 5. `UnLoaded()` → 退订 + 取消 worker。 #### 8.6.3 收到信号时 `classisland.signal` 设置:`{ "SignalName": "", "IsRevert": false }`。行为(`SignalTrigger.cs:24-36`):收到信号事件时,名称不等于 `SignalName` 直接忽略;`e.Revert` 为真 → `TriggerRevert()`,否则 `Trigger()`。信号总线由 `SignalTriggerHandlerService` 提供,它与「广播信号」行动双向绑定(同名信号即可联动)。 #### 8.6.4 从托盘菜单运行时 `classisland.trayMenu` 设置:`{ "Header": "", "IsRevert": false }`(`TrayMenuTriggerSettings.cs:7-8`)。行为(`TrayMenuTrigger.cs:20-51`): 1. 每个触发器实例创建一个 `NativeMenuItem`,文本 = `Settings.Header`。 2. 挂到托盘图标的「更多选项」子菜单 `"运行工作流"`(图标 `\uedbf`)下;子菜单为空时显示禁用项 `"(空)"`(`TrayMenuTriggerHandlerService.cs:15-62`)。 3. `Header` 变化必须实时刷新菜单文本。 4. 点击:`IsRevert` 为真 → `TriggerRevert()`,否则 `Trigger()`。 5. 卸载时移除菜单项。 #### 8.6.5 调用 Uri 时 `classisland.uri` 设置:`{ "UriSuffix": "" }`(`UriTriggerSettings.cs:7`)。行为(`UriTrigger.cs:13-39`、`UriTriggerHandlerService.cs:14-27`):注册两个 URI 路由 `{AppScheme}://api/automation/run/...` 与 `.../revert/...`;把路由剩余路径段用 `/` 连接后与 `UriSuffix` 比较,相等则分别 `Trigger()` / `TriggerRevert()`。即:用户可通过协议链接直接触发某条自动化。 #### 8.6.6 规则集更新时 `classisland.ruleSet.rulesetChanged` 订阅 `IRulesetService.StatusUpdated` 即 `Trigger()`(不区分是哪个规则集),因此**必须配合规则集使用**,否则会在任意规则状态变化时反复触发。 ### 8.7 规则集求值(算法必须逐字实现) 求值入口 `IsRulesetSatisfied(ruleset)`(`repos/ClassIsland/ClassIsland/Services/RulesetService.cs:109-147`): 1. `isSatisfied = (ruleset.Mode == And)`(And 初始 true,Or 初始 false)。 2. `Groups.Count <= 0` → `State=0` 并返回 **false**。 3. 先复位所有 Group/Rule 的 `State = 0`。 4. 只遍历 `IsEnabled` 的 Group:`res = IsRulesetGroupSatisfied(group)`;`group.State = BoolToRuleObjectState(res)`;`res == null` → 跳过。 5. And 模式下遇到 false 立即 break;Or 模式下遇到 true 立即 break。 6. `isSatisfied ^= ruleset.IsReversed`;`ruleset.State = 状态`;返回。 `IsRulesetGroupSatisfied`(74-107): 1. `groupSatisfied = (group.Mode == And)`。 2. 组内 `Id != ""` 的规则数 ≤ 0 → 返回 **`null`**(三值:未判定)。 3. 逐条 `res = IsRuleSatisfied(rule)`;`null` → 记 State 0 并 continue。 4. `result = (bool)res ^ rule.IsReversed`;`rule.State = result ? 2 : 1`。 5. And 遇 false / Or 遇 true 立即 break。 6. `groupSatisfied ^= group.IsReversed`;返回。 `IsRuleSatisfied`(36-72): 1. `Id == ""` → `null`。 2. 规则 ID 未注册 → 记 Warning `"找不到规则 {id} 的注册信息,已默认其结果为 false."`,返回 **false**(不是 null)。 3. 若该规则有设置类型:`settings = rule.Settings ?? Activator.CreateInstance(settingsType)`;若是 `JsonElement` 则反序列化。 4. 无处理程序 → 记 Warning 并返回 false。 5. 处理程序抛异常 → 记 Warning `"规则 {id} 的处理程序在处理过程中发生异常,已默认其结果为 false."` 并返回 false。 `BoolToRuleObjectState`:`true→2`、`false→1`、`null→0`(29-34)。**必须实现三值状态**,否则 UI 无法区分「不满足」与「未判定」。 ### 8.8 内置规则(逐个实现,共 15 个) 注册表:`App.Services.xaml.cs:253-267`。名称/图标/设置类型必须照抄: | # | ID | 名称 | 图标 | 设置类型与默认值 | 判定 | |---|---|---|---|---|---| | 1 | `classisland.test.true` | 总是为真 | — | 无 | 恒 true(测试用) | | 2 | `classisland.test.false` | 总是为假 | — | 无 | 恒 false(测试用) | | 3 | `classisland.windows.className` | 前台窗口类名 | `\uF4A2` | `StringMatchingSettings { Text:"", UseRegex:false }` | 匹配前台窗口类名 | | 4 | `classisland.windows.text` | 前台窗口标题 | `\uF26B` | 同上 | 匹配前台窗口标题 | | 5 | `classisland.windows.status` | 前台窗口状态是 | `\uEC83` | `WindowStatusRuleSettings { State:1 }` | 见下 | | 6 | `classisland.windows.processName` | 前台窗口进程 | `\uF488` | `StringMatchingSettings` | 匹配前台窗口进程名 | | 7 | `classisland.lessons.currentSubject` | 科目是 | `\uE215` | `CurrentSubjectRuleSettings { SubjectId: Guid.Empty }` | 当前科目 == 指定科目 | | 8 | `classisland.lessons.nextSubject` | 下节课科目是 | `\uE217` | 同上 | 下节课科目 == 指定科目 | | 9 | `classisland.lessons.previousSubject` | 上节课科目是 | `\uE226` | 同上 | 上一节课科目 == 指定科目 | | 10 | `classisland.lessons.timeState` | 当前时间状态是 | `\uE4C4` | `TimeStateRuleSettings { State:1(OnClass) }` | 见下 | | 11 | `classisland.weather.currentWeather` | 当前天气是 | `\uE4DC` | `CurrentWeatherRuleSettings { WeatherId:0, IsFuzzyMatch:false }` | 见下 | | 12 | `classisland.weather.tomorrowWeather` | 明天天气是 | `\uE4DC` | 同上 | 见下 | | 13 | `classisland.weather.hasWeatherAlert` | 存在气象预警 | `\uF431` | `StringMatchingSettings` | 匹配任一预警标题 | | 14 | `classisland.weather.rainTime` | 距离降水开始/结束还剩 | `\uF43F` | `RainTimeRuleSettings { RainTimeMinutes:60, IsRemainingTime:false }` | 见下 | | 15 | `classisland.weather.sunRiseSet` | 是否日出/日落 | `\uE150` | `SunRiseSetRuleSettings { TimeMinutes:60, IsSunset:false }` | 见下 | **字符串匹配规则的语义**(`repos/ClassIsland/ClassIsland.Core/Models/Ruleset/StringMatchingSettings.cs:46-60`,必须精确):`UseRegex=false` 时用**完全相等** `str == Text`(不是包含);`UseRegex=true` 时用 `Regex.Match(str, Text).Success`,正则非法时返回 false。因此实现时文本框旁必须提供「使用正则」开关,并在正则非法时给出提示。 **窗口状态规则**(`Settings.State` 默认 **1**):0=正常、1=最大化、2=最小化、3=全屏(`WindowStatusRuleSettings.cs:9-18`)。判定实现(`WindowRuleService.cs:61-87`):先取主界面所在屏幕,分别判断「前台窗口是否全屏(该屏)」「是否最大化(该屏)」「是否最小化」: - 0 正常 → `!(fullscreen || maximize || minimize)` - 1 最大化 → `maximize && !fullscreen` - 2 最小化 → `minimize` - 3 全屏 → `fullscreen` **科目规则**(`LessonsService.cs:295-360`): - `currentSubject`:`Subjects[SubjectId]` 存在且 `CurrentSubject == 该科目`。 - `nextSubject`:`NextClassSubject == 该科目`。 - `previousSubject`:取当前时间表中「最后一个 `TimeType==0 && EndTime < now`」的时间点,按上课序号映射到课程并取科目,比较。若找不到上一节课或科目缺失 → false。 - 科目 Guid 不在档案中 → 一律 false(不得抛异常)。 **时间状态规则**(`LessonsService.cs:362-371`):`CurrentState == State`,或(`CurrentState == AfterSchool && State == None`)也算真。这是为了让「状态为无」的规则在放学后仍成立。 **天气规则**(`WeatherService.cs:93-221`): - 所有天气规则都要求 `IsWeatherRefreshed == true`,否则 false。 - `currentWeather`:精确模式比字符串 `WeatherId == Current.Weather`;模糊模式用天气码集合交集(见下)。 - `tomorrowWeather`:需要 `ForecastDaily.Weather` 至少 2 天;精确模式比第 2 天的 `From` 或 `To`;模糊模式对二者分别判交集。 - 天气码扩展表(`ExpandWeatherCode`,205-221,必须照抄): | 码 | 等价集合 | |---|---| | `21` | `7,8,21` | | `22` | `8,9,22` | | `23` | `9,10,23` | | `24` | `10,11,24` | | `25` | `11,12,25` | | `26` | `14,15,26` | | `27` | `15,16,27` | | `28` | `16,17,28` | | `301`(雨夹雪等) | `3,4,5,6,7,8,9,10,11,12,19,21,22,23,24,25,301` | | `302` | `6,13,14,15,16,17,26,27,28,34,302` | | 其他 | 仅自身 | - `hasWeatherAlert`:任一预警 `Title` 匹配(同上字符串匹配语义)。 - `rainTime`:`baseTime = (IsRemainingTime ? -1 : 1) * LastWeatherInfo.Minutely.Precipitation.RainRemainingMinutes`;判定 `baseTime > 0 && baseTime <= RainTimeMinutes`。即:`IsRemainingTime=false` 表示「距降水开始还有 N 分钟内」;`true` 表示「距降水结束还剩 N 分钟内」。 - `sunRiseSet`:解析 `ForecastDaily.SunRiseSet` 中日期与今天相同的 `From`(日出)/`To`(日落);`IsSunset=false` → `now >= sunrise && now < sunset`(白天);`true` → `now >= sunset || now < sunrise`(夜间)。解析失败 → false。 ### 8.9 时间点行动(TimeType = 3) 与「自动化」并列的第二条执行通道,**必须实现**(`AutomationService.cs:84-108`): 1. 仅在 `ProfileService.IsCurrentProfileTrusted == true` 时执行(未信任档案不执行时间点行动)。 2. 每个 `PostMainTimerTicked`:从当前课表时间表中筛出 `TimeType == 3` 且 `StartTime > LastActionRunTime.TimeOfDay && StartTime <= now.TimeOfDay` 的时间点(即「上次检查之后、本次检查之前」被跨过的行动时间点)。 3. 更新 `LastActionRunTime = now`(无论是否命中,避免重复)。 4. 对每个命中的时间点,若 `ActionSet != null` → `ActionService.InvokeActionSetAsync(actionSet, isRevertable: false)`(时间点行动**不可恢复**)。 5. `LastActionRunTime` 初始化为自动化服务启动时刻(79 行),因此启动前的时间点不会被补触发。 6. 每个时间点的 `ActionSet` 在时间表编辑器中编辑(见第 6 节,新增「行动」时间点时自动创建空 ActionSet)。 7. 时间点行动与「附加设置」互不影响:`TimeLayoutItem.AttachedObjects`(附加设置模型见 06 §6.10)不参与时间点行动的执行判定;反之行动也不会修改附加设置。两者只共享同一个 `TimeLayoutItem` 对象。 8. 时间点行动不受 `Settings.IsAutomationEnabled` 控制(该开关只控制「自动化工作流」通道),但受档案信任状态控制;安全模式下不执行。 ### 8.10 设置页 UI(自动化) 页面 `AutomationSettingsPage`:`[SettingsPageInfo("automation", "自动化", "\ueEF1", "\ueEF0", Internal)]`、`[FullWidthPage]`(`Views/SettingPages/AutomationSettingsPage.axaml.cs:27-29`)。布局为「列表 + 详情」双栏(`ListDetailView`),必须实现: 1. **首次使用须知**(`Settings.IsAutomationWarningVisible == true` 时整页替换为该内容,`AutomationSettingsPage.axaml:333-400`): - 顶部图标行:`Grid ColumnDefinitions="75,Auto,75"`,左 `/Assets/AppLogo.png`,中间 `FluentIcon \uE00C` 25×25(`Margin=16 0`),右 `FluentIcon \uEEF1` 75×75。 - 标题 `"自动化"`(Subtitle 样式,上边距 8)。 - 正文(行距 4):`" 欢迎使用自动化!\n 自动化可以使 ClassIsland 根据条件自动作出行动,\n 比如修改应用设置、打开应用,等等。"`。 - 链接按钮 `"阅读自动化帮助"`(透明按钮,图标 `\uEC2D`,指向 `https://docs.classisland.tech/app/automation.html`)。 - 警告块:`Border Background="{DynamicResource SystemFillColorCautionBackgroundBrush}" CornerRadius=4 Padding="10 10 10 14"`,内含图标 `\uF430` + 文本 `"警告"`,正文 `"自动化允许自动修改 ClassIsland 的各项设置,并调用\n 外部程序,可能有一定安全风险。\n 不当的自动化编写可能对正常教学造成影响,请勿滥用。"`。 - 底部 Accent 按钮 `"同意并开始使用自动化"`(图标 `\uE424`)。 2. **主界面**: - 左上 `SettingsExpander`:`Header="启用自动化"`、`Description="自动化是一种可使 ClassIsland 自动作出行动的功能。"`,右侧「帮助…」按钮(图标 `\uE23C`)+ `ToggleSwitch` 绑定 `IsAutomationEnabled`(25-49)。 - 工具行:`"添加自动化"`(图标 `\uE00D`,计数为 0 时用强调色)、右侧配置方案 `ComboBox`(`MinWidth=120 MaxWidth=150`,`ItemsSource=AutomationService.Configs`,下拉展开时刷新列表)、`"新建…"`(`\uE689`)、`"打开…"`(`\uE88D`,打开配置文件夹)(50-85)。 - 左侧工作流列表:每项 `Height=42`,`Grid ColumnDefinitions="Auto 5* 2* Auto"`——左侧拖动柄(`TouchDragThumb Width=20`)、标题 `ActionSet.Name`(`FontSize=13`、`FontWeight=Medium`、可换行)、进度指示器(`WorkflowProgressIndicatorControl`,`Opacity 0→1` 过渡 100ms,`Tag=ActionSet.IsWorking`)、启用开关 `ToggleSwitch` 绑定 `ActionSet.IsEnabled`(110-155)。列表必须支持垂直拖动排序(`AdvancedItemDragBehavior`,垂直阈值 3px)。 3. **右侧详情**(`SelectedWorkflow != null` 时显示,197-296): - 标题行:名称 `TextBox`(`MinWidth=150`,`Watermark="自动化名称"`)、`CheckBox "启用恢复"`(`ToolTip="该自动化是否启用恢复。"`)、`ActionSetRunningControl`(运行/中断按钮,由 Core 控件提供)、`"复制"`(图标 `\uE58B`,ToolTip `"创建该自动化的副本。"`)、`"删除"`(图标 `\uE61D`,前景 `IndianRed`,ToolTip `"删除该自动化。"`);三者之间用 `Separator Width=1 Height=25 Margin=4 0` 分隔。 - `Expander "当事件触发时"`(图标 `\uE84F`)→ `TriggerSettingsControl`,绑定 `Triggers`。 - `Expander "并且满足规则集时"`(图标 `\uF17E`)头部右侧为 `ToggleSwitch` 绑定 `IsConditionEnabled`;未启用时显示灰字 `"工作流运行时不会考虑规则集。"`,启用时显示 `RulesetControl`(`ShowTitle=False`)(249-271)。 - `Expander "触发行动"`(图标 `\uE01F`)→ `ActionControl`,绑定 `ActionSet`(272-279)。 - 若 `ActionSet.IsRevertEnabled`,行动编辑下方显示提示(图标 `\uE01D`)`"当规则集不再满足时,将自动恢复行动。"`(290-293)。 - 未选择工作流时显示空态:75×75 贴纸(`avares://ClassIsland/Assets/HoYoStickers/光辉矢愿_瞄准.png`,加载失败回退 `FluentIcon \uEEF1`,`FontSize=75`)+ 文案 `"在左侧选择或添加一个自动化,然后在此处进行设置。"` + 帮助链接(299-317)。 4. **删除必须可撤销**:删除工作流后弹 `ToastMessage`,文案 `"已删除自动化“{名称}”。"`,`Duration=10 秒`,带「撤销」按钮;撤销时把工作流插回原索引并重新选中(`AutomationSettingsPage.axaml.cs:39-67`)。 5. **复制工作流**:JSON 深拷贝后 `ActionSet.Guid = Guid.NewGuid()`,插入到原项之后并选中(69-78)。 6. **新建工作流**:`ActionSet = { Name: "新自动化", IsRevertEnabled: true }`(注意:新建默认**开启恢复**),插入到当前选中项之后并展开详情(117-133)。 7. **新建配置方案**:`ContentDialog` 标题 `"新建自动化配置方案"`,输入框标签 `"配置方案名称"`,后缀 `.json`;路径已存在则中止;创建后刷新列表并把 `CurrentAutomationConfig` 设为新名称(87-115)。 ### 8.11 边界与异常(必须处理) 1. **总开关关闭**:所有触发路径第一行判断,直接返回;不装载触发器(安全模式同理)。 2. **行动组未启用**:触发时跳过。 3. **恢复开关与重复触发**:`IsRevertEnabled && Status != Normal` 时不再执行,防止重复叠加(例如反复「修改设置」)。 4. **规则集未启用**:`IsConditionEnabled=false` → 忽略规则集,永远执行。 5. **规则集为空**(`Groups.Count == 0`)→ 视为 false;组内无有效规则 → 三值 `null`(不参与逻辑)。 6. **未注册规则 / 处理程序异常** → false + Warning 日志,绝不抛出。 7. **未知触发器 ID**:实例化为 null,触发器被静默跳过(`LoadTrigger` 347-350 返回),配置保留不丢。 8. **未知行动 ID**:先迁移,仍未知则跳过(`InvokeActionItemAsync` 96-98)。 9. **单个行动异常**:记日志并继续后续行动;异常文本写进 `ActionItem.Exception` 供界面展示。 10. **长时行动的取消**:`Wait/Sleep/命令执行` 必须响应 `InterruptCancellationToken`;命令被中断要杀进程树。 11. **用户在执行过程中编辑行动列表**:按 8.4.4 的可变列表迭代算法处理。 12. **重启后状态恢复**:`ActionSet.Status == IsOn` 必须落盘(8.3 第 8 条),重启后仍可被规则集变化触发「恢复」。 13. **时间点行动重复执行**:靠 `LastActionRunTime` 单调推进避免;应用时间被大幅回拨时不得重复执行历史时间点(`StartTime > LastActionRunTime.TimeOfDay` 保证)。 14. **持久化失败**:配置写盘失败必须保留内存状态并在 UI 提示,不得因保存失败导致触发器被卸载。 ### 8.12 验收标准 1. 首次进入自动化设置页看到「使用须知」,点「同意并开始使用自动化」后 `IsAutomationEnabled=true`、`IsAutomationWarningVisible=false`,刷新页面后不再出现。 2. 新建自动化默认名称 `"新自动化"` 且「启用恢复」为**勾选**状态;复制出来的自动化 `ActionSet.Guid` 与原项不同。 3. 建一条「上课时 → 显示提醒」的工作流:到上课时刻必须弹出提醒;把「启用恢复」打开并加规则集(例如「当前时间状态是 课间」),当状态变为课间时行动必须被恢复(若行动可恢复)。 4. 12 个触发器按本节表格的 ID/名称/图标出现在「添加触发器」列表中,且参数默认值一致(`preTimePoint.TimeSeconds=60`、`cron="* * * * *"`、`trayMenu.Header=""` 等)。 5. 8 个行动按本节表格出现在「添加行动」菜单中;「添加行动」菜单必须含分组「应用设置 / 运行 / 提醒 / ClassIsland」,且「应用设置」组只有 4 个快捷项(选择应用设置、组件配置方案、应用主题、窗口停靠位置)。 6. 运行「cmd 命令」失败时,`ActionItem.Exception` 包含退出码与标准输出/错误输出(各截断到 2000 字符)。 7. 「等待时长」行动的进度条在 0~100 之间单调变化,改设置立即按新目标重算。 8. 规则集:And/Or 与三层反转(规则、组、规则集)组合 8 种情况结果正确;空规则集返回 false;未注册规则返回 false 且只记一条 Warning。 9. 天气模糊匹配:`21` 与 `7` 判定为匹配(交集),`21` 与 `301` 判定为匹配(`301` 集合含 `21`),`21` 与 `302` 不匹配。 10. 时间点行动:把某时间点类型设为「行动」并挂行动组后,到点执行一次;重启应用后不补执行到期时间点。 11. 删除工作流后可点 Toast 上的「撤销」恢复到原位置,且触发器重新生效。 12. 任何单条行动抛异常时,后续行动仍被执行,日志中可见 `"触发{行动项}时出现错误。"`。