CNC结果预览
This commit is contained in:
@@ -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));
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 → 浅绿色 #E8F5E9,IsPass=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 定义 JSON(2 空格缩进)
|
||||
/// 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 JSON(2 空格缩进)
|
||||
/// 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
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user