using System.Diagnostics;
using System.Text;
namespace FluidExplorer.Services.Operations;
///
/// 文件操作引擎(纯 .NET 实现,不引用任何 WinUI 类型,可独立测试与复用)。
///
/// 线程模型:
/// - 所有作业都在后台 worker 上执行,UI 线程只负责入队/暂停/继续/取消,永不阻塞;
/// - 默认串行执行(避免多作业同时读写同一块磁盘造成抖动);
/// 同卷 Move / 重命名 / 新建文件夹这类"瞬时元数据操作"走并行车道,不占用串行队首;
/// - 进度回调按 50ms 限频,避免 UI 事件风暴。
///
public sealed class FileOperationService : IFileOperationService, IDisposable
{
/// 撤销栈上限:超出后丢弃最旧的条目。
private const int MaxUndoEntries = 30;
/// 进度上报限频(毫秒)。
private const int ProgressFlushIntervalMs = 50;
/// JobsChanged 限频(毫秒):进度类变化不按字节风暴式通知。
private const int JobsChangedThrottleMs = 250;
private const int MaxErrorLength = 2000;
private readonly JobQueue _queue;
private readonly Stack _undoStack = new();
private readonly object _undoGate = new();
private int _lastJobsChangedTick;
private bool _disposed;
public FileOperationService()
{
_queue = new JobQueue(ExecuteJobAsync);
_queue.Start();
}
// ------------------------------------------------------------ 队列与状态
public IReadOnlyList Jobs => _queue.Jobs;
public event EventHandler? JobsChanged
{
add => _queue.JobsChanged += value;
remove => _queue.JobsChanged -= value;
}
/// UI 设置冲突回调;未设置时 Ask 策略按 KeepBoth(保留两者)处理。
public Func>? ConflictResolver { get; set; }
public void Pause(Guid jobId) => _queue.Pause(jobId);
public void Resume(Guid jobId) => _queue.Resume(jobId);
public void Cancel(Guid jobId) => _queue.Cancel(jobId);
public void ClearFinished() => _queue.ClearFinished();
/// 进度类变化按 250ms 限频触发 JobsChanged,避免逐字节通知造成 UI 事件风暴。
private void NotifyJobsChangedThrottled()
{
var now = Environment.TickCount;
if (unchecked(now - _lastJobsChangedTick) < JobsChangedThrottleMs) return;
_lastJobsChangedTick = now;
_queue.Raise();
}
// ------------------------------------------------------------ 入队
public FileOperationJob EnqueueCopy(IReadOnlyList sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
=> EnqueueTransfer(FileOperationKind.Copy, sources, destination, policy);
public FileOperationJob EnqueueMove(IReadOnlyList sources, string destination, ConflictPolicy policy = ConflictPolicy.Ask)
=> EnqueueTransfer(FileOperationKind.Move, sources, destination, policy);
public FileOperationJob EnqueueDelete(IReadOnlyList paths, bool permanent = false)
{
var list = NormalizePaths(paths);
var job = new FileOperationJob
{
Kind = permanent ? FileOperationKind.Delete : FileOperationKind.Recycle,
Title = permanent ? $"永久删除 {list.Count} 个项目" : $"删除 {list.Count} 个项目到回收站",
Sources = list,
PermanentDelete = permanent,
Policy = ConflictPolicy.Replace,
// 回收站删除由 Shell 一次调用完成一批,拿不到字节进度,只按文件数上报。
IsIndeterminate = !permanent
};
_queue.Enqueue(job, instantLane: false);
return job;
}
public FileOperationJob EnqueueRename(string path, string newName)
{
var source = PathHelper.TryGetFullPath(path) ?? path;
var job = new FileOperationJob
{
Kind = FileOperationKind.Rename,
Title = $"重命名为“{newName}”",
Sources = [source],
NewName = newName,
Policy = ConflictPolicy.Skip,
IsIndeterminate = true
};
_queue.Enqueue(job, instantLane: true);
return job;
}
public FileOperationJob EnqueueNewFolder(string parentDirectory, string name)
{
var parent = PathHelper.TryGetFullPath(parentDirectory) ?? parentDirectory;
var job = new FileOperationJob
{
Kind = FileOperationKind.NewFolder,
Title = $"新建文件夹“{name}”",
Sources = [parent],
Destination = parent,
NewName = name,
Policy = ConflictPolicy.KeepBoth,
IsIndeterminate = true
};
_queue.Enqueue(job, instantLane: true);
return job;
}
private FileOperationJob EnqueueTransfer(FileOperationKind kind, IReadOnlyList sources, string destination, ConflictPolicy policy)
{
ArgumentNullException.ThrowIfNull(sources);
ArgumentNullException.ThrowIfNull(destination);
var list = NormalizePaths(sources);
var destinationPath = PathHelper.TryGetFullPath(destination) ?? destination;
var verb = kind == FileOperationKind.Copy ? "复制" : "移动";
var job = new FileOperationJob
{
Kind = kind,
Title = list.Count == 1
? $"{verb}“{PathHelper.GetFileName(list[0])}”到 {destinationPath}"
: $"{verb} {list.Count} 个项目到 {destinationPath}",
Sources = list,
Destination = destinationPath,
Policy = policy
};
_queue.Enqueue(job, instantLane: kind == FileOperationKind.Move && IsSameVolumeMove(list, destinationPath));
return job;
}
private static List NormalizePaths(IReadOnlyList paths)
{
var result = new List();
if (paths is null) return result;
foreach (var path in paths)
{
if (string.IsNullOrWhiteSpace(path)) continue;
var full = PathHelper.TryGetFullPath(path) ?? path.Trim();
if (!result.Contains(full, StringComparer.OrdinalIgnoreCase)) result.Add(full);
}
return result;
}
///
/// 目标路径解析(入队与执行两处必须一致):
/// - 目标是已存在的目录 / 末尾带分隔符 / 无扩展名 → 视为"放进该目录";
/// - 否则(单个源 + 目标不存在 + 带扩展名)→ 目标即完整目标路径,等价于"复制并改名"。
///
private static List<(string Source, string Target)> ResolveTargets(IReadOnlyList sources, string destination)
{
var targets = new List<(string Source, string Target)>();
if (sources.Count == 0) return targets;
var treatAsDirectory = sources.Count > 1
|| PathHelper.DirectoryExists(destination)
|| destination.EndsWith(Path.DirectorySeparatorChar)
|| destination.EndsWith(Path.AltDirectorySeparatorChar)
|| Path.GetExtension(destination).Length == 0;
foreach (var source in sources)
{
targets.Add(treatAsDirectory
? (source, PathHelper.Combine(destination, PathHelper.GetFileName(source)))
: (source, destination));
}
return targets;
}
private static bool IsSameVolumeMove(IReadOnlyList sources, string destination)
{
if (sources.Count == 0) return false;
foreach (var (source, target) in ResolveTargets(sources, destination))
{
if (!PathHelper.SameVolume(source, target)) return false;
}
return true;
}
// ------------------------------------------------------------ 作业执行
private async Task ExecuteJobAsync(JobContext ctx)
{
var job = ctx.Job;
var runner = new JobRunner(this, ctx);
try
{
job.State = ctx.PauseGate.IsSet ? JobState.Running : JobState.Paused;
runner.Notify();
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (!ctx.Cts.IsCancellationRequested)
{
switch (job.Kind)
{
case FileOperationKind.Copy:
await RunTransferAsync(runner, isMove: false).ConfigureAwait(false);
break;
case FileOperationKind.Move:
await RunTransferAsync(runner, isMove: true).ConfigureAwait(false);
break;
case FileOperationKind.Delete:
case FileOperationKind.Recycle:
await RunDeleteAsync(runner).ConfigureAwait(false);
break;
case FileOperationKind.Rename:
await RunRenameAsync(runner).ConfigureAwait(false);
break;
case FileOperationKind.NewFolder:
await RunNewFolderAsync(runner).ConfigureAwait(false);
break;
}
}
}
catch (OperationCanceledException)
{
// 取消是正常流程:已完成的部分保留,不回滚。
}
catch (Exception ex)
{
runner.Warn($"作业异常:{ex.Message}");
}
finally
{
runner.Complete();
if (runner.UndoEntry is { } entry) PushUndo(entry);
}
}
private static async Task RunTransferAsync(JobRunner runner, bool isMove)
{
var job = runner.Job;
var targets = ResolveTargets(job.Sources, job.Destination ?? string.Empty);
var sameVolumeMove = isMove && IsSameVolumeMove(job.Sources, job.Destination ?? string.Empty);
if (sameVolumeMove)
{
// 同卷 Move 是瞬时元数据操作,不产生字节流量:
// 这里刻意不做字节测量,让进度条按"条目数"走,而不是永远停在 0%。
job.TotalBytes = 0;
job.TotalItems = targets.Count;
}
else
{
var (bytes, items) = CopyEngine.Measure(job.Sources, runner.Token, runner.Warn);
job.TotalBytes = bytes;
job.TotalItems = items;
}
job.IsIndeterminate = false;
runner.Notify();
foreach (var (source, target) in targets)
{
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (runner.IsCancellationRequested) break;
runner.SetCurrentItem(source);
var outcome = isMove
? await CopyEngine.MoveEntryAsync(source, target, runner).ConfigureAwait(false)
: await CopyEngine.CopyEntryAsync(source, target, runner).ConfigureAwait(false);
if (outcome == EntryOutcome.Success) runner.CountSucceeded();
else if (outcome == EntryOutcome.Cancelled) break;
}
if (isMove && runner.MoveRecords.Count > 0)
{
runner.SetUndoEntry(new UndoEntry
{
Description = $"撤销 移动 {job.Sources.Count} 个项目到 {job.Destination}",
Kind = FileOperationKind.Move,
Moves = [.. runner.MoveRecords]
});
}
}
private static async Task RunDeleteAsync(JobRunner runner)
{
var job = runner.Job;
if (job.Sources.Count == 0) return;
if (job.PermanentDelete)
{
var (_, deleteItems) = CopyEngine.Measure(job.Sources, runner.Token, runner.Warn);
job.TotalBytes = 0;
job.TotalItems = deleteItems;
job.IsIndeterminate = false;
runner.Notify();
foreach (var path in job.Sources)
{
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (runner.IsCancellationRequested) break;
var outcome = await CopyEngine.DeletePermanentAsync(path, runner, countAsItem: true).ConfigureAwait(false);
if (outcome == EntryOutcome.Success) runner.CountSucceeded();
else if (outcome == EntryOutcome.Cancelled) break;
}
// 永久删除无法撤销:不产生 UndoEntry(避免 Ctrl+Z 出现"撤销后什么都没发生"的空操作)。
return;
}
job.TotalBytes = 0;
job.TotalItems = job.Sources.Count;
job.IsIndeterminate = true;
runner.Notify();
// 1) 删除前对每个卷的 <卷>:\$Recycle.Bin\\ 做 $I 快照(撤销定位靠前后差集)。
var before = RecycleBinLocator.CaptureState(job.Sources);
runner.SetCurrentItem(job.Sources.Count == 1 ? job.Sources[0] : $"{job.Sources.Count} 个项目");
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (runner.IsCancellationRequested) return;
// 2) 一次 Shell 调用完成一批(FOF_ALLOWUNDO = 进回收站而不是永久删除)。
var (success, aborted, code) = await RecycleBinLocator.DeleteToRecycleBinAsync(job.Sources).ConfigureAwait(false);
if (!success)
{
var reason = aborted ? "操作被 Shell 中止。" : RecycleBinLocator.DescribeResult(code);
runner.Warn($"删除到回收站失败:{reason}");
runner.CountFailed(job.Sources.Count);
return;
}
var deletedCount = job.Sources.Count(p => !PathHelper.Exists(p));
job.CompletedItems = deletedCount;
runner.CountSucceeded(Math.Max(deletedCount, 0));
foreach (var path in job.Sources.Where(PathHelper.Exists))
runner.Warn($"Shell 报告成功但文件仍然存在:{path}");
// 3) 差集定位回收站内新增的 $I/$R,填进 UndoEntry.Deleted(解析失败只警告,绝不崩溃)。
var (items, diagnostics) = RecycleBinLocator.ResolveDeletedItems(job.Sources, before);
foreach (var diagnostic in diagnostics) runner.Warn(diagnostic);
if (items.Count > 0 && items.All(i => string.IsNullOrEmpty(i.RecyclePath)))
{
runner.Warn("回收站不可用(Shell 无法在 <卷>:\\$Recycle.Bin 下建立 $I/$R 记录),本次删除实际为永久删除,无法撤销。");
}
if (items.Count > 0)
{
runner.SetUndoEntry(new UndoEntry
{
Description = $"撤销 删除 {deletedCount} 个项目",
Kind = FileOperationKind.Recycle,
Moves = [],
Deleted = items
});
}
}
private static async Task RunRenameAsync(JobRunner runner)
{
var job = runner.Job;
job.TotalBytes = 0;
job.TotalItems = 1;
job.IsIndeterminate = true;
runner.Notify();
var source = job.Sources.FirstOrDefault();
if (source is null)
{
runner.Warn("没有指定要重命名的路径。");
runner.CountFailed();
return;
}
var newName = job.NewName ?? string.Empty;
if (!PathHelper.TryValidateFileName(newName, out var validationError))
{
runner.Warn(validationError!);
runner.CountFailed();
return;
}
var target = PathHelper.Combine(PathHelper.GetDirectoryName(source), newName);
if (string.Equals(source.TrimEnd('\\'), target.TrimEnd('\\'), StringComparison.OrdinalIgnoreCase))
{
// 名称没变化:直接算成功,不要动文件。
job.CompletedItems = 1;
runner.CountSucceeded();
return;
}
if (PathHelper.Exists(target))
{
runner.Warn($"目标名称已存在,重命名不会覆盖:{target}");
runner.CountFailed();
return;
}
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (runner.IsCancellationRequested) return;
var outcome = await CopyEngine.MoveDirectAsync(source, target, runner).ConfigureAwait(false);
if (outcome == EntryOutcome.Success)
{
job.CompletedItems = 1;
runner.CountSucceeded();
runner.SetUndoEntry(new UndoEntry
{
Description = $"撤销 重命名“{newName}”",
Kind = FileOperationKind.Rename,
Moves = [.. runner.MoveRecords]
});
}
else if (outcome != EntryOutcome.Cancelled)
{
runner.CountFailed();
}
}
private static async Task RunNewFolderAsync(JobRunner runner)
{
var job = runner.Job;
job.TotalBytes = 0;
job.TotalItems = 1;
job.IsIndeterminate = true;
runner.Notify();
var parent = job.Destination ?? job.Sources.FirstOrDefault();
if (parent is null)
{
runner.Warn("没有指定父目录。");
runner.CountFailed();
return;
}
var name = job.NewName ?? string.Empty;
if (!PathHelper.TryValidateFileName(name, out var validationError))
{
runner.Warn(validationError!);
runner.CountFailed();
return;
}
await runner.WaitIfPausedAsync().ConfigureAwait(false);
if (runner.IsCancellationRequested) return;
try
{
Directory.CreateDirectory(PathHelper.ToExtended(parent));
// 重名时自动避让:"新建文件夹 (2)"。
var target = PathHelper.MakeUniquePath(PathHelper.Combine(parent, name));
Directory.CreateDirectory(PathHelper.ToExtended(target));
job.CurrentItem = target;
job.CompletedItems = 1;
runner.CountSucceeded();
runner.Warn($"已创建:{target}");
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
runner.Warn($"新建文件夹失败:{ex.Message}");
runner.CountFailed();
}
}
// ------------------------------------------------------------ 撤销
public bool CanUndo
{
get
{
lock (_undoGate) return _undoStack.Count > 0;
}
}
public string? UndoDescription
{
get
{
lock (_undoGate) return _undoStack.Count > 0 ? _undoStack.Peek().Description : null;
}
}
public event EventHandler? UndoStackChanged;
///
/// 一步撤销栈顶作业:Move/Rename 逐项搬回原位置,Recycle 把回收站里的 $R 搬回原始路径。
/// 所有 IO 都在线程池上执行(Task.Run + 异步 IO),UI await 即可,绝不会阻塞 UI 线程。
///
public Task UndoAsync(CancellationToken cancellationToken = default)
=> Task.Run(() => UndoCoreAsync(cancellationToken), CancellationToken.None);
private async Task UndoCoreAsync(CancellationToken cancellationToken)
{
UndoEntry? entry;
lock (_undoGate)
{
if (_undoStack.Count == 0) return new OperationResult(false, 0, 0, 0, "没有可撤销的操作。");
entry = _undoStack.Pop();
}
UndoStackChanged?.Invoke(this, EventArgs.Empty);
var sink = new UndoSink(cancellationToken);
var succeeded = 0;
var failed = 0;
// 1) Move / Rename:把 From(当前位置)搬回 To(原位置)。同卷时是瞬时 File.Move。
foreach (var (from, to) in entry.Moves)
{
if (cancellationToken.IsCancellationRequested) break;
if (!PathHelper.Exists(from))
{
sink.Warn($"撤销失败,找不到待搬回的项目:{from}");
failed++;
continue;
}
PathHelper.EnsureParentDirectory(to);
// 撤销时遇到冲突按 KeepBoth:绝不覆盖用户现有数据。
var target = PathHelper.Exists(to) ? PathHelper.MakeUniquePath(to) : to;
var outcome = await CopyEngine.MoveDirectAsync(from, target, sink).ConfigureAwait(false);
if (outcome == EntryOutcome.Success) succeeded++;
else failed++;
}
// 2) Recycle:把回收站里的 $R 数据搬回原始路径(目标父目录不存在时先创建)。
foreach (var (recyclePath, originalPath) in entry.Deleted)
{
if (cancellationToken.IsCancellationRequested) break;
if (string.IsNullOrEmpty(recyclePath) || !PathHelper.Exists(recyclePath))
{
sink.Warn($"无法还原(回收站条目缺失或 $I/$R 解析失败):{originalPath}");
failed++;
continue;
}
PathHelper.EnsureParentDirectory(originalPath);
var target = PathHelper.Exists(originalPath) ? PathHelper.MakeUniquePath(originalPath) : originalPath;
var outcome = await CopyEngine.MoveDirectAsync(recyclePath, target, sink).ConfigureAwait(false);
if (outcome == EntryOutcome.Success)
{
succeeded++;
RemoveIndexFile(recyclePath);
}
else
{
failed++;
}
}
var errors = sink.Errors.Count > 0 ? string.Join(Environment.NewLine, sink.Errors) : null;
return new OperationResult(failed == 0 && succeeded > 0, succeeded, failed, 0, errors);
}
/// 还原成功后顺手删掉对应的 $I 索引,避免回收站里留下指向不存在数据的死条目。
private static void RemoveIndexFile(string recycleDataPath)
{
try
{
var indexPath = RecycleBinLocator.GetIndexPathFromDataPath(recycleDataPath);
if (indexPath is not null && PathHelper.FileExists(indexPath))
{
PathHelper.ClearReadOnly(indexPath);
File.Delete(PathHelper.ToExtended(indexPath));
}
}
catch (Exception)
{
// 元数据清理失败不影响还原结果。
}
}
private void PushUndo(UndoEntry entry)
{
if (entry.Moves.Count == 0 && entry.Deleted.Count == 0) return;
lock (_undoGate)
{
_undoStack.Push(entry);
if (_undoStack.Count > MaxUndoEntries)
{
// Stack 的枚举顺序是"栈顶在前",取前 N 条即保留最新的 N 条。
var kept = _undoStack.Take(MaxUndoEntries).Reverse().ToArray();
_undoStack.Clear();
foreach (var item in kept) _undoStack.Push(item);
}
}
UndoStackChanged?.Invoke(this, EventArgs.Empty);
}
// ------------------------------------------------------------ 测量
public Task<(long Bytes, int Items)> MeasureAsync(IReadOnlyList paths, CancellationToken cancellationToken)
{
var list = paths is null || paths.Count == 0 ? [] : NormalizePaths(paths);
return Task.Run(() => CopyEngine.Measure(list, cancellationToken), CancellationToken.None);
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
_queue.Dispose();
}
// ------------------------------------------------------------ 撤销用的空实现 sink
private sealed class UndoSink : IJobSink
{
public UndoSink(CancellationToken token) => Token = token;
public List Errors { get; } = [];
public CancellationToken Token { get; }
public bool IsCancellationRequested => Token.IsCancellationRequested;
public Task WaitIfPausedAsync() => Task.CompletedTask;
public void AddBytes(long delta) { }
public void AddCompletedItems(int delta) { }
public void SetCurrentItem(string path) { }
public void Warn(string message) => Errors.Add(message);
public void AddFailed(string path, string reason) => Errors.Add($"{path}:{reason}");
public void AddSkipped(int delta = 1) { }
public void RecordMoveForUndo(string newPath, string originalPath) { }
public Task ResolveConflictAsync(ConflictInfo info) => Task.FromResult(ConflictResolution.KeepBoth);
public void RequestCancel() { }
}
// ------------------------------------------------------------ 单个作业的运行器
///
/// 单个作业的执行上下文:负责限频进度上报、统计成功/失败/跳过、收集撤销记录与诊断信息。
///
private sealed class JobRunner : IJobSink
{
private readonly FileOperationService _owner;
private readonly JobContext _ctx;
private readonly Stopwatch _clock = Stopwatch.StartNew();
private readonly List _diagnostics = [];
private readonly List<(string From, string To)> _moveRecords = [];
private long _pendingBytes;
private int _pendingItems;
private string? _pendingCurrentItem;
private long _lastFlushMs;
private long _lastBytesFlushMs;
private long _lastRaiseAllMs;
private double _speed;
private int _succeeded;
private int _failed;
private int _skipped;
private ConflictResolution? _applyToAll;
public JobRunner(FileOperationService owner, JobContext ctx)
{
_owner = owner;
_ctx = ctx;
}
public FileOperationJob Job => _ctx.Job;
public CancellationToken Token => _ctx.Cts.Token;
public bool IsCancellationRequested => _ctx.Cts.IsCancellationRequested;
public List<(string From, string To)> MoveRecords => _moveRecords;
public UndoEntry? UndoEntry { get; private set; }
public void SetUndoEntry(UndoEntry entry) => UndoEntry = entry;
/// 立即刷新一次进度并通知 UI(作业开始、阶段切换、结束等关键时刻)。
public void Notify() => FlushProgress(force: true);
public async Task WaitIfPausedAsync()
{
var gate = _ctx.PauseGate;
while (!gate.IsSet)
{
if (IsCancellationRequested) return;
await gate.WaitAsync().ConfigureAwait(false);
}
}
public void AddBytes(long delta)
{
if (delta == 0) return;
Interlocked.Add(ref _pendingBytes, delta);
FlushIfDue();
}
public void AddCompletedItems(int delta)
{
if (delta == 0) return;
Interlocked.Add(ref _pendingItems, delta);
FlushIfDue();
}
public void SetCurrentItem(string path)
{
Interlocked.Exchange(ref _pendingCurrentItem, path);
FlushIfDue();
}
public void AddSkipped(int delta = 1)
{
_skipped += delta;
AddCompletedItems(delta);
}
public void AddFailed(string path, string reason)
{
_failed++;
Warn($"{path}:{reason}");
}
public void Warn(string message)
{
if (string.IsNullOrWhiteSpace(message)) return;
_diagnostics.Add(message);
}
public void CountSucceeded(int delta = 1) => _succeeded += delta;
public void CountFailed(int delta = 1) => _failed += delta;
public void RecordMoveForUndo(string newPath, string originalPath) => _moveRecords.Add((newPath, originalPath));
public void RequestCancel()
{
Warn("已按用户选择取消后续操作。");
_ctx.Cts.Cancel();
_ctx.PauseGate.Set();
}
public async Task ResolveConflictAsync(ConflictInfo info)
{
// "为后续所有冲突执行相同操作":一次勾选,后续冲突不再打扰 UI。
if (_applyToAll is { } applied) return applied;
var resolver = _owner.ConflictResolver;
if (Job.Policy == ConflictPolicy.Ask && resolver is not null)
{
try
{
// 回调是 await 的:期间作业状态保持 Running(或 Paused),当前项挂起,
// 但 UI 线程完全自由(回调由 UI 自己 marshal 回 UI 线程弹对话框)。
var resolution = await resolver(info).ConfigureAwait(false);
if (info.ApplyToAll) _applyToAll = resolution;
return resolution;
}
catch (Exception ex)
{
Warn($"冲突回调异常,按“保留两者”处理:{ex.Message}");
return ConflictResolution.KeepBoth;
}
}
return Job.Policy switch
{
ConflictPolicy.Replace or ConflictPolicy.Merge => ConflictResolution.Replace,
ConflictPolicy.Skip => ConflictResolution.Skip,
_ => ConflictResolution.KeepBoth // Ask 但没有 UI 回调 → 保留两者
};
}
private void FlushIfDue()
{
if (_clock.ElapsedMilliseconds - _lastFlushMs >= ProgressFlushIntervalMs) FlushProgress(force: false);
}
///
/// 限频进度刷新:
/// - 数值属性(字节 / 条目 / 当前项 / 速度)每 50ms 写一次,各自只触发一个 PropertyChanged;
/// - 计算属性(Progress / Eta / CanPause…)每 250ms 通过一次 RaiseAll 刷新。
/// 于是 2000 个文件的复制只产生约 20 次/秒的 UI 通知,而不是"每个文件一次"的事件风暴。
///
private void FlushProgress(bool force)
{
var now = _clock.ElapsedMilliseconds;
var bytes = Interlocked.Exchange(ref _pendingBytes, 0);
var items = Interlocked.Exchange(ref _pendingItems, 0);
var current = Interlocked.Exchange(ref _pendingCurrentItem, null);
if (bytes != 0)
{
Job.CompletedBytes += bytes;
var elapsed = Math.Max(1, now - _lastBytesFlushMs);
var instant = bytes * 1000.0 / elapsed;
_speed = _speed <= 1 ? instant : (_speed * 0.7) + (instant * 0.3);
Job.BytesPerSecond = _speed;
_lastBytesFlushMs = now;
}
if (items != 0) Job.CompletedItems += items;
if (current is not null) Job.CurrentItem = current;
_lastFlushMs = now;
if (force || now - _lastRaiseAllMs >= JobsChangedThrottleMs)
{
_lastRaiseAllMs = now;
// RaiseAll 让绑定 Progress / Eta 的 UI 也能刷新(普通 Set 只通知单个属性)。
Job.RaiseAll();
_owner.NotifyJobsChangedThrottled();
}
}
public void Complete()
{
FlushProgress(force: true);
var job = Job;
var cancelled = IsCancellationRequested;
if (cancelled)
{
var bytes = job.TotalBytes > 0
? $",{FormatBytes(job.CompletedBytes)}/{FormatBytes(job.TotalBytes)}"
: string.Empty;
_diagnostics.Insert(0, $"已取消,已完成 {job.CompletedItems}/{job.TotalItems} 项{bytes}。");
job.State = JobState.Cancelled;
}
else if (_failed > 0)
{
job.State = _succeeded > 0 ? JobState.CompletedWithErrors : JobState.Failed;
}
else
{
job.State = JobState.Completed;
}
if (_skipped > 0) _diagnostics.Add($"已跳过 {_skipped} 个项目。");
job.Error = BuildDiagnostics();
Job.RaiseAll();
_owner._queue.Raise();
}
private string? BuildDiagnostics()
{
if (_diagnostics.Count == 0) return null;
var builder = new StringBuilder();
var included = 0;
foreach (var line in _diagnostics)
{
if (builder.Length + line.Length + 1 > MaxErrorLength)
{
builder.Append(Environment.NewLine).Append($"…(其余 {_diagnostics.Count - included} 条已省略)");
break;
}
if (builder.Length > 0) builder.Append(Environment.NewLine);
builder.Append(line);
included++;
}
return builder.ToString();
}
internal static string FormatBytes(long bytes)
{
string[] units = ["B", "KB", "MB", "GB", "TB"];
double value = bytes;
var unit = 0;
while (value >= 1024 && unit < units.Length - 1)
{
value /= 1024;
unit++;
}
return $"{value:0.##}{units[unit]}";
}
}
}