Files
Axi_Omron/src/AxiOmron.PcbCheck/ViewModels/MainWindowViewModel.cs
yunxiao.zhu 17839a9e76 ✨ feat(*): 更新 PLC 相关配置与结果代码类型
- 修改 appConfig.json 中 PLC 和扫描仪的 IP 地址及字节序配置
- 将多个地方的 ResultCode 类型从 short 更新为 ushort,以确保与 PLC 通信一致
- 更新相关服务和模型以适应新的 ResultCode 类型
- 调整单元测试以验证新配置和类型的正确性
2026-06-02 17:59:04 +08:00

675 lines
22 KiB
C#

using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Windows.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using AxiOmron.PcbCheck.Models;
using AxiOmron.PcbCheck.Options;
using AxiOmron.PcbCheck.Services.Interfaces;
namespace AxiOmron.PcbCheck.ViewModels;
/// <summary>
/// 提供主窗口与首页共享的运行状态、日志和命令绑定能力。
/// </summary>
public partial class MainWindowViewModel : ObservableObject
{
private readonly IAppStateStore _stateStore;
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 _lastAppliedRecordDataUpdatedAt = DateTimeOffset.MinValue;
private DateTimeOffset? _adminUnlockedUntil;
/// <summary>
/// 初始化主窗口视图模型。
/// </summary>
/// <param name="stateStore">运行态存储。</param>
/// <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,
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>();
PlcMonitorItems = new ObservableCollection<PlcMonitorItem>();
AdminUnlockStatus = "管理员功能已锁定";
Logs.CollectionChanged += OnLogsCollectionChanged;
RecentBoards.CollectionChanged += OnRecentBoardsCollectionChanged;
_stateStore.SnapshotChanged += OnSnapshotChanged;
_stateStore.LogAdded += OnLogAdded;
ApplySnapshot(_stateStore.GetSnapshot());
RecalculateLogStatistics();
}
/// <summary>
/// 获取运行日志集合。
/// </summary>
public ObservableCollection<UiLogEntry> Logs { get; }
/// <summary>
/// 获取最近处理记录集合。
/// </summary>
public ObservableCollection<BoardProcessRecord> RecentBoards { get; }
/// <summary>
/// 获取 PLC 实时监控集合。
/// </summary>
public ObservableCollection<PlcMonitorItem> PlcMonitorItems { get; }
/// <summary>
/// 获取最近运行日志集合(与 <see cref="Logs"/> 为同一集合,供 UI 绑定语义更清晰)。
/// </summary>
public ObservableCollection<UiLogEntry> RecentLogs => Logs;
/// <summary>
/// 获取最近处理记录集合(与 <see cref="RecentBoards"/> 为同一集合,供 UI 绑定语义更清晰)。
/// </summary>
public ObservableCollection<BoardProcessRecord> RecentProcessRecords => RecentBoards;
/// <summary>
/// 获取或设置主窗口标题。
/// </summary>
[ObservableProperty]
private string _title = string.Empty;
/// <summary>
/// 获取或设置 PLC 状态文本。
/// </summary>
[ObservableProperty]
private string _plcStatus = "未连接";
/// <summary>
/// 获取或设置扫码枪状态文本。
/// </summary>
[ObservableProperty]
private string _scannerStatus = "未验证";
/// <summary>
/// 获取或设置文件校验通道状态文本。
/// </summary>
[ObservableProperty]
private string _sftpStatus = "未验证";
/// <summary>
/// 获取或设置安灯状态文本。
/// </summary>
[ObservableProperty]
private string _andonStatus = "未验证";
/// <summary>
/// 获取或设置当前流程状态文本。
/// </summary>
[ObservableProperty]
private string _workflowStateText = "空闲等待";
/// <summary>
/// 获取或设置当前条码。
/// </summary>
[ObservableProperty]
private string _currentBarcode = string.Empty;
/// <summary>
/// 获取或设置当前结果描述。
/// </summary>
[ObservableProperty]
private string _resultDescription = "等待触发";
/// <summary>
/// 获取或设置当前故障信息。
/// </summary>
[ObservableProperty]
private string _faultMessage = string.Empty;
/// <summary>
/// 获取或设置结果代码。
/// </summary>
[ObservableProperty]
private ushort _resultCode;
/// <summary>
/// 获取或设置最近触发时间。
/// </summary>
[ObservableProperty]
private string _lastTriggeredAt = "-";
/// <summary>
/// 获取或设置最近完成时间。
/// </summary>
[ObservableProperty]
private string _lastCompletedAt = "-";
/// <summary>
/// 获取或设置是否忙碌。
/// </summary>
[ObservableProperty]
private bool _isBusy;
/// <summary>
/// 获取或设置最近更新时间。
/// </summary>
[ObservableProperty]
private string _lastUpdatedAt = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
/// <summary>
/// 获取或设置运行日志区的最后刷新时间文本。
/// </summary>
[ObservableProperty]
private string _lastLogUpdateTime = "-";
/// <summary>
/// 获取或设置运行日志区域是否展开;该状态仅影响首页布局,不参与业务流程。
/// </summary>
[ObservableProperty]
private bool _isLogAreaExpanded;
/// <summary>
/// 获取或设置今日异常日志条数。
/// </summary>
[ObservableProperty]
private int _todayErrorCount;
/// <summary>
/// 获取或设置最近一次异常日志的时间文本。
/// </summary>
[ObservableProperty]
private string _lastErrorTime = "-";
/// <summary>
/// 获取或设置当前是否存在处理记录,供空状态显示切换使用。
/// </summary>
[ObservableProperty]
private bool _hasProcessRecords;
/// <summary>
/// 获取或设置今日处理总数。
/// </summary>
[ObservableProperty]
private int _todayProcessCount;
/// <summary>
/// 获取或设置今日 OK 数。
/// </summary>
[ObservableProperty]
private int _todayOkCount;
/// <summary>
/// 获取或设置今日 NG 数。
/// </summary>
[ObservableProperty]
private int _todayNgCount;
/// <summary>
/// 获取或设置处理记录区的最后刷新时间文本。
/// </summary>
[ObservableProperty]
private string _lastProcessUpdateTime = "-";
/// <summary>
/// 获取或设置当前处理记录统计日期文本。
/// </summary>
[ObservableProperty]
private string _recordStatisticDateText = "-";
/// <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(CanExecute = nameof(CanExecuteManualAction))]
private Task ResetAsync()
{
return ExecuteManualActionAsync("正在执行手动复位...", () => _workflowControlService.ResetAsync(CancellationToken.None));
}
/// <summary>
/// 执行 PLC 重连命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
private Task ReconnectPlcAsync()
{
return ExecuteManualActionAsync("正在重连 PLC...", () => _workflowControlService.ReconnectPlcAsync(CancellationToken.None));
}
/// <summary>
/// 执行扫码枪重连命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
private Task ReconnectScannerAsync()
{
return ExecuteManualActionAsync("正在重连扫码枪...", () => _workflowControlService.ReconnectScannerAsync(CancellationToken.None));
}
/// <summary>
/// 执行安灯测试命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand(CanExecute = nameof(CanExecuteManualAction))]
private Task TestAndonAsync()
{
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>
[RelayCommand]
private void ShowAllLogs()
{
RecalculateLogStatistics();
}
/// <summary>
/// 仅显示错误日志命令占位。后续可接入筛选视图,当前刷新统计与时间戳。
/// </summary>
[RelayCommand]
private void ShowErrorLogs()
{
RecalculateLogStatistics();
}
/// <summary>
/// 导出运行日志命令占位。后续可接入日志文件导出逻辑。
/// </summary>
[RelayCommand]
private void ExportLogs()
{
LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 切换运行日志区域展开状态,收起或恢复上方状态概览区域。
/// </summary>
[RelayCommand]
private void ToggleLogArea()
{
IsLogAreaExpanded = !IsLogAreaExpanded;
}
/// <summary>
/// 打开追踪区命令占位。后续应由导航服务跳转至追踪页面。
/// </summary>
[RelayCommand]
private void OpenTrackingArea()
{
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 刷新处理记录命令占位。重新计算统计并更新最后刷新时间。
/// </summary>
[RelayCommand]
private void RefreshProcessRecords()
{
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 将快照应用到当前视图模型状态。
/// </summary>
/// <param name="snapshot">运行态快照。</param>
private void ApplySnapshot(RuntimeSnapshot snapshot)
{
PlcStatus = snapshot.PlcStatus;
ScannerStatus = snapshot.ScannerStatus;
SftpStatus = snapshot.SftpStatus;
AndonStatus = snapshot.AndonStatus;
WorkflowStateText = snapshot.WorkflowStateText;
CurrentBarcode = snapshot.CurrentBarcode;
ResultDescription = snapshot.ResultDescription;
FaultMessage = snapshot.FaultMessage;
ResultCode = snapshot.ResultCode;
LastTriggeredAt = snapshot.LastTriggeredAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
LastCompletedAt = snapshot.LastCompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
IsBusy = snapshot.IsBusy;
LastUpdatedAt = snapshot.LastUpdatedAt.ToString("yyyy-MM-dd HH:mm:ss");
RecordStatisticDateText = snapshot.RecordStatisticDate?.ToString("yyyy-MM-dd") ?? "-";
TodayProcessCount = snapshot.TodayProcessCount;
TodayOkCount = snapshot.TodayOkCount;
TodayNgCount = snapshot.TodayNgCount;
PlcMonitorItems.Clear();
foreach (PlcMonitorItem item in snapshot.PlcMonitorItems)
{
PlcMonitorItems.Add(item.Clone());
}
if (snapshot.RecordsLastUpdatedAt != _lastAppliedRecordDataUpdatedAt)
{
_lastAppliedRecordDataUpdatedAt = snapshot.RecordsLastUpdatedAt;
RecentBoards.Clear();
foreach (BoardProcessRecord record in snapshot.BoardRecords
.OrderByDescending(item => item.CompletedAt)
.Take(_workflowOptions.MaxBoardRecords))
{
RecentBoards.Add(new BoardProcessRecord
{
StartedAt = record.StartedAt,
CompletedAt = record.CompletedAt,
Barcode = record.Barcode,
ScanTryCount = record.ScanTryCount,
SftpTryCount = record.SftpTryCount,
ResultCode = record.ResultCode,
ResultDescription = record.ResultDescription,
ReleaseSent = record.ReleaseSent,
AlarmRaised = record.AlarmRaised,
ExceptionSummary = record.ExceptionSummary
});
}
}
}
/// <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>
/// <param name="sender">事件源。</param>
/// <param name="e">集合变化参数。</param>
private void OnLogsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RecalculateLogStatistics();
}
/// <summary>
/// 处理处理记录集合变化事件,刷新处理区统计字段。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="e">集合变化参数。</param>
private void OnRecentBoardsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
HasProcessRecords = RecentBoards.Count > 0;
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 根据当前日志集合重新计算日志区的统计信息与最后刷新时间。
/// </summary>
private void RecalculateLogStatistics()
{
DateTime today = DateTime.Today;
int errorToday = 0;
DateTimeOffset? lastErrorAt = null;
foreach (UiLogEntry entry in Logs)
{
bool isError = string.Equals(entry.Level, "Error", StringComparison.OrdinalIgnoreCase);
if (!isError)
{
continue;
}
if (entry.Timestamp.LocalDateTime.Date == today)
{
errorToday++;
}
if (lastErrorAt is null || entry.Timestamp > lastErrorAt.Value)
{
lastErrorAt = entry.Timestamp;
}
}
TodayErrorCount = errorToday;
LastErrorTime = lastErrorAt.HasValue
? lastErrorAt.Value.ToString("MM-dd HH:mm:ss")
: "-";
LastLogUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 处理运行态快照变化事件。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="snapshot">最新快照。</param>
private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
{
await ApplySnapshotOnUiAsync(snapshot).ConfigureAwait(false);
}
/// <summary>
/// 处理新增日志事件。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="entry">新增日志。</param>
private async void OnLogAdded(object? sender, UiLogEntry entry)
{
await AppendLogOnUiAsync(entry).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);
}
}
/// <summary>
/// 在 UI 线程中安全应用最新运行态快照;若应用关闭导致 Dispatcher 调度被取消,则忽略本次刷新。
/// </summary>
/// <param name="snapshot">待应用的运行态快照。</param>
/// <returns>表示刷新流程结束的任务。</returns>
private async Task ApplySnapshotOnUiAsync(RuntimeSnapshot snapshot)
{
try
{
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// 应用关闭或 Dispatcher 停止时,取消属于预期行为,避免 async void 事件将其上抛为未处理异常。
}
}
/// <summary>
/// 在 UI 线程中安全追加日志;若应用关闭导致 Dispatcher 调度被取消,则忽略本次日志刷新。
/// </summary>
/// <param name="entry">待追加的日志项。</param>
/// <returns>表示日志刷新流程结束的任务。</returns>
private async Task AppendLogOnUiAsync(UiLogEntry entry)
{
try
{
await _dispatcherService.InvokeAsync(() =>
{
Logs.Insert(0, entry);
while (Logs.Count > _workflowOptions.MaxUiLogEntries)
{
Logs.RemoveAt(Logs.Count - 1);
}
}).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
// 应用关闭或 Dispatcher 停止时,取消属于预期行为,避免 async void 事件将其上抛为未处理异常。
}
}
}