diff --git a/XplorePlane.Tests/ViewModels/InspectionReportViewerViewModelTests.cs b/XplorePlane.Tests/ViewModels/InspectionReportViewerViewModelTests.cs new file mode 100644 index 0000000..7608d8a --- /dev/null +++ b/XplorePlane.Tests/ViewModels/InspectionReportViewerViewModelTests.cs @@ -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 mockStore = null, + Mock mockLogger = null, + Mock mockDataPathService = null) + { + mockStore ??= new Mock(); + mockLogger ??= new Mock(); + mockDataPathService ??= new Mock(); + + mockLogger.Setup(l => l.ForModule()).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(), It.IsAny())) + .ReturnsAsync(new List()); + + 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 + { + 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(), + Assets = new List(), + PipelineSnapshots = new List(), + Events = new List() + }; + } + + // ── Task 4.2: LoadDetailAsync selection-change and cancellation paths ──────── + + /// + /// Test: Switching SelectedRun cancels prior load + /// Requirements: 4.8, 11.5 + /// + [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(); + var tcs2 = new TaskCompletionSource(); + + var mockStore = new Mock(); + + // Setup QueryRunsAsync to return two runs + mockStore + .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List + { + detail1.Run, + detail2.Run + }); + + var callCount = 0; + mockStore + .Setup(s => s.GetRunDetailAsync(It.IsAny(), It.IsAny())) + .Returns((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); + }); + } + + /// + /// Test: Cancellation is silent (no error displayed) + /// Requirements: 11.5 + /// + [Fact] + public async Task LoadDetailAsync_WhenCancelled_IsSilent() + { + // Arrange + var runId = Guid.NewGuid(); + var detail = CreateMockDetail(runId); + + var tcs = new TaskCompletionSource(); + var mockStore = new Mock(); + + mockStore + .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { detail.Run }); + + mockStore + .Setup(s => s.GetRunDetailAsync(It.IsAny(), It.IsAny())) + .Returns((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"); + }); + } + + /// + /// Test: Error sets HasDetailError + /// Requirements: 4.8 + /// + [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(); + + mockStore + .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { detail.Run }); + + mockStore + .Setup(s => s.GetRunDetailAsync(It.IsAny(), It.IsAny())) + .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"); + }); + } + + /// + /// Test: Successful load clears error state and populates detail + /// Requirements: 4.8 + /// + [Fact] + public async Task LoadDetailAsync_WhenSuccessful_PopulatesDetailAndClearsErrors() + { + // Arrange + var runId = Guid.NewGuid(); + var detail = CreateMockDetail(runId, "SuccessProgram"); + + var mockStore = new Mock(); + + mockStore + .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { detail.Run }); + + mockStore + .Setup(s => s.GetRunDetailAsync(runId, It.IsAny())) + .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); + }); + } + + /// + /// Test: LoadDetailAsync clears previous detail state before loading + /// Requirements: 4.8 + /// + [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(); + + mockStore + .Setup(s => s.QueryRunsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new List { detail1.Run, detail2.Run }); + + mockStore + .Setup(s => s.GetRunDetailAsync(runId1, It.IsAny())) + .ReturnsAsync(detail1); + + mockStore + .Setup(s => s.GetRunDetailAsync(runId2, It.IsAny())) + .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)); + }); + } + } +} diff --git a/XplorePlane/App.xaml b/XplorePlane/App.xaml index 5445b1b..dfa1cb0 100644 --- a/XplorePlane/App.xaml +++ b/XplorePlane/App.xaml @@ -1,11 +1,15 @@ + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" + xmlns:converters="clr-namespace:XplorePlane.Converters"> + + + \ No newline at end of file diff --git a/XplorePlane/Converters/EventTypeIconConverter.cs b/XplorePlane/Converters/EventTypeIconConverter.cs new file mode 100644 index 0000000..768feea --- /dev/null +++ b/XplorePlane/Converters/EventTypeIconConverter.cs @@ -0,0 +1,55 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using XplorePlane.Models; + +namespace XplorePlane.Converters +{ + /// + /// 检测运行事件类型到图标颜色转换器 | Inspection run event type to icon color converter + /// + 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(); + } + } +} diff --git a/XplorePlane/Converters/NullToPlaceholderConverter.cs b/XplorePlane/Converters/NullToPlaceholderConverter.cs new file mode 100644 index 0000000..306deba --- /dev/null +++ b/XplorePlane/Converters/NullToPlaceholderConverter.cs @@ -0,0 +1,26 @@ +using System; +using System.Globalization; +using System.Windows.Data; + +namespace XplorePlane.Converters +{ + /// + /// 将 null 或空字符串转换为占位符 "—",否则原样返回 | Converts null or empty string to placeholder "—", otherwise pass-through + /// + 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(); + } + } +} diff --git a/XplorePlane/Converters/PassFailColorConverter.cs b/XplorePlane/Converters/PassFailColorConverter.cs new file mode 100644 index 0000000..7d5946c --- /dev/null +++ b/XplorePlane/Converters/PassFailColorConverter.cs @@ -0,0 +1,40 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; + +namespace XplorePlane.Converters +{ + /// + /// Converts a boolean pass/fail value to a color brush. + /// True (Pass) → Green (#2E7D32), False (Fail) → Red (#C62828) + /// + 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."); + } + } +} diff --git a/XplorePlane/Converters/RunStatusColorConverter.cs b/XplorePlane/Converters/RunStatusColorConverter.cs new file mode 100644 index 0000000..0ee5abe --- /dev/null +++ b/XplorePlane/Converters/RunStatusColorConverter.cs @@ -0,0 +1,34 @@ +using System; +using System.Globalization; +using System.Windows.Data; +using System.Windows.Media; +using XplorePlane.Models; + +namespace XplorePlane.Converters +{ + /// + /// 检测运行状态到颜色转换器 | Inspection run status to color converter + /// Error → 红色 (#C62828), Stopped → 橙色 (#E65100), 其他 → 透明 | Error → Red (#C62828), Stopped → Orange (#E65100), Others → Transparent + /// + 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(); + } + } +} diff --git a/XplorePlane/Helpers/ImageLoader.cs b/XplorePlane/Helpers/ImageLoader.cs new file mode 100644 index 0000000..02f6371 --- /dev/null +++ b/XplorePlane/Helpers/ImageLoader.cs @@ -0,0 +1,39 @@ +using System; +using System.Threading.Tasks; +using System.Windows.Media.Imaging; + +namespace XplorePlane.Helpers +{ + /// + /// 图像加载辅助工具,提供安全的异步 BMP 图像加载功能 + /// + public static class ImageLoader + { + /// + /// 在后台线程安全加载 BMP 图像 + /// + /// 图像文件的绝对路径 + /// 成功时返回冻结的 BitmapSource,失败时返回 null + public static async Task 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; + } + }); + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/InspectionMetricRowViewModel.cs b/XplorePlane/ViewModels/Cnc/InspectionMetricRowViewModel.cs new file mode 100644 index 0000000..d6b543a --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/InspectionMetricRowViewModel.cs @@ -0,0 +1,107 @@ +using Prism.Mvvm; +using System.Windows.Media; +using XplorePlane.Models; + +namespace XplorePlane.ViewModels.Cnc +{ + /// + /// 检测指标行 ViewModel,将 InspectionMetricResult 模型封装为可绑定的 WPF ViewModel + /// Inspection metric row ViewModel that wraps an InspectionMetricResult model into a bindable WPF ViewModel + /// + public class InspectionMetricRowViewModel : BindableBase + { + private readonly InspectionMetricResult _model; + + /// + /// 构造函数,从 InspectionMetricResult 模型初始化 ViewModel + /// Constructor that initializes the ViewModel from an InspectionMetricResult model + /// + public InspectionMetricRowViewModel(InspectionMetricResult model) + { + _model = model; + } + + /// 底层检测指标模型(只读)| Underlying inspection metric model (read-only) + public InspectionMetricResult Model => _model; + + /// 指标名称 | Metric name + public string MetricName => _model.MetricName; + + /// 实测值 | Measured metric value + public double MetricValue => _model.MetricValue; + + /// 单位 | Unit of measurement + public string Unit => _model.Unit; + + /// 下限 | Lower limit (nullable) + public double? LowerLimit => _model.LowerLimit; + + /// 上限 | Upper limit (nullable) + public double? UpperLimit => _model.UpperLimit; + + /// 单指标判定 | Individual metric pass/fail status + public bool IsPass => _model.IsPass; + + /// 显示顺序 | Display order for sorting + public int DisplayOrder => _model.DisplayOrder; + + /// + /// 下限文本(null 时显示 "—") + /// Lower limit text (displays "—" when null) + /// + public string LowerLimitText => _model.LowerLimit.HasValue ? _model.LowerLimit.Value.ToString("F2") : "—"; + + /// + /// 上限文本(null 时显示 "—") + /// Upper limit text (displays "—" when null) + /// + public string UpperLimitText => _model.UpperLimit.HasValue ? _model.UpperLimit.Value.ToString("F2") : "—"; + + /// + /// 实测值是否超出范围(用于加粗字体触发器) + /// Whether the measured value is out of range (used for bold font trigger) + /// + 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; + } + } + + /// + /// 行背景色(IsPass=true → 浅绿色 #E8F5E9,IsPass=false → 浅红色 #FFEBEE) + /// Row background color (IsPass=true → light green #E8F5E9, IsPass=false → light red #FFEBEE) + /// + 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")); + } + } + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/InspectionNodeCardViewModel.cs b/XplorePlane/ViewModels/Cnc/InspectionNodeCardViewModel.cs new file mode 100644 index 0000000..854646a --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/InspectionNodeCardViewModel.cs @@ -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 +{ + /// + /// 检测节点卡片 ViewModel,包装单个 InspectionNodeResult 并提供图像切换和快照查看功能 + /// Inspection node card ViewModel that wraps a single InspectionNodeResult and provides image toggle and snapshot viewing + /// + 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; + + /// + /// 构造函数 | Constructor + /// + /// 节点结果数据 | Node result data + /// 该节点的所有指标 | All metrics for this node + /// 该节点的所有资产 | All assets for this node + /// Pipeline 快照(可为 null)| Pipeline snapshot (can be null) + /// 数据路径服务 | Data path service + public InspectionNodeCardViewModel( + InspectionNodeResult nodeResult, + IEnumerable metrics, + IEnumerable 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(); + _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(); + + Metrics = new ObservableCollection(metricList); + + // 初始化命令 + // Initialize commands + ToggleImageCommand = new DelegateCommand(ExecuteToggleImage, CanExecuteToggleImage); + ViewSnapshotCommand = new DelegateCommand(ExecuteViewSnapshot, CanExecuteViewSnapshot); + + // 异步加载图像 + // Load images asynchronously + _ = LoadImagesAsync(); + } + + // ── 属性 | Properties ────────────────────────────────────────── + + /// 节点序号 | Node index + public int NodeIndex => _nodeResult.NodeIndex; + + /// 节点名称 | Node name + public string NodeName => _nodeResult.NodeName; + + /// Pipeline 名称 | Pipeline name + public string PipelineName => _nodeResult.PipelineName; + + /// 节点判定 | Node pass status + public bool NodePass => _nodeResult.NodePass; + + /// 节点状态 | Node status + public InspectionNodeStatus Status => _nodeResult.Status; + + /// 耗时(毫秒)| Duration in milliseconds + public long DurationMs => _nodeResult.DurationMs; + + /// + /// 判定标签文字("Pass" / "Fail") + /// Pass label text ("Pass" / "Fail") + /// + public string PassLabel => NodePass ? "Pass" : "Fail"; + + /// + /// 判定标签颜色(绿色 #2E7D32 / 红色 #C62828) + /// Pass label color (green #2E7D32 / red #C62828) + /// + public Brush PassLabelColor => NodePass + ? new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2E7D32")) + : new SolidColorBrush((Color)ColorConverter.ConvertFromString("#C62828")); + + /// + /// 是否有资产缺失警告(Status == AssetMissing) + /// Whether there is an asset missing warning (Status == AssetMissing) + /// + public bool HasAssetMissingWarning => Status == InspectionNodeStatus.AssetMissing; + + /// + /// Pipeline 版本标识(hash 前 8 位,空则 "--") + /// Pipeline version identifier (first 8 chars of hash, or "--" if empty) + /// + public string PipelineVersionShort + { + get + { + if (string.IsNullOrWhiteSpace(_nodeResult.PipelineVersionHash)) + { + return "--"; + } + + return _nodeResult.PipelineVersionHash.Length >= 8 + ? _nodeResult.PipelineVersionHash.Substring(0, 8) + : _nodeResult.PipelineVersionHash; + } + } + + /// + /// 是否有 Pipeline 快照 + /// Whether pipeline snapshot exists + /// + public bool HasPipelineSnapshot => _snapshot != null && !string.IsNullOrWhiteSpace(_snapshot.PipelineDefinitionJson); + + /// + /// 格式化的 Pipeline 定义 JSON(2 空格缩进) + /// Formatted pipeline definition JSON (2-space indentation) + /// + 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; + } + } + } + + /// + /// 节点结果图(可为 null) + /// Node result image (can be null) + /// + public BitmapSource ResultImage + { + get => _resultImage; + private set => SetProperty(ref _resultImage, value); + } + + /// + /// 节点输入图(可为 null) + /// Node input image (can be null) + /// + public BitmapSource InputImage + { + get => _inputImage; + private set => SetProperty(ref _inputImage, value); + } + + /// + /// 当前显示的图像(默认为结果图) + /// Currently displayed image (defaults to result image) + /// + public BitmapSource CurrentImage + { + get => _currentImage; + private set => SetProperty(ref _currentImage, value); + } + + /// + /// 是否正在显示输入图 + /// Whether currently showing input image + /// + public bool IsShowingInputImage + { + get => _isShowingInputImage; + private set => SetProperty(ref _isShowingInputImage, value); + } + + /// + /// 是否有输入图 + /// Whether input image exists + /// + public bool HasInputImage => InputImage != null; + + /// + /// 指标列表(按 DisplayOrder 升序,同序按 MetricName 升序) + /// Metrics list (sorted by DisplayOrder ascending, then by MetricName ascending) + /// + public ObservableCollection Metrics { get; } + + // ── 命令 | Commands ──────────────────────────────────────────── + + /// + /// 切换图像命令(在输入图和结果图之间切换) + /// Toggle image command (switch between input and result images) + /// + public DelegateCommand ToggleImageCommand { get; } + + /// + /// 查看快照命令(打开 SnapshotViewerWindow) + /// View snapshot command (open SnapshotViewerWindow) + /// + public DelegateCommand ViewSnapshotCommand { get; } + + // ── 私有方法 | Private Methods ───────────────────────────────── + + /// + /// 异步加载图像 + /// Load images asynchronously + /// + 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(); + } + + /// + /// 执行切换图像命令 + /// Execute toggle image command + /// + private void ExecuteToggleImage() + { + if (IsShowingInputImage) + { + // 切换到结果图 + // Switch to result image + CurrentImage = ResultImage; + IsShowingInputImage = false; + } + else + { + // 切换到输入图 + // Switch to input image + CurrentImage = InputImage; + IsShowingInputImage = true; + } + } + + /// + /// 判断是否可以执行切换图像命令 + /// Determine whether toggle image command can be executed + /// + private bool CanExecuteToggleImage() + { + return InputImage != null && ResultImage != null; + } + + /// + /// 执行查看快照命令 + /// Execute view snapshot command + /// + 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}"); + } + } + + /// + /// 判断是否可以执行查看快照命令 + /// Determine whether view snapshot command can be executed + /// + private bool CanExecuteViewSnapshot() + { + return HasPipelineSnapshot; + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/InspectionReportViewerViewModel.cs b/XplorePlane/ViewModels/Cnc/InspectionReportViewerViewModel.cs new file mode 100644 index 0000000..e954d86 --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/InspectionReportViewerViewModel.cs @@ -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 +{ + /// + /// CNC 检测结果查看器 ViewModel + /// CNC Inspection Report Viewer ViewModel + /// + public class InspectionReportViewerViewModel : BindableBase + { + private readonly IInspectionResultStore _store; + private readonly ILoggerService _logger; + private readonly IXpDataPathService _dataPathService; + + private CancellationTokenSource _runListCts; + private CancellationTokenSource _detailCts; + + private ObservableCollection _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 _detailNodes; + private BitmapSource _detailSourceImage; + private bool _hasDetailSourceImage; + private ObservableCollection _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 + + /// + /// 构造函数 | Constructor + /// + public InspectionReportViewerViewModel( + IInspectionResultStore store, + ILoggerService logger, + IXpDataPathService dataPathService) + { + _store = store ?? throw new ArgumentNullException(nameof(store)); + _logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule(); + _dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService)); + + _runRows = new ObservableCollection(); + _detailNodes = new ObservableCollection(); + _detailEvents = new ObservableCollection(); + + // 初始化命令 + // 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 ────────────────────────────────────────── + + /// + /// RunList 数据源 + /// RunList data source + /// + public ObservableCollection RunRows + { + get => _runRows; + private set => SetProperty(ref _runRows, value); + } + + /// + /// 当前选中行,setter 触发 LoadDetailAsync + /// Currently selected row, setter triggers LoadDetailAsync + /// + public InspectionRunRowViewModel SelectedRun + { + get => _selectedRun; + set + { + if (SetProperty(ref _selectedRun, value) && value != null) + { + _ = LoadDetailAsync(value.RunId); + } + } + } + + /// + /// RunList 加载中标志 + /// RunList loading flag + /// + public bool IsRunListLoading + { + get => _isRunListLoading; + private set + { + if (SetProperty(ref _isRunListLoading, value)) + { + QueryCommand.RaiseCanExecuteChanged(); + ResetFilterCommand.RaiseCanExecuteChanged(); + RefreshCommand.RaiseCanExecuteChanged(); + } + } + } + + /// + /// RunList 错误消息 + /// RunList error message + /// + public string RunListError + { + get => _runListError; + private set => SetProperty(ref _runListError, value); + } + + /// + /// 是否有 RunList 错误 + /// Whether there is a RunList error + /// + public bool HasRunListError + { + get => _hasRunListError; + private set => SetProperty(ref _hasRunListError, value); + } + + /// + /// DetailPanel 加载中标志 + /// DetailPanel loading flag + /// + public bool IsDetailLoading + { + get => _isDetailLoading; + private set => SetProperty(ref _isDetailLoading, value); + } + + /// + /// DetailPanel 错误消息 + /// DetailPanel error message + /// + public string DetailError + { + get => _detailError; + private set => SetProperty(ref _detailError, value); + } + + /// + /// 是否有 DetailPanel 错误 + /// Whether there is a DetailPanel error + /// + public bool HasDetailError + { + get => _hasDetailError; + private set => SetProperty(ref _hasDetailError, value); + } + + /// + /// 当前详情的 Run 摘要 + /// Current detail Run summary + /// + public InspectionRunRecord DetailRun + { + get => _detailRun; + private set => SetProperty(ref _detailRun, value); + } + + /// + /// 节点卡片列表 + /// Node card list + /// + public ObservableCollection DetailNodes + { + get => _detailNodes; + private set => SetProperty(ref _detailNodes, value); + } + + /// + /// 原始输入图(null 时显示占位符) + /// Original source image (shows placeholder when null) + /// + public BitmapSource DetailSourceImage + { + get => _detailSourceImage; + private set => SetProperty(ref _detailSourceImage, value); + } + + /// + /// 原图是否可用 + /// Whether source image is available + /// + public bool HasDetailSourceImage + { + get => _hasDetailSourceImage; + private set => SetProperty(ref _hasDetailSourceImage, value); + } + + /// + /// 事件时间线 + /// Event timeline + /// + public ObservableCollection DetailEvents + { + get => _detailEvents; + private set => SetProperty(ref _detailEvents, value); + } + + /// + /// 事件时间线折叠状态 + /// Event timeline expanded state + /// + public bool IsEventTimelineExpanded + { + get => _isEventTimelineExpanded; + set => SetProperty(ref _isEventTimelineExpanded, value); + } + + // ── 筛选属性 | Filter Properties ─────────────────────────────── + + /// 筛选:程序名 | Filter: Program name + public string FilterProgramName + { + get => _filterProgramName; + set => SetProperty(ref _filterProgramName, value); + } + + /// 筛选:工件号 | Filter: Workpiece ID + public string FilterWorkpieceId + { + get => _filterWorkpieceId; + set => SetProperty(ref _filterWorkpieceId, value); + } + + /// 筛选:序列号 | Filter: Serial number + public string FilterSerialNumber + { + get => _filterSerialNumber; + set => SetProperty(ref _filterSerialNumber, value); + } + + /// 筛选:开始日期 | Filter: Start date + public DateTime? FilterFrom + { + get => _filterFrom; + set => SetProperty(ref _filterFrom, value); + } + + /// 筛选:结束日期 | Filter: End date + public DateTime? FilterTo + { + get => _filterTo; + set => SetProperty(ref _filterTo, value); + } + + /// 筛选:判定(0=全部, 1=Pass, 2=Fail)| Filter: Overall pass (0=All, 1=Pass, 2=Fail) + public int FilterOverallPass + { + get => _filterOverallPass; + set => SetProperty(ref _filterOverallPass, value); + } + + // ── 命令 | Commands ──────────────────────────────────────────── + + /// + /// 使用当前筛选条件查询,加载中时禁用 + /// Query using current filter conditions, disabled when loading + /// + public DelegateCommand QueryCommand { get; } + + /// + /// 重置所有筛选条件并重新查询 + /// Reset all filter conditions and re-query + /// + public DelegateCommand ResetFilterCommand { get; } + + /// + /// 保留筛选条件刷新列表,加载中时禁用 + /// Refresh list while keeping filter conditions, disabled when loading + /// + public DelegateCommand RefreshCommand { get; } + + /// + /// 重试 RunList 加载 + /// Retry RunList loading + /// + public DelegateCommand RetryRunListCommand { get; } + + /// + /// 重试 DetailPanel 加载 + /// Retry DetailPanel loading + /// + public DelegateCommand RetryDetailCommand { get; } + + // ── 异步加载方法 | Async Loading Methods ─────────────────────── + + /// + /// 加载检测实例列表 + /// Load inspection run list + /// + /// 查询条件 | Query conditions + 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(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; + } + } + + /// + /// 加载检测实例详情 + /// Load inspection run detail + /// + /// 运行 ID | Run ID + 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(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(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; + } + } + + /// + /// 后台加载原始输入图 + /// Load source image in background + /// + private async Task LoadSourceImageAsync(IReadOnlyList 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; + } + } + + /// + /// 清空详情面板 + /// Clear detail panel + /// + private void ClearDetailPanel() + { + DetailRun = null; + DetailNodes.Clear(); + DetailEvents.Clear(); + DetailSourceImage = null; + HasDetailSourceImage = false; + HasDetailError = false; + DetailError = null; + } + + // ── 命令实现 | Command Implementations ───────────────────────── + + /// + /// 执行查询命令 + /// Execute query command + /// + private async Task ExecuteQueryAsync() + { + var query = BuildQueryFromFilters(); + await LoadRunsAsync(query); + } + + /// + /// 判断是否可以执行查询命令 + /// Determine whether query command can be executed + /// + private bool CanExecuteQuery() + { + return !IsRunListLoading; + } + + /// + /// 执行重置筛选命令 + /// Execute reset filter command + /// + 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 }); + } + + /// + /// 执行刷新命令 + /// Execute refresh command + /// + private async Task ExecuteRefreshAsync() + { + var query = BuildQueryFromFilters(); + await LoadRunsAsync(query); + } + + /// + /// 判断是否可以执行刷新命令 + /// Determine whether refresh command can be executed + /// + private bool CanExecuteRefresh() + { + return !IsRunListLoading; + } + + /// + /// 执行重试 RunList 命令 + /// Execute retry RunList command + /// + private async Task ExecuteRetryRunListAsync() + { + var query = BuildQueryFromFilters(); + await LoadRunsAsync(query); + } + + /// + /// 执行重试 DetailPanel 命令 + /// Execute retry DetailPanel command + /// + private async Task ExecuteRetryDetailAsync() + { + if (SelectedRun != null) + { + await LoadDetailAsync(SelectedRun.RunId); + } + } + + /// + /// 从筛选条件构建查询对象 + /// Build query object from filter conditions + /// + 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 ────────────────────── + + /// + /// 窗体关闭时调用,取消所有异步操作并释放资源 + /// Called when window is closing, cancels all async operations and releases resources + /// + 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(); + + 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"); + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/InspectionRunEventRowViewModel.cs b/XplorePlane/ViewModels/Cnc/InspectionRunEventRowViewModel.cs new file mode 100644 index 0000000..ece9e24 --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/InspectionRunEventRowViewModel.cs @@ -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 +{ + /// + /// 运行事件行 ViewModel,包装 InspectionRunEvent 供事件时间线绑定 + /// Run event row ViewModel that wraps InspectionRunEvent for event timeline binding + /// + public class InspectionRunEventRowViewModel : BindableBase + { + private readonly InspectionRunEvent _event; + private readonly Dictionary _nodeIdToNameMap; + private bool _isExpanded; + + /// + /// 构造函数 | Constructor + /// + /// 运行事件记录 | Run event record + /// 节点 ID 到节点名称的映射字典 | Dictionary mapping node IDs to node names + public InspectionRunEventRowViewModel(InspectionRunEvent eventRecord, Dictionary nodeIdToNameMap) + { + _event = eventRecord ?? throw new ArgumentNullException(nameof(eventRecord)); + _nodeIdToNameMap = nodeIdToNameMap ?? new Dictionary(); + } + + // ── 属性 | Properties ────────────────────────────────────────── + + /// 事件类型 | Event type + public InspectionRunEventType EventType => _event.EventType; + + /// + /// 事件时间(本地时区,格式 HH:mm:ss.fff) + /// Event time in local timezone (format: HH:mm:ss.fff) + /// + public string EventTimeLocal => _event.EventTime.ToLocalTime().ToString("HH:mm:ss.fff"); + + /// + /// 关联节点显示名称 + /// 规则:如果 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 + /// + 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); + } + } + + /// + /// 格式化的 Payload JSON(2 空格缩进) + /// Formatted payload JSON (2-space indentation) + /// + 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; + } + } + } + + /// + /// 是否有 Payload 数据 + /// Whether payload data exists + /// + public bool HasPayload => !string.IsNullOrWhiteSpace(_event.PayloadJson); + + /// + /// 图标颜色(根据事件类型) + /// Icon color (based on event type) + /// RunStarted/RunCompleted → 蓝色 #1565C0 + /// NodeStarted/NodeCompleted → 绿色 #2E7D32 + /// NodeFailed/RunError → 红色 #C62828 + /// RunStopped → 橙色 #E65100 + /// 其他 → 灰色 #9E9E9E + /// + 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")) + }; + } + } + + /// + /// 是否展开显示 Payload(用于 UI 绑定) + /// Whether to expand and display payload (for UI binding) + /// + public bool IsExpanded + { + get => _isExpanded; + set => SetProperty(ref _isExpanded, value); + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/InspectionRunRowViewModel.cs b/XplorePlane/ViewModels/Cnc/InspectionRunRowViewModel.cs new file mode 100644 index 0000000..e26dc7b --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/InspectionRunRowViewModel.cs @@ -0,0 +1,82 @@ +using Prism.Mvvm; +using System; +using System.Windows.Media; +using XplorePlane.Models; + +namespace XplorePlane.ViewModels.Cnc +{ + /// + /// 检测实例行 ViewModel,包装 InspectionRunRecord 供 RunList 行绑定 + /// Inspection run row ViewModel that wraps InspectionRunRecord for RunList row binding + /// + public class InspectionRunRowViewModel : BindableBase + { + private readonly InspectionRunRecord _record; + + /// + /// 构造函数 | Constructor + /// + /// 检测实例记录 | Inspection run record + public InspectionRunRowViewModel(InspectionRunRecord record) + { + _record = record ?? throw new ArgumentNullException(nameof(record)); + } + + // ── 属性 | Properties ────────────────────────────────────────── + + /// 运行 ID | Run ID + public Guid RunId => _record.RunId; + + /// 程序名 | Program name + public string ProgramName => _record.ProgramName; + + /// 工件号 | Workpiece ID + public string WorkpieceId => _record.WorkpieceId; + + /// 序列号 | Serial number + public string SerialNumber => _record.SerialNumber; + + /// + /// 开始时间(本地时区,格式 yyyy-MM-dd HH:mm:ss) + /// Start time in local timezone (format: yyyy-MM-dd HH:mm:ss) + /// + public string StartedAtLocal => _record.StartedAt.ToLocalTime().ToString("yyyy-MM-dd HH:mm:ss"); + + /// 整体判定 | Overall pass + public bool OverallPass => _record.OverallPass; + + /// 运行状态 | Run status + public InspectionRunStatus Status => _record.Status; + + /// + /// 判定标签文字("Pass" / "Fail") + /// Pass label text ("Pass" / "Fail") + /// + public string PassLabel => OverallPass ? "Pass" : "Fail"; + + /// + /// 判定标签颜色(绿色 #2E7D32 / 红色 #C62828) + /// Pass label color (green #2E7D32 / red #C62828) + /// + public Brush PassLabelColor => OverallPass + ? new SolidColorBrush((Color)ColorConverter.ConvertFromString("#2E7D32")) + : new SolidColorBrush((Color)ColorConverter.ConvertFromString("#C62828")); + + /// + /// 状态警告颜色(Error=红 #C62828, Stopped=橙 #E65100, 其他=Transparent) + /// Status warning color (Error=red #C62828, Stopped=orange #E65100, others=Transparent) + /// + 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 + }; + } + } + } +}