fix: 使用 CommunityToolkit.Mvvm

This commit is contained in:
Shiqvlizi Name
2026-05-28 23:26:37 +08:00
parent 47572fefb8
commit ef61ae7e6b
12 changed files with 310 additions and 512 deletions

View File

@@ -12,6 +12,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
<PackageReference Include="Google.Protobuf" Version="3.27.3" /> <PackageReference Include="Google.Protobuf" Version="3.27.3" />
<PackageReference Include="Grpc.Net.Client" Version="2.65.0" /> <PackageReference Include="Grpc.Net.Client" Version="2.65.0" />
<PackageReference Include="Grpc.Tools" Version="2.65.0" PrivateAssets="All" /> <PackageReference Include="Grpc.Tools" Version="2.65.0" PrivateAssets="All" />

View File

@@ -1,55 +0,0 @@
using System.Windows.Input;
namespace CloudPrint.Client.Infrastructure;
public sealed class AsyncRelayCommand : ICommand
{
private readonly Func<Task> _execute;
private readonly Func<bool>? _canExecute;
private bool _isExecuting;
public AsyncRelayCommand(Func<Task> execute, Func<bool>? 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);
}

View File

@@ -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<T>(ref T field, T value, [CallerMemberName] string? propertyName = null)
{
if (EqualityComparer<T>.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));
}
}

View File

@@ -1,28 +0,0 @@
using System.Windows.Input;
namespace CloudPrint.Client.Infrastructure;
public sealed class RelayCommand : ICommand
{
private readonly Action<object?> _execute;
private readonly Predicate<object?>? _canExecute;
public RelayCommand(Action execute, Func<bool>? canExecute = null)
: this(_ => execute(), canExecute is null ? null : _ => canExecute())
{
}
public RelayCommand(Action<object?> execute, Predicate<object?>? 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);
}

View File

@@ -1,77 +1,45 @@
using CloudPrint.Client.Infrastructure; using CommunityToolkit.Mvvm.ComponentModel;
namespace CloudPrint.Client.Models; namespace CloudPrint.Client.Models;
public sealed class ClientSettings : ObservableObject public sealed partial class ClientSettings : ObservableObject
{ {
private string _serverAddress = "http://localhost:8080"; [ObservableProperty]
private string _apiKey = string.Empty; private string serverAddress = "http://localhost:8080";
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;
public string ServerAddress [ObservableProperty]
{ private string apiKey = string.Empty;
get => _serverAddress;
set => SetProperty(ref _serverAddress, value);
}
public string ApiKey [ObservableProperty]
{ private string adminUsername = string.Empty;
get => _apiKey;
set => SetProperty(ref _apiKey, value);
}
public string AdminUsername [ObservableProperty]
{ private string adminAccessToken = string.Empty;
get => _adminUsername;
set => SetProperty(ref _adminUsername, value);
}
public string AdminAccessToken [ObservableProperty]
{ private string storeName = "默认打印店";
get => _adminAccessToken;
set => SetProperty(ref _adminAccessToken, value);
}
public string StoreName [ObservableProperty]
{ private bool allowInsecureGrpc = true;
get => _storeName;
set => SetProperty(ref _storeName, value);
}
public bool AllowInsecureGrpc [ObservableProperty]
{ private string libraryRoot = string.Empty;
get => _allowInsecureGrpc;
set => SetProperty(ref _allowInsecureGrpc, value);
}
public string LibraryRoot [ObservableProperty]
{ private string printCacheRoot = string.Empty;
get => _libraryRoot;
set => SetProperty(ref _libraryRoot, value);
}
public string PrintCacheRoot private int maxUploadSizeMb = 50;
{ private int heartbeatIntervalSeconds = 15;
get => _printCacheRoot;
set => SetProperty(ref _printCacheRoot, value);
}
public int MaxUploadSizeMb public int MaxUploadSizeMb
{ {
get => _maxUploadSizeMb; get => maxUploadSizeMb;
set => SetProperty(ref _maxUploadSizeMb, Math.Clamp(value, 1, 1024)); set => SetProperty(ref maxUploadSizeMb, Math.Clamp(value, 1, 1024));
} }
public int HeartbeatIntervalSeconds public int HeartbeatIntervalSeconds
{ {
get => _heartbeatIntervalSeconds; get => heartbeatIntervalSeconds;
set => SetProperty(ref _heartbeatIntervalSeconds, Math.Clamp(value, 5, 300)); set => SetProperty(ref heartbeatIntervalSeconds, Math.Clamp(value, 5, 300));
} }
} }

View File

@@ -1,14 +1,23 @@
using CloudPrint.Client.Infrastructure; using CommunityToolkit.Mvvm.ComponentModel;
namespace CloudPrint.Client.Models; namespace CloudPrint.Client.Models;
public sealed class CloudPrintDocument : ObservableObject public sealed partial class CloudPrintDocument : ObservableObject
{ {
private string _title = string.Empty; [ObservableProperty]
private string _description = string.Empty; private string title = string.Empty;
private string _categoryName = "未分类";
private bool _isUploaded; [ObservableProperty]
private string _syncStatus = "本地"; private string description = string.Empty;
[ObservableProperty]
private string categoryName = "未分类";
[ObservableProperty]
private bool isUploaded;
[ObservableProperty]
private string syncStatus = "本地";
public int Id { get; init; } public int Id { get; init; }
public string FileName { get; init; } = string.Empty; public string FileName { get; init; } = string.Empty;
@@ -21,36 +30,6 @@ public sealed class CloudPrintDocument : ObservableObject
public int PageCount { get; init; } public int PageCount { get; init; }
public DateTime CreatedAt { get; init; } = DateTime.Now; 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 public string FileSizeText
{ {
get get

View File

@@ -1,4 +1,4 @@
using CloudPrint.Client.Infrastructure; using CommunityToolkit.Mvvm.ComponentModel;
namespace CloudPrint.Client.Models; namespace CloudPrint.Client.Models;
@@ -11,13 +11,22 @@ public enum PrintJobStatus
Cancelled Cancelled
} }
public sealed class PrintJob : ObservableObject public sealed partial class PrintJob : ObservableObject
{ {
private PrintJobStatus _status = PrintJobStatus.Pending; [ObservableProperty]
private int _progress; private int? windowsJobId;
private string _errorMessage = string.Empty;
private int? _windowsJobId; [ObservableProperty]
private string _windowsJobStatus = string.Empty; 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 int Id { get; init; }
public string JobNo { get; init; } = string.Empty; public string JobNo { get; init; } = string.Empty;
@@ -34,40 +43,10 @@ public sealed class PrintJob : ObservableObject
public DateTime? StartedAt { get; set; } public DateTime? StartedAt { get; set; }
public DateTime? CompletedAt { 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 public int Progress
{ {
get => _progress; get => progress;
set => SetProperty(ref _progress, Math.Clamp(value, 0, 100)); set => SetProperty(ref progress, Math.Clamp(value, 0, 100));
}
public string ErrorMessage
{
get => _errorMessage;
set => SetProperty(ref _errorMessage, value);
} }
public string StatusText => Status switch public string StatusText => Status switch

View File

@@ -1,4 +1,4 @@
using CloudPrint.Client.Infrastructure; using CommunityToolkit.Mvvm.ComponentModel;
namespace CloudPrint.Client.Models; namespace CloudPrint.Client.Models;
@@ -11,11 +11,20 @@ public enum PrinterStatus
Unknown = 99 Unknown = 99
} }
public sealed class PrinterInfo : ObservableObject public sealed partial class PrinterInfo : ObservableObject
{ {
private PrinterStatus _status; [ObservableProperty]
private int _queueCount; [NotifyPropertyChangedFor(nameof(DisplayText))]
private string _statusMessage = string.Empty; 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 int Id { get; init; }
public string Name { get; init; } = string.Empty; public string Name { get; init; } = string.Empty;
@@ -24,40 +33,11 @@ public sealed class PrinterInfo : ObservableObject
public string ApiKey { get; init; } = string.Empty; public string ApiKey { get; init; } = string.Empty;
public DateTime? LastHeartbeat { get; set; } public DateTime? LastHeartbeat { get; set; }
public int QueueCount partial void OnQueueCountChanged(int value)
{ {
get => _queueCount; if (value < 0)
set
{ {
if (SetProperty(ref _queueCount, Math.Max(0, value))) QueueCount = 0;
{
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));
}
} }
} }

View File

@@ -52,13 +52,16 @@ public sealed class GrpcService : IPrintBackendClient
throw new ArgumentException("API Key 不能为空", nameof(apiKey)); throw new ArgumentException("API Key 不能为空", nameof(apiKey));
} }
_apiKey = apiKey;
if (address.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && AllowInsecureGrpc) if (address.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && AllowInsecureGrpc)
{ {
AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true); AppContext.SetSwitch("System.Net.Http.SocketsHttpHandler.Http2UnencryptedSupport", true);
} }
_channel?.Dispose(); ResetConnectionState(clearCredentials: true);
try
{
_apiKey = apiKey;
_channel = GrpcChannel.ForAddress(address); _channel = GrpcChannel.ForAddress(address);
_printClient = new Cloudprint.PrintService.PrintServiceClient(_channel); _printClient = new Cloudprint.PrintService.PrintServiceClient(_channel);
_libraryClient = new Cloudprint.LibraryService.LibraryServiceClient(_channel); _libraryClient = new Cloudprint.LibraryService.LibraryServiceClient(_channel);
@@ -67,6 +70,12 @@ public sealed class GrpcService : IPrintBackendClient
IsConnected = true; IsConnected = true;
Log(AppLogLevel.Success, $"已连接 gRPC 服务:{address}"); Log(AppLogLevel.Success, $"已连接 gRPC 服务:{address}");
} }
catch
{
ResetConnectionState(clearCredentials: true);
throw;
}
}
public async Task<AdminSession> LoginAdminAsync(string username, string password, CancellationToken cancellationToken = default) public async Task<AdminSession> LoginAdminAsync(string username, string password, CancellationToken cancellationToken = default)
{ {
@@ -240,14 +249,7 @@ public sealed class GrpcService : IPrintBackendClient
public void Disconnect() public void Disconnect()
{ {
_subscriptionCts?.Cancel(); ResetConnectionState(clearCredentials: true);
IsConnected = false;
_channel?.Dispose();
_channel = null;
_printClient = null;
_libraryClient = null;
_fileClient = null;
_adminClient = null;
Log(AppLogLevel.Warning, "已断开 gRPC 连接"); Log(AppLogLevel.Warning, "已断开 gRPC 连接");
} }
@@ -283,7 +285,28 @@ public sealed class GrpcService : IPrintBackendClient
catch (Exception ex) catch (Exception ex)
{ {
Log(AppLogLevel.Error, $"打印任务订阅中断:{ex.Message}"); Log(AppLogLevel.Error, $"打印任务订阅中断:{ex.Message}");
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; IsConnected = false;
PrinterId = 0;
Token = string.Empty;
if (clearCredentials)
{
_apiKey = string.Empty;
} }
} }

View File

@@ -1,20 +1,29 @@
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using CloudPrint.Client.Infrastructure;
using CloudPrint.Client.Models; using CloudPrint.Client.Models;
using CloudPrint.Client.Services; using CloudPrint.Client.Services;
using CloudPrint.Client.Services.Abstractions; using CloudPrint.Client.Services.Abstractions;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Microsoft.Win32; using Microsoft.Win32;
namespace CloudPrint.Client.ViewModels; namespace CloudPrint.Client.ViewModels;
public sealed class LibraryViewModel : ObservableObject public sealed partial class LibraryViewModel : ObservableObject
{ {
private readonly IFileService _fileService; private readonly IFileService _fileService;
private readonly IPrintBackendClient _backendClient; private readonly IPrintBackendClient _backendClient;
private readonly ClientSettings _settings; private readonly ClientSettings _settings;
private LibraryCategory? _selectedCategory;
private CloudPrintDocument? _selectedDocument; [ObservableProperty]
private string _statusMessage = "文库就绪"; private LibraryCategory? selectedCategory;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(RemoveDocumentCommand))]
[NotifyCanExecuteChangedFor(nameof(SyncSelectedCommand))]
private CloudPrintDocument? selectedDocument;
[ObservableProperty]
private string statusMessage = "文库就绪";
public LibraryViewModel() public LibraryViewModel()
: this(new FileService(), new GrpcService(), new ClientSettings()) : 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 }); Categories.Add(new LibraryCategory { Id = 4, Name = "其他文件", Icon = "📁", SortOrder = 99 });
SelectedCategory = Categories.FirstOrDefault(); 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<LibraryCategory> Categories { get; } = new();
public ObservableCollection<CloudPrintDocument> Documents { 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() public void RefreshCommandState()
{ {
SyncSelectedCommand.RaiseCanExecuteChanged(); SyncSelectedCommand.NotifyCanExecuteChanged();
SyncAllCommand.RaiseCanExecuteChanged(); SyncAllCommand.NotifyCanExecuteChanged();
} }
[RelayCommand]
private void AddDocument() private void AddDocument()
{ {
var dialog = new Microsoft.Win32.OpenFileDialog var dialog = new Microsoft.Win32.OpenFileDialog
@@ -114,7 +90,10 @@ public sealed class LibraryViewModel : ObservableObject
RefreshCommandState(); RefreshCommandState();
} }
private void RemoveSelectedDocument() private bool CanRemoveDocument() => SelectedDocument is not null;
[RelayCommand(CanExecute = nameof(CanRemoveDocument))]
private void RemoveDocument()
{ {
if (SelectedDocument is null) if (SelectedDocument is null)
{ {
@@ -128,6 +107,9 @@ public sealed class LibraryViewModel : ObservableObject
RefreshCommandState(); RefreshCommandState();
} }
private bool CanSyncSelected() => SelectedDocument is not null && _backendClient.IsConnected;
[RelayCommand(CanExecute = nameof(CanSyncSelected))]
private async Task SyncSelectedAsync() private async Task SyncSelectedAsync()
{ {
if (SelectedDocument is null) if (SelectedDocument is null)
@@ -140,6 +122,9 @@ public sealed class LibraryViewModel : ObservableObject
RefreshCommandState(); RefreshCommandState();
} }
private bool CanSyncAll() => Documents.Any(document => !document.IsUploaded) && _backendClient.IsConnected;
[RelayCommand(CanExecute = nameof(CanSyncAll))]
private async Task SyncAllAsync() private async Task SyncAllAsync()
{ {
var pendingDocuments = Documents.Where(document => !document.IsUploaded).ToList(); var pendingDocuments = Documents.Where(document => !document.IsUploaded).ToList();

View File

@@ -3,11 +3,14 @@ using CloudPrint.Client.Infrastructure;
using CloudPrint.Client.Models; using CloudPrint.Client.Models;
using CloudPrint.Client.Services; using CloudPrint.Client.Services;
using CloudPrint.Client.Services.Abstractions; using CloudPrint.Client.Services.Abstractions;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using Grpc.Core; using Grpc.Core;
using ObservableObject = CommunityToolkit.Mvvm.ComponentModel.ObservableObject;
namespace CloudPrint.Client.ViewModels; namespace CloudPrint.Client.ViewModels;
public sealed class MainViewModel : ObservableObject public sealed partial class MainViewModel : ObservableObject
{ {
private const int MaxReconnectAttempts = 5; private const int MaxReconnectAttempts = 5;
private static readonly TimeSpan SubscriptionWatchInterval = TimeSpan.FromSeconds(5); private static readonly TimeSpan SubscriptionWatchInterval = TimeSpan.FromSeconds(5);
@@ -28,16 +31,42 @@ public sealed class MainViewModel : ObservableObject
private CancellationTokenSource? _queueCts; private CancellationTokenSource? _queueCts;
private CancellationTokenSource? _processingCts; private CancellationTokenSource? _processingCts;
private Task? _queueWorker; private Task? _queueWorker;
private PrintJob? _selectedJob;
private PrintJob? _processingJob; private PrintJob? _processingJob;
private PrinterInfo? _selectedPrinter; private PrinterInfo? _subscribedPrinter;
private ConnectionState _connectionState = ConnectionState.Disconnected;
private string _lastMessage = "已就绪";
private bool _isReconnecting; private bool _isReconnecting;
private bool _isManualDisconnect = true; private bool _isManualDisconnect = true;
private bool _autoProcessQueue = true;
private bool _isQueuePaused; [ObservableProperty]
private bool _isQueueProcessing; [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() public MainViewModel()
: this(new PrintService(), new FileService(), new GrpcService(), new WebSocketService(), new JsonSettingsService(), new WpfUiDispatcher(), new FileAppLogger()) : 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); _backendClient.LogReceived += (_, entry) => AddLog(entry);
_webSocketService.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(); RefreshPrinters();
} }
@@ -91,47 +109,6 @@ public sealed class MainViewModel : ObservableObject
public LibraryViewModel Library { get; } public LibraryViewModel Library { get; }
public SettingsViewModel SettingsPanel { 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 public string ConnectionStatus => ConnectionState switch
{ {
ConnectionState.Connecting => "连接中", ConnectionState.Connecting => "连接中",
@@ -145,49 +122,6 @@ public sealed class MainViewModel : ObservableObject
public bool CanSubscribe => SelectedPrinter is not null && _backendClient.IsConnected; public bool CanSubscribe => SelectedPrinter is not null && _backendClient.IsConnected;
public bool CanCancelSelectedJob => SelectedJob is { Status: PrintJobStatus.Pending or PrintJobStatus.Printing }; 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 public string QueueStatusText => AutoProcessQueue switch
{ {
false => "自动队列已关闭,可手动处理下一任务", false => "自动队列已关闭,可手动处理下一任务",
@@ -196,23 +130,31 @@ public sealed class MainViewModel : ObservableObject
_ => "自动队列已开启,等待新任务" _ => "自动队列已开启,等待新任务"
}; };
public string LastMessage partial void OnConnectionStateChanged(ConnectionState value)
{ {
get => _lastMessage; Library.RefreshCommandState();
private set => SetProperty(ref _lastMessage, value);
} }
public RelayCommand RefreshPrintersCommand { get; } partial void OnAutoProcessQueueChanged(bool value)
public AsyncRelayCommand ConnectCommand { get; } {
public AsyncRelayCommand SubscribeCommand { get; } RaiseQueueCommandStates();
public AsyncRelayCommand ProcessSelectedJobCommand { get; } if (value)
public AsyncRelayCommand CancelSelectedJobCommand { get; } {
public RelayCommand PauseQueueCommand { get; } StartQueueProcessingIfNeeded();
public RelayCommand ResumeQueueCommand { get; } }
public RelayCommand DisconnectCommand { get; } }
public RelayCommand SaveSettingsCommand { get; }
public RelayCommand ClearLogsCommand { get; }
partial void OnIsQueuePausedChanged(bool value)
{
RaiseQueueCommandStates();
}
partial void OnIsQueueProcessingChanged(bool value)
{
RaiseQueueCommandStates();
}
[RelayCommand]
private void RefreshPrinters() private void RefreshPrinters()
{ {
Printers.Clear(); Printers.Clear();
@@ -225,6 +167,9 @@ public sealed class MainViewModel : ObservableObject
AddLog(AppLogLevel.Info, $"发现 {Printers.Count} 台打印机"); AddLog(AppLogLevel.Info, $"发现 {Printers.Count} 台打印机");
} }
private bool CanConnect() => SelectedPrinter is not null;
[RelayCommand(CanExecute = nameof(CanConnect))]
private async Task ConnectAsync() private async Task ConnectAsync()
{ {
if (SelectedPrinter is null) if (SelectedPrinter is null)
@@ -246,16 +191,20 @@ public sealed class MainViewModel : ObservableObject
StartHeartbeatLoop(); StartHeartbeatLoop();
ConnectionState = ConnectionState.Connected; ConnectionState = ConnectionState.Connected;
DisconnectCommand.RaiseCanExecuteChanged(); DisconnectCommand.NotifyCanExecuteChanged();
Library.RefreshCommandState(); Library.RefreshCommandState();
} }
catch (Exception ex) catch (Exception ex)
{ {
ResetConnectionsAfterFailure();
ConnectionState = ConnectionState.Error; ConnectionState = ConnectionState.Error;
AddLog(AppLogEntry.FromException($"连接失败:{ex.Message}", ex)); AddLog(AppLogEntry.FromException($"连接失败:{ex.Message}", ex));
} }
} }
private bool CanSubscribeCommand() => CanSubscribe;
[RelayCommand(CanExecute = nameof(CanSubscribeCommand))]
private async Task SubscribeAsync() private async Task SubscribeAsync()
{ {
if (SelectedPrinter is null) if (SelectedPrinter is null)
@@ -268,19 +217,24 @@ public sealed class MainViewModel : ObservableObject
{ {
ConnectionState = ConnectionState.Subscribing; ConnectionState = ConnectionState.Subscribing;
await _backendClient.SubscribePrintJobsAsync(SelectedPrinter).ConfigureAwait(true); await _backendClient.SubscribePrintJobsAsync(SelectedPrinter).ConfigureAwait(true);
_subscribedPrinter = SelectedPrinter;
ConnectionState = ConnectionState.Subscribed; ConnectionState = ConnectionState.Subscribed;
StartSubscriptionWatchLoop(); StartSubscriptionWatchLoop();
ProcessSelectedJobCommand.RaiseCanExecuteChanged(); ProcessSelectedJobCommand.NotifyCanExecuteChanged();
Library.RefreshCommandState(); Library.RefreshCommandState();
StartQueueProcessingIfNeeded(); StartQueueProcessingIfNeeded();
} }
catch (Exception ex) catch (Exception ex)
{ {
ResetConnectionsAfterFailure();
ConnectionState = ConnectionState.Error; ConnectionState = ConnectionState.Error;
AddLog(AppLogEntry.FromException($"订阅失败:{ex.Message}", ex)); 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() private async Task ProcessSelectedJobAsync()
{ {
var job = GetNextPendingJob(preferSelected: true); 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() private async Task CancelSelectedJobAsync()
{ {
if (SelectedJob is null) if (SelectedJob is null)
@@ -335,8 +292,8 @@ public sealed class MainViewModel : ObservableObject
} }
finally finally
{ {
ProcessSelectedJobCommand.RaiseCanExecuteChanged(); ProcessSelectedJobCommand.NotifyCanExecuteChanged();
CancelSelectedJobCommand.RaiseCanExecuteChanged(); CancelSelectedJobCommand.NotifyCanExecuteChanged();
} }
} }
@@ -351,8 +308,9 @@ public sealed class MainViewModel : ObservableObject
return; return;
} }
EnsureJobPrinter(e.Job);
Jobs.Insert(0, e.Job); Jobs.Insert(0, e.Job);
ProcessSelectedJobCommand.RaiseCanExecuteChanged(); ProcessSelectedJobCommand.NotifyCanExecuteChanged();
}); });
if (isDuplicate) if (isDuplicate)
@@ -364,6 +322,7 @@ public sealed class MainViewModel : ObservableObject
StartQueueProcessingIfNeeded(); StartQueueProcessingIfNeeded();
} }
[RelayCommand]
private void SaveSettings() private void SaveSettings()
{ {
ConfigureBackendMode(); 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() private void Disconnect()
{ {
_isManualDisconnect = true; _isManualDisconnect = true;
@@ -386,19 +361,27 @@ public sealed class MainViewModel : ObservableObject
_subscriptionWatchCts?.Cancel(); _subscriptionWatchCts?.Cancel();
_queueCts?.Cancel(); _queueCts?.Cancel();
_processingCts?.Cancel(); _processingCts?.Cancel();
_subscribedPrinter = null;
_backendClient.Disconnect(); _backendClient.Disconnect();
_ = _webSocketService.DisconnectAsync(); _ = _webSocketService.DisconnectAsync();
ConnectionState = ConnectionState.Disconnected; ConnectionState = ConnectionState.Disconnected;
AddLog(AppLogLevel.Warning, "客户端已断开连接"); AddLog(AppLogLevel.Warning, "客户端已断开连接");
NotifyConnectionCommandStates();
Library.RefreshCommandState(); Library.RefreshCommandState();
} }
private bool CanPauseQueue() => AutoProcessQueue && !IsQueuePaused;
[RelayCommand(CanExecute = nameof(CanPauseQueue))]
private void PauseQueue() private void PauseQueue()
{ {
IsQueuePaused = true; IsQueuePaused = true;
AddLog(AppLogLevel.Warning, "自动队列已暂停,当前任务完成后停止处理后续任务"); AddLog(AppLogLevel.Warning, "自动队列已暂停,当前任务完成后停止处理后续任务");
} }
private bool CanResumeQueue() => AutoProcessQueue && IsQueuePaused;
[RelayCommand(CanExecute = nameof(CanResumeQueue))]
private void ResumeQueue() private void ResumeQueue()
{ {
IsQueuePaused = false; IsQueuePaused = false;
@@ -521,10 +504,19 @@ public sealed class MainViewModel : ObservableObject
{ {
_uiDispatcher.Invoke(() => _uiDispatcher.Invoke(() =>
{ {
ProcessSelectedJobCommand.RaiseCanExecuteChanged(); ProcessSelectedJobCommand.NotifyCanExecuteChanged();
CancelSelectedJobCommand.RaiseCanExecuteChanged(); CancelSelectedJobCommand.NotifyCanExecuteChanged();
PauseQueueCommand.RaiseCanExecuteChanged(); PauseQueueCommand.NotifyCanExecuteChanged();
ResumeQueueCommand.RaiseCanExecuteChanged(); 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.RegisterPrinterAsync(SelectedPrinter, Settings.ApiKey, cancellationToken).ConfigureAwait(false);
await _backendClient.SendHeartbeatAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false); await _backendClient.SendHeartbeatAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false);
await _backendClient.SubscribePrintJobsAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false); await _backendClient.SubscribePrintJobsAsync(SelectedPrinter, cancellationToken).ConfigureAwait(false);
_subscribedPrinter = SelectedPrinter;
await _webSocketService.ConnectAsync(new Uri(ToWebSocketAddress(Settings.ServerAddress)), cancellationToken).ConfigureAwait(false); await _webSocketService.ConnectAsync(new Uri(ToWebSocketAddress(Settings.ServerAddress)), cancellationToken).ConfigureAwait(false);
_uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Subscribed); _uiDispatcher.Invoke(() => ConnectionState = ConnectionState.Subscribed);
AddLog(AppLogLevel.Success, "自动重连成功,任务订阅已恢复"); AddLog(AppLogLevel.Success, "自动重连成功,任务订阅已恢复");
@@ -672,6 +665,7 @@ public sealed class MainViewModel : ObservableObject
} }
} }
[RelayCommand]
private void ClearLogs() private void ClearLogs()
{ {
Logs.Clear(); Logs.Clear();
@@ -724,6 +718,23 @@ public sealed class MainViewModel : ObservableObject
return !Jobs.Any(existing => GetJobKey(existing).Equals(key, StringComparison.OrdinalIgnoreCase)); 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) private static string GetJobKey(PrintJob job)
{ {
if (!string.IsNullOrWhiteSpace(job.JobNo)) if (!string.IsNullOrWhiteSpace(job.JobNo))

View File

@@ -1,18 +1,28 @@
using CloudPrint.Client.Models; using CloudPrint.Client.Models;
using CloudPrint.Client.Infrastructure;
using CloudPrint.Client.Services; using CloudPrint.Client.Services;
using CloudPrint.Client.Services.Abstractions; using CloudPrint.Client.Services.Abstractions;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using System.IO; using System.IO;
namespace CloudPrint.Client.ViewModels; namespace CloudPrint.Client.ViewModels;
public sealed class SettingsViewModel : ObservableObject public sealed partial class SettingsViewModel : ObservableObject
{ {
private readonly ISettingsService _settingsService; private readonly ISettingsService _settingsService;
private readonly IPrintBackendClient _backendClient; private readonly IPrintBackendClient _backendClient;
private readonly IAppLogger _appLogger; 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() public SettingsViewModel()
: this(new JsonSettingsService().Load(), new JsonSettingsService(), new GrpcService(), new FileAppLogger()) : this(new JsonSettingsService().Load(), new JsonSettingsService(), new GrpcService(), new FileAppLogger())
@@ -25,11 +35,6 @@ public sealed class SettingsViewModel : ObservableObject
_settingsService = settingsService; _settingsService = settingsService;
_backendClient = backendClient; _backendClient = backendClient;
_appLogger = appLogger; _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)) if (!string.IsNullOrWhiteSpace(Settings.AdminAccessToken))
{ {
@@ -40,47 +45,18 @@ public sealed class SettingsViewModel : ObservableObject
public ClientSettings Settings { get; } 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; 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 AdminLoginStatus => IsAdminLoggedIn ? $"已登录:{Settings.AdminUsername}" : "未登录";
public string AdminPassword [RelayCommand]
{
get => _adminPassword;
set => SetProperty(ref _adminPassword, value);
}
private void Save() private void Save()
{ {
_settingsService.Save(Settings); _settingsService.Save(Settings);
StatusMessage = "设置已保存"; StatusMessage = "设置已保存";
} }
[RelayCommand]
private async Task LoginAdminAsync() private async Task LoginAdminAsync()
{ {
try try
@@ -106,6 +82,9 @@ public sealed class SettingsViewModel : ObservableObject
} }
} }
private bool CanLogoutAdmin() => IsAdminLoggedIn;
[RelayCommand(CanExecute = nameof(CanLogoutAdmin))]
private void LogoutAdmin() private void LogoutAdmin()
{ {
_backendClient.LogoutAdmin(); _backendClient.LogoutAdmin();
@@ -124,6 +103,7 @@ public sealed class SettingsViewModel : ObservableObject
} }
} }
[RelayCommand]
private void SelectLibraryRoot() private void SelectLibraryRoot()
{ {
using var dialog = new System.Windows.Forms.FolderBrowserDialog using var dialog = new System.Windows.Forms.FolderBrowserDialog
@@ -142,6 +122,7 @@ public sealed class SettingsViewModel : ObservableObject
} }
} }
[RelayCommand]
private void OpenLogDirectory() private void OpenLogDirectory()
{ {
Directory.CreateDirectory(_appLogger.LogDirectory); Directory.CreateDirectory(_appLogger.LogDirectory);