feat: 初始功能

This commit is contained in:
Shiqvlizi Name
2026-05-23 18:16:50 +08:00
parent a5bfd8792e
commit 9a51259e9e
63 changed files with 5377 additions and 1 deletions

View File

@@ -0,0 +1,154 @@
using System.Collections.ObjectModel;
using CloudPrint.Client.Infrastructure;
using CloudPrint.Client.Models;
using CloudPrint.Client.Services;
using CloudPrint.Client.Services.Abstractions;
using Microsoft.Win32;
namespace CloudPrint.Client.ViewModels;
public sealed class LibraryViewModel : ObservableObject
{
private readonly IFileService _fileService;
private readonly IPrintBackendClient _backendClient;
private readonly ClientSettings _settings;
private LibraryCategory? _selectedCategory;
private CloudPrintDocument? _selectedDocument;
private string _statusMessage = "文库就绪";
public LibraryViewModel()
: this(new FileService(), new GrpcService(), new ClientSettings())
{
}
public LibraryViewModel(IFileService fileService, IPrintBackendClient backendClient, ClientSettings settings)
{
_fileService = fileService;
_backendClient = backendClient;
_settings = settings;
Categories.Add(new LibraryCategory { Id = 1, Name = "教材资料", Icon = "📘", SortOrder = 1 });
Categories.Add(new LibraryCategory { Id = 2, Name = "考试真题", Icon = "📝", SortOrder = 2 });
Categories.Add(new LibraryCategory { Id = 3, Name = "证件照片", Icon = "🖼️", SortOrder = 3 });
Categories.Add(new LibraryCategory { Id = 4, Name = "其他文件", Icon = "📁", SortOrder = 99 });
SelectedCategory = Categories.FirstOrDefault();
AddDocumentCommand = new RelayCommand(AddDocument);
RemoveDocumentCommand = new RelayCommand(RemoveSelectedDocument, () => SelectedDocument is not null);
SyncSelectedCommand = new AsyncRelayCommand(SyncSelectedAsync, () => SelectedDocument is not null && _backendClient.IsConnected);
SyncAllCommand = new AsyncRelayCommand(SyncAllAsync, () => Documents.Any(document => !document.IsUploaded) && _backendClient.IsConnected);
}
public ObservableCollection<LibraryCategory> Categories { get; } = new();
public ObservableCollection<CloudPrintDocument> Documents { get; } = new();
public LibraryCategory? SelectedCategory
{
get => _selectedCategory;
set => SetProperty(ref _selectedCategory, value);
}
public CloudPrintDocument? SelectedDocument
{
get => _selectedDocument;
set
{
if (SetProperty(ref _selectedDocument, value))
{
RemoveDocumentCommand.RaiseCanExecuteChanged();
SyncSelectedCommand.RaiseCanExecuteChanged();
}
}
}
public string StatusMessage
{
get => _statusMessage;
private set => SetProperty(ref _statusMessage, value);
}
public RelayCommand AddDocumentCommand { get; }
public RelayCommand RemoveDocumentCommand { get; }
public AsyncRelayCommand SyncSelectedCommand { get; }
public AsyncRelayCommand SyncAllCommand { get; }
public void RefreshCommandState()
{
SyncSelectedCommand.RaiseCanExecuteChanged();
SyncAllCommand.RaiseCanExecuteChanged();
}
private void AddDocument()
{
var dialog = new Microsoft.Win32.OpenFileDialog
{
Title = "选择要加入文库的文件",
Multiselect = true,
Filter = "支持的文件|*.pdf;*.doc;*.docx;*.txt;*.xls;*.xlsx;*.ppt;*.pptx;*.jpg;*.jpeg;*.png;*.bmp;*.gif;*.zip;*.rar|所有文件|*.*"
};
if (dialog.ShowDialog() != true)
{
return;
}
var addedCount = 0;
var categoryName = SelectedCategory?.Name ?? "未分类";
var limit = Math.Max(1, _settings.MaxUploadSizeMb) * 1024L * 1024L;
foreach (var fileName in dialog.FileNames)
{
var validation = _fileService.ValidateFile(fileName, limit);
if (!validation.IsValid)
{
StatusMessage = $"跳过 {System.IO.Path.GetFileName(fileName)}{validation.Message}";
continue;
}
var document = _fileService.CopyToLibrary(fileName, _settings.LibraryRoot, categoryName, SelectedCategory?.Id ?? 0);
Documents.Insert(0, document);
addedCount++;
}
StatusMessage = addedCount > 0 ? $"已加入 {addedCount} 个文件到文库" : "没有文件被加入文库";
RefreshCommandState();
}
private void RemoveSelectedDocument()
{
if (SelectedDocument is null)
{
return;
}
var document = SelectedDocument;
Documents.Remove(document);
SelectedDocument = null;
StatusMessage = $"已从列表移除:{document.Title}";
RefreshCommandState();
}
private async Task SyncSelectedAsync()
{
if (SelectedDocument is null)
{
return;
}
await _backendClient.UploadLibraryDocumentAsync(SelectedDocument).ConfigureAwait(true);
StatusMessage = $"已同步:{SelectedDocument.Title}";
RefreshCommandState();
}
private async Task SyncAllAsync()
{
var pendingDocuments = Documents.Where(document => !document.IsUploaded).ToList();
foreach (var document in pendingDocuments)
{
await _backendClient.UploadLibraryDocumentAsync(document).ConfigureAwait(true);
}
StatusMessage = $"已同步 {pendingDocuments.Count} 个文库文件";
RefreshCommandState();
}
}

767
ViewModels/MainViewModel.cs Normal file
View File

@@ -0,0 +1,767 @@
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;
}
}

View File

@@ -0,0 +1,9 @@
using System.Collections.ObjectModel;
using CloudPrint.Client.Models;
namespace CloudPrint.Client.ViewModels;
public sealed class PrintQueueViewModel
{
public ObservableCollection<PrintJob> Jobs { get; } = new();
}

View File

@@ -0,0 +1,155 @@
using CloudPrint.Client.Models;
using CloudPrint.Client.Infrastructure;
using CloudPrint.Client.Services;
using CloudPrint.Client.Services.Abstractions;
using System.IO;
namespace CloudPrint.Client.ViewModels;
public sealed class SettingsViewModel : ObservableObject
{
private readonly ISettingsService _settingsService;
private readonly IPrintBackendClient _backendClient;
private readonly IAppLogger _appLogger;
private string _statusMessage = "设置就绪";
private string _adminPassword = string.Empty;
public SettingsViewModel()
: this(new JsonSettingsService().Load(), new JsonSettingsService(), new GrpcService(), new FileAppLogger())
{
}
public SettingsViewModel(ClientSettings settings, ISettingsService settingsService, IPrintBackendClient backendClient, IAppLogger appLogger)
{
Settings = settings;
_settingsService = settingsService;
_backendClient = backendClient;
_appLogger = appLogger;
SaveCommand = new RelayCommand(Save);
SelectLibraryRootCommand = new RelayCommand(SelectLibraryRoot);
LoginAdminCommand = new AsyncRelayCommand(LoginAdminAsync);
LogoutAdminCommand = new RelayCommand(LogoutAdmin, () => IsAdminLoggedIn);
OpenLogDirectoryCommand = new RelayCommand(OpenLogDirectory);
if (!string.IsNullOrWhiteSpace(Settings.AdminAccessToken))
{
_backendClient.SetAdminToken(Settings.AdminUsername, Settings.AdminAccessToken);
IsAdminLoggedIn = true;
}
}
public ClientSettings Settings { get; }
public string StatusMessage
{
get => _statusMessage;
private set => SetProperty(ref _statusMessage, value);
}
public RelayCommand SaveCommand { get; }
public RelayCommand SelectLibraryRootCommand { get; }
public AsyncRelayCommand LoginAdminCommand { get; }
public RelayCommand LogoutAdminCommand { get; }
public RelayCommand OpenLogDirectoryCommand { get; }
public string LogDirectory => _appLogger.LogDirectory;
private bool _isAdminLoggedIn;
public bool IsAdminLoggedIn
{
get => _isAdminLoggedIn;
private set
{
if (SetProperty(ref _isAdminLoggedIn, value))
{
OnPropertyChanged(nameof(AdminLoginStatus));
LogoutAdminCommand.RaiseCanExecuteChanged();
}
}
}
public string AdminLoginStatus => IsAdminLoggedIn ? $"已登录:{Settings.AdminUsername}" : "未登录";
public string AdminPassword
{
get => _adminPassword;
set => SetProperty(ref _adminPassword, value);
}
private void Save()
{
_settingsService.Save(Settings);
StatusMessage = "设置已保存";
}
private async Task LoginAdminAsync()
{
try
{
ConfigureBackendMode();
if (!_backendClient.IsConnected)
{
await _backendClient.ConnectAsync(Settings.ServerAddress, Settings.ApiKey).ConfigureAwait(true);
}
var session = await _backendClient.LoginAdminAsync(Settings.AdminUsername, AdminPassword).ConfigureAwait(true);
Settings.AdminUsername = session.Username;
Settings.AdminAccessToken = session.AccessToken;
AdminPassword = string.Empty;
IsAdminLoggedIn = true;
_settingsService.Save(Settings);
StatusMessage = $"管理员登录成功:{session.DisplayName}";
}
catch (Exception ex)
{
IsAdminLoggedIn = false;
StatusMessage = $"管理员登录失败:{ex.Message}";
}
}
private void LogoutAdmin()
{
_backendClient.LogoutAdmin();
Settings.AdminAccessToken = string.Empty;
AdminPassword = string.Empty;
IsAdminLoggedIn = false;
_settingsService.Save(Settings);
StatusMessage = "管理员已退出登录";
}
private void ConfigureBackendMode()
{
if (_backendClient is GrpcService grpcService)
{
grpcService.AllowInsecureGrpc = Settings.AllowInsecureGrpc;
}
}
private void SelectLibraryRoot()
{
using var dialog = new System.Windows.Forms.FolderBrowserDialog
{
Description = "选择本地文库目录",
UseDescriptionForTitle = true,
SelectedPath = string.IsNullOrWhiteSpace(Settings.LibraryRoot)
? Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments)
: Settings.LibraryRoot
};
if (dialog.ShowDialog() == System.Windows.Forms.DialogResult.OK)
{
Settings.LibraryRoot = dialog.SelectedPath;
StatusMessage = "已选择文库目录";
}
}
private void OpenLogDirectory()
{
Directory.CreateDirectory(_appLogger.LogDirectory);
var startInfo = new System.Diagnostics.ProcessStartInfo
{
FileName = _appLogger.LogDirectory,
UseShellExecute = true
};
System.Diagnostics.Process.Start(startInfo);
}
}