From 8f74e07c665bf12ffe2f63e5506e4bc0e80802f3 Mon Sep 17 00:00:00 2001 From: "yunxiao.zhu" Date: Fri, 17 Apr 2026 14:12:10 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat(*):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E5=91=98=E8=A7=A3=E9=94=81=E4=B8=8E=20SFTP?= =?UTF-8?q?=20=E6=8E=A2=E6=B4=BB=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 新增管理员解锁弹窗、手动操作权限控制与倒计时状态 * 支持系统设置页测试 SFTP 连接,并在启动时执行探活 * 补充设计时服务、全局异常兜底与相关单元测试 --- .claude/settings.local.json | 11 +- AxiOmron.PcbCheck.slnx | 10 +- src/AxiOmron.PcbCheck/App.xaml.cs | 92 ++++++++ .../DesignTimeAdminUnlockDialogService.cs | 20 ++ .../DesignTime/DesignTimeAppConfigService.cs | 14 +- .../DesignTime/DesignTimeSftpLookupService.cs | 44 ++++ .../DesignTime/DesignTimeViewModelLocator.cs | 7 +- .../Models/WorkflowModels.cs | 36 ++++ src/AxiOmron.PcbCheck/Options/AppConfig.cs | 41 +++- .../AdminUnlockDialogService.cs | 43 ++++ .../Services/Implementations/AndonService.cs | 36 ++-- .../Implementations/SftpLookupService.cs | 139 +++++++++++- .../Implementations/WorkflowHostedService.cs | 145 +++++++++++-- .../Services/Interfaces/CoreInterfaces.cs | 22 ++ .../ViewModels/MainWindowViewModel.cs | 203 +++++++++++++++++- .../ViewModels/SystemSettingViewModel.cs | 41 +++- .../Views/Pages/DashboardPage.xaml | 81 +++++-- .../Views/Pages/SystemSettingsPage.xaml | 13 +- .../Views/Windows/AdminUnlockDialog.xaml | 146 +++++++++++++ .../Views/Windows/AdminUnlockDialog.xaml.cs | 94 ++++++++ .../appConfig.Development.json | 12 +- src/AxiOmron.PcbCheck/appConfig.json | 20 +- .../AxiOmron.PcbCheck.Tests.csproj | 26 +++ .../SystemSettingViewModelTests.cs | 95 ++++++++ .../WorkflowHostedServiceTests.cs | 86 ++++++++ 25 files changed, 1382 insertions(+), 95 deletions(-) create mode 100644 src/AxiOmron.PcbCheck/DesignTime/DesignTimeAdminUnlockDialogService.cs create mode 100644 src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs create mode 100644 src/AxiOmron.PcbCheck/Services/Implementations/AdminUnlockDialogService.cs create mode 100644 src/AxiOmron.PcbCheck/Views/Windows/AdminUnlockDialog.xaml create mode 100644 src/AxiOmron.PcbCheck/Views/Windows/AdminUnlockDialog.xaml.cs create mode 100644 tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj create mode 100644 tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs create mode 100644 tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 433e36a..4508f63 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -14,7 +14,16 @@ "Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); \"BASE: $\\($type.BaseType.FullName\\)\"; $type.GetMethods\\([System.Reflection.BindingFlags]::Instance -bor [System.Reflection.BindingFlags]::Public\\) | Where-Object { $_.Name -match \"Connect|Close|Dispose|Disconnect|Open|Start|Stop|Release\" } | ForEach-Object { $_.DeclaringType.FullName + \" :: \" + $_.ToString\\(\\) }')", "Bash(powershell -NoProfile -Command '$asm=[Reflection.Assembly]::LoadFrom\\(\"C:/Users/yunxiao.zhu/.nuget/packages/iotclient/1.0.42/lib/netstandard2.0/IoTClient.dll\"\\); $type=$asm.GetType\\(\"IoTClient.Clients.Modbus.ModbusTcpClient\"\\); $type.GetConstructors\\(\\) | ForEach-Object { $_.ToString\\(\\) }')", "Read(//d D:/Dev/Codes/MFD_Solution/Axi_Omron/src/**)", - "Bash(dotnet restore:*)" + "Bash(dotnet restore:*)", + "Bash(systemctl status *)", + "Bash(service ssh *)", + "Bash(sshd -V)", + "Bash(apt list *)", + "Bash(dpkg -l)", + "Bash(sudo apt-get *)", + "Bash(sudo -n true)", + "Bash(mkdir -p /tmp/pcb)", + "Read(//tmp/**)" ] } } diff --git a/AxiOmron.PcbCheck.slnx b/AxiOmron.PcbCheck.slnx index a32a5fc..0b68aa9 100644 --- a/AxiOmron.PcbCheck.slnx +++ b/AxiOmron.PcbCheck.slnx @@ -1,2 +1,8 @@ - - + + + + + + + + diff --git a/src/AxiOmron.PcbCheck/App.xaml.cs b/src/AxiOmron.PcbCheck/App.xaml.cs index 39df095..0359889 100644 --- a/src/AxiOmron.PcbCheck/App.xaml.cs +++ b/src/AxiOmron.PcbCheck/App.xaml.cs @@ -1,11 +1,13 @@ using System.IO; using System.Text; using System.Windows; +using System.Windows.Threading; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Implementations; using AxiOmron.PcbCheck.Services.Interfaces; using AxiOmron.PcbCheck.ViewModels; using AxiOmron.PcbCheck.Views.Pages; +using AxiOmron.PcbCheck.Views.Windows; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; @@ -19,6 +21,7 @@ namespace AxiOmron.PcbCheck; /// public partial class App : Application { + private static readonly NLog.Logger FallbackLogger = NLog.LogManager.GetCurrentClassLogger(); private IHost? _host; /// @@ -33,6 +36,7 @@ public partial class App : Application protected override async void OnStartup(StartupEventArgs e) { base.OnStartup(e); + RegisterGlobalExceptionHandlers(); try { Console.OutputEncoding = Encoding.UTF8; @@ -53,6 +57,7 @@ public partial class App : Application } catch (Exception ex) { + LogGlobalException("应用启动失败", ex); MessageBox.Show($"应用启动失败: {ex.Message}", "Axi Omron PCB Check", MessageBoxButton.OK, MessageBoxImage.Error); Shutdown(-1); } @@ -64,6 +69,7 @@ public partial class App : Application /// 退出事件参数。 protected override async void OnExit(ExitEventArgs e) { + UnregisterGlobalExceptionHandlers(); if (_host is not null) { try @@ -118,13 +124,99 @@ public partial class App : Application services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); services.AddHostedService(sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddTransient(); services.AddSingleton(); }) .Build(); } + + /// + /// 注册全局异常处理器,用于兜底记录 UI 线程、未观察任务和域级异常。 + /// + private void RegisterGlobalExceptionHandlers() + { + DispatcherUnhandledException += OnDispatcherUnhandledException; + AppDomain.CurrentDomain.UnhandledException += OnCurrentDomainUnhandledException; + TaskScheduler.UnobservedTaskException += OnTaskSchedulerUnobservedTaskException; + } + + /// + /// 注销全局异常处理器。 + /// + private void UnregisterGlobalExceptionHandlers() + { + DispatcherUnhandledException -= OnDispatcherUnhandledException; + AppDomain.CurrentDomain.UnhandledException -= OnCurrentDomainUnhandledException; + TaskScheduler.UnobservedTaskException -= OnTaskSchedulerUnobservedTaskException; + } + + /// + /// 处理 UI 线程未捕获异常,记录日志并阻止应用直接闪退。 + /// + /// 事件源。 + /// 异常事件参数。 + private void OnDispatcherUnhandledException(object sender, DispatcherUnhandledExceptionEventArgs e) + { + LogGlobalException("UI 线程未处理异常", e.Exception); + MessageBox.Show($"程序捕获到未处理异常,已写入日志:{e.Exception.Message}", "Axi Omron PCB Check", MessageBoxButton.OK, MessageBoxImage.Warning); + e.Handled = true; + } + + /// + /// 处理未观察任务异常,记录日志并标记为已观察。 + /// + /// 事件源。 + /// 异常事件参数。 + private void OnTaskSchedulerUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e) + { + LogGlobalException("未观察任务异常", e.Exception); + e.SetObserved(); + } + + /// + /// 处理应用程序域未捕获异常,尽量在进程退出前补充日志。 + /// + /// 事件源。 + /// 异常事件参数。 + private void OnCurrentDomainUnhandledException(object? sender, UnhandledExceptionEventArgs e) + { + var exception = e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString() ?? "未知未处理异常"); + LogGlobalException($"应用程序域未处理异常(IsTerminating={e.IsTerminating})", exception); + } + + /// + /// 统一记录全局异常日志,优先复用宿主日志器,失败时退回 NLog 直接写入。 + /// + /// 日志消息。 + /// 异常对象。 + private static void LogGlobalException(string message, Exception exception) + { + try + { + if (Services is not null) + { + var logger = Services.GetService>(); + logger?.LogError(exception, message); + } + } + catch + { + // 忽略宿主日志失败,继续使用回退日志。 + } + + try + { + FallbackLogger.Error(exception, message); + } + catch + { + // 全局兜底日志不再向外抛出。 + } + } } diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAdminUnlockDialogService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAdminUnlockDialogService.cs new file mode 100644 index 0000000..9368933 --- /dev/null +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAdminUnlockDialogService.cs @@ -0,0 +1,20 @@ +using AxiOmron.PcbCheck.Services.Interfaces; + +namespace AxiOmron.PcbCheck.DesignTime; + +/// +/// 提供设计时管理员解锁弹窗服务占位实现。 +/// +public sealed class DesignTimeAdminUnlockDialogService : IAdminUnlockDialogService +{ + /// + /// 显示管理员密码输入弹窗并校验是否通过。 + /// + /// 期望密码。 + /// 本次解锁会话持续时间。 + /// 设计时固定返回 + public bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout) + { + return false; + } +} diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs index 02a7851..4751402 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeAppConfigService.cs @@ -40,6 +40,7 @@ public sealed class DesignTimeAppConfigService : IAppConfigService _config.Sftp = config.Sftp; _config.Andon = config.Andon; _config.Workflow = config.Workflow; + _config.Security = config.Security; } /// @@ -98,11 +99,13 @@ public sealed class DesignTimeAppConfigService : IAppConfigService Andon = new AndonOptions { Enable = true, - Url = "http://10.10.20.50/api/andon/alarm", + Url = "https://shp.prod.aliyun.mit.uaes.com/api/mit/MAE/Station/Status/alarm", Method = "POST", TimeoutMs = 3000, - StationCode = "OMRON-L01", - StationName = "欧姆龙 PCB 检测", + AndonTheme = "SS7_Auto_Andon", + Eid = "PR1965269806334783488", + FromSign = "IOT", + ServiceId = "4dccd822-86b2-47dd-89bd-ca472ba0d14d", EnableScanFailAlarm = true, EnableFileNotFoundAlarm = true }, @@ -114,6 +117,11 @@ public sealed class DesignTimeAppConfigService : IAppConfigService RequireManualResetAfterFault = true, MaxUiLogEntries = 200, MaxBoardRecords = 100 + }, + Security = new SecurityOptions + { + AdminPassword = "AxiOmron@123", + AdminSessionTimeoutMinutes = 15 } }; } diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs new file mode 100644 index 0000000..0b7fcb0 --- /dev/null +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeSftpLookupService.cs @@ -0,0 +1,44 @@ +using AxiOmron.PcbCheck.Models; +using AxiOmron.PcbCheck.Options; +using AxiOmron.PcbCheck.Services.Interfaces; + +namespace AxiOmron.PcbCheck.DesignTime; + +/// +/// 为设计器提供 SFTP 测试结果的设计时服务。 +/// +public sealed class DesignTimeSftpLookupService : ISftpLookupService +{ + /// + /// 返回设计时文件校验结果。 + /// + /// 条码。 + /// 取消令牌。 + /// 固定设计时结果。 + public Task CheckFileAsync(string barcode, CancellationToken cancellationToken) + { + return Task.FromResult(new SftpCheckOutcome + { + Exists = true, + ConnectionSucceeded = true, + MatchedFilePath = $"/pcb/{barcode}.txt" + }); + } + + /// + /// 返回设计时连接测试结果。 + /// + /// SFTP 配置。 + /// 取消令牌。 + /// 固定成功结果。 + public Task TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken) + { + return Task.FromResult(new SftpConnectionTestOutcome + { + IsSuccess = true, + ConnectionSucceeded = true, + RootPathAccessible = true, + StatusMessage = "设计时 SFTP 连接成功。" + }); + } +} diff --git a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs index 4d90a26..ed92cc9 100644 --- a/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs +++ b/src/AxiOmron.PcbCheck/DesignTime/DesignTimeViewModelLocator.cs @@ -1,4 +1,5 @@ using AxiOmron.PcbCheck.Options; +using AxiOmron.PcbCheck.Services.Interfaces; using AxiOmron.PcbCheck.ViewModels; namespace AxiOmron.PcbCheck.DesignTime; @@ -12,6 +13,8 @@ public sealed class DesignTimeViewModelLocator private readonly DesignTimeDispatcherService _dispatcherService = new(); private readonly DesignTimeWorkflowControlService _workflowControlService = new(); private readonly DesignTimeAppConfigService _appConfigService = new(); + private readonly DesignTimeAdminUnlockDialogService _adminUnlockDialogService = new(); + private readonly DesignTimeSftpLookupService _sftpLookupService = new(); private MainWindowViewModel? _mainWindowViewModel; private SystemSettingViewModel? _systemSettingViewModel; @@ -25,7 +28,7 @@ public sealed class DesignTimeViewModelLocator /// 获取系统设置设计时视图模型。 /// public SystemSettingViewModel SystemSettingViewModel - => _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService); + => _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService); /// /// 创建首页设计时视图模型。 @@ -34,6 +37,6 @@ public sealed class DesignTimeViewModelLocator private MainWindowViewModel CreateMainWindowViewModel() { AppConfig config = _appConfigService.Load(); - return new MainWindowViewModel(_appStateStore, _dispatcherService, _workflowControlService, config); + return new MainWindowViewModel(_appStateStore, _dispatcherService, _workflowControlService, config, _adminUnlockDialogService); } } diff --git a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs index 4ab4dbe..e1c6c59 100644 --- a/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs +++ b/src/AxiOmron.PcbCheck/Models/WorkflowModels.cs @@ -383,6 +383,42 @@ public sealed class SftpCheckOutcome public string ErrorMessage { get; set; } = string.Empty; } +/// +/// 表示一次 SFTP 连接测试结果。 +/// +public sealed class SftpConnectionTestOutcome +{ + /// + /// 获取或设置连接测试是否成功。 + /// + public bool IsSuccess { get; set; } + + /// + /// 获取或设置是否为系统级异常。 + /// + public bool IsSystemError { get; set; } + + /// + /// 获取或设置是否为配置级异常。 + /// + public bool IsConfigurationError { get; set; } + + /// + /// 获取或设置本次连接是否成功建立。 + /// + public bool ConnectionSucceeded { get; set; } + + /// + /// 获取或设置根目录是否可访问。 + /// + public bool RootPathAccessible { get; set; } + + /// + /// 获取或设置状态描述文本。 + /// + public string StatusMessage { get; set; } = string.Empty; +} + /// /// 表示一次安灯请求。 /// diff --git a/src/AxiOmron.PcbCheck/Options/AppConfig.cs b/src/AxiOmron.PcbCheck/Options/AppConfig.cs index 8720be7..56e3106 100644 --- a/src/AxiOmron.PcbCheck/Options/AppConfig.cs +++ b/src/AxiOmron.PcbCheck/Options/AppConfig.cs @@ -29,6 +29,11 @@ public sealed class AppConfig /// 获取或设置流程控制配置。 /// public WorkflowOptions Workflow { get; set; } = new(); + + /// + /// 获取或设置安全控制配置。 + /// + public SecurityOptions Security { get; set; } = new(); } /// @@ -217,7 +222,7 @@ public sealed class AndonOptions /// /// 获取或设置安灯接口地址。 /// - public string Url { get; set; } = "http://127.0.0.1:5000/api/andon"; + public string Url { get; set; } = "https://shp.prod.aliyun.mit.uaes.com/api/mit/MAE/Station/Status/alarm"; /// /// 获取或设置请求方法。 @@ -230,14 +235,24 @@ public sealed class AndonOptions public int TimeoutMs { get; set; } = 3000; /// - /// 获取或设置工位编码。 + /// 获取或设置安灯主题。 /// - public string StationCode { get; set; } = "OMRON-01"; + public string AndonTheme { get; set; } = "SS7_Auto_Andon"; /// - /// 获取或设置工位名称。 + /// 获取或设置设备唯一标识。 /// - public string StationName { get; set; } = "PCB 目检工位"; + public string Eid { get; set; } = "PR1965269806334783488"; + + /// + /// 获取或设置调用来源标记。 + /// + public string FromSign { get; set; } = "IOT"; + + /// + /// 获取或设置服务标识。 + /// + public string ServiceId { get; set; } = "4dccd822-86b2-47dd-89bd-ca472ba0d14d"; /// /// 获取或设置扫码失败报警是否启用。 @@ -291,6 +306,22 @@ public sealed class WorkflowOptions public int MaxBoardRecords { get; set; } = 100; } +/// +/// 表示简易管理员控制配置。 +/// +public sealed class SecurityOptions +{ + /// + /// 获取或设置管理员解锁密码。 + /// + public string AdminPassword { get; set; } = "AxiOmron@123"; + + /// + /// 获取或设置管理员解锁会话超时时间,单位为分钟。 + /// + public int AdminSessionTimeoutMinutes { get; set; } = 15; +} + /// /// 表示 PLC 输入点位地址配置。 /// diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/AdminUnlockDialogService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/AdminUnlockDialogService.cs new file mode 100644 index 0000000..3235601 --- /dev/null +++ b/src/AxiOmron.PcbCheck/Services/Implementations/AdminUnlockDialogService.cs @@ -0,0 +1,43 @@ +using AxiOmron.PcbCheck.Services.Interfaces; +using AxiOmron.PcbCheck.Views.Windows; +using Microsoft.Extensions.DependencyInjection; + +namespace AxiOmron.PcbCheck.Services.Implementations; + +/// +/// 提供管理员密码输入弹窗显示能力。 +/// +public sealed class AdminUnlockDialogService : IAdminUnlockDialogService +{ + private readonly IServiceProvider _serviceProvider; + + /// + /// 初始化管理员密码弹窗服务。 + /// + /// 服务提供器。 + public AdminUnlockDialogService(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + } + + /// + /// 显示管理员密码输入弹窗并校验是否通过。 + /// + /// 期望密码。 + /// 本次解锁会话持续时间。 + /// 输入正确返回 ;取消或错误返回 + public bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout) + { + ArgumentException.ThrowIfNullOrWhiteSpace(expectedPassword); + + var dialog = _serviceProvider.GetRequiredService(); + + if (System.Windows.Application.Current?.MainWindow is { } mainWindow) + { + dialog.Owner = mainWindow; + } + + dialog.Configure(expectedPassword, sessionTimeout); + return dialog.ShowDialog() == true; + } +} diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs index 42f2127..3457028 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/AndonService.cs @@ -1,5 +1,5 @@ using System.Net.Http; -using System.Net.Http.Json; +using System.Web; using AxiOmron.PcbCheck.Models; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Interfaces; @@ -52,20 +52,8 @@ public sealed class AndonService : IAndonService { using var client = _httpClientFactory.CreateClient(nameof(AndonService)); client.Timeout = TimeSpan.FromMilliseconds(_options.TimeoutMs); - using var message = new HttpRequestMessage(new HttpMethod(_options.Method), _options.Url) - { - Content = JsonContent.Create(new - { - stationCode = _options.StationCode, - stationName = _options.StationName, - alarmType = request.AlarmType, - alarmCode = request.AlarmCode, - alarmMessage = request.AlarmMessage, - barcode = request.Barcode, - triggeredAt = request.TriggeredAt, - machineName = Environment.MachineName - }) - }; + var requestUri = BuildRequestUri(request); + using var message = new HttpRequestMessage(new HttpMethod(_options.Method), requestUri); foreach (var header in _options.Headers) { @@ -99,6 +87,24 @@ public sealed class AndonService : IAndonService } } + /// + /// 根据运行时报警内容构建安灯请求地址。 + /// + /// 报警请求对象。 + /// 包含查询参数的请求地址。 + private Uri BuildRequestUri(AndonAlarmRequest request) + { + var builder = new UriBuilder(_options.Url); + var query = HttpUtility.ParseQueryString(builder.Query); + query["andonTheme"] = _options.AndonTheme; + query["eid"] = _options.Eid; + query["faultCode"] = request.AlarmMessage; + query["fromSign"] = _options.FromSign; + query["serviceId"] = _options.ServiceId; + builder.Query = query.ToString() ?? string.Empty; + return builder.Uri; + } + /// /// 发送一次测试报警请求。 /// diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs index 03239a4..5c89a8e 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/SftpLookupService.cs @@ -58,6 +58,27 @@ public sealed class SftpLookupService : ISftpLookupService return await Task.Run(() => CheckInternal(barcode, cancellationToken), cancellationToken).ConfigureAwait(false); } + /// + /// 测试当前或指定配置下的 SFTP 连接可用性。 + /// + /// 待测试的 SFTP 配置;为 时使用当前应用配置。 + /// 取消令牌。若在连接过程中取消,将终止测试。 + /// 连接测试结果。 + public async Task TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken) + { + SftpOptions effectiveOptions = options ?? _options; + if (string.IsNullOrWhiteSpace(effectiveOptions.Host) || string.IsNullOrWhiteSpace(effectiveOptions.RootPath)) + { + return new SftpConnectionTestOutcome + { + IsConfigurationError = true, + StatusMessage = "SFTP 配置缺失 Host 或 RootPath。" + }; + } + + return await Task.Run(() => TestConnectionInternal(effectiveOptions, cancellationToken), cancellationToken).ConfigureAwait(false); + } + /// /// 在同步上下文中执行 SFTP 查询。 /// @@ -70,9 +91,8 @@ public sealed class SftpLookupService : ISftpLookupService try { - using var client = CreateClient(); - client.ConnectionInfo.Timeout = TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs); - client.Connect(); + using var client = CreateClient(_options); + ConnectClient(client, _options); if (!client.IsConnected) { @@ -163,18 +183,39 @@ public sealed class SftpLookupService : ISftpLookupService /// SFTP 客户端实例。 private SftpClient CreateClient() { - if (!string.IsNullOrWhiteSpace(_options.PrivateKeyPath)) - { - var privateKeyFile = string.IsNullOrWhiteSpace(_options.PrivateKeyPassphrase) - ? new PrivateKeyFile(_options.PrivateKeyPath) - : new PrivateKeyFile(_options.PrivateKeyPath, _options.PrivateKeyPassphrase); + return CreateClient(_options); + } - var keyAuth = new PrivateKeyAuthenticationMethod(_options.Username, privateKeyFile); - var connectionInfo = new ConnectionInfo(_options.Host, _options.Port, _options.Username, keyAuth); + /// + /// 根据指定配置创建 SFTP 客户端。 + /// + /// SFTP 配置。 + /// SFTP 客户端实例。 + private static SftpClient CreateClient(SftpOptions options) + { + if (!string.IsNullOrWhiteSpace(options.PrivateKeyPath)) + { + var privateKeyFile = string.IsNullOrWhiteSpace(options.PrivateKeyPassphrase) + ? new PrivateKeyFile(options.PrivateKeyPath) + : new PrivateKeyFile(options.PrivateKeyPath, options.PrivateKeyPassphrase); + + var keyAuth = new PrivateKeyAuthenticationMethod(options.Username, privateKeyFile); + var connectionInfo = new ConnectionInfo(options.Host, options.Port, options.Username, keyAuth); return new SftpClient(connectionInfo); } - return new SftpClient(_options.Host, _options.Port, _options.Username, _options.Password); + return new SftpClient(options.Host, options.Port, options.Username, options.Password); + } + + /// + /// 统一设置超时并建立 SFTP 连接。 + /// + /// SFTP 客户端。 + /// SFTP 配置。 + private static void ConnectClient(SftpClient client, SftpOptions options) + { + client.ConnectionInfo.Timeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs); + client.Connect(); } /// @@ -197,4 +238,80 @@ public sealed class SftpLookupService : ISftpLookupService { return path.Replace('\\', '/').TrimEnd('/'); } + + /// + /// 在同步上下文中执行 SFTP 连通性测试。 + /// + /// 待测试的配置。 + /// 取消令牌。 + /// 连接测试结果。 + private SftpConnectionTestOutcome TestConnectionInternal(SftpOptions options, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + using var client = CreateClient(options); + ConnectClient(client, options); + + if (!client.IsConnected) + { + return new SftpConnectionTestOutcome + { + IsSystemError = true, + StatusMessage = "SFTP 未能建立连接。" + }; + } + + var rootPath = NormalizeDirectory(options.RootPath); + if (!client.Exists(rootPath)) + { + return new SftpConnectionTestOutcome + { + IsConfigurationError = true, + ConnectionSucceeded = true, + StatusMessage = $"SFTP 根目录不存在: {rootPath}" + }; + } + + return new SftpConnectionTestOutcome + { + IsSuccess = true, + ConnectionSucceeded = true, + RootPathAccessible = true, + StatusMessage = "SFTP 连接成功,根目录可访问。" + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (SshAuthenticationException ex) + { + _logger.LogError(ex, "SFTP 认证失败"); + return new SftpConnectionTestOutcome + { + IsSystemError = true, + StatusMessage = $"SFTP 认证失败: {ex.Message}" + }; + } + catch (SshConnectionException ex) + { + _logger.LogError(ex, "SFTP 连接失败"); + return new SftpConnectionTestOutcome + { + IsSystemError = true, + StatusMessage = $"SFTP 连接失败: {ex.Message}" + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "SFTP 连接测试异常"); + return new SftpConnectionTestOutcome + { + IsSystemError = true, + StatusMessage = ex.Message + }; + } + } } diff --git a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs index 55c09ef..5aca7ef 100644 --- a/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs +++ b/src/AxiOmron.PcbCheck/Services/Implementations/WorkflowHostedService.cs @@ -73,6 +73,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true); + await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false); PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位"); while (!stoppingToken.IsCancellationRequested) @@ -113,6 +114,50 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS await CancelActiveWorkflowAsync(CancellationToken.None).ConfigureAwait(false); } + /// + /// 在应用启动后执行一次 SFTP 探活,仅更新状态与日志,不阻断程序运行。 + /// + /// 取消令牌。若应用正在关闭,则中止本次探活。 + /// 表示探活完成的任务。 + public async Task ProbeSftpOnStartupAsync(CancellationToken cancellationToken) + { + try + { + SftpConnectionTestOutcome outcome = await _sftpLookupService + .TestConnectionAsync(null, cancellationToken) + .ConfigureAwait(false); + + UpdateSnapshot(snapshot => + { + snapshot.SftpStatus = outcome.IsSuccess + ? "在线" + : outcome.ConnectionSucceeded || outcome.IsConfigurationError + ? "配置异常" + : outcome.IsSystemError + ? "异常" + : "未验证"; + }); + + if (outcome.IsSuccess) + { + _appLogger.LogInformation($"启动时 SFTP 探活成功: {outcome.StatusMessage}", true); + } + else + { + _appLogger.LogWarning($"启动时 SFTP 探活失败: {outcome.StatusMessage}", true); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _appLogger.LogInformation("启动时 SFTP 探活已取消。"); + } + catch (Exception ex) + { + _appLogger.LogError(ex, "启动时 SFTP 探活异常"); + UpdateSnapshot(snapshot => snapshot.SftpStatus = "异常"); + } + } + /// /// 手动复位流程状态。 /// @@ -123,15 +168,31 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await CancelActiveWorkflowAsync(cancellationToken).ConfigureAwait(false); - ResetProcessStateCore(); - await WritePlcStateAsync(state => + try { - ResetResultBits(state); - state.FlowStateCode = WorkflowState.Idle.ToFlowStateCode(); - }, cancellationToken).ConfigureAwait(false); - PublishRuntimeState(WorkflowState.Idle, "已复位,等待 PCB 到位"); - _appLogger.LogInformation("已执行流程复位。", true); + await CancelActiveWorkflowAsync(cancellationToken).ConfigureAwait(false); + ResetProcessStateCore(); + await WritePlcStateAsync(state => + { + ResetResultBits(state); + state.FlowStateCode = WorkflowState.Idle.ToFlowStateCode(); + }, cancellationToken).ConfigureAwait(false); + PublishRuntimeState(WorkflowState.Idle, "已复位,等待 PCB 到位"); + _appLogger.LogInformation("已执行流程复位。", true); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _appLogger.LogError(ex, "手动复位失败"); + UpdateSnapshot(snapshot => + { + snapshot.FaultMessage = $"手动复位失败: {ex.Message}"; + snapshot.ResultDescription = "手动复位失败"; + }); + } } finally { @@ -149,9 +210,25 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await _plcService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false); - UpdateSnapshot(snapshot => snapshot.PlcStatus = "已重连"); - _appLogger.LogInformation("PLC 已手动重连。", true); + try + { + await _plcService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false); + UpdateSnapshot(snapshot => snapshot.PlcStatus = "已重连"); + _appLogger.LogInformation("PLC 已手动重连。", true); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _appLogger.LogError(ex, "PLC 手动重连失败"); + UpdateSnapshot(snapshot => + { + snapshot.PlcStatus = $"重连失败: {ex.Message}"; + snapshot.FaultMessage = $"PLC 手动重连失败: {ex.Message}"; + }); + } } finally { @@ -169,9 +246,25 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await _scannerService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false); - UpdateSnapshot(snapshot => snapshot.ScannerStatus = "已重连"); - _appLogger.LogInformation("扫码枪已手动重连。", true); + try + { + await _scannerService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false); + UpdateSnapshot(snapshot => snapshot.ScannerStatus = "已重连"); + _appLogger.LogInformation("扫码枪已手动重连。", true); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _appLogger.LogError(ex, "扫码枪手动重连失败"); + UpdateSnapshot(snapshot => + { + snapshot.ScannerStatus = $"重连失败: {ex.Message}"; + snapshot.FaultMessage = $"扫码枪手动重连失败: {ex.Message}"; + }); + } } finally { @@ -189,10 +282,26 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - var result = await _andonService.TestAsync(cancellationToken).ConfigureAwait(false); - var status = result.IsSuccess ? "测试成功" : $"测试失败: {result.ErrorMessage}"; - UpdateSnapshot(snapshot => snapshot.AndonStatus = status); - _appLogger.LogInformation($"安灯接口手动测试结果: {status}", true); + try + { + var result = await _andonService.TestAsync(cancellationToken).ConfigureAwait(false); + var status = result.IsSuccess ? "测试成功" : $"测试失败: {result.ErrorMessage}"; + UpdateSnapshot(snapshot => snapshot.AndonStatus = status); + _appLogger.LogInformation($"安灯接口手动测试结果: {status}", true); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _appLogger.LogError(ex, "安灯接口手动测试失败"); + UpdateSnapshot(snapshot => + { + snapshot.AndonStatus = $"测试失败: {ex.Message}"; + snapshot.FaultMessage = $"安灯接口手动测试失败: {ex.Message}"; + }); + } } finally { diff --git a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs index 13e5ac4..2342d7d 100644 --- a/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs +++ b/src/AxiOmron.PcbCheck/Services/Interfaces/CoreInterfaces.cs @@ -158,6 +158,14 @@ public interface ISftpLookupService /// 取消令牌。 /// 文件校验结果。 Task CheckFileAsync(string barcode, CancellationToken cancellationToken); + + /// + /// 测试当前或指定配置下的 SFTP 连接可用性。 + /// + /// 待测试的 SFTP 配置;传入 时使用当前应用配置。 + /// 取消令牌。若在连接过程中取消,将终止本次测试且不更新状态。 + /// 连接测试结果。 + Task TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken); } /// @@ -215,6 +223,20 @@ public interface IWorkflowControlService Task TestAndonAsync(CancellationToken cancellationToken); } +/// +/// 定义管理员解锁密码弹窗交互能力。 +/// +public interface IAdminUnlockDialogService +{ + /// + /// 显示管理员密码输入弹窗并校验是否通过。 + /// + /// 期望密码。 + /// 本次解锁会话持续时间。 + /// 输入正确返回 ;取消或错误返回 + bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout); +} + /// /// 定义运行态快照与 UI 事件分发能力。 /// diff --git a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs index b9c4362..f490b65 100644 --- a/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs +++ b/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs @@ -1,5 +1,6 @@ using System.Collections.ObjectModel; using System.Collections.Specialized; +using System.Windows.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using AxiOmron.PcbCheck.Models; @@ -17,6 +18,10 @@ public partial class MainWindowViewModel : ObservableObject private readonly IDispatcherService _dispatcherService; private readonly IWorkflowControlService _workflowControlService; private readonly WorkflowOptions _workflowOptions; + private readonly SecurityOptions _securityOptions; + private readonly IAdminUnlockDialogService _adminUnlockDialogService; + private readonly DispatcherTimer _adminUnlockTimer; + private DateTimeOffset? _adminUnlockedUntil; /// /// 初始化主窗口视图模型。 @@ -25,21 +30,31 @@ public partial class MainWindowViewModel : ObservableObject /// Dispatcher 调度服务。 /// 流程控制服务。 /// 应用配置。 + /// 管理员解锁弹窗服务。 public MainWindowViewModel( IAppStateStore stateStore, IDispatcherService dispatcherService, IWorkflowControlService workflowControlService, - AppConfig config) + AppConfig config, + IAdminUnlockDialogService adminUnlockDialogService) { _stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore)); _dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService)); _workflowControlService = workflowControlService ?? throw new ArgumentNullException(nameof(workflowControlService)); ArgumentNullException.ThrowIfNull(config); + _adminUnlockDialogService = adminUnlockDialogService ?? throw new ArgumentNullException(nameof(adminUnlockDialogService)); _workflowOptions = config.Workflow; + _securityOptions = config.Security; + _adminUnlockTimer = new DispatcherTimer + { + Interval = TimeSpan.FromSeconds(1) + }; + _adminUnlockTimer.Tick += OnAdminUnlockTimerTick; Title = "Axi Omron PCB Check"; Logs = new ObservableCollection(); RecentBoards = new ObservableCollection(); + AdminUnlockStatus = "管理员功能已锁定"; Logs.CollectionChanged += OnLogsCollectionChanged; RecentBoards.CollectionChanged += OnRecentBoardsCollectionChanged; @@ -246,44 +261,114 @@ public partial class MainWindowViewModel : ObservableObject [ObservableProperty] private string _lastProcessUpdateTime = "-"; + /// + /// 获取或设置管理员功能是否已解锁。 + /// + [NotifyCanExecuteChangedFor(nameof(UnlockAdminCommand))] + [NotifyCanExecuteChangedFor(nameof(ResetCommand))] + [NotifyCanExecuteChangedFor(nameof(ReconnectPlcCommand))] + [NotifyCanExecuteChangedFor(nameof(ReconnectScannerCommand))] + [NotifyCanExecuteChangedFor(nameof(TestAndonCommand))] + [ObservableProperty] + private bool _isAdminUnlocked; + + /// + /// 获取或设置管理员解锁状态文本。 + /// + [ObservableProperty] + private string _adminUnlockStatus = string.Empty; + + /// + /// 获取或设置当前是否正在执行手动操作。 + /// + [NotifyCanExecuteChangedFor(nameof(UnlockAdminCommand))] + [NotifyCanExecuteChangedFor(nameof(ResetCommand))] + [NotifyCanExecuteChangedFor(nameof(ReconnectPlcCommand))] + [NotifyCanExecuteChangedFor(nameof(ReconnectScannerCommand))] + [NotifyCanExecuteChangedFor(nameof(TestAndonCommand))] + [ObservableProperty] + private bool _isManualActionRunning; + + /// + /// 获取或设置当前手动操作状态文本。 + /// + [ObservableProperty] + private string _manualActionStatus = "待命"; + + /// + /// 获取当前是否允许执行管理员解锁。 + /// + private bool CanUnlockAdmin => !IsManualActionRunning; + + /// + /// 获取当前是否允许执行手动操作。 + /// + private bool CanExecuteManualAction => IsAdminUnlocked && !IsManualActionRunning; + /// /// 执行手动复位命令。 /// /// 表示命令执行完成的任务。 - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanExecuteManualAction))] private Task ResetAsync() { - return _workflowControlService.ResetAsync(CancellationToken.None); + return ExecuteManualActionAsync("正在执行手动复位...", () => _workflowControlService.ResetAsync(CancellationToken.None)); } /// /// 执行 PLC 重连命令。 /// /// 表示命令执行完成的任务。 - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanExecuteManualAction))] private Task ReconnectPlcAsync() { - return _workflowControlService.ReconnectPlcAsync(CancellationToken.None); + return ExecuteManualActionAsync("正在重连 PLC...", () => _workflowControlService.ReconnectPlcAsync(CancellationToken.None)); } /// /// 执行扫码枪重连命令。 /// /// 表示命令执行完成的任务。 - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanExecuteManualAction))] private Task ReconnectScannerAsync() { - return _workflowControlService.ReconnectScannerAsync(CancellationToken.None); + return ExecuteManualActionAsync("正在重连扫码枪...", () => _workflowControlService.ReconnectScannerAsync(CancellationToken.None)); } /// /// 执行安灯测试命令。 /// /// 表示命令执行完成的任务。 - [RelayCommand] + [RelayCommand(CanExecute = nameof(CanExecuteManualAction))] private Task TestAndonAsync() { - return _workflowControlService.TestAndonAsync(CancellationToken.None); + return ExecuteManualActionAsync("正在测试安灯接口...", () => _workflowControlService.TestAndonAsync(CancellationToken.None)); + } + + /// + /// 执行管理员解锁命令。 + /// + [RelayCommand(CanExecute = nameof(CanUnlockAdmin))] + private void UnlockAdmin() + { + string password = _securityOptions.AdminPassword?.Trim() ?? string.Empty; + if (string.IsNullOrWhiteSpace(password)) + { + AdminUnlockStatus = "未配置管理员密码"; + return; + } + + TimeSpan sessionTimeout = TimeSpan.FromMinutes(Math.Max(1, _securityOptions.AdminSessionTimeoutMinutes)); + bool unlocked = _adminUnlockDialogService.ShowUnlockDialog(password, sessionTimeout); + if (!unlocked) + { + return; + } + + _adminUnlockedUntil = DateTimeOffset.Now.Add(sessionTimeout); + IsAdminUnlocked = true; + UpdateAdminUnlockStatus(); + _adminUnlockTimer.Start(); } /// @@ -368,6 +453,32 @@ public partial class MainWindowViewModel : ObservableObject ActiveAlarmCount = value ? 1 : 0; } + /// + /// 当 发生变化时同步解锁状态文案。 + /// + /// 最新解锁状态。 + partial void OnIsAdminUnlockedChanged(bool value) + { + if (!value) + { + _adminUnlockedUntil = null; + _adminUnlockTimer.Stop(); + AdminUnlockStatus = "管理员功能已锁定"; + } + } + + /// + /// 当 变化时刷新状态文案。 + /// + /// 最新执行状态。 + partial void OnIsManualActionRunningChanged(bool value) + { + if (!value) + { + ManualActionStatus = "待命"; + } + } + /// /// 处理日志集合变化事件,刷新日志区统计字段。 /// @@ -502,4 +613,78 @@ public partial class MainWindowViewModel : ObservableObject } }).ConfigureAwait(false); } + + /// + /// 处理管理员解锁倒计时,超时后自动恢复锁定。 + /// + /// 事件源。 + /// 事件参数。 + private void OnAdminUnlockTimerTick(object? sender, EventArgs e) + { + if (!IsAdminUnlocked || !_adminUnlockedUntil.HasValue) + { + return; + } + + if (_adminUnlockedUntil.Value <= DateTimeOffset.Now) + { + IsAdminUnlocked = false; + return; + } + + UpdateAdminUnlockStatus(); + } + + /// + /// 更新管理员解锁状态文案。 + /// + private void UpdateAdminUnlockStatus() + { + if (!_adminUnlockedUntil.HasValue) + { + AdminUnlockStatus = "管理员功能已锁定"; + return; + } + + TimeSpan remaining = _adminUnlockedUntil.Value - DateTimeOffset.Now; + if (remaining <= TimeSpan.Zero) + { + IsAdminUnlocked = false; + return; + } + + AdminUnlockStatus = $"管理员功能已解锁,剩余 {remaining.Minutes:D2}:{remaining.Seconds:D2}"; + } + + /// + /// 统一执行手动操作,并在执行期间维护按钮可用状态。 + /// + /// 执行中的状态文本。 + /// 待执行操作。 + /// 表示执行完成的任务。 + private async Task ExecuteManualActionAsync(string runningStatus, Func action) + { + if (IsManualActionRunning) + { + return; + } + + await _dispatcherService.InvokeAsync(() => + { + IsManualActionRunning = true; + ManualActionStatus = runningStatus; + }).ConfigureAwait(false); + + try + { + await action().ConfigureAwait(false); + } + finally + { + await _dispatcherService.InvokeAsync(() => + { + IsManualActionRunning = false; + }).ConfigureAwait(false); + } + } } diff --git a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs index 3ea474f..0a4b63f 100644 --- a/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs +++ b/src/AxiOmron.PcbCheck/ViewModels/SystemSettingViewModel.cs @@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using AxiOmron.PcbCheck.Options; using AxiOmron.PcbCheck.Services.Interfaces; +using AxiOmron.PcbCheck.Models; namespace AxiOmron.PcbCheck.ViewModels; @@ -11,14 +12,17 @@ namespace AxiOmron.PcbCheck.ViewModels; public partial class SystemSettingViewModel : ObservableObject { private readonly IAppConfigService _appConfigService; + private readonly ISftpLookupService _sftpLookupService; /// /// 初始化系统设置视图模型。 /// /// 配置读写服务。 - public SystemSettingViewModel(IAppConfigService appConfigService) + /// SFTP 连接测试服务。 + public SystemSettingViewModel(IAppConfigService appConfigService, ISftpLookupService sftpLookupService) { _appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService)); + _sftpLookupService = sftpLookupService ?? throw new ArgumentNullException(nameof(sftpLookupService)); EditableConfig = _appConfigService.Load(); ConfigPath = _appConfigService.GetConfigPath(); StatusMessage = "已加载配置。"; @@ -42,6 +46,13 @@ public partial class SystemSettingViewModel : ObservableObject [ObservableProperty] private string _configPath = string.Empty; + /// + /// 获取或设置当前是否正在测试 SFTP 连接。 + /// + [NotifyCanExecuteChangedFor(nameof(TestSftpConnectionCommand))] + [ObservableProperty] + private bool _isTestingSftpConnection; + /// /// 保存当前配置。 /// @@ -77,4 +88,32 @@ public partial class SystemSettingViewModel : ObservableObject { ReloadConfig(); } + + /// + /// 测试当前界面配置下的 SFTP 连接。 + /// + /// 表示测试完成的任务。 + [RelayCommand(CanExecute = nameof(CanTestSftpConnection))] + private async Task TestSftpConnectionAsync() + { + IsTestingSftpConnection = true; + StatusMessage = "正在测试 SFTP 连接..."; + + try + { + SftpConnectionTestOutcome outcome = await _sftpLookupService + .TestConnectionAsync(EditableConfig.Sftp, CancellationToken.None); + + StatusMessage = outcome.StatusMessage; + } + finally + { + IsTestingSftpConnection = false; + } + } + + /// + /// 判断当前是否允许执行 SFTP 测试连接。 + /// + private bool CanTestSftpConnection => !IsTestingSftpConnection; } diff --git a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml index 37098fe..60d3334 100644 --- a/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml +++ b/src/AxiOmron.PcbCheck/Views/Pages/DashboardPage.xaml @@ -426,22 +426,69 @@ - - - - - - - - - - - -