Files
classhelper-island-refactor…/out/sections/08-automation.md
T
ClassIsland Prompt Kit 22c1784bab feat: ClassIsland / ClassHelper 灵动岛提示词套件
- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构)
- ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本)
- 投喂指南、分卷、逐节文件
- 调研记录、一致性基线、生成与质检脚本
2026-11-12 21:59:58 +08:00

42 KiB
Raw Blame History

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. 任何单条行动抛异常时,后续行动仍被执行,日志中可见 "触发{行动项}时出现错误。"。