diff --git a/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs b/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs
index 7dfb29f2..c4010a98 100644
--- a/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs
+++ b/BetterGenshinImpact/GameTask/AutoPathing/PathExecutor.cs
@@ -38,6 +38,7 @@ using BetterGenshinImpact.GameTask.Common.Element.Assets;
using BetterGenshinImpact.GameTask.Common.Exceptions;
using BetterGenshinImpact.GameTask.Common.Map.Maps;
using BetterGenshinImpact.GameTask.AutoFight;
+using BetterGenshinImpact.Service.GearTask.Execution;
namespace BetterGenshinImpact.GameTask.AutoPathing;
@@ -101,6 +102,10 @@ public class PathExecutor
// 最近一次获取派遣奖励的时间
private DateTime _lastGetExpeditionRewardsTime = DateTime.MinValue;
+ private PathingGearTaskResumeState? _resumeState;
+
+ public IPathingRuntimeNotifier RuntimeNotifier { get; set; } = NullPathingRuntimeNotifier.Instance;
+
//当到达恢复点位
public void TryCloseSkipOtherOperations()
@@ -128,6 +133,25 @@ public class PathExecutor
RecordWaypoint = CurWaypoint;
}
+ public void ApplyResumeState(PathingGearTaskResumeState state)
+ {
+ _resumeState = state;
+ _skipOtherOperations = state.SkipOtherOperations;
+ }
+
+ public PathingGearTaskResumeState CaptureResumeState()
+ {
+ return new PathingGearTaskResumeState
+ {
+ PathFile = string.Empty,
+ WaypointGroupIndex = CurWaypoints.Item1,
+ WaypointIndex = CurWaypoint.Item1,
+ SkipOtherOperations = _skipOtherOperations,
+ LastAction = CurWaypoint.Item2?.Action,
+ CaptureTime = DateTime.Now,
+ };
+ }
+
public async Task Pathing(PathingTask task)
{
// SuspendableDictionary;
@@ -181,7 +205,9 @@ public class PathExecutor
foreach (var waypoint in waypoints) // 一条路径
{
CurWaypoint = (waypoints.FindIndex(wps => wps == waypoint), waypoint);
+ await NotifyWaypointEnteredAsync(waypoint);
TryCloseSkipOtherOperations();
+ await NotifyResumePointUpdatedAsync("路径断点已更新");
await RecoverWhenLowHp(waypoint); // 低血量恢复
if (waypoint.Type == WaypointType.Teleport.Code)
@@ -203,6 +229,8 @@ public class PathExecutor
}
}
await HandleTeleportWaypoint(waypoint);
+ await RuntimeNotifier.NotifyTeleportCompletedAsync(CurWaypoints.Item1, CurWaypoint.Item1, waypoint.X, waypoint.Y, ct);
+ await NotifyWaypointCompletedAsync(waypoint);
}
else
{
@@ -235,7 +263,13 @@ public class PathExecutor
// 执行 action
await AfterMoveToTarget(waypoint);
+ if (!string.IsNullOrWhiteSpace(waypoint.Action))
+ {
+ await RuntimeNotifier.NotifyActionCompletedAsync(CurWaypoints.Item1, CurWaypoint.Item1, waypoint.Action, waypoint.X, waypoint.Y, ct);
+ }
}
+
+ await NotifyWaypointCompletedAsync(waypoint);
}
}
@@ -717,6 +751,46 @@ public class PathExecutor
await Delay(500, ct); // 多等一会
}
+ private async Task NotifyWaypointEnteredAsync(WaypointForTrack waypoint)
+ {
+ ApplyResumeSkipPolicy();
+ await RuntimeNotifier.NotifyWaypointEnteredAsync(CurWaypoints.Item1, CurWaypoint.Item1, waypoint.Action, waypoint.X, waypoint.Y, ct);
+ }
+
+ private async Task NotifyWaypointCompletedAsync(WaypointForTrack waypoint)
+ {
+ await RuntimeNotifier.NotifyWaypointCompletedAsync(CurWaypoints.Item1, CurWaypoint.Item1, waypoint.Action, waypoint.X, waypoint.Y, ct);
+ }
+
+ private async Task NotifyResumePointUpdatedAsync(string message)
+ {
+ var state = CaptureResumeState();
+ await RuntimeNotifier.NotifyResumePointUpdatedAsync(state, message, ct);
+ }
+
+ private void ApplyResumeSkipPolicy()
+ {
+ if (_resumeState == null)
+ {
+ return;
+ }
+
+ if (CurWaypoints.Item1 < _resumeState.WaypointGroupIndex)
+ {
+ _skipOtherOperations = true;
+ return;
+ }
+
+ if (CurWaypoints.Item1 == _resumeState.WaypointGroupIndex && CurWaypoint.Item1 < _resumeState.WaypointIndex)
+ {
+ _skipOtherOperations = true;
+ return;
+ }
+
+ _skipOtherOperations = false;
+ _resumeState = null;
+ }
+
public async Task FaceTo(WaypointForTrack waypoint)
{
var screen = CaptureToRectArea();
diff --git a/BetterGenshinImpact/Model/Gear/Tasks/BaseGearTask.cs b/BetterGenshinImpact/Model/Gear/Tasks/BaseGearTask.cs
index 86187eb7..27bfda4a 100644
--- a/BetterGenshinImpact/Model/Gear/Tasks/BaseGearTask.cs
+++ b/BetterGenshinImpact/Model/Gear/Tasks/BaseGearTask.cs
@@ -5,6 +5,7 @@ using System.IO;
using System.Threading;
using System.Threading.Tasks;
using BetterGenshinImpact.GameTask.AutoGeniusInvokation.Exception;
+using BetterGenshinImpact.Service.GearTask.Execution;
using CommunityToolkit.Mvvm.ComponentModel;
using Microsoft.Extensions.Logging;
using Newtonsoft.Json;
@@ -12,7 +13,7 @@ using Newtonsoft.Json;
namespace BetterGenshinImpact.Model.Gear.Tasks;
///
-/// 为了和其他Task做区分,使用Gear(齿轮)来作为前缀命名调度器内定义的任务
+/// 为了和其他 Task 做区分,这里使用 Gear(齿轮) 作为调度器内任务定义的统一抽象。
///
public abstract class BaseGearTask : ObservableObject
{
@@ -23,14 +24,14 @@ public abstract class BaseGearTask : ObservableObject
/// 任务名称
///
public string Name { get; set; } = string.Empty;
-
+
///
/// 任务类型
///
public string Type { get; set; } = string.Empty;
///
- /// 任务的文件位置,如果有
+ /// 任务的文件位置,如果有。
///
public string FilePath { get; set; } = string.Empty;
@@ -50,6 +51,14 @@ public abstract class BaseGearTask : ObservableObject
///
public List Children { get; set; } = [];
+ [JsonIgnore]
+ public GearTaskNodeExecutionContext? ExecutionContext { get; private set; }
+
+ public virtual void SetExecutionContext(GearTaskNodeExecutionContext context)
+ {
+ ExecutionContext = context;
+ }
+
///
/// 执行任务
///
@@ -62,7 +71,7 @@ public abstract class BaseGearTask : ObservableObject
stopwatch.Start();
await Run(ct);
}
- catch (NormalEndException e)
+ catch (NormalEndException)
{
throw;
}
@@ -75,13 +84,17 @@ public abstract class BaseGearTask : ObservableObject
{
_logger.LogDebug(e, "执行脚本时发生异常");
_logger.LogError("执行脚本时发生异常: {Msg}", e.Message);
+ throw;
}
finally
{
stopwatch.Stop();
var elapsedTime = TimeSpan.FromMilliseconds(stopwatch.ElapsedMilliseconds);
- _logger.LogInformation("→ 脚本执行结束: {Name}, 耗时: {Minutes}分{Seconds:0.000}秒", Name,
- elapsedTime.Hours * 60 + elapsedTime.Minutes, elapsedTime.TotalSeconds % 60);
+ _logger.LogInformation(
+ "-> 脚本执行结束: {Name}, 耗时: {Minutes}分{Seconds:0.000}秒",
+ Name,
+ elapsedTime.Hours * 60 + elapsedTime.Minutes,
+ elapsedTime.TotalSeconds % 60);
_logger.LogInformation("------------------------------");
}
}
@@ -90,7 +103,6 @@ public abstract class BaseGearTask : ObservableObject
/// 执行任务
///
public abstract Task Run(CancellationToken ct);
-
public static BaseGearTask ReadFileToBaseGearTasks(string? path)
{
@@ -98,8 +110,8 @@ public abstract class BaseGearTask : ObservableObject
{
throw new ArgumentException("任务文件路径不能为空", nameof(path));
}
+
var json = File.ReadAllText(path);
return JsonConvert.DeserializeObject(json) ?? throw new InvalidOperationException("任务数据读取结果为空");
}
-
}
diff --git a/BetterGenshinImpact/Model/Gear/Tasks/PathingGearTask.cs b/BetterGenshinImpact/Model/Gear/Tasks/PathingGearTask.cs
index 93423dd9..f0235263 100644
--- a/BetterGenshinImpact/Model/Gear/Tasks/PathingGearTask.cs
+++ b/BetterGenshinImpact/Model/Gear/Tasks/PathingGearTask.cs
@@ -3,38 +3,59 @@ using System.Threading.Tasks;
using BetterGenshinImpact.Core.Script;
using BetterGenshinImpact.GameTask;
using BetterGenshinImpact.GameTask.AutoPathing;
-using BetterGenshinImpact.GameTask.AutoPathing.Model;
using BetterGenshinImpact.Model.Gear.Parameter;
+using BetterGenshinImpact.Service.GearTask.Execution;
+using Newtonsoft.Json;
namespace BetterGenshinImpact.Model.Gear.Tasks;
-public class PathingGearTask : BaseGearTask
+public class PathingGearTask : BaseGearTask, IGearTaskResumable
{
-
- private PathingGearTaskParams _params;
+ private readonly PathingGearTaskParams _params;
+ private PathingGearTaskResumeState? _resumeState;
public PathingGearTask(PathingGearTaskParams param)
{
FilePath = param.Path;
_params = param;
}
-
+
public override async Task Run(CancellationToken ct)
{
- // 加载并执行
- var task = PathingTask.BuildFromFilePath(_params.Path);
- var pathingTask = new PathExecutor(ct);
+ var task = BetterGenshinImpact.GameTask.AutoPathing.Model.PathingTask.BuildFromFilePath(_params.Path);
+ var pathExecutor = new PathExecutor(ct);
if (_params.PathingPartyConfig != null)
{
- pathingTask.PartyConfig = _params.PathingPartyConfig;
+ pathExecutor.PartyConfig = _params.PathingPartyConfig;
}
- if (pathingTask.PartyConfig.AutoPickEnabled)
+ if (pathExecutor.PartyConfig.AutoPickEnabled)
{
TaskTriggerDispatcher.Instance().AddTrigger("AutoPick", null);
}
- await pathingTask.Pathing(task);
+ pathExecutor.RuntimeNotifier = ExecutionContext != null
+ ? new GearTaskPathingRuntimeNotifier(ExecutionContext)
+ : NullPathingRuntimeNotifier.Instance;
+
+ if (_resumeState != null)
+ {
+ pathExecutor.ApplyResumeState(_resumeState);
+ }
+
+ await pathExecutor.Pathing(task);
}
-}
\ No newline at end of file
+
+ public Task ApplyResumeTokenAsync(string? resumeTokenJson, CancellationToken ct)
+ {
+ if (string.IsNullOrWhiteSpace(resumeTokenJson))
+ {
+ _resumeState = null;
+ return Task.CompletedTask;
+ }
+
+ _resumeState = JsonConvert.DeserializeObject(resumeTokenJson);
+ return Task.CompletedTask;
+ }
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskEventBus.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskEventBus.cs
new file mode 100644
index 00000000..a2caf8e7
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskEventBus.cs
@@ -0,0 +1,97 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// 默认事件总线实现。
+/// 当前采用进程内同步分发,借由消费者内部自行决定是否异步缓冲。
+///
+public sealed class GearTaskEventBus : IGearTaskEventBus
+{
+ private readonly ILogger _logger;
+ private readonly object _gate = new();
+ private readonly List _consumers = [];
+
+ public GearTaskEventBus(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public ValueTask PublishAsync(GearTaskExecutionEvent evt, CancellationToken ct = default)
+ {
+ IGearTaskEventConsumer[] snapshot;
+ lock (_gate)
+ {
+ // 先拷贝快照,避免消费者在回调期间订阅或退订影响当前广播。
+ snapshot = _consumers.ToArray();
+ }
+
+ return PublishCoreAsync(snapshot, evt, ct);
+ }
+
+ public IDisposable Subscribe(IGearTaskEventConsumer consumer)
+ {
+ lock (_gate)
+ {
+ _consumers.Add(consumer);
+ }
+
+ return new Subscription(this, consumer);
+ }
+
+ private async ValueTask PublishCoreAsync(IEnumerable consumers, GearTaskExecutionEvent evt, CancellationToken ct)
+ {
+ foreach (var consumer in consumers)
+ {
+ try
+ {
+ await consumer.ConsumeAsync(evt, ct);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex,
+ "GearTask 事件消费者处理失败,已隔离该异常。Consumer: {ConsumerType}, Event: {EventType}, RecordId: {RecordId}",
+ consumer.GetType().Name,
+ evt.GetType().Name,
+ evt.RecordId);
+ }
+ }
+ }
+
+ private void Unsubscribe(IGearTaskEventConsumer consumer)
+ {
+ lock (_gate)
+ {
+ _consumers.Remove(consumer);
+ }
+ }
+
+ private sealed class Subscription : IDisposable
+ {
+ private readonly GearTaskEventBus _bus;
+ private readonly IGearTaskEventConsumer _consumer;
+ private bool _disposed;
+
+ public Subscription(GearTaskEventBus bus, IGearTaskEventConsumer consumer)
+ {
+ _bus = bus;
+ _consumer = consumer;
+ }
+
+ public void Dispose()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ _disposed = true;
+ _bus.Unsubscribe(_consumer);
+ }
+ }
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvent.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvent.cs
new file mode 100644
index 00000000..c7c81c1a
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvent.cs
@@ -0,0 +1,53 @@
+using System;
+using System.Collections.Generic;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// GearTask 执行链路中的统一事件基类。
+/// 执行器只负责发布事件,记录器、UI 投影和其他消费者按需订阅并消费。
+///
+public abstract class GearTaskExecutionEvent
+{
+ ///
+ /// 当前执行记录的唯一标识。
+ ///
+ public string RecordId { get; set; } = string.Empty;
+
+ ///
+ /// 任务定义展示名。
+ ///
+ public string TaskDefinitionName { get; set; } = string.Empty;
+
+ ///
+ /// 任务定义对应的安全文件名,用于历史记录目录划分。
+ ///
+ public string TaskDefinitionFileKey { get; set; } = string.Empty;
+
+ ///
+ /// 任务树中的节点路径,例如 0/1/2。
+ ///
+ public string NodeId { get; set; } = string.Empty;
+
+ public string? ParentNodeId { get; set; }
+
+ public string TaskName { get; set; } = string.Empty;
+
+ public string TaskType { get; set; } = string.Empty;
+
+ public string TaskPath { get; set; } = string.Empty;
+
+ public int NodeDepth { get; set; }
+
+ public int NodeOrder { get; set; }
+
+ ///
+ /// 事件发生时间,默认使用本地时间,便于直接展示在历史时间线中。
+ ///
+ public DateTime Timestamp { get; set; } = DateTime.Now;
+
+ ///
+ /// 预留给具体任务类型的扩展字段,避免事件模型为了单个任务不断膨胀。
+ ///
+ public Dictionary Extra { get; set; } = [];
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvents.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvents.cs
new file mode 100644
index 00000000..aee8328c
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionEvents.cs
@@ -0,0 +1,171 @@
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// 一次任务组执行开始。
+///
+public sealed class ExecutionStartedEvent : GearTaskExecutionEvent
+{
+ ///
+ /// 如果本次执行来自续跑,则记录源执行记录 Id。
+ ///
+ public string? SourceRecordId { get; set; }
+
+ ///
+ /// 本次预计可执行的总节点数,用于进度展示。
+ ///
+ public int TotalRunnableNodeCount { get; set; }
+}
+
+///
+/// 一次任务组执行正常结束。
+///
+public sealed class ExecutionCompletedEvent : GearTaskExecutionEvent
+{
+}
+
+///
+/// 一次任务组执行因异常失败。
+///
+public sealed class ExecutionFailedEvent : GearTaskExecutionEvent
+{
+ public string ErrorMessage { get; set; } = string.Empty;
+}
+
+///
+/// 一次任务组执行被取消。
+///
+public sealed class ExecutionCancelledEvent : GearTaskExecutionEvent
+{
+ public string? Reason { get; set; }
+}
+
+///
+/// 一次任务组执行被外部中断,但不一定等价于异常失败。
+///
+public sealed class ExecutionInterruptedEvent : GearTaskExecutionEvent
+{
+ public string? Reason { get; set; }
+}
+
+///
+/// 单个任务节点开始执行。
+///
+public sealed class TaskNodeStartedEvent : GearTaskExecutionEvent
+{
+}
+
+///
+/// 单个任务节点执行完成。
+///
+public sealed class TaskNodeCompletedEvent : GearTaskExecutionEvent
+{
+ public string? Message { get; set; }
+}
+
+///
+/// 单个任务节点执行失败。
+///
+public sealed class TaskNodeFailedEvent : GearTaskExecutionEvent
+{
+ public string ErrorMessage { get; set; } = string.Empty;
+}
+
+///
+/// 单个任务节点被跳过。
+///
+public sealed class TaskNodeSkippedEvent : GearTaskExecutionEvent
+{
+ public string Reason { get; set; } = string.Empty;
+}
+
+///
+/// 任务内部恢复点更新。
+///
+public sealed class ResumePointUpdatedEvent : GearTaskExecutionEvent
+{
+ ///
+ /// 是否支持在该任务内部继续执行,而不是仅支持节点级重跑。
+ ///
+ public bool CanResumeInsideTask { get; set; }
+
+ ///
+ /// 任务私有恢复状态序列化结果。
+ ///
+ public string? ResumeTokenJson { get; set; }
+
+ public string? Message { get; set; }
+}
+
+///
+/// Pathing 进入某个 waypoint。
+///
+public sealed class PathingWaypointEnteredEvent : GearTaskExecutionEvent
+{
+ public int WaypointGroupIndex { get; set; }
+
+ public int WaypointIndex { get; set; }
+
+ public string? Action { get; set; }
+
+ public double PositionX { get; set; }
+
+ public double PositionY { get; set; }
+}
+
+///
+/// Pathing 完成某个 waypoint。
+///
+public sealed class PathingWaypointCompletedEvent : GearTaskExecutionEvent
+{
+ public int WaypointGroupIndex { get; set; }
+
+ public int WaypointIndex { get; set; }
+
+ public string? Action { get; set; }
+
+ public double PositionX { get; set; }
+
+ public double PositionY { get; set; }
+}
+
+///
+/// Pathing 完成传送步骤。
+///
+public sealed class PathingTeleportCompletedEvent : GearTaskExecutionEvent
+{
+ public int WaypointGroupIndex { get; set; }
+
+ public int WaypointIndex { get; set; }
+
+ public double PositionX { get; set; }
+
+ public double PositionY { get; set; }
+}
+
+///
+/// Pathing 完成 waypoint 绑定动作。
+///
+public sealed class PathingActionCompletedEvent : GearTaskExecutionEvent
+{
+ public int WaypointGroupIndex { get; set; }
+
+ public int WaypointIndex { get; set; }
+
+ public string Action { get; set; } = string.Empty;
+
+ public double PositionX { get; set; }
+
+ public double PositionY { get; set; }
+}
+
+///
+/// JavaScript 脚本主动上报的检查点事件。
+///
+public sealed class ScriptCheckpointReachedEvent : GearTaskExecutionEvent
+{
+ public string CheckpointName { get; set; } = string.Empty;
+
+ public string? ResumeTokenJson { get; set; }
+
+ public string? Message { get; set; }
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionModels.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionModels.cs
new file mode 100644
index 00000000..a73562e6
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionModels.cs
@@ -0,0 +1,186 @@
+using System;
+using System.Collections.Generic;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// 一次任务组执行记录的状态。
+///
+public enum GearTaskExecutionRecordStatus
+{
+ Pending,
+ Running,
+ Succeeded,
+ Failed,
+ Cancelled,
+ Interrupted,
+}
+
+///
+/// 单个任务节点在执行记录中的状态。
+///
+public enum GearTaskExecutionNodeStatus
+{
+ Pending,
+ Running,
+ Succeeded,
+ Failed,
+ Skipped,
+ Cancelled,
+ Interrupted,
+}
+
+///
+/// 一次大任务组执行的持久化记录。
+///
+public class GearTaskExecutionRecord
+{
+ public string RecordId { get; set; } = string.Empty;
+
+ public string TaskDefinitionName { get; set; } = string.Empty;
+
+ public string TaskDefinitionFileKey { get; set; } = string.Empty;
+
+ public string? SourceRecordId { get; set; }
+
+ public GearTaskExecutionRecordStatus Status { get; set; }
+
+ public DateTime StartTime { get; set; }
+
+ public DateTime? EndTime { get; set; }
+
+ public int TotalRunnableNodeCount { get; set; }
+
+ public int CompletedNodeCount { get; set; }
+
+ public int FailedNodeCount { get; set; }
+
+ public int SkippedNodeCount { get; set; }
+
+ public string? CurrentNodeId { get; set; }
+
+ ///
+ /// 当前记录可恢复时,对应的节点 Id。
+ ///
+ public string? ResumeNodeId { get; set; }
+
+ ///
+ /// 是否存在有效恢复点。
+ ///
+ public bool CanResume { get; set; }
+
+ public string? InterruptReason { get; set; }
+
+ public string? ErrorMessage { get; set; }
+
+ ///
+ /// 当前记录内所有节点的最终或最近状态快照。
+ ///
+ public List Nodes { get; set; } = [];
+
+ ///
+ /// 完整时间线,面向执行记录详情和调试定位。
+ ///
+ public List Timeline { get; set; } = [];
+
+ public int SchemaVersion { get; set; } = 1;
+}
+
+///
+/// 单个任务节点的持久化快照。
+///
+public class GearTaskExecutionNodeRecord
+{
+ public string NodeId { get; set; } = string.Empty;
+
+ public string? ParentNodeId { get; set; }
+
+ public string TaskName { get; set; } = string.Empty;
+
+ public string TaskType { get; set; } = string.Empty;
+
+ public string TaskPath { get; set; } = string.Empty;
+
+ public int Depth { get; set; }
+
+ public int Order { get; set; }
+
+ public GearTaskExecutionNodeStatus Status { get; set; }
+
+ public DateTime? StartTime { get; set; }
+
+ public DateTime? EndTime { get; set; }
+
+ public string? StatusMessage { get; set; }
+
+ public string? ErrorMessage { get; set; }
+
+ public bool CanResumeInsideTask { get; set; }
+
+ ///
+ /// 节点内部恢复状态,通常由具体任务类型自己定义。
+ ///
+ public string? ResumeTokenJson { get; set; }
+}
+
+///
+/// 面向“执行详情”视图的时间线条目。
+///
+public class GearTaskExecutionTimelineItem
+{
+ public DateTime Timestamp { get; set; }
+
+ public string NodeId { get; set; } = string.Empty;
+
+ public string EventType { get; set; } = string.Empty;
+
+ public string Message { get; set; } = string.Empty;
+
+ public Dictionary Extra { get; set; } = [];
+}
+
+///
+/// 执行恢复计划。
+/// 由历史记录解析得到,再传递给执行器决定哪些节点跳过、哪些节点恢复。
+///
+public class GearTaskResumePlan
+{
+ public string SourceRecordId { get; set; } = string.Empty;
+
+ public string ResumeNodeId { get; set; } = string.Empty;
+
+ public Dictionary NodeResumeTokens { get; set; } = [];
+}
+
+///
+/// 一次执行运行时的公共上下文。
+///
+public sealed class GearTaskExecutionRunContext
+{
+ public string RecordId { get; init; } = string.Empty;
+
+ public string TaskDefinitionName { get; init; } = string.Empty;
+
+ public string TaskDefinitionFileKey { get; init; } = string.Empty;
+
+ public GearTaskResumePlan? ResumePlan { get; init; }
+}
+
+///
+/// PathingGearTask 的内部恢复状态。
+/// 当前按 waypoint 粒度记录,后续需要更细时可继续扩展。
+///
+public class PathingGearTaskResumeState
+{
+ public string PathFile { get; set; } = string.Empty;
+
+ public int WaypointGroupIndex { get; set; }
+
+ public int WaypointIndex { get; set; }
+
+ public bool SkipOtherOperations { get; set; }
+
+ public string? LastAction { get; set; }
+
+ public DateTime CaptureTime { get; set; }
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionPathFormatter.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionPathFormatter.cs
new file mode 100644
index 00000000..01644e64
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionPathFormatter.cs
@@ -0,0 +1,87 @@
+using System;
+using System.IO;
+using BetterGenshinImpact.Core.Config;
+using BetterGenshinImpact.ViewModel.Pages;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// 执行记录中的任务路径格式化器。
+/// 将运行期绝对路径转换为和任务定义列表一致的占位符路径。
+///
+public static class GearTaskExecutionPathFormatter
+{
+ public const string PathingRepoFolderPlaceholder = "{pathingRepoFolder}";
+ public const string JsUserFolderPlaceholder = "{jsUserFolder}";
+
+ public static string FormatTaskPath(string? taskPath)
+ {
+ if (string.IsNullOrWhiteSpace(taskPath))
+ {
+ return string.Empty;
+ }
+
+ var normalizedInput = taskPath.Trim();
+ if (!Path.IsPathRooted(normalizedInput))
+ {
+ return normalizedInput;
+ }
+
+ if (TryFormatUnderRoot(normalizedInput, MapPathingViewModel.PathJsonPath, PathingRepoFolderPlaceholder, out var pathingPath))
+ {
+ return pathingPath;
+ }
+
+ if (TryFormatUnderRoot(normalizedInput, Global.ScriptPath(), JsUserFolderPlaceholder, out var jsPath))
+ {
+ return jsPath;
+ }
+
+ return normalizedInput;
+ }
+
+ private static bool TryFormatUnderRoot(string sourcePath, string rootPath, string placeholder, out string formattedPath)
+ {
+ formattedPath = string.Empty;
+
+ var normalizedSource = Path.GetFullPath(sourcePath);
+ var normalizedRoot = Path.GetFullPath(rootPath);
+ var sourceWithSeparator = EnsureTrailingSeparator(normalizedSource);
+ var rootWithSeparator = EnsureTrailingSeparator(normalizedRoot);
+
+ if (string.Equals(normalizedSource, normalizedRoot, StringComparison.OrdinalIgnoreCase))
+ {
+ formattedPath = sourcePath.EndsWith(Path.DirectorySeparatorChar) || sourcePath.EndsWith(Path.AltDirectorySeparatorChar)
+ ? placeholder + Path.DirectorySeparatorChar
+ : placeholder;
+ return true;
+ }
+
+ if (!sourceWithSeparator.StartsWith(rootWithSeparator, StringComparison.OrdinalIgnoreCase))
+ {
+ return false;
+ }
+
+ var relativePath = Path.GetRelativePath(normalizedRoot, normalizedSource);
+ formattedPath = string.IsNullOrEmpty(relativePath)
+ ? placeholder
+ : $@"{placeholder}\{relativePath}";
+
+ if (sourcePath.EndsWith(Path.DirectorySeparatorChar) || sourcePath.EndsWith(Path.AltDirectorySeparatorChar))
+ {
+ formattedPath += Path.DirectorySeparatorChar;
+ }
+
+ return true;
+ }
+
+ private static string EnsureTrailingSeparator(string path)
+ {
+ if (path.EndsWith(Path.DirectorySeparatorChar) || path.EndsWith(Path.AltDirectorySeparatorChar))
+ {
+ return path;
+ }
+
+ return path + Path.DirectorySeparatorChar;
+ }
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionRunner.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionRunner.cs
new file mode 100644
index 00000000..2bda22ea
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskExecutionRunner.cs
@@ -0,0 +1,206 @@
+using System;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using BetterGenshinImpact.Model.Gear.Tasks;
+using Microsoft.Extensions.Logging;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// GearTask 任务树执行器。
+/// 只负责遍历节点、执行任务以及向外发布业务事件。
+///
+public interface IGearTaskExecutionRunner
+{
+ Task RunAsync(BaseGearTask rootTask, GearTaskExecutionRunContext context, CancellationToken ct);
+}
+
+///
+/// 默认 GearTask 执行器实现。
+///
+public sealed class GearTaskExecutionRunner : IGearTaskExecutionRunner
+{
+ private readonly ILogger _logger;
+ private readonly IGearTaskEventBus _eventBus;
+
+ public GearTaskExecutionRunner(ILogger logger, IGearTaskEventBus eventBus)
+ {
+ _logger = logger;
+ _eventBus = eventBus;
+ }
+
+ public async Task RunAsync(BaseGearTask rootTask, GearTaskExecutionRunContext context, CancellationToken ct)
+ {
+ var rootContext = CreateNodeContext(rootTask, context, "0", null, 0, 0);
+ var startedEvent = rootContext.CreateEvent();
+ startedEvent.SourceRecordId = context.ResumePlan?.SourceRecordId;
+ startedEvent.TotalRunnableNodeCount = CountTotalNodes(rootTask);
+ await _eventBus.PublishAsync(startedEvent, ct);
+
+ try
+ {
+ await ExecuteNodeAsync(rootTask, context, "0", null, 0, 0, ct);
+
+ var completedEvent = rootContext.CreateEvent();
+ await _eventBus.PublishAsync(completedEvent, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ var cancelledEvent = rootContext.CreateEvent();
+ cancelledEvent.Reason = "用户取消执行";
+ await _eventBus.PublishAsync(cancelledEvent, CancellationToken.None);
+ throw;
+ }
+ catch (Exception ex)
+ {
+ var failedEvent = rootContext.CreateEvent();
+ failedEvent.ErrorMessage = ex.Message;
+ await _eventBus.PublishAsync(failedEvent, CancellationToken.None);
+ throw;
+ }
+ }
+
+ private async Task ExecuteNodeAsync(
+ BaseGearTask task,
+ GearTaskExecutionRunContext runContext,
+ string nodeId,
+ string? parentNodeId,
+ int depth,
+ int order,
+ CancellationToken ct)
+ {
+ var nodeContext = CreateNodeContext(task, runContext, nodeId, parentNodeId, depth, order);
+
+ task.SetExecutionContext(nodeContext);
+
+ if (!task.Enabled)
+ {
+ var skippedEvent = nodeContext.CreateEvent();
+ skippedEvent.Reason = "任务未启用";
+ await nodeContext.PublishAsync(skippedEvent, ct);
+ return;
+ }
+
+ if (ShouldSkipByResume(runContext.ResumePlan, nodeId))
+ {
+ var skippedEvent = nodeContext.CreateEvent();
+ skippedEvent.Reason = "恢复执行时跳过已完成节点";
+ await nodeContext.PublishAsync(skippedEvent, ct);
+ return;
+ }
+
+ var startedEvent = nodeContext.CreateEvent();
+ await nodeContext.PublishAsync(startedEvent, ct);
+
+ try
+ {
+ if (task is IGearTaskResumable resumable)
+ {
+ await resumable.ApplyResumeTokenAsync(nodeContext.ResumeTokenJson, ct);
+ }
+
+ await task.Execute(ct);
+
+ var childOrder = 0;
+ foreach (var child in task.Children)
+ {
+ await ExecuteNodeAsync(child, runContext, $"{nodeId}/{childOrder}", nodeId, depth + 1, childOrder, ct);
+ childOrder++;
+ }
+
+ var completedEvent = nodeContext.CreateEvent();
+ completedEvent.Message = "节点执行完成";
+ await nodeContext.PublishAsync(completedEvent, ct);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "执行节点失败: {TaskName} ({NodeId})", task.Name, nodeId);
+ var failedEvent = nodeContext.CreateEvent();
+ failedEvent.ErrorMessage = ex.Message;
+ await nodeContext.PublishAsync(failedEvent, CancellationToken.None);
+ throw;
+ }
+ }
+
+ private static string? GetResumeToken(GearTaskResumePlan? plan, string nodeId)
+ {
+ if (plan == null)
+ {
+ return null;
+ }
+
+ return plan.NodeResumeTokens.TryGetValue(nodeId, out var token) ? token : null;
+ }
+
+ private static bool ShouldSkipByResume(GearTaskResumePlan? plan, string nodeId)
+ {
+ if (plan == null || string.IsNullOrWhiteSpace(plan.ResumeNodeId))
+ {
+ return false;
+ }
+
+ if (nodeId == plan.ResumeNodeId)
+ {
+ return false;
+ }
+
+ if (plan.ResumeNodeId.StartsWith(nodeId + "/", StringComparison.Ordinal))
+ {
+ // 恢复节点的祖先链仍然需要执行,否则无法走到真正的恢复节点。
+ return false;
+ }
+
+ return IsNodeBefore(nodeId, plan.ResumeNodeId);
+ }
+
+ ///
+ /// 比较两个节点路径的顺序,供恢复逻辑判断当前节点是否位于恢复点之前。
+ ///
+ private static bool IsNodeBefore(string leftNodeId, string rightNodeId)
+ {
+ var left = leftNodeId.Split('/').Select(int.Parse).ToArray();
+ var right = rightNodeId.Split('/').Select(int.Parse).ToArray();
+ var minLength = Math.Min(left.Length, right.Length);
+ for (var i = 0; i < minLength; i++)
+ {
+ if (left[i] != right[i])
+ {
+ return left[i] < right[i];
+ }
+ }
+
+ return left.Length < right.Length;
+ }
+
+ ///
+ /// 统计任务树节点总数,用于生成执行进度的总量基线。
+ ///
+ private static int CountTotalNodes(BaseGearTask task)
+ {
+ return 1 + task.Children.Sum(CountTotalNodes);
+ }
+
+ private GearTaskNodeExecutionContext CreateNodeContext(
+ BaseGearTask task,
+ GearTaskExecutionRunContext runContext,
+ string nodeId,
+ string? parentNodeId,
+ int depth,
+ int order)
+ {
+ return GearTaskNodeExecutionContext.Create(
+ _eventBus,
+ task,
+ runContext,
+ nodeId,
+ parentNodeId,
+ depth,
+ order,
+ GetResumeToken(runContext.ResumePlan, nodeId));
+ }
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryRecorder.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryRecorder.cs
new file mode 100644
index 00000000..604fb5d3
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryRecorder.cs
@@ -0,0 +1,404 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Channels;
+using System.Threading.Tasks;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// GearTask 历史记录器。
+/// 通过订阅执行事件把事件写入 Channel,再由单消费者后台循环聚合并异步落盘。
+///
+public sealed class GearTaskHistoryRecorder : BackgroundService, IGearTaskEventConsumer
+{
+ private static readonly TimeSpan FlushDebounce = TimeSpan.FromMilliseconds(300);
+
+ private readonly ILogger _logger;
+ private readonly IGearTaskHistoryStore _historyStore;
+ private readonly IDisposable _subscription;
+ private readonly Channel _eventChannel;
+ private readonly Dictionary _records = [];
+ private readonly Dictionary _dirtyRecords = [];
+ private readonly HashSet _priorityFlushRecords = [];
+ private bool _disposed;
+
+ public GearTaskHistoryRecorder(
+ ILogger logger,
+ IGearTaskHistoryStore historyStore,
+ IGearTaskEventBus eventBus)
+ {
+ _logger = logger;
+ _historyStore = historyStore;
+ _subscription = eventBus.Subscribe(this);
+ _eventChannel = Channel.CreateUnbounded(new UnboundedChannelOptions
+ {
+ SingleReader = true,
+ SingleWriter = false,
+ AllowSynchronousContinuations = false,
+ });
+ }
+
+ public ValueTask ConsumeAsync(GearTaskExecutionEvent evt, CancellationToken ct = default)
+ {
+ if (_disposed)
+ {
+ return ValueTask.CompletedTask;
+ }
+
+ if (!_eventChannel.Writer.TryWrite(evt))
+ {
+ _logger.LogDebug("GearTask 历史记录事件被忽略,记录器可能正在关闭: {RecordId}", evt.RecordId);
+ }
+
+ return ValueTask.CompletedTask;
+ }
+
+ protected override async Task ExecuteAsync(CancellationToken stoppingToken)
+ {
+ try
+ {
+ while (true)
+ {
+ var waitForEventTask = _eventChannel.Reader.WaitToReadAsync(stoppingToken).AsTask();
+ var waitForFlushDelayTask = Task.Delay(FlushDebounce, stoppingToken);
+ var completedTask = await Task.WhenAny(waitForEventTask, waitForFlushDelayTask);
+
+ if (completedTask == waitForEventTask)
+ {
+ if (!await waitForEventTask)
+ {
+ break;
+ }
+
+ while (_eventChannel.Reader.TryRead(out var evt))
+ {
+ ProcessEvent(evt);
+ }
+ }
+
+ await FlushDirtyRecordsAsync(force: false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // shutdown
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "GearTask 历史记录后台消费者异常退出");
+ }
+ finally
+ {
+ try
+ {
+ while (_eventChannel.Reader.TryRead(out var evt))
+ {
+ ProcessEvent(evt);
+ }
+
+ await FlushDirtyRecordsAsync(force: true);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "GearTask 历史记录器在退出时落盘失败");
+ }
+ }
+ }
+
+ private void ProcessEvent(GearTaskExecutionEvent evt)
+ {
+ var record = GetOrAddRecord(evt);
+ ApplyEvent(record, evt);
+
+ _dirtyRecords[record.RecordId] = DateTime.UtcNow;
+ if (IsTerminalEvent(evt))
+ {
+ // 终态记录在当前批次处理完后优先刷盘,尽量避免任务刚结束但历史还没落地。
+ _priorityFlushRecords.Add(record.RecordId);
+ }
+ }
+
+ private GearTaskExecutionRecord GetOrAddRecord(GearTaskExecutionEvent evt)
+ {
+ if (_records.TryGetValue(evt.RecordId, out var record))
+ {
+ return record;
+ }
+
+ record = new GearTaskExecutionRecord
+ {
+ RecordId = evt.RecordId,
+ TaskDefinitionName = evt.TaskDefinitionName,
+ TaskDefinitionFileKey = evt.TaskDefinitionFileKey,
+ };
+ _records[evt.RecordId] = record;
+ return record;
+ }
+
+ private async Task FlushDirtyRecordsAsync(bool force)
+ {
+ if (_dirtyRecords.Count == 0)
+ {
+ return;
+ }
+
+ var now = DateTime.UtcNow;
+ var recordIds = _dirtyRecords.Keys.ToList();
+ foreach (var recordId in recordIds)
+ {
+ if (!_records.TryGetValue(recordId, out var record))
+ {
+ _dirtyRecords.Remove(recordId);
+ _priorityFlushRecords.Remove(recordId);
+ continue;
+ }
+
+ var shouldFlush = force
+ || _priorityFlushRecords.Contains(recordId)
+ || now - _dirtyRecords[recordId] >= FlushDebounce;
+
+ if (!shouldFlush)
+ {
+ continue;
+ }
+
+ try
+ {
+ await _historyStore.SaveAsync(record);
+ await _historyStore.TrimAsync(record.TaskDefinitionFileKey, 30);
+
+ _dirtyRecords.Remove(recordId);
+ _priorityFlushRecords.Remove(recordId);
+
+ if (IsTerminalStatus(record.Status))
+ {
+ // 历史记录已经落盘,终态记录不再保留在内存里,避免长期运行时内存持续增长。
+ _records.Remove(recordId);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "GearTask 历史记录落盘失败,将在后续批次重试。RecordId: {RecordId}", recordId);
+ }
+ }
+ }
+
+ private static bool IsTerminalEvent(GearTaskExecutionEvent evt)
+ {
+ return evt is ExecutionCompletedEvent or ExecutionFailedEvent or ExecutionCancelledEvent or ExecutionInterruptedEvent;
+ }
+
+ private static bool IsTerminalStatus(GearTaskExecutionRecordStatus status)
+ {
+ return status is GearTaskExecutionRecordStatus.Succeeded
+ or GearTaskExecutionRecordStatus.Failed
+ or GearTaskExecutionRecordStatus.Cancelled
+ or GearTaskExecutionRecordStatus.Interrupted;
+ }
+
+ private static void ApplyEvent(GearTaskExecutionRecord record, GearTaskExecutionEvent evt)
+ {
+ record.TaskDefinitionName = evt.TaskDefinitionName;
+ record.TaskDefinitionFileKey = evt.TaskDefinitionFileKey;
+ record.CurrentNodeId = evt.NodeId;
+
+ switch (evt)
+ {
+ case ExecutionStartedEvent started:
+ record.SourceRecordId = started.SourceRecordId;
+ record.StartTime = started.Timestamp;
+ record.Status = GearTaskExecutionRecordStatus.Running;
+ record.TotalRunnableNodeCount = started.TotalRunnableNodeCount;
+ break;
+ case ExecutionCompletedEvent completed:
+ record.Status = GearTaskExecutionRecordStatus.Succeeded;
+ record.EndTime = completed.Timestamp;
+ break;
+ case ExecutionFailedEvent failed:
+ record.Status = GearTaskExecutionRecordStatus.Failed;
+ record.ErrorMessage = failed.ErrorMessage;
+ record.EndTime = failed.Timestamp;
+ break;
+ case ExecutionCancelledEvent cancelled:
+ record.Status = GearTaskExecutionRecordStatus.Cancelled;
+ record.InterruptReason = cancelled.Reason;
+ record.EndTime = cancelled.Timestamp;
+ break;
+ case ExecutionInterruptedEvent interrupted:
+ record.Status = GearTaskExecutionRecordStatus.Interrupted;
+ record.InterruptReason = interrupted.Reason;
+ record.EndTime = interrupted.Timestamp;
+ break;
+ case TaskNodeStartedEvent:
+ {
+ var node = GetOrAddNode(record, evt);
+ node.Status = GearTaskExecutionNodeStatus.Running;
+ node.StartTime ??= evt.Timestamp;
+ break;
+ }
+ case TaskNodeCompletedEvent completed:
+ {
+ var node = GetOrAddNode(record, evt);
+ node.Status = GearTaskExecutionNodeStatus.Succeeded;
+ node.EndTime = evt.Timestamp;
+ node.StatusMessage = completed.Message;
+ break;
+ }
+ case TaskNodeFailedEvent failed:
+ {
+ var node = GetOrAddNode(record, evt);
+ node.Status = GearTaskExecutionNodeStatus.Failed;
+ node.EndTime = evt.Timestamp;
+ node.ErrorMessage = failed.ErrorMessage;
+ break;
+ }
+ case TaskNodeSkippedEvent skipped:
+ {
+ var node = GetOrAddNode(record, evt);
+ node.Status = GearTaskExecutionNodeStatus.Skipped;
+ node.EndTime = evt.Timestamp;
+ node.StatusMessage = skipped.Reason;
+ break;
+ }
+ case ResumePointUpdatedEvent resume:
+ {
+ var node = GetOrAddNode(record, evt);
+ node.CanResumeInsideTask = resume.CanResumeInsideTask;
+ node.ResumeTokenJson = resume.ResumeTokenJson;
+ node.StatusMessage = resume.Message ?? node.StatusMessage;
+ record.ResumeNodeId = evt.NodeId;
+ record.CanResume = resume.CanResumeInsideTask || !string.IsNullOrWhiteSpace(resume.ResumeTokenJson);
+ break;
+ }
+ }
+
+ record.CompletedNodeCount = record.Nodes.Count(n => n.Status == GearTaskExecutionNodeStatus.Succeeded);
+ record.FailedNodeCount = record.Nodes.Count(n => n.Status == GearTaskExecutionNodeStatus.Failed);
+ record.SkippedNodeCount = record.Nodes.Count(n => n.Status == GearTaskExecutionNodeStatus.Skipped);
+
+ record.Timeline.Add(new GearTaskExecutionTimelineItem
+ {
+ Timestamp = evt.Timestamp,
+ NodeId = evt.NodeId,
+ EventType = evt.GetType().Name,
+ Message = BuildTimelineMessage(evt),
+ Extra = BuildTimelineExtra(evt),
+ });
+ }
+
+ private static string BuildTimelineMessage(GearTaskExecutionEvent evt)
+ {
+ return evt switch
+ {
+ ExecutionStartedEvent => "执行开始",
+ ExecutionCompletedEvent => "执行完成",
+ ExecutionFailedEvent failed => $"执行失败: {failed.ErrorMessage}",
+ ExecutionCancelledEvent cancelled => $"执行取消: {cancelled.Reason}",
+ ExecutionInterruptedEvent interrupted => $"执行中断: {interrupted.Reason}",
+ TaskNodeStartedEvent => "节点开始执行",
+ TaskNodeCompletedEvent completed => completed.Message ?? "节点执行完成",
+ TaskNodeFailedEvent failed => $"节点执行失败: {failed.ErrorMessage}",
+ TaskNodeSkippedEvent skipped => $"节点跳过: {skipped.Reason}",
+ ResumePointUpdatedEvent resume => resume.Message ?? "更新恢复点",
+ PathingWaypointEnteredEvent entered => $"进入路径点 {entered.WaypointGroupIndex}/{entered.WaypointIndex} ({entered.PositionX:F2}, {entered.PositionY:F2})",
+ PathingWaypointCompletedEvent completed => $"完成路径点 {completed.WaypointGroupIndex}/{completed.WaypointIndex} ({completed.PositionX:F2}, {completed.PositionY:F2})",
+ PathingTeleportCompletedEvent teleport => $"完成传送 {teleport.WaypointGroupIndex}/{teleport.WaypointIndex} ({teleport.PositionX:F2}, {teleport.PositionY:F2})",
+ PathingActionCompletedEvent action => $"完成动作 {action.Action} ({action.PositionX:F2}, {action.PositionY:F2})",
+ ScriptCheckpointReachedEvent script => $"脚本检查点 {script.CheckpointName}",
+ _ => evt.GetType().Name,
+ };
+ }
+
+ private static Dictionary BuildTimelineExtra(GearTaskExecutionEvent evt)
+ {
+ var extra = new Dictionary(evt.Extra);
+
+ switch (evt)
+ {
+ case PathingWaypointEnteredEvent entered:
+ FillPathingExtra(extra, entered.WaypointGroupIndex, entered.WaypointIndex, entered.PositionX, entered.PositionY, entered.Action);
+ break;
+ case PathingWaypointCompletedEvent completed:
+ FillPathingExtra(extra, completed.WaypointGroupIndex, completed.WaypointIndex, completed.PositionX, completed.PositionY, completed.Action);
+ break;
+ case PathingTeleportCompletedEvent teleport:
+ FillPathingExtra(extra, teleport.WaypointGroupIndex, teleport.WaypointIndex, teleport.PositionX, teleport.PositionY);
+ break;
+ case PathingActionCompletedEvent action:
+ FillPathingExtra(extra, action.WaypointGroupIndex, action.WaypointIndex, action.PositionX, action.PositionY, action.Action);
+ break;
+ }
+
+ return extra;
+ }
+
+ private static void FillPathingExtra(
+ Dictionary extra,
+ int waypointGroupIndex,
+ int waypointIndex,
+ double positionX,
+ double positionY,
+ string? action = null)
+ {
+ extra["waypointGroupIndex"] = waypointGroupIndex;
+ extra["waypointIndex"] = waypointIndex;
+ extra["positionX"] = positionX;
+ extra["positionY"] = positionY;
+
+ if (!string.IsNullOrWhiteSpace(action))
+ {
+ extra["action"] = action;
+ }
+ }
+
+ private static GearTaskExecutionNodeRecord GetOrAddNode(GearTaskExecutionRecord record, GearTaskExecutionEvent evt)
+ {
+ var node = record.Nodes.FirstOrDefault(n => n.NodeId == evt.NodeId);
+ if (node != null)
+ {
+ return node;
+ }
+
+ node = new GearTaskExecutionNodeRecord
+ {
+ NodeId = evt.NodeId,
+ ParentNodeId = evt.ParentNodeId,
+ TaskName = evt.TaskName,
+ TaskType = evt.TaskType,
+ TaskPath = evt.TaskPath,
+ Depth = evt.NodeDepth,
+ Order = evt.NodeOrder,
+ Status = GearTaskExecutionNodeStatus.Pending,
+ };
+ record.Nodes.Add(node);
+ return node;
+ }
+
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ if (!_disposed)
+ {
+ _disposed = true;
+ _subscription.Dispose();
+ _eventChannel.Writer.TryComplete();
+ }
+
+ await base.StopAsync(cancellationToken);
+ }
+
+ public override void Dispose()
+ {
+ if (!_disposed)
+ {
+ _disposed = true;
+ _subscription.Dispose();
+ _eventChannel.Writer.TryComplete();
+ }
+
+ base.Dispose();
+ }
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryStore.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryStore.cs
new file mode 100644
index 00000000..1e9d7a93
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskHistoryStore.cs
@@ -0,0 +1,132 @@
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// GearTask 历史记录存储接口。
+///
+public interface IGearTaskHistoryStore
+{
+ Task SaveAsync(GearTaskExecutionRecord record);
+
+ Task LoadAsync(string taskDefinitionFileKey, string recordId);
+
+ Task> LoadLatestAsync(string taskDefinitionFileKey, int count);
+
+ Task TrimAsync(string taskDefinitionFileKey, int keepCount);
+}
+
+///
+/// 基于磁盘 JSON 文件的历史记录存储。
+/// 一个执行记录对应一个文件,按任务定义分目录存放。
+///
+public sealed class GearTaskHistoryStore : IGearTaskHistoryStore
+{
+ private readonly JsonSerializerSettings _jsonSettings = new()
+ {
+ Formatting = Formatting.Indented,
+ NullValueHandling = NullValueHandling.Ignore,
+ DateFormatString = "yyyy-MM-dd HH:mm:ss.fff",
+ };
+
+ private readonly string _historyRootPath = Path.Combine(AppContext.BaseDirectory, "User", "task_v2", "history");
+
+ public GearTaskHistoryStore()
+ {
+ Directory.CreateDirectory(_historyRootPath);
+ }
+
+ public async Task SaveAsync(GearTaskExecutionRecord record)
+ {
+ var filePath = GetRecordFilePath(record.TaskDefinitionFileKey, record.RecordId, record.StartTime);
+ Directory.CreateDirectory(Path.GetDirectoryName(filePath)!);
+
+ // 先写临时文件再覆盖正式文件,避免进程中断时把历史记录写坏。
+ var json = JsonConvert.SerializeObject(record, _jsonSettings);
+ var tempPath = filePath + ".tmp";
+ await File.WriteAllTextAsync(tempPath, json);
+ File.Move(tempPath, filePath, true);
+ }
+
+ public async Task LoadAsync(string taskDefinitionFileKey, string recordId)
+ {
+ var directory = GetDefinitionDirectory(taskDefinitionFileKey);
+ if (!Directory.Exists(directory))
+ {
+ return null;
+ }
+
+ var filePath = Directory.GetFiles(directory, $"*_{recordId}.json").OrderByDescending(f => f).FirstOrDefault();
+ if (filePath == null)
+ {
+ return null;
+ }
+
+ var json = await File.ReadAllTextAsync(filePath);
+ return JsonConvert.DeserializeObject(json, _jsonSettings);
+ }
+
+ public async Task> LoadLatestAsync(string taskDefinitionFileKey, int count)
+ {
+ var directory = GetDefinitionDirectory(taskDefinitionFileKey);
+ if (!Directory.Exists(directory))
+ {
+ return [];
+ }
+
+ var files = Directory.GetFiles(directory, "*.json")
+ .OrderByDescending(Path.GetFileNameWithoutExtension)
+ .Take(count)
+ .ToList();
+
+ var list = new List(files.Count);
+ foreach (var file in files)
+ {
+ var json = await File.ReadAllTextAsync(file);
+ var record = JsonConvert.DeserializeObject(json, _jsonSettings);
+ if (record != null)
+ {
+ list.Add(record);
+ }
+ }
+
+ return list;
+ }
+
+ public Task TrimAsync(string taskDefinitionFileKey, int keepCount)
+ {
+ var directory = GetDefinitionDirectory(taskDefinitionFileKey);
+ if (!Directory.Exists(directory))
+ {
+ return Task.CompletedTask;
+ }
+
+ // 文件名以开始时间开头,按文件名倒序即可近似得到最近执行记录。
+ var files = Directory.GetFiles(directory, "*.json")
+ .OrderByDescending(Path.GetFileNameWithoutExtension)
+ .Skip(keepCount)
+ .ToList();
+
+ foreach (var file in files)
+ {
+ File.Delete(file);
+ }
+
+ return Task.CompletedTask;
+ }
+
+ public string GetDefinitionDirectory(string taskDefinitionFileKey)
+ {
+ return Path.Combine(_historyRootPath, taskDefinitionFileKey);
+ }
+
+ private string GetRecordFilePath(string taskDefinitionFileKey, string recordId, DateTime startTime)
+ {
+ return Path.Combine(GetDefinitionDirectory(taskDefinitionFileKey), $"{startTime:yyyyMMddHHmmss}_{recordId}.json");
+ }
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/GearTaskNodeExecutionContext.cs b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskNodeExecutionContext.cs
new file mode 100644
index 00000000..ec082e3e
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/GearTaskNodeExecutionContext.cs
@@ -0,0 +1,124 @@
+using System.Threading;
+using System.Threading.Tasks;
+using BetterGenshinImpact.Model.Gear.Tasks;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// 单个任务节点在一次执行中的上下文。
+/// 它负责把节点元数据与事件发布能力打包传给具体任务实例。
+///
+public sealed class GearTaskNodeExecutionContext
+{
+ private readonly IGearTaskEventBus _eventBus;
+
+ public GearTaskNodeExecutionContext(
+ IGearTaskEventBus eventBus,
+ string recordId,
+ string taskDefinitionName,
+ string taskDefinitionFileKey,
+ string nodeId,
+ string? parentNodeId,
+ string taskName,
+ string taskType,
+ string taskPath,
+ int nodeDepth,
+ int nodeOrder,
+ string? resumeTokenJson)
+ {
+ _eventBus = eventBus;
+ RecordId = recordId;
+ TaskDefinitionName = taskDefinitionName;
+ TaskDefinitionFileKey = taskDefinitionFileKey;
+ NodeId = nodeId;
+ ParentNodeId = parentNodeId;
+ TaskName = taskName;
+ TaskType = taskType;
+ TaskPath = taskPath;
+ NodeDepth = nodeDepth;
+ NodeOrder = nodeOrder;
+ ResumeTokenJson = resumeTokenJson;
+ }
+
+ ///
+ /// 使用任务节点和运行上下文创建节点执行上下文。
+ ///
+ public static GearTaskNodeExecutionContext Create(
+ IGearTaskEventBus eventBus,
+ BaseGearTask task,
+ GearTaskExecutionRunContext runContext,
+ string nodeId,
+ string? parentNodeId,
+ int nodeDepth,
+ int nodeOrder,
+ string? resumeTokenJson)
+ {
+ return new GearTaskNodeExecutionContext(
+ eventBus,
+ runContext.RecordId,
+ runContext.TaskDefinitionName,
+ runContext.TaskDefinitionFileKey,
+ nodeId,
+ parentNodeId,
+ task.Name,
+ task.Type,
+ GearTaskExecutionPathFormatter.FormatTaskPath(task.FilePath),
+ nodeDepth,
+ nodeOrder,
+ resumeTokenJson);
+ }
+
+ public string RecordId { get; }
+
+ public string TaskDefinitionName { get; }
+
+ public string TaskDefinitionFileKey { get; }
+
+ public string NodeId { get; }
+
+ public string? ParentNodeId { get; }
+
+ public string TaskName { get; }
+
+ public string TaskType { get; }
+
+ public string TaskPath { get; }
+
+ public int NodeDepth { get; }
+
+ public int NodeOrder { get; }
+
+ ///
+ /// 从历史记录恢复时传入的节点级恢复令牌。
+ /// 由实现了 IGearTaskResumable 的任务自行解释。
+ ///
+ public string? ResumeTokenJson { get; }
+
+ ///
+ /// 基于当前节点信息构造一条事件,避免各任务重复填公共字段。
+ ///
+ public T CreateEvent() where T : GearTaskExecutionEvent, new()
+ {
+ return new T
+ {
+ RecordId = RecordId,
+ TaskDefinitionName = TaskDefinitionName,
+ TaskDefinitionFileKey = TaskDefinitionFileKey,
+ NodeId = NodeId,
+ ParentNodeId = ParentNodeId,
+ TaskName = TaskName,
+ TaskType = TaskType,
+ TaskPath = TaskPath,
+ NodeDepth = NodeDepth,
+ NodeOrder = NodeOrder,
+ };
+ }
+
+ ///
+ /// 发布当前节点相关事件。
+ ///
+ public ValueTask PublishAsync(GearTaskExecutionEvent evt, CancellationToken ct = default)
+ {
+ return _eventBus.PublishAsync(evt, ct);
+ }
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskEventBus.cs b/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskEventBus.cs
new file mode 100644
index 00000000..0e46ca9e
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskEventBus.cs
@@ -0,0 +1,30 @@
+using System;
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// GearTask 执行事件总线。
+/// 负责在执行器与记录器、UI 投影等消费者之间解耦。
+///
+public interface IGearTaskEventBus
+{
+ ///
+ /// 发布一条执行事件。
+ ///
+ ValueTask PublishAsync(GearTaskExecutionEvent evt, CancellationToken ct = default);
+
+ ///
+ /// 注册一个事件消费者。
+ ///
+ IDisposable Subscribe(IGearTaskEventConsumer consumer);
+}
+
+///
+/// GearTask 执行事件消费者。
+///
+public interface IGearTaskEventConsumer
+{
+ ValueTask ConsumeAsync(GearTaskExecutionEvent evt, CancellationToken ct = default);
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskResumable.cs b/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskResumable.cs
new file mode 100644
index 00000000..77146768
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/IGearTaskResumable.cs
@@ -0,0 +1,13 @@
+using System.Threading;
+using System.Threading.Tasks;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// 支持节点内部恢复的 GearTask 需要实现的接口。
+/// 执行器会在节点执行前注入上次记录下来的恢复令牌。
+///
+public interface IGearTaskResumable
+{
+ Task ApplyResumeTokenAsync(string? resumeTokenJson, CancellationToken ct);
+}
diff --git a/BetterGenshinImpact/Service/GearTask/Execution/PathingRuntimeNotifier.cs b/BetterGenshinImpact/Service/GearTask/Execution/PathingRuntimeNotifier.cs
new file mode 100644
index 00000000..ff187694
--- /dev/null
+++ b/BetterGenshinImpact/Service/GearTask/Execution/PathingRuntimeNotifier.cs
@@ -0,0 +1,118 @@
+using System.Threading;
+using System.Threading.Tasks;
+using Newtonsoft.Json;
+
+namespace BetterGenshinImpact.Service.GearTask.Execution;
+
+///
+/// Pathing 运行期通知接口。
+/// PathExecutor 从业务步骤出发发出通知,外部可以选择记录、展示或转发。
+///
+public interface IPathingRuntimeNotifier
+{
+ ValueTask NotifyWaypointEnteredAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default);
+
+ ValueTask NotifyWaypointCompletedAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default);
+
+ ValueTask NotifyTeleportCompletedAsync(int waypointGroupIndex, int waypointIndex, double positionX, double positionY, CancellationToken ct = default);
+
+ ValueTask NotifyActionCompletedAsync(int waypointGroupIndex, int waypointIndex, string action, double positionX, double positionY, CancellationToken ct = default);
+
+ ValueTask NotifyResumePointUpdatedAsync(PathingGearTaskResumeState state, string? message = null, CancellationToken ct = default);
+}
+
+///
+/// 空通知器,供不关心 Pathing 运行态的场景使用。
+///
+public sealed class NullPathingRuntimeNotifier : IPathingRuntimeNotifier
+{
+ public static readonly NullPathingRuntimeNotifier Instance = new();
+
+ public ValueTask NotifyActionCompletedAsync(int waypointGroupIndex, int waypointIndex, string action, double positionX, double positionY, CancellationToken ct = default) => ValueTask.CompletedTask;
+
+ public ValueTask NotifyResumePointUpdatedAsync(PathingGearTaskResumeState state, string? message = null, CancellationToken ct = default) => ValueTask.CompletedTask;
+
+ public ValueTask NotifyTeleportCompletedAsync(int waypointGroupIndex, int waypointIndex, double positionX, double positionY, CancellationToken ct = default) => ValueTask.CompletedTask;
+
+ public ValueTask NotifyWaypointCompletedAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default) => ValueTask.CompletedTask;
+
+ public ValueTask NotifyWaypointEnteredAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default) => ValueTask.CompletedTask;
+}
+
+///
+/// 把 Pathing 运行步骤转换成 GearTask 执行事件。
+///
+public sealed class GearTaskPathingRuntimeNotifier : IPathingRuntimeNotifier
+{
+ private readonly GearTaskNodeExecutionContext _context;
+
+ public GearTaskPathingRuntimeNotifier(GearTaskNodeExecutionContext context)
+ {
+ _context = context;
+ }
+
+ public ValueTask NotifyWaypointEnteredAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default)
+ {
+ var evt = _context.CreateEvent();
+ evt.WaypointGroupIndex = waypointGroupIndex;
+ evt.WaypointIndex = waypointIndex;
+ evt.Action = action;
+ evt.PositionX = positionX;
+ evt.PositionY = positionY;
+ evt.Extra["positionX"] = positionX;
+ evt.Extra["positionY"] = positionY;
+ return _context.PublishAsync(evt, ct);
+ }
+
+ public ValueTask NotifyWaypointCompletedAsync(int waypointGroupIndex, int waypointIndex, string? action, double positionX, double positionY, CancellationToken ct = default)
+ {
+ var evt = _context.CreateEvent();
+ evt.WaypointGroupIndex = waypointGroupIndex;
+ evt.WaypointIndex = waypointIndex;
+ evt.Action = action;
+ evt.PositionX = positionX;
+ evt.PositionY = positionY;
+ evt.Extra["positionX"] = positionX;
+ evt.Extra["positionY"] = positionY;
+ return _context.PublishAsync(evt, ct);
+ }
+
+ public ValueTask NotifyTeleportCompletedAsync(int waypointGroupIndex, int waypointIndex, double positionX, double positionY, CancellationToken ct = default)
+ {
+ var evt = _context.CreateEvent();
+ evt.WaypointGroupIndex = waypointGroupIndex;
+ evt.WaypointIndex = waypointIndex;
+ evt.PositionX = positionX;
+ evt.PositionY = positionY;
+ evt.Extra["positionX"] = positionX;
+ evt.Extra["positionY"] = positionY;
+ return _context.PublishAsync(evt, ct);
+ }
+
+ public ValueTask NotifyActionCompletedAsync(int waypointGroupIndex, int waypointIndex, string action, double positionX, double positionY, CancellationToken ct = default)
+ {
+ var evt = _context.CreateEvent();
+ evt.WaypointGroupIndex = waypointGroupIndex;
+ evt.WaypointIndex = waypointIndex;
+ evt.Action = action;
+ evt.PositionX = positionX;
+ evt.PositionY = positionY;
+ evt.Extra["positionX"] = positionX;
+ evt.Extra["positionY"] = positionY;
+ return _context.PublishAsync(evt, ct);
+ }
+
+ public ValueTask NotifyResumePointUpdatedAsync(PathingGearTaskResumeState state, string? message = null, CancellationToken ct = default)
+ {
+ if (string.IsNullOrWhiteSpace(state.PathFile))
+ {
+ state.PathFile = _context.TaskPath;
+ }
+
+ var evt = _context.CreateEvent();
+ evt.CanResumeInsideTask = true;
+ evt.ResumeTokenJson = JsonConvert.SerializeObject(state);
+ evt.Message = message;
+ return _context.PublishAsync(evt, ct);
+ }
+}
diff --git a/BetterGenshinImpact/Service/GearTask/GearTaskExecutionManager.cs b/BetterGenshinImpact/Service/GearTask/GearTaskExecutionManager.cs
deleted file mode 100644
index 5825a153..00000000
--- a/BetterGenshinImpact/Service/GearTask/GearTaskExecutionManager.cs
+++ /dev/null
@@ -1,489 +0,0 @@
-using System;
-using System.Collections.Generic;
-using System.Threading;
-using System.Threading.Tasks;
-using BetterGenshinImpact.Model.Gear.Tasks;
-using CommunityToolkit.Mvvm.ComponentModel;
-using Microsoft.Extensions.Logging;
-
-namespace BetterGenshinImpact.Service;
-
-///
-/// 任务执行状态
-///
-public enum TaskExecutionStatus
-{
- ///
- /// 等待执行
- ///
- Pending,
-
- ///
- /// 正在执行
- ///
- Running,
-
- ///
- /// 执行完成
- ///
- Completed,
-
- ///
- /// 执行失败
- ///
- Failed,
-
- ///
- /// 已取消
- ///
- Cancelled,
-
- ///
- /// 已跳过
- ///
- Skipped
-}
-
-///
-/// 任务执行信息
-///
-public partial class TaskExecutionInfo : ObservableObject
-{
- [ObservableProperty]
- private string _taskName = string.Empty;
-
- [ObservableProperty]
- private string _taskType = string.Empty;
-
- [ObservableProperty]
- private TaskExecutionStatus _status = TaskExecutionStatus.Pending;
-
- [ObservableProperty]
- private DateTime _startTime;
-
- [ObservableProperty]
- private DateTime _endTime;
-
- [ObservableProperty]
- private TimeSpan _duration;
-
- [ObservableProperty]
- private string _errorMessage = string.Empty;
-
- [ObservableProperty]
- private double _progress;
-
- [ObservableProperty]
- private string _statusMessage = string.Empty;
-
- public BaseGearTask? Task { get; set; }
- public List Children { get; set; } = new();
- public TaskExecutionInfo? Parent { get; set; }
-}
-
-///
-/// 齿轮任务执行管理器,负责管理任务执行状态和进度跟踪
-///
-public partial class GearTaskExecutionManager : ObservableObject
-{
- private readonly ILogger _logger;
- private readonly Dictionary _taskInfoMap = new();
- private CancellationTokenSource? _cancellationTokenSource;
-
- [ObservableProperty]
- private TaskExecutionInfo? _rootTaskInfo;
-
- [ObservableProperty]
- private TaskExecutionInfo? _currentTaskInfo;
-
- [ObservableProperty]
- private bool _isExecuting;
-
- [ObservableProperty]
- private double _overallProgress;
-
- [ObservableProperty]
- private string _overallStatusMessage = string.Empty;
-
- [ObservableProperty]
- private int _totalTasks;
-
- [ObservableProperty]
- private int _completedTasks;
-
- [ObservableProperty]
- private int _failedTasks;
-
- [ObservableProperty]
- private int _skippedTasks;
-
- public event EventHandler? TaskStarted;
- public event EventHandler? TaskCompleted;
- public event EventHandler? TaskFailed;
- public event EventHandler? TaskSkipped;
- public event EventHandler? ProgressChanged;
-
- public GearTaskExecutionManager(ILogger logger)
- {
- _logger = logger;
- }
-
- ///
- /// 开始执行任务并跟踪状态
- ///
- /// 根任务
- /// 取消令牌
- ///
- public async Task ExecuteWithTrackingAsync(BaseGearTask rootTask, CancellationToken ct = default)
- {
- if (IsExecuting)
- {
- throw new InvalidOperationException("任务执行管理器正在运行中");
- }
-
- try
- {
- IsExecuting = true;
- _cancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct);
-
- // 初始化执行信息
- InitializeExecutionInfo(rootTask);
-
- await ScriptService.StartGameTask();
-
- // 开始执行
- await ExecuteTaskWithTrackingAsync(rootTask, _cancellationTokenSource.Token);
-
- // 更新最终状态
- UpdateOverallStatus();
- }
- catch (OperationCanceledException)
- {
- _logger.LogInformation("任务执行已取消");
- OverallStatusMessage = "任务执行已取消";
- throw;
- }
- catch (Exception ex)
- {
- _logger.LogError(ex, "任务执行过程中发生错误");
- OverallStatusMessage = $"任务执行失败: {ex.Message}";
- throw;
- }
- finally
- {
- IsExecuting = false;
- _cancellationTokenSource?.Dispose();
- _cancellationTokenSource = null;
- }
- }
-
- ///
- /// 取消任务执行
- ///
- public void CancelExecution()
- {
- if (IsExecuting && _cancellationTokenSource != null)
- {
- _logger.LogInformation("用户请求取消任务执行");
- _cancellationTokenSource.Cancel();
- OverallStatusMessage = "正在取消任务执行...";
- }
- }
-
- ///
- /// 初始化执行信息
- ///
- /// 根任务
- private void InitializeExecutionInfo(BaseGearTask rootTask)
- {
- _taskInfoMap.Clear();
- CompletedTasks = 0;
- FailedTasks = 0;
- SkippedTasks = 0;
- OverallProgress = 0;
-
- RootTaskInfo = CreateTaskExecutionInfo(rootTask);
- TotalTasks = CountTotalTasks(rootTask);
-
- OverallStatusMessage = "准备执行任务...";
-
- _logger.LogInformation("初始化任务执行跟踪,总任务数: {TotalTasks}", TotalTasks);
- }
-
- ///
- /// 创建任务执行信息
- ///
- /// 任务
- /// 父任务信息
- ///
- private TaskExecutionInfo CreateTaskExecutionInfo(BaseGearTask task, TaskExecutionInfo? parent = null)
- {
- var info = new TaskExecutionInfo
- {
- TaskName = task.Name,
- TaskType = task.Type,
- Status = TaskExecutionStatus.Pending,
- Task = task,
- Parent = parent
- };
-
- _taskInfoMap[task] = info;
-
- // 递归创建子任务信息
- if (task.Children?.Count > 0)
- {
- foreach (var child in task.Children)
- {
- var childInfo = CreateTaskExecutionInfo(child, info);
- info.Children.Add(childInfo);
- }
- }
-
- return info;
- }
-
- ///
- /// 执行任务并跟踪状态
- ///
- /// 要执行的任务
- /// 取消令牌
- ///
- private async Task ExecuteTaskWithTrackingAsync(BaseGearTask task, CancellationToken ct)
- {
- if (!_taskInfoMap.TryGetValue(task, out var taskInfo))
- {
- _logger.LogWarning("未找到任务执行信息: {TaskName}", task.Name);
- return;
- }
-
- CurrentTaskInfo = taskInfo;
-
- // 检查任务是否启用
- if (!task.Enabled)
- {
- await MarkTaskSkipped(taskInfo, "任务已禁用");
- return;
- }
-
- try
- {
- // 开始执行任务
- await StartTask(taskInfo);
-
- // 执行当前任务
- await task.Execute(ct);
-
- // 执行子任务
- if (task.Children?.Count > 0)
- {
- for (int i = 0; i < task.Children.Count; i++)
- {
- var child = task.Children[i];
- await ExecuteTaskWithTrackingAsync(child, ct);
-
- // 更新进度
- var childProgress = (double)(i + 1) / task.Children.Count * 100;
- taskInfo.Progress = childProgress;
- UpdateOverallProgress();
- }
- }
-
- // 完成任务
- await CompleteTask(taskInfo);
- }
- catch (OperationCanceledException)
- {
- await MarkTaskCancelled(taskInfo);
- throw;
- }
- catch (Exception ex)
- {
- await FailTask(taskInfo, ex);
- throw;
- }
- }
-
- ///
- /// 开始执行任务
- ///
- /// 任务信息
- private async Task StartTask(TaskExecutionInfo taskInfo)
- {
- taskInfo.Status = TaskExecutionStatus.Running;
- taskInfo.StartTime = DateTime.Now;
- taskInfo.StatusMessage = "正在执行...";
-
- _logger.LogInformation("开始执行任务: {TaskName}", taskInfo.TaskName);
-
- TaskStarted?.Invoke(this, taskInfo);
- await Task.CompletedTask;
- }
-
- ///
- /// 完成任务
- ///
- /// 任务信息
- private async Task CompleteTask(TaskExecutionInfo taskInfo)
- {
- taskInfo.Status = TaskExecutionStatus.Completed;
- taskInfo.EndTime = DateTime.Now;
- taskInfo.Duration = taskInfo.EndTime - taskInfo.StartTime;
- taskInfo.Progress = 100;
- taskInfo.StatusMessage = "执行完成";
-
- CompletedTasks++;
-
- _logger.LogInformation("任务执行完成: {TaskName}, 耗时: {Duration}ms",
- taskInfo.TaskName, taskInfo.Duration.TotalMilliseconds);
-
- TaskCompleted?.Invoke(this, taskInfo);
- UpdateOverallProgress();
-
- await Task.CompletedTask;
- }
-
- ///
- /// 任务执行失败
- ///
- /// 任务信息
- /// 异常
- private async Task FailTask(TaskExecutionInfo taskInfo, Exception exception)
- {
- taskInfo.Status = TaskExecutionStatus.Failed;
- taskInfo.EndTime = DateTime.Now;
- taskInfo.Duration = taskInfo.EndTime - taskInfo.StartTime;
- taskInfo.ErrorMessage = exception.Message;
- taskInfo.StatusMessage = $"执行失败: {exception.Message}";
-
- FailedTasks++;
-
- _logger.LogError(exception, "任务执行失败: {TaskName}", taskInfo.TaskName);
-
- TaskFailed?.Invoke(this, taskInfo);
- UpdateOverallProgress();
-
- await Task.CompletedTask;
- }
-
- ///
- /// 标记任务为已跳过
- ///
- /// 任务信息
- /// 跳过原因
- private async Task MarkTaskSkipped(TaskExecutionInfo taskInfo, string reason)
- {
- taskInfo.Status = TaskExecutionStatus.Skipped;
- taskInfo.StatusMessage = $"已跳过: {reason}";
-
- SkippedTasks++;
-
- _logger.LogInformation("任务已跳过: {TaskName}, 原因: {Reason}", taskInfo.TaskName, reason);
-
- TaskSkipped?.Invoke(this, taskInfo);
- UpdateOverallProgress();
-
- await Task.CompletedTask;
- }
-
- ///
- /// 标记任务为已取消
- ///
- /// 任务信息
- private async Task MarkTaskCancelled(TaskExecutionInfo taskInfo)
- {
- taskInfo.Status = TaskExecutionStatus.Cancelled;
- taskInfo.EndTime = DateTime.Now;
- taskInfo.Duration = taskInfo.EndTime - taskInfo.StartTime;
- taskInfo.StatusMessage = "已取消";
-
- _logger.LogInformation("任务已取消: {TaskName}", taskInfo.TaskName);
-
- await Task.CompletedTask;
- }
-
- ///
- /// 更新整体进度
- ///
- private void UpdateOverallProgress()
- {
- if (TotalTasks > 0)
- {
- var processedTasks = CompletedTasks + FailedTasks + SkippedTasks;
- OverallProgress = (double)processedTasks / TotalTasks * 100;
-
- OverallStatusMessage = $"进度: {processedTasks}/{TotalTasks} (完成: {CompletedTasks}, 失败: {FailedTasks}, 跳过: {SkippedTasks})";
-
- ProgressChanged?.Invoke(this, OverallProgress);
- }
- }
-
- ///
- /// 更新最终状态
- ///
- private void UpdateOverallStatus()
- {
- if (FailedTasks > 0)
- {
- OverallStatusMessage = $"执行完成,但有 {FailedTasks} 个任务失败";
- }
- else if (SkippedTasks > 0)
- {
- OverallStatusMessage = $"执行完成,跳过了 {SkippedTasks} 个任务";
- }
- else
- {
- OverallStatusMessage = "所有任务执行完成";
- }
- }
-
- ///
- /// 统计任务总数
- ///
- /// 根任务
- /// 任务总数
- private int CountTotalTasks(BaseGearTask task)
- {
- int count = 1;
- if (task.Children?.Count > 0)
- {
- foreach (var child in task.Children)
- {
- count += CountTotalTasks(child);
- }
- }
- return count;
- }
-
- ///
- /// 获取任务执行统计信息
- ///
- /// 统计信息
- public TaskExecutionStatistics GetStatistics()
- {
- return new TaskExecutionStatistics
- {
- TotalTasks = TotalTasks,
- CompletedTasks = CompletedTasks,
- FailedTasks = FailedTasks,
- SkippedTasks = SkippedTasks,
- OverallProgress = OverallProgress,
- IsExecuting = IsExecuting
- };
- }
-}
-
-///
-/// 任务执行统计信息
-///
-public class TaskExecutionStatistics
-{
- public int TotalTasks { get; set; }
- public int CompletedTasks { get; set; }
- public int FailedTasks { get; set; }
- public int SkippedTasks { get; set; }
- public double OverallProgress { get; set; }
- public bool IsExecuting { get; set; }
-
- public int ProcessedTasks => CompletedTasks + FailedTasks + SkippedTasks;
- public double SuccessRate => TotalTasks > 0 ? (double)CompletedTasks / TotalTasks * 100 : 0;
-}
\ No newline at end of file
diff --git a/BetterGenshinImpact/Service/GearTask/GearTaskExecutor.cs b/BetterGenshinImpact/Service/GearTask/GearTaskExecutor.cs
index 9fb84fbe..81a86290 100644
--- a/BetterGenshinImpact/Service/GearTask/GearTaskExecutor.cs
+++ b/BetterGenshinImpact/Service/GearTask/GearTaskExecutor.cs
@@ -1,59 +1,61 @@
using System;
using System.Collections.Generic;
-using System.ComponentModel;
+using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using BetterGenshinImpact.Core.Script;
using BetterGenshinImpact.GameTask;
using BetterGenshinImpact.Model.Gear;
using BetterGenshinImpact.Model.Gear.Tasks;
-using BetterGenshinImpact.Service.GearTask;
+using BetterGenshinImpact.Service.GearTask.Execution;
using BetterGenshinImpact.ViewModel.Pages.Component;
-using Microsoft.Extensions.Logging;
-using Microsoft.Extensions.DependencyInjection;
using CommunityToolkit.Mvvm.ComponentModel;
+using Microsoft.Extensions.Logging;
namespace BetterGenshinImpact.Service;
///
-/// 齿轮任务执行器,负责从 JSON 数据解析任务并执行
+/// 齿轮任务执行入口,负责加载任务定义、构造执行上下文并启动执行。
///
-public partial class GearTaskExecutor : ObservableObject
+public partial class GearTaskExecutor : ObservableObject, IGearTaskEventConsumer, IDisposable
{
private readonly ILogger _logger;
- private readonly GearTaskStorageService _storageService;
+ private readonly BetterGenshinImpact.Service.GearTask.GearTaskStorageService _storageService;
private readonly GearTaskConverter _taskConverter;
- private readonly GearTaskExecutionManager _executionManager;
- private readonly IServiceProvider _serviceProvider;
-
+ private readonly IGearTaskExecutionRunner _executionRunner;
+ private readonly IGearTaskHistoryStore _historyStore;
+ private readonly IDisposable _subscription;
+ private CancellationTokenSource? _runningCancellationTokenSource;
+ private string? _activeRecordId;
+ private int _totalNodeCount;
+ private int _processedNodeCount;
+
[ObservableProperty]
private bool _isExecuting;
-
+
[ObservableProperty]
private string _currentTaskName = string.Empty;
-
+
[ObservableProperty]
private double _progress;
-
+
[ObservableProperty]
private string _statusMessage = string.Empty;
public GearTaskExecutor(
ILogger logger,
- GearTaskStorageService storageService,
+ BetterGenshinImpact.Service.GearTask.GearTaskStorageService storageService,
GearTaskConverter taskConverter,
- GearTaskExecutionManager executionManager,
- IServiceProvider serviceProvider)
+ IGearTaskExecutionRunner executionRunner,
+ IGearTaskHistoryStore historyStore,
+ IGearTaskEventBus eventBus)
{
_logger = logger;
_storageService = storageService;
_taskConverter = taskConverter;
- _executionManager = executionManager;
- _serviceProvider = serviceProvider;
-
- // 订阅执行管理器事件
- _executionManager.ProgressChanged += OnProgressChanged;
- _executionManager.PropertyChanged += OnExecutionManagerPropertyChanged;
+ _executionRunner = executionRunner;
+ _historyStore = historyStore;
+ _subscription = eventBus.Subscribe(this);
}
///
@@ -74,12 +76,12 @@ public partial class GearTaskExecutor : ObservableObject
IsExecuting = true;
StatusMessage = "正在加载任务定义...";
Progress = 0;
+ CurrentTaskName = string.Empty;
- // 从存储服务加载任务定义
var taskDefinitionViewModel = await _storageService.LoadTaskDefinitionAsync(taskDefinitionName);
if (taskDefinitionViewModel == null)
{
- throw new ArgumentException($"未找到任务定义: {taskDefinitionName}");
+ throw new ArgumentException($"未找到任务定义 {taskDefinitionName}");
}
if (taskDefinitionViewModel.RootTask == null)
@@ -87,120 +89,169 @@ public partial class GearTaskExecutor : ObservableObject
throw new InvalidOperationException($"任务定义 '{taskDefinitionName}' 没有根任务");
}
- _logger.LogInformation("开始执行任务定义: {TaskDefinitionName}", taskDefinitionName);
-
- // 转换为可执行的任务
- var rootTaskData = ConvertViewModelToData(taskDefinitionViewModel.RootTask);
- var rootTask = await _taskConverter.ConvertTaskDataAsync(rootTaskData);
-
- // 使用执行管理器执行任务
- CancellationContext.Instance.Set();
- using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, CancellationContext.Instance.GetActiveToken());
- await _executionManager.ExecuteWithTrackingAsync(rootTask, linkedCts.Token);
-
- StatusMessage = "任务执行完成";
- Progress = 100;
- _logger.LogInformation("任务定义执行完成: {TaskDefinitionName}", taskDefinitionName);
- }
- catch (OperationCanceledException)
- {
- StatusMessage = "任务执行已取消";
- _logger.LogInformation("任务定义执行已取消: {TaskDefinitionName}", taskDefinitionName);
- throw;
- }
- catch (Exception ex)
- {
- StatusMessage = $"任务执行失败: {ex.Message}";
- _logger.LogError(ex, "执行任务定义时发生错误: {TaskDefinitionName}", taskDefinitionName);
- throw;
+ await ExecuteInternalAsync(taskDefinitionViewModel, resumePlan: null, ct);
}
finally
{
- CancellationContext.Instance.Clear();
- RunnerContext.Instance.Clear();
- IsExecuting = false;
+ CleanupExecutionState();
}
}
- ///
- /// 停止当前执行的任务
- ///
- public void StopExecution()
+ public async Task ResumeTaskDefinitionAsync(string taskDefinitionName, string sourceRecordId, CancellationToken ct = default)
{
if (IsExecuting)
{
- StatusMessage = "正在停止任务执行...";
- _logger.LogInformation("用户请求停止任务执行");
- _executionManager.CancelExecution();
+ throw new InvalidOperationException("任务执行器正在运行中,请等待当前任务完成");
}
- }
- ///
- /// 处理进度变化事件
- ///
- /// 事件发送者
- /// 进度值
- private void OnProgressChanged(object? sender, double progress)
- {
- Progress = progress;
- }
-
- ///
- /// 处理执行管理器属性变化事件
- ///
- /// 事件发送者
- /// 属性变化事件参数
- private void OnExecutionManagerPropertyChanged(object? sender, PropertyChangedEventArgs e)
- {
- switch (e.PropertyName)
+ try
{
- case nameof(GearTaskExecutionManager.CurrentTaskInfo):
- if (_executionManager.CurrentTaskInfo != null)
- {
- CurrentTaskName = _executionManager.CurrentTaskInfo.TaskName;
- }
+ IsExecuting = true;
+ StatusMessage = "正在加载恢复记录...";
+ Progress = 0;
+ CurrentTaskName = string.Empty;
+
+ var taskDefinitionViewModel = await _storageService.LoadTaskDefinitionAsync(taskDefinitionName);
+ if (taskDefinitionViewModel == null)
+ {
+ throw new ArgumentException($"未找到任务定义 {taskDefinitionName}");
+ }
+
+ if (taskDefinitionViewModel.RootTask == null)
+ {
+ throw new InvalidOperationException($"任务定义 '{taskDefinitionName}' 没有根任务");
+ }
+
+ var taskDefinitionFileKey = GetTaskDefinitionFileKey(taskDefinitionViewModel.Name);
+ var sourceRecord = await _historyStore.LoadAsync(taskDefinitionFileKey, sourceRecordId);
+ if (sourceRecord == null)
+ {
+ throw new InvalidOperationException($"未找到恢复记录 {sourceRecordId}");
+ }
+
+ var resumePlan = BuildResumePlan(sourceRecord);
+ await ExecuteInternalAsync(taskDefinitionViewModel, resumePlan, ct);
+ }
+ finally
+ {
+ CleanupExecutionState();
+ }
+ }
+
+ public void StopExecution()
+ {
+ if (!IsExecuting || _runningCancellationTokenSource == null)
+ {
+ return;
+ }
+
+ StatusMessage = "正在停止任务执行...";
+ _logger.LogInformation("用户请求停止 GearTask 执行");
+ _runningCancellationTokenSource.Cancel();
+ }
+
+ public async ValueTask ConsumeAsync(GearTaskExecutionEvent evt, CancellationToken ct = default)
+ {
+ if (_activeRecordId == null || evt.RecordId != _activeRecordId)
+ {
+ return;
+ }
+
+ switch (evt)
+ {
+ case ExecutionStartedEvent started:
+ _totalNodeCount = Math.Max(started.TotalRunnableNodeCount, 1);
+ _processedNodeCount = 0;
+ StatusMessage = "任务执行中...";
+ Progress = 0;
break;
- case nameof(GearTaskExecutionManager.OverallStatusMessage):
- StatusMessage = _executionManager.OverallStatusMessage;
+ case TaskNodeStartedEvent:
+ CurrentTaskName = evt.TaskName;
+ StatusMessage = $"正在执行: {evt.TaskName}";
break;
- case nameof(GearTaskExecutionManager.IsExecuting):
- IsExecuting = _executionManager.IsExecuting;
+ case TaskNodeCompletedEvent:
+ case TaskNodeFailedEvent:
+ case TaskNodeSkippedEvent:
+ _processedNodeCount++;
+ Progress = Math.Min(100, (double)_processedNodeCount / _totalNodeCount * 100);
+ break;
+ case ExecutionCompletedEvent:
+ Progress = 100;
+ StatusMessage = "任务执行完成";
+ break;
+ case ExecutionCancelledEvent:
+ StatusMessage = "任务执行已取消";
+ break;
+ case ExecutionFailedEvent failed:
+ StatusMessage = $"任务执行失败: {failed.ErrorMessage}";
+ break;
+ case ExecutionInterruptedEvent interrupted:
+ StatusMessage = $"任务执行中断: {interrupted.Reason}";
break;
}
}
- ///
- /// 获取执行统计信息
- ///
- /// 统计信息
- public TaskExecutionStatistics GetExecutionStatistics()
+ public void Dispose()
{
- return _executionManager.GetStatistics();
+ _subscription.Dispose();
+ _runningCancellationTokenSource?.Dispose();
}
- ///
- /// 获取当前任务执行信息
- ///
- /// 当前任务信息
- public TaskExecutionInfo? GetCurrentTaskInfo()
+ private async Task ExecuteInternalAsync(GearTaskDefinitionViewModel taskDefinitionViewModel, GearTaskResumePlan? resumePlan, CancellationToken ct)
{
- return _executionManager.CurrentTaskInfo;
+ _logger.LogInformation("开始执行任务定义 {TaskDefinitionName}", taskDefinitionViewModel.Name);
+
+ var rootTaskData = ConvertViewModelToData(taskDefinitionViewModel.RootTask!);
+ var rootTask = await _taskConverter.ConvertTaskDataAsync(rootTaskData);
+ var recordId = Guid.NewGuid().ToString("N");
+ var taskDefinitionFileKey = GetTaskDefinitionFileKey(taskDefinitionViewModel.Name);
+ var runContext = new GearTaskExecutionRunContext
+ {
+ RecordId = recordId,
+ TaskDefinitionName = taskDefinitionViewModel.Name,
+ TaskDefinitionFileKey = taskDefinitionFileKey,
+ ResumePlan = resumePlan,
+ };
+
+ _activeRecordId = recordId;
+ CancellationContext.Instance.Set();
+ _runningCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(ct, CancellationContext.Instance.GetActiveToken());
+
+ await ScriptService.StartGameTask();
+ await _executionRunner.RunAsync(rootTask, runContext, _runningCancellationTokenSource.Token);
}
- ///
- /// 获取根任务执行信息
- ///
- ///
- public TaskExecutionInfo? GetRootTaskInfo()
+ private void CleanupExecutionState()
{
- return _executionManager.RootTaskInfo;
+ CancellationContext.Instance.Clear();
+ RunnerContext.Instance.Clear();
+ _runningCancellationTokenSource?.Dispose();
+ _runningCancellationTokenSource = null;
+ _activeRecordId = null;
+ _totalNodeCount = 0;
+ _processedNodeCount = 0;
+ IsExecuting = false;
+ }
+
+ private static GearTaskResumePlan BuildResumePlan(GearTaskExecutionRecord sourceRecord)
+ {
+ return new GearTaskResumePlan
+ {
+ SourceRecordId = sourceRecord.RecordId,
+ ResumeNodeId = sourceRecord.ResumeNodeId ?? "0",
+ NodeResumeTokens = sourceRecord.Nodes
+ .Where(n => !string.IsNullOrWhiteSpace(n.ResumeTokenJson))
+ .ToDictionary(n => n.NodeId, n => n.ResumeTokenJson!),
+ };
+ }
+
+ private static string GetTaskDefinitionFileKey(string name)
+ {
+ var invalidChars = System.IO.Path.GetInvalidFileNameChars();
+ var safeName = string.Join("_", name.Split(invalidChars, StringSplitOptions.RemoveEmptyEntries));
+ return string.IsNullOrWhiteSpace(safeName) ? "unnamed_task" : safeName;
}
- ///
- /// 将 GearTaskViewModel 转换为 GearTaskData
- ///
- /// 视图模型
- /// 任务数据
private GearTaskData ConvertViewModelToData(GearTaskViewModel viewModel)
{
var taskData = new GearTaskData
@@ -216,7 +267,6 @@ public partial class GearTaskExecutor : ObservableObject
Priority = viewModel.Priority,
};
- // 递归转换子任务
if (viewModel.Children?.Count > 0)
{
taskData.Children = new List();
@@ -229,14 +279,3 @@ public partial class GearTaskExecutor : ObservableObject
return taskData;
}
}
-
-///
-/// 空任务实现,用于已禁用的任务
-///
-internal class EmptyGearTask : BaseGearTask
-{
- public override Task Run(CancellationToken ct)
- {
- return Task.CompletedTask;
- }
-}
diff --git a/BetterGenshinImpact/Service/GearTask/GearTaskServiceExtensions.cs b/BetterGenshinImpact/Service/GearTask/GearTaskServiceExtensions.cs
index c9340377..cfe7db1b 100644
--- a/BetterGenshinImpact/Service/GearTask/GearTaskServiceExtensions.cs
+++ b/BetterGenshinImpact/Service/GearTask/GearTaskServiceExtensions.cs
@@ -1,3 +1,4 @@
+using BetterGenshinImpact.Service.GearTask.Execution;
using Microsoft.Extensions.DependencyInjection;
namespace BetterGenshinImpact.Service.GearTask;
@@ -10,17 +11,19 @@ public static class GearTaskServiceExtensions
///
/// 注册齿轮任务相关服务
///
- /// 服务集合
- /// 服务集合
public static IServiceCollection AddGearTaskServices(this IServiceCollection services)
{
- // 注册核心服务
services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
- services.AddTransient();
- services.AddTransient();
-
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddHostedService();
+
+ services.AddSingleton();
+ services.AddSingleton();
+
return services;
}
}