Files
client/ViewModels/MainViewModel.cs
2026-05-28 23:26:37 +08:00

779 lines
26 KiB
C#

using System.Collections.ObjectModel;
using CloudPrint.Client.Infrastructure;
using CloudPrint.Client.Models;
using CloudPrint.Client.Services;
using CloudPrint.Client.Services.Abstractions;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Grpc.Core;
using ObservableObject = CommunityToolkit.Mvvm.ComponentModel.ObservableObject;
namespace CloudPrint.Client.ViewModels;
public sealed partial class MainViewModel : ObservableObject
{
private const int MaxReconnectAttempts = 5;
private static readonly TimeSpan SubscriptionWatchInterval = TimeSpan.FromSeconds(5);
private static readonly TimeSpan InitialReconnectDelay = TimeSpan.FromSeconds(2);
private static readonly TimeSpan MaxReconnectDelay = TimeSpan.FromSeconds(30);
private readonly IPrintService _printService;
private readonly IPrintBackendClient _backendClient;
private readonly IWebSocketService _webSocketService;
private readonly ISettingsService _settingsService;
private readonly IUiDispatcher _uiDispatcher;
private readonly IAppLogger _appLogger;
private readonly PrintJobCoordinator _jobCoordinator;
private readonly HashSet<string> _knownJobKeys = new(StringComparer.OrdinalIgnoreCase);
private readonly SemaphoreSlim _queueProcessingGate = new(1, 1);
private CancellationTokenSource? _heartbeatCts;
private CancellationTokenSource? _subscriptionWatchCts;
private CancellationTokenSource? _queueCts;
private CancellationTokenSource? _processingCts;
private Task? _queueWorker;
private PrintJob? _processingJob;
private PrinterInfo? _subscribedPrinter;
private bool _isReconnecting;
private bool _isManualDisconnect = true;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(CancelSelectedJobCommand))]
private PrintJob? selectedJob;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(CanSubscribe))]
[NotifyCanExecuteChangedFor(nameof(ConnectCommand))]
[NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
private PrinterInfo? selectedPrinter;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(ConnectionStatus))]
[NotifyPropertyChangedFor(nameof(CanSubscribe))]
[NotifyCanExecuteChangedFor(nameof(SubscribeCommand))]
[NotifyCanExecuteChangedFor(nameof(DisconnectCommand))]
private ConnectionState connectionState = ConnectionState.Disconnected;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(QueueStatusText))]
private bool autoProcessQueue = true;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(QueueStatusText))]
private bool isQueuePaused;
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(QueueStatusText))]
private bool isQueueProcessing;
[ObservableProperty]
private string lastMessage = "已就绪";
public MainViewModel()
: this(new PrintService(), new FileService(), new GrpcService(), new WebSocketService(), new JsonSettingsService(), new WpfUiDispatcher(), new FileAppLogger())
{
}
public MainViewModel(
IPrintService printService,
IFileService fileService,
IPrintBackendClient backendClient,
IWebSocketService webSocketService,
ISettingsService settingsService,
IUiDispatcher uiDispatcher,
IAppLogger appLogger)
{
_printService = printService;
_backendClient = backendClient;
_webSocketService = webSocketService;
_settingsService = settingsService;
_uiDispatcher = uiDispatcher;
_appLogger = appLogger;
_jobCoordinator = new PrintJobCoordinator(fileService, printService, backendClient);
Settings = _settingsService.Load();
ConfigureBackendMode();
Library = new LibraryViewModel(fileService, backendClient, Settings);
SettingsPanel = new SettingsViewModel(Settings, settingsService, backendClient, appLogger);
_backendClient.PrintJobReceived += OnPrintJobReceived;
_backendClient.LogReceived += (_, entry) => AddLog(entry);
_webSocketService.LogReceived += (_, entry) => AddLog(entry);
RefreshPrinters();
}
public ClientSettings Settings { get; }
public ObservableCollection<PrinterInfo> Printers { get; } = new();
public ObservableCollection<PrintJob> Jobs { get; } = new();
public ObservableCollection<AppLogEntry> Logs { get; } = new();
public LibraryViewModel Library { get; }
public SettingsViewModel SettingsPanel { get; }
public string ConnectionStatus => ConnectionState switch
{
ConnectionState.Connecting => "连接中",
ConnectionState.Connected => "已连接",
ConnectionState.Subscribing => "订阅中",
ConnectionState.Subscribed => "已订阅",
ConnectionState.Error => "连接异常",
_ => "未连接"
};
public bool CanSubscribe => SelectedPrinter is not null && _backendClient.IsConnected;
public bool CanCancelSelectedJob => SelectedJob is { Status: PrintJobStatus.Pending or PrintJobStatus.Printing };
public string QueueStatusText => AutoProcessQueue switch
{
false => "自动队列已关闭,可手动处理下一任务",
_ when IsQueuePaused => "队列已暂停,当前任务完成后不再继续处理",
_ when IsQueueProcessing => "队列处理中,任务将按顺序自动打印",
_ => "自动队列已开启,等待新任务"
};
partial void OnConnectionStateChanged(ConnectionState value)
{
Library.RefreshCommandState();
}
partial void OnAutoProcessQueueChanged(bool value)
{
RaiseQueueCommandStates();
if (value)
{
StartQueueProcessingIfNeeded();
}
}
partial void OnIsQueuePausedChanged(bool value)
{
RaiseQueueCommandStates();
}
partial void OnIsQueueProcessingChanged(bool value)
{
RaiseQueueCommandStates();
}
[RelayCommand]
private void RefreshPrinters()
{
Printers.Clear();
foreach (var printer in _printService.GetPrinters())
{
Printers.Add(printer);
}
SelectedPrinter = Printers.FirstOrDefault();
AddLog(AppLogLevel.Info, $"发现 {Printers.Count} 台打印机");
}
private bool CanConnect() => SelectedPrinter is not null;
[RelayCommand(CanExecute = nameof(CanConnect))]
private async Task ConnectAsync()
{
if (SelectedPrinter is null)
{
AddLog(AppLogLevel.Warning, "请先选择打印机");
return;
}
try
{
_isManualDisconnect = false;
ConfigureBackendMode();
ConnectionState = ConnectionState.Connecting;
await _backendClient.ConnectAsync(Settings.ServerAddress, Settings.ApiKey).ConfigureAwait(true);
await _backendClient.RegisterPrinterAsync(SelectedPrinter, Settings.ApiKey).ConfigureAwait(true);
await _backendClient.SendHeartbeatAsync(SelectedPrinter).ConfigureAwait(true);
await _webSocketService.ConnectAsync(new Uri(ToWebSocketAddress(Settings.ServerAddress))).ConfigureAwait(true);
_settingsService.Save(Settings);
StartHeartbeatLoop();
ConnectionState = ConnectionState.Connected;
DisconnectCommand.NotifyCanExecuteChanged();
Library.RefreshCommandState();
}
catch (Exception ex)
{
ResetConnectionsAfterFailure();
ConnectionState = ConnectionState.Error;
AddLog(AppLogEntry.FromException($"连接失败:{ex.Message}", ex));
}
}
private bool CanSubscribeCommand() => CanSubscribe;
[RelayCommand(CanExecute = nameof(CanSubscribeCommand))]
private async Task SubscribeAsync()
{
if (SelectedPrinter is null)
{
AddLog(AppLogLevel.Warning, "请先选择打印机");
return;
}
try
{
ConnectionState = ConnectionState.Subscribing;
await _backendClient.SubscribePrintJobsAsync(SelectedPrinter).ConfigureAwait(true);
_subscribedPrinter = SelectedPrinter;
ConnectionState = ConnectionState.Subscribed;
StartSubscriptionWatchLoop();
ProcessSelectedJobCommand.NotifyCanExecuteChanged();
Library.RefreshCommandState();
StartQueueProcessingIfNeeded();
}
catch (Exception ex)
{
ResetConnectionsAfterFailure();
ConnectionState = ConnectionState.Error;
AddLog(AppLogEntry.FromException($"订阅失败:{ex.Message}", ex));
}
}
private bool CanProcessSelectedJob() => Jobs.Any(job => job.Status == PrintJobStatus.Pending) && !IsQueueProcessing;
[RelayCommand(CanExecute = nameof(CanProcessSelectedJob))]
private async Task ProcessSelectedJobAsync()
{
var job = GetNextPendingJob(preferSelected: true);
if (job is null)
{
AddLog(AppLogLevel.Warning, "没有待打印任务");
return;
}
if (!await _queueProcessingGate.WaitAsync(0).ConfigureAwait(true))
{
AddLog(AppLogLevel.Warning, "队列正在处理任务,请稍后再试");
return;
}
try
{
IsQueueProcessing = true;
await ProcessJobAsync(job, CancellationToken.None).ConfigureAwait(true);
}
finally
{
IsQueueProcessing = false;
_queueProcessingGate.Release();
RaiseQueueCommandStates();
}
}
private bool CanCancelSelectedJobCommand() => CanCancelSelectedJob;
[RelayCommand(CanExecute = nameof(CanCancelSelectedJobCommand))]
private async Task CancelSelectedJobAsync()
{
if (SelectedJob is null)
{
AddLog(AppLogLevel.Warning, "请先选择要取消的任务");
return;
}
try
{
if (ReferenceEquals(SelectedJob, _processingJob))
{
_processingCts?.Cancel();
}
_printService.CancelPrintJob(SelectedJob);
await _backendClient.CancelPrintJobAsync(SelectedJob).ConfigureAwait(true);
await _backendClient.SendStatusUpdateAsync(SelectedJob).ConfigureAwait(true);
AddLog(AppLogLevel.Warning, $"已取消任务:{SelectedJob.JobNo}");
}
catch (Exception ex)
{
AddLog(AppLogEntry.FromException($"取消任务失败:{ex.Message}", ex));
}
finally
{
ProcessSelectedJobCommand.NotifyCanExecuteChanged();
CancelSelectedJobCommand.NotifyCanExecuteChanged();
}
}
private void OnPrintJobReceived(object? sender, PrintJobReceivedEventArgs e)
{
var isDuplicate = false;
_uiDispatcher.Invoke(() =>
{
if (!TryRememberJob(e.Job))
{
isDuplicate = true;
return;
}
EnsureJobPrinter(e.Job);
Jobs.Insert(0, e.Job);
ProcessSelectedJobCommand.NotifyCanExecuteChanged();
});
if (isDuplicate)
{
AddLog(AppLogLevel.Warning, $"已忽略重复打印任务:{GetJobDisplayName(e.Job)}");
return;
}
StartQueueProcessingIfNeeded();
}
[RelayCommand]
private void SaveSettings()
{
ConfigureBackendMode();
_settingsService.Save(Settings);
AddLog(AppLogLevel.Success, "设置已保存");
}
private void ConfigureBackendMode()
{
if (_backendClient is GrpcService grpcService)
{
grpcService.AllowInsecureGrpc = Settings.AllowInsecureGrpc;
}
}
private void ResetConnectionsAfterFailure()
{
_heartbeatCts?.Cancel();
_subscriptionWatchCts?.Cancel();
_queueCts?.Cancel();
_processingCts?.Cancel();
_subscribedPrinter = null;
_backendClient.Disconnect();
_ = _webSocketService.DisconnectAsync();
NotifyConnectionCommandStates();
Library.RefreshCommandState();
}
private bool CanDisconnect() => _backendClient.IsConnected || _webSocketService.IsConnected;
[RelayCommand(CanExecute = nameof(CanDisconnect))]
private void Disconnect()
{
_isManualDisconnect = true;
_heartbeatCts?.Cancel();
_subscriptionWatchCts?.Cancel();
_queueCts?.Cancel();
_processingCts?.Cancel();
_subscribedPrinter = null;
_backendClient.Disconnect();
_ = _webSocketService.DisconnectAsync();
ConnectionState = ConnectionState.Disconnected;
AddLog(AppLogLevel.Warning, "客户端已断开连接");
NotifyConnectionCommandStates();
Library.RefreshCommandState();
}
private bool CanPauseQueue() => AutoProcessQueue && !IsQueuePaused;
[RelayCommand(CanExecute = nameof(CanPauseQueue))]
private void PauseQueue()
{
IsQueuePaused = true;
AddLog(AppLogLevel.Warning, "自动队列已暂停,当前任务完成后停止处理后续任务");
}
private bool CanResumeQueue() => AutoProcessQueue && IsQueuePaused;
[RelayCommand(CanExecute = nameof(CanResumeQueue))]
private void ResumeQueue()
{
IsQueuePaused = false;
AddLog(AppLogLevel.Info, "自动队列已恢复");
StartQueueProcessingIfNeeded();
}
private void StartQueueProcessingIfNeeded()
{
if (!AutoProcessQueue || IsQueuePaused || _isManualDisconnect)
{
RaiseQueueCommandStates();
return;
}
if (_queueWorker is { IsCompleted: false })
{
return;
}
_queueCts?.Cancel();
_queueCts?.Dispose();
_queueCts = new CancellationTokenSource();
var token = _queueCts.Token;
_queueWorker = Task.Run(() => RunQueueProcessingLoopAsync(token), CancellationToken.None);
}
private async Task RunQueueProcessingLoopAsync(CancellationToken cancellationToken)
{
if (!await _queueProcessingGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
{
return;
}
try
{
_uiDispatcher.Invoke(() => IsQueueProcessing = true);
while (!cancellationToken.IsCancellationRequested && AutoProcessQueue && !IsQueuePaused)
{
var job = GetNextPendingJob(preferSelected: false);
if (job is null)
{
break;
}
await ProcessJobAsync(job, cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Normal shutdown or user cancellation path.
}
finally
{
_uiDispatcher.Invoke(() => IsQueueProcessing = false);
_queueProcessingGate.Release();
RaiseQueueCommandStates();
_queueWorker = null;
if (HasPendingJobs() && AutoProcessQueue && !IsQueuePaused && !_isManualDisconnect && !cancellationToken.IsCancellationRequested)
{
StartQueueProcessingIfNeeded();
}
}
}
private async Task ProcessJobAsync(PrintJob job, CancellationToken cancellationToken)
{
try
{
_processingJob = job;
_processingCts?.Cancel();
_processingCts?.Dispose();
_processingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
_uiDispatcher.Invoke(() => SelectedJob = job);
await _jobCoordinator.ProcessAsync(job, Settings, cancellationToken: _processingCts.Token).ConfigureAwait(false);
AddLog(AppLogLevel.Success, $"任务处理完成:{job.JobNo}");
}
catch (OperationCanceledException)
{
AddLog(AppLogLevel.Warning, $"任务已取消:{job.JobNo}");
if (cancellationToken.IsCancellationRequested)
{
throw;
}
}
catch (Exception ex)
{
AddLog(AppLogEntry.FromException($"任务处理失败:{ex.Message}", ex));
}
finally
{
_processingJob = null;
RaiseQueueCommandStates();
}
}
private PrintJob? GetNextPendingJob(bool preferSelected)
{
PrintJob? job = null;
_uiDispatcher.Invoke(() =>
{
job = preferSelected && SelectedJob?.Status == PrintJobStatus.Pending
? SelectedJob
: Jobs.LastOrDefault(item => item.Status == PrintJobStatus.Pending);
});
return job;
}
private bool HasPendingJobs()
{
var hasPending = false;
_uiDispatcher.Invoke(() => hasPending = Jobs.Any(item => item.Status == PrintJobStatus.Pending));
return hasPending;
}
private void RaiseQueueCommandStates()
{
_uiDispatcher.Invoke(() =>
{
ProcessSelectedJobCommand.NotifyCanExecuteChanged();
CancelSelectedJobCommand.NotifyCanExecuteChanged();
PauseQueueCommand.NotifyCanExecuteChanged();
ResumeQueueCommand.NotifyCanExecuteChanged();
});
}
private void NotifyConnectionCommandStates()
{
_uiDispatcher.Invoke(() =>
{
SubscribeCommand.NotifyCanExecuteChanged();
DisconnectCommand.NotifyCanExecuteChanged();
});
}
private void StartHeartbeatLoop()
{
_heartbeatCts?.Cancel();
_heartbeatCts?.Dispose();
_heartbeatCts = new CancellationTokenSource();
var token = _heartbeatCts.Token;
_ = Task.Run(() => RunHeartbeatLoopAsync(token), CancellationToken.None);
}
private void StartSubscriptionWatchLoop()
{
_subscriptionWatchCts?.Cancel();
_subscriptionWatchCts?.Dispose();
_subscriptionWatchCts = new CancellationTokenSource();
var token = _subscriptionWatchCts.Token;
_ = Task.Run(() => RunSubscriptionWatchLoopAsync(token), CancellationToken.None);
}
private async Task RunSubscriptionWatchLoopAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(SubscriptionWatchInterval);
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
if (_isManualDisconnect || SelectedPrinter is null)
{
continue;
}
if (!_backendClient.IsConnected)
{
AddLog(AppLogLevel.Warning, "检测到任务订阅流断开,准备自动恢复...");
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error);
await TryReconnectAsync(cancellationToken).ConfigureAwait(false);
}
}
}
catch (OperationCanceledException)
{
// Normal shutdown path.
}
}
private async Task RunHeartbeatLoopAsync(CancellationToken cancellationToken)
{
try
{
using var timer = new PeriodicTimer(TimeSpan.FromSeconds(Math.Max(5, Settings.HeartbeatIntervalSeconds)));
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
{
await SendHeartbeatOnceAsync(cancellationToken).ConfigureAwait(false);
}
}
catch (OperationCanceledException)
{
// Normal shutdown path.
}
}
private async Task SendHeartbeatOnceAsync(CancellationToken cancellationToken)
{
if (SelectedPrinter is null || !_backendClient.IsConnected)
{
if (!_isManualDisconnect && SelectedPrinter is not null)
{
await TryReconnectAsync(cancellationToken).ConfigureAwait(false);
}
return;
}
try
{
await _backendClient.SendHeartbeatAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
AddLog(AppLogEntry.FromException($"心跳失败:{ex.Message}", ex));
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error);
await TryReconnectAsync(cancellationToken).ConfigureAwait(false);
}
}
private async Task TryReconnectAsync(CancellationToken cancellationToken)
{
if (SelectedPrinter is null || _isManualDisconnect || _isReconnecting)
{
return;
}
_isReconnecting = true;
try
{
for (var attempt = 1; attempt <= MaxReconnectAttempts; attempt++)
{
cancellationToken.ThrowIfCancellationRequested();
var delay = GetReconnectDelay(attempt);
AddLog(AppLogLevel.Warning, $"正在尝试自动重连(第 {attempt}/{MaxReconnectAttempts} 次,等待 {delay.TotalSeconds:0} 秒)...");
await Task.Delay(delay, cancellationToken).ConfigureAwait(false);
try
{
ConfigureBackendMode();
await _backendClient.ConnectAsync(Settings.ServerAddress, Settings.ApiKey, cancellationToken).ConfigureAwait(false);
await _backendClient.RegisterPrinterAsync(SelectedPrinter, Settings.ApiKey, cancellationToken).ConfigureAwait(false);
await _backendClient.SendHeartbeatAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false);
await _backendClient.SubscribePrintJobsAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false);
_subscribedPrinter = SelectedPrinter;
await _webSocketService.ConnectAsync(new Uri(ToWebSocketAddress(Settings.ServerAddress)), cancellationToken).ConfigureAwait(false);
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Subscribed);
AddLog(AppLogLevel.Success, "自动重连成功,任务订阅已恢复");
return;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex) when (IsFatalReconnectError(ex))
{
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error);
AddLog(AppLogEntry.FromException($"自动重连停止:{ex.Message}", ex));
return;
}
catch (Exception ex)
{
AddLog(AppLogEntry.FromException($"自动重连失败:{ex.Message}", ex));
}
}
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Error);
AddLog(AppLogLevel.Error, "自动重连已达到最大次数,请检查网络、后端服务或 API Key 后手动重试");
}
finally
{
_isReconnecting = false;
}
}
[RelayCommand]
private void ClearLogs()
{
Logs.Clear();
LastMessage = "日志已清空";
}
private void AddLog(AppLogLevel level, string message)
{
AddLog(new AppLogEntry { Level = level, Message = message });
}
private void AddLog(AppLogEntry entry)
{
_appLogger.Write(entry);
_uiDispatcher.Invoke(() =>
{
LastMessage = entry.Message;
Logs.Insert(0, entry);
while (Logs.Count > 200)
{
Logs.RemoveAt(Logs.Count - 1);
}
});
}
private static string ToWebSocketAddress(string address)
{
if (address.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
{
return "wss://" + address[8..].TrimEnd('/') + "/ws/print";
}
if (address.StartsWith("http://", StringComparison.OrdinalIgnoreCase))
{
return "ws://" + address[7..].TrimEnd('/') + "/ws/print";
}
return "ws://" + address.TrimEnd('/') + "/ws/print";
}
private bool TryRememberJob(PrintJob job)
{
var key = GetJobKey(job);
if (!_knownJobKeys.Add(key))
{
return false;
}
return !Jobs.Any(existing => GetJobKey(existing).Equals(key, StringComparison.OrdinalIgnoreCase));
}
private void EnsureJobPrinter(PrintJob job)
{
if (!string.IsNullOrWhiteSpace(job.PrinterName))
{
return;
}
var printer = _subscribedPrinter ?? SelectedPrinter;
if (printer is null)
{
AddLog(AppLogLevel.Error, $"打印任务缺少本机打印机:{GetJobDisplayName(job)}");
return;
}
job.PrinterName = printer.Name;
}
private static string GetJobKey(PrintJob job)
{
if (!string.IsNullOrWhiteSpace(job.JobNo))
{
return $"job-no:{job.JobNo}";
}
if (job.Id > 0)
{
return $"id:{job.Id}";
}
return $"file:{job.FileId}:{job.FileName}:{job.OrderItemId}";
}
private static string GetJobDisplayName(PrintJob job)
{
return string.IsNullOrWhiteSpace(job.JobNo) ? job.FileName : job.JobNo;
}
private static TimeSpan GetReconnectDelay(int attempt)
{
var seconds = InitialReconnectDelay.TotalSeconds * Math.Pow(2, Math.Max(0, attempt - 1));
return TimeSpan.FromSeconds(Math.Min(seconds, MaxReconnectDelay.TotalSeconds));
}
private static bool IsFatalReconnectError(Exception exception)
{
if (exception is ArgumentException or InvalidOperationException)
{
return true;
}
if (exception is RpcException rpcException)
{
return rpcException.StatusCode is StatusCode.Unauthenticated or StatusCode.PermissionDenied or StatusCode.InvalidArgument;
}
return false;
}
}