From c91b55785edf74c4d16dbd6582db95f2191d0a08 Mon Sep 17 00:00:00 2001 From: "zhengxuan.zhang" Date: Mon, 20 Apr 2026 21:36:46 +0800 Subject: [PATCH 1/9] =?UTF-8?q?CNC=E7=BC=96=E8=BE=91=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E4=B8=8E=E5=B8=B8=E8=A7=84=E6=A8=A1=E5=BC=8F=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../XP.Hardware.Detector.deps.json | 5 +- XplorePlane.Tests/XplorePlane.Tests.csproj | 7 - XplorePlane/GapInspect.ico | Bin 178211 -> 0 bytes XplorePlane/ViewModels/Main/MainViewModel.cs | 43 ++++- XplorePlane/Views/Cnc/CncEditorWindow.xaml | 9 +- XplorePlane/Views/Cnc/CncPageView.xaml | 147 ++---------------- XplorePlane/Views/Cnc/CncPageView.xaml.cs | 25 ++- .../ImageProcessing/PipelineEditorView.xaml | 3 +- XplorePlane/Views/Main/ImagePanelView.xaml | 5 +- XplorePlane/Views/Main/MainWindow.xaml | 25 +-- XplorePlane/XplorePlane.csproj | 3 + XplorePlane/XplorerPlane.ico | Bin 20972 -> 93830 bytes 12 files changed, 103 insertions(+), 169 deletions(-) delete mode 100644 XplorePlane/GapInspect.ico diff --git a/XP.Hardware.Detector/bin/Debug/net8.0-windows7.0/XP.Hardware.Detector.deps.json b/XP.Hardware.Detector/bin/Debug/net8.0-windows7.0/XP.Hardware.Detector.deps.json index 54eff8d..3f740dc 100644 --- a/XP.Hardware.Detector/bin/Debug/net8.0-windows7.0/XP.Hardware.Detector.deps.json +++ b/XP.Hardware.Detector/bin/Debug/net8.0-windows7.0/XP.Hardware.Detector.deps.json @@ -1878,10 +1878,7 @@ "Telerik.UI.for.Wpf.NetCore.Xaml": "2024.1.408" }, "runtime": { - "XP.Common.dll": { - "assemblyVersion": "1.4.16.1", - "fileVersion": "1.4.16.1" - } + "XP.Common.dll": {} }, "resources": { "en-US/XP.Common.resources.dll": { diff --git a/XplorePlane.Tests/XplorePlane.Tests.csproj b/XplorePlane.Tests/XplorePlane.Tests.csproj index ae65b4a..c55372f 100644 --- a/XplorePlane.Tests/XplorePlane.Tests.csproj +++ b/XplorePlane.Tests/XplorePlane.Tests.csproj @@ -34,11 +34,4 @@ - - - ..\XplorePlane\Libs\ImageProcessing\ImageProcessing.Core.dll - True - - - diff --git a/XplorePlane/GapInspect.ico b/XplorePlane/GapInspect.ico deleted file mode 100644 index 53ae163eac310460f78bf6de31ff5a3d9e5340b1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 178211 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 PK15Mg?M右侧图像区域内容 | Right-side image panel content + public object ImagePanelContent + { + get => _imagePanelContent; + set => SetProperty(ref _imagePanelContent, value); + } + + /// 右侧图像区域宽度 | Right-side image panel width + public GridLength ImagePanelWidth + { + get => _imagePanelWidth; + set => SetProperty(ref _imagePanelWidth, value); + } + // 窗口引用(单例窗口防止重复打开) private Window _motionDebugWindow; private Window _detectorConfigWindow; @@ -69,6 +85,9 @@ namespace XplorePlane.ViewModels private Window _realTimeLogViewerWindow; private Window _toolboxWindow; private Window _raySourceConfigWindow; + private object _imagePanelContent; + private GridLength _imagePanelWidth = new GridLength(350); + private bool _isCncEditorMode; public MainViewModel(ILoggerService logger, IContainerProvider containerProvider, IEventAggregator eventAggregator) { @@ -90,7 +109,7 @@ namespace XplorePlane.ViewModels OpenImageProcessingCommand = new DelegateCommand(() => ShowWindow(new Views.ImageProcessingWindow(), "图像处理")); LoadImageCommand = new DelegateCommand(ExecuteLoadImage); OpenPipelineEditorCommand = new DelegateCommand(() => ShowWindow(new Views.PipelineEditorWindow(), "流水线编辑器")); - OpenCncEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.CncEditorWindow(), "CNC 编辑器")); + OpenCncEditorCommand = new DelegateCommand(ExecuteOpenCncEditor); OpenMatrixEditorCommand = new DelegateCommand(() => ShowWindow(new Views.Cnc.MatrixEditorWindow(), "矩阵编排")); OpenToolboxCommand = new DelegateCommand(ExecuteOpenToolbox); OpenLibraryVersionsCommand = new DelegateCommand(() => ShowWindow(new Views.LibraryVersionsWindow(), "关于")); @@ -109,6 +128,9 @@ namespace XplorePlane.ViewModels OpenLanguageSwitcherCommand = new DelegateCommand(ExecuteOpenLanguageSwitcher); OpenRealTimeLogViewerCommand = new DelegateCommand(ExecuteOpenRealTimeLogViewer); + ImagePanelContent = new PipelineEditorView(); + ImagePanelWidth = new GridLength(350); + _logger.Info("MainViewModel 已初始化"); } @@ -153,6 +175,23 @@ namespace XplorePlane.ViewModels ShowOrActivate(_toolboxWindow, w => _toolboxWindow = w, () => new Views.OperatorToolboxWindow(), "算子工具箱"); } + private void ExecuteOpenCncEditor() + { + if (_isCncEditorMode) + { + ImagePanelContent = new PipelineEditorView(); + ImagePanelWidth = new GridLength(350); + _isCncEditorMode = false; + _logger.Info("已退出 CNC 编辑模式"); + return; + } + + ImagePanelContent = new CncPageView(); + ImagePanelWidth = new GridLength(430); + _isCncEditorMode = true; + _logger.Info("CNC 编辑器已切换到主界面图像区域"); + } + private void ExecuteOpenUserManual() { try diff --git a/XplorePlane/Views/Cnc/CncEditorWindow.xaml b/XplorePlane/Views/Cnc/CncEditorWindow.xaml index 0f00806..7496f51 100644 --- a/XplorePlane/Views/Cnc/CncEditorWindow.xaml +++ b/XplorePlane/Views/Cnc/CncEditorWindow.xaml @@ -1,12 +1,15 @@ - - \ No newline at end of file + diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml b/XplorePlane/Views/Cnc/CncPageView.xaml index f16e760..cb9b5a3 100644 --- a/XplorePlane/Views/Cnc/CncPageView.xaml +++ b/XplorePlane/Views/Cnc/CncPageView.xaml @@ -1,32 +1,28 @@ - - - - Microsoft YaHei UI - - + + + + + - - - + + + + + - - - - - - + + + + + - - + + + + + + + - - - + ItemsSource="{Binding TreeNodes}" + SelectedItemChanged="CncTreeView_SelectedItemChanged"> + + + - + @@ -121,7 +147,7 @@ Background="Transparent" BorderBrush="#cdcbcb" BorderThickness="1" - Command="{Binding DataContext.MoveNodeUpCommand, RelativeSource={RelativeSource AncestorType=ListBox}}" + Command="{Binding DataContext.MoveNodeUpCommand, RelativeSource={RelativeSource AncestorType=TreeView}}" CommandParameter="{Binding}" Content="▲" Cursor="Hand" @@ -134,7 +160,7 @@ Background="Transparent" BorderBrush="#cdcbcb" BorderThickness="1" - Command="{Binding DataContext.MoveNodeDownCommand, RelativeSource={RelativeSource AncestorType=ListBox}}" + Command="{Binding DataContext.MoveNodeDownCommand, RelativeSource={RelativeSource AncestorType=TreeView}}" CommandParameter="{Binding}" Content="▼" Cursor="Hand" @@ -147,7 +173,7 @@ Background="Transparent" BorderBrush="#E05050" BorderThickness="1" - Command="{Binding DataContext.DeleteNodeCommand, RelativeSource={RelativeSource AncestorType=ListBox}}" + Command="{Binding DataContext.DeleteNodeCommand, RelativeSource={RelativeSource AncestorType=TreeView}}" Content="✕" Cursor="Hand" FontSize="10" @@ -159,17 +185,225 @@ - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml.cs b/XplorePlane/Views/Cnc/CncPageView.xaml.cs index 172345e..af635de 100644 --- a/XplorePlane/Views/Cnc/CncPageView.xaml.cs +++ b/XplorePlane/Views/Cnc/CncPageView.xaml.cs @@ -1,7 +1,9 @@ -using System; +using Prism.Ioc; +using System; +using System.Globalization; using System.Windows; using System.Windows.Controls; -using Prism.Ioc; +using System.Windows.Data; using XplorePlane.ViewModels.Cnc; namespace XplorePlane.Views.Cnc @@ -31,5 +33,33 @@ namespace XplorePlane.Views.Cnc } } } + + private void CncTreeView_SelectedItemChanged(object sender, RoutedPropertyChangedEventArgs e) + { + if (DataContext is CncEditorViewModel viewModel) + { + viewModel.SelectedNode = e.NewValue as CncNodeViewModel; + } + } + } + + public class NullToVisibilityConverter : IValueConverter + { + public object Convert(object value, Type targetType, object parameter, CultureInfo culture) + { + var invert = string.Equals(parameter as string, "Invert", StringComparison.OrdinalIgnoreCase); + var isVisible = value != null; + if (invert) + { + isVisible = !isVisible; + } + + return isVisible ? Visibility.Visible : Visibility.Collapsed; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) + { + throw new NotSupportedException(); + } } } From 238e97d110156a10d481e7ee75147afacc7e7342 Mon Sep 17 00:00:00 2001 From: "zhengxuan.zhang" Date: Tue, 21 Apr 2026 07:32:28 +0800 Subject: [PATCH 4/9] =?UTF-8?q?CNC=E6=95=B0=E6=8D=AE=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E9=97=AE=E9=A2=98=EF=BC=8C=E5=8C=85=E6=8B=AC=E4=B8=AD=E9=97=B4?= =?UTF-8?q?=E7=9A=84=E5=A4=84=E7=90=86=E6=83=85=E5=86=B5=E7=9A=84=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Services/InspectionResultStoreTests.cs | 340 ++++++ XplorePlane/App.xaml.cs | 4 +- XplorePlane/Models/InspectionResultModels.cs | 116 ++ .../IInspectionResultStore.cs | 24 + .../InspectionResultStore.cs | 1006 +++++++++++++++++ XplorePlane/Views/Cnc/CncEditorWindow.xaml | 8 +- XplorePlane/Views/Cnc/CncPageView.xaml | 484 ++++++-- 7 files changed, 1871 insertions(+), 111 deletions(-) create mode 100644 XplorePlane.Tests/Services/InspectionResultStoreTests.cs create mode 100644 XplorePlane/Models/InspectionResultModels.cs create mode 100644 XplorePlane/Services/InspectionResults/IInspectionResultStore.cs create mode 100644 XplorePlane/Services/InspectionResults/InspectionResultStore.cs diff --git a/XplorePlane.Tests/Services/InspectionResultStoreTests.cs b/XplorePlane.Tests/Services/InspectionResultStoreTests.cs new file mode 100644 index 0000000..640eeea --- /dev/null +++ b/XplorePlane.Tests/Services/InspectionResultStoreTests.cs @@ -0,0 +1,340 @@ +using Moq; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using XP.Common.Configs; +using XP.Common.Database.Implementations; +using XP.Common.Database.Interfaces; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.InspectionResults; +using Xunit; + +namespace XplorePlane.Tests.Services +{ + public class InspectionResultStoreTests : IDisposable + { + private readonly string _tempRoot; + private readonly Mock _mockLogger; + private readonly IDbContext _dbContext; + private readonly InspectionResultStore _store; + + public InspectionResultStoreTests() + { + _tempRoot = Path.Combine(Path.GetTempPath(), "XplorePlane.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempRoot); + + _mockLogger = new Mock(); + _mockLogger.Setup(l => l.ForModule(It.IsAny())).Returns(_mockLogger.Object); + _mockLogger.Setup(l => l.ForModule()).Returns(_mockLogger.Object); + + var sqliteConfig = new SqliteConfig + { + DbFilePath = Path.Combine(_tempRoot, "inspection-results.db"), + CreateIfNotExists = true, + EnableWalMode = false, + EnableSqlLogging = false + }; + + _dbContext = new SqliteContext(sqliteConfig, _mockLogger.Object); + _store = new InspectionResultStore(_dbContext, _mockLogger.Object, Path.Combine(_tempRoot, "assets")); + } + + [Fact] + public async Task FullRun_WithTwoNodes_CanRoundTripDetailAndQuery() + { + var startedAt = new DateTime(2026, 4, 21, 10, 0, 0, DateTimeKind.Utc); + var run = new InspectionRunRecord + { + ProgramName = "NewCncProgram", + WorkpieceId = "QFN_1", + SerialNumber = "SN-001", + StartedAt = startedAt + }; + + var runSource = CreateTempFile("run-source.bmp", "run-source"); + await _store.BeginRunAsync(run, new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.RunSourceImage, + SourceFilePath = runSource, + FileFormat = "bmp" + }); + + var pipelineA = BuildPipeline("Recipe-A", ("GaussianBlur", 0), ("Threshold", 1)); + var node1Id = Guid.NewGuid(); + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = node1Id, + NodeIndex = 1, + NodeName = "检测节点1", + PipelineId = pipelineA.Id, + PipelineName = pipelineA.Name, + NodePass = true, + DurationMs = 135 + }, + new[] + { + new InspectionMetricResult + { + MetricKey = "bridge.rate", + MetricName = "Bridge Rate", + MetricValue = 0.12, + Unit = "%", + UpperLimit = 0.2, + IsPass = true, + DisplayOrder = 1 + }, + new InspectionMetricResult + { + MetricKey = "void.area", + MetricName = "Void Area", + MetricValue = 5.6, + Unit = "px", + UpperLimit = 8, + IsPass = true, + DisplayOrder = 2 + } + }, + new PipelineExecutionSnapshot + { + PipelineName = pipelineA.Name, + PipelineDefinitionJson = JsonSerializer.Serialize(pipelineA) + }, + new[] + { + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeInputImage, + SourceFilePath = CreateTempFile("node1-input.bmp", "node1-input"), + FileFormat = "bmp" + }, + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeResultImage, + SourceFilePath = CreateTempFile("node1-result.bmp", "node1-result"), + FileFormat = "bmp" + } + }); + + var pipelineB = BuildPipeline("Recipe-B", ("MeanFilter", 0), ("ContourDetection", 1)); + var node2Id = Guid.NewGuid(); + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = node2Id, + NodeIndex = 2, + NodeName = "检测节点2", + PipelineId = pipelineB.Id, + PipelineName = pipelineB.Name, + NodePass = false, + Status = InspectionNodeStatus.Failed, + DurationMs = 240 + }, + new[] + { + new InspectionMetricResult + { + MetricKey = "solder.height", + MetricName = "Solder Height", + MetricValue = 1.7, + Unit = "mm", + LowerLimit = 1.8, + IsPass = false, + DisplayOrder = 1 + } + }, + new PipelineExecutionSnapshot + { + PipelineName = pipelineB.Name, + PipelineDefinitionJson = JsonSerializer.Serialize(pipelineB) + }, + new[] + { + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeResultImage, + SourceFilePath = CreateTempFile("node2-result.bmp", "node2-result"), + FileFormat = "bmp" + } + }); + + await _store.CompleteRunAsync(run.RunId); + + var queried = await _store.QueryRunsAsync(new InspectionRunQuery + { + ProgramName = "NewCncProgram", + WorkpieceId = "QFN_1", + PipelineName = "Recipe-A" + }); + + var detail = await _store.GetRunDetailAsync(run.RunId); + + Assert.Single(queried); + Assert.Equal(run.RunId, queried[0].RunId); + Assert.False(detail.Run.OverallPass); + Assert.Equal(2, detail.Run.NodeCount); + Assert.Equal(2, detail.Nodes.Count); + Assert.Equal(3, detail.Metrics.Count); + Assert.Equal(4, detail.Assets.Count); + Assert.Equal(2, detail.PipelineSnapshots.Count); + Assert.Contains(detail.Nodes, n => n.NodeId == node1Id && n.NodePass); + Assert.Contains(detail.Nodes, n => n.NodeId == node2Id && !n.NodePass); + Assert.All(detail.PipelineSnapshots, snapshot => Assert.False(string.IsNullOrWhiteSpace(snapshot.PipelineHash))); + + var manifestPath = Path.Combine(_tempRoot, "assets", detail.Run.ResultRootPath.Replace('/', Path.DirectorySeparatorChar), "manifest.json"); + Assert.True(File.Exists(manifestPath)); + } + + [Fact] + public async Task AppendNodeResult_MissingAsset_DoesNotCrashAndMarksAssetMissing() + { + var run = new InspectionRunRecord + { + ProgramName = "Program-A", + WorkpieceId = "Part-01", + SerialNumber = "SN-404" + }; + + await _store.BeginRunAsync(run); + + var nodeId = Guid.NewGuid(); + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = nodeId, + NodeIndex = 1, + NodeName = "缺图节点", + PipelineId = Guid.NewGuid(), + PipelineName = "Recipe-Missing", + NodePass = true + }, + new[] + { + new InspectionMetricResult + { + MetricKey = "metric.only", + MetricName = "Metric Only", + MetricValue = 1, + Unit = "pcs", + IsPass = true, + DisplayOrder = 1 + } + }, + new PipelineExecutionSnapshot + { + PipelineName = "Recipe-Missing", + PipelineDefinitionJson = "{\"nodes\":[\"gaussian\"]}" + }, + new[] + { + new InspectionAssetWriteRequest + { + AssetType = InspectionAssetType.NodeResultImage, + SourceFilePath = Path.Combine(_tempRoot, "missing-file.bmp"), + FileFormat = "bmp" + } + }); + + var detail = await _store.GetRunDetailAsync(run.RunId); + var node = Assert.Single(detail.Nodes); + + Assert.Equal(InspectionNodeStatus.AssetMissing, node.Status); + Assert.Single(detail.Metrics); + Assert.Empty(detail.Assets); + } + + [Fact] + public async Task PipelineSnapshot_IsStoredAsExecutionSnapshot_NotDependentOnLaterChanges() + { + var run = new InspectionRunRecord + { + ProgramName = "Program-Snapshot", + WorkpieceId = "Part-02", + SerialNumber = "SN-SNAP" + }; + + await _store.BeginRunAsync(run); + + var pipeline = BuildPipeline("Recipe-Snapshot", ("GaussianBlur", 0), ("ContourDetection", 1)); + var snapshotJson = JsonSerializer.Serialize(pipeline); + var originalHash = ComputeExpectedHash(snapshotJson); + + await _store.AppendNodeResultAsync( + new InspectionNodeResult + { + RunId = run.RunId, + NodeId = Guid.NewGuid(), + NodeIndex = 1, + NodeName = "快照节点", + PipelineId = pipeline.Id, + PipelineName = pipeline.Name, + NodePass = true + }, + pipelineSnapshot: new PipelineExecutionSnapshot + { + PipelineName = pipeline.Name, + PipelineDefinitionJson = snapshotJson + }); + + pipeline.Name = "Recipe-Snapshot-Changed"; + pipeline.Nodes[0].OperatorKey = "MeanFilter"; + + var detail = await _store.GetRunDetailAsync(run.RunId); + var snapshot = Assert.Single(detail.PipelineSnapshots); + + Assert.Equal("Recipe-Snapshot", snapshot.PipelineName); + Assert.Equal(snapshotJson, snapshot.PipelineDefinitionJson); + Assert.Equal(originalHash, snapshot.PipelineHash); + } + + public void Dispose() + { + _dbContext.Dispose(); + if (Directory.Exists(_tempRoot)) + { + try + { + Directory.Delete(_tempRoot, true); + } + catch (IOException) + { + // SQLite file handles may release slightly after test teardown. + } + } + } + + private string CreateTempFile(string fileName, string content) + { + var path = Path.Combine(_tempRoot, fileName); + File.WriteAllText(path, content); + return path; + } + + private static PipelineModel BuildPipeline(string name, params (string OperatorKey, int Order)[] nodes) + { + return new PipelineModel + { + Name = name, + Nodes = nodes.Select(node => new PipelineNodeModel + { + OperatorKey = node.OperatorKey, + Order = node.Order + }).ToList() + }; + } + + private static string ComputeExpectedHash(string value) + { + using var sha = System.Security.Cryptography.SHA256.Create(); + var bytes = System.Text.Encoding.UTF8.GetBytes(value); + return Convert.ToHexString(sha.ComputeHash(bytes)); + } + } +} diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index 6f1bd73..c9258a7 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -36,6 +36,7 @@ using XplorePlane.Services.Camera; using XplorePlane.Services.Cnc; using XplorePlane.Services.Matrix; using XplorePlane.Services.Measurement; +using XplorePlane.Services.InspectionResults; using XplorePlane.Services.Recipe; using XplorePlane.ViewModels; using XplorePlane.ViewModels.Cnc; @@ -317,6 +318,7 @@ namespace XplorePlane containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); // ── CNC / 矩阵 ViewModel(瞬态)── containerRegistry.Register(); @@ -354,4 +356,4 @@ namespace XplorePlane base.ConfigureModuleCatalog(moduleCatalog); } } -} \ No newline at end of file +} diff --git a/XplorePlane/Models/InspectionResultModels.cs b/XplorePlane/Models/InspectionResultModels.cs new file mode 100644 index 0000000..9f439b2 --- /dev/null +++ b/XplorePlane/Models/InspectionResultModels.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; + +namespace XplorePlane.Models +{ + public enum InspectionAssetType + { + RunSourceImage, + NodeInputImage, + NodeResultImage + } + + public enum InspectionNodeStatus + { + Succeeded, + Failed, + PartialSuccess, + AssetMissing + } + + public class InspectionRunRecord + { + public Guid RunId { get; set; } = Guid.NewGuid(); + public string ProgramName { get; set; } = string.Empty; + public string WorkpieceId { get; set; } = string.Empty; + public string SerialNumber { get; set; } = string.Empty; + public DateTime StartedAt { get; set; } = DateTime.UtcNow; + public DateTime? CompletedAt { get; set; } + public bool OverallPass { get; set; } + public string SourceImagePath { get; set; } = string.Empty; + public string ResultRootPath { get; set; } = string.Empty; + public int NodeCount { get; set; } + } + + public class InspectionNodeResult + { + public Guid RunId { get; set; } + public Guid NodeId { get; set; } = Guid.NewGuid(); + public int NodeIndex { get; set; } + public string NodeName { get; set; } = string.Empty; + public Guid PipelineId { get; set; } + public string PipelineName { get; set; } = string.Empty; + public string PipelineVersionHash { get; set; } = string.Empty; + public bool NodePass { get; set; } + public string SourceImagePath { get; set; } = string.Empty; + public string ResultImagePath { get; set; } = string.Empty; + public InspectionNodeStatus Status { get; set; } = InspectionNodeStatus.Succeeded; + public long DurationMs { get; set; } + } + + public class InspectionMetricResult + { + public Guid RunId { get; set; } + public Guid NodeId { get; set; } + public string MetricKey { get; set; } = string.Empty; + public string MetricName { get; set; } = string.Empty; + public double MetricValue { get; set; } + public string Unit { get; set; } = string.Empty; + public double? LowerLimit { get; set; } + public double? UpperLimit { get; set; } + public bool IsPass { get; set; } + public int DisplayOrder { get; set; } + } + + public class InspectionAssetRecord + { + public Guid RunId { get; set; } + public Guid? NodeId { get; set; } + public InspectionAssetType AssetType { get; set; } + public string RelativePath { get; set; } = string.Empty; + public string FileFormat { get; set; } = string.Empty; + public int Width { get; set; } + public int Height { get; set; } + } + + public class PipelineExecutionSnapshot + { + public Guid RunId { get; set; } + public Guid NodeId { get; set; } + public string PipelineName { get; set; } = string.Empty; + public string PipelineDefinitionJson { get; set; } = string.Empty; + public string PipelineHash { get; set; } = string.Empty; + } + + public class InspectionAssetWriteRequest + { + public InspectionAssetType AssetType { get; set; } + public string FileName { get; set; } = string.Empty; + public string SourceFilePath { get; set; } = string.Empty; + public byte[] Content { get; set; } + public string FileFormat { get; set; } = string.Empty; + public int Width { get; set; } + public int Height { get; set; } + } + + public class InspectionRunQuery + { + public string ProgramName { get; set; } = string.Empty; + public string WorkpieceId { get; set; } = string.Empty; + public string SerialNumber { get; set; } = string.Empty; + public string PipelineName { get; set; } = string.Empty; + public DateTime? From { get; set; } + public DateTime? To { get; set; } + public int? Skip { get; set; } + public int? Take { get; set; } + } + + public class InspectionRunDetail + { + public InspectionRunRecord Run { get; set; } = new(); + public IReadOnlyList Nodes { get; set; } = Array.Empty(); + public IReadOnlyList Metrics { get; set; } = Array.Empty(); + public IReadOnlyList Assets { get; set; } = Array.Empty(); + public IReadOnlyList PipelineSnapshots { get; set; } = Array.Empty(); + } +} diff --git a/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs b/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs new file mode 100644 index 0000000..7944723 --- /dev/null +++ b/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services.InspectionResults +{ + public interface IInspectionResultStore + { + Task BeginRunAsync(InspectionRunRecord runRecord, InspectionAssetWriteRequest runSourceAsset = null); + + Task AppendNodeResultAsync( + InspectionNodeResult nodeResult, + IEnumerable metrics = null, + PipelineExecutionSnapshot pipelineSnapshot = null, + IEnumerable assets = null); + + Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null); + + Task> QueryRunsAsync(InspectionRunQuery query = null); + + Task GetRunDetailAsync(Guid runId); + } +} diff --git a/XplorePlane/Services/InspectionResults/InspectionResultStore.cs b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs new file mode 100644 index 0000000..88c046f --- /dev/null +++ b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs @@ -0,0 +1,1006 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using System.Threading.Tasks; +using XP.Common.Database.Interfaces; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; + +namespace XplorePlane.Services.InspectionResults +{ + public class InspectionResultStore : IInspectionResultStore + { + private readonly IDbContext _db; + private readonly ILoggerService _logger; + private readonly string _baseDirectory; + private bool _initialized; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + Converters = { new JsonStringEnumConverter() } + }; + + private const string CreateTableSql = @" +CREATE TABLE IF NOT EXISTS inspection_runs ( + run_id TEXT PRIMARY KEY, + program_name TEXT NOT NULL, + workpiece_id TEXT NOT NULL, + serial_number TEXT NOT NULL, + started_at TEXT NOT NULL, + completed_at TEXT NULL, + overall_pass INTEGER NOT NULL DEFAULT 0, + source_image_path TEXT NOT NULL, + result_root_path TEXT NOT NULL, + node_count INTEGER NOT NULL DEFAULT 0 +); +CREATE INDEX IF NOT EXISTS idx_inspection_runs_started_at ON inspection_runs(started_at); +CREATE INDEX IF NOT EXISTS idx_inspection_runs_program_name ON inspection_runs(program_name); +CREATE INDEX IF NOT EXISTS idx_inspection_runs_workpiece_id ON inspection_runs(workpiece_id); +CREATE INDEX IF NOT EXISTS idx_inspection_runs_serial_number ON inspection_runs(serial_number); + +CREATE TABLE IF NOT EXISTS inspection_node_results ( + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + node_index INTEGER NOT NULL, + node_name TEXT NOT NULL, + pipeline_id TEXT NOT NULL, + pipeline_name TEXT NOT NULL, + pipeline_version_hash TEXT NOT NULL, + node_pass INTEGER NOT NULL DEFAULT 0, + source_image_path TEXT NOT NULL, + result_image_path TEXT NOT NULL, + status TEXT NOT NULL, + duration_ms INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, node_id) +); +CREATE INDEX IF NOT EXISTS idx_inspection_nodes_run_id ON inspection_node_results(run_id); +CREATE INDEX IF NOT EXISTS idx_inspection_nodes_pipeline_name ON inspection_node_results(pipeline_name); + +CREATE TABLE IF NOT EXISTS inspection_metric_results ( + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + metric_key TEXT NOT NULL, + metric_name TEXT NOT NULL, + metric_value REAL NOT NULL, + unit TEXT NOT NULL, + lower_limit REAL NULL, + upper_limit REAL NULL, + is_pass INTEGER NOT NULL DEFAULT 0, + display_order INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, node_id, metric_key) +); +CREATE INDEX IF NOT EXISTS idx_inspection_metrics_run_node ON inspection_metric_results(run_id, node_id); + +CREATE TABLE IF NOT EXISTS inspection_assets ( + run_id TEXT NOT NULL, + node_id TEXT NULL, + asset_type TEXT NOT NULL, + relative_path TEXT NOT NULL, + file_format TEXT NOT NULL, + width INTEGER NOT NULL DEFAULT 0, + height INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (run_id, node_id, asset_type, relative_path) +); +CREATE INDEX IF NOT EXISTS idx_inspection_assets_run_node ON inspection_assets(run_id, node_id); + +CREATE TABLE IF NOT EXISTS pipeline_execution_snapshots ( + run_id TEXT NOT NULL, + node_id TEXT NOT NULL, + pipeline_name TEXT NOT NULL, + pipeline_definition_json TEXT NOT NULL, + pipeline_hash TEXT NOT NULL, + PRIMARY KEY (run_id, node_id) +); +CREATE INDEX IF NOT EXISTS idx_pipeline_snapshots_run_id ON pipeline_execution_snapshots(run_id);"; + + private const string InsertRunSql = @" +INSERT INTO inspection_runs ( + run_id, program_name, workpiece_id, serial_number, started_at, completed_at, + overall_pass, source_image_path, result_root_path, node_count) +VALUES ( + @run_id, @program_name, @workpiece_id, @serial_number, @started_at, @completed_at, + @overall_pass, @source_image_path, @result_root_path, @node_count)"; + + private const string InsertNodeSql = @" +INSERT OR REPLACE INTO inspection_node_results ( + run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash, + node_pass, source_image_path, result_image_path, status, duration_ms) +VALUES ( + @run_id, @node_id, @node_index, @node_name, @pipeline_id, @pipeline_name, @pipeline_version_hash, + @node_pass, @source_image_path, @result_image_path, @status, @duration_ms)"; + + private const string InsertMetricSql = @" +INSERT OR REPLACE INTO inspection_metric_results ( + run_id, node_id, metric_key, metric_name, metric_value, unit, + lower_limit, upper_limit, is_pass, display_order) +VALUES ( + @run_id, @node_id, @metric_key, @metric_name, @metric_value, @unit, + @lower_limit, @upper_limit, @is_pass, @display_order)"; + + private const string InsertAssetSql = @" +INSERT OR REPLACE INTO inspection_assets ( + run_id, node_id, asset_type, relative_path, file_format, width, height) +VALUES ( + @run_id, @node_id, @asset_type, @relative_path, @file_format, @width, @height)"; + + private const string InsertSnapshotSql = @" +INSERT OR REPLACE INTO pipeline_execution_snapshots ( + run_id, node_id, pipeline_name, pipeline_definition_json, pipeline_hash) +VALUES ( + @run_id, @node_id, @pipeline_name, @pipeline_definition_json, @pipeline_hash)"; + + private const string DeleteNodeMetricsSql = "DELETE FROM inspection_metric_results WHERE run_id = @run_id AND node_id = @node_id"; + private const string DeleteNodeAssetsSql = "DELETE FROM inspection_assets WHERE run_id = @run_id AND node_id = @node_id"; + + private const string UpdateRunSql = @" +UPDATE inspection_runs +SET completed_at = @completed_at, + overall_pass = @overall_pass, + node_count = @node_count, + source_image_path = @source_image_path +WHERE run_id = @run_id"; + + public InspectionResultStore(IDbContext db, ILoggerService logger, string baseDirectory = null) + { + ArgumentNullException.ThrowIfNull(db); + ArgumentNullException.ThrowIfNull(logger); + + _db = db; + _logger = logger.ForModule(); + _baseDirectory = baseDirectory ?? Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "XplorePlane", + "InspectionResults"); + } + + public async Task BeginRunAsync(InspectionRunRecord runRecord, InspectionAssetWriteRequest runSourceAsset = null) + { + ArgumentNullException.ThrowIfNull(runRecord); + await EnsureInitializedAsync().ConfigureAwait(false); + + NormalizeRunRecord(runRecord); + ValidateRunRecord(runRecord); + + var runDirectory = GetRunDirectory(runRecord.StartedAt, runRecord.RunId); + Directory.CreateDirectory(Path.Combine(runDirectory, "run")); + Directory.CreateDirectory(Path.Combine(runDirectory, "nodes")); + + if (runSourceAsset != null) + { + var savedAsset = await TryPersistAssetAsync( + runRecord.RunId, + null, + nodeIndex: null, + nodeName: null, + runRecord.ResultRootPath, + runSourceAsset).ConfigureAwait(false); + + if (savedAsset != null) + { + runRecord.SourceImagePath = savedAsset.RelativePath; + await SaveAssetRecordAsync(savedAsset).ConfigureAwait(false); + } + } + + var result = await _db.ExecuteNonQueryAsync(InsertRunSql, new Dictionary + { + ["run_id"] = runRecord.RunId.ToString("D"), + ["program_name"] = runRecord.ProgramName, + ["workpiece_id"] = runRecord.WorkpieceId, + ["serial_number"] = runRecord.SerialNumber, + ["started_at"] = runRecord.StartedAt.ToString("o"), + ["completed_at"] = runRecord.CompletedAt?.ToString("o"), + ["overall_pass"] = runRecord.OverallPass ? 1 : 0, + ["source_image_path"] = runRecord.SourceImagePath, + ["result_root_path"] = runRecord.ResultRootPath, + ["node_count"] = runRecord.NodeCount + }).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "创建检测实例失败 | Failed to create inspection run: {Message}", result.Message); + throw new InvalidOperationException($"创建检测实例失败: {result.Message}", result.Exception); + } + } + + public async Task AppendNodeResultAsync( + InspectionNodeResult nodeResult, + IEnumerable metrics = null, + PipelineExecutionSnapshot pipelineSnapshot = null, + IEnumerable assets = null) + { + ArgumentNullException.ThrowIfNull(nodeResult); + await EnsureInitializedAsync().ConfigureAwait(false); + ValidateNodeResult(nodeResult); + await EnsureRunExistsAsync(nodeResult.RunId).ConfigureAwait(false); + + var assetRecords = new List(); + var assetFailureMode = (InspectionNodeStatus?)null; + foreach (var asset in assets ?? Enumerable.Empty()) + { + var savedAsset = await TryPersistAssetAsync( + nodeResult.RunId, + nodeResult.NodeId, + nodeResult.NodeIndex, + nodeResult.NodeName, + await GetRunRootRelativePathAsync(nodeResult.RunId).ConfigureAwait(false), + asset).ConfigureAwait(false); + + if (savedAsset == null) + { + assetFailureMode ??= ResolveAssetFailureMode(asset); + continue; + } + + assetRecords.Add(savedAsset); + + if (asset.AssetType == InspectionAssetType.NodeInputImage && string.IsNullOrWhiteSpace(nodeResult.SourceImagePath)) + { + nodeResult.SourceImagePath = savedAsset.RelativePath; + } + + if (asset.AssetType == InspectionAssetType.NodeResultImage && string.IsNullOrWhiteSpace(nodeResult.ResultImagePath)) + { + nodeResult.ResultImagePath = savedAsset.RelativePath; + } + } + + if (pipelineSnapshot != null) + { + pipelineSnapshot.RunId = nodeResult.RunId; + pipelineSnapshot.NodeId = nodeResult.NodeId; + if (string.IsNullOrWhiteSpace(pipelineSnapshot.PipelineName)) + { + pipelineSnapshot.PipelineName = nodeResult.PipelineName; + } + + if (string.IsNullOrWhiteSpace(pipelineSnapshot.PipelineHash)) + { + pipelineSnapshot.PipelineHash = ComputeSha256(pipelineSnapshot.PipelineDefinitionJson); + } + + if (string.IsNullOrWhiteSpace(nodeResult.PipelineVersionHash)) + { + nodeResult.PipelineVersionHash = pipelineSnapshot.PipelineHash; + } + } + + if (string.IsNullOrWhiteSpace(nodeResult.PipelineVersionHash)) + { + nodeResult.PipelineVersionHash = ComputeSha256(nodeResult.PipelineName); + } + + if (assetFailureMode.HasValue && nodeResult.Status == InspectionNodeStatus.Succeeded) + { + nodeResult.Status = assetFailureMode.Value; + } + + var metricsList = (metrics ?? Enumerable.Empty()) + .Select(metric => + { + metric.RunId = nodeResult.RunId; + metric.NodeId = nodeResult.NodeId; + return metric; + }) + .ToList(); + + await _db.ExecuteNonQueryAsync(DeleteNodeMetricsSql, new Dictionary + { + ["run_id"] = nodeResult.RunId.ToString("D"), + ["node_id"] = nodeResult.NodeId.ToString("D") + }).ConfigureAwait(false); + + await _db.ExecuteNonQueryAsync(DeleteNodeAssetsSql, new Dictionary + { + ["run_id"] = nodeResult.RunId.ToString("D"), + ["node_id"] = nodeResult.NodeId.ToString("D") + }).ConfigureAwait(false); + + var saveNode = await _db.ExecuteNonQueryAsync(InsertNodeSql, new Dictionary + { + ["run_id"] = nodeResult.RunId.ToString("D"), + ["node_id"] = nodeResult.NodeId.ToString("D"), + ["node_index"] = nodeResult.NodeIndex, + ["node_name"] = nodeResult.NodeName, + ["pipeline_id"] = nodeResult.PipelineId.ToString("D"), + ["pipeline_name"] = nodeResult.PipelineName, + ["pipeline_version_hash"] = nodeResult.PipelineVersionHash, + ["node_pass"] = nodeResult.NodePass ? 1 : 0, + ["source_image_path"] = nodeResult.SourceImagePath, + ["result_image_path"] = nodeResult.ResultImagePath, + ["status"] = nodeResult.Status.ToString(), + ["duration_ms"] = nodeResult.DurationMs + }).ConfigureAwait(false); + + if (!saveNode.IsSuccess) + { + _logger.Error(saveNode.Exception!, "保存节点检测结果失败 | Failed to save inspection node result: {Message}", saveNode.Message); + throw new InvalidOperationException($"保存节点检测结果失败: {saveNode.Message}", saveNode.Exception); + } + + foreach (var metric in metricsList) + { + var metricResult = await _db.ExecuteNonQueryAsync(InsertMetricSql, new Dictionary + { + ["run_id"] = metric.RunId.ToString("D"), + ["node_id"] = metric.NodeId.ToString("D"), + ["metric_key"] = metric.MetricKey, + ["metric_name"] = metric.MetricName, + ["metric_value"] = metric.MetricValue, + ["unit"] = metric.Unit, + ["lower_limit"] = metric.LowerLimit, + ["upper_limit"] = metric.UpperLimit, + ["is_pass"] = metric.IsPass ? 1 : 0, + ["display_order"] = metric.DisplayOrder + }).ConfigureAwait(false); + + if (!metricResult.IsSuccess) + { + _logger.Error(metricResult.Exception!, "保存检测指标失败 | Failed to save inspection metric: {Message}", metricResult.Message); + throw new InvalidOperationException($"保存检测指标失败: {metricResult.Message}", metricResult.Exception); + } + } + + foreach (var assetRecord in assetRecords) + { + await SaveAssetRecordAsync(assetRecord).ConfigureAwait(false); + } + + if (pipelineSnapshot != null) + { + var snapshotResult = await _db.ExecuteNonQueryAsync(InsertSnapshotSql, new Dictionary + { + ["run_id"] = pipelineSnapshot.RunId.ToString("D"), + ["node_id"] = pipelineSnapshot.NodeId.ToString("D"), + ["pipeline_name"] = pipelineSnapshot.PipelineName, + ["pipeline_definition_json"] = pipelineSnapshot.PipelineDefinitionJson, + ["pipeline_hash"] = pipelineSnapshot.PipelineHash + }).ConfigureAwait(false); + + if (!snapshotResult.IsSuccess) + { + _logger.Error(snapshotResult.Exception!, "保存流水线快照失败 | Failed to save pipeline snapshot: {Message}", snapshotResult.Message); + throw new InvalidOperationException($"保存流水线快照失败: {snapshotResult.Message}", snapshotResult.Exception); + } + } + } + + public async Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null) + { + await EnsureInitializedAsync().ConfigureAwait(false); + await EnsureRunExistsAsync(runId).ConfigureAwait(false); + + var detail = await GetRunDetailAsync(runId).ConfigureAwait(false); + var run = detail.Run; + run.CompletedAt = completedAt ?? DateTime.UtcNow; + run.NodeCount = detail.Nodes.Count; + run.OverallPass = overallPass ?? detail.Nodes.All(node => node.NodePass); + + var update = await _db.ExecuteNonQueryAsync(UpdateRunSql, new Dictionary + { + ["run_id"] = run.RunId.ToString("D"), + ["completed_at"] = run.CompletedAt?.ToString("o"), + ["overall_pass"] = run.OverallPass ? 1 : 0, + ["node_count"] = run.NodeCount, + ["source_image_path"] = run.SourceImagePath + }).ConfigureAwait(false); + + if (!update.IsSuccess) + { + _logger.Error(update.Exception!, "完成检测实例失败 | Failed to complete inspection run: {Message}", update.Message); + throw new InvalidOperationException($"完成检测实例失败: {update.Message}", update.Exception); + } + + detail.Run = run; + await WriteManifestAsync(detail).ConfigureAwait(false); + } + + public async Task> QueryRunsAsync(InspectionRunQuery query = null) + { + await EnsureInitializedAsync().ConfigureAwait(false); + query ??= new InspectionRunQuery(); + + var sql = new StringBuilder(); + sql.Append("SELECT DISTINCT r.run_id, r.program_name, r.workpiece_id, r.serial_number, r.started_at, r.completed_at, r.overall_pass, r.source_image_path, r.result_root_path, r.node_count FROM inspection_runs r"); + var conditions = new List(); + var parameters = new Dictionary(); + + if (!string.IsNullOrWhiteSpace(query.PipelineName)) + { + sql.Append(" INNER JOIN inspection_node_results n ON n.run_id = r.run_id"); + conditions.Add("n.pipeline_name = @pipeline_name"); + parameters["pipeline_name"] = query.PipelineName; + } + + if (!string.IsNullOrWhiteSpace(query.ProgramName)) + { + conditions.Add("r.program_name = @program_name"); + parameters["program_name"] = query.ProgramName; + } + + if (!string.IsNullOrWhiteSpace(query.WorkpieceId)) + { + conditions.Add("r.workpiece_id = @workpiece_id"); + parameters["workpiece_id"] = query.WorkpieceId; + } + + if (!string.IsNullOrWhiteSpace(query.SerialNumber)) + { + conditions.Add("r.serial_number = @serial_number"); + parameters["serial_number"] = query.SerialNumber; + } + + if (query.From.HasValue) + { + conditions.Add("r.started_at >= @from"); + parameters["from"] = query.From.Value.ToString("o"); + } + + if (query.To.HasValue) + { + conditions.Add("r.started_at <= @to"); + parameters["to"] = query.To.Value.ToString("o"); + } + + if (conditions.Count > 0) + { + sql.Append(" WHERE "); + sql.Append(string.Join(" AND ", conditions)); + } + + sql.Append(" ORDER BY r.started_at DESC"); + + if (query.Take.HasValue) + { + sql.Append(" LIMIT @take"); + parameters["take"] = query.Take.Value; + + if (query.Skip.GetValueOrDefault() > 0) + { + sql.Append(" OFFSET @skip"); + parameters["skip"] = query.Skip.Value; + } + } + + var (result, data) = await _db.QueryListAsync(sql.ToString(), parameters).ConfigureAwait(false); + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "查询检测实例失败 | Failed to query inspection runs: {Message}", result.Message); + throw new InvalidOperationException($"查询检测实例失败: {result.Message}", result.Exception); + } + + return data.Select(MapRun).ToList().AsReadOnly(); + } + + public async Task GetRunDetailAsync(Guid runId) + { + await EnsureInitializedAsync().ConfigureAwait(false); + + var (runResult, runs) = await _db.QueryListAsync( + "SELECT run_id, program_name, workpiece_id, serial_number, started_at, completed_at, overall_pass, source_image_path, result_root_path, node_count FROM inspection_runs WHERE run_id = @run_id", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + if (!runResult.IsSuccess) + { + _logger.Error(runResult.Exception!, "加载检测实例失败 | Failed to load inspection run: {Message}", runResult.Message); + throw new InvalidOperationException($"加载检测实例失败: {runResult.Message}", runResult.Exception); + } + + if (runs.Count == 0) + { + throw new InvalidOperationException($"检测实例不存在: {runId}"); + } + + var run = MapRun(runs[0]); + + var (nodeResult, nodeRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, node_index, node_name, pipeline_id, pipeline_name, pipeline_version_hash, node_pass, source_image_path, result_image_path, status, duration_ms FROM inspection_node_results WHERE run_id = @run_id ORDER BY node_index ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + var (metricResult, metricRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, metric_key, metric_name, metric_value, unit, COALESCE(CAST(lower_limit AS TEXT), '') AS lower_limit, COALESCE(CAST(upper_limit AS TEXT), '') AS upper_limit, is_pass, display_order FROM inspection_metric_results WHERE run_id = @run_id ORDER BY node_id ASC, display_order ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + var (assetResult, assetRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, asset_type, relative_path, file_format, width, height FROM inspection_assets WHERE run_id = @run_id ORDER BY node_id ASC, asset_type ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + var (snapshotResult, snapshotRows) = await _db.QueryListAsync( + "SELECT run_id, node_id, pipeline_name, pipeline_definition_json, pipeline_hash FROM pipeline_execution_snapshots WHERE run_id = @run_id ORDER BY node_id ASC", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + ThrowIfQueryFailed(nodeResult, "加载检测节点结果失败"); + ThrowIfQueryFailed(metricResult, "加载检测指标结果失败"); + ThrowIfQueryFailed(assetResult, "加载检测资产结果失败"); + ThrowIfQueryFailed(snapshotResult, "加载流水线快照失败"); + + return new InspectionRunDetail + { + Run = run, + Nodes = nodeRows.Select(MapNode).ToList().AsReadOnly(), + Metrics = metricRows.Select(MapMetric).ToList().AsReadOnly(), + Assets = assetRows.Select(MapAsset).ToList().AsReadOnly(), + PipelineSnapshots = snapshotRows.Select(MapSnapshot).ToList().AsReadOnly() + }; + } + + private async Task EnsureInitializedAsync() + { + if (_initialized) + { + return; + } + + Directory.CreateDirectory(_baseDirectory); + var result = await _db.ExecuteNonQueryAsync(CreateTableSql).ConfigureAwait(false); + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "初始化检测结果归档失败 | Failed to initialize inspection result store: {Message}", result.Message); + throw new InvalidOperationException($"初始化检测结果归档失败: {result.Message}", result.Exception); + } + + _initialized = true; + } + + private async Task EnsureRunExistsAsync(Guid runId) + { + var (result, value) = await _db.ExecuteScalarAsync( + "SELECT COUNT(*) FROM inspection_runs WHERE run_id = @run_id", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "校验检测实例失败 | Failed to validate inspection run: {Message}", result.Message); + throw new InvalidOperationException($"校验检测实例失败: {result.Message}", result.Exception); + } + + if (value <= 0) + { + throw new InvalidOperationException($"检测实例不存在: {runId}"); + } + } + + private async Task SaveAssetRecordAsync(InspectionAssetRecord assetRecord) + { + var saveAsset = await _db.ExecuteNonQueryAsync(InsertAssetSql, new Dictionary + { + ["run_id"] = assetRecord.RunId.ToString("D"), + ["node_id"] = assetRecord.NodeId?.ToString("D"), + ["asset_type"] = assetRecord.AssetType.ToString(), + ["relative_path"] = assetRecord.RelativePath, + ["file_format"] = assetRecord.FileFormat, + ["width"] = assetRecord.Width, + ["height"] = assetRecord.Height + }).ConfigureAwait(false); + + if (!saveAsset.IsSuccess) + { + _logger.Error(saveAsset.Exception!, "保存检测资产索引失败 | Failed to save inspection asset record: {Message}", saveAsset.Message); + throw new InvalidOperationException($"保存检测资产索引失败: {saveAsset.Message}", saveAsset.Exception); + } + } + + private async Task TryPersistAssetAsync( + Guid runId, + Guid? nodeId, + int? nodeIndex, + string nodeName, + string runRelativeRoot, + InspectionAssetWriteRequest assetRequest) + { + try + { + if (assetRequest == null) + { + return null; + } + + var bytes = assetRequest.Content; + if (bytes == null) + { + if (string.IsNullOrWhiteSpace(assetRequest.SourceFilePath) || !File.Exists(assetRequest.SourceFilePath)) + { + throw new FileNotFoundException("资产源文件不存在", assetRequest.SourceFilePath); + } + + bytes = await File.ReadAllBytesAsync(assetRequest.SourceFilePath).ConfigureAwait(false); + } + + var relativePath = BuildAssetRelativePath(runRelativeRoot, nodeIndex, nodeName, assetRequest); + var absolutePath = Path.Combine(_baseDirectory, relativePath); + var targetDirectory = Path.GetDirectoryName(absolutePath); + if (!string.IsNullOrWhiteSpace(targetDirectory)) + { + Directory.CreateDirectory(targetDirectory); + } + + await File.WriteAllBytesAsync(absolutePath, bytes).ConfigureAwait(false); + + return new InspectionAssetRecord + { + RunId = runId, + NodeId = nodeId, + AssetType = assetRequest.AssetType, + RelativePath = relativePath.Replace('\\', '/'), + FileFormat = ResolveFileFormat(assetRequest), + Width = assetRequest.Width, + Height = assetRequest.Height + }; + } + catch (Exception ex) + { + _logger.Warn("检测资产保存失败 | Failed to persist inspection asset: {Message}", ex.Message); + return null; + } + } + + private async Task WriteManifestAsync(InspectionRunDetail detail) + { + var runDirectory = Path.Combine(_baseDirectory, detail.Run.ResultRootPath); + Directory.CreateDirectory(runDirectory); + var manifestPath = Path.Combine(runDirectory, "manifest.json"); + var json = JsonSerializer.Serialize(detail, JsonOptions); + await File.WriteAllTextAsync(manifestPath, json).ConfigureAwait(false); + } + + private string BuildAssetRelativePath(string runRelativeRoot, int? nodeIndex, string nodeName, InspectionAssetWriteRequest assetRequest) + { + if (assetRequest.AssetType == InspectionAssetType.RunSourceImage) + { + return Path.Combine(runRelativeRoot, "run", ResolveAssetFileName(assetRequest, "source")); + } + + var nodeFolder = $"{nodeIndex.GetValueOrDefault():D3}_{SanitizePathSegment(nodeName)}"; + return Path.Combine(runRelativeRoot, "nodes", nodeFolder, ResolveNodeAssetFileName(assetRequest)); + } + + private string ResolveNodeAssetFileName(InspectionAssetWriteRequest assetRequest) + { + var extension = ResolveExtension(assetRequest); + return assetRequest.AssetType switch + { + InspectionAssetType.NodeInputImage => $"input{extension}", + InspectionAssetType.NodeResultImage => $"result_overlay{extension}", + _ => ResolveAssetFileName(assetRequest, "asset") + }; + } + + private string ResolveAssetFileName(InspectionAssetWriteRequest assetRequest, string fallbackName) + { + var extension = ResolveExtension(assetRequest); + if (!string.IsNullOrWhiteSpace(assetRequest.FileName)) + { + return Path.GetFileName(assetRequest.FileName); + } + + return $"{fallbackName}{extension}"; + } + + private string ResolveExtension(InspectionAssetWriteRequest assetRequest) + { + if (!string.IsNullOrWhiteSpace(assetRequest.FileName)) + { + var extension = Path.GetExtension(assetRequest.FileName); + if (!string.IsNullOrWhiteSpace(extension)) + { + return extension; + } + } + + if (!string.IsNullOrWhiteSpace(assetRequest.SourceFilePath)) + { + var extension = Path.GetExtension(assetRequest.SourceFilePath); + if (!string.IsNullOrWhiteSpace(extension)) + { + return extension; + } + } + + if (!string.IsNullOrWhiteSpace(assetRequest.FileFormat)) + { + return "." + assetRequest.FileFormat.Trim().TrimStart('.'); + } + + return ".bin"; + } + + private string ResolveFileFormat(InspectionAssetWriteRequest assetRequest) + { + if (!string.IsNullOrWhiteSpace(assetRequest.FileFormat)) + { + return assetRequest.FileFormat.Trim().TrimStart('.'); + } + + var extension = ResolveExtension(assetRequest); + return extension.Trim().TrimStart('.'); + } + + private string GetRunDirectory(DateTime startedAt, Guid runId) + { + return Path.Combine(_baseDirectory, GetRunRelativeRoot(runId, startedAt)); + } + + private async Task GetRunRootRelativePathAsync(Guid runId) + { + var (result, value) = await _db.ExecuteScalarAsync( + "SELECT result_root_path FROM inspection_runs WHERE run_id = @run_id", + new Dictionary { ["run_id"] = runId.ToString("D") }).ConfigureAwait(false); + + if (!result.IsSuccess) + { + _logger.Error(result.Exception!, "读取检测实例目录失败 | Failed to get inspection run root path: {Message}", result.Message); + throw new InvalidOperationException($"读取检测实例目录失败: {result.Message}", result.Exception); + } + + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException($"检测实例目录不存在: {runId}"); + } + + return value.Replace('\\', '/'); + } + + private string GetRunRelativeRoot(Guid runId, DateTime? startedAt = null) + { + if (startedAt.HasValue) + { + return Path.Combine( + "Results", + startedAt.Value.ToString("yyyy"), + startedAt.Value.ToString("MM"), + startedAt.Value.ToString("dd"), + runId.ToString("D")); + } + + return Path.Combine("Results", runId.ToString("D")); + } + + private static string ComputeSha256(string value) + { + using var sha = SHA256.Create(); + var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); + var hash = sha.ComputeHash(bytes); + return Convert.ToHexString(hash); + } + + private static string SanitizePathSegment(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "unnamed"; + } + + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new string(value.Select(ch => invalidChars.Contains(ch) ? '_' : ch).ToArray()); + return sanitized.Replace(' ', '_'); + } + + private static InspectionNodeStatus ResolveAssetFailureMode(InspectionAssetWriteRequest assetRequest) + { + if (!string.IsNullOrWhiteSpace(assetRequest?.SourceFilePath) && !File.Exists(assetRequest.SourceFilePath)) + { + return InspectionNodeStatus.AssetMissing; + } + + return InspectionNodeStatus.PartialSuccess; + } + + private static void NormalizeRunRecord(InspectionRunRecord runRecord) + { + if (runRecord.RunId == Guid.Empty) + { + runRecord.RunId = Guid.NewGuid(); + } + + if (runRecord.StartedAt == default) + { + runRecord.StartedAt = DateTime.UtcNow; + } + + runRecord.ResultRootPath = Path.Combine( + "Results", + runRecord.StartedAt.ToString("yyyy"), + runRecord.StartedAt.ToString("MM"), + runRecord.StartedAt.ToString("dd"), + runRecord.RunId.ToString("D")).Replace('\\', '/'); + } + + private static void ValidateRunRecord(InspectionRunRecord runRecord) + { + if (string.IsNullOrWhiteSpace(runRecord.ProgramName)) + { + throw new ArgumentException("ProgramName 不能为空", nameof(runRecord)); + } + } + + private static void ValidateNodeResult(InspectionNodeResult nodeResult) + { + if (nodeResult.RunId == Guid.Empty) + { + throw new ArgumentException("NodeResult 必须带 RunId", nameof(nodeResult)); + } + + if (nodeResult.NodeId == Guid.Empty) + { + throw new ArgumentException("NodeResult 必须带 NodeId", nameof(nodeResult)); + } + + if (string.IsNullOrWhiteSpace(nodeResult.NodeName)) + { + throw new ArgumentException("NodeResult 必须带 NodeName", nameof(nodeResult)); + } + } + + private static InspectionRunRecord MapRun(InspectionRunRow row) + { + return new InspectionRunRecord + { + RunId = Guid.Parse(row.run_id), + ProgramName = row.program_name, + WorkpieceId = row.workpiece_id, + SerialNumber = row.serial_number, + StartedAt = DateTime.Parse(row.started_at, null, System.Globalization.DateTimeStyles.RoundtripKind), + CompletedAt = string.IsNullOrWhiteSpace(row.completed_at) + ? null + : DateTime.Parse(row.completed_at, null, System.Globalization.DateTimeStyles.RoundtripKind), + OverallPass = row.overall_pass != 0, + SourceImagePath = row.source_image_path, + ResultRootPath = row.result_root_path, + NodeCount = row.node_count + }; + } + + private static InspectionNodeResult MapNode(InspectionNodeRow row) + { + _ = Enum.TryParse(row.status, out var status); + return new InspectionNodeResult + { + RunId = Guid.Parse(row.run_id), + NodeId = Guid.Parse(row.node_id), + NodeIndex = row.node_index, + NodeName = row.node_name, + PipelineId = Guid.Parse(row.pipeline_id), + PipelineName = row.pipeline_name, + PipelineVersionHash = row.pipeline_version_hash, + NodePass = row.node_pass != 0, + SourceImagePath = row.source_image_path, + ResultImagePath = row.result_image_path, + Status = status, + DurationMs = row.duration_ms + }; + } + + private static InspectionMetricResult MapMetric(InspectionMetricRow row) + { + return new InspectionMetricResult + { + RunId = Guid.Parse(row.run_id), + NodeId = Guid.Parse(row.node_id), + MetricKey = row.metric_key, + MetricName = row.metric_name, + MetricValue = row.metric_value, + Unit = row.unit, + LowerLimit = ParseNullableDouble(row.lower_limit), + UpperLimit = ParseNullableDouble(row.upper_limit), + IsPass = row.is_pass != 0, + DisplayOrder = row.display_order + }; + } + + private static double? ParseNullableDouble(string value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + return double.Parse(value, System.Globalization.CultureInfo.InvariantCulture); + } + + private static void ThrowIfQueryFailed(XP.Common.Database.Interfaces.IDbExecuteResult result, string message) + { + if (!result.IsSuccess) + { + throw new InvalidOperationException(message, result.Exception); + } + } + + private static InspectionAssetRecord MapAsset(InspectionAssetRow row) + { + _ = Enum.TryParse(row.asset_type, out var assetType); + return new InspectionAssetRecord + { + RunId = Guid.Parse(row.run_id), + NodeId = string.IsNullOrWhiteSpace(row.node_id) ? null : Guid.Parse(row.node_id), + AssetType = assetType, + RelativePath = row.relative_path, + FileFormat = row.file_format, + Width = row.width, + Height = row.height + }; + } + + private static PipelineExecutionSnapshot MapSnapshot(PipelineSnapshotRow row) + { + return new PipelineExecutionSnapshot + { + RunId = Guid.Parse(row.run_id), + NodeId = Guid.Parse(row.node_id), + PipelineName = row.pipeline_name, + PipelineDefinitionJson = row.pipeline_definition_json, + PipelineHash = row.pipeline_hash + }; + } + + internal class InspectionRunRow + { + public string run_id { get; set; } = string.Empty; + public string program_name { get; set; } = string.Empty; + public string workpiece_id { get; set; } = string.Empty; + public string serial_number { get; set; } = string.Empty; + public string started_at { get; set; } = string.Empty; + public string completed_at { get; set; } = string.Empty; + public int overall_pass { get; set; } + public string source_image_path { get; set; } = string.Empty; + public string result_root_path { get; set; } = string.Empty; + public int node_count { get; set; } + } + + internal class InspectionNodeRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public int node_index { get; set; } + public string node_name { get; set; } = string.Empty; + public string pipeline_id { get; set; } = string.Empty; + public string pipeline_name { get; set; } = string.Empty; + public string pipeline_version_hash { get; set; } = string.Empty; + public int node_pass { get; set; } + public string source_image_path { get; set; } = string.Empty; + public string result_image_path { get; set; } = string.Empty; + public string status { get; set; } = string.Empty; + public long duration_ms { get; set; } + } + + internal class InspectionMetricRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public string metric_key { get; set; } = string.Empty; + public string metric_name { get; set; } = string.Empty; + public double metric_value { get; set; } + public string unit { get; set; } = string.Empty; + public string lower_limit { get; set; } = string.Empty; + public string upper_limit { get; set; } = string.Empty; + public int is_pass { get; set; } + public int display_order { get; set; } + } + + internal class InspectionAssetRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public string asset_type { get; set; } = string.Empty; + public string relative_path { get; set; } = string.Empty; + public string file_format { get; set; } = string.Empty; + public int width { get; set; } + public int height { get; set; } + } + + internal class PipelineSnapshotRow + { + public string run_id { get; set; } = string.Empty; + public string node_id { get; set; } = string.Empty; + public string pipeline_name { get; set; } = string.Empty; + public string pipeline_definition_json { get; set; } = string.Empty; + public string pipeline_hash { get; set; } = string.Empty; + } + } +} diff --git a/XplorePlane/Views/Cnc/CncEditorWindow.xaml b/XplorePlane/Views/Cnc/CncEditorWindow.xaml index 7496f51..1386766 100644 --- a/XplorePlane/Views/Cnc/CncEditorWindow.xaml +++ b/XplorePlane/Views/Cnc/CncEditorWindow.xaml @@ -4,10 +4,10 @@ xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:cnc="clr-namespace:XplorePlane.Views.Cnc" Title="CNC 编辑器" - Width="544" - Height="750" - MinWidth="544" - MinHeight="750" + Width="1180" + Height="780" + MinWidth="1040" + MinHeight="720" ResizeMode="CanResize" ShowInTaskbar="False" WindowStartupLocation="CenterOwner"> diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml b/XplorePlane/Views/Cnc/CncPageView.xaml index db3b2f1..10a65e5 100644 --- a/XplorePlane/Views/Cnc/CncPageView.xaml +++ b/XplorePlane/Views/Cnc/CncPageView.xaml @@ -8,7 +8,7 @@ xmlns:prism="http://prismlibrary.com/" xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc" d:DesignHeight="760" - d:DesignWidth="1040" + d:DesignWidth="1180" prism:ViewModelLocator.AutoWireViewModel="True" mc:Ignorable="d"> @@ -17,19 +17,35 @@ - - - - + + + + + + Microsoft YaHei UI + + @@ -41,10 +57,46 @@ + + + + + + + + + - + + + @@ -65,116 +119,116 @@ + Text="{Binding ProgramName, TargetNullValue=NewCncProgram}" /> + Margin="0,3,0,0" + FontFamily="{StaticResource UiFont}" + FontSize="10.5" + Foreground="#666666" + Text="模块节点下会自动显示标记、等待、消息等子节点" /> - + - + + + + + + +