✨ feat(*): 添加管理员解锁与 SFTP 探活能力
* 新增管理员解锁弹窗、手动操作权限控制与倒计时状态 * 支持系统设置页测试 SFTP 连接,并在启动时执行探活 * 补充设计时服务、全局异常兜底与相关单元测试
This commit is contained in:
@@ -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\"\\); \"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\\(\\) }')",
|
"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/**)",
|
"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/**)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,8 @@
|
|||||||
<Solution>
|
<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>
|
</Solution>
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Windows;
|
using System.Windows;
|
||||||
|
using System.Windows.Threading;
|
||||||
using AxiOmron.PcbCheck.Options;
|
using AxiOmron.PcbCheck.Options;
|
||||||
using AxiOmron.PcbCheck.Services.Implementations;
|
using AxiOmron.PcbCheck.Services.Implementations;
|
||||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
using AxiOmron.PcbCheck.ViewModels;
|
using AxiOmron.PcbCheck.ViewModels;
|
||||||
using AxiOmron.PcbCheck.Views.Pages;
|
using AxiOmron.PcbCheck.Views.Pages;
|
||||||
|
using AxiOmron.PcbCheck.Views.Windows;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
@@ -19,6 +21,7 @@ namespace AxiOmron.PcbCheck;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
|
private static readonly NLog.Logger FallbackLogger = NLog.LogManager.GetCurrentClassLogger();
|
||||||
private IHost? _host;
|
private IHost? _host;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -33,6 +36,7 @@ public partial class App : Application
|
|||||||
protected override async void OnStartup(StartupEventArgs e)
|
protected override async void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnStartup(e);
|
base.OnStartup(e);
|
||||||
|
RegisterGlobalExceptionHandlers();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
Console.OutputEncoding = Encoding.UTF8;
|
Console.OutputEncoding = Encoding.UTF8;
|
||||||
@@ -53,6 +57,7 @@ public partial class App : Application
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
LogGlobalException("应用启动失败", ex);
|
||||||
MessageBox.Show($"应用启动失败: {ex.Message}", "Axi Omron PCB Check", MessageBoxButton.OK, MessageBoxImage.Error);
|
MessageBox.Show($"应用启动失败: {ex.Message}", "Axi Omron PCB Check", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
Shutdown(-1);
|
Shutdown(-1);
|
||||||
}
|
}
|
||||||
@@ -64,6 +69,7 @@ public partial class App : Application
|
|||||||
/// <param name="e">退出事件参数。</param>
|
/// <param name="e">退出事件参数。</param>
|
||||||
protected override async void OnExit(ExitEventArgs e)
|
protected override async void OnExit(ExitEventArgs e)
|
||||||
{
|
{
|
||||||
|
UnregisterGlobalExceptionHandlers();
|
||||||
if (_host is not null)
|
if (_host is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -118,13 +124,99 @@ public partial class App : Application
|
|||||||
services.AddSingleton<WorkflowHostedService>();
|
services.AddSingleton<WorkflowHostedService>();
|
||||||
services.AddSingleton<IWorkflowControlService>(sp => sp.GetRequiredService<WorkflowHostedService>());
|
services.AddSingleton<IWorkflowControlService>(sp => sp.GetRequiredService<WorkflowHostedService>());
|
||||||
services.AddHostedService(sp => sp.GetRequiredService<WorkflowHostedService>());
|
services.AddHostedService(sp => sp.GetRequiredService<WorkflowHostedService>());
|
||||||
|
services.AddSingleton<IAdminUnlockDialogService, AdminUnlockDialogService>();
|
||||||
|
|
||||||
services.AddSingleton<MainWindowViewModel>();
|
services.AddSingleton<MainWindowViewModel>();
|
||||||
services.AddSingleton<SystemSettingViewModel>();
|
services.AddSingleton<SystemSettingViewModel>();
|
||||||
services.AddSingleton<DashboardPage>();
|
services.AddSingleton<DashboardPage>();
|
||||||
services.AddSingleton<SystemSettingsPage>();
|
services.AddSingleton<SystemSettingsPage>();
|
||||||
|
services.AddTransient<AdminUnlockDialog>();
|
||||||
services.AddSingleton<MainWindow>();
|
services.AddSingleton<MainWindow>();
|
||||||
})
|
})
|
||||||
.Build();
|
.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
|
||||||
|
{
|
||||||
|
// 全局兜底日志不再向外抛出。
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -40,6 +40,7 @@ public sealed class DesignTimeAppConfigService : IAppConfigService
|
|||||||
_config.Sftp = config.Sftp;
|
_config.Sftp = config.Sftp;
|
||||||
_config.Andon = config.Andon;
|
_config.Andon = config.Andon;
|
||||||
_config.Workflow = config.Workflow;
|
_config.Workflow = config.Workflow;
|
||||||
|
_config.Security = config.Security;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -98,11 +99,13 @@ public sealed class DesignTimeAppConfigService : IAppConfigService
|
|||||||
Andon = new AndonOptions
|
Andon = new AndonOptions
|
||||||
{
|
{
|
||||||
Enable = true,
|
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",
|
Method = "POST",
|
||||||
TimeoutMs = 3000,
|
TimeoutMs = 3000,
|
||||||
StationCode = "OMRON-L01",
|
AndonTheme = "SS7_Auto_Andon",
|
||||||
StationName = "欧姆龙 PCB 检测",
|
Eid = "PR1965269806334783488",
|
||||||
|
FromSign = "IOT",
|
||||||
|
ServiceId = "4dccd822-86b2-47dd-89bd-ca472ba0d14d",
|
||||||
EnableScanFailAlarm = true,
|
EnableScanFailAlarm = true,
|
||||||
EnableFileNotFoundAlarm = true
|
EnableFileNotFoundAlarm = true
|
||||||
},
|
},
|
||||||
@@ -114,6 +117,11 @@ public sealed class DesignTimeAppConfigService : IAppConfigService
|
|||||||
RequireManualResetAfterFault = true,
|
RequireManualResetAfterFault = true,
|
||||||
MaxUiLogEntries = 200,
|
MaxUiLogEntries = 200,
|
||||||
MaxBoardRecords = 100
|
MaxBoardRecords = 100
|
||||||
|
},
|
||||||
|
Security = new SecurityOptions
|
||||||
|
{
|
||||||
|
AdminPassword = "AxiOmron@123",
|
||||||
|
AdminSessionTimeoutMinutes = 15
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 连接成功。"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using AxiOmron.PcbCheck.Options;
|
using AxiOmron.PcbCheck.Options;
|
||||||
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
using AxiOmron.PcbCheck.ViewModels;
|
using AxiOmron.PcbCheck.ViewModels;
|
||||||
|
|
||||||
namespace AxiOmron.PcbCheck.DesignTime;
|
namespace AxiOmron.PcbCheck.DesignTime;
|
||||||
@@ -12,6 +13,8 @@ public sealed class DesignTimeViewModelLocator
|
|||||||
private readonly DesignTimeDispatcherService _dispatcherService = new();
|
private readonly DesignTimeDispatcherService _dispatcherService = new();
|
||||||
private readonly DesignTimeWorkflowControlService _workflowControlService = new();
|
private readonly DesignTimeWorkflowControlService _workflowControlService = new();
|
||||||
private readonly DesignTimeAppConfigService _appConfigService = new();
|
private readonly DesignTimeAppConfigService _appConfigService = new();
|
||||||
|
private readonly DesignTimeAdminUnlockDialogService _adminUnlockDialogService = new();
|
||||||
|
private readonly DesignTimeSftpLookupService _sftpLookupService = new();
|
||||||
private MainWindowViewModel? _mainWindowViewModel;
|
private MainWindowViewModel? _mainWindowViewModel;
|
||||||
private SystemSettingViewModel? _systemSettingViewModel;
|
private SystemSettingViewModel? _systemSettingViewModel;
|
||||||
|
|
||||||
@@ -25,7 +28,7 @@ public sealed class DesignTimeViewModelLocator
|
|||||||
/// 获取系统设置设计时视图模型。
|
/// 获取系统设置设计时视图模型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SystemSettingViewModel SystemSettingViewModel
|
public SystemSettingViewModel SystemSettingViewModel
|
||||||
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService);
|
=> _systemSettingViewModel ??= new SystemSettingViewModel(_appConfigService, _sftpLookupService);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 创建首页设计时视图模型。
|
/// 创建首页设计时视图模型。
|
||||||
@@ -34,6 +37,6 @@ public sealed class DesignTimeViewModelLocator
|
|||||||
private MainWindowViewModel CreateMainWindowViewModel()
|
private MainWindowViewModel CreateMainWindowViewModel()
|
||||||
{
|
{
|
||||||
AppConfig config = _appConfigService.Load();
|
AppConfig config = _appConfigService.Load();
|
||||||
return new MainWindowViewModel(_appStateStore, _dispatcherService, _workflowControlService, config);
|
return new MainWindowViewModel(_appStateStore, _dispatcherService, _workflowControlService, config, _adminUnlockDialogService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -383,6 +383,42 @@ public sealed class SftpCheckOutcome
|
|||||||
public string ErrorMessage { get; set; } = string.Empty;
|
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>
|
||||||
/// 表示一次安灯请求。
|
/// 表示一次安灯请求。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -29,6 +29,11 @@ public sealed class AppConfig
|
|||||||
/// 获取或设置流程控制配置。
|
/// 获取或设置流程控制配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public WorkflowOptions Workflow { get; set; } = new();
|
public WorkflowOptions Workflow { get; set; } = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置安全控制配置。
|
||||||
|
/// </summary>
|
||||||
|
public SecurityOptions Security { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -217,7 +222,7 @@ public sealed class AndonOptions
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置安灯接口地址。
|
/// 获取或设置安灯接口地址。
|
||||||
/// </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>
|
/// <summary>
|
||||||
/// 获取或设置请求方法。
|
/// 获取或设置请求方法。
|
||||||
@@ -230,14 +235,24 @@ public sealed class AndonOptions
|
|||||||
public int TimeoutMs { get; set; } = 3000;
|
public int TimeoutMs { get; set; } = 3000;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置工位编码。
|
/// 获取或设置安灯主题。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string StationCode { get; set; } = "OMRON-01";
|
public string AndonTheme { get; set; } = "SS7_Auto_Andon";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 获取或设置工位名称。
|
/// 获取或设置设备唯一标识。
|
||||||
/// </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>
|
/// <summary>
|
||||||
/// 获取或设置扫码失败报警是否启用。
|
/// 获取或设置扫码失败报警是否启用。
|
||||||
@@ -291,6 +306,22 @@ public sealed class WorkflowOptions
|
|||||||
public int MaxBoardRecords { get; set; } = 100;
|
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>
|
/// <summary>
|
||||||
/// 表示 PLC 输入点位地址配置。
|
/// 表示 PLC 输入点位地址配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using System.Net.Http.Json;
|
using System.Web;
|
||||||
using AxiOmron.PcbCheck.Models;
|
using AxiOmron.PcbCheck.Models;
|
||||||
using AxiOmron.PcbCheck.Options;
|
using AxiOmron.PcbCheck.Options;
|
||||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
@@ -52,20 +52,8 @@ public sealed class AndonService : IAndonService
|
|||||||
{
|
{
|
||||||
using var client = _httpClientFactory.CreateClient(nameof(AndonService));
|
using var client = _httpClientFactory.CreateClient(nameof(AndonService));
|
||||||
client.Timeout = TimeSpan.FromMilliseconds(_options.TimeoutMs);
|
client.Timeout = TimeSpan.FromMilliseconds(_options.TimeoutMs);
|
||||||
using var message = new HttpRequestMessage(new HttpMethod(_options.Method), _options.Url)
|
var requestUri = BuildRequestUri(request);
|
||||||
{
|
using var message = new HttpRequestMessage(new HttpMethod(_options.Method), requestUri);
|
||||||
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
|
|
||||||
})
|
|
||||||
};
|
|
||||||
|
|
||||||
foreach (var header in _options.Headers)
|
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>
|
||||||
/// 发送一次测试报警请求。
|
/// 发送一次测试报警请求。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -58,6 +58,27 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
return await Task.Run(() => CheckInternal(barcode, cancellationToken), cancellationToken).ConfigureAwait(false);
|
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>
|
/// <summary>
|
||||||
/// 在同步上下文中执行 SFTP 查询。
|
/// 在同步上下文中执行 SFTP 查询。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -70,9 +91,8 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var client = CreateClient();
|
using var client = CreateClient(_options);
|
||||||
client.ConnectionInfo.Timeout = TimeSpan.FromMilliseconds(_options.ConnectTimeoutMs);
|
ConnectClient(client, _options);
|
||||||
client.Connect();
|
|
||||||
|
|
||||||
if (!client.IsConnected)
|
if (!client.IsConnected)
|
||||||
{
|
{
|
||||||
@@ -163,18 +183,39 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
/// <returns>SFTP 客户端实例。</returns>
|
/// <returns>SFTP 客户端实例。</returns>
|
||||||
private SftpClient CreateClient()
|
private SftpClient CreateClient()
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(_options.PrivateKeyPath))
|
return CreateClient(_options);
|
||||||
{
|
}
|
||||||
var privateKeyFile = string.IsNullOrWhiteSpace(_options.PrivateKeyPassphrase)
|
|
||||||
? new PrivateKeyFile(_options.PrivateKeyPath)
|
|
||||||
: new PrivateKeyFile(_options.PrivateKeyPath, _options.PrivateKeyPassphrase);
|
|
||||||
|
|
||||||
var keyAuth = new PrivateKeyAuthenticationMethod(_options.Username, privateKeyFile);
|
/// <summary>
|
||||||
var connectionInfo = new ConnectionInfo(_options.Host, _options.Port, _options.Username, keyAuth);
|
/// 根据指定配置创建 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(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>
|
/// <summary>
|
||||||
@@ -197,4 +238,80 @@ public sealed class SftpLookupService : ISftpLookupService
|
|||||||
{
|
{
|
||||||
return path.Replace('\\', '/').TrimEnd('/');
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true);
|
_appLogger.LogInformation("流程服务已启动,等待 PLC 到位信号。", true);
|
||||||
|
await ProbeSftpOnStartupAsync(stoppingToken).ConfigureAwait(false);
|
||||||
PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位");
|
PublishRuntimeState(WorkflowState.Idle, "等待 PCB 到位");
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
@@ -113,6 +114,50 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
await CancelActiveWorkflowAsync(CancellationToken.None).ConfigureAwait(false);
|
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>
|
||||||
/// 手动复位流程状态。
|
/// 手动复位流程状态。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -123,15 +168,31 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await CancelActiveWorkflowAsync(cancellationToken).ConfigureAwait(false);
|
try
|
||||||
ResetProcessStateCore();
|
|
||||||
await WritePlcStateAsync(state =>
|
|
||||||
{
|
{
|
||||||
ResetResultBits(state);
|
await CancelActiveWorkflowAsync(cancellationToken).ConfigureAwait(false);
|
||||||
state.FlowStateCode = WorkflowState.Idle.ToFlowStateCode();
|
ResetProcessStateCore();
|
||||||
}, cancellationToken).ConfigureAwait(false);
|
await WritePlcStateAsync(state =>
|
||||||
PublishRuntimeState(WorkflowState.Idle, "已复位,等待 PCB 到位");
|
{
|
||||||
_appLogger.LogInformation("已执行流程复位。", true);
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -149,9 +210,25 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _plcService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false);
|
try
|
||||||
UpdateSnapshot(snapshot => snapshot.PlcStatus = "已重连");
|
{
|
||||||
_appLogger.LogInformation("PLC 已手动重连。", true);
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -169,9 +246,25 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _scannerService.ForceReconnectAsync(cancellationToken).ConfigureAwait(false);
|
try
|
||||||
UpdateSnapshot(snapshot => snapshot.ScannerStatus = "已重连");
|
{
|
||||||
_appLogger.LogInformation("扫码枪已手动重连。", true);
|
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
|
finally
|
||||||
{
|
{
|
||||||
@@ -189,10 +282,26 @@ public sealed class WorkflowHostedService : BackgroundService, IWorkflowControlS
|
|||||||
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _manualActionLock.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var result = await _andonService.TestAsync(cancellationToken).ConfigureAwait(false);
|
try
|
||||||
var status = result.IsSuccess ? "测试成功" : $"测试失败: {result.ErrorMessage}";
|
{
|
||||||
UpdateSnapshot(snapshot => snapshot.AndonStatus = status);
|
var result = await _andonService.TestAsync(cancellationToken).ConfigureAwait(false);
|
||||||
_appLogger.LogInformation($"安灯接口手动测试结果: {status}", true);
|
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
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -158,6 +158,14 @@ public interface ISftpLookupService
|
|||||||
/// <param name="cancellationToken">取消令牌。</param>
|
/// <param name="cancellationToken">取消令牌。</param>
|
||||||
/// <returns>文件校验结果。</returns>
|
/// <returns>文件校验结果。</returns>
|
||||||
Task<SftpCheckOutcome> CheckFileAsync(string barcode, CancellationToken cancellationToken);
|
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>
|
/// <summary>
|
||||||
@@ -215,6 +223,20 @@ public interface IWorkflowControlService
|
|||||||
Task TestAndonAsync(CancellationToken cancellationToken);
|
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>
|
/// <summary>
|
||||||
/// 定义运行态快照与 UI 事件分发能力。
|
/// 定义运行态快照与 UI 事件分发能力。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Collections.ObjectModel;
|
using System.Collections.ObjectModel;
|
||||||
using System.Collections.Specialized;
|
using System.Collections.Specialized;
|
||||||
|
using System.Windows.Threading;
|
||||||
using CommunityToolkit.Mvvm.ComponentModel;
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using AxiOmron.PcbCheck.Models;
|
using AxiOmron.PcbCheck.Models;
|
||||||
@@ -17,6 +18,10 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
private readonly IDispatcherService _dispatcherService;
|
private readonly IDispatcherService _dispatcherService;
|
||||||
private readonly IWorkflowControlService _workflowControlService;
|
private readonly IWorkflowControlService _workflowControlService;
|
||||||
private readonly WorkflowOptions _workflowOptions;
|
private readonly WorkflowOptions _workflowOptions;
|
||||||
|
private readonly SecurityOptions _securityOptions;
|
||||||
|
private readonly IAdminUnlockDialogService _adminUnlockDialogService;
|
||||||
|
private readonly DispatcherTimer _adminUnlockTimer;
|
||||||
|
private DateTimeOffset? _adminUnlockedUntil;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 初始化主窗口视图模型。
|
/// 初始化主窗口视图模型。
|
||||||
@@ -25,21 +30,31 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
/// <param name="dispatcherService">Dispatcher 调度服务。</param>
|
/// <param name="dispatcherService">Dispatcher 调度服务。</param>
|
||||||
/// <param name="workflowControlService">流程控制服务。</param>
|
/// <param name="workflowControlService">流程控制服务。</param>
|
||||||
/// <param name="config">应用配置。</param>
|
/// <param name="config">应用配置。</param>
|
||||||
|
/// <param name="adminUnlockDialogService">管理员解锁弹窗服务。</param>
|
||||||
public MainWindowViewModel(
|
public MainWindowViewModel(
|
||||||
IAppStateStore stateStore,
|
IAppStateStore stateStore,
|
||||||
IDispatcherService dispatcherService,
|
IDispatcherService dispatcherService,
|
||||||
IWorkflowControlService workflowControlService,
|
IWorkflowControlService workflowControlService,
|
||||||
AppConfig config)
|
AppConfig config,
|
||||||
|
IAdminUnlockDialogService adminUnlockDialogService)
|
||||||
{
|
{
|
||||||
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
|
||||||
_dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService));
|
_dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService));
|
||||||
_workflowControlService = workflowControlService ?? throw new ArgumentNullException(nameof(workflowControlService));
|
_workflowControlService = workflowControlService ?? throw new ArgumentNullException(nameof(workflowControlService));
|
||||||
ArgumentNullException.ThrowIfNull(config);
|
ArgumentNullException.ThrowIfNull(config);
|
||||||
|
_adminUnlockDialogService = adminUnlockDialogService ?? throw new ArgumentNullException(nameof(adminUnlockDialogService));
|
||||||
_workflowOptions = config.Workflow;
|
_workflowOptions = config.Workflow;
|
||||||
|
_securityOptions = config.Security;
|
||||||
|
_adminUnlockTimer = new DispatcherTimer
|
||||||
|
{
|
||||||
|
Interval = TimeSpan.FromSeconds(1)
|
||||||
|
};
|
||||||
|
_adminUnlockTimer.Tick += OnAdminUnlockTimerTick;
|
||||||
|
|
||||||
Title = "Axi Omron PCB Check";
|
Title = "Axi Omron PCB Check";
|
||||||
Logs = new ObservableCollection<UiLogEntry>();
|
Logs = new ObservableCollection<UiLogEntry>();
|
||||||
RecentBoards = new ObservableCollection<BoardProcessRecord>();
|
RecentBoards = new ObservableCollection<BoardProcessRecord>();
|
||||||
|
AdminUnlockStatus = "管理员功能已锁定";
|
||||||
|
|
||||||
Logs.CollectionChanged += OnLogsCollectionChanged;
|
Logs.CollectionChanged += OnLogsCollectionChanged;
|
||||||
RecentBoards.CollectionChanged += OnRecentBoardsCollectionChanged;
|
RecentBoards.CollectionChanged += OnRecentBoardsCollectionChanged;
|
||||||
@@ -246,44 +261,114 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _lastProcessUpdateTime = "-";
|
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>
|
||||||
/// 执行手动复位命令。
|
/// 执行手动复位命令。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>表示命令执行完成的任务。</returns>
|
/// <returns>表示命令执行完成的任务。</returns>
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
|
||||||
private Task ResetAsync()
|
private Task ResetAsync()
|
||||||
{
|
{
|
||||||
return _workflowControlService.ResetAsync(CancellationToken.None);
|
return ExecuteManualActionAsync("正在执行手动复位...", () => _workflowControlService.ResetAsync(CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 执行 PLC 重连命令。
|
/// 执行 PLC 重连命令。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>表示命令执行完成的任务。</returns>
|
/// <returns>表示命令执行完成的任务。</returns>
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
|
||||||
private Task ReconnectPlcAsync()
|
private Task ReconnectPlcAsync()
|
||||||
{
|
{
|
||||||
return _workflowControlService.ReconnectPlcAsync(CancellationToken.None);
|
return ExecuteManualActionAsync("正在重连 PLC...", () => _workflowControlService.ReconnectPlcAsync(CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 执行扫码枪重连命令。
|
/// 执行扫码枪重连命令。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>表示命令执行完成的任务。</returns>
|
/// <returns>表示命令执行完成的任务。</returns>
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
|
||||||
private Task ReconnectScannerAsync()
|
private Task ReconnectScannerAsync()
|
||||||
{
|
{
|
||||||
return _workflowControlService.ReconnectScannerAsync(CancellationToken.None);
|
return ExecuteManualActionAsync("正在重连扫码枪...", () => _workflowControlService.ReconnectScannerAsync(CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 执行安灯测试命令。
|
/// 执行安灯测试命令。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <returns>表示命令执行完成的任务。</returns>
|
/// <returns>表示命令执行完成的任务。</returns>
|
||||||
[RelayCommand]
|
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
|
||||||
private Task TestAndonAsync()
|
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>
|
/// <summary>
|
||||||
@@ -368,6 +453,32 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
ActiveAlarmCount = value ? 1 : 0;
|
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>
|
||||||
/// 处理日志集合变化事件,刷新日志区统计字段。
|
/// 处理日志集合变化事件,刷新日志区统计字段。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -502,4 +613,78 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}).ConfigureAwait(false);
|
}).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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
|
|||||||
using CommunityToolkit.Mvvm.Input;
|
using CommunityToolkit.Mvvm.Input;
|
||||||
using AxiOmron.PcbCheck.Options;
|
using AxiOmron.PcbCheck.Options;
|
||||||
using AxiOmron.PcbCheck.Services.Interfaces;
|
using AxiOmron.PcbCheck.Services.Interfaces;
|
||||||
|
using AxiOmron.PcbCheck.Models;
|
||||||
|
|
||||||
namespace AxiOmron.PcbCheck.ViewModels;
|
namespace AxiOmron.PcbCheck.ViewModels;
|
||||||
|
|
||||||
@@ -11,14 +12,17 @@ namespace AxiOmron.PcbCheck.ViewModels;
|
|||||||
public partial class SystemSettingViewModel : ObservableObject
|
public partial class SystemSettingViewModel : ObservableObject
|
||||||
{
|
{
|
||||||
private readonly IAppConfigService _appConfigService;
|
private readonly IAppConfigService _appConfigService;
|
||||||
|
private readonly ISftpLookupService _sftpLookupService;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 初始化系统设置视图模型。
|
/// 初始化系统设置视图模型。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="appConfigService">配置读写服务。</param>
|
/// <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));
|
_appConfigService = appConfigService ?? throw new ArgumentNullException(nameof(appConfigService));
|
||||||
|
_sftpLookupService = sftpLookupService ?? throw new ArgumentNullException(nameof(sftpLookupService));
|
||||||
EditableConfig = _appConfigService.Load();
|
EditableConfig = _appConfigService.Load();
|
||||||
ConfigPath = _appConfigService.GetConfigPath();
|
ConfigPath = _appConfigService.GetConfigPath();
|
||||||
StatusMessage = "已加载配置。";
|
StatusMessage = "已加载配置。";
|
||||||
@@ -42,6 +46,13 @@ public partial class SystemSettingViewModel : ObservableObject
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private string _configPath = string.Empty;
|
private string _configPath = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 获取或设置当前是否正在测试 SFTP 连接。
|
||||||
|
/// </summary>
|
||||||
|
[NotifyCanExecuteChangedFor(nameof(TestSftpConnectionCommand))]
|
||||||
|
[ObservableProperty]
|
||||||
|
private bool _isTestingSftpConnection;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 保存当前配置。
|
/// 保存当前配置。
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -77,4 +88,32 @@ public partial class SystemSettingViewModel : ObservableObject
|
|||||||
{
|
{
|
||||||
ReloadConfig();
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -434,13 +434,60 @@
|
|||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
<ColumnDefinition Width="12" />
|
<ColumnDefinition Width="12" />
|
||||||
<ColumnDefinition Width="Auto" />
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="12" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="16" />
|
||||||
<ColumnDefinition Width="*" />
|
<ColumnDefinition Width="*" />
|
||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
<Button Grid.Column="0" Style="{StaticResource PrimaryActionButtonStyle}" Content="手动复位" Command="{Binding ResetCommand}" />
|
<Button Grid.Column="0"
|
||||||
<Button Grid.Column="2" Style="{StaticResource PrimaryActionButtonStyle}" Content="重连 PLC" Command="{Binding ReconnectPlcCommand}" />
|
Style="{StaticResource ToolbarButtonStyle}"
|
||||||
<Button Grid.Column="4" Style="{StaticResource PrimaryActionButtonStyle}" Content="重连扫码枪" Command="{Binding ReconnectScannerCommand}" />
|
Content="管理员解锁"
|
||||||
<Button Grid.Column="6" Style="{StaticResource ToolbarButtonStyle}" Content="测试安灯接口" Command="{Binding TestAndonCommand}" />
|
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 Grid.Row="1" Margin="20,0,20,20">
|
<Grid Grid.Row="1" Margin="20,0,20,20">
|
||||||
@@ -451,7 +498,7 @@
|
|||||||
</Grid.ColumnDefinitions>
|
</Grid.ColumnDefinitions>
|
||||||
<Grid.RowDefinitions>
|
<Grid.RowDefinitions>
|
||||||
<RowDefinition Height="Auto" />
|
<RowDefinition Height="Auto" />
|
||||||
<RowDefinition Height="16" />
|
<RowDefinition Height="10" />
|
||||||
<RowDefinition Height="*" />
|
<RowDefinition Height="*" />
|
||||||
</Grid.RowDefinitions>
|
</Grid.RowDefinitions>
|
||||||
|
|
||||||
|
|||||||
@@ -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.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.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}" />
|
<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>
|
</StackPanel>
|
||||||
</ScrollViewer>
|
</ScrollViewer>
|
||||||
</TabItem>
|
</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="安灯 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="请求方法" 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="超时(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.AndonTheme, 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="设备 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.EnableScanFailAlarm}" Margin="0,6,0,0" />
|
||||||
<CheckBox Content="文件未找到时报警" IsChecked="{Binding EditableConfig.Andon.EnableFileNotFoundAlarm}" 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" />
|
<CheckBox Content="要求 PLC Ready" IsChecked="{Binding EditableConfig.Workflow.RequirePlcReady}" Margin="0,6,0,0" />
|
||||||
|
|||||||
146
src/AxiOmron.PcbCheck/Views/Windows/AdminUnlockDialog.xaml
Normal file
146
src/AxiOmron.PcbCheck/Views/Windows/AdminUnlockDialog.xaml
Normal 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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,9 +7,17 @@
|
|||||||
},
|
},
|
||||||
"Sftp": {
|
"Sftp": {
|
||||||
"Host": "127.0.0.1",
|
"Host": "127.0.0.1",
|
||||||
"RootPath": "/tmp/pcb"
|
"RootPath": "/home/uaesadmin/",
|
||||||
},
|
},
|
||||||
"Andon": {
|
"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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,11 +50,11 @@
|
|||||||
"Sftp": {
|
"Sftp": {
|
||||||
"Host": "127.0.0.1",
|
"Host": "127.0.0.1",
|
||||||
"Port": 22,
|
"Port": 22,
|
||||||
"Username": "user",
|
"Username": "uaesadmin",
|
||||||
"Password": "",
|
"Password": "uaes,123",
|
||||||
"PrivateKeyPath": "",
|
"PrivateKeyPath": "",
|
||||||
"PrivateKeyPassphrase": "",
|
"PrivateKeyPassphrase": "",
|
||||||
"RootPath": "/pcb",
|
"RootPath": "/home/uaesadmin/",
|
||||||
"FileNamePattern": "${barcode}.txt",
|
"FileNamePattern": "${barcode}.txt",
|
||||||
"RetryIntervalSeconds": 2,
|
"RetryIntervalSeconds": 2,
|
||||||
"MaxRetryCount": 3,
|
"MaxRetryCount": 3,
|
||||||
@@ -62,15 +62,17 @@
|
|||||||
},
|
},
|
||||||
"Andon": {
|
"Andon": {
|
||||||
"Enable": true,
|
"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",
|
"Method": "POST",
|
||||||
"TimeoutMs": 3000,
|
"TimeoutMs": 3000,
|
||||||
"StationCode": "OMRON-01",
|
"AndonTheme": "SS7_Auto_Andon",
|
||||||
"StationName": "PCB目检工位",
|
"Eid": "PR1965269806334783488",
|
||||||
|
"FromSign": "IOT",
|
||||||
|
"ServiceId": "4dccd822-86b2-47dd-89bd-ca472ba0d14d",
|
||||||
"EnableScanFailAlarm": true,
|
"EnableScanFailAlarm": true,
|
||||||
"EnableFileNotFoundAlarm": false,
|
"EnableFileNotFoundAlarm": false,
|
||||||
"Headers": {
|
"Headers": {
|
||||||
"Content-Type": "application/json"
|
"Accept": "application/json"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Workflow": {
|
"Workflow": {
|
||||||
@@ -80,5 +82,9 @@
|
|||||||
"RequireManualResetAfterFault": true,
|
"RequireManualResetAfterFault": true,
|
||||||
"MaxUiLogEntries": 200,
|
"MaxUiLogEntries": 200,
|
||||||
"MaxBoardRecords": 100
|
"MaxBoardRecords": 100
|
||||||
|
},
|
||||||
|
"Security": {
|
||||||
|
"AdminPassword": "AxiOmron@123",
|
||||||
|
"AdminSessionTimeoutMinutes": 15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj
Normal file
26
tests/AxiOmron.PcbCheck.Tests/AxiOmron.PcbCheck.Tests.csproj
Normal 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>
|
||||||
95
tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs
Normal file
95
tests/AxiOmron.PcbCheck.Tests/SystemSettingViewModelTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
86
tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs
Normal file
86
tests/AxiOmron.PcbCheck.Tests/WorkflowHostedServiceTests.cs
Normal 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) { }
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user