✨ feat(*): 添加 PCB 检测记录的 CSV 持久化存储功能

* 新增 CsvBoardRecordRepository 实现按日期分卷的 CSV 记录存储
* 新增 RecordPersistenceModels 定义板件检测记录数据结构
* 在 WorkflowHostedService 中集成检测完成后的记录持久化
* 更新 MainWindowViewModel 支持记录查询与异常标记
* 更新 AppStateStore 添加记录存储相关状态管理
* 新增 DashboardPage UI 元素展示记录存储状态
* 更新 SystemSettingViewModel 与 appConfig 添加存储路径配置
* 注册 TimeProvider 与 IBoardRecordRepository 到 DI 容器
* 新增 CsvBoardRecordRepositoryTests 与 MainWindowViewModelTests
* 配置 Release 模式 portable PDB 并在发布时自动移除
This commit is contained in:
2026-04-19 15:39:31 +08:00
parent d70b94e904
commit 9a2211ccaa
17 changed files with 1802 additions and 89 deletions

View File

@@ -21,6 +21,7 @@ public partial class MainWindowViewModel : ObservableObject
private readonly SecurityOptions _securityOptions;
private readonly IAdminUnlockDialogService _adminUnlockDialogService;
private readonly DispatcherTimer _adminUnlockTimer;
private DateTimeOffset _lastAppliedRecordDataUpdatedAt = DateTimeOffset.MinValue;
private DateTimeOffset? _adminUnlockedUntil;
/// <summary>
@@ -62,10 +63,8 @@ public partial class MainWindowViewModel : ObservableObject
_stateStore.SnapshotChanged += OnSnapshotChanged;
_stateStore.LogAdded += OnLogAdded;
_stateStore.RecordAdded += OnRecordAdded;
ApplySnapshot(_stateStore.GetSnapshot());
RecalculateLogStatistics();
RecalculateProcessStatistics();
}
/// <summary>
@@ -225,6 +224,12 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty]
private string _lastProcessUpdateTime = "-";
/// <summary>
/// 获取或设置当前处理记录统计日期文本。
/// </summary>
[ObservableProperty]
private string _recordStatisticDateText = "-";
/// <summary>
/// 获取或设置管理员功能是否已解锁。
/// </summary>
@@ -377,7 +382,7 @@ public partial class MainWindowViewModel : ObservableObject
[RelayCommand]
private void RefreshProcessRecords()
{
RecalculateProcessStatistics();
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
@@ -399,12 +404,40 @@ public partial class MainWindowViewModel : ObservableObject
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>
@@ -450,7 +483,8 @@ public partial class MainWindowViewModel : ObservableObject
/// <param name="e">集合变化参数。</param>
private void OnRecentBoardsCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
RecalculateProcessStatistics();
HasProcessRecords = RecentBoards.Count > 0;
LastProcessUpdateTime = DateTimeOffset.Now.ToString("yyyy-MM-dd HH:mm:ss");
}
/// <summary>
@@ -488,42 +522,6 @@ public partial class MainWindowViewModel : ObservableObject
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>
@@ -531,7 +529,7 @@ public partial class MainWindowViewModel : ObservableObject
/// <param name="snapshot">最新快照。</param>
private async void OnSnapshotChanged(object? sender, RuntimeSnapshot snapshot)
{
await _dispatcherService.InvokeAsync(() => ApplySnapshot(snapshot)).ConfigureAwait(false);
await ApplySnapshotOnUiAsync(snapshot).ConfigureAwait(false);
}
/// <summary>
@@ -541,34 +539,9 @@ public partial class MainWindowViewModel : ObservableObject
/// <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);
await AppendLogOnUiAsync(entry).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);
}
/// <summary>
/// 处理管理员解锁倒计时,超时后自动恢复锁定。
/// </summary>
/// <param name="sender">事件源。</param>
@@ -641,4 +614,45 @@ public partial class MainWindowViewModel : ObservableObject
}).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 事件将其上抛为未处理异常。
}
}
}