✨ feat: 初始化 PCB 检测 WPF 应用程序

* 创建 AxiOmron.PcbCheck 项目主框架及解决方案
* 添加 Dashboard 和系统设置页面
* 实现 Modbus TCP PLC、扫码枪、SFTP 查询等核心服务
* 集成 Andon 报警、工作流托管服务与日志配置
* 补充项目文档和 UI 设计规范
This commit is contained in:
2026-04-17 10:43:51 +08:00
parent 660ee99442
commit 49f113dcf3
46 changed files with 8042 additions and 0 deletions

View File

@@ -0,0 +1,505 @@
using System.Collections.ObjectModel;
using System.Collections.Specialized;
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;
/// <summary>
/// 初始化主窗口视图模型。
/// </summary>
/// <param name="stateStore">运行态存储。</param>
/// <param name="dispatcherService">Dispatcher 调度服务。</param>
/// <param name="workflowControlService">流程控制服务。</param>
/// <param name="config">应用配置。</param>
public MainWindowViewModel(
IAppStateStore stateStore,
IDispatcherService dispatcherService,
IWorkflowControlService workflowControlService,
AppConfig config)
{
_stateStore = stateStore ?? throw new ArgumentNullException(nameof(stateStore));
_dispatcherService = dispatcherService ?? throw new ArgumentNullException(nameof(dispatcherService));
_workflowControlService = workflowControlService ?? throw new ArgumentNullException(nameof(workflowControlService));
ArgumentNullException.ThrowIfNull(config);
_workflowOptions = config.Workflow;
Title = "Axi Omron PCB Check";
Logs = new ObservableCollection<UiLogEntry>();
RecentBoards = new ObservableCollection<BoardProcessRecord>();
Logs.CollectionChanged += OnLogsCollectionChanged;
RecentBoards.CollectionChanged += OnRecentBoardsCollectionChanged;
_stateStore.SnapshotChanged += OnSnapshotChanged;
_stateStore.LogAdded += OnLogAdded;
_stateStore.RecordAdded += OnRecordAdded;
ApplySnapshot(_stateStore.GetSnapshot());
RecalculateLogStatistics();
RecalculateProcessStatistics();
}
/// <summary>
/// 获取运行日志集合。
/// </summary>
public ObservableCollection<UiLogEntry> Logs { get; }
/// <summary>
/// 获取最近处理记录集合。
/// </summary>
public ObservableCollection<BoardProcessRecord> RecentBoards { 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>
/// 获取或设置 SFTP 状态文本。
/// </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 int _scanTryCount;
/// <summary>
/// 获取或设置 SFTP 查询次数。
/// </summary>
[ObservableProperty]
private int _sftpTryCount;
/// <summary>
/// 获取或设置结果代码。
/// </summary>
[ObservableProperty]
private ushort _resultCode;
/// <summary>
/// 获取或设置报警代码。
/// </summary>
[ObservableProperty]
private ushort _alarmCode;
/// <summary>
/// 获取或设置最近触发时间。
/// </summary>
[ObservableProperty]
private string _lastTriggeredAt = "-";
/// <summary>
/// 获取或设置最近完成时间。
/// </summary>
[ObservableProperty]
private string _lastCompletedAt = "-";
/// <summary>
/// 获取或设置是否忙碌。
/// </summary>
[ObservableProperty]
private bool _isBusy;
/// <summary>
/// 获取或设置是否存在系统故障。
/// </summary>
[ObservableProperty]
private bool _isFaulted;
/// <summary>
/// 获取或设置是否已完成。
/// </summary>
[ObservableProperty]
private bool _isDone;
/// <summary>
/// 获取或设置是否已触发报警。
/// </summary>
[ObservableProperty]
private bool _isAlarmRaised;
/// <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 int _todayErrorCount;
/// <summary>
/// 获取或设置当前活跃告警数量。
/// </summary>
[ObservableProperty]
private int _activeAlarmCount;
/// <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>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
private Task ResetAsync()
{
return _workflowControlService.ResetAsync(CancellationToken.None);
}
/// <summary>
/// 执行 PLC 重连命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
private Task ReconnectPlcAsync()
{
return _workflowControlService.ReconnectPlcAsync(CancellationToken.None);
}
/// <summary>
/// 执行扫码枪重连命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
private Task ReconnectScannerAsync()
{
return _workflowControlService.ReconnectScannerAsync(CancellationToken.None);
}
/// <summary>
/// 执行安灯测试命令。
/// </summary>
/// <returns>表示命令执行完成的任务。</returns>
[RelayCommand]
private Task TestAndonAsync()
{
return _workflowControlService.TestAndonAsync(CancellationToken.None);
}
/// <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 OpenTrackingArea()
{
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 刷新处理记录命令占位。重新计算统计并更新最后刷新时间。
/// </summary>
[RelayCommand]
private void RefreshProcessRecords()
{
RecalculateProcessStatistics();
}
/// <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;
ScanTryCount = snapshot.ScanTryCount;
SftpTryCount = snapshot.SftpTryCount;
ResultCode = snapshot.ResultCode;
AlarmCode = snapshot.AlarmCode;
LastTriggeredAt = snapshot.LastTriggeredAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
LastCompletedAt = snapshot.LastCompletedAt?.ToString("yyyy-MM-dd HH:mm:ss") ?? "-";
IsBusy = snapshot.IsBusy;
IsFaulted = snapshot.SystemFault;
IsDone = snapshot.ProcessDone;
IsAlarmRaised = snapshot.AlarmRaised;
ActiveAlarmCount = snapshot.AlarmRaised ? 1 : 0;
LastUpdatedAt = snapshot.LastUpdatedAt.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
/// 当 <see cref="IsAlarmRaised"/> 发生变化时同步活跃告警计数。
/// </summary>
/// <param name="value">最新告警状态。</param>
partial void OnIsAlarmRaisedChanged(bool value)
{
ActiveAlarmCount = value ? 1 : 0;
}
/// <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)
{
RecalculateProcessStatistics();
}
/// <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>
private void RecalculateProcessStatistics()
{
DateTime today = DateTime.Today;
int totalToday = 0;
int okToday = 0;
int ngToday = 0;
foreach (BoardProcessRecord record in RecentBoards)
{
if (record.CompletedAt.LocalDateTime.Date != today)
{
continue;
}
totalToday++;
if (record.ResultCode == (ushort)WorkflowResultCode.Passed)
{
okToday++;
}
else if (record.ResultCode != (ushort)WorkflowResultCode.Processing
&& record.ResultCode != (ushort)WorkflowResultCode.None)
{
ngToday++;
}
}
TodayProcessCount = totalToday;
TodayOkCount = okToday;
TodayNgCount = ngToday;
HasProcessRecords = RecentBoards.Count > 0;
LastProcessUpdateTime = 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 _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
}
/// <summary>
/// 处理新增日志事件。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="entry">新增日志。</param>
private async void OnLogAdded(object? sender, UiLogEntry entry)
{
await _dispatcherService.InvokeAsync(() =>
{
Logs.Insert(0, entry);
while (Logs.Count > _workflowOptions.MaxUiLogEntries)
{
Logs.RemoveAt(Logs.Count - 1);
}
}).ConfigureAwait(false);
}
/// <summary>
/// 处理新增单板记录事件。
/// </summary>
/// <param name="sender">事件源。</param>
/// <param name="record">新增单板记录。</param>
private async void OnRecordAdded(object? sender, BoardProcessRecord record)
{
await _dispatcherService.InvokeAsync(() =>
{
RecentBoards.Insert(0, record);
while (RecentBoards.Count > _workflowOptions.MaxBoardRecords)
{
RecentBoards.RemoveAt(RecentBoards.Count - 1);
}
}).ConfigureAwait(false);
}
}