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

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

View File

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