diff --git a/CloudPrint.Client.csproj b/CloudPrint.Client.csproj index 78317cd..81a17ce 100644 --- a/CloudPrint.Client.csproj +++ b/CloudPrint.Client.csproj @@ -12,6 +12,7 @@ + @@ -25,4 +26,4 @@ - \ No newline at end of file + diff --git a/Infrastructure/AsyncRelayCommand.cs b/Infrastructure/AsyncRelayCommand.cs deleted file mode 100644 index e30d481..0000000 --- a/Infrastructure/AsyncRelayCommand.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.Windows.Input; - -namespace CloudPrint.Client.Infrastructure; - -public sealed class AsyncRelayCommand : ICommand -{ - private readonly Func _execute; - private readonly Func? _canExecute; - private bool _isExecuting; - - public AsyncRelayCommand(Func execute, Func? canExecute = null) - { - _execute = execute ?? throw new ArgumentNullException(nameof(execute)); - _canExecute = canExecute; - } - - public event EventHandler? CanExecuteChanged; - - public bool IsExecuting - { - get => _isExecuting; - private set - { - if (_isExecuting == value) - { - return; - } - - _isExecuting = value; - RaiseCanExecuteChanged(); - } - } - - public bool CanExecute(object? parameter) => !IsExecuting && (_canExecute?.Invoke() ?? true); - - public async void Execute(object? parameter) - { - if (!CanExecute(parameter)) - { - return; - } - - try - { - IsExecuting = true; - await _execute().ConfigureAwait(true); - } - finally - { - IsExecuting = false; - } - } - - public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); -} \ No newline at end of file diff --git a/Infrastructure/ObservableObject.cs b/Infrastructure/ObservableObject.cs deleted file mode 100644 index 99cb8cb..0000000 --- a/Infrastructure/ObservableObject.cs +++ /dev/null @@ -1,26 +0,0 @@ -using System.ComponentModel; -using System.Runtime.CompilerServices; - -namespace CloudPrint.Client.Infrastructure; - -public abstract class ObservableObject : INotifyPropertyChanged -{ - public event PropertyChangedEventHandler? PropertyChanged; - - protected bool SetProperty(ref T field, T value, [CallerMemberName] string? propertyName = null) - { - if (EqualityComparer.Default.Equals(field, value)) - { - return false; - } - - field = value; - OnPropertyChanged(propertyName); - return true; - } - - protected void OnPropertyChanged([CallerMemberName] string? propertyName = null) - { - PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); - } -} \ No newline at end of file diff --git a/Infrastructure/RelayCommand.cs b/Infrastructure/RelayCommand.cs deleted file mode 100644 index bd4daa7..0000000 --- a/Infrastructure/RelayCommand.cs +++ /dev/null @@ -1,28 +0,0 @@ -using System.Windows.Input; - -namespace CloudPrint.Client.Infrastructure; - -public sealed class RelayCommand : ICommand -{ - private readonly Action _execute; - private readonly Predicate? _canExecute; - - public RelayCommand(Action execute, Func? canExecute = null) - : this(_ => execute(), canExecute is null ? null : _ => canExecute()) - { - } - - public RelayCommand(Action execute, Predicate? canExecute = null) - { - _execute = execute ?? throw new ArgumentNullException(nameof(execute)); - _canExecute = canExecute; - } - - public event EventHandler? CanExecuteChanged; - - public bool CanExecute(object? parameter) => _canExecute?.Invoke(parameter) ?? true; - - public void Execute(object? parameter) => _execute(parameter); - - public void RaiseCanExecuteChanged() => CanExecuteChanged?.Invoke(this, EventArgs.Empty); -} \ No newline at end of file diff --git a/Models/ClientSettings.cs b/Models/ClientSettings.cs index 98946e5..9a8d421 100644 --- a/Models/ClientSettings.cs +++ b/Models/ClientSettings.cs @@ -1,77 +1,45 @@ -using CloudPrint.Client.Infrastructure; +using CommunityToolkit.Mvvm.ComponentModel; namespace CloudPrint.Client.Models; -public sealed class ClientSettings : ObservableObject +public sealed partial class ClientSettings : ObservableObject { - private string _serverAddress = "http://localhost:8080"; - private string _apiKey = string.Empty; - private string _adminUsername = string.Empty; - private string _adminAccessToken = string.Empty; - private string _storeName = "默认打印店"; - private bool _allowInsecureGrpc = true; - private string _libraryRoot = string.Empty; - private string _printCacheRoot = string.Empty; - private int _maxUploadSizeMb = 50; - private int _heartbeatIntervalSeconds = 15; + [ObservableProperty] + private string serverAddress = "http://localhost:8080"; - public string ServerAddress - { - get => _serverAddress; - set => SetProperty(ref _serverAddress, value); - } + [ObservableProperty] + private string apiKey = string.Empty; - public string ApiKey - { - get => _apiKey; - set => SetProperty(ref _apiKey, value); - } + [ObservableProperty] + private string adminUsername = string.Empty; - public string AdminUsername - { - get => _adminUsername; - set => SetProperty(ref _adminUsername, value); - } + [ObservableProperty] + private string adminAccessToken = string.Empty; - public string AdminAccessToken - { - get => _adminAccessToken; - set => SetProperty(ref _adminAccessToken, value); - } + [ObservableProperty] + private string storeName = "默认打印店"; - public string StoreName - { - get => _storeName; - set => SetProperty(ref _storeName, value); - } + [ObservableProperty] + private bool allowInsecureGrpc = true; - public bool AllowInsecureGrpc - { - get => _allowInsecureGrpc; - set => SetProperty(ref _allowInsecureGrpc, value); - } + [ObservableProperty] + private string libraryRoot = string.Empty; - public string LibraryRoot - { - get => _libraryRoot; - set => SetProperty(ref _libraryRoot, value); - } + [ObservableProperty] + private string printCacheRoot = string.Empty; - public string PrintCacheRoot - { - get => _printCacheRoot; - set => SetProperty(ref _printCacheRoot, value); - } + private int maxUploadSizeMb = 50; + private int heartbeatIntervalSeconds = 15; public int MaxUploadSizeMb { - get => _maxUploadSizeMb; - set => SetProperty(ref _maxUploadSizeMb, Math.Clamp(value, 1, 1024)); + get => maxUploadSizeMb; + set => SetProperty(ref maxUploadSizeMb, Math.Clamp(value, 1, 1024)); } public int HeartbeatIntervalSeconds { - get => _heartbeatIntervalSeconds; - set => SetProperty(ref _heartbeatIntervalSeconds, Math.Clamp(value, 5, 300)); + get => heartbeatIntervalSeconds; + set => SetProperty(ref heartbeatIntervalSeconds, Math.Clamp(value, 5, 300)); } -} \ No newline at end of file +} diff --git a/Models/Document.cs b/Models/Document.cs index dfa5199..861fb99 100644 --- a/Models/Document.cs +++ b/Models/Document.cs @@ -1,14 +1,23 @@ -using CloudPrint.Client.Infrastructure; +using CommunityToolkit.Mvvm.ComponentModel; namespace CloudPrint.Client.Models; -public sealed class CloudPrintDocument : ObservableObject +public sealed partial class CloudPrintDocument : ObservableObject { - private string _title = string.Empty; - private string _description = string.Empty; - private string _categoryName = "未分类"; - private bool _isUploaded; - private string _syncStatus = "本地"; + [ObservableProperty] + private string title = string.Empty; + + [ObservableProperty] + private string description = string.Empty; + + [ObservableProperty] + private string categoryName = "未分类"; + + [ObservableProperty] + private bool isUploaded; + + [ObservableProperty] + private string syncStatus = "本地"; public int Id { get; init; } public string FileName { get; init; } = string.Empty; @@ -21,36 +30,6 @@ public sealed class CloudPrintDocument : ObservableObject public int PageCount { get; init; } public DateTime CreatedAt { get; init; } = DateTime.Now; - public string Title - { - get => _title; - set => SetProperty(ref _title, value); - } - - public string Description - { - get => _description; - set => SetProperty(ref _description, value); - } - - public string CategoryName - { - get => _categoryName; - set => SetProperty(ref _categoryName, value); - } - - public bool IsUploaded - { - get => _isUploaded; - set => SetProperty(ref _isUploaded, value); - } - - public string SyncStatus - { - get => _syncStatus; - set => SetProperty(ref _syncStatus, value); - } - public string FileSizeText { get @@ -68,4 +47,4 @@ public sealed class CloudPrintDocument : ObservableObject return $"{FileSize} B"; } } -} \ No newline at end of file +} diff --git a/Models/PrintJob.cs b/Models/PrintJob.cs index 6bfd924..27f0384 100644 --- a/Models/PrintJob.cs +++ b/Models/PrintJob.cs @@ -1,4 +1,4 @@ -using CloudPrint.Client.Infrastructure; +using CommunityToolkit.Mvvm.ComponentModel; namespace CloudPrint.Client.Models; @@ -11,13 +11,22 @@ public enum PrintJobStatus Cancelled } -public sealed class PrintJob : ObservableObject +public sealed partial class PrintJob : ObservableObject { - private PrintJobStatus _status = PrintJobStatus.Pending; - private int _progress; - private string _errorMessage = string.Empty; - private int? _windowsJobId; - private string _windowsJobStatus = string.Empty; + [ObservableProperty] + private int? windowsJobId; + + [ObservableProperty] + private string windowsJobStatus = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusText))] + private PrintJobStatus status = PrintJobStatus.Pending; + + [ObservableProperty] + private string errorMessage = string.Empty; + + private int progress; public int Id { get; init; } public string JobNo { get; init; } = string.Empty; @@ -34,40 +43,10 @@ public sealed class PrintJob : ObservableObject public DateTime? StartedAt { get; set; } public DateTime? CompletedAt { get; set; } - public int? WindowsJobId - { - get => _windowsJobId; - set => SetProperty(ref _windowsJobId, value); - } - - public string WindowsJobStatus - { - get => _windowsJobStatus; - set => SetProperty(ref _windowsJobStatus, value); - } - - public PrintJobStatus Status - { - get => _status; - set - { - if (SetProperty(ref _status, value)) - { - OnPropertyChanged(nameof(StatusText)); - } - } - } - public int Progress { - get => _progress; - set => SetProperty(ref _progress, Math.Clamp(value, 0, 100)); - } - - public string ErrorMessage - { - get => _errorMessage; - set => SetProperty(ref _errorMessage, value); + get => progress; + set => SetProperty(ref progress, Math.Clamp(value, 0, 100)); } public string StatusText => Status switch @@ -107,4 +86,4 @@ public sealed class PrintJob : ObservableObject Status = PrintJobStatus.Cancelled; CompletedAt = DateTime.Now; } -} \ No newline at end of file +} diff --git a/Models/Printer.cs b/Models/Printer.cs index 8b3e634..3f489f3 100644 --- a/Models/Printer.cs +++ b/Models/Printer.cs @@ -1,4 +1,4 @@ -using CloudPrint.Client.Infrastructure; +using CommunityToolkit.Mvvm.ComponentModel; namespace CloudPrint.Client.Models; @@ -11,11 +11,20 @@ public enum PrinterStatus Unknown = 99 } -public sealed class PrinterInfo : ObservableObject +public sealed partial class PrinterInfo : ObservableObject { - private PrinterStatus _status; - private int _queueCount; - private string _statusMessage = string.Empty; + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DisplayText))] + private int queueCount; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(DisplayText))] + private string statusMessage = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(StatusText))] + [NotifyPropertyChangedFor(nameof(DisplayText))] + private PrinterStatus status; public int Id { get; init; } public string Name { get; init; } = string.Empty; @@ -24,40 +33,11 @@ public sealed class PrinterInfo : ObservableObject public string ApiKey { get; init; } = string.Empty; public DateTime? LastHeartbeat { get; set; } - public int QueueCount + partial void OnQueueCountChanged(int value) { - get => _queueCount; - set + if (value < 0) { - if (SetProperty(ref _queueCount, Math.Max(0, value))) - { - OnPropertyChanged(nameof(DisplayText)); - } - } - } - - public string StatusMessage - { - get => _statusMessage; - set - { - if (SetProperty(ref _statusMessage, value)) - { - OnPropertyChanged(nameof(DisplayText)); - } - } - } - - public PrinterStatus Status - { - get => _status; - set - { - if (SetProperty(ref _status, value)) - { - OnPropertyChanged(nameof(StatusText)); - OnPropertyChanged(nameof(DisplayText)); - } + QueueCount = 0; } } @@ -79,4 +59,4 @@ public sealed class PrinterInfo : ObservableObject return $"{DisplayName}({StatusText}{queueText}{messageText})"; } } -} \ No newline at end of file +} diff --git a/Services/GrpcService.cs b/Services/GrpcService.cs index 9223830..64d7c2a 100644 --- a/Services/GrpcService.cs +++ b/Services/GrpcService.cs @@ -52,20 +52,29 @@ public sealed class GrpcService : IPrintBackendClient throw new ArgumentException("API Key 不能为空", nameof(apiKey)); } - _apiKey = apiKey; if (address.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && AllowInsecureGrpc) { AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); } - _channel?.Dispose(); - _channel = GrpcChannel.ForAddress(address); - _printClient = new Cloudprint.PrintService.PrintServiceClient(_channel); - _libraryClient = new Cloudprint.LibraryService.LibraryServiceClient(_channel); - _fileClient = new Cloudprint.FileService.FileServiceClient(_channel); - _adminClient = new Cloudprint.AdminService.AdminServiceClient(_channel); - IsConnected = true; - Log(AppLogLevel.Success, $"已连接 gRPC 服务:{address}"); + ResetConnectionState(clearCredentials: true); + + try + { + _apiKey = apiKey; + _channel = GrpcChannel.ForAddress(address); + _printClient = new Cloudprint.PrintService.PrintServiceClient(_channel); + _libraryClient = new Cloudprint.LibraryService.LibraryServiceClient(_channel); + _fileClient = new Cloudprint.FileService.FileServiceClient(_channel); + _adminClient = new Cloudprint.AdminService.AdminServiceClient(_channel); + IsConnected = true; + Log(AppLogLevel.Success, $"已连接 gRPC 服务:{address}"); + } + catch + { + ResetConnectionState(clearCredentials: true); + throw; + } } public async Task LoginAdminAsync(string username, string password, CancellationToken cancellationToken = default) @@ -240,14 +249,7 @@ public sealed class GrpcService : IPrintBackendClient public void Disconnect() { - _subscriptionCts?.Cancel(); - IsConnected = false; - _channel?.Dispose(); - _channel = null; - _printClient = null; - _libraryClient = null; - _fileClient = null; - _adminClient = null; + ResetConnectionState(clearCredentials: true); Log(AppLogLevel.Warning, "已断开 gRPC 连接"); } @@ -283,7 +285,28 @@ public sealed class GrpcService : IPrintBackendClient catch (Exception ex) { Log(AppLogLevel.Error, $"打印任务订阅中断:{ex.Message}"); - IsConnected = false; + ResetConnectionState(clearCredentials: false); + } + } + + private void ResetConnectionState(bool clearCredentials) + { + _subscriptionCts?.Cancel(); + _subscriptionCts?.Dispose(); + _subscriptionCts = null; + _channel?.Dispose(); + _channel = null; + _printClient = null; + _libraryClient = null; + _fileClient = null; + _adminClient = null; + IsConnected = false; + PrinterId = 0; + Token = string.Empty; + + if (clearCredentials) + { + _apiKey = string.Empty; } } @@ -359,4 +382,4 @@ public sealed class GrpcService : IPrintBackendClient Message = message }); } -} \ No newline at end of file +} diff --git a/ViewModels/LibraryViewModel.cs b/ViewModels/LibraryViewModel.cs index 96d5ff1..d55d451 100644 --- a/ViewModels/LibraryViewModel.cs +++ b/ViewModels/LibraryViewModel.cs @@ -1,20 +1,29 @@ 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 Microsoft.Win32; namespace CloudPrint.Client.ViewModels; -public sealed class LibraryViewModel : ObservableObject +public sealed partial class LibraryViewModel : ObservableObject { private readonly IFileService _fileService; private readonly IPrintBackendClient _backendClient; private readonly ClientSettings _settings; - private LibraryCategory? _selectedCategory; - private CloudPrintDocument? _selectedDocument; - private string _statusMessage = "文库就绪"; + + [ObservableProperty] + private LibraryCategory? selectedCategory; + + [ObservableProperty] + [NotifyCanExecuteChangedFor(nameof(RemoveDocumentCommand))] + [NotifyCanExecuteChangedFor(nameof(SyncSelectedCommand))] + private CloudPrintDocument? selectedDocument; + + [ObservableProperty] + private string statusMessage = "文库就绪"; public LibraryViewModel() : this(new FileService(), new GrpcService(), new ClientSettings()) @@ -33,51 +42,18 @@ public sealed class LibraryViewModel : ObservableObject 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 Categories { get; } = new(); public ObservableCollection 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(); + SyncSelectedCommand.NotifyCanExecuteChanged(); + SyncAllCommand.NotifyCanExecuteChanged(); } + [RelayCommand] private void AddDocument() { var dialog = new Microsoft.Win32.OpenFileDialog @@ -114,7 +90,10 @@ public sealed class LibraryViewModel : ObservableObject RefreshCommandState(); } - private void RemoveSelectedDocument() + private bool CanRemoveDocument() => SelectedDocument is not null; + + [RelayCommand(CanExecute = nameof(CanRemoveDocument))] + private void RemoveDocument() { if (SelectedDocument is null) { @@ -128,6 +107,9 @@ public sealed class LibraryViewModel : ObservableObject RefreshCommandState(); } + private bool CanSyncSelected() => SelectedDocument is not null && _backendClient.IsConnected; + + [RelayCommand(CanExecute = nameof(CanSyncSelected))] private async Task SyncSelectedAsync() { if (SelectedDocument is null) @@ -140,6 +122,9 @@ public sealed class LibraryViewModel : ObservableObject RefreshCommandState(); } + private bool CanSyncAll() => Documents.Any(document => !document.IsUploaded) && _backendClient.IsConnected; + + [RelayCommand(CanExecute = nameof(CanSyncAll))] private async Task SyncAllAsync() { var pendingDocuments = Documents.Where(document => !document.IsUploaded).ToList(); @@ -151,4 +136,4 @@ public sealed class LibraryViewModel : ObservableObject StatusMessage = $"已同步 {pendingDocuments.Count} 个文库文件"; RefreshCommandState(); } -} \ No newline at end of file +} diff --git a/ViewModels/MainViewModel.cs b/ViewModels/MainViewModel.cs index 06aa34d..313d076 100644 --- a/ViewModels/MainViewModel.cs +++ b/ViewModels/MainViewModel.cs @@ -3,11 +3,14 @@ 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 class MainViewModel : ObservableObject +public sealed partial class MainViewModel : ObservableObject { private const int MaxReconnectAttempts = 5; private static readonly TimeSpan SubscriptionWatchInterval = TimeSpan.FromSeconds(5); @@ -28,16 +31,42 @@ public sealed class MainViewModel : ObservableObject 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 PrinterInfo? _subscribedPrinter; private bool _isReconnecting; private bool _isManualDisconnect = true; - private bool _autoProcessQueue = true; - private bool _isQueuePaused; - private bool _isQueueProcessing; + + [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()) @@ -70,17 +99,6 @@ public sealed class MainViewModel : ObservableObject _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(); } @@ -91,47 +109,6 @@ public sealed class MainViewModel : ObservableObject 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 => "连接中", @@ -145,49 +122,6 @@ public sealed class MainViewModel : ObservableObject 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 => "自动队列已关闭,可手动处理下一任务", @@ -196,23 +130,31 @@ public sealed class MainViewModel : ObservableObject _ => "自动队列已开启,等待新任务" }; - public string LastMessage + partial void OnConnectionStateChanged(ConnectionState value) { - get => _lastMessage; - private set => SetProperty(ref _lastMessage, value); + Library.RefreshCommandState(); } - 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; } + 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(); @@ -225,6 +167,9 @@ public sealed class MainViewModel : ObservableObject AddLog(AppLogLevel.Info, $"发现 {Printers.Count} 台打印机"); } + private bool CanConnect() => SelectedPrinter is not null; + + [RelayCommand(CanExecute = nameof(CanConnect))] private async Task ConnectAsync() { if (SelectedPrinter is null) @@ -246,16 +191,20 @@ public sealed class MainViewModel : ObservableObject StartHeartbeatLoop(); ConnectionState = ConnectionState.Connected; - DisconnectCommand.RaiseCanExecuteChanged(); + 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) @@ -268,19 +217,24 @@ public sealed class MainViewModel : ObservableObject { ConnectionState = ConnectionState.Subscribing; await _backendClient.SubscribePrintJobsAsync(SelectedPrinter).ConfigureAwait(true); + _subscribedPrinter = SelectedPrinter; ConnectionState = ConnectionState.Subscribed; StartSubscriptionWatchLoop(); - ProcessSelectedJobCommand.RaiseCanExecuteChanged(); + 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); @@ -309,6 +263,9 @@ public sealed class MainViewModel : ObservableObject } } + private bool CanCancelSelectedJobCommand() => CanCancelSelectedJob; + + [RelayCommand(CanExecute = nameof(CanCancelSelectedJobCommand))] private async Task CancelSelectedJobAsync() { if (SelectedJob is null) @@ -335,8 +292,8 @@ public sealed class MainViewModel : ObservableObject } finally { - ProcessSelectedJobCommand.RaiseCanExecuteChanged(); - CancelSelectedJobCommand.RaiseCanExecuteChanged(); + ProcessSelectedJobCommand.NotifyCanExecuteChanged(); + CancelSelectedJobCommand.NotifyCanExecuteChanged(); } } @@ -351,8 +308,9 @@ public sealed class MainViewModel : ObservableObject return; } + EnsureJobPrinter(e.Job); Jobs.Insert(0, e.Job); - ProcessSelectedJobCommand.RaiseCanExecuteChanged(); + ProcessSelectedJobCommand.NotifyCanExecuteChanged(); }); if (isDuplicate) @@ -364,6 +322,7 @@ public sealed class MainViewModel : ObservableObject StartQueueProcessingIfNeeded(); } + [RelayCommand] private void SaveSettings() { ConfigureBackendMode(); @@ -379,6 +338,22 @@ public sealed class MainViewModel : ObservableObject } } + 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; @@ -386,19 +361,27 @@ public sealed class MainViewModel : ObservableObject _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; @@ -521,10 +504,19 @@ public sealed class MainViewModel : ObservableObject { _uiDispatcher.Invoke(() => { - ProcessSelectedJobCommand.RaiseCanExecuteChanged(); - CancelSelectedJobCommand.RaiseCanExecuteChanged(); - PauseQueueCommand.RaiseCanExecuteChanged(); - ResumeQueueCommand.RaiseCanExecuteChanged(); + ProcessSelectedJobCommand.NotifyCanExecuteChanged(); + CancelSelectedJobCommand.NotifyCanExecuteChanged(); + PauseQueueCommand.NotifyCanExecuteChanged(); + ResumeQueueCommand.NotifyCanExecuteChanged(); + }); + } + + private void NotifyConnectionCommandStates() + { + _uiDispatcher.Invoke(() => + { + SubscribeCommand.NotifyCanExecuteChanged(); + DisconnectCommand.NotifyCanExecuteChanged(); }); } @@ -642,6 +634,7 @@ public sealed class MainViewModel : ObservableObject 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, "自动重连成功,任务订阅已恢复"); @@ -672,6 +665,7 @@ public sealed class MainViewModel : ObservableObject } } + [RelayCommand] private void ClearLogs() { Logs.Clear(); @@ -724,6 +718,23 @@ public sealed class MainViewModel : ObservableObject 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)) @@ -764,4 +775,4 @@ public sealed class MainViewModel : ObservableObject return false; } -} \ No newline at end of file +} diff --git a/ViewModels/SettingsViewModel.cs b/ViewModels/SettingsViewModel.cs index 01a397b..9014f43 100644 --- a/ViewModels/SettingsViewModel.cs +++ b/ViewModels/SettingsViewModel.cs @@ -1,18 +1,28 @@ using CloudPrint.Client.Models; -using CloudPrint.Client.Infrastructure; using CloudPrint.Client.Services; using CloudPrint.Client.Services.Abstractions; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; using System.IO; namespace CloudPrint.Client.ViewModels; -public sealed class SettingsViewModel : ObservableObject +public sealed partial class SettingsViewModel : ObservableObject { private readonly ISettingsService _settingsService; private readonly IPrintBackendClient _backendClient; private readonly IAppLogger _appLogger; - private string _statusMessage = "设置就绪"; - private string _adminPassword = string.Empty; + + [ObservableProperty] + private string statusMessage = "设置就绪"; + + [ObservableProperty] + private string adminPassword = string.Empty; + + [ObservableProperty] + [NotifyPropertyChangedFor(nameof(AdminLoginStatus))] + [NotifyCanExecuteChangedFor(nameof(LogoutAdminCommand))] + private bool isAdminLoggedIn; public SettingsViewModel() : this(new JsonSettingsService().Load(), new JsonSettingsService(), new GrpcService(), new FileAppLogger()) @@ -25,11 +35,6 @@ public sealed class SettingsViewModel : ObservableObject _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)) { @@ -40,47 +45,18 @@ public sealed class SettingsViewModel : ObservableObject 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); - } - + [RelayCommand] private void Save() { _settingsService.Save(Settings); StatusMessage = "设置已保存"; } + [RelayCommand] private async Task LoginAdminAsync() { try @@ -106,6 +82,9 @@ public sealed class SettingsViewModel : ObservableObject } } + private bool CanLogoutAdmin() => IsAdminLoggedIn; + + [RelayCommand(CanExecute = nameof(CanLogoutAdmin))] private void LogoutAdmin() { _backendClient.LogoutAdmin(); @@ -124,6 +103,7 @@ public sealed class SettingsViewModel : ObservableObject } } + [RelayCommand] private void SelectLibraryRoot() { using var dialog = new System.Windows.Forms.FolderBrowserDialog @@ -142,6 +122,7 @@ public sealed class SettingsViewModel : ObservableObject } } + [RelayCommand] private void OpenLogDirectory() { Directory.CreateDirectory(_appLogger.LogDirectory); @@ -152,4 +133,4 @@ public sealed class SettingsViewModel : ObservableObject }; System.Diagnostics.Process.Start(startInfo); } -} \ No newline at end of file +}