using System.Collections.ObjectModel; using CloudPrint.Client.Infrastructure; using CloudPrint.Client.Models; using CloudPrint.Client.Services; using CloudPrint.Client.Services.Abstractions; using Grpc.Core; namespace CloudPrint.Client.ViewModels; public sealed 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 _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? _selectedJob; private PrintJob? _processingJob; private PrinterInfo? _selectedPrinter; private ConnectionState _connectionState = ConnectionState.Disconnected; private string _lastMessage = "已就绪"; private bool _isReconnecting; private bool _isManualDisconnect = true; private bool _autoProcessQueue = true; private bool _isQueuePaused; private bool _isQueueProcessing; 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); RefreshPrintersCommand = new RelayCommand(RefreshPrinters); ConnectCommand = new AsyncRelayCommand(ConnectAsync, () => SelectedPrinter is not null); SubscribeCommand = new AsyncRelayCommand(SubscribeAsync, () => CanSubscribe); ProcessSelectedJobCommand = new AsyncRelayCommand(ProcessSelectedJobAsync, () => Jobs.Any(job => job.Status == PrintJobStatus.Pending) && !IsQueueProcessing); CancelSelectedJobCommand = new AsyncRelayCommand(CancelSelectedJobAsync, () => CanCancelSelectedJob); PauseQueueCommand = new RelayCommand(PauseQueue, () => AutoProcessQueue && !IsQueuePaused); ResumeQueueCommand = new RelayCommand(ResumeQueue, () => AutoProcessQueue && IsQueuePaused); DisconnectCommand = new RelayCommand(Disconnect, () => _backendClient.IsConnected || _webSocketService.IsConnected); SaveSettingsCommand = new RelayCommand(SaveSettings); ClearLogsCommand = new RelayCommand(ClearLogs); RefreshPrinters(); } public ClientSettings Settings { get; } public ObservableCollection Printers { get; } = new(); public ObservableCollection Jobs { get; } = new(); public ObservableCollection Logs { get; } = new(); public LibraryViewModel Library { get; } public SettingsViewModel SettingsPanel { get; } public PrintJob? SelectedJob { get => _selectedJob; set { if (SetProperty(ref _selectedJob, value)) { CancelSelectedJobCommand.RaiseCanExecuteChanged(); } } } public PrinterInfo? SelectedPrinter { get => _selectedPrinter; set { if (SetProperty(ref _selectedPrinter, value)) { (ConnectCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged(); (SubscribeCommand as AsyncRelayCommand)?.RaiseCanExecuteChanged(); } } } public ConnectionState ConnectionState { get => _connectionState; private set { if (SetProperty(ref _connectionState, value)) { OnPropertyChanged(nameof(ConnectionStatus)); OnPropertyChanged(nameof(CanSubscribe)); SubscribeCommand.RaiseCanExecuteChanged(); DisconnectCommand.RaiseCanExecuteChanged(); Library.RefreshCommandState(); } } } 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 bool AutoProcessQueue { get => _autoProcessQueue; set { if (SetProperty(ref _autoProcessQueue, value)) { OnPropertyChanged(nameof(QueueStatusText)); RaiseQueueCommandStates(); if (value) { StartQueueProcessingIfNeeded(); } } } } public bool IsQueuePaused { get => _isQueuePaused; private set { if (SetProperty(ref _isQueuePaused, value)) { OnPropertyChanged(nameof(QueueStatusText)); RaiseQueueCommandStates(); } } } public bool IsQueueProcessing { get => _isQueueProcessing; private set { if (SetProperty(ref _isQueueProcessing, value)) { OnPropertyChanged(nameof(QueueStatusText)); RaiseQueueCommandStates(); } } } public string QueueStatusText => AutoProcessQueue switch { false => "自动队列已关闭,可手动处理下一任务", _ when IsQueuePaused => "队列已暂停,当前任务完成后不再继续处理", _ when IsQueueProcessing => "队列处理中,任务将按顺序自动打印", _ => "自动队列已开启,等待新任务" }; public string LastMessage { get => _lastMessage; private set => SetProperty(ref _lastMessage, value); } public RelayCommand RefreshPrintersCommand { get; } public AsyncRelayCommand ConnectCommand { get; } public AsyncRelayCommand SubscribeCommand { get; } public AsyncRelayCommand ProcessSelectedJobCommand { get; } public AsyncRelayCommand CancelSelectedJobCommand { get; } public RelayCommand PauseQueueCommand { get; } public RelayCommand ResumeQueueCommand { get; } public RelayCommand DisconnectCommand { get; } public RelayCommand SaveSettingsCommand { get; } public RelayCommand ClearLogsCommand { get; } private void RefreshPrinters() { Printers.Clear(); foreach (var printer in _printService.GetPrinters()) { Printers.Add(printer); } SelectedPrinter = Printers.FirstOrDefault(); AddLog(AppLogLevel.Info, $"发现 {Printers.Count} 台打印机"); } 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.RaiseCanExecuteChanged(); Library.RefreshCommandState(); } catch (Exception ex) { ConnectionState = ConnectionState.Error; AddLog(AppLogEntry.FromException($"连接失败:{ex.Message}", ex)); } } private async Task SubscribeAsync() { if (SelectedPrinter is null) { AddLog(AppLogLevel.Warning, "请先选择打印机"); return; } try { ConnectionState = ConnectionState.Subscribing; await _backendClient.SubscribePrintJobsAsync(SelectedPrinter).ConfigureAwait(true); ConnectionState = ConnectionState.Subscribed; StartSubscriptionWatchLoop(); ProcessSelectedJobCommand.RaiseCanExecuteChanged(); Library.RefreshCommandState(); StartQueueProcessingIfNeeded(); } catch (Exception ex) { ConnectionState = ConnectionState.Error; AddLog(AppLogEntry.FromException($"订阅失败:{ex.Message}", ex)); } } 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 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.RaiseCanExecuteChanged(); CancelSelectedJobCommand.RaiseCanExecuteChanged(); } } private void OnPrintJobReceived(object? sender, PrintJobReceivedEventArgs e) { var isDuplicate = false; _uiDispatcher.Invoke(() => { if (!TryRememberJob(e.Job)) { isDuplicate = true; return; } Jobs.Insert(0, e.Job); ProcessSelectedJobCommand.RaiseCanExecuteChanged(); }); if (isDuplicate) { AddLog(AppLogLevel.Warning, $"已忽略重复打印任务:{GetJobDisplayName(e.Job)}"); return; } StartQueueProcessingIfNeeded(); } private void SaveSettings() { ConfigureBackendMode(); _settingsService.Save(Settings); AddLog(AppLogLevel.Success, "设置已保存"); } private void ConfigureBackendMode() { if (_backendClient is GrpcService grpcService) { grpcService.AllowInsecureGrpc = Settings.AllowInsecureGrpc; } } private void Disconnect() { _isManualDisconnect = true; _heartbeatCts?.Cancel(); _subscriptionWatchCts?.Cancel(); _queueCts?.Cancel(); _processingCts?.Cancel(); _backendClient.Disconnect(); _ = _webSocketService.DisconnectAsync(); ConnectionState = ConnectionState.Disconnected; AddLog(AppLogLevel.Warning, "客户端已断开连接"); Library.RefreshCommandState(); } private void PauseQueue() { IsQueuePaused = true; AddLog(AppLogLevel.Warning, "自动队列已暂停,当前任务完成后停止处理后续任务"); } 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.RaiseCanExecuteChanged(); CancelSelectedJobCommand.RaiseCanExecuteChanged(); PauseQueueCommand.RaiseCanExecuteChanged(); ResumeQueueCommand.RaiseCanExecuteChanged(); }); } 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); 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; } } 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 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; } }