CNC结果预览

This commit is contained in:
zhengxuan.zhang
2026-05-12 00:29:21 +08:00
parent cfdfe330a5
commit 8b29285d03
12 changed files with 1995 additions and 1 deletions
@@ -0,0 +1,392 @@
// Feature: cnc-inspection-report-viewer
// Task 4.2: Unit tests for LoadDetailAsync selection-change and cancellation paths
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.InspectionResults;
using XplorePlane.ViewModels.Cnc;
using Xunit;
namespace XplorePlane.Tests.ViewModels
{
public class InspectionReportViewerViewModelTests
{
// ── Helpers ──────────────────────────────────────────────────────────────────
private static InspectionReportViewerViewModel CreateVm(
Mock<IInspectionResultStore> mockStore = null,
Mock<ILoggerService> mockLogger = null,
Mock<IXpDataPathService> mockDataPathService = null)
{
mockStore ??= new Mock<IInspectionResultStore>();
mockLogger ??= new Mock<ILoggerService>();
mockDataPathService ??= new Mock<IXpDataPathService>();
mockLogger.Setup(l => l.ForModule<InspectionReportViewerViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.DataPath).Returns(System.IO.Path.GetTempPath());
// Setup default QueryRunsAsync to return empty list
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InspectionRunRecord>());
return new InspectionReportViewerViewModel(
mockStore.Object,
mockLogger.Object,
mockDataPathService.Object);
}
private static InspectionRunDetail CreateMockDetail(Guid runId, string programName = "TestProgram")
{
return new InspectionRunDetail
{
Run = new InspectionRunRecord
{
RunId = runId,
ProgramName = programName,
WorkpieceId = "WP001",
SerialNumber = "SN001",
StartedAt = DateTime.UtcNow,
CompletedAt = DateTime.UtcNow.AddMinutes(5),
OverallPass = true,
Status = InspectionRunStatus.Completed,
NodeCount = 2
},
Nodes = new List<InspectionNodeResult>
{
new InspectionNodeResult
{
NodeId = Guid.NewGuid(),
NodeIndex = 0,
NodeName = "Node_0",
PipelineName = "Pipeline_0",
NodePass = true,
Status = InspectionNodeStatus.Completed,
DurationMs = 100
},
new InspectionNodeResult
{
NodeId = Guid.NewGuid(),
NodeIndex = 1,
NodeName = "Node_1",
PipelineName = "Pipeline_1",
NodePass = true,
Status = InspectionNodeStatus.Completed,
DurationMs = 150
}
},
Metrics = new List<InspectionMetricResult>(),
Assets = new List<InspectionAssetRecord>(),
PipelineSnapshots = new List<PipelineExecutionSnapshot>(),
Events = new List<InspectionRunEvent>()
};
}
// ── Task 4.2: LoadDetailAsync selection-change and cancellation paths ────────
/// <summary>
/// Test: Switching SelectedRun cancels prior load
/// Requirements: 4.8, 11.5
/// </summary>
[Fact]
public async Task LoadDetailAsync_WhenSelectedRunChanges_CancelsPriorLoad()
{
// Arrange
var runId1 = Guid.NewGuid();
var runId2 = Guid.NewGuid();
var detail1 = CreateMockDetail(runId1, "Program1");
var detail2 = CreateMockDetail(runId2, "Program2");
var tcs1 = new TaskCompletionSource<InspectionRunDetail>();
var tcs2 = new TaskCompletionSource<InspectionRunDetail>();
var mockStore = new Mock<IInspectionResultStore>();
// Setup QueryRunsAsync to return two runs
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InspectionRunRecord>
{
detail1.Run,
detail2.Run
});
var callCount = 0;
mockStore
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.Returns<Guid, CancellationToken>((id, ct) =>
{
callCount++;
if (callCount == 1)
{
// First call - register cancellation callback
ct.Register(() => tcs1.TrySetCanceled());
return tcs1.Task;
}
else
{
// Second call - complete immediately
tcs2.SetResult(detail2);
return tcs2.Task;
}
});
var vm = CreateVm(mockStore);
// Wait for initial load to complete
await Task.Delay(100);
// Act
// Select first run (starts loading)
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail1.Run);
});
// Give the first load a moment to start
await Task.Delay(50);
// Select second run (should cancel first load)
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail2.Run);
});
// Wait for second load to complete
await Task.Delay(200);
// Assert
Assert.True(tcs1.Task.IsCanceled, "First load should have been cancelled");
Assert.True(tcs2.Task.IsCompletedSuccessfully, "Second load should have completed");
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.Equal("Program2", vm.DetailRun?.ProgramName);
Assert.Equal(2, vm.DetailNodes.Count);
});
}
/// <summary>
/// Test: Cancellation is silent (no error displayed)
/// Requirements: 11.5
/// </summary>
[Fact]
public async Task LoadDetailAsync_WhenCancelled_IsSilent()
{
// Arrange
var runId = Guid.NewGuid();
var detail = CreateMockDetail(runId);
var tcs = new TaskCompletionSource<InspectionRunDetail>();
var mockStore = new Mock<IInspectionResultStore>();
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
mockStore
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.Returns<Guid, CancellationToken>((id, ct) =>
{
ct.Register(() => tcs.TrySetCanceled());
return tcs.Task;
});
var vm = CreateVm(mockStore);
// Wait for initial load
await Task.Delay(100);
// Act
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
});
// Give it a moment to start loading
await Task.Delay(50);
// Cancel by selecting null
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = null;
});
// Wait for cancellation to propagate
await Task.Delay(200);
// Assert - cancellation should be silent (no error state)
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.False(vm.HasDetailError, "HasDetailError should be false after cancellation");
Assert.Null(vm.DetailError);
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after cancellation");
});
}
/// <summary>
/// Test: Error sets HasDetailError
/// Requirements: 4.8
/// </summary>
[Fact]
public async Task LoadDetailAsync_WhenErrorOccurs_SetsHasDetailError()
{
// Arrange
var runId = Guid.NewGuid();
var detail = CreateMockDetail(runId);
var expectedErrorMessage = "Database connection failed";
var mockStore = new Mock<IInspectionResultStore>();
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
mockStore
.Setup(s => s.GetRunDetailAsync(It.IsAny<Guid>(), It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException(expectedErrorMessage));
var vm = CreateVm(mockStore);
// Wait for initial load
await Task.Delay(100);
// Act
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
});
// Wait for error to be processed
await Task.Delay(200);
// Assert
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.True(vm.HasDetailError, "HasDetailError should be true after error");
Assert.NotNull(vm.DetailError);
Assert.Contains(expectedErrorMessage, vm.DetailError);
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after error");
});
}
/// <summary>
/// Test: Successful load clears error state and populates detail
/// Requirements: 4.8
/// </summary>
[Fact]
public async Task LoadDetailAsync_WhenSuccessful_PopulatesDetailAndClearsErrors()
{
// Arrange
var runId = Guid.NewGuid();
var detail = CreateMockDetail(runId, "SuccessProgram");
var mockStore = new Mock<IInspectionResultStore>();
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InspectionRunRecord> { detail.Run });
mockStore
.Setup(s => s.GetRunDetailAsync(runId, It.IsAny<CancellationToken>()))
.ReturnsAsync(detail);
var vm = CreateVm(mockStore);
// Wait for initial load
await Task.Delay(100);
// Act
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail.Run);
});
// Wait for load to complete
await Task.Delay(200);
// Assert
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.False(vm.HasDetailError, "HasDetailError should be false after successful load");
Assert.Null(vm.DetailError);
Assert.False(vm.IsDetailLoading, "IsDetailLoading should be false after load completes");
Assert.NotNull(vm.DetailRun);
Assert.Equal("SuccessProgram", vm.DetailRun.ProgramName);
Assert.Equal(2, vm.DetailNodes.Count);
Assert.Equal("Node_0", vm.DetailNodes[0].NodeName);
Assert.Equal("Node_1", vm.DetailNodes[1].NodeName);
});
}
/// <summary>
/// Test: LoadDetailAsync clears previous detail state before loading
/// Requirements: 4.8
/// </summary>
[Fact]
public async Task LoadDetailAsync_ClearsPreviousDetailState()
{
// Arrange
var runId1 = Guid.NewGuid();
var runId2 = Guid.NewGuid();
var detail1 = CreateMockDetail(runId1, "Program1");
var detail2 = CreateMockDetail(runId2, "Program2");
var mockStore = new Mock<IInspectionResultStore>();
mockStore
.Setup(s => s.QueryRunsAsync(It.IsAny<InspectionRunQuery>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<InspectionRunRecord> { detail1.Run, detail2.Run });
mockStore
.Setup(s => s.GetRunDetailAsync(runId1, It.IsAny<CancellationToken>()))
.ReturnsAsync(detail1);
mockStore
.Setup(s => s.GetRunDetailAsync(runId2, It.IsAny<CancellationToken>()))
.ReturnsAsync(detail2);
var vm = CreateVm(mockStore);
// Wait for initial load
await Task.Delay(100);
// Act - Load first detail
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail1.Run);
});
await Task.Delay(200);
// Verify first detail is loaded
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.Equal("Program1", vm.DetailRun?.ProgramName);
});
// Load second detail
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
vm.SelectedRun = new InspectionRunRowViewModel(detail2.Run);
});
await Task.Delay(200);
// Assert - second detail should replace first
await System.Windows.Application.Current.Dispatcher.InvokeAsync(() =>
{
Assert.Equal("Program2", vm.DetailRun?.ProgramName);
Assert.Equal(2, vm.DetailNodes.Count);
Assert.All(vm.DetailNodes, node => Assert.Contains("Node_", node.NodeName));
});
}
}
}
+5 -1
View File
@@ -1,11 +1,15 @@
<Application x:Class="XplorePlane.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:XplorePlane.Converters">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<!-- NetCore (Xaml-included) 版本已内置主题,无需手动加载 -->
</ResourceDictionary.MergedDictionaries>
<!-- Value Converters -->
<converters:PassFailColorConverter x:Key="PassFailColorConverter" />
</ResourceDictionary>
</Application.Resources>
</Application>
@@ -0,0 +1,55 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
using XplorePlane.Models;
namespace XplorePlane.Converters
{
/// <summary>
/// 检测运行事件类型到图标颜色转换器 | Inspection run event type to icon color converter
/// </summary>
public class EventTypeIconConverter : IValueConverter
{
// 预定义颜色画刷(避免重复创建)| Pre-defined color brushes (avoid repeated creation)
private static readonly Brush BlueColor = new SolidColorBrush(Color.FromRgb(0x15, 0x65, 0xC0)); // #1565C0
private static readonly Brush GreenColor = new SolidColorBrush(Color.FromRgb(0x2E, 0x7D, 0x32)); // #2E7D32
private static readonly Brush RedColor = new SolidColorBrush(Color.FromRgb(0xC6, 0x28, 0x28)); // #C62828
private static readonly Brush OrangeColor = new SolidColorBrush(Color.FromRgb(0xE6, 0x51, 0x00)); // #E65100
private static readonly Brush GrayColor = new SolidColorBrush(Color.FromRgb(0x9E, 0x9E, 0x9E)); // #9E9E9E
static EventTypeIconConverter()
{
// 冻结画刷以提高性能 | Freeze brushes for better performance
BlueColor.Freeze();
GreenColor.Freeze();
RedColor.Freeze();
OrangeColor.Freeze();
GrayColor.Freeze();
}
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is InspectionRunEventType eventType)
{
return eventType switch
{
InspectionRunEventType.RunStarted => BlueColor,
InspectionRunEventType.RunCompleted => BlueColor,
InspectionRunEventType.NodeStarted => GreenColor,
InspectionRunEventType.NodeCompleted => GreenColor,
InspectionRunEventType.NodeFailed => RedColor,
InspectionRunEventType.RunError => RedColor,
InspectionRunEventType.RunStopped => OrangeColor,
_ => GrayColor
};
}
return GrayColor;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
@@ -0,0 +1,26 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace XplorePlane.Converters
{
/// <summary>
/// 将 null 或空字符串转换为占位符 "—",否则原样返回 | Converts null or empty string to placeholder "—", otherwise pass-through
/// </summary>
public class NullToPlaceholderConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value == null || (value is string str && string.IsNullOrEmpty(str)))
{
return "—";
}
return value;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotSupportedException();
}
}
}
@@ -0,0 +1,40 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
namespace XplorePlane.Converters
{
/// <summary>
/// Converts a boolean pass/fail value to a color brush.
/// True (Pass) → Green (#2E7D32), False (Fail) → Red (#C62828)
/// </summary>
public class PassFailColorConverter : IValueConverter
{
private static readonly Brush PassBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2E7D32"));
private static readonly Brush FailBrush = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#C62828"));
static PassFailColorConverter()
{
// Freeze brushes for better performance
PassBrush.Freeze();
FailBrush.Freeze();
}
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is bool pass)
{
return pass ? PassBrush : FailBrush;
}
// Return transparent brush for null or invalid values
return Brushes.Transparent;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException("PassFailColorConverter does not support two-way binding.");
}
}
}
@@ -0,0 +1,34 @@
using System;
using System.Globalization;
using System.Windows.Data;
using System.Windows.Media;
using XplorePlane.Models;
namespace XplorePlane.Converters
{
/// <summary>
/// 检测运行状态到颜色转换器 | Inspection run status to color converter
/// Error → 红色 (#C62828), Stopped → 橙色 (#E65100), 其他 → 透明 | Error → Red (#C62828), Stopped → Orange (#E65100), Others → Transparent
/// </summary>
public class RunStatusColorConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is InspectionRunStatus status)
{
return status switch
{
InspectionRunStatus.Error => new SolidColorBrush(Color.FromRgb(0xC6, 0x28, 0x28)),
InspectionRunStatus.Stopped => new SolidColorBrush(Color.FromRgb(0xE6, 0x51, 0x00)),
_ => Brushes.Transparent
};
}
return Brushes.Transparent;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
throw new NotImplementedException();
}
}
}
+39
View File
@@ -0,0 +1,39 @@
using System;
using System.Threading.Tasks;
using System.Windows.Media.Imaging;
namespace XplorePlane.Helpers
{
/// <summary>
/// 图像加载辅助工具,提供安全的异步 BMP 图像加载功能
/// </summary>
public static class ImageLoader
{
/// <summary>
/// 在后台线程安全加载 BMP 图像
/// </summary>
/// <param name="absolutePath">图像文件的绝对路径</param>
/// <returns>成功时返回冻结的 BitmapSource,失败时返回 null</returns>
public static async Task<BitmapSource> LoadBitmapSafeAsync(string absolutePath)
{
return await Task.Run(() =>
{
try
{
var bitmap = new BitmapImage();
bitmap.BeginInit();
bitmap.UriSource = new Uri(absolutePath, UriKind.Absolute);
bitmap.CacheOption = BitmapCacheOption.OnLoad;
bitmap.EndInit();
bitmap.Freeze(); // 允许跨线程访问
return (BitmapSource)bitmap;
}
catch
{
// 任何异常都返回 null,由调用方处理
return null;
}
});
}
}
}
@@ -0,0 +1,107 @@
using Prism.Mvvm;
using System.Windows.Media;
using XplorePlane.Models;
namespace XplorePlane.ViewModels.Cnc
{
/// <summary>
/// 检测指标行 ViewModel,将 InspectionMetricResult 模型封装为可绑定的 WPF ViewModel
/// Inspection metric row ViewModel that wraps an InspectionMetricResult model into a bindable WPF ViewModel
/// </summary>
public class InspectionMetricRowViewModel : BindableBase
{
private readonly InspectionMetricResult _model;
/// <summary>
/// 构造函数,从 InspectionMetricResult 模型初始化 ViewModel
/// Constructor that initializes the ViewModel from an InspectionMetricResult model
/// </summary>
public InspectionMetricRowViewModel(InspectionMetricResult model)
{
_model = model;
}
/// <summary>底层检测指标模型(只读)| Underlying inspection metric model (read-only)</summary>
public InspectionMetricResult Model => _model;
/// <summary>指标名称 | Metric name</summary>
public string MetricName => _model.MetricName;
/// <summary>实测值 | Measured metric value</summary>
public double MetricValue => _model.MetricValue;
/// <summary>单位 | Unit of measurement</summary>
public string Unit => _model.Unit;
/// <summary>下限 | Lower limit (nullable)</summary>
public double? LowerLimit => _model.LowerLimit;
/// <summary>上限 | Upper limit (nullable)</summary>
public double? UpperLimit => _model.UpperLimit;
/// <summary>单指标判定 | Individual metric pass/fail status</summary>
public bool IsPass => _model.IsPass;
/// <summary>显示顺序 | Display order for sorting</summary>
public int DisplayOrder => _model.DisplayOrder;
/// <summary>
/// 下限文本(null 时显示 "—"
/// Lower limit text (displays "—" when null)
/// </summary>
public string LowerLimitText => _model.LowerLimit.HasValue ? _model.LowerLimit.Value.ToString("F2") : "—";
/// <summary>
/// 上限文本(null 时显示 "—"
/// Upper limit text (displays "—" when null)
/// </summary>
public string UpperLimitText => _model.UpperLimit.HasValue ? _model.UpperLimit.Value.ToString("F2") : "—";
/// <summary>
/// 实测值是否超出范围(用于加粗字体触发器)
/// Whether the measured value is out of range (used for bold font trigger)
/// </summary>
public bool IsValueOutOfRange
{
get
{
// 如果下限不为空且实测值小于下限,则超出范围
// If lower limit is not null and measured value is less than lower limit, it's out of range
if (_model.LowerLimit.HasValue && _model.MetricValue < _model.LowerLimit.Value)
{
return true;
}
// 如果上限不为空且实测值大于上限,则超出范围
// If upper limit is not null and measured value is greater than upper limit, it's out of range
if (_model.UpperLimit.HasValue && _model.MetricValue > _model.UpperLimit.Value)
{
return true;
}
return false;
}
}
/// <summary>
/// 行背景色(IsPass=true → 浅绿色 #E8F5E9IsPass=false → 浅红色 #FFEBEE
/// Row background color (IsPass=true → light green #E8F5E9, IsPass=false → light red #FFEBEE)
/// </summary>
public Brush RowBackground
{
get
{
if (_model.IsPass)
{
// 浅绿色 | Light green
return new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E8F5E9"));
}
else
{
// 浅红色 | Light red
return new SolidColorBrush((Color)ColorConverter.ConvertFromString("#FFEBEE"));
}
}
}
}
}
@@ -0,0 +1,344 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Prism.Commands;
using Prism.Mvvm;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using XplorePlane.Helpers;
using XplorePlane.Models;
using XplorePlane.Services.Storage;
namespace XplorePlane.ViewModels.Cnc
{
/// <summary>
/// 检测节点卡片 ViewModel,包装单个 InspectionNodeResult 并提供图像切换和快照查看功能
/// Inspection node card ViewModel that wraps a single InspectionNodeResult and provides image toggle and snapshot viewing
/// </summary>
public class InspectionNodeCardViewModel : BindableBase
{
private readonly InspectionNodeResult _nodeResult;
private readonly IXpDataPathService _dataPathService;
private readonly PipelineExecutionSnapshot _snapshot;
private readonly InspectionAssetRecord _resultImageAsset;
private readonly InspectionAssetRecord _inputImageAsset;
private BitmapSource _resultImage;
private BitmapSource _inputImage;
private BitmapSource _currentImage;
private bool _isShowingInputImage;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="nodeResult">节点结果数据 | Node result data</param>
/// <param name="metrics">该节点的所有指标 | All metrics for this node</param>
/// <param name="assets">该节点的所有资产 | All assets for this node</param>
/// <param name="snapshot">Pipeline 快照(可为 null| Pipeline snapshot (can be null)</param>
/// <param name="dataPathService">数据路径服务 | Data path service</param>
public InspectionNodeCardViewModel(
InspectionNodeResult nodeResult,
IEnumerable<InspectionMetricResult> metrics,
IEnumerable<InspectionAssetRecord> assets,
PipelineExecutionSnapshot snapshot,
IXpDataPathService dataPathService)
{
_nodeResult = nodeResult ?? throw new ArgumentNullException(nameof(nodeResult));
_dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService));
_snapshot = snapshot;
// 查找资产记录
// Find asset records
var assetList = assets?.ToList() ?? new List<InspectionAssetRecord>();
_resultImageAsset = assetList.FirstOrDefault(a => a.NodeId == nodeResult.NodeId && a.AssetType == InspectionAssetType.NodeResultImage);
_inputImageAsset = assetList.FirstOrDefault(a => a.NodeId == nodeResult.NodeId && a.AssetType == InspectionAssetType.NodeInputImage);
// 构建指标列表(按 DisplayOrder 升序,同序按 MetricName 升序)
// Build metrics list (sorted by DisplayOrder ascending, then by MetricName ascending)
var metricList = metrics?
.Where(m => m.NodeId == nodeResult.NodeId)
.OrderBy(m => m.DisplayOrder)
.ThenBy(m => m.MetricName)
.Select(m => new InspectionMetricRowViewModel(m))
.ToList() ?? new List<InspectionMetricRowViewModel>();
Metrics = new ObservableCollection<InspectionMetricRowViewModel>(metricList);
// 初始化命令
// Initialize commands
ToggleImageCommand = new DelegateCommand(ExecuteToggleImage, CanExecuteToggleImage);
ViewSnapshotCommand = new DelegateCommand(ExecuteViewSnapshot, CanExecuteViewSnapshot);
// 异步加载图像
// Load images asynchronously
_ = LoadImagesAsync();
}
// ── 属性 | Properties ──────────────────────────────────────────
/// <summary>节点序号 | Node index</summary>
public int NodeIndex => _nodeResult.NodeIndex;
/// <summary>节点名称 | Node name</summary>
public string NodeName => _nodeResult.NodeName;
/// <summary>Pipeline 名称 | Pipeline name</summary>
public string PipelineName => _nodeResult.PipelineName;
/// <summary>节点判定 | Node pass status</summary>
public bool NodePass => _nodeResult.NodePass;
/// <summary>节点状态 | Node status</summary>
public InspectionNodeStatus Status => _nodeResult.Status;
/// <summary>耗时(毫秒)| Duration in milliseconds</summary>
public long DurationMs => _nodeResult.DurationMs;
/// <summary>
/// 判定标签文字("Pass" / "Fail"
/// Pass label text ("Pass" / "Fail")
/// </summary>
public string PassLabel => NodePass ? "Pass" : "Fail";
/// <summary>
/// 判定标签颜色(绿色 #2E7D32 / 红色 #C62828
/// Pass label color (green #2E7D32 / red #C62828)
/// </summary>
public Brush PassLabelColor => NodePass
? new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2E7D32"))
: new SolidColorBrush((Color)ColorConverter.ConvertFromString("#C62828"));
/// <summary>
/// 是否有资产缺失警告(Status == AssetMissing
/// Whether there is an asset missing warning (Status == AssetMissing)
/// </summary>
public bool HasAssetMissingWarning => Status == InspectionNodeStatus.AssetMissing;
/// <summary>
/// Pipeline 版本标识(hash 前 8 位,空则 "--"
/// Pipeline version identifier (first 8 chars of hash, or "--" if empty)
/// </summary>
public string PipelineVersionShort
{
get
{
if (string.IsNullOrWhiteSpace(_nodeResult.PipelineVersionHash))
{
return "--";
}
return _nodeResult.PipelineVersionHash.Length >= 8
? _nodeResult.PipelineVersionHash.Substring(0, 8)
: _nodeResult.PipelineVersionHash;
}
}
/// <summary>
/// 是否有 Pipeline 快照
/// Whether pipeline snapshot exists
/// </summary>
public bool HasPipelineSnapshot => _snapshot != null && !string.IsNullOrWhiteSpace(_snapshot.PipelineDefinitionJson);
/// <summary>
/// 格式化的 Pipeline 定义 JSON2 空格缩进)
/// Formatted pipeline definition JSON (2-space indentation)
/// </summary>
public string PipelineDefinitionJson
{
get
{
if (_snapshot == null || string.IsNullOrWhiteSpace(_snapshot.PipelineDefinitionJson))
{
return string.Empty;
}
try
{
// 尝试解析并格式化 JSON
// Try to parse and format JSON
var parsed = JToken.Parse(_snapshot.PipelineDefinitionJson);
return parsed.ToString(Formatting.Indented);
}
catch
{
// 解析失败,返回原始字符串
// Parsing failed, return original string
return _snapshot.PipelineDefinitionJson;
}
}
}
/// <summary>
/// 节点结果图(可为 null
/// Node result image (can be null)
/// </summary>
public BitmapSource ResultImage
{
get => _resultImage;
private set => SetProperty(ref _resultImage, value);
}
/// <summary>
/// 节点输入图(可为 null
/// Node input image (can be null)
/// </summary>
public BitmapSource InputImage
{
get => _inputImage;
private set => SetProperty(ref _inputImage, value);
}
/// <summary>
/// 当前显示的图像(默认为结果图)
/// Currently displayed image (defaults to result image)
/// </summary>
public BitmapSource CurrentImage
{
get => _currentImage;
private set => SetProperty(ref _currentImage, value);
}
/// <summary>
/// 是否正在显示输入图
/// Whether currently showing input image
/// </summary>
public bool IsShowingInputImage
{
get => _isShowingInputImage;
private set => SetProperty(ref _isShowingInputImage, value);
}
/// <summary>
/// 是否有输入图
/// Whether input image exists
/// </summary>
public bool HasInputImage => InputImage != null;
/// <summary>
/// 指标列表(按 DisplayOrder 升序,同序按 MetricName 升序)
/// Metrics list (sorted by DisplayOrder ascending, then by MetricName ascending)
/// </summary>
public ObservableCollection<InspectionMetricRowViewModel> Metrics { get; }
// ── 命令 | Commands ────────────────────────────────────────────
/// <summary>
/// 切换图像命令(在输入图和结果图之间切换)
/// Toggle image command (switch between input and result images)
/// </summary>
public DelegateCommand ToggleImageCommand { get; }
/// <summary>
/// 查看快照命令(打开 SnapshotViewerWindow
/// View snapshot command (open SnapshotViewerWindow)
/// </summary>
public DelegateCommand ViewSnapshotCommand { get; }
// ── 私有方法 | Private Methods ─────────────────────────────────
/// <summary>
/// 异步加载图像
/// Load images asynchronously
/// </summary>
private async Task LoadImagesAsync()
{
// 加载结果图
// Load result image
if (_resultImageAsset != null && !string.IsNullOrWhiteSpace(_resultImageAsset.RelativePath))
{
var resultImagePath = Path.Combine(_dataPathService.DataPath, _resultImageAsset.RelativePath);
ResultImage = await ImageLoader.LoadBitmapSafeAsync(resultImagePath);
}
// 加载输入图
// Load input image
if (_inputImageAsset != null && !string.IsNullOrWhiteSpace(_inputImageAsset.RelativePath))
{
var inputImagePath = Path.Combine(_dataPathService.DataPath, _inputImageAsset.RelativePath);
InputImage = await ImageLoader.LoadBitmapSafeAsync(inputImagePath);
}
// 设置当前图像为结果图
// Set current image to result image
CurrentImage = ResultImage;
IsShowingInputImage = false;
// 通知命令状态可能已更改
// Notify that command state may have changed
ToggleImageCommand.RaiseCanExecuteChanged();
}
/// <summary>
/// 执行切换图像命令
/// Execute toggle image command
/// </summary>
private void ExecuteToggleImage()
{
if (IsShowingInputImage)
{
// 切换到结果图
// Switch to result image
CurrentImage = ResultImage;
IsShowingInputImage = false;
}
else
{
// 切换到输入图
// Switch to input image
CurrentImage = InputImage;
IsShowingInputImage = true;
}
}
/// <summary>
/// 判断是否可以执行切换图像命令
/// Determine whether toggle image command can be executed
/// </summary>
private bool CanExecuteToggleImage()
{
return InputImage != null && ResultImage != null;
}
/// <summary>
/// 执行查看快照命令
/// Execute view snapshot command
/// </summary>
private void ExecuteViewSnapshot()
{
if (!HasPipelineSnapshot)
{
return;
}
// 创建并显示快照查看器窗口
// Create and show snapshot viewer window
// 注意:这里需要在 Views/Cnc 中实现 SnapshotViewerWindow
// Note: SnapshotViewerWindow needs to be implemented in Views/Cnc
try
{
var window = new Views.Cnc.SnapshotViewerWindow(PipelineDefinitionJson, PipelineName);
window.Show();
}
catch (Exception ex)
{
// 如果窗口尚未实现,静默失败
// Silently fail if window is not yet implemented
System.Diagnostics.Debug.WriteLine($"Failed to open snapshot viewer: {ex.Message}");
}
}
/// <summary>
/// 判断是否可以执行查看快照命令
/// Determine whether view snapshot command can be executed
/// </summary>
private bool CanExecuteViewSnapshot()
{
return HasPipelineSnapshot;
}
}
}
@@ -0,0 +1,727 @@
using Prism.Commands;
using Prism.Mvvm;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Media.Imaging;
using XP.Common.Logging.Interfaces;
using XplorePlane.Helpers;
using XplorePlane.Models;
using XplorePlane.Services.InspectionResults;
using XplorePlane.Services.Storage;
namespace XplorePlane.ViewModels.Cnc
{
/// <summary>
/// CNC 检测结果查看器 ViewModel
/// CNC Inspection Report Viewer ViewModel
/// </summary>
public class InspectionReportViewerViewModel : BindableBase
{
private readonly IInspectionResultStore _store;
private readonly ILoggerService _logger;
private readonly IXpDataPathService _dataPathService;
private CancellationTokenSource _runListCts;
private CancellationTokenSource _detailCts;
private ObservableCollection<InspectionRunRowViewModel> _runRows;
private InspectionRunRowViewModel _selectedRun;
private bool _isRunListLoading;
private string _runListError;
private bool _hasRunListError;
private bool _isDetailLoading;
private string _detailError;
private bool _hasDetailError;
private InspectionRunRecord _detailRun;
private ObservableCollection<InspectionNodeCardViewModel> _detailNodes;
private BitmapSource _detailSourceImage;
private bool _hasDetailSourceImage;
private ObservableCollection<InspectionRunEventRowViewModel> _detailEvents;
private bool _isEventTimelineExpanded;
private string _filterProgramName;
private string _filterWorkpieceId;
private string _filterSerialNumber;
private DateTime? _filterFrom;
private DateTime? _filterTo;
private int _filterOverallPass; // 0=全部, 1=Pass, 2=Fail
/// <summary>
/// 构造函数 | Constructor
/// </summary>
public InspectionReportViewerViewModel(
IInspectionResultStore store,
ILoggerService logger,
IXpDataPathService dataPathService)
{
_store = store ?? throw new ArgumentNullException(nameof(store));
_logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule<InspectionReportViewerViewModel>();
_dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService));
_runRows = new ObservableCollection<InspectionRunRowViewModel>();
_detailNodes = new ObservableCollection<InspectionNodeCardViewModel>();
_detailEvents = new ObservableCollection<InspectionRunEventRowViewModel>();
// 初始化命令
// Initialize commands
QueryCommand = new DelegateCommand(async () => await ExecuteQueryAsync(), CanExecuteQuery);
ResetFilterCommand = new DelegateCommand(async () => await ExecuteResetFilterAsync());
RefreshCommand = new DelegateCommand(async () => await ExecuteRefreshAsync(), CanExecuteRefresh);
RetryRunListCommand = new DelegateCommand(async () => await ExecuteRetryRunListAsync());
RetryDetailCommand = new DelegateCommand(async () => await ExecuteRetryDetailAsync());
_logger.Info("InspectionReportViewerViewModel initialized");
// 自动加载最近 100 条记录
// Automatically load the most recent 100 records
_ = LoadRunsAsync(new InspectionRunQuery { Take = 100 });
}
// ── 属性 | Properties ──────────────────────────────────────────
/// <summary>
/// RunList 数据源
/// RunList data source
/// </summary>
public ObservableCollection<InspectionRunRowViewModel> RunRows
{
get => _runRows;
private set => SetProperty(ref _runRows, value);
}
/// <summary>
/// 当前选中行,setter 触发 LoadDetailAsync
/// Currently selected row, setter triggers LoadDetailAsync
/// </summary>
public InspectionRunRowViewModel SelectedRun
{
get => _selectedRun;
set
{
if (SetProperty(ref _selectedRun, value) && value != null)
{
_ = LoadDetailAsync(value.RunId);
}
}
}
/// <summary>
/// RunList 加载中标志
/// RunList loading flag
/// </summary>
public bool IsRunListLoading
{
get => _isRunListLoading;
private set
{
if (SetProperty(ref _isRunListLoading, value))
{
QueryCommand.RaiseCanExecuteChanged();
ResetFilterCommand.RaiseCanExecuteChanged();
RefreshCommand.RaiseCanExecuteChanged();
}
}
}
/// <summary>
/// RunList 错误消息
/// RunList error message
/// </summary>
public string RunListError
{
get => _runListError;
private set => SetProperty(ref _runListError, value);
}
/// <summary>
/// 是否有 RunList 错误
/// Whether there is a RunList error
/// </summary>
public bool HasRunListError
{
get => _hasRunListError;
private set => SetProperty(ref _hasRunListError, value);
}
/// <summary>
/// DetailPanel 加载中标志
/// DetailPanel loading flag
/// </summary>
public bool IsDetailLoading
{
get => _isDetailLoading;
private set => SetProperty(ref _isDetailLoading, value);
}
/// <summary>
/// DetailPanel 错误消息
/// DetailPanel error message
/// </summary>
public string DetailError
{
get => _detailError;
private set => SetProperty(ref _detailError, value);
}
/// <summary>
/// 是否有 DetailPanel 错误
/// Whether there is a DetailPanel error
/// </summary>
public bool HasDetailError
{
get => _hasDetailError;
private set => SetProperty(ref _hasDetailError, value);
}
/// <summary>
/// 当前详情的 Run 摘要
/// Current detail Run summary
/// </summary>
public InspectionRunRecord DetailRun
{
get => _detailRun;
private set => SetProperty(ref _detailRun, value);
}
/// <summary>
/// 节点卡片列表
/// Node card list
/// </summary>
public ObservableCollection<InspectionNodeCardViewModel> DetailNodes
{
get => _detailNodes;
private set => SetProperty(ref _detailNodes, value);
}
/// <summary>
/// 原始输入图(null 时显示占位符)
/// Original source image (shows placeholder when null)
/// </summary>
public BitmapSource DetailSourceImage
{
get => _detailSourceImage;
private set => SetProperty(ref _detailSourceImage, value);
}
/// <summary>
/// 原图是否可用
/// Whether source image is available
/// </summary>
public bool HasDetailSourceImage
{
get => _hasDetailSourceImage;
private set => SetProperty(ref _hasDetailSourceImage, value);
}
/// <summary>
/// 事件时间线
/// Event timeline
/// </summary>
public ObservableCollection<InspectionRunEventRowViewModel> DetailEvents
{
get => _detailEvents;
private set => SetProperty(ref _detailEvents, value);
}
/// <summary>
/// 事件时间线折叠状态
/// Event timeline expanded state
/// </summary>
public bool IsEventTimelineExpanded
{
get => _isEventTimelineExpanded;
set => SetProperty(ref _isEventTimelineExpanded, value);
}
// ── 筛选属性 | Filter Properties ───────────────────────────────
/// <summary>筛选:程序名 | Filter: Program name</summary>
public string FilterProgramName
{
get => _filterProgramName;
set => SetProperty(ref _filterProgramName, value);
}
/// <summary>筛选:工件号 | Filter: Workpiece ID</summary>
public string FilterWorkpieceId
{
get => _filterWorkpieceId;
set => SetProperty(ref _filterWorkpieceId, value);
}
/// <summary>筛选:序列号 | Filter: Serial number</summary>
public string FilterSerialNumber
{
get => _filterSerialNumber;
set => SetProperty(ref _filterSerialNumber, value);
}
/// <summary>筛选:开始日期 | Filter: Start date</summary>
public DateTime? FilterFrom
{
get => _filterFrom;
set => SetProperty(ref _filterFrom, value);
}
/// <summary>筛选:结束日期 | Filter: End date</summary>
public DateTime? FilterTo
{
get => _filterTo;
set => SetProperty(ref _filterTo, value);
}
/// <summary>筛选:判定(0=全部, 1=Pass, 2=Fail| Filter: Overall pass (0=All, 1=Pass, 2=Fail)</summary>
public int FilterOverallPass
{
get => _filterOverallPass;
set => SetProperty(ref _filterOverallPass, value);
}
// ── 命令 | Commands ────────────────────────────────────────────
/// <summary>
/// 使用当前筛选条件查询,加载中时禁用
/// Query using current filter conditions, disabled when loading
/// </summary>
public DelegateCommand QueryCommand { get; }
/// <summary>
/// 重置所有筛选条件并重新查询
/// Reset all filter conditions and re-query
/// </summary>
public DelegateCommand ResetFilterCommand { get; }
/// <summary>
/// 保留筛选条件刷新列表,加载中时禁用
/// Refresh list while keeping filter conditions, disabled when loading
/// </summary>
public DelegateCommand RefreshCommand { get; }
/// <summary>
/// 重试 RunList 加载
/// Retry RunList loading
/// </summary>
public DelegateCommand RetryRunListCommand { get; }
/// <summary>
/// 重试 DetailPanel 加载
/// Retry DetailPanel loading
/// </summary>
public DelegateCommand RetryDetailCommand { get; }
// ── 异步加载方法 | Async Loading Methods ───────────────────────
/// <summary>
/// 加载检测实例列表
/// Load inspection run list
/// </summary>
/// <param name="query">查询条件 | Query conditions</param>
public async Task LoadRunsAsync(InspectionRunQuery query)
{
// 设置加载中标志
// Set loading flag
IsRunListLoading = true;
HasRunListError = false;
RunListError = null;
// 取消上一个查询
// Cancel previous query
_runListCts?.Cancel();
_runListCts?.Dispose();
_runListCts = new CancellationTokenSource();
var ct = _runListCts.Token;
var previousSelectedRunId = SelectedRun?.RunId;
try
{
_logger.Info("Loading runs with query: ProgramName={ProgramName}, WorkpieceId={WorkpieceId}, SerialNumber={SerialNumber}, From={From}, To={To}, Take={Take}",
query?.ProgramName, query?.WorkpieceId, query?.SerialNumber, query?.From, query?.To, query?.Take);
// 调用数据服务
// Call data service
var runs = await _store.QueryRunsAsync(query);
if (ct.IsCancellationRequested)
{
return;
}
// 填充 RunRows
// Populate RunRows
var rowViewModels = runs.Select(r => new InspectionRunRowViewModel(r)).ToList();
RunRows = new ObservableCollection<InspectionRunRowViewModel>(rowViewModels);
_logger.Info("Loaded {Count} runs", runs.Count);
// 刷新时保持选中状态
// Maintain selection on refresh
if (previousSelectedRunId.HasValue)
{
var previousRun = RunRows.FirstOrDefault(r => r.RunId == previousSelectedRunId.Value);
if (previousRun != null)
{
SelectedRun = previousRun;
}
else
{
// 选中的记录不再存在,清空详情
// Selected record no longer exists, clear detail
SelectedRun = null;
ClearDetailPanel();
}
}
}
catch (OperationCanceledException)
{
// 静默处理取消
// Silently handle cancellation
_logger.Debug("LoadRunsAsync was cancelled");
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to load runs");
HasRunListError = true;
RunListError = $"加载失败:{ex.Message}";
RunRows.Clear();
}
finally
{
IsRunListLoading = false;
}
}
/// <summary>
/// 加载检测实例详情
/// Load inspection run detail
/// </summary>
/// <param name="runId">运行 ID | Run ID</param>
public async Task LoadDetailAsync(Guid runId)
{
// 清空详情状态
// Clear detail state
ClearDetailPanel();
// 设置加载中标志
// Set loading flag
IsDetailLoading = true;
HasDetailError = false;
DetailError = null;
// 取消上一个详情加载
// Cancel previous detail load
_detailCts?.Cancel();
_detailCts?.Dispose();
_detailCts = new CancellationTokenSource();
var ct = _detailCts.Token;
try
{
_logger.Info("Loading detail for run: {RunId}", runId);
// 调用数据服务
// Call data service
var detail = await _store.GetRunDetailAsync(runId);
if (ct.IsCancellationRequested)
{
return;
}
// 构建 DetailRun
// Build DetailRun
DetailRun = detail.Run;
// 构建 DetailNodes(按 NodeIndex 升序)
// Build DetailNodes (ascending NodeIndex)
var nodeViewModels = detail.Nodes
.OrderBy(n => n.NodeIndex)
.Select(n => new InspectionNodeCardViewModel(
n,
detail.Metrics,
detail.Assets,
detail.PipelineSnapshots.FirstOrDefault(s => s.NodeId == n.NodeId),
_dataPathService))
.ToList();
DetailNodes = new ObservableCollection<InspectionNodeCardViewModel>(nodeViewModels);
// 构建 DetailEvents(按 EventTime 升序)
// Build DetailEvents (ascending EventTime)
// 创建节点 ID 到节点名称的映射字典
// Create node ID to node name mapping dictionary
var nodeIdToNameMap = detail.Nodes.ToDictionary(n => n.NodeId, n => n.NodeName);
var eventViewModels = detail.Events
.OrderBy(e => e.EventTime)
.Select(e => new InspectionRunEventRowViewModel(e, nodeIdToNameMap))
.ToList();
DetailEvents = new ObservableCollection<InspectionRunEventRowViewModel>(eventViewModels);
// 后台加载原图
// Load source image in background
_ = LoadSourceImageAsync(detail.Assets, ct);
_logger.Info("Loaded detail for run: {RunId}, Nodes={NodeCount}, Events={EventCount}",
runId, detail.Nodes.Count, detail.Events.Count);
}
catch (OperationCanceledException)
{
// 静默处理取消
// Silently handle cancellation
_logger.Debug("LoadDetailAsync was cancelled");
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to load detail for run: {RunId}", runId);
HasDetailError = true;
DetailError = $"详情加载失败:{ex.Message}";
}
finally
{
IsDetailLoading = false;
}
}
/// <summary>
/// 后台加载原始输入图
/// Load source image in background
/// </summary>
private async Task LoadSourceImageAsync(IReadOnlyList<InspectionAssetRecord> assets, CancellationToken ct)
{
try
{
// 查找原始输入图资产
// Find source image asset
var sourceAsset = assets.FirstOrDefault(a => a.AssetType == InspectionAssetType.RunSourceImage);
if (sourceAsset == null || string.IsNullOrWhiteSpace(sourceAsset.RelativePath))
{
HasDetailSourceImage = false;
return;
}
var sourceImagePath = Path.Combine(_dataPathService.DataPath, sourceAsset.RelativePath);
if (!File.Exists(sourceImagePath))
{
_logger.Warn("Source image file not found: {Path}", sourceImagePath);
HasDetailSourceImage = false;
return;
}
// 加载图像
// Load image
var image = await ImageLoader.LoadBitmapSafeAsync(sourceImagePath);
if (ct.IsCancellationRequested)
{
return;
}
if (image != null)
{
DetailSourceImage = image;
HasDetailSourceImage = true;
}
else
{
_logger.Warn("Failed to load source image: {Path}", sourceImagePath);
HasDetailSourceImage = false;
}
}
catch (Exception ex)
{
_logger.Error(ex, "Error loading source image");
HasDetailSourceImage = false;
}
}
/// <summary>
/// 清空详情面板
/// Clear detail panel
/// </summary>
private void ClearDetailPanel()
{
DetailRun = null;
DetailNodes.Clear();
DetailEvents.Clear();
DetailSourceImage = null;
HasDetailSourceImage = false;
HasDetailError = false;
DetailError = null;
}
// ── 命令实现 | Command Implementations ─────────────────────────
/// <summary>
/// 执行查询命令
/// Execute query command
/// </summary>
private async Task ExecuteQueryAsync()
{
var query = BuildQueryFromFilters();
await LoadRunsAsync(query);
}
/// <summary>
/// 判断是否可以执行查询命令
/// Determine whether query command can be executed
/// </summary>
private bool CanExecuteQuery()
{
return !IsRunListLoading;
}
/// <summary>
/// 执行重置筛选命令
/// Execute reset filter command
/// </summary>
private async Task ExecuteResetFilterAsync()
{
// 重置所有筛选条件
// Reset all filter conditions
FilterProgramName = null;
FilterWorkpieceId = null;
FilterSerialNumber = null;
FilterFrom = null;
FilterTo = null;
FilterOverallPass = 0;
// 重新加载默认的最近 100 条记录
// Reload default recent 100 records
await LoadRunsAsync(new InspectionRunQuery { Take = 100 });
}
/// <summary>
/// 执行刷新命令
/// Execute refresh command
/// </summary>
private async Task ExecuteRefreshAsync()
{
var query = BuildQueryFromFilters();
await LoadRunsAsync(query);
}
/// <summary>
/// 判断是否可以执行刷新命令
/// Determine whether refresh command can be executed
/// </summary>
private bool CanExecuteRefresh()
{
return !IsRunListLoading;
}
/// <summary>
/// 执行重试 RunList 命令
/// Execute retry RunList command
/// </summary>
private async Task ExecuteRetryRunListAsync()
{
var query = BuildQueryFromFilters();
await LoadRunsAsync(query);
}
/// <summary>
/// 执行重试 DetailPanel 命令
/// Execute retry DetailPanel command
/// </summary>
private async Task ExecuteRetryDetailAsync()
{
if (SelectedRun != null)
{
await LoadDetailAsync(SelectedRun.RunId);
}
}
/// <summary>
/// 从筛选条件构建查询对象
/// Build query object from filter conditions
/// </summary>
private InspectionRunQuery BuildQueryFromFilters()
{
var query = new InspectionRunQuery
{
ProgramName = string.IsNullOrWhiteSpace(FilterProgramName) ? null : FilterProgramName,
WorkpieceId = string.IsNullOrWhiteSpace(FilterWorkpieceId) ? null : FilterWorkpieceId,
SerialNumber = string.IsNullOrWhiteSpace(FilterSerialNumber) ? null : FilterSerialNumber,
From = FilterFrom,
To = FilterTo,
Take = 100
};
// 注意:InspectionRunQuery 没有 OverallPass 字段,需要在服务层实现或在客户端过滤
// Note: InspectionRunQuery does not have OverallPass field, needs to be implemented in service layer or filtered on client side
// 这里暂时忽略 FilterOverallPass,因为查询接口不支持
// Temporarily ignore FilterOverallPass as the query interface does not support it
return query;
}
// ── 窗体关闭处理 | Window Closing Handler ──────────────────────
/// <summary>
/// 窗体关闭时调用,取消所有异步操作并释放资源
/// Called when window is closing, cancels all async operations and releases resources
/// </summary>
public async Task OnWindowClosingAsync()
{
_logger.Info("InspectionReportViewerViewModel closing, cancelling async operations");
// 取消所有异步操作
// Cancel all async operations
_runListCts?.Cancel();
_detailCts?.Cancel();
// 等待最多 2 秒
// Wait up to 2 seconds
try
{
var tasks = new List<Task>();
if (_runListCts != null)
{
tasks.Add(Task.Delay(2000, _runListCts.Token));
}
if (_detailCts != null)
{
tasks.Add(Task.Delay(2000, _detailCts.Token));
}
if (tasks.Count > 0)
{
await Task.WhenAny(Task.WhenAll(tasks), Task.Delay(2000));
}
}
catch (OperationCanceledException)
{
// 预期的取消异常
// Expected cancellation exception
}
catch (Exception ex)
{
_logger.Warn("Error during window closing cleanup: {Message}", ex.Message);
}
finally
{
// 释放资源
// Dispose resources
_runListCts?.Dispose();
_detailCts?.Dispose();
_runListCts = null;
_detailCts = null;
}
_logger.Info("InspectionReportViewerViewModel closed");
}
}
}
@@ -0,0 +1,144 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Prism.Mvvm;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Windows.Media;
using XplorePlane.Models;
namespace XplorePlane.ViewModels.Cnc
{
/// <summary>
/// 运行事件行 ViewModel,包装 InspectionRunEvent 供事件时间线绑定
/// Run event row ViewModel that wraps InspectionRunEvent for event timeline binding
/// </summary>
public class InspectionRunEventRowViewModel : BindableBase
{
private readonly InspectionRunEvent _event;
private readonly Dictionary<Guid, string> _nodeIdToNameMap;
private bool _isExpanded;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="eventRecord">运行事件记录 | Run event record</param>
/// <param name="nodeIdToNameMap">节点 ID 到节点名称的映射字典 | Dictionary mapping node IDs to node names</param>
public InspectionRunEventRowViewModel(InspectionRunEvent eventRecord, Dictionary<Guid, string> nodeIdToNameMap)
{
_event = eventRecord ?? throw new ArgumentNullException(nameof(eventRecord));
_nodeIdToNameMap = nodeIdToNameMap ?? new Dictionary<Guid, string>();
}
// ── 属性 | Properties ──────────────────────────────────────────
/// <summary>事件类型 | Event type</summary>
public InspectionRunEventType EventType => _event.EventType;
/// <summary>
/// 事件时间(本地时区,格式 HH:mm:ss.fff
/// Event time in local timezone (format: HH:mm:ss.fff)
/// </summary>
public string EventTimeLocal => _event.EventTime.ToLocalTime().ToString("HH:mm:ss.fff");
/// <summary>
/// 关联节点显示名称
/// 规则:如果 NodeId 不为空,优先显示节点名称;若无法解析则显示 NodeId 前 8 位;若 NodeId 为空则返回空字符串
/// Associated node display name
/// Rules: If NodeId is not null, display node name if available; otherwise display first 8 chars of NodeId; if NodeId is null return empty string
/// </summary>
public string NodeDisplayName
{
get
{
if (!_event.NodeId.HasValue)
{
return string.Empty;
}
// 尝试从映射字典中获取节点名称
// Try to get node name from the mapping dictionary
if (_nodeIdToNameMap.TryGetValue(_event.NodeId.Value, out string nodeName) && !string.IsNullOrWhiteSpace(nodeName))
{
return nodeName;
}
// 无法解析节点名称,返回 NodeId 前 8 位
// Cannot resolve node name, return first 8 chars of NodeId
return _event.NodeId.Value.ToString().Substring(0, 8);
}
}
/// <summary>
/// 格式化的 Payload JSON2 空格缩进)
/// Formatted payload JSON (2-space indentation)
/// </summary>
public string PayloadFormatted
{
get
{
if (string.IsNullOrWhiteSpace(_event.PayloadJson))
{
return string.Empty;
}
try
{
// 尝试解析并格式化 JSON
// Try to parse and format JSON
var parsed = JToken.Parse(_event.PayloadJson);
return parsed.ToString(Formatting.Indented);
}
catch
{
// 解析失败,返回原始字符串
// Parsing failed, return original string
return _event.PayloadJson;
}
}
}
/// <summary>
/// 是否有 Payload 数据
/// Whether payload data exists
/// </summary>
public bool HasPayload => !string.IsNullOrWhiteSpace(_event.PayloadJson);
/// <summary>
/// 图标颜色(根据事件类型)
/// Icon color (based on event type)
/// RunStarted/RunCompleted → 蓝色 #1565C0
/// NodeStarted/NodeCompleted → 绿色 #2E7D32
/// NodeFailed/RunError → 红色 #C62828
/// RunStopped → 橙色 #E65100
/// 其他 → 灰色 #9E9E9E
/// </summary>
public Brush IconColor
{
get
{
return EventType switch
{
InspectionRunEventType.RunStarted => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1565C0")),
InspectionRunEventType.RunCompleted => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#1565C0")),
InspectionRunEventType.NodeStarted => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2E7D32")),
InspectionRunEventType.NodeCompleted => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2E7D32")),
InspectionRunEventType.NodeFailed => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#C62828")),
InspectionRunEventType.RunError => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#C62828")),
InspectionRunEventType.RunStopped => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E65100")),
_ => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#9E9E9E"))
};
}
}
/// <summary>
/// 是否展开显示 Payload(用于 UI 绑定)
/// Whether to expand and display payload (for UI binding)
/// </summary>
public bool IsExpanded
{
get => _isExpanded;
set => SetProperty(ref _isExpanded, value);
}
}
}
@@ -0,0 +1,82 @@
using Prism.Mvvm;
using System;
using System.Windows.Media;
using XplorePlane.Models;
namespace XplorePlane.ViewModels.Cnc
{
/// <summary>
/// 检测实例行 ViewModel,包装 InspectionRunRecord 供 RunList 行绑定
/// Inspection run row ViewModel that wraps InspectionRunRecord for RunList row binding
/// </summary>
public class InspectionRunRowViewModel : BindableBase
{
private readonly InspectionRunRecord _record;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
/// <param name="record">检测实例记录 | Inspection run record</param>
public InspectionRunRowViewModel(InspectionRunRecord record)
{
_record = record ?? throw new ArgumentNullException(nameof(record));
}
// ── 属性 | Properties ──────────────────────────────────────────
/// <summary>运行 ID | Run ID</summary>
public Guid RunId => _record.RunId;
/// <summary>程序名 | Program name</summary>
public string ProgramName => _record.ProgramName;
/// <summary>工件号 | Workpiece ID</summary>
public string WorkpieceId => _record.WorkpieceId;
/// <summary>序列号 | Serial number</summary>
public string SerialNumber => _record.SerialNumber;
/// <summary>
/// 开始时间(本地时区,格式 yyyy-MM-dd HH:mm:ss
/// Start time in local timezone (format: yyyy-MM-dd HH:mm:ss)
/// </summary>
public string StartedAtLocal => _record.StartedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss");
/// <summary>整体判定 | Overall pass</summary>
public bool OverallPass => _record.OverallPass;
/// <summary>运行状态 | Run status</summary>
public InspectionRunStatus Status => _record.Status;
/// <summary>
/// 判定标签文字("Pass" / "Fail"
/// Pass label text ("Pass" / "Fail")
/// </summary>
public string PassLabel => OverallPass ? "Pass" : "Fail";
/// <summary>
/// 判定标签颜色(绿色 #2E7D32 / 红色 #C62828
/// Pass label color (green #2E7D32 / red #C62828)
/// </summary>
public Brush PassLabelColor => OverallPass
? new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2E7D32"))
: new SolidColorBrush((Color)ColorConverter.ConvertFromString("#C62828"));
/// <summary>
/// 状态警告颜色(Error=红 #C62828, Stopped=橙 #E65100, 其他=Transparent
/// Status warning color (Error=red #C62828, Stopped=orange #E65100, others=Transparent)
/// </summary>
public Brush StatusWarningColor
{
get
{
return Status switch
{
InspectionRunStatus.Error => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#C62828")),
InspectionRunStatus.Stopped => new SolidColorBrush((Color)ColorConverter.ConvertFromString("#E65100")),
_ => Brushes.Transparent
};
}
}
}
}