779 lines
26 KiB
C#
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;
|
|
}
|
|
}
|