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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -451,7 +498,7 @@
-
+
diff --git a/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml b/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml
index 5a54bc3..a3571d4 100644
--- a/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml
+++ b/src/AxiOmron.PcbCheck/Views/Pages/SystemSettingsPage.xaml
@@ -60,6 +60,13 @@
+
+
+
@@ -71,8 +78,10 @@
-
-
+
+
+
+
diff --git a/src/AxiOmron.PcbCheck/Views/Windows/AdminUnlockDialog.xaml b/src/AxiOmron.PcbCheck/Views/Windows/AdminUnlockDialog.xaml
new file mode 100644
index 0000000..295b6c1
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Views/Windows/AdminUnlockDialog.xaml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/AxiOmron.PcbCheck/Views/Windows/AdminUnlockDialog.xaml.cs b/src/AxiOmron.PcbCheck/Views/Windows/AdminUnlockDialog.xaml.cs
new file mode 100644
index 0000000..990fbac
--- /dev/null
+++ b/src/AxiOmron.PcbCheck/Views/Windows/AdminUnlockDialog.xaml.cs
@@ -0,0 +1,94 @@
+using System.Windows;
+using System.Windows.Input;
+
+namespace AxiOmron.PcbCheck.Views.Windows;
+
+///
+/// 表示管理员密码输入弹窗。
+///
+public partial class AdminUnlockDialog : Window
+{
+ private string _expectedPassword = string.Empty;
+
+ ///
+ /// 初始化管理员密码输入弹窗。
+ ///
+ public AdminUnlockDialog()
+ {
+ InitializeComponent();
+ Loaded += OnLoaded;
+ PreviewKeyDown += OnPreviewKeyDown;
+ }
+
+ ///
+ /// 使用指定参数配置弹窗显示内容。
+ ///
+ /// 期望密码。
+ /// 解锁会话持续时间。
+ public void Configure(string expectedPassword, TimeSpan sessionTimeout)
+ {
+ _expectedPassword = expectedPassword ?? string.Empty;
+ HintTextBlock.Text = $"输入管理员密码后,可临时显示高级操作按钮,{sessionTimeout.TotalMinutes:0} 分钟后自动恢复隐藏。";
+ PasswordInput.Password = string.Empty;
+ ErrorTextBlock.Visibility = Visibility.Collapsed;
+ }
+
+ ///
+ /// 处理窗口加载事件,并将焦点定位到密码框。
+ ///
+ /// 事件源。
+ /// 事件参数。
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ PasswordInput.Focus();
+ }
+
+ ///
+ /// 处理确认解锁按钮点击事件。
+ ///
+ /// 事件源。
+ /// 事件参数。
+ private void UnlockButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ if (string.Equals(PasswordInput.Password, _expectedPassword, StringComparison.Ordinal))
+ {
+ DialogResult = true;
+ return;
+ }
+
+ ErrorTextBlock.Visibility = Visibility.Visible;
+ PasswordInput.SelectAll();
+ PasswordInput.Focus();
+ }
+
+ ///
+ /// 处理取消按钮点击事件。
+ ///
+ /// 事件源。
+ /// 事件参数。
+ private void CancelButton_OnClick(object sender, RoutedEventArgs e)
+ {
+ DialogResult = false;
+ }
+
+ ///
+ /// 处理窗口按键事件,支持回车确认与 Esc 取消。
+ ///
+ /// 事件源。
+ /// 事件参数。
+ private void OnPreviewKeyDown(object sender, KeyEventArgs e)
+ {
+ if (e.Key == Key.Enter)
+ {
+ UnlockButton_OnClick(sender, new RoutedEventArgs());
+ e.Handled = true;
+ return;
+ }
+
+ if (e.Key == Key.Escape)
+ {
+ DialogResult = false;
+ e.Handled = true;
+ }
+ }
+}
diff --git a/src/AxiOmron.PcbCheck/appConfig.Development.json b/src/AxiOmron.PcbCheck/appConfig.Development.json
index bcbd87b..e78aee3 100644
--- a/src/AxiOmron.PcbCheck/appConfig.Development.json
+++ b/src/AxiOmron.PcbCheck/appConfig.Development.json
@@ -7,9 +7,17 @@
},
"Sftp": {
"Host": "127.0.0.1",
- "RootPath": "/tmp/pcb"
+ "RootPath": "/home/uaesadmin/",
},
"Andon": {
- "Url": "http://127.0.0.1:5000/api/andon/test"
+ "Url": "https://shp.prod.aliyun.mit.uaes.com/api/mit/MAE/Station/Status/alarm",
+ "AndonTheme": "SS7_Auto_Andon",
+ "Eid": "PR1965269806334783488",
+ "FromSign": "IOT",
+ "ServiceId": "4dccd822-86b2-47dd-89bd-ca472ba0d14d"
+ },
+ "Security": {
+ "AdminPassword": "uaes,123",
+ "AdminSessionTimeoutMinutes": 15
}
}
diff --git a/src/AxiOmron.PcbCheck/appConfig.json b/src/AxiOmron.PcbCheck/appConfig.json
index b6397a2..257b25e 100644
--- a/src/AxiOmron.PcbCheck/appConfig.json
+++ b/src/AxiOmron.PcbCheck/appConfig.json
@@ -50,11 +50,11 @@
"Sftp": {
"Host": "127.0.0.1",
"Port": 22,
- "Username": "user",
- "Password": "",
+ "Username": "uaesadmin",
+ "Password": "uaes,123",
"PrivateKeyPath": "",
"PrivateKeyPassphrase": "",
- "RootPath": "/pcb",
+ "RootPath": "/home/uaesadmin/",
"FileNamePattern": "${barcode}.txt",
"RetryIntervalSeconds": 2,
"MaxRetryCount": 3,
@@ -62,15 +62,17 @@
},
"Andon": {
"Enable": true,
- "Url": "http://127.0.0.1:5000/api/andon",
+ "Url": "https://shp.prod.aliyun.mit.uaes.com/api/mit/MAE/Station/Status/alarm",
"Method": "POST",
"TimeoutMs": 3000,
- "StationCode": "OMRON-01",
- "StationName": "PCB目检工位",
+ "AndonTheme": "SS7_Auto_Andon",
+ "Eid": "PR1965269806334783488",
+ "FromSign": "IOT",
+ "ServiceId": "4dccd822-86b2-47dd-89bd-ca472ba0d14d",
"EnableScanFailAlarm": true,
"EnableFileNotFoundAlarm": false,
"Headers": {
- "Content-Type": "application/json"
+ "Accept": "application/json"
}
},
"Workflow": {
@@ -80,5 +82,9 @@
"RequireManualResetAfterFault": true,
"MaxUiLogEntries": 200,
"MaxBoardRecords": 100
+ },
+ "Security": {
+ "AdminPassword": "AxiOmron@123",
+ "AdminSessionTimeoutMinutes": 15
}
}
diff --git a/tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj b/tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj
new file mode 100644
index 0000000..3c8345c
--- /dev/null
+++ b/tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj
@@ -0,0 +1,26 @@
+
+
+ net8.0-windows
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+ runtime; build; native; contentfiles; analyzers; buildtransitive
+ all
+
+
+
+
+
+
+
diff --git a/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs b/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs
new file mode 100644
index 0000000..90588b3
--- /dev/null
+++ b/tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs
@@ -0,0 +1,95 @@
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Interfaces;
+using AxiOmron.PcbCheck.ViewModels;
+
+namespace AxiOmron.PcbCheck.Tests;
+
+///
+/// 验证系统设置页视图模型中的 SFTP 测试连接行为。
+///
+public sealed class SystemSettingViewModelTests
+{
+ ///
+ /// 当 SFTP 测试成功时,应更新成功状态文本。
+ ///
+ /// 异步测试任务。
+ [Fact]
+ public async Task TestSftpConnectionAsync_ShouldReportSuccess_WhenConnectionSucceeds()
+ {
+ var configService = new FakeAppConfigService();
+ var sftpLookupService = new FakeSftpLookupService
+ {
+ TestOutcome = new SftpConnectionTestOutcome
+ {
+ IsSuccess = true,
+ RootPathAccessible = true,
+ StatusMessage = "SFTP 连接成功,根目录可访问。"
+ }
+ };
+ var viewModel = new SystemSettingViewModel(configService, sftpLookupService);
+
+ await viewModel.TestSftpConnectionCommand.ExecuteAsync(null);
+
+ Assert.Equal("SFTP 连接成功,根目录可访问。", viewModel.StatusMessage);
+ }
+
+ ///
+ /// 当 SFTP 测试失败时,应将失败原因展示到状态文本。
+ ///
+ /// 异步测试任务。
+ [Fact]
+ public async Task TestSftpConnectionAsync_ShouldReportFailure_WhenConnectionFails()
+ {
+ var configService = new FakeAppConfigService();
+ var sftpLookupService = new FakeSftpLookupService
+ {
+ TestOutcome = new SftpConnectionTestOutcome
+ {
+ IsSuccess = false,
+ IsSystemError = true,
+ StatusMessage = "SFTP 连接失败: timeout"
+ }
+ };
+ var viewModel = new SystemSettingViewModel(configService, sftpLookupService);
+
+ await viewModel.TestSftpConnectionCommand.ExecuteAsync(null);
+
+ Assert.Equal("SFTP 连接失败: timeout", viewModel.StatusMessage);
+ }
+
+ private sealed class FakeAppConfigService : IAppConfigService
+ {
+ public AppConfig Config { get; } = new();
+
+ public AppConfig Load()
+ {
+ return Config;
+ }
+
+ public void Save(AppConfig config)
+ {
+ ArgumentNullException.ThrowIfNull(config);
+ }
+
+ public string GetConfigPath()
+ {
+ return "appConfig.json";
+ }
+ }
+
+ private sealed class FakeSftpLookupService : ISftpLookupService
+ {
+ public SftpConnectionTestOutcome TestOutcome { get; set; } = new();
+
+ public Task CheckFileAsync(string barcode, CancellationToken cancellationToken)
+ {
+ throw new NotSupportedException();
+ }
+
+ public Task TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(TestOutcome);
+ }
+ }
+}
diff --git a/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs b/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs
new file mode 100644
index 0000000..f4523f1
--- /dev/null
+++ b/tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs
@@ -0,0 +1,86 @@
+using AxiOmron.PcbCheck.Models;
+using AxiOmron.PcbCheck.Options;
+using AxiOmron.PcbCheck.Services.Implementations;
+using AxiOmron.PcbCheck.Services.Interfaces;
+
+namespace AxiOmron.PcbCheck.Tests;
+
+///
+/// 验证流程后台服务的 SFTP 启动探活行为。
+///
+public sealed class WorkflowHostedServiceTests
+{
+ ///
+ /// 启动探活失败时,不应抛出异常,且应写入运行态状态。
+ ///
+ /// 异步测试任务。
+ [Fact]
+ public async Task ProbeSftpOnStartupAsync_ShouldUpdateSnapshot_WhenConnectionFails()
+ {
+ var stateStore = new AppStateStore();
+ var service = new WorkflowHostedService(
+ new FakePlcService(),
+ new FakeScannerService(),
+ new FakeSftpLookupService
+ {
+ TestOutcome = new SftpConnectionTestOutcome
+ {
+ IsSuccess = false,
+ IsSystemError = true,
+ StatusMessage = "启动探活失败"
+ }
+ },
+ new FakeAndonService(),
+ stateStore,
+ new AppConfig(),
+ new FakeAppLogger());
+
+ await service.ProbeSftpOnStartupAsync(CancellationToken.None);
+
+ Assert.Equal("异常", stateStore.GetSnapshot().SftpStatus);
+ }
+
+ private sealed class FakePlcService : IPlcService
+ {
+ public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task ReadSignalsAsync(CancellationToken cancellationToken) => Task.FromResult(new PlcSignalSnapshot());
+ public Task WriteStateAsync(PlcProcessState state, CancellationToken cancellationToken) => Task.CompletedTask;
+ }
+
+ private sealed class FakeScannerService : IScannerService
+ {
+ public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
+ public Task TestConnectionAsync(CancellationToken cancellationToken) => Task.FromResult(true);
+ public Task TriggerScanAsync(CancellationToken cancellationToken) => Task.FromResult(new ScanOperationResult());
+ }
+
+ private sealed class FakeSftpLookupService : ISftpLookupService
+ {
+ public SftpConnectionTestOutcome TestOutcome { get; set; } = new();
+
+ public Task CheckFileAsync(string barcode, CancellationToken cancellationToken) => Task.FromResult(new SftpCheckOutcome());
+
+ public Task TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
+ {
+ return Task.FromResult(TestOutcome);
+ }
+ }
+
+ private sealed class FakeAndonService : IAndonService
+ {
+ public Task RaiseAlarmAsync(AndonAlarmRequest request, CancellationToken cancellationToken)
+ => Task.FromResult(new AndonOperationResult());
+
+ public Task TestAsync(CancellationToken cancellationToken)
+ => Task.FromResult(new AndonOperationResult());
+ }
+
+ private sealed class FakeAppLogger : IAppLogger
+ {
+ public void LogError(string message, bool showInUi = false, params object?[] args) { }
+ public void LogError(Exception exception, string message, bool showInUi = false, params object?[] args) { }
+ public void LogInformation(string message, bool showInUi = false, params object?[] args) { }
+ public void LogWarning(string message, bool showInUi = false, params object?[] args) { }
+ public void LogWarning(Exception exception, string message, bool showInUi = false, params object?[] args) { }
+ }
+}