- 重构版主提示词(14 parts,面向 ClassHelper 灵动岛重构) - ClassIsland 全量规格提示词(20 parts,含分卷与逐章版本) - 投喂指南、分卷、逐节文件 - 调研记录、一致性基线、生成与质检脚本
42 KiB
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):
- 读配置:配置不存在 → 用空工作流数组新建并立即保存(
note="新建自动化配置")。 - 安全模式:直接返回,不装载触发器。
- 逐个
LoadWorkflow:订阅Triggers.CollectionChanged、ActionSet.PropertyChanged、Unloading;对每个触发器LoadTrigger。 - 卸载(
UnloadWorkflow/UnloadTrigger):必须调用触发器的UnLoaded()、退订全部事件、把TriggerInstance置 null。源码注释明确「否则可能造成内存泄漏」(repos/ClassIsland/ClassIsland.Core/Abstractions/Automation/TriggerBase.cs:35-39)。 - 触发器
Id改变 → 卸载再加载;Workflows集合增删 → 增量加载/卸载。 - 切换配置方案:
Settings.CurrentAutomationConfig变化时先InterruptAllWorkflows()(对每个工作流InterruptActionSetAsync)再保存旧配置,然后加载新配置(AutomationService.cs:59-74)。 - 关闭自动化总开关:
IsAutomationEnabled变 false 时InterruptAllWorkflows()(71-73)。 - 行动组状态变化:
ActionSet.Status变化且该组不在运行中时保存配置(ActionSetOnPropertyChanged,218-223),保证IsOn状态跨重启可恢复。 - 触发器实例化:按
Id从 DI 的键控服务取TriggerBase;若触发器带设置控件且控件基类的泛型参数为设置类型,则把 JSONJsonElement反序列化为该类型实例后注入(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):
- 若状态是
Reverting→ 先InterruptActionSetAsync。 - 若
IsWorking→ 直接返回(不排队)。 MigrateActionSet(迁移旧格式,见 8.5.6)。SetStartRunning(true)。- 逐项执行
ActionItems.Where(x => !x.IsRevertActionItem),每项InvokeActionItemAsync(x, actionSet, isRevertable && actionSet.IsRevertEnabled)。 finally { SetEndRunning(true) }。
RevertActionSetAsync(actionSet):
- 若状态是
Invoking→ 先中断。 - 若
IsWorking→ 返回。 SetStartRunning(false)。- 逐项执行
ActionItems.Where(x => x.IsRevertActionItem || x.IsRevertEnabled):IsRevertActionItem的项执行其 Invoke(它是「恢复专用行动项」),IsRevertEnabled的项执行RevertActionItemAsync。 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):
Name为空 → 直接返回(不报错)。- 通过
SettingsService.GetPropertyInfoByName(Name)反射取属性;找不到 → 抛KeyNotFoundException("应用设置中找不到属性“{Name}”。")。 Value == null→ 抛ArgumentNullException。- 值转换
ConvertToAssignableToSettingsType:支持string/int/double/bool/Color与枚举直接转换;其余类型走 JSON 反序列化(容忍注释、尾逗号、大小写、数字字符串、枚举字符串)。若转换结果为 null → 抛异常。 - 在 UI 线程执行:若
IsRevertable为真(行动组启用了恢复)→SettingsService.AddSettingsOverlay(ActionSet.Guid, Name, value)(叠层,不覆盖原值);否则直接propertyInfo.SetValue。 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):
Id以classisland.settings.开头才迁移。- 取后缀,首字母大写 → 新
ModifyAppSettingsActionSettings.Name。 Id = "classisland.settings"。- 若旧
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,必须逐条实现):
Loaded()时记录LastCheckTime = now,订阅PostMainTimerTicked。- 每 tick:若
CurrentState == TargetState→TriggerRevert()并结束(状态已到,恢复)。 TimeSeconds < 0→ 返回。- 求目标时刻
targetStartTime:TargetState == AfterSchool:取有效时间点中最后一个TimeType∈{0,1}的EndTime;无 → 返回。OnClass:NextClassTimeLayoutItem.StartTime;Breaking:NextBreakingTimeLayoutItem.StartTime;其他 →TimeLayoutItem.Empty;为空 → 返回。
targetTime = targetStartTime - TimeSeconds秒,拼成今天的时刻。- 跨点判定:
LastCheckTime < targetTime && targetTime <= now才Trigger()(保证只触发一次,且应用在跨点后才启动时不会误触发)。 finally { LastCheckTime = now }。
8.6.2 cron classisland.cron
设置:{ "CronExpression": "* * * * *" }(CronTriggerSettings.cs:7)。行为(CronTrigger.cs:25-84):
Loaded()时订阅设置变化并LoadCron()。LoadCron():取消旧 worker → 解析表达式 → 记日志「cron 表达式解析成功,下次触发时间:…」→ 起一个 LongRunning 后台任务。- Worker 循环:
sleep = crontab.GetSleepTimeSpan(now);await Task.Delay(sleep, token);到点在 UI 线程Trigger();直到被取消。 - 解析失败(
TimeCrontabException)→ 记 Warning 并停止该触发器,不崩溃。 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):
- 每个触发器实例创建一个
NativeMenuItem,文本 =Settings.Header。 - 挂到托盘图标的「更多选项」子菜单
"运行工作流"(图标\uedbf)下;子菜单为空时显示禁用项"(空)"(TrayMenuTriggerHandlerService.cs:15-62)。 Header变化必须实时刷新菜单文本。- 点击:
IsRevert为真 →TriggerRevert(),否则Trigger()。 - 卸载时移除菜单项。
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):
isSatisfied = (ruleset.Mode == And)(And 初始 true,Or 初始 false)。Groups.Count <= 0→State=0并返回 false。- 先复位所有 Group/Rule 的
State = 0。 - 只遍历
IsEnabled的 Group:res = IsRulesetGroupSatisfied(group);group.State = BoolToRuleObjectState(res);res == null→ 跳过。 - And 模式下遇到 false 立即 break;Or 模式下遇到 true 立即 break。
isSatisfied ^= ruleset.IsReversed;ruleset.State = 状态;返回。
IsRulesetGroupSatisfied(74-107):
groupSatisfied = (group.Mode == And)。- 组内
Id != ""的规则数 ≤ 0 → 返回null(三值:未判定)。 - 逐条
res = IsRuleSatisfied(rule);null→ 记 State 0 并 continue。 result = (bool)res ^ rule.IsReversed;rule.State = result ? 2 : 1。- And 遇 false / Or 遇 true 立即 break。
groupSatisfied ^= group.IsReversed;返回。
IsRuleSatisfied(36-72):
Id == ""→null。- 规则 ID 未注册 → 记 Warning
"找不到规则 {id} 的注册信息,已默认其结果为 false.",返回 false(不是 null)。 - 若该规则有设置类型:
settings = rule.Settings ?? Activator.CreateInstance(settingsType);若是JsonElement则反序列化。 - 无处理程序 → 记 Warning 并返回 false。
- 处理程序抛异常 → 记 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):
- 仅在
ProfileService.IsCurrentProfileTrusted == true时执行(未信任档案不执行时间点行动)。 - 每个
PostMainTimerTicked:从当前课表时间表中筛出TimeType == 3且StartTime > LastActionRunTime.TimeOfDay && StartTime <= now.TimeOfDay的时间点(即「上次检查之后、本次检查之前」被跨过的行动时间点)。 - 更新
LastActionRunTime = now(无论是否命中,避免重复)。 - 对每个命中的时间点,若
ActionSet != null→ActionService.InvokeActionSetAsync(actionSet, isRevertable: false)(时间点行动不可恢复)。 LastActionRunTime初始化为自动化服务启动时刻(79 行),因此启动前的时间点不会被补触发。- 每个时间点的
ActionSet在时间表编辑器中编辑(见第 6 节,新增「行动」时间点时自动创建空 ActionSet)。 - 时间点行动与「附加设置」互不影响:
TimeLayoutItem.AttachedObjects(附加设置模型见 06 §6.10)不参与时间点行动的执行判定;反之行动也不会修改附加设置。两者只共享同一个TimeLayoutItem对象。 - 时间点行动不受
Settings.IsAutomationEnabled控制(该开关只控制「自动化工作流」通道),但受档案信任状态控制;安全模式下不执行。
8.10 设置页 UI(自动化)
页面 AutomationSettingsPage:[SettingsPageInfo("automation", "自动化", "\ueEF1", "\ueEF0", Internal)]、[FullWidthPage](Views/SettingPages/AutomationSettingsPage.axaml.cs:27-29)。布局为「列表 + 详情」双栏(ListDetailView),必须实现:
- 首次使用须知(
Settings.IsAutomationWarningVisible == true时整页替换为该内容,AutomationSettingsPage.axaml:333-400):- 顶部图标行:
Grid ColumnDefinitions="75,Auto,75",左/Assets/AppLogo.png,中间FluentIcon \uE00C25×25(Margin=16 0),右FluentIcon \uEEF175×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)。
- 顶部图标行:
- 主界面:
- 左上
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)。
- 左上
- 右侧详情(
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)。
- 标题行:名称
- 删除必须可撤销:删除工作流后弹
ToastMessage,文案"已删除自动化“{名称}”。",Duration=10 秒,带「撤销」按钮;撤销时把工作流插回原索引并重新选中(AutomationSettingsPage.axaml.cs:39-67)。 - 复制工作流:JSON 深拷贝后
ActionSet.Guid = Guid.NewGuid(),插入到原项之后并选中(69-78)。 - 新建工作流:
ActionSet = { Name: "新自动化", IsRevertEnabled: true }(注意:新建默认开启恢复),插入到当前选中项之后并展开详情(117-133)。 - 新建配置方案:
ContentDialog标题"新建自动化配置方案",输入框标签"配置方案名称",后缀.json;路径已存在则中止;创建后刷新列表并把CurrentAutomationConfig设为新名称(87-115)。
8.11 边界与异常(必须处理)
- 总开关关闭:所有触发路径第一行判断,直接返回;不装载触发器(安全模式同理)。
- 行动组未启用:触发时跳过。
- 恢复开关与重复触发:
IsRevertEnabled && Status != Normal时不再执行,防止重复叠加(例如反复「修改设置」)。 - 规则集未启用:
IsConditionEnabled=false→ 忽略规则集,永远执行。 - 规则集为空(
Groups.Count == 0)→ 视为 false;组内无有效规则 → 三值null(不参与逻辑)。 - 未注册规则 / 处理程序异常 → false + Warning 日志,绝不抛出。
- 未知触发器 ID:实例化为 null,触发器被静默跳过(
LoadTrigger347-350 返回),配置保留不丢。 - 未知行动 ID:先迁移,仍未知则跳过(
InvokeActionItemAsync96-98)。 - 单个行动异常:记日志并继续后续行动;异常文本写进
ActionItem.Exception供界面展示。 - 长时行动的取消:
Wait/Sleep/命令执行必须响应InterruptCancellationToken;命令被中断要杀进程树。 - 用户在执行过程中编辑行动列表:按 8.4.4 的可变列表迭代算法处理。
- 重启后状态恢复:
ActionSet.Status == IsOn必须落盘(8.3 第 8 条),重启后仍可被规则集变化触发「恢复」。 - 时间点行动重复执行:靠
LastActionRunTime单调推进避免;应用时间被大幅回拨时不得重复执行历史时间点(StartTime > LastActionRunTime.TimeOfDay保证)。 - 持久化失败:配置写盘失败必须保留内存状态并在 UI 提示,不得因保存失败导致触发器被卸载。
8.12 验收标准
- 首次进入自动化设置页看到「使用须知」,点「同意并开始使用自动化」后
IsAutomationEnabled=true、IsAutomationWarningVisible=false,刷新页面后不再出现。 - 新建自动化默认名称
"新自动化"且「启用恢复」为勾选状态;复制出来的自动化ActionSet.Guid与原项不同。 - 建一条「上课时 → 显示提醒」的工作流:到上课时刻必须弹出提醒;把「启用恢复」打开并加规则集(例如「当前时间状态是 课间」),当状态变为课间时行动必须被恢复(若行动可恢复)。
- 12 个触发器按本节表格的 ID/名称/图标出现在「添加触发器」列表中,且参数默认值一致(
preTimePoint.TimeSeconds=60、cron="* * * * *"、trayMenu.Header=""等)。 - 8 个行动按本节表格出现在「添加行动」菜单中;「添加行动」菜单必须含分组「应用设置 / 运行 / 提醒 / ClassIsland」,且「应用设置」组只有 4 个快捷项(选择应用设置、组件配置方案、应用主题、窗口停靠位置)。
- 运行「cmd 命令」失败时,
ActionItem.Exception包含退出码与标准输出/错误输出(各截断到 2000 字符)。 - 「等待时长」行动的进度条在 0~100 之间单调变化,改设置立即按新目标重算。
- 规则集:And/Or 与三层反转(规则、组、规则集)组合 8 种情况结果正确;空规则集返回 false;未注册规则返回 false 且只记一条 Warning。
- 天气模糊匹配:
21与7判定为匹配(交集),21与301判定为匹配(301集合含21),21与302不匹配。 - 时间点行动:把某时间点类型设为「行动」并挂行动组后,到点执行一次;重启应用后不补执行到期时间点。
- 删除工作流后可点 Toast 上的「撤销」恢复到原位置,且触发器重新生效。
- 任何单条行动抛异常时,后续行动仍被执行,日志中可见
"触发{行动项}时出现错误。"。