feat(*): 添加管理员解锁与 SFTP 探活能力

* 新增管理员解锁弹窗、手动操作权限控制与倒计时状态
* 支持系统设置页测试 SFTP 连接,并在启动时执行探活
* 补充设计时服务、全局异常兜底与相关单元测试
This commit is contained in:
2026-04-17 14:12:10 +08:00
parent 49f113dcf3
commit 8f74e07c66
25 changed files with 1382 additions and 95 deletions

View File

@@ -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/**)"
]
}
}

View File

@@ -1,2 +1,8 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/AxiOmron.PcbCheck/AxiOmron.PcbCheck.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj" />
</Folder>
</Solution>

View File

@@ -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;
/// </summary>
public partial class App : Application
{
private static readonly NLog.Logger FallbackLogger = NLog.LogManager.GetCurrentClassLogger();
private IHost? _host;
/// <summary>
@@ -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
/// <param name="e">退出事件参数。</param>
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<WorkflowHostedService>();
services.AddSingleton<IWorkflowControlService>(sp => sp.GetRequiredService<WorkflowHostedService>());
services.AddHostedService(sp => sp.GetRequiredService<WorkflowHostedService>());
services.AddSingleton<IAdminUnlockDialogService, AdminUnlockDialogService>();
services.AddSingleton<MainWindowViewModel>();
services.AddSingleton<SystemSettingViewModel>();
services.AddSingleton<DashboardPage>();
services.AddSingleton<SystemSettingsPage>();
services.AddTransient<AdminUnlockDialog>();
services.AddSingleton<MainWindow>();
})
.Build();
}
/// <summary>
/// 注册全局异常处理器,用于兜底记录 UI 线程、未观察任务和域级异常。
/// </summary>
private void RegisterGlobalExceptionHandlers()
{
DispatcherUnhandledException += OnDispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException += OnCurrentDomainUnhandledException;
TaskScheduler.UnobservedTaskException += OnTaskSchedulerUnobservedTaskException;
}
/// <summary>
/// 注销全局异常处理器。
/// </summary>
private void UnregisterGlobalExceptionHandlers()
{
DispatcherUnhandledException -= OnDispatcherUnhandledException;
AppDomain.CurrentDomain.UnhandledException -= OnCurrentDomainUnhandledException;
TaskScheduler.UnobservedTaskException -= OnTaskSchedulerUnobservedTaskException;
}
/// <summary>
/// 处理 UI 线程未捕获异常,记录日志并阻止应用直接闪退。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">异常事件参数。</param>
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;
}
/// <summary>
/// 处理未观察任务异常,记录日志并标记为已观察。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">异常事件参数。</param>
private void OnTaskSchedulerUnobservedTaskException(object? sender, UnobservedTaskExceptionEventArgs e)
{
LogGlobalException("未观察任务异常", e.Exception);
e.SetObserved();
}
/// <summary>
/// 处理应用程序域未捕获异常,尽量在进程退出前补充日志。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">异常事件参数。</param>
private void OnCurrentDomainUnhandledException(object? sender, UnhandledExceptionEventArgs e)
{
var exception = e.ExceptionObject as Exception ?? new Exception(e.ExceptionObject?.ToString() ?? "未知未处理异常");
LogGlobalException($"应用程序域未处理异常IsTerminating={e.IsTerminating}", exception);
}
/// <summary>
/// 统一记录全局异常日志,优先复用宿主日志器,失败时退回 NLog 直接写入。
/// </summary>
/// <param name="message">日志消息。</param>
/// <param name="exception">异常对象。</param>
private static void LogGlobalException(string message, Exception exception)
{
try
{
if (Services is not null)
{
var logger = Services.GetService<ILogger<App>>();
logger?.LogError(exception, message);
}
}
catch
{
// 忽略宿主日志失败,继续使用回退日志。
}
try
{
FallbackLogger.Error(exception, message);
}
catch
{
// 全局兜底日志不再向外抛出。
}
}
}

View File

@@ -0,0 +1,20 @@
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.DesignTime;
/// <summary>
/// 提供设计时管理员解锁弹窗服务占位实现。
/// </summary>
public sealed class DesignTimeAdminUnlockDialogService : IAdminUnlockDialogService
{
/// <summary>
/// 显示管理员密码输入弹窗并校验是否通过。
/// </summary>
/// <param name="expectedPassword">期望密码。</param>
/// <param name="sessionTimeout">本次解锁会话持续时间。</param>
/// <returns>设计时固定返回 <see langword="false"/>。</returns>
public bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout)
{
return false;
}
}

View File

@@ -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;
}
/// <summary>
@@ -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
}
};
}

View File

@@ -0,0 +1,44 @@
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.DesignTime;
/// <summary>
/// 为设计器提供 SFTP 测试结果的设计时服务。
/// </summary>
public sealed class DesignTimeSftpLookupService : ISftpLookupService
{
/// <summary>
/// 返回设计时文件校验结果。
/// </summary>
/// <param name="barcode">条码。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定设计时结果。</returns>
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
{
return Task.FromResult(new SftpCheckOutcome
{
Exists = true,
ConnectionSucceeded = true,
MatchedFilePath = $"/pcb/{barcode}.txt"
});
}
/// <summary>
/// 返回设计时连接测试结果。
/// </summary>
/// <param name="options">SFTP 配置。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>固定成功结果。</returns>
public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
{
return Task.FromResult(new SftpConnectionTestOutcome
{
IsSuccess = true,
ConnectionSucceeded = true,
RootPathAccessible = true,
StatusMessage = "设计时 SFTP 连接成功。"
});
}
}

View File

@@ -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
/// 获取系统设置设计时视图模型。
/// </summary>
public SystemSettingViewModel SystemSettingViewModel
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService);
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService);
/// <summary>
/// 创建首页设计时视图模型。
@@ -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);
}
}

View File

@@ -383,6 +383,42 @@ public sealed class SftpCheckOutcome
public string ErrorMessage { get; set; } = string.Empty;
}
/// <summary>
/// 表示一次 SFTP 连接测试结果。
/// </summary>
public sealed class SftpConnectionTestOutcome
{
/// <summary>
/// 获取或设置连接测试是否成功。
/// </summary>
public bool IsSuccess { get; set; }
/// <summary>
/// 获取或设置是否为系统级异常。
/// </summary>
public bool IsSystemError { get; set; }
/// <summary>
/// 获取或设置是否为配置级异常。
/// </summary>
public bool IsConfigurationError { get; set; }
/// <summary>
/// 获取或设置本次连接是否成功建立。
/// </summary>
public bool ConnectionSucceeded { get; set; }
/// <summary>
/// 获取或设置根目录是否可访问。
/// </summary>
public bool RootPathAccessible { get; set; }
/// <summary>
/// 获取或设置状态描述文本。
/// </summary>
public string StatusMessage { get; set; } = string.Empty;
}
/// <summary>
/// 表示一次安灯请求。
/// </summary>

View File

@@ -29,6 +29,11 @@ public sealed class AppConfig
/// 获取或设置流程控制配置。
/// </summary>
public WorkflowOptions Workflow { get; set; } = new();
/// <summary>
/// 获取或设置安全控制配置。
/// </summary>
public SecurityOptions Security { get; set; } = new();
}
/// <summary>
@@ -217,7 +222,7 @@ public sealed class AndonOptions
/// <summary>
/// 获取或设置安灯接口地址。
/// </summary>
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";
/// <summary>
/// 获取或设置请求方法。
@@ -230,14 +235,24 @@ public sealed class AndonOptions
public int TimeoutMs { get; set; } = 3000;
/// <summary>
/// 获取或设置工位编码
/// 获取或设置安灯主题
/// </summary>
public string StationCode { get; set; } = "OMRON-01";
public string AndonTheme { get; set; } = "SS7_Auto_Andon";
/// <summary>
/// 获取或设置工位名称
/// 获取或设置设备唯一标识
/// </summary>
public string StationName { get; set; } = "PCB 目检工位";
public string Eid { get; set; } = "PR1965269806334783488";
/// <summary>
/// 获取或设置调用来源标记。
/// </summary>
public string FromSign { get; set; } = "IOT";
/// <summary>
/// 获取或设置服务标识。
/// </summary>
public string ServiceId { get; set; } = "4dccd822-86b2-47dd-89bd-ca472ba0d14d";
/// <summary>
/// 获取或设置扫码失败报警是否启用。
@@ -291,6 +306,22 @@ public sealed class WorkflowOptions
public int MaxBoardRecords { get; set; } = 100;
}
/// <summary>
/// 表示简易管理员控制配置。
/// </summary>
public sealed class SecurityOptions
{
/// <summary>
/// 获取或设置管理员解锁密码。
/// </summary>
public string AdminPassword { get; set; } = "AxiOmron@123";
/// <summary>
/// 获取或设置管理员解锁会话超时时间,单位为分钟。
/// </summary>
public int AdminSessionTimeoutMinutes { get; set; } = 15;
}
/// <summary>
/// 表示 PLC 输入点位地址配置。
/// </summary>

View File

@@ -0,0 +1,43 @@
using AxiOmron.PcbCheck.Services.Interfaces;
using AxiOmron.PcbCheck.Views.Windows;
using Microsoft.Extensions.DependencyInjection;
namespace AxiOmron.PcbCheck.Services.Implementations;
/// <summary>
/// 提供管理员密码输入弹窗显示能力。
/// </summary>
public sealed class AdminUnlockDialogService : IAdminUnlockDialogService
{
private readonly IServiceProvider _serviceProvider;
/// <summary>
/// 初始化管理员密码弹窗服务。
/// </summary>
/// <param name="serviceProvider">服务提供器。</param>
public AdminUnlockDialogService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
}
/// <summary>
/// 显示管理员密码输入弹窗并校验是否通过。
/// </summary>
/// <param name="expectedPassword">期望密码。</param>
/// <param name="sessionTimeout">本次解锁会话持续时间。</param>
/// <returns>输入正确返回 <see langword="true"/>;取消或错误返回 <see langword="false"/>。</returns>
public bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout)
{
ArgumentException.ThrowIfNullOrWhiteSpace(expectedPassword);
var dialog = _serviceProvider.GetRequiredService<AdminUnlockDialog>();
if (System.Windows.Application.Current?.MainWindow is { } mainWindow)
{
dialog.Owner = mainWindow;
}
dialog.Configure(expectedPassword, sessionTimeout);
return dialog.ShowDialog() == true;
}
}

View File

@@ -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
}
}
/// <summary>
/// 根据运行时报警内容构建安灯请求地址。
/// </summary>
/// <param name="request">报警请求对象。</param>
/// <returns>包含查询参数的请求地址。</returns>
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;
}
/// <summary>
/// 发送一次测试报警请求。
/// </summary>

View File

@@ -58,6 +58,27 @@ public sealed class SftpLookupService : ISftpLookupService
return await Task.Run(() => CheckInternal(barcode, cancellationToken), cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// 测试当前或指定配置下的 SFTP 连接可用性。
/// </summary>
/// <param name="options">待测试的 SFTP 配置;为 <see langword="null"/> 时使用当前应用配置。</param>
/// <param name="cancellationToken">取消令牌。若在连接过程中取消,将终止测试。</param>
/// <returns>连接测试结果。</returns>
public async Task<SftpConnectionTestOutcome> 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);
}
/// <summary>
/// 在同步上下文中执行 SFTP 查询。
/// </summary>
@@ -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
/// <returns>SFTP 客户端实例。</returns>
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);
/// <summary>
/// 根据指定配置创建 SFTP 客户端。
/// </summary>
/// <param name="options">SFTP 配置。</param>
/// <returns>SFTP 客户端实例。</returns>
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);
}
/// <summary>
/// 统一设置超时并建立 SFTP 连接。
/// </summary>
/// <param name="client">SFTP 客户端。</param>
/// <param name="options">SFTP 配置。</param>
private static void ConnectClient(SftpClient client, SftpOptions options)
{
client.ConnectionInfo.Timeout = TimeSpan.FromMilliseconds(options.ConnectTimeoutMs);
client.Connect();
}
/// <summary>
@@ -197,4 +238,80 @@ public sealed class SftpLookupService : ISftpLookupService
{
return path.Replace('\\', '/').TrimEnd('/');
}
/// <summary>
/// 在同步上下文中执行 SFTP 连通性测试。
/// </summary>
/// <param name="options">待测试的配置。</param>
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>连接测试结果。</returns>
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
};
}
}
}

View File

@@ -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);
}
/// <summary>
/// 在应用启动后执行一次 SFTP 探活,仅更新状态与日志,不阻断程序运行。
/// </summary>
/// <param name="cancellationToken">取消令牌。若应用正在关闭,则中止本次探活。</param>
/// <returns>表示探活完成的任务。</returns>
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 = "异常");
}
}
/// <summary>
/// 手动复位流程状态。
/// </summary>
@@ -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
{

View File

@@ -158,6 +158,14 @@ public interface ISftpLookupService
/// <param name="cancellationToken">取消令牌。</param>
/// <returns>文件校验结果。</returns>
Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken);
/// <summary>
/// 测试当前或指定配置下的 SFTP 连接可用性。
/// </summary>
/// <param name="options">待测试的 SFTP 配置;传入 <see langword="null"/> 时使用当前应用配置。</param>
/// <param name="cancellationToken">取消令牌。若在连接过程中取消,将终止本次测试且不更新状态。</param>
/// <returns>连接测试结果。</returns>
Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken);
}
/// <summary>
@@ -215,6 +223,20 @@ public interface IWorkflowControlService
Task TestAndonAsync(CancellationToken cancellationToken);
}
/// <summary>
/// 定义管理员解锁密码弹窗交互能力。
/// </summary>
public interface IAdminUnlockDialogService
{
/// <summary>
/// 显示管理员密码输入弹窗并校验是否通过。
/// </summary>
/// <param name="expectedPassword">期望密码。</param>
/// <param name="sessionTimeout">本次解锁会话持续时间。</param>
/// <returns>输入正确返回 <see langword="true"/>;取消或错误返回 <see langword="false"/>。</returns>
bool ShowUnlockDialog(string expectedPassword, TimeSpan sessionTimeout);
}
/// <summary>
/// 定义运行态快照与 UI 事件分发能力。
/// </summary>

View File

@@ -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;
/// <summary>
/// 初始化主窗口视图模型。
@@ -25,21 +30,31 @@ public partial class MainWindowViewModel : ObservableObject
/// <param name="dispatcherService">Dispatcher 调度服务。</param>
/// <param name="workflowControlService">流程控制服务。</param>
/// <param name="config">应用配置。</param>
/// <param name="adminUnlockDialogService">管理员解锁弹窗服务。</param>
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<UiLogEntry>();
RecentBoards = new ObservableCollection<BoardProcessRecord>();
AdminUnlockStatus = "管理员功能已锁定";
Logs.CollectionChanged += OnLogsCollectionChanged;
RecentBoards.CollectionChanged += OnRecentBoardsCollectionChanged;
@@ -246,44 +261,114 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty]
private string _lastProcessUpdateTime = "-";
/// <summary>
/// 获取或设置管理员功能是否已解锁。
/// </summary>
[NotifyCanExecuteChangedFor(nameof(UnlockAdminCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
[NotifyCanExecuteChangedFor(nameof(ReconnectPlcCommand))]
[NotifyCanExecuteChangedFor(nameof(ReconnectScannerCommand))]
[NotifyCanExecuteChangedFor(nameof(TestAndonCommand))]
[ObservableProperty]
private bool _isAdminUnlocked;
/// <summary>
/// 获取或设置管理员解锁状态文本。
/// </summary>
[ObservableProperty]
private string _adminUnlockStatus = string.Empty;
/// <summary>
/// 获取或设置当前是否正在执行手动操作。
/// </summary>
[NotifyCanExecuteChangedFor(nameof(UnlockAdminCommand))]
[NotifyCanExecuteChangedFor(nameof(ResetCommand))]
[NotifyCanExecuteChangedFor(nameof(ReconnectPlcCommand))]
[NotifyCanExecuteChangedFor(nameof(ReconnectScannerCommand))]
[NotifyCanExecuteChangedFor(nameof(TestAndonCommand))]
[ObservableProperty]
private bool _isManualActionRunning;
/// <summary>
/// 获取或设置当前手动操作状态文本。
/// </summary>
[ObservableProperty]
private string _manualActionStatus = "待命";
/// <summary>
/// 获取当前是否允许执行管理员解锁。
/// </summary>
private bool CanUnlockAdmin => !IsManualActionRunning;
/// <summary>
/// 获取当前是否允许执行手动操作。
/// </summary>
private bool CanExecuteManualAction => IsAdminUnlocked && !IsManualActionRunning;
/// <summary>
/// 执行手动复位命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
private Task ResetAsync()
{
return _workflowControlService.ResetAsync(CancellationToken.None);
return ExecuteManualActionAsync("正在执行手动复位...", () => _workflowControlService.ResetAsync(CancellationToken.None));
}
/// <summary>
/// 执行 PLC 重连命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
private Task ReconnectPlcAsync()
{
return _workflowControlService.ReconnectPlcAsync(CancellationToken.None);
return ExecuteManualActionAsync("正在重连 PLC...", () => _workflowControlService.ReconnectPlcAsync(CancellationToken.None));
}
/// <summary>
/// 执行扫码枪重连命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
private Task ReconnectScannerAsync()
{
return _workflowControlService.ReconnectScannerAsync(CancellationToken.None);
return ExecuteManualActionAsync("正在重连扫码枪...", () => _workflowControlService.ReconnectScannerAsync(CancellationToken.None));
}
/// <summary>
/// 执行安灯测试命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
private Task TestAndonAsync()
{
return _workflowControlService.TestAndonAsync(CancellationToken.None);
return ExecuteManualActionAsync("正在测试安灯接口...", () => _workflowControlService.TestAndonAsync(CancellationToken.None));
}
/// <summary>
/// 执行管理员解锁命令。
/// </summary>
[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();
}
/// <summary>
@@ -368,6 +453,32 @@ public partial class MainWindowViewModel : ObservableObject
ActiveAlarmCount = value ? 1 : 0;
}
/// <summary>
/// 当 <see cref="IsAdminUnlocked"/> 发生变化时同步解锁状态文案。
/// </summary>
/// <param name="value">最新解锁状态。</param>
partial void OnIsAdminUnlockedChanged(bool value)
{
if (!value)
{
_adminUnlockedUntil = null;
_adminUnlockTimer.Stop();
AdminUnlockStatus = "管理员功能已锁定";
}
}
/// <summary>
/// 当 <see cref="IsManualActionRunning"/> 变化时刷新状态文案。
/// </summary>
/// <param name="value">最新执行状态。</param>
partial void OnIsManualActionRunningChanged(bool value)
{
if (!value)
{
ManualActionStatus = "待命";
}
}
/// <summary>
/// 处理日志集合变化事件,刷新日志区统计字段。
/// </summary>
@@ -502,4 +613,78 @@ public partial class MainWindowViewModel : ObservableObject
}
}).ConfigureAwait(false);
}
/// <summary>
/// 处理管理员解锁倒计时,超时后自动恢复锁定。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">事件参数。</param>
private void OnAdminUnlockTimerTick(object? sender, EventArgs e)
{
if (!IsAdminUnlocked || !_adminUnlockedUntil.HasValue)
{
return;
}
if (_adminUnlockedUntil.Value <= DateTimeOffset.Now)
{
IsAdminUnlocked = false;
return;
}
UpdateAdminUnlockStatus();
}
/// <summary>
/// 更新管理员解锁状态文案。
/// </summary>
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}";
}
/// <summary>
/// 统一执行手动操作,并在执行期间维护按钮可用状态。
/// </summary>
/// <param name="runningStatus">执行中的状态文本。</param>
/// <param name="action">待执行操作。</param>
/// <returns>表示执行完成的任务。</returns>
private async Task ExecuteManualActionAsync(string runningStatus, Func<Task> 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);
}
}
}

View File

@@ -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;
/// <summary>
/// 初始化系统设置视图模型。
/// </summary>
/// <param name="appConfigService">配置读写服务。</param>
public SystemSettingViewModel(IAppConfigService appConfigService)
/// <param name="sftpLookupService">SFTP 连接测试服务。</param>
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;
/// <summary>
/// 获取或设置当前是否正在测试 SFTP 连接。
/// </summary>
[NotifyCanExecuteChangedFor(nameof(TestSftpConnectionCommand))]
[ObservableProperty]
private bool _isTestingSftpConnection;
/// <summary>
/// 保存当前配置。
/// </summary>
@@ -77,4 +88,32 @@ public partial class SystemSettingViewModel : ObservableObject
{
ReloadConfig();
}
/// <summary>
/// 测试当前界面配置下的 SFTP 连接。
/// </summary>
/// <returns>表示测试完成的任务。</returns>
[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;
}
}
/// <summary>
/// 判断当前是否允许执行 SFTP 测试连接。
/// </summary>
private bool CanTestSftpConnection => !IsTestingSftpConnection;
}

View File

@@ -434,13 +434,60 @@
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="16" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Grid.Column="0" Style="{StaticResource PrimaryActionButtonStyle}" Content="手动复位" Command="{Binding ResetCommand}" />
<Button Grid.Column="2" Style="{StaticResource PrimaryActionButtonStyle}" Content="重连 PLC" Command="{Binding ReconnectPlcCommand}" />
<Button Grid.Column="4" Style="{StaticResource PrimaryActionButtonStyle}" Content="重连扫码枪" Command="{Binding ReconnectScannerCommand}" />
<Button Grid.Column="6" Style="{StaticResource ToolbarButtonStyle}" Content="测试安灯接口" Command="{Binding TestAndonCommand}" />
<Button Grid.Column="0"
Style="{StaticResource ToolbarButtonStyle}"
Content="管理员解锁"
Command="{Binding UnlockAdminCommand}" />
<Button Grid.Column="2"
Style="{StaticResource PrimaryActionButtonStyle}"
Content="手动复位"
Command="{Binding ResetCommand}"
Visibility="{Binding IsAdminUnlocked, Converter={StaticResource BooleanToVisibilityConverter}}" />
<Button Grid.Column="4"
Style="{StaticResource PrimaryActionButtonStyle}"
Content="重连 PLC"
Command="{Binding ReconnectPlcCommand}"
Visibility="{Binding IsAdminUnlocked, Converter={StaticResource BooleanToVisibilityConverter}}" />
<Button Grid.Column="6"
Style="{StaticResource PrimaryActionButtonStyle}"
Content="重连扫码枪"
Command="{Binding ReconnectScannerCommand}"
Visibility="{Binding IsAdminUnlocked, Converter={StaticResource BooleanToVisibilityConverter}}" />
<Button Grid.Column="8"
Style="{StaticResource ToolbarButtonStyle}"
Content="测试安灯接口"
Command="{Binding TestAndonCommand}"
Visibility="{Binding IsAdminUnlocked, Converter={StaticResource BooleanToVisibilityConverter}}" />
<Border Grid.Column="10"
Background="#FFFFFF"
BorderBrush="{StaticResource CardBorderBrush}"
BorderThickness="1"
CornerRadius="999"
Padding="12,6"
HorizontalAlignment="Left"
VerticalAlignment="Center">
<StackPanel Orientation="Horizontal">
<TextBlock Text="{Binding AdminUnlockStatus}"
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource SecondaryTitleBrush}" />
<TextBlock Text=" | "
FontSize="12"
FontWeight="SemiBold"
Foreground="#CBD5E1"
Margin="8,0" />
<TextBlock Text="{Binding ManualActionStatus}"
FontSize="12"
FontWeight="SemiBold"
Foreground="{StaticResource AccentBlueBrush}" />
</StackPanel>
</Border>
</Grid>
<Grid Grid.Row="1" Margin="20,0,20,20">
@@ -451,7 +498,7 @@
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="16" />
<RowDefinition Height="10" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>

View File

@@ -60,6 +60,13 @@
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="文件名模板" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.FileNamePattern, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="重试间隔(秒)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.RetryIntervalSeconds, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="最大重试次数" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Sftp.MaxRetryCount, UpdateSourceTrigger=PropertyChanged}" />
<StackPanel Orientation="Horizontal" Margin="0,12,0,0">
<Button Style="{StaticResource ButtonDefault}"
Padding="18,8"
MinHeight="36"
Content="测试连接"
Command="{Binding TestSftpConnectionCommand}" />
</StackPanel>
</StackPanel>
</ScrollViewer>
</TabItem>
@@ -71,8 +78,10 @@
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="安灯 URL" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Url, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="请求方法" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Method, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="超时(ms)" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.TimeoutMs, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="工位编码" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.StationCode, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="工位名称" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.StationName, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="安灯主题" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.AndonTheme, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="设备 EID" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.Eid, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="来源标记" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.FromSign, UpdateSourceTrigger=PropertyChanged}" />
<TextBox Style="{StaticResource TextBoxExtend}" hc:InfoElement.Title="服务 ID" hc:InfoElement.TitlePlacement="Left" Margin="0,6,0,0" Text="{Binding EditableConfig.Andon.ServiceId, UpdateSourceTrigger=PropertyChanged}" />
<CheckBox Content="扫码失败时报警" IsChecked="{Binding EditableConfig.Andon.EnableScanFailAlarm}" Margin="0,6,0,0" />
<CheckBox Content="文件未找到时报警" IsChecked="{Binding EditableConfig.Andon.EnableFileNotFoundAlarm}" Margin="0,6,0,0" />
<CheckBox Content="要求 PLC Ready" IsChecked="{Binding EditableConfig.Workflow.RequirePlcReady}" Margin="0,6,0,0" />

View File

@@ -0,0 +1,146 @@
<Window x:Class="AxiOmron.PcbCheck.Views.Windows.AdminUnlockDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Width="460"
Height="320"
ResizeMode="NoResize"
WindowStyle="None"
AllowsTransparency="True"
Background="Transparent"
ShowInTaskbar="False"
WindowStartupLocation="CenterOwner"
Title="管理员解锁">
<Border CornerRadius="22"
Background="#F8FAFC"
BorderBrush="#D8E2F1"
BorderThickness="1">
<Border.Effect>
<DropShadowEffect BlurRadius="26"
ShadowDepth="6"
Direction="270"
Color="#1E293B"
Opacity="0.18" />
</Border.Effect>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="108" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border Grid.Row="0"
CornerRadius="22,22,0,0"
Background="#2563EB">
<Grid Margin="24,18,24,18">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="56" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Width="48"
Height="48"
CornerRadius="24"
Background="#FFFFFF22"
VerticalAlignment="Center">
<TextBlock Text="管"
FontSize="24"
FontWeight="Bold"
Foreground="White"
HorizontalAlignment="Center"
VerticalAlignment="Center" />
</Border>
<StackPanel Grid.Column="1"
Margin="16,0,0,0"
VerticalAlignment="Center">
<TextBlock Text="管理员权限解锁"
FontSize="22"
FontWeight="SemiBold"
Foreground="White" />
<TextBlock x:Name="HintTextBlock"
Margin="0,6,0,0"
FontSize="13"
Foreground="#DBEAFE"
Text="输入管理员密码后,可临时显示高级操作按钮。" />
</StackPanel>
</Grid>
</Border>
<Grid Grid.Row="1" Margin="24,22,24,22">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="12" />
<RowDefinition Height="Auto" />
<RowDefinition Height="10" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
Text="管理员密码"
FontSize="13"
FontWeight="SemiBold"
Foreground="#334155" />
<Border Grid.Row="2"
CornerRadius="12"
Background="White"
BorderBrush="#D8E2F1"
BorderThickness="1.2"
Padding="14,4">
<PasswordBox x:Name="PasswordInput"
BorderThickness="0"
Background="Transparent"
FontSize="16"
CaretBrush="#2563EB"
VerticalContentAlignment="Center"
PasswordChar="●" />
</Border>
<TextBlock Grid.Row="4"
x:Name="ErrorTextBlock"
FontSize="12"
Foreground="#DC2626"
Visibility="Collapsed"
Text="密码错误,请重新输入。" />
<Grid Grid.Row="6">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="10" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Button Grid.Column="1"
Width="92"
Height="38"
Content="取消"
Click="CancelButton_OnClick"
Background="White"
Foreground="#475569"
BorderBrush="#D8E2F1"
BorderThickness="1"
FontWeight="SemiBold"
Cursor="Hand" />
<Button Grid.Column="3"
Width="128"
Height="38"
Content="确认解锁"
Click="UnlockButton_OnClick"
Background="#2563EB"
Foreground="White"
BorderBrush="#2563EB"
BorderThickness="1"
FontWeight="SemiBold"
Cursor="Hand" />
</Grid>
</Grid>
</Grid>
</Border>
</Window>

View File

@@ -0,0 +1,94 @@
using System.Windows;
using System.Windows.Input;
namespace AxiOmron.PcbCheck.Views.Windows;
/// <summary>
/// 表示管理员密码输入弹窗。
/// </summary>
public partial class AdminUnlockDialog : Window
{
private string _expectedPassword = string.Empty;
/// <summary>
/// 初始化管理员密码输入弹窗。
/// </summary>
public AdminUnlockDialog()
{
InitializeComponent();
Loaded += OnLoaded;
PreviewKeyDown += OnPreviewKeyDown;
}
/// <summary>
/// 使用指定参数配置弹窗显示内容。
/// </summary>
/// <param name="expectedPassword">期望密码。</param>
/// <param name="sessionTimeout">解锁会话持续时间。</param>
public void Configure(string expectedPassword, TimeSpan sessionTimeout)
{
_expectedPassword = expectedPassword ?? string.Empty;
HintTextBlock.Text = $"输入管理员密码后,可临时显示高级操作按钮,{sessionTimeout.TotalMinutes:0} 分钟后自动恢复隐藏。";
PasswordInput.Password = string.Empty;
ErrorTextBlock.Visibility = Visibility.Collapsed;
}
/// <summary>
/// 处理窗口加载事件,并将焦点定位到密码框。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">事件参数。</param>
private void OnLoaded(object sender, RoutedEventArgs e)
{
PasswordInput.Focus();
}
/// <summary>
/// 处理确认解锁按钮点击事件。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">事件参数。</param>
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();
}
/// <summary>
/// 处理取消按钮点击事件。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">事件参数。</param>
private void CancelButton_OnClick(object sender, RoutedEventArgs e)
{
DialogResult = false;
}
/// <summary>
/// 处理窗口按键事件,支持回车确认与 Esc 取消。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">事件参数。</param>
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;
}
}
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\AxiOmron.PcbCheck\AxiOmron.PcbCheck.csproj" />
</ItemGroup>
</Project>

View File

@@ -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;
/// <summary>
/// 验证系统设置页视图模型中的 SFTP 测试连接行为。
/// </summary>
public sealed class SystemSettingViewModelTests
{
/// <summary>
/// 当 SFTP 测试成功时,应更新成功状态文本。
/// </summary>
/// <returns>异步测试任务。</returns>
[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);
}
/// <summary>
/// 当 SFTP 测试失败时,应将失败原因展示到状态文本。
/// </summary>
/// <returns>异步测试任务。</returns>
[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<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
{
return Task.FromResult(TestOutcome);
}
}
}

View File

@@ -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;
/// <summary>
/// 验证流程后台服务的 SFTP 启动探活行为。
/// </summary>
public sealed class WorkflowHostedServiceTests
{
/// <summary>
/// 启动探活失败时,不应抛出异常,且应写入运行态状态。
/// </summary>
/// <returns>异步测试任务。</returns>
[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<WorkflowHostedService>());
await service.ProbeSftpOnStartupAsync(CancellationToken.None);
Assert.Equal("异常", stateStore.GetSnapshot().SftpStatus);
}
private sealed class FakePlcService : IPlcService
{
public Task ForceReconnectAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task<PlcSignalSnapshot> 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<bool> TestConnectionAsync(CancellationToken cancellationToken) => Task.FromResult(true);
public Task<ScanOperationResult> TriggerScanAsync(CancellationToken cancellationToken) => Task.FromResult(new ScanOperationResult());
}
private sealed class FakeSftpLookupService : ISftpLookupService
{
public SftpConnectionTestOutcome TestOutcome { get; set; } = new();
public Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken) => Task.FromResult(new SftpCheckOutcome());
public Task<SftpConnectionTestOutcome> TestConnectionAsync(SftpOptions? options, CancellationToken cancellationToken)
{
return Task.FromResult(TestOutcome);
}
}
private sealed class FakeAndonService : IAndonService
{
public Task<AndonOperationResult> RaiseAlarmAsync(AndonAlarmRequest request, CancellationToken cancellationToken)
=> Task.FromResult(new AndonOperationResult());
public Task<AndonOperationResult> TestAsync(CancellationToken cancellationToken)
=> Task.FromResult(new AndonOperationResult());
}
private sealed class FakeAppLogger<TCategoryName> : IAppLogger<TCategoryName>
{
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) { }
}
}