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]}"; } } }