diff --git a/.gitignore b/.gitignore index 2265021..1798086 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,5 @@ XplorePlane/Tests/ ExternalLibraries/Telerik/ build_out.txt XplorePlane/data/ +XplorePlane.Tests/bin_codex/ + diff --git a/XplorePlane.Tests/ViewModels/SnapshotManagerViewModelTests.cs b/XplorePlane.Tests/ViewModels/SnapshotManagerViewModelTests.cs new file mode 100644 index 0000000..4b41b50 --- /dev/null +++ b/XplorePlane.Tests/ViewModels/SnapshotManagerViewModelTests.cs @@ -0,0 +1,160 @@ +using Moq; +using System; +using System.Linq; +using System.Windows.Threading; +using Xunit; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.AppState; +using XplorePlane.ViewModels.Debug; + +namespace XplorePlane.Tests.ViewModels +{ + public class SnapshotManagerViewModelTests + { + private readonly Mock _mockAppStateService; + private readonly Mock _mockLoggerService; + private readonly Mock _mockLogger; + private readonly Dispatcher _dispatcher; + + public SnapshotManagerViewModelTests() + { + _mockAppStateService = new Mock(); + _mockLoggerService = new Mock(); + _mockLogger = new Mock(); + _mockLoggerService.Setup(x => x.ForModule()).Returns(_mockLogger.Object); + + // Create a dispatcher for the current thread + _dispatcher = Dispatcher.CurrentDispatcher; + } + + [Fact] + public void UpdateSnapshotDetails_WhenSnapshotSelected_ShouldPopulateSnapshotDetails() + { + // Arrange + var viewModel = new SnapshotManagerViewModel(_mockAppStateService.Object, _mockLoggerService.Object, _dispatcher); + + var snapshot = new StateSnapshot + { + Id = Guid.NewGuid(), + Timestamp = DateTime.Now, + MotionState = new MotionState( + StageX: 100.5, + StageY: 200.3, + SourceZ: 300.1, + DetectorZ: 400.2, + DetectorSwing: 45.0, + FDD: 1000.0, + StageXSpeed: 10.0, + StageYSpeed: 20.0, + SourceZSpeed: 30.0, + DetectorZSpeed: 40.0, + DetectorSwingSpeed: 5.0, + FDDSpeed: 50.0, + StageRotation: 0.0, + FixtureRotation: 0.0, + FOD: 500.0, + Magnification: 2.0, + StageRotationSpeed: 0.0, + FixtureRotationSpeed: 0.0 + ), + RaySourceState = new RaySourceState(IsOn: true, Voltage: 160.0, Power: 8.0), + DetectorState = new DetectorState(IsConnected: true, IsAcquiring: false, FrameRate: 30.0, Resolution: "1920x1080"), + SystemState = new SystemState(OperationMode: OperationMode.Idle, HasError: false, ErrorMessage: ""), + CameraState = new CameraState(IsConnected: true, IsStreaming: false, CurrentFrame: null, Width: 1920, Height: 1080, FrameRate: 30.0), + LinkedViewState = new LinkedViewState(TargetPosition: new PhysicalPosition(0, 0, 0), IsExecuting: false, LastRequestTime: DateTime.Now), + RecipeExecutionState = new RecipeExecutionState(CurrentStepIndex: 0, TotalSteps: 10, Status: RecipeExecutionStatus.Idle, CurrentRecipeName: "Test Recipe"), + CalibrationMatrix = new CalibrationMatrix(1, 0, 0, 0, 1, 0, 0, 0, 1) + }; + + var snapshotViewModel = new SnapshotViewModel { Snapshot = snapshot }; + + // Act + viewModel.SelectedSnapshot = snapshotViewModel; + + // Assert + Assert.NotEmpty(viewModel.SnapshotDetails); + Assert.Equal(8, viewModel.SnapshotDetails.Count); // Should have 8 root nodes (one for each state type) + + // Verify MotionState node exists and has children + var motionStateNode = viewModel.SnapshotDetails.FirstOrDefault(n => n.Name == "MotionState"); + Assert.NotNull(motionStateNode); + Assert.NotEmpty(motionStateNode.Children); + + // Verify specific field values + var stageXNode = motionStateNode.Children.FirstOrDefault(c => c.Name == "StageX"); + Assert.NotNull(stageXNode); + Assert.Equal("100.50", stageXNode.Value); + + // Verify RaySourceState node exists + var raySourceNode = viewModel.SnapshotDetails.FirstOrDefault(n => n.Name == "RaySourceState"); + Assert.NotNull(raySourceNode); + Assert.NotEmpty(raySourceNode.Children); + } + + [Fact] + public void UpdateSnapshotDetails_WhenNoSnapshotSelected_ShouldClearSnapshotDetails() + { + // Arrange + var viewModel = new SnapshotManagerViewModel(_mockAppStateService.Object, _mockLoggerService.Object, _dispatcher); + + // Add a snapshot first + var snapshot = new StateSnapshot + { + Id = Guid.NewGuid(), + Timestamp = DateTime.Now, + MotionState = new MotionState(100, 200, 300, 400, 45, 1000, 10, 20, 30, 40, 5, 50, 0, 0, 500, 2, 0, 0), + RaySourceState = new RaySourceState(true, 160, 8), + DetectorState = new DetectorState(true, false, 30, "1920x1080"), + SystemState = new SystemState(OperationMode.Idle, false, ""), + CameraState = new CameraState(true, false, null, 1920, 1080, 30), + LinkedViewState = new LinkedViewState(new PhysicalPosition(0, 0, 0), false, DateTime.Now), + RecipeExecutionState = new RecipeExecutionState(0, 10, RecipeExecutionStatus.Idle, "Test"), + CalibrationMatrix = new CalibrationMatrix(1, 0, 0, 0, 1, 0, 0, 0, 1) + }; + + viewModel.SelectedSnapshot = new SnapshotViewModel { Snapshot = snapshot }; + Assert.NotEmpty(viewModel.SnapshotDetails); + + // Act - Deselect snapshot + viewModel.SelectedSnapshot = null; + + // Assert + Assert.Empty(viewModel.SnapshotDetails); + } + + [Fact] + public void CaptureSnapshot_ShouldCreateSnapshotAndSelectIt() + { + // Arrange + var motionState = new MotionState(100, 200, 300, 400, 45, 1000, 10, 20, 30, 40, 5, 50, 0, 0, 500, 2, 0, 0); + var raySourceState = new RaySourceState(true, 160, 8); + var detectorState = new DetectorState(true, false, 30, "1920x1080"); + var systemState = new SystemState(OperationMode.Idle, false, ""); + var cameraState = new CameraState(true, false, null, 1920, 1080, 30); + var linkedViewState = new LinkedViewState(new PhysicalPosition(0, 0, 0), false, DateTime.Now); + var recipeState = new RecipeExecutionState(0, 10, RecipeExecutionStatus.Idle, "Test"); + var calibMatrix = new CalibrationMatrix(1, 0, 0, 0, 1, 0, 0, 0, 1); + + _mockAppStateService.Setup(x => x.MotionState).Returns(motionState); + _mockAppStateService.Setup(x => x.RaySourceState).Returns(raySourceState); + _mockAppStateService.Setup(x => x.DetectorState).Returns(detectorState); + _mockAppStateService.Setup(x => x.SystemState).Returns(systemState); + _mockAppStateService.Setup(x => x.CameraState).Returns(cameraState); + _mockAppStateService.Setup(x => x.LinkedViewState).Returns(linkedViewState); + _mockAppStateService.Setup(x => x.RecipeExecutionState).Returns(recipeState); + _mockAppStateService.Setup(x => x.CalibrationMatrix).Returns(calibMatrix); + + var viewModel = new SnapshotManagerViewModel(_mockAppStateService.Object, _mockLoggerService.Object, _dispatcher); + + // Act + viewModel.CaptureSnapshot(); + + // Assert + Assert.Single(viewModel.Snapshots); + Assert.NotNull(viewModel.SelectedSnapshot); + Assert.Equal(viewModel.Snapshots[0], viewModel.SelectedSnapshot); + Assert.NotEmpty(viewModel.SnapshotDetails); // Details should be populated for selected snapshot + } + } +} diff --git a/XplorePlane/Doc/CNC检测结果manifest说明.md b/XplorePlane/Doc/CNC检测结果manifest说明.md new file mode 100644 index 0000000..5ab833f --- /dev/null +++ b/XplorePlane/Doc/CNC检测结果manifest说明.md @@ -0,0 +1,793 @@ +# CNC 检测结果 manifest.json 说明文档 + +## 概述 + +`manifest.json` 是 CNC 程序执行完成后生成的检测结果清单文件,包含了一次完整检测运行的所有元数据和结果信息。该文件采用 JSON 格式存储,便于程序读取和人工查看。 + +## 文件位置 + +``` +{XpData根目录}/Data/InspectionResults/Results/{yyyy-MM-dd}/{RunId}/manifest.json +``` + +**示例路径:** +``` +D:/XPData/Data/InspectionResults/Results/2026-05-15/4b6a5cda-33ca-4419-a79c-b87f4e79e8d1/manifest.json +``` + +## 生成时机 + +`manifest.json` 在以下情况下生成或更新: + +1. **CNC 程序执行完成时** - 调用 `InspectionResultStore.CompleteRunAsync()` 时写入 +2. **包含完整的运行详情** - 包括所有节点结果、指标、资产和流水线快照 + +## JSON 结构 + +### 顶层结构 + +```json +{ + "Run": { ... }, // 运行记录 + "Nodes": [ ... ], // 节点结果列表 + "Metrics": [ ... ], // 检测指标列表 + "Assets": [ ... ], // 资产文件列表 + "PipelineSnapshots": [ ... ],// 流水线快照列表 + "Events": [ ... ] // 运行事件列表 +} +``` + +--- + +## 1. Run - 运行记录 + +记录本次 CNC 检测运行的基本信息。 + +### 字段说明 + +| 字段 | 类型 | 说明 | 示例 | +|------|------|------|------| +| `RunId` | string (GUID) | 运行唯一标识符 | `"4b6a5cda-33ca-4419-a79c-b87f4e79e8d1"` | +| `ProgramName` | string | CNC 程序名称 | `"测试.xp"` | +| `WorkpieceId` | string | 工件标识 | `"WP-2024-001"` | +| `SerialNumber` | string | 序列号 | `"SN-20240515-001"` | +| `StartedAt` | string (ISO 8601) | 开始时间 | `"2026-05-15T14:30:00.0000000Z"` | +| `CompletedAt` | string (ISO 8601) | 完成时间 | `"2026-05-15T14:35:00.0000000Z"` | +| `OverallPass` | boolean | 整体是否通过 | `true` | +| `Status` | string | 运行状态 | `"completed"` / `"stopped"` / `"error"` | +| `SourceImagePath` | string | 源图像相对路径 | `"run/source.png"` | +| `ResultRootPath` | string | 结果根路径 | `"Results/2026-05-15/4b6a5cda-..."` | +| `NodeCount` | number | 节点总数 | `5` | + +### 状态枚举 + +- `pending` - 等待执行 +- `running` - 正在执行 +- `completed` - 正常完成 +- `stopped` - 用户停止 +- `error` - 异常终止 + +### 示例 + +```json +{ + "Run": { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "ProgramName": "测试.xp", + "WorkpieceId": "WP-2024-001", + "SerialNumber": "SN-20240515-001", + "StartedAt": "2026-05-15T14:30:00.0000000Z", + "CompletedAt": "2026-05-15T14:35:00.0000000Z", + "OverallPass": true, + "Status": "completed", + "SourceImagePath": "run/source.png", + "ResultRootPath": "Results/2026-05-15/4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeCount": 5 + } +} +``` + +--- + +## 2. Nodes - 节点结果列表 + +记录每个检测节点的执行结果。 + +### 字段说明 + +| 字段 | 类型 | 说明 | 示例 | +|------|------|------|------| +| `RunId` | string (GUID) | 所属运行ID | `"4b6a5cda-..."` | +| `NodeId` | string (GUID) | 节点唯一标识符 | `"a1b2c3d4-..."` | +| `NodeIndex` | number | 节点索引(从0开始) | `0` | +| `NodeName` | string | 节点名称 | `"检测模块_0"` | +| `PipelineId` | string (GUID) | 流水线ID | `"e5f6g7h8-..."` | +| `PipelineName` | string | 流水线名称 | `"边缘检测"` | +| `PipelineVersionHash` | string | 流水线版本哈希 | `"A1B2C3D4..."` | +| `NodePass` | boolean | 节点是否通过 | `true` | +| `SourceImagePath` | string | 输入图像相对路径 | `"nodes/0_检测模块_0_input.png"` | +| `ResultImagePath` | string | 结果图像相对路径 | `"nodes/0_检测模块_0_result.png"` | +| `Status` | string | 节点状态 | `"Succeeded"` | +| `DurationMs` | number | 执行耗时(毫秒) | `1250` | + +### 状态枚举 + +- `Succeeded` - 执行成功 +- `Failed` - 执行失败 +- `PartialSuccess` - 部分成功 +- `AssetMissing` - 资产缺失 + +### 示例 + +```json +{ + "Nodes": [ + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "NodeIndex": 0, + "NodeName": "检测模块_0", + "PipelineId": "e5f6g7h8-i9j0-1234-5678-90abcdef1234", + "PipelineName": "边缘检测", + "PipelineVersionHash": "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6", + "NodePass": true, + "SourceImagePath": "nodes/0_检测模块_0_input.png", + "ResultImagePath": "nodes/0_检测模块_0_result.png", + "Status": "Succeeded", + "DurationMs": 1250 + } + ] +} +``` + +--- + +## 3. Metrics - 检测指标列表 + +记录每个节点产生的检测指标和判定结果。 + +### 字段说明 + +| 字段 | 类型 | 说明 | 示例 | +|------|------|------|------| +| `RunId` | string (GUID) | 所属运行ID | `"4b6a5cda-..."` | +| `NodeId` | string (GUID) | 所属节点ID | `"a1b2c3d4-..."` | +| `MetricKey` | string | 指标键(唯一标识) | `"edge_count"` | +| `MetricName` | string | 指标显示名称 | `"边缘数量"` | +| `MetricValue` | number | 指标值 | `42.5` | +| `Unit` | string | 单位 | `"个"` / `"mm"` / `"%"` | +| `LowerLimit` | number? | 下限(可选) | `30.0` | +| `UpperLimit` | number? | 上限(可选) | `50.0` | +| `IsPass` | boolean | 是否通过判定 | `true` | +| `DisplayOrder` | number | 显示顺序 | `0` | + +### 示例 + +```json +{ + "Metrics": [ + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "MetricKey": "edge_count", + "MetricName": "边缘数量", + "MetricValue": 42.5, + "Unit": "个", + "LowerLimit": 30.0, + "UpperLimit": 50.0, + "IsPass": true, + "DisplayOrder": 0 + }, + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "MetricKey": "defect_area", + "MetricName": "缺陷面积", + "MetricValue": 2.3, + "Unit": "mm²", + "LowerLimit": null, + "UpperLimit": 5.0, + "IsPass": true, + "DisplayOrder": 1 + } + ] +} +``` + +--- + +## 4. Assets - 资产文件列表 + +记录所有保存的图像和文件资产。 + +### 字段说明 + +| 字段 | 类型 | 说明 | 示例 | +|------|------|------|------| +| `RunId` | string (GUID) | 所属运行ID | `"4b6a5cda-..."` | +| `NodeId` | string (GUID)? | 所属节点ID(可选) | `"a1b2c3d4-..."` / `null` | +| `AssetType` | string | 资产类型 | `"NodeResultImage"` | +| `RelativePath` | string | 相对路径 | `"nodes/0_检测模块_0_result.png"` | +| `FileFormat` | string | 文件格式 | `"png"` / `"jpg"` / `"tiff"` | +| `Width` | number | 图像宽度(像素) | `1920` | +| `Height` | number | 图像高度(像素) | `1080` | + +### 资产类型枚举 + +- `RunSourceImage` - 运行源图像(整个运行的输入图像) +- `NodeInputImage` - 节点输入图像 +- `NodeResultImage` - 节点结果图像 + +### 示例 + +```json +{ + "Assets": [ + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": null, + "AssetType": "RunSourceImage", + "RelativePath": "run/source.png", + "FileFormat": "png", + "Width": 1920, + "Height": 1080 + }, + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "AssetType": "NodeInputImage", + "RelativePath": "nodes/0_检测模块_0_input.png", + "FileFormat": "png", + "Width": 1920, + "Height": 1080 + }, + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "AssetType": "NodeResultImage", + "RelativePath": "nodes/0_检测模块_0_result.png", + "FileFormat": "png", + "Width": 1920, + "Height": 1080 + } + ] +} +``` + +--- + +## 5. PipelineSnapshots - 流水线快照列表 + +记录每个节点执行时使用的图像处理流水线完整定义,用于结果追溯和复现。 + +### 字段说明 + +| 字段 | 类型 | 说明 | 示例 | +|------|------|------|------| +| `RunId` | string (GUID) | 所属运行ID | `"4b6a5cda-..."` | +| `NodeId` | string (GUID) | 所属节点ID | `"a1b2c3d4-..."` | +| `PipelineName` | string | 流水线名称 | `"边缘检测"` | +| `PipelineDefinitionJson` | string | 流水线完整定义(JSON字符串) | `"{\"Name\":\"边缘检测\",...}"` | +| `PipelineHash` | string | 流水线定义的SHA256哈希 | `"A1B2C3D4..."` | + +### 用途 + +- **版本追溯** - 记录执行时的流水线配置,即使后续流水线被修改也能追溯 +- **结果复现** - 可以根据快照重新执行相同的流水线 +- **版本对比** - 通过哈希值快速判断流水线是否发生变化 + +### 示例 + +```json +{ + "PipelineSnapshots": [ + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "PipelineName": "边缘检测", + "PipelineDefinitionJson": "{\"Name\":\"边缘检测\",\"Processors\":[{\"Type\":\"GaussianBlur\",\"Parameters\":{\"KernelSize\":5}},{\"Type\":\"CannyEdge\",\"Parameters\":{\"Threshold1\":50,\"Threshold2\":150}}]}", + "PipelineHash": "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6" + } + ] +} +``` + +--- + +## 6. Events - 运行事件列表 + +记录运行过程中的所有关键事件,用于完整回放执行时间线。 + +### 字段说明 + +| 字段 | 类型 | 说明 | 示例 | +|------|------|------|------| +| `Id` | number | 事件自增ID | `1` | +| `RunId` | string (GUID) | 所属运行ID | `"4b6a5cda-..."` | +| `NodeId` | string (GUID)? | 相关节点ID(可选) | `"a1b2c3d4-..."` / `null` | +| `EventType` | string | 事件类型 | `"RunStarted"` | +| `EventTime` | string (ISO 8601) | 事件时间 | `"2026-05-15T14:30:00.0000000Z"` | +| `PayloadJson` | string | 事件负载(JSON字符串) | `"{\"Message\":\"开始执行\"}"` | + +### 事件类型枚举 + +- `RunStarted` - 运行开始 +- `NodeStarted` - 节点开始执行 +- `NodeCompleted` - 节点执行完成 +- `NodeFailed` - 节点执行失败 +- `RunStopped` - 运行被用户停止 +- `RunCompleted` - 运行正常完成 +- `RunError` - 运行异常终止 + +### 示例 + +```json +{ + "Events": [ + { + "Id": 1, + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": null, + "EventType": "RunStarted", + "EventTime": "2026-05-15T14:30:00.0000000Z", + "PayloadJson": "" + }, + { + "Id": 2, + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "EventType": "NodeStarted", + "EventTime": "2026-05-15T14:30:05.0000000Z", + "PayloadJson": "" + }, + { + "Id": 3, + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "EventType": "NodeCompleted", + "EventTime": "2026-05-15T14:30:06.2500000Z", + "PayloadJson": "" + }, + { + "Id": 4, + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": null, + "EventType": "RunCompleted", + "EventTime": "2026-05-15T14:35:00.0000000Z", + "PayloadJson": "" + } + ] +} +``` + +--- + +## 完整示例 + +```json +{ + "Run": { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "ProgramName": "测试.xp", + "WorkpieceId": "WP-2024-001", + "SerialNumber": "SN-20240515-001", + "StartedAt": "2026-05-15T14:30:00.0000000Z", + "CompletedAt": "2026-05-15T14:35:00.0000000Z", + "OverallPass": true, + "Status": "completed", + "SourceImagePath": "run/source.png", + "ResultRootPath": "Results/2026-05-15/4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeCount": 1 + }, + "Nodes": [ + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "NodeIndex": 0, + "NodeName": "检测模块_0", + "PipelineId": "e5f6g7h8-i9j0-1234-5678-90abcdef1234", + "PipelineName": "边缘检测", + "PipelineVersionHash": "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6", + "NodePass": true, + "SourceImagePath": "nodes/0_检测模块_0_input.png", + "ResultImagePath": "nodes/0_检测模块_0_result.png", + "Status": "Succeeded", + "DurationMs": 1250 + } + ], + "Metrics": [ + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "MetricKey": "edge_count", + "MetricName": "边缘数量", + "MetricValue": 42.5, + "Unit": "个", + "LowerLimit": 30.0, + "UpperLimit": 50.0, + "IsPass": true, + "DisplayOrder": 0 + } + ], + "Assets": [ + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": null, + "AssetType": "RunSourceImage", + "RelativePath": "run/source.png", + "FileFormat": "png", + "Width": 1920, + "Height": 1080 + }, + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "AssetType": "NodeResultImage", + "RelativePath": "nodes/0_检测模块_0_result.png", + "FileFormat": "png", + "Width": 1920, + "Height": 1080 + } + ], + "PipelineSnapshots": [ + { + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "PipelineName": "边缘检测", + "PipelineDefinitionJson": "{\"Name\":\"边缘检测\",\"Processors\":[]}", + "PipelineHash": "A1B2C3D4E5F6G7H8I9J0K1L2M3N4O5P6Q7R8S9T0U1V2W3X4Y5Z6" + } + ], + "Events": [ + { + "Id": 1, + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": null, + "EventType": "RunStarted", + "EventTime": "2026-05-15T14:30:00.0000000Z", + "PayloadJson": "" + }, + { + "Id": 2, + "RunId": "4b6a5cda-33ca-4419-a79c-b87f4e79e8d1", + "NodeId": null, + "EventType": "RunCompleted", + "EventTime": "2026-05-15T14:35:00.0000000Z", + "PayloadJson": "" + } + ] +} +``` + +--- + +## 使用场景 + +### 1. 结果查看与分析 +- 通过 `InspectionReportViewerViewModel` 读取 manifest.json +- 显示运行列表、节点结果、指标数据 +- 查看保存的图像资产 + +### 2. 质量追溯 +- 根据 `WorkpieceId` 和 `SerialNumber` 追溯特定工件的检测历史 +- 通过 `PipelineSnapshots` 确认使用的流水线版本 +- 通过 `Events` 回放执行时间线 + +### 3. 数据导出与报告 +- 解析 manifest.json 生成检测报告 +- 导出指标数据到 Excel/CSV +- 批量分析多次运行的统计数据 + +### 4. 结果复现 +- 根据 `PipelineSnapshots` 重新执行相同的流水线 +- 使用保存的输入图像重新检测 +- 对比不同版本流水线的结果差异 + +--- + +## 相关代码 + +### 写入 +- `InspectionResultStore.WriteManifestAsync()` - 写入 manifest.json +- `InspectionResultStore.CompleteRunAsync()` - 完成运行时调用写入 + +### 读取 +- `InspectionResultStore.GetRunDetailAsync()` - 从数据库读取运行详情 +- 前端可直接解析 manifest.json 文件 + +### 数据模型 +- `XplorePlane.Models.InspectionResultModels.cs` - 所有相关数据模型定义 + +--- + +## 注意事项 + +1. **路径格式** - 所有路径使用正斜杠 `/` 作为分隔符,跨平台兼容 +2. **时间格式** - 使用 ISO 8601 格式(UTC时间),精确到微秒 +3. **GUID 格式** - 使用标准的 GUID 字符串格式(带连字符) +4. **JSON 格式** - 使用缩进格式(WriteIndented = true),便于人工查看 +5. **版本兼容** - 新增字段应设置默认值,保持向后兼容 +6. **文件编码** - UTF-8 编码,支持中文字符 + +--- + +## 完整性确认 + +### ✅ manifest.json 包含的完整信息 + +`manifest.json` 是一个**完整的检测结果清单**,包含生成完整报告所需的所有信息: + +#### 1. 基本信息 +- ✅ 运行标识(RunId、ProgramName) +- ✅ 工件信息(WorkpieceId、SerialNumber) +- ✅ 时间信息(StartedAt、CompletedAt) +- ✅ 整体判定结果(OverallPass、Status) + +#### 2. 节点执行结果 +- ✅ 每个节点的执行状态(Status、NodePass) +- ✅ 节点名称和索引(NodeName、NodeIndex) +- ✅ 使用的流水线信息(PipelineName、PipelineVersionHash) +- ✅ 执行耗时(DurationMs) + +#### 3. 检测指标数据 +- ✅ 所有检测指标的名称和值(MetricName、MetricValue) +- ✅ 指标单位(Unit) +- ✅ 判定上下限(LowerLimit、UpperLimit) +- ✅ 判定结果(IsPass) + +#### 4. 图像资产路径 +- ✅ 输入图像路径(SourceImagePath、NodeInputImage) +- ✅ 输出图像路径(ResultImagePath、NodeResultImage) +- ✅ 图像尺寸信息(Width、Height) +- ✅ 图像格式(FileFormat) + +#### 5. 流水线快照 +- ✅ 完整的流水线定义(PipelineDefinitionJson) +- ✅ 流水线版本哈希(PipelineHash) +- ✅ 用于结果追溯和复现 + +#### 6. 事件时间线 +- ✅ 完整的执行事件序列(Events) +- ✅ 每个事件的时间戳(EventTime) +- ✅ 用于回放执行过程 + +--- + +### 📊 如何使用 manifest.json 生成完整报告 + +以下是读取 manifest.json 并生成完整报告的示例代码: + +```csharp +// 1. 读取 manifest.json +var manifestPath = Path.Combine(resultRootPath, "manifest.json"); +var manifestJson = await File.ReadAllTextAsync(manifestPath); +var manifest = JsonSerializer.Deserialize(manifestJson); + +// 2. 获取基本信息 +var runInfo = manifest.Run; +Console.WriteLine($"程序名称: {runInfo.ProgramName}"); +Console.WriteLine($"工件ID: {runInfo.WorkpieceId}"); +Console.WriteLine($"序列号: {runInfo.SerialNumber}"); +Console.WriteLine($"开始时间: {runInfo.StartedAt}"); +Console.WriteLine($"完成时间: {runInfo.CompletedAt}"); +Console.WriteLine($"整体判定: {(runInfo.OverallPass ? "通过" : "不通过")}"); + +// 3. 遍历所有节点结果 +foreach (var node in manifest.Nodes) +{ + Console.WriteLine($"\n节点 {node.NodeIndex}: {node.NodeName}"); + Console.WriteLine($" 流水线: {node.PipelineName}"); + Console.WriteLine($" 状态: {node.Status}"); + Console.WriteLine($" 判定: {(node.NodePass ? "通过" : "不通过")}"); + Console.WriteLine($" 耗时: {node.DurationMs} ms"); + + // 4. 读取输入图像 + var inputImagePath = Path.Combine(resultRootPath, node.SourceImagePath); + if (File.Exists(inputImagePath)) + { + var inputImage = Image.FromFile(inputImagePath); + Console.WriteLine($" 输入图像: {inputImage.Width}x{inputImage.Height}"); + // 可以将图像添加到报告中 + } + + // 5. 读取输出图像 + var outputImagePath = Path.Combine(resultRootPath, node.ResultImagePath); + if (File.Exists(outputImagePath)) + { + var outputImage = Image.FromFile(outputImagePath); + Console.WriteLine($" 输出图像: {outputImage.Width}x{outputImage.Height}"); + // 可以将图像添加到报告中 + } + + // 6. 获取该节点的所有指标 + var nodeMetrics = manifest.Metrics.Where(m => m.NodeId == node.NodeId); + foreach (var metric in nodeMetrics) + { + Console.WriteLine($" 指标: {metric.MetricName} = {metric.MetricValue} {metric.Unit}"); + if (metric.LowerLimit.HasValue || metric.UpperLimit.HasValue) + { + Console.WriteLine($" 范围: [{metric.LowerLimit ?? double.MinValue}, {metric.UpperLimit ?? double.MaxValue}]"); + } + Console.WriteLine($" 判定: {(metric.IsPass ? "通过" : "不通过")}"); + } +} + +// 7. 生成 PDF 报告(示例) +var pdfReport = new PdfDocument(); +var page = pdfReport.AddPage(); +var graphics = XGraphics.FromPdfPage(page); + +// 添加标题 +graphics.DrawString($"检测报告 - {runInfo.ProgramName}", + new XFont("Arial", 20), XBrushes.Black, new XPoint(50, 50)); + +// 添加基本信息 +graphics.DrawString($"工件ID: {runInfo.WorkpieceId}", + new XFont("Arial", 12), XBrushes.Black, new XPoint(50, 80)); + +// 添加图像和指标... +// (省略详细的 PDF 生成代码) + +pdfReport.Save("检测报告.pdf"); +``` + +--- + +### 🎯 实际应用场景 + +#### 场景 1: 生成 PDF 检测报告 +```csharp +// 读取 manifest.json +var manifest = await LoadManifestAsync(runId); + +// 创建 PDF 报告 +var report = new PdfReportGenerator(); +report.AddHeader(manifest.Run); +foreach (var node in manifest.Nodes) +{ + report.AddNodeSection(node); + report.AddInputImage(GetImagePath(node.SourceImagePath)); + report.AddOutputImage(GetImagePath(node.ResultImagePath)); + report.AddMetrics(manifest.Metrics.Where(m => m.NodeId == node.NodeId)); +} +report.Save($"Report_{runId}.pdf"); +``` + +#### 场景 2: 导出 Excel 数据表 +```csharp +// 读取 manifest.json +var manifest = await LoadManifestAsync(runId); + +// 创建 Excel 工作簿 +var workbook = new XLWorkbook(); +var worksheet = workbook.Worksheets.Add("检测结果"); + +// 写入基本信息 +worksheet.Cell("A1").Value = "程序名称"; +worksheet.Cell("B1").Value = manifest.Run.ProgramName; +worksheet.Cell("A2").Value = "工件ID"; +worksheet.Cell("B2").Value = manifest.Run.WorkpieceId; + +// 写入指标数据 +int row = 5; +worksheet.Cell(row, 1).Value = "节点名称"; +worksheet.Cell(row, 2).Value = "指标名称"; +worksheet.Cell(row, 3).Value = "指标值"; +worksheet.Cell(row, 4).Value = "单位"; +worksheet.Cell(row, 5).Value = "判定"; + +foreach (var metric in manifest.Metrics) +{ + row++; + var node = manifest.Nodes.First(n => n.NodeId == metric.NodeId); + worksheet.Cell(row, 1).Value = node.NodeName; + worksheet.Cell(row, 2).Value = metric.MetricName; + worksheet.Cell(row, 3).Value = metric.MetricValue; + worksheet.Cell(row, 4).Value = metric.Unit; + worksheet.Cell(row, 5).Value = metric.IsPass ? "通过" : "不通过"; +} + +workbook.SaveAs($"Data_{runId}.xlsx"); +``` + +#### 场景 3: 结果追溯与对比 +```csharp +// 读取多个运行的 manifest.json +var run1 = await LoadManifestAsync(runId1); +var run2 = await LoadManifestAsync(runId2); + +// 对比流水线版本 +foreach (var node1 in run1.Nodes) +{ + var node2 = run2.Nodes.FirstOrDefault(n => n.NodeIndex == node1.NodeIndex); + if (node2 != null) + { + var snapshot1 = run1.PipelineSnapshots.First(s => s.NodeId == node1.NodeId); + var snapshot2 = run2.PipelineSnapshots.First(s => s.NodeId == node2.NodeId); + + if (snapshot1.PipelineHash != snapshot2.PipelineHash) + { + Console.WriteLine($"节点 {node1.NodeIndex} 流水线版本不同:"); + Console.WriteLine($" 运行1: {snapshot1.PipelineHash}"); + Console.WriteLine($" 运行2: {snapshot2.PipelineHash}"); + } + } +} + +// 对比指标值 +var metric1 = run1.Metrics.First(m => m.MetricKey == "edge_count"); +var metric2 = run2.Metrics.First(m => m.MetricKey == "edge_count"); +Console.WriteLine($"边缘数量对比: {metric1.MetricValue} vs {metric2.MetricValue}"); +``` + +#### 场景 4: 质量统计分析 +```csharp +// 读取一批运行的 manifest.json +var manifests = await LoadManifestsAsync(startDate, endDate); + +// 统计通过率 +var totalRuns = manifests.Count; +var passedRuns = manifests.Count(m => m.Run.OverallPass); +var passRate = (double)passedRuns / totalRuns * 100; +Console.WriteLine($"通过率: {passRate:F2}%"); + +// 统计各指标的平均值 +var allMetrics = manifests.SelectMany(m => m.Metrics); +var avgEdgeCount = allMetrics + .Where(m => m.MetricKey == "edge_count") + .Average(m => m.MetricValue); +Console.WriteLine($"平均边缘数量: {avgEdgeCount:F2}"); + +// 统计执行时间 +var avgDuration = manifests + .SelectMany(m => m.Nodes) + .Average(n => n.DurationMs); +Console.WriteLine($"平均节点执行时间: {avgDuration:F2} ms"); +``` + +--- + +### ⚠️ 使用注意事项 + +1. **路径拼接** - manifest.json 中的路径是相对路径,需要与 `ResultRootPath` 拼接: + ```csharp + var fullPath = Path.Combine(manifest.Run.ResultRootPath, asset.RelativePath); + ``` + +2. **图像文件存在性检查** - 读取图像前应检查文件是否存在: + ```csharp + if (File.Exists(imagePath)) + { + var image = Image.FromFile(imagePath); + // 处理图像... + } + ``` + +3. **JSON 反序列化** - 使用 `System.Text.Json` 或 `Newtonsoft.Json` 反序列化: + ```csharp + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true + }; + var manifest = JsonSerializer.Deserialize(json, options); + ``` + +4. **时区处理** - manifest.json 中的时间是 UTC 时间,显示时需要转换为本地时间: + ```csharp + var localTime = DateTime.Parse(manifest.Run.StartedAt).ToLocalTime(); + ``` + +5. **大文件处理** - 对于包含大量节点的运行,manifest.json 可能较大,建议使用流式读取: + ```csharp + using var stream = File.OpenRead(manifestPath); + var manifest = await JsonSerializer.DeserializeAsync(stream); + ``` + +--- + +## 更新历史 + +| 日期 | 版本 | 说明 | +|------|------|------| +| 2026-05-15 | 1.0 | 初始版本,记录当前 manifest.json 结构 | +| 2026-05-15 | 1.1 | 添加完整性确认章节,包含使用示例和实际应用场景 | diff --git a/XplorePlane/Services/Debug/DebugPanelConfigService.cs b/XplorePlane/Services/Debug/DebugPanelConfigService.cs new file mode 100644 index 0000000..f60990b --- /dev/null +++ b/XplorePlane/Services/Debug/DebugPanelConfigService.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Windows; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.ViewModels.Debug; + +namespace XplorePlane.Services.Debug +{ + public class DebugPanelConfigService : IDebugPanelConfigService + { + private readonly ILoggerService _logger; + private readonly JsonSerializerOptions _jsonOptions; + private string _configPath; + + public DebugPanelConfigService(ILoggerService logger) + { + ArgumentNullException.ThrowIfNull(logger); + + _logger = logger.ForModule(); + _jsonOptions = new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; + + _configPath = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), + "XplorePlane", + "DebugPanel.config"); + } + + public DebugPanelConfig LoadConfig() + { + try + { + if (!File.Exists(_configPath)) + { + _logger.Info("调试面板配置不存在,使用默认配置 | Debug panel config missing, using defaults"); + return CreateDefaultConfig(); + } + + var json = File.ReadAllText(_configPath); + var config = JsonSerializer.Deserialize(json, _jsonOptions); + return NormalizeConfig(config); + } + catch (Exception ex) + { + _logger.Error(ex, "加载调试面板配置失败,回退默认配置 | Failed to load debug panel config, falling back to defaults"); + return CreateDefaultConfig(); + } + } + + public void SaveConfig(DebugPanelConfig config) + { + if (config == null) + { + _logger.Warn("保存调试面板配置被忽略:配置为空 | Skip saving debug panel config because config is null"); + return; + } + + try + { + config = NormalizeConfig(config); + + var directory = Path.GetDirectoryName(_configPath); + if (!string.IsNullOrWhiteSpace(directory) && !Directory.Exists(directory)) + { + Directory.CreateDirectory(directory); + } + + var json = JsonSerializer.Serialize(config, _jsonOptions); + File.WriteAllText(_configPath, json); + + _logger.Info("调试面板配置已保存到 {Path} | Debug panel config saved to {Path}", _configPath); + } + catch (Exception ex) + { + _logger.Error(ex, "保存调试面板配置失败 | Failed to save debug panel config"); + } + } + + private DebugPanelConfig NormalizeConfig(DebugPanelConfig config) + { + var normalized = config ?? CreateDefaultConfig(); + normalized.Window ??= CreateDefaultWindowConfig(); + + if (normalized.Window.Width <= 0) + { + normalized.Window.Width = 1200; + } + + if (normalized.Window.Height <= 0) + { + normalized.Window.Height = 800; + } + + normalized.EventFilters ??= new Dictionary(); + foreach (var key in DebugPanelStateMetadata.StateCategories) + { + if (!normalized.EventFilters.ContainsKey(key)) + { + normalized.EventFilters[key] = true; + } + } + + return normalized; + } + + private static DebugPanelConfig CreateDefaultConfig() + { + var filters = new Dictionary(); + foreach (var key in DebugPanelStateMetadata.StateCategories) + { + filters[key] = true; + } + + return new DebugPanelConfig + { + Window = CreateDefaultWindowConfig(), + EventFilters = filters, + DockingLayout = string.Empty + }; + } + + private static WindowConfig CreateDefaultWindowConfig() + { + return new WindowConfig + { + Left = 100, + Top = 100, + Width = 1200, + Height = 800, + State = WindowState.Normal + }; + } + } +} diff --git a/XplorePlane/Services/Debug/IDebugPanelConfigService.cs b/XplorePlane/Services/Debug/IDebugPanelConfigService.cs new file mode 100644 index 0000000..55556b7 --- /dev/null +++ b/XplorePlane/Services/Debug/IDebugPanelConfigService.cs @@ -0,0 +1,11 @@ +using XplorePlane.Models; + +namespace XplorePlane.Services.Debug +{ + public interface IDebugPanelConfigService + { + DebugPanelConfig LoadConfig(); + + void SaveConfig(DebugPanelConfig config); + } +} diff --git a/XplorePlane/ViewModels/Debug/DebugPanelStateFormatter.cs b/XplorePlane/ViewModels/Debug/DebugPanelStateFormatter.cs new file mode 100644 index 0000000..0690e4d --- /dev/null +++ b/XplorePlane/ViewModels/Debug/DebugPanelStateFormatter.cs @@ -0,0 +1,80 @@ +using System; +using System.Globalization; +using System.Reflection; + +namespace XplorePlane.ViewModels.Debug +{ + internal static class DebugPanelStateFormatter + { + public static string FormatValue(object value) + { + if (value == null) + { + return string.Empty; + } + + var type = value.GetType(); + + if (type == typeof(bool)) + { + return ((bool)value).ToString(); + } + + if (IsNumeric(type)) + { + return Convert.ToDouble(value, CultureInfo.InvariantCulture).ToString("F2", CultureInfo.InvariantCulture); + } + + if (type.IsEnum) + { + return value.ToString(); + } + + if (type == typeof(DateTime)) + { + return ((DateTime)value).ToString("yyyy-MM-dd HH:mm:ss.fff", CultureInfo.InvariantCulture); + } + + if (type.Namespace != null && type.Namespace.StartsWith("XplorePlane.Models", StringComparison.Ordinal)) + { + return FormatObjectSummary(value); + } + + return Convert.ToString(value, CultureInfo.InvariantCulture) ?? string.Empty; + } + + public static bool IsNumeric(Type type) + { + type = Nullable.GetUnderlyingType(type) ?? type; + return type == typeof(byte) || + type == typeof(sbyte) || + type == typeof(short) || + type == typeof(ushort) || + type == typeof(int) || + type == typeof(uint) || + type == typeof(long) || + type == typeof(ulong) || + type == typeof(float) || + type == typeof(double) || + type == typeof(decimal); + } + + private static string FormatObjectSummary(object value) + { + var properties = value.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public); + if (properties.Length == 0) + { + return value.ToString() ?? string.Empty; + } + + var parts = new string[properties.Length]; + for (int i = 0; i < properties.Length; i++) + { + var property = properties[i]; + parts[i] = $"{property.Name}={FormatValue(property.GetValue(value))}"; + } + + return string.Join(", ", parts); + } + } +} diff --git a/XplorePlane/ViewModels/Debug/DebugPanelStateMetadata.cs b/XplorePlane/ViewModels/Debug/DebugPanelStateMetadata.cs new file mode 100644 index 0000000..e302fdb --- /dev/null +++ b/XplorePlane/ViewModels/Debug/DebugPanelStateMetadata.cs @@ -0,0 +1,17 @@ +namespace XplorePlane.ViewModels.Debug +{ + internal static class DebugPanelStateMetadata + { + public static readonly string[] StateCategories = + { + "MotionState", + "RaySourceState", + "DetectorState", + "SystemState", + "CameraState", + "LinkedViewState", + "RecipeExecutionState", + "CalibrationMatrix" + }; + } +} diff --git a/XplorePlane/ViewModels/Debug/DebugPanelViewModel.cs b/XplorePlane/ViewModels/Debug/DebugPanelViewModel.cs new file mode 100644 index 0000000..d3b21b8 --- /dev/null +++ b/XplorePlane/ViewModels/Debug/DebugPanelViewModel.cs @@ -0,0 +1,182 @@ +using Prism.Commands; +using Prism.Mvvm; +using System; +using System.Collections.Generic; +using System.IO; +using System.Text.Json; +using System.Windows.Threading; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.AppState; +using XplorePlane.Services.Debug; + +namespace XplorePlane.ViewModels.Debug +{ + public class DebugPanelViewModel : BindableBase, IDisposable + { + private readonly ILoggerService _logger; + private readonly IDebugPanelConfigService _configService; + private readonly Dispatcher _dispatcher; + private DebugPanelConfig _config; + private bool _initialized; + private bool _disposed; + + public StateDisplayViewModel StateDisplay { get; } + public EventLogViewModel EventLog { get; } + public SnapshotManagerViewModel SnapshotManager { get; } + public PerformanceMonitorViewModel PerformanceMonitor { get; } + + public DelegateCommand SaveLayoutCommand { get; } + public DelegateCommand ResetLayoutCommand { get; } + public DelegateCommand ExportAllCommand { get; } + + public DebugPanelConfig CurrentConfig + { + get => _config; + private set => SetProperty(ref _config, value); + } + + public DebugPanelViewModel( + IAppStateService appStateService, + ILoggerService loggerService, + IDebugPanelConfigService configService) + { + ArgumentNullException.ThrowIfNull(appStateService); + + _logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule(); + _configService = configService ?? throw new ArgumentNullException(nameof(configService)); + _dispatcher = System.Windows.Application.Current?.Dispatcher ?? Dispatcher.CurrentDispatcher; + + StateDisplay = new StateDisplayViewModel(appStateService, loggerService, _dispatcher); + EventLog = new EventLogViewModel(appStateService, loggerService, _dispatcher); + SnapshotManager = new SnapshotManagerViewModel(appStateService, loggerService, _dispatcher); + PerformanceMonitor = new PerformanceMonitorViewModel(appStateService, loggerService, _dispatcher); + + SaveLayoutCommand = new DelegateCommand(SaveLayout); + ResetLayoutCommand = new DelegateCommand(ResetLayout); + ExportAllCommand = new DelegateCommand(ExportAll); + + CurrentConfig = _configService.LoadConfig(); + } + + public void Initialize() + { + if (_initialized || _disposed) + { + return; + } + + CurrentConfig = _configService.LoadConfig(); + StateDisplay.Initialize(); + EventLog.Initialize(); + PerformanceMonitor.Initialize(); + + ApplyFilterConfig(); + _initialized = true; + } + + public void SaveWindowConfig(WindowConfig windowConfig) + { + CurrentConfig ??= _configService.LoadConfig(); + CurrentConfig.Window = windowConfig; + SaveLayout(); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + SaveLayout(); + StateDisplay.Dispose(); + EventLog.Dispose(); + SnapshotManager.Dispose(); + PerformanceMonitor.Dispose(); + _disposed = true; + } + + private void SaveLayout() + { + try + { + CurrentConfig ??= _configService.LoadConfig(); + CurrentConfig.EventFilters = new Dictionary(EventLog.FilterOptions); + _configService.SaveConfig(CurrentConfig); + } + catch (Exception ex) + { + _logger.Error(ex, "保存调试面板布局失败 | Failed to save debug panel layout"); + } + } + + private void ResetLayout() + { + CurrentConfig = new DebugPanelConfig + { + Window = new WindowConfig + { + Left = 100, + Top = 100, + Width = 1200, + Height = 800, + State = System.Windows.WindowState.Normal + }, + EventFilters = new Dictionary(), + DockingLayout = string.Empty + }; + + foreach (var category in DebugPanelStateMetadata.StateCategories) + { + CurrentConfig.EventFilters[category] = true; + EventLog.FilterOptions[category] = true; + } + + EventLog.ApplyFilter(); + _configService.SaveConfig(CurrentConfig); + } + + private void ExportAll() + { + try + { + var path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), + $"debug-panel-export-{DateTime.Now:yyyyMMdd-HHmmss}.json"); + + var payload = new + { + ExportedAt = DateTime.Now, + EventLog = EventLog.EventLog, + Snapshots = SnapshotManager.Snapshots + }; + + var json = JsonSerializer.Serialize(payload, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json); + } + catch (Exception ex) + { + _logger.Error(ex, "导出调试面板数据失败 | Failed to export debug panel data"); + } + } + + private void ApplyFilterConfig() + { + if (CurrentConfig?.EventFilters == null) + { + return; + } + + foreach (var pair in CurrentConfig.EventFilters) + { + if (EventLog.FilterOptions.ContainsKey(pair.Key)) + { + EventLog.FilterOptions[pair.Key] = pair.Value; + } + } + + EventLog.ApplyFilter(); + } + } +} diff --git a/XplorePlane/ViewModels/Debug/EventLogViewModel.cs b/XplorePlane/ViewModels/Debug/EventLogViewModel.cs new file mode 100644 index 0000000..2db6ff7 --- /dev/null +++ b/XplorePlane/ViewModels/Debug/EventLogViewModel.cs @@ -0,0 +1,371 @@ +using Microsoft.Win32; +using Prism.Commands; +using Prism.Mvvm; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Text.Json; +using System.Windows; +using System.Windows.Threading; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.AppState; + +namespace XplorePlane.ViewModels.Debug +{ + /// + /// Filter option view model for binding to UI + /// 用于绑定到 UI 的过滤器选项视图模型 + /// + public class FilterOptionViewModel : BindableBase + { + private bool _isEnabled; + + public string Category { get; set; } + + public bool IsEnabled + { + get => _isEnabled; + set => SetProperty(ref _isEnabled, value); + } + } + + public class EventLogViewModel : BindableBase, IDisposable + { + private const int MaxLogEntries = 1000; + + private readonly IAppStateService _appStateService; + private readonly ILoggerService _logger; + private readonly Dispatcher _dispatcher; + private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; + private bool _initialized; + private bool _disposed; + + public ObservableCollection EventLog { get; } + public ObservableCollection FilteredEventLog { get; } = new(); + public Dictionary FilterOptions { get; } = new(); + public ObservableCollection FilterOptionViewModels { get; } = new(); + + public DelegateCommand ToggleFilterCommand { get; } + public DelegateCommand SelectAllFiltersCommand { get; } + public DelegateCommand ClearAllFiltersCommand { get; } + public DelegateCommand ExportToJsonCommand { get; } + public DelegateCommand ExportToCsvCommand { get; } + + public EventLogViewModel(IAppStateService appStateService, ILoggerService loggerService, Dispatcher dispatcher) + { + _appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService)); + _logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule(); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + EventLog = new ObservableCollection(); + EventLog.CollectionChanged += OnEventLogCollectionChanged; + + // Initialize filter options with both dictionary and view models + foreach (var category in DebugPanelStateMetadata.StateCategories) + { + FilterOptions[category] = true; + var filterVM = new FilterOptionViewModel { Category = category, IsEnabled = true }; + filterVM.PropertyChanged += (s, e) => + { + if (e.PropertyName == nameof(FilterOptionViewModel.IsEnabled) && s is FilterOptionViewModel vm) + { + FilterOptions[vm.Category] = vm.IsEnabled; + ApplyFilter(); + } + }; + FilterOptionViewModels.Add(filterVM); + } + + ToggleFilterCommand = new DelegateCommand(ToggleFilter); + SelectAllFiltersCommand = new DelegateCommand(SelectAllFilters); + ClearAllFiltersCommand = new DelegateCommand(ClearAllFilters); + ExportToJsonCommand = new DelegateCommand(ExportToJson); + ExportToCsvCommand = new DelegateCommand(ExportToCsv); + } + + public void Initialize() + { + if (_initialized || _disposed) + { + return; + } + + _appStateService.MotionStateChanged += OnMotionStateChanged; + _appStateService.RaySourceStateChanged += OnRaySourceStateChanged; + _appStateService.DetectorStateChanged += OnDetectorStateChanged; + _appStateService.SystemStateChanged += OnSystemStateChanged; + _appStateService.CameraStateChanged += OnCameraStateChanged; + _appStateService.LinkedViewStateChanged += OnLinkedViewStateChanged; + _appStateService.RecipeExecutionStateChanged += OnRecipeExecutionStateChanged; + + _initialized = true; + } + + public void ApplyFilter() + { + FilteredEventLog.Clear(); + + foreach (var entry in EventLog.Where(e => FilterOptions.TryGetValue(e.Category, out var enabled) && enabled)) + { + FilteredEventLog.Add(entry); + } + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + if (_initialized) + { + _appStateService.MotionStateChanged -= OnMotionStateChanged; + _appStateService.RaySourceStateChanged -= OnRaySourceStateChanged; + _appStateService.DetectorStateChanged -= OnDetectorStateChanged; + _appStateService.SystemStateChanged -= OnSystemStateChanged; + _appStateService.CameraStateChanged -= OnCameraStateChanged; + _appStateService.LinkedViewStateChanged -= OnLinkedViewStateChanged; + _appStateService.RecipeExecutionStateChanged -= OnRecipeExecutionStateChanged; + } + + EventLog.CollectionChanged -= OnEventLogCollectionChanged; + EventLog.Clear(); + FilteredEventLog.Clear(); + + _disposed = true; + } + + private void OnMotionStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("MotionStateChanged", "MotionState", e.OldValue, e.NewValue); + private void OnRaySourceStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("RaySourceStateChanged", "RaySourceState", e.OldValue, e.NewValue); + private void OnDetectorStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("DetectorStateChanged", "DetectorState", e.OldValue, e.NewValue); + private void OnSystemStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("SystemStateChanged", "SystemState", e.OldValue, e.NewValue); + private void OnCameraStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("CameraStateChanged", "CameraState", e.OldValue, e.NewValue); + private void OnLinkedViewStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("LinkedViewStateChanged", "LinkedViewState", e.OldValue, e.NewValue); + private void OnRecipeExecutionStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("RecipeExecutionStateChanged", "RecipeExecutionState", e.OldValue, e.NewValue); + + private void OnStateChanged(string eventType, string category, T oldState, T newState) + { + try + { + _dispatcher.BeginInvoke(new Action(() => + { + foreach (var entry in DetectChanges(eventType, category, oldState, newState)) + { + EventLog.Insert(0, entry); + } + })); + } + catch (Exception ex) + { + _logger.Error(ex, "记录状态事件日志失败 | Failed to record event log for {Category}", category); + } + } + + private IEnumerable DetectChanges(string eventType, string category, T oldState, T newState) + { + if (newState == null) + { + yield break; + } + + var timestamp = DateTime.Now; + var changed = false; + + foreach (var property in typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + var oldValue = oldState == null ? null : property.GetValue(oldState); + var newValue = property.GetValue(newState); + if (Equals(oldValue, newValue)) + { + continue; + } + + changed = true; + yield return new EventLogEntry + { + Timestamp = timestamp, + EventType = eventType, + FieldName = property.Name, + OldValue = DebugPanelStateFormatter.FormatValue(oldValue), + NewValue = DebugPanelStateFormatter.FormatValue(newValue), + Category = category + }; + } + + if (!changed) + { + yield return new EventLogEntry + { + Timestamp = timestamp, + EventType = eventType, + FieldName = "(No changes)", + OldValue = string.Empty, + NewValue = string.Empty, + Category = category + }; + } + } + + private void OnEventLogCollectionChanged(object sender, NotifyCollectionChangedEventArgs e) + { + while (EventLog.Count > MaxLogEntries) + { + EventLog.RemoveAt(EventLog.Count - 1); + } + + ApplyFilter(); + } + + private void ToggleFilter(string category) + { + if (string.IsNullOrWhiteSpace(category) || !FilterOptions.ContainsKey(category)) + { + return; + } + + FilterOptions[category] = !FilterOptions[category]; + + // Update the corresponding view model + var filterVM = FilterOptionViewModels.FirstOrDefault(f => f.Category == category); + if (filterVM != null) + { + filterVM.IsEnabled = FilterOptions[category]; + } + + ApplyFilter(); + } + + private void SelectAllFilters() + { + foreach (var key in FilterOptions.Keys.ToList()) + { + FilterOptions[key] = true; + } + + // Update all view models + foreach (var filterVM in FilterOptionViewModels) + { + filterVM.IsEnabled = true; + } + + ApplyFilter(); + } + + private void ClearAllFilters() + { + foreach (var key in FilterOptions.Keys.ToList()) + { + FilterOptions[key] = false; + } + + // Update all view models + foreach (var filterVM in FilterOptionViewModels) + { + filterVM.IsEnabled = false; + } + + ApplyFilter(); + } + + private void ExportToJson() + { + try + { + var path = PromptSavePath("Export event log as JSON", "JSON files (*.json)|*.json"); + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + var payload = new + { + ExportedAt = DateTime.Now, + ApplicationVersion = GetApplicationVersion(), + Entries = EventLog.ToList() + }; + + File.WriteAllText(path, JsonSerializer.Serialize(payload, _jsonOptions), Encoding.UTF8); + MessageBox.Show("Event log exported successfully.", "Export", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + _logger.Error(ex, "导出事件日志 JSON 失败 | Failed to export event log as JSON"); + MessageBox.Show($"Failed to export JSON: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void ExportToCsv() + { + try + { + var path = PromptSavePath("Export event log as CSV", "CSV files (*.csv)|*.csv"); + if (string.IsNullOrWhiteSpace(path)) + { + return; + } + + var builder = new StringBuilder(); + builder.AppendLine($"# ExportedAt,{DateTime.Now:yyyy-MM-dd HH:mm:ss.fff}"); + builder.AppendLine($"# ApplicationVersion,{GetApplicationVersion()}"); + builder.AppendLine("Timestamp,EventType,Category,FieldName,OldValue,NewValue"); + + foreach (var entry in EventLog) + { + builder.AppendLine(string.Join(",", + EscapeCsv(entry.Timestamp.ToString("yyyy-MM-dd HH:mm:ss.fff")), + EscapeCsv(entry.EventType), + EscapeCsv(entry.Category), + EscapeCsv(entry.FieldName), + EscapeCsv(entry.OldValue), + EscapeCsv(entry.NewValue))); + } + + File.WriteAllText(path, builder.ToString(), Encoding.UTF8); + MessageBox.Show("Event log exported successfully.", "Export", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + _logger.Error(ex, "导出事件日志 CSV 失败 | Failed to export event log as CSV"); + MessageBox.Show($"Failed to export CSV: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private static string PromptSavePath(string title, string filter) + { + var dialog = new SaveFileDialog + { + Title = title, + Filter = filter, + AddExtension = true, + OverwritePrompt = true + }; + + return dialog.ShowDialog() == true ? dialog.FileName : null; + } + + private static string GetApplicationVersion() + { + return Assembly.GetEntryAssembly()?.GetName().Version?.ToString() ?? + Assembly.GetExecutingAssembly().GetName().Version?.ToString() ?? + "unknown"; + } + + private static string EscapeCsv(string value) + { + value ??= string.Empty; + if (value.Contains(",") || value.Contains("\"") || value.Contains("\n") || value.Contains("\r")) + { + return $"\"{value.Replace("\"", "\"\"")}\""; + } + + return value; + } + } +} diff --git a/XplorePlane/ViewModels/Debug/PerformanceMonitorViewModel.cs b/XplorePlane/ViewModels/Debug/PerformanceMonitorViewModel.cs new file mode 100644 index 0000000..78c918a --- /dev/null +++ b/XplorePlane/ViewModels/Debug/PerformanceMonitorViewModel.cs @@ -0,0 +1,168 @@ +using Prism.Mvvm; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Windows.Threading; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.AppState; + +namespace XplorePlane.ViewModels.Debug +{ + public class PerformanceMonitorViewModel : BindableBase, IDisposable + { + private readonly IAppStateService _appStateService; + private readonly ILoggerService _logger; + private readonly Dispatcher _dispatcher; + private readonly DispatcherTimer _timer; + private readonly ConcurrentQueue<(string Category, DateTime Timestamp)> _eventQueue = new(); + private readonly Dictionary> _latencyHistory = new(); + private double _maxLatency; + private bool _isLatencyWarning; + private bool _initialized; + private bool _disposed; + + public ObservableCollection Metrics { get; } = new(); + public ObservableCollection TrendData { get; } = new(); + + public double MaxLatency + { + get => _maxLatency; + private set => SetProperty(ref _maxLatency, value); + } + + public bool IsLatencyWarning + { + get => _isLatencyWarning; + private set => SetProperty(ref _isLatencyWarning, value); + } + + public PerformanceMonitorViewModel(IAppStateService appStateService, ILoggerService loggerService, Dispatcher dispatcher) + { + _appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService)); + _logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule(); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + foreach (var category in DebugPanelStateMetadata.StateCategories) + { + if (category == "CalibrationMatrix") + { + continue; + } + + Metrics.Add(new PerformanceMetric { StateType = category }); + _latencyHistory[category] = new Queue(); + } + + _timer = new DispatcherTimer(DispatcherPriority.Background, _dispatcher) + { + Interval = TimeSpan.FromSeconds(1) + }; + _timer.Tick += (_, _) => + { + UpdateFrequencyMetrics(); + UpdateTrendData(); + }; + } + + public void Initialize() + { + if (_initialized || _disposed) + { + return; + } + + _appStateService.MotionStateChanged += (_, _) => RecordEvent("MotionState"); + _appStateService.RaySourceStateChanged += (_, _) => RecordEvent("RaySourceState"); + _appStateService.DetectorStateChanged += (_, _) => RecordEvent("DetectorState"); + _appStateService.SystemStateChanged += (_, _) => RecordEvent("SystemState"); + _appStateService.CameraStateChanged += (_, _) => RecordEvent("CameraState"); + _appStateService.LinkedViewStateChanged += (_, _) => RecordEvent("LinkedViewState"); + _appStateService.RecipeExecutionStateChanged += (_, _) => RecordEvent("RecipeExecutionState"); + + _timer.Start(); + _initialized = true; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _timer.Stop(); + Metrics.Clear(); + TrendData.Clear(); + _disposed = true; + } + + private void RecordEvent(string category) + { + var timestamp = DateTime.UtcNow; + _eventQueue.Enqueue((category, timestamp)); + + _dispatcher.BeginInvoke(new Action(() => + { + var latency = (DateTime.UtcNow - timestamp).TotalMilliseconds; + UpdateLatencyMetric(category, latency); + })); + } + + private void UpdateFrequencyMetrics() + { + var cutoff = DateTime.UtcNow.AddSeconds(-1); + var events = _eventQueue.ToArray(); + var grouped = events.Where(e => e.Timestamp >= cutoff).GroupBy(e => e.Category).ToDictionary(g => g.Key, g => g.Count()); + + foreach (var metric in Metrics) + { + metric.EventsPerSecond = grouped.TryGetValue(metric.StateType, out var count) ? count : 0; + } + + while (_eventQueue.TryPeek(out var item) && item.Timestamp < cutoff.AddSeconds(-1)) + { + _eventQueue.TryDequeue(out _); + } + } + + private void UpdateLatencyMetric(string category, double latency) + { + if (!_latencyHistory.TryGetValue(category, out var history)) + { + return; + } + + history.Enqueue(latency); + while (history.Count > 60) + { + history.Dequeue(); + } + + var metric = Metrics.FirstOrDefault(m => m.StateType == category); + if (metric != null) + { + metric.AverageLatency = history.Average(); + } + + MaxLatency = Metrics.Count == 0 ? 0 : Metrics.Max(m => m.AverageLatency); + IsLatencyWarning = MaxLatency > 500; + } + + private void UpdateTrendData() + { + TrendData.Add(new TrendDataPoint + { + Timestamp = DateTime.Now, + Value = Metrics.Sum(m => m.EventsPerSecond) + }); + + while (TrendData.Count > 60) + { + TrendData.RemoveAt(0); + } + } + } +} diff --git a/XplorePlane/ViewModels/Debug/SnapshotDiffViewModel.cs b/XplorePlane/ViewModels/Debug/SnapshotDiffViewModel.cs new file mode 100644 index 0000000..576448c --- /dev/null +++ b/XplorePlane/ViewModels/Debug/SnapshotDiffViewModel.cs @@ -0,0 +1,58 @@ +using Prism.Commands; +using Prism.Mvvm; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Text; +using System.Text.Json; +using System.Windows; +using System.Windows.Threading; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; + +namespace XplorePlane.ViewModels.Debug +{ + public class SnapshotDiffViewModel : BindableBase + { + private readonly ILoggerService _logger; + private readonly Dispatcher _dispatcher; + + public ObservableCollection Differences { get; } = new(); + + public DelegateCommand ExportDifferencesCommand { get; } + + public SnapshotDiffViewModel(IEnumerable differences, ILoggerService loggerService, Dispatcher dispatcher) + { + _logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule(); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + foreach (var difference in differences ?? Array.Empty()) + { + Differences.Add(difference); + } + + ExportDifferencesCommand = new DelegateCommand(ExportDifferences); + } + + private void ExportDifferences() + { + try + { + var path = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.DesktopDirectory), + $"snapshot-diff-{DateTime.Now:yyyyMMdd-HHmmss}.json"); + + var json = JsonSerializer.Serialize(Differences, new JsonSerializerOptions { WriteIndented = true }); + File.WriteAllText(path, json, Encoding.UTF8); + + MessageBox.Show($"Differences exported to:\n{path}", "Snapshot Diff", MessageBoxButton.OK, MessageBoxImage.Information); + } + catch (Exception ex) + { + _logger.Error(ex, "导出快照差异失败 | Failed to export snapshot differences"); + MessageBox.Show($"Failed to export differences: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + } +} diff --git a/XplorePlane/ViewModels/Debug/SnapshotManagerViewModel.cs b/XplorePlane/ViewModels/Debug/SnapshotManagerViewModel.cs new file mode 100644 index 0000000..93b5449 --- /dev/null +++ b/XplorePlane/ViewModels/Debug/SnapshotManagerViewModel.cs @@ -0,0 +1,293 @@ +using Microsoft.Win32; +using Prism.Commands; +using Prism.Mvvm; +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Text.Json; +using System.Windows; +using System.Windows.Threading; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.AppState; +using XplorePlane.Views.Debug; + +namespace XplorePlane.ViewModels.Debug +{ + public class SnapshotViewModel : BindableBase + { + private bool _isSelected; + + public StateSnapshot Snapshot { get; set; } + + public bool IsSelected + { + get => _isSelected; + set => SetProperty(ref _isSelected, value); + } + } + + public class SnapshotManagerViewModel : BindableBase, IDisposable + { + private const int MaxSnapshots = 50; + + private readonly IAppStateService _appStateService; + private readonly ILoggerService _logger; + private readonly Dispatcher _dispatcher; + private readonly JsonSerializerOptions _jsonOptions = new() { WriteIndented = true }; + private SnapshotViewModel _selectedSnapshot; + + public ObservableCollection Snapshots { get; } = new(); + public ObservableCollection SnapshotDetails { get; } = new(); + + public SnapshotViewModel SelectedSnapshot + { + get => _selectedSnapshot; + set + { + if (SetProperty(ref _selectedSnapshot, value)) + { + UpdateSnapshotDetails(); + } + } + } + + public DelegateCommand CaptureSnapshotCommand { get; } + public DelegateCommand CompareSnapshotsCommand { get; } + public DelegateCommand ExportSnapshotCommand { get; } + public DelegateCommand DeleteSnapshotCommand { get; } + + public SnapshotManagerViewModel(IAppStateService appStateService, ILoggerService loggerService, Dispatcher dispatcher) + { + _appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService)); + _logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule(); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + CaptureSnapshotCommand = new DelegateCommand(CaptureSnapshot); + CompareSnapshotsCommand = new DelegateCommand(CompareSnapshots); + ExportSnapshotCommand = new DelegateCommand(ExportSnapshot); + DeleteSnapshotCommand = new DelegateCommand(DeleteSnapshot); + } + + public void Dispose() + { + Snapshots.Clear(); + SnapshotDetails.Clear(); + SelectedSnapshot = null; + } + + public void CaptureSnapshot() + { + try + { + var snapshot = new StateSnapshot + { + Id = Guid.NewGuid(), + Timestamp = DateTime.Now, + MotionState = _appStateService.MotionState, + RaySourceState = _appStateService.RaySourceState, + DetectorState = _appStateService.DetectorState, + SystemState = _appStateService.SystemState, + CameraState = _appStateService.CameraState, + LinkedViewState = _appStateService.LinkedViewState, + RecipeExecutionState = _appStateService.RecipeExecutionState, + CalibrationMatrix = _appStateService.CalibrationMatrix + }; + + var item = new SnapshotViewModel { Snapshot = snapshot }; + Snapshots.Insert(0, item); + SelectedSnapshot = item; + + while (Snapshots.Count > MaxSnapshots) + { + Snapshots.RemoveAt(Snapshots.Count - 1); + } + + _logger.Info("快照已捕获 {Id} | Snapshot captured {Id}", snapshot.Id); + } + catch (Exception ex) + { + _logger.Error(ex, "捕获调试快照失败 | Failed to capture debug snapshot"); + } + } + + public IReadOnlyList ComputeDifference(StateSnapshot first, StateSnapshot second) + { + var differences = new List(); + if (first == null || second == null) + { + return differences; + } + + CompareObject("MotionState", first.MotionState, second.MotionState, differences); + CompareObject("RaySourceState", first.RaySourceState, second.RaySourceState, differences); + CompareObject("DetectorState", first.DetectorState, second.DetectorState, differences); + CompareObject("SystemState", first.SystemState, second.SystemState, differences); + CompareObject("CameraState", first.CameraState, second.CameraState, differences); + CompareObject("LinkedViewState", first.LinkedViewState, second.LinkedViewState, differences); + CompareObject("RecipeExecutionState", first.RecipeExecutionState, second.RecipeExecutionState, differences); + CompareObject("CalibrationMatrix", first.CalibrationMatrix, second.CalibrationMatrix, differences); + + return differences; + } + + private void CompareSnapshots() + { + try + { + var selected = Snapshots.Where(s => s.IsSelected).Take(2).ToList(); + if (selected.Count != 2) + { + MessageBox.Show("Please select exactly two snapshots to compare.", "Compare Snapshots", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var differences = ComputeDifference(selected[0].Snapshot, selected[1].Snapshot); + var viewModel = new SnapshotDiffViewModel(differences, _logger, _dispatcher); + var window = new SnapshotDiffWindow + { + DataContext = viewModel, + Owner = Application.Current?.MainWindow + }; + + window.Show(); + _logger.Info("快照对比已打开 | Snapshot comparison window opened"); + } + catch (Exception ex) + { + _logger.Error(ex, "比较快照失败 | Failed to compare snapshots"); + MessageBox.Show($"Failed to compare snapshots: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void ExportSnapshot() + { + try + { + if (SelectedSnapshot?.Snapshot == null) + { + MessageBox.Show("Please select a snapshot first.", "Export Snapshot", MessageBoxButton.OK, MessageBoxImage.Information); + return; + } + + var dialog = new SaveFileDialog + { + Title = "Export Snapshot", + Filter = "JSON files (*.json)|*.json", + FileName = $"snapshot-{SelectedSnapshot.Snapshot.Timestamp:yyyyMMdd-HHmmss}.json" + }; + + if (dialog.ShowDialog() != true) + { + return; + } + + File.WriteAllText(dialog.FileName, JsonSerializer.Serialize(SelectedSnapshot.Snapshot, _jsonOptions)); + _logger.Info("快照已导出到 {Path} | Snapshot exported to {Path}", dialog.FileName); + } + catch (Exception ex) + { + _logger.Error(ex, "导出快照失败 | Failed to export snapshot"); + MessageBox.Show($"Failed to export snapshot: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error); + } + } + + private void DeleteSnapshot() + { + try + { + if (SelectedSnapshot == null) + { + return; + } + + var result = MessageBox.Show("Delete selected snapshot?", "Delete Snapshot", MessageBoxButton.YesNo, MessageBoxImage.Question); + if (result != MessageBoxResult.Yes) + { + return; + } + + var snapshot = SelectedSnapshot; + var index = Snapshots.IndexOf(snapshot); + Snapshots.Remove(snapshot); + SelectedSnapshot = index >= 0 && index < Snapshots.Count ? Snapshots[index] : Snapshots.FirstOrDefault(); + } + catch (Exception ex) + { + _logger.Error(ex, "删除快照失败 | Failed to delete snapshot"); + } + } + + private void UpdateSnapshotDetails() + { + SnapshotDetails.Clear(); + if (SelectedSnapshot?.Snapshot == null) + { + return; + } + + AddSnapshotRoot("MotionState", SelectedSnapshot.Snapshot.MotionState); + AddSnapshotRoot("RaySourceState", SelectedSnapshot.Snapshot.RaySourceState); + AddSnapshotRoot("DetectorState", SelectedSnapshot.Snapshot.DetectorState); + AddSnapshotRoot("SystemState", SelectedSnapshot.Snapshot.SystemState); + AddSnapshotRoot("CameraState", SelectedSnapshot.Snapshot.CameraState); + AddSnapshotRoot("LinkedViewState", SelectedSnapshot.Snapshot.LinkedViewState); + AddSnapshotRoot("RecipeExecutionState", SelectedSnapshot.Snapshot.RecipeExecutionState); + AddSnapshotRoot("CalibrationMatrix", SelectedSnapshot.Snapshot.CalibrationMatrix); + } + + private void AddSnapshotRoot(string category, object value) + { + var root = new StateNodeViewModel + { + Name = category, + Category = category + }; + + if (value != null) + { + foreach (var property in value.GetType().GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + root.Children.Add(new StateNodeViewModel + { + Name = property.Name, + Category = category, + Value = DebugPanelStateFormatter.FormatValue(property.GetValue(value)) + }); + } + } + + SnapshotDetails.Add(root); + } + + private static void CompareObject(string category, object first, object second, ICollection differences) + { + var type = first?.GetType() ?? second?.GetType(); + if (type == null) + { + return; + } + + foreach (var property in type.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + var firstValue = first == null ? null : property.GetValue(first); + var secondValue = second == null ? null : property.GetValue(second); + if (Equals(firstValue, secondValue)) + { + continue; + } + + differences.Add(new StateDifference + { + Category = category, + FieldName = property.Name, + Value1 = DebugPanelStateFormatter.FormatValue(firstValue), + Value2 = DebugPanelStateFormatter.FormatValue(secondValue) + }); + } + } + } +} diff --git a/XplorePlane/ViewModels/Debug/StateDisplayViewModel.cs b/XplorePlane/ViewModels/Debug/StateDisplayViewModel.cs new file mode 100644 index 0000000..b920df0 --- /dev/null +++ b/XplorePlane/ViewModels/Debug/StateDisplayViewModel.cs @@ -0,0 +1,250 @@ +using Prism.Mvvm; +using System; +using System.Collections.ObjectModel; +using System.Reflection; +using System.Threading.Tasks; +using System.Windows.Threading; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.AppState; + +namespace XplorePlane.ViewModels.Debug +{ + public class StateDisplayViewModel : BindableBase, IDisposable + { + private readonly IAppStateService _appStateService; + private readonly ILoggerService _logger; + private readonly Dispatcher _dispatcher; + private bool _initialized; + private bool _disposed; + + public ObservableCollection StateTree { get; } = new(); + + public StateDisplayViewModel(IAppStateService appStateService, ILoggerService loggerService, Dispatcher dispatcher) + { + _appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService)); + _logger = (loggerService ?? throw new ArgumentNullException(nameof(loggerService))).ForModule(); + _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher)); + + InitializeStateTree(); + } + + public void Initialize() + { + if (_initialized || _disposed) + { + return; + } + + _appStateService.MotionStateChanged += OnMotionStateChanged; + _appStateService.RaySourceStateChanged += OnRaySourceStateChanged; + _appStateService.DetectorStateChanged += OnDetectorStateChanged; + _appStateService.SystemStateChanged += OnSystemStateChanged; + _appStateService.CameraStateChanged += OnCameraStateChanged; + _appStateService.LinkedViewStateChanged += OnLinkedViewStateChanged; + _appStateService.RecipeExecutionStateChanged += OnRecipeExecutionStateChanged; + + ApplyStateSnapshot("MotionState", _appStateService.MotionState); + ApplyStateSnapshot("RaySourceState", _appStateService.RaySourceState); + ApplyStateSnapshot("DetectorState", _appStateService.DetectorState); + ApplyStateSnapshot("SystemState", _appStateService.SystemState); + ApplyStateSnapshot("CameraState", _appStateService.CameraState); + ApplyStateSnapshot("LinkedViewState", _appStateService.LinkedViewState); + ApplyStateSnapshot("RecipeExecutionState", _appStateService.RecipeExecutionState); + ApplyStateSnapshot("CalibrationMatrix", _appStateService.CalibrationMatrix); + + _initialized = true; + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + if (_initialized) + { + _appStateService.MotionStateChanged -= OnMotionStateChanged; + _appStateService.RaySourceStateChanged -= OnRaySourceStateChanged; + _appStateService.DetectorStateChanged -= OnDetectorStateChanged; + _appStateService.SystemStateChanged -= OnSystemStateChanged; + _appStateService.CameraStateChanged -= OnCameraStateChanged; + _appStateService.LinkedViewStateChanged -= OnLinkedViewStateChanged; + _appStateService.RecipeExecutionStateChanged -= OnRecipeExecutionStateChanged; + } + + _disposed = true; + } + + private void InitializeStateTree() + { + AddRootNode("MotionState", typeof(MotionState)); + AddRootNode("RaySourceState", typeof(RaySourceState)); + AddRootNode("DetectorState", typeof(DetectorState)); + AddRootNode("SystemState", typeof(SystemState)); + AddRootNode("CameraState", typeof(CameraState)); + AddRootNode("LinkedViewState", typeof(LinkedViewState)); + AddRootNode("RecipeExecutionState", typeof(RecipeExecutionState)); + AddRootNode("CalibrationMatrix", typeof(CalibrationMatrix)); + } + + private void AddRootNode(string category, Type stateType) + { + var root = new StateNodeViewModel + { + Name = category, + Category = category + }; + + foreach (var property in stateType.GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + root.Children.Add(new StateNodeViewModel + { + Name = property.Name, + Category = category, + Value = string.Empty + }); + } + + StateTree.Add(root); + } + + private void OnMotionStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("MotionState", e.OldValue, e.NewValue); + private void OnRaySourceStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("RaySourceState", e.OldValue, e.NewValue); + private void OnDetectorStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("DetectorState", e.OldValue, e.NewValue); + private void OnSystemStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("SystemState", e.OldValue, e.NewValue); + private void OnCameraStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("CameraState", e.OldValue, e.NewValue); + private void OnLinkedViewStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("LinkedViewState", e.OldValue, e.NewValue); + private void OnRecipeExecutionStateChanged(object sender, StateChangedEventArgs e) => OnStateChanged("RecipeExecutionState", e.OldValue, e.NewValue); + + private void OnStateChanged(string category, T oldState, T newState) + { + try + { + _dispatcher.BeginInvoke(new Action(() => + { + UpdateStateNodes(category, oldState, newState); + })); + } + catch (Exception ex) + { + _logger.Error(ex, "更新状态树失败 | Failed to update state tree for {Category}", category); + } + } + + private void ApplyStateSnapshot(string category, T state) + { + if (state == null) + { + return; + } + + UpdateStateNodes(category, default, state); + } + + private void UpdateStateNodes(string category, T oldState, T newState) + { + if (newState == null) + { + return; + } + + foreach (var property in typeof(T).GetProperties(BindingFlags.Instance | BindingFlags.Public)) + { + var oldValue = oldState == null ? null : property.GetValue(oldState); + var newValue = property.GetValue(newState); + UpdateStateNode(category, property.Name, oldValue, newValue); + } + } + + private void UpdateStateNode(string category, string fieldName, object oldValue, object newValue) + { + var node = FindNode(category, fieldName); + if (node == null) + { + return; + } + + node.Value = DebugPanelStateFormatter.FormatValue(newValue); + + if (!Equals(oldValue, newValue)) + { + node.HighlightColor = DetermineHighlightColor(oldValue, newValue); + node.IsHighlighted = true; + _ = ClearHighlightAsync(node); + } + else + { + node.IsHighlighted = false; + node.HighlightColor = null; + } + } + + private StateNodeViewModel FindNode(string category, string fieldName) + { + foreach (var root in StateTree) + { + if (!string.Equals(root.Name, category, StringComparison.Ordinal)) + { + continue; + } + + foreach (var child in root.Children) + { + if (string.Equals(child.Name, fieldName, StringComparison.Ordinal)) + { + return child; + } + } + } + + return null; + } + + private string DetermineHighlightColor(object oldValue, object newValue) + { + if (newValue is bool boolValue) + { + return boolValue ? "Green" : "Red"; + } + + var newType = newValue?.GetType(); + if (newType != null && DebugPanelStateFormatter.IsNumeric(newType)) + { + var oldNumber = oldValue == null ? 0d : Convert.ToDouble(oldValue); + var newNumber = Convert.ToDouble(newValue); + + if (newNumber > oldNumber) + { + return "Green"; + } + + if (newNumber < oldNumber) + { + return "Red"; + } + + return "Yellow"; + } + + return "Yellow"; + } + + private async Task ClearHighlightAsync(StateNodeViewModel node) + { + await Task.Delay(TimeSpan.FromSeconds(2)); + + if (_disposed) + { + return; + } + + _dispatcher.BeginInvoke(new Action(() => + { + node.IsHighlighted = false; + node.HighlightColor = null; + })); + } + } +} diff --git a/XplorePlane/Views/Debug/DebugPanelWindow.xaml b/XplorePlane/Views/Debug/DebugPanelWindow.xaml new file mode 100644 index 0000000..631a0e5 --- /dev/null +++ b/XplorePlane/Views/Debug/DebugPanelWindow.xaml @@ -0,0 +1,50 @@ + + + + + + + + +