✨ 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\"\\); $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/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,8 @@
|
||||
<Solution>
|
||||
</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>
|
||||
|
||||
@@ -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
|
||||
{
|
||||
// 全局兜底日志不再向外抛出。
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -426,22 +426,69 @@
|
||||
</Grid.RowDefinitions>
|
||||
|
||||
<Grid Margin="20,20,20,16">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<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}" />
|
||||
</Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<ColumnDefinition Width="Auto" />
|
||||
<ColumnDefinition Width="12" />
|
||||
<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 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">
|
||||
<Grid.ColumnDefinitions>
|
||||
@@ -451,7 +498,7 @@
|
||||
</Grid.ColumnDefinitions>
|
||||
<Grid.RowDefinitions>
|
||||
<RowDefinition Height="Auto" />
|
||||
<RowDefinition Height="16" />
|
||||
<RowDefinition Height="10" />
|
||||
<RowDefinition Height="*" />
|
||||
</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.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" />
|
||||
|
||||
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": {
|
||||
"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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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