767 lines
26 KiB
C#
767 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 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<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? _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<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 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;
|
||
|
|
}
|
||
|
|
}
|