diff --git a/XplorePlane/Doc/CNC多检测结果归档.md b/XplorePlane/Doc/CNC多检测结果归档.md
index fd167f3..eb786d4 100644
--- a/XplorePlane/Doc/CNC多检测结果归档.md
+++ b/XplorePlane/Doc/CNC多检测结果归档.md
@@ -45,6 +45,8 @@
- 图像或附件索引
- `PipelineExecutionSnapshot`
- 节点执行时使用的 Pipeline 快照
+- `InspectionRunEvent`
+ - 运行过程事件记录,用于完整回放执行时间线
默认图片保留策略:
@@ -119,7 +121,7 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
## 4. 数据库表设计
-当前实现包含 5 张主表。
+当前实现包含 6 张主表。
### 4.1 `inspection_runs`
@@ -134,10 +136,21 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
| `started_at` | `TEXT` | 开始时间,ISO 8601 |
| `completed_at` | `TEXT` | 结束时间,ISO 8601,可空 |
| `overall_pass` | `INTEGER` | 整体判定,`0/1` |
+| `status` | `TEXT` | 运行状态:`pending / running / completed / stopped / error` |
| `source_image_path` | `TEXT` | 原图相对路径 |
| `result_root_path` | `TEXT` | 本次结果包根目录相对路径 |
| `node_count` | `INTEGER` | 节点数量 |
+`status` 字段说明:
+
+| 状态值 | 含义 |
+|---|---|
+| `pending` | 等待执行(默认初始值) |
+| `running` | 正在执行中 |
+| `completed` | 正常完成 |
+| `stopped` | 用户手动停止 |
+| `error` | 异常终止 |
+
样例数据:
| run_id | program_name | workpiece_id | serial_number | started_at | completed_at | overall_pass | source_image_path | result_root_path | node_count |
@@ -243,6 +256,42 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `11111111-1111-1111-1111-111111111111` | `Recipe-A` | `A1B2C3...` |
| `7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a` | `22222222-2222-2222-2222-222222222222` | `Recipe-B` | `D4E5F6...` |
+### 4.6 `inspection_run_events`
+
+用途:记录运行过程中的事件流水,用于完整回放一次 CNC 运行的时间线,便于故障排查和性能分析。
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| `id` | `INTEGER` | 主键,自增 |
+| `run_id` | `TEXT` | 所属检测实例 ID |
+| `node_id` | `TEXT` | 关联节点 ID,运行级事件时为空 |
+| `event_type` | `TEXT` | 事件类型 |
+| `event_time` | `TEXT` | 事件时间,ISO 8601 |
+| `payload_json` | `TEXT` | 事件附加数据,JSON 格式,可空 |
+
+约定的 `event_type`:
+
+| 事件类型 | 含义 |
+|---|---|
+| `RunStarted` | 运行开始 |
+| `NodeStarted` | 节点开始执行 |
+| `NodeCompleted` | 节点执行完成 |
+| `NodeFailed` | 节点执行失败 |
+| `RunStopped` | 运行被用户停止 |
+| `RunCompleted` | 运行正常完成 |
+| `RunError` | 运行异常终止 |
+
+样例数据:
+
+| id | run_id | node_id | event_type | event_time | payload_json |
+|---:|---|---|---|---|---|
+| `1` | `7d7d8d7d-...` | | `RunStarted` | `2026-04-21T10:00:00.0000000Z` | |
+| `2` | `7d7d8d7d-...` | `11111111-...` | `NodeStarted` | `2026-04-21T10:00:00.1000000Z` | |
+| `3` | `7d7d8d7d-...` | `11111111-...` | `NodeCompleted` | `2026-04-21T10:00:00.2350000Z` | |
+| `4` | `7d7d8d7d-...` | `22222222-...` | `NodeStarted` | `2026-04-21T10:00:00.2400000Z` | |
+| `5` | `7d7d8d7d-...` | `22222222-...` | `NodeFailed` | `2026-04-21T10:00:00.4800000Z` | `{"error":"Solder height out of range"}` |
+| `6` | `7d7d8d7d-...` | | `RunCompleted` | `2026-04-21T10:00:03.2000000Z` | |
+
---
## 5. `manifest.json` 示例
@@ -261,6 +310,7 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
"StartedAt": "2026-04-21T10:00:00.0000000Z",
"CompletedAt": "2026-04-21T10:00:03.2000000Z",
"OverallPass": false,
+ "Status": "completed",
"SourceImagePath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a/run/source.bmp",
"ResultRootPath": "Results/2026/04/21/7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
"NodeCount": 2
@@ -318,6 +368,40 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
"IsPass": false,
"DisplayOrder": 1
}
+ ],
+ "Events": [
+ {
+ "Id": 1,
+ "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
+ "NodeId": null,
+ "EventType": "RunStarted",
+ "EventTime": "2026-04-21T10:00:00.0000000Z",
+ "PayloadJson": ""
+ },
+ {
+ "Id": 2,
+ "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
+ "NodeId": "11111111-1111-1111-1111-111111111111",
+ "EventType": "NodeStarted",
+ "EventTime": "2026-04-21T10:00:00.1000000Z",
+ "PayloadJson": ""
+ },
+ {
+ "Id": 3,
+ "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
+ "NodeId": "11111111-1111-1111-1111-111111111111",
+ "EventType": "NodeCompleted",
+ "EventTime": "2026-04-21T10:00:00.2350000Z",
+ "PayloadJson": ""
+ },
+ {
+ "Id": 6,
+ "RunId": "7d7d8d7d-1234-4567-89ab-9f0e1d2c3b4a",
+ "NodeId": null,
+ "EventType": "RunCompleted",
+ "EventTime": "2026-04-21T10:00:03.2000000Z",
+ "PayloadJson": ""
+ }
]
}
```
@@ -355,11 +439,12 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
得到:
-- `Run`
+- `Run`(含 `Status` 字段)
- `Nodes`
- `Metrics`
- `Assets`
- `PipelineSnapshots`
+- `Events`(运行事件时间线)
即可直接组装报告:
@@ -395,15 +480,19 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
当前服务接口:
- `BeginRunAsync(...)`
- - 创建实例记录和结果目录
+ - 创建实例记录和结果目录,设置状态为 `running`,记录 `RunStarted` 事件
- `AppendNodeResultAsync(...)`
- 写入节点结果、指标、图片索引、Pipeline 快照
- `CompleteRunAsync(...)`
- - 回填结束时间、整体判定,并写出 `manifest.json`
+ - 回填结束时间、整体判定,设置状态为 `completed` 或 `stopped`,记录对应事件,并写出 `manifest.json`
+- `AppendRunEventAsync(...)`
+ - 追加运行事件记录,用于完整回放执行时间线
+- `QueryRunEventsAsync(...)`
+ - 查询指定运行的所有事件记录
- `QueryRunsAsync(...)`
- 查询检测实例列表
- `GetRunDetailAsync(...)`
- - 查询单个实例的完整报告数据
+ - 查询单个实例的完整报告数据(含事件列表)
当前 DI 注册:
@@ -498,8 +587,9 @@ Results/{yyyy}/{MM}/{dd}/{RunId}/
对于后续报告模块,这套结构已经可以直接支持:
-- 历史列表查询
+- 历史列表查询(支持按状态筛选)
- 单次检测报告生成
- 结果图展示
- 节点级指标展示
- 历史结果可追溯
+- 执行过程时间线回放(故障排查、性能分析)
diff --git a/XplorePlane/Models/InspectionResultModels.cs b/XplorePlane/Models/InspectionResultModels.cs
index 9f439b2..bde9ab4 100644
--- a/XplorePlane/Models/InspectionResultModels.cs
+++ b/XplorePlane/Models/InspectionResultModels.cs
@@ -10,6 +10,44 @@ namespace XplorePlane.Models
NodeResultImage
}
+ ///
+ /// 检测运行状态枚举
+ ///
+ public enum InspectionRunStatus
+ {
+ /// 等待执行
+ Pending,
+ /// 正在执行
+ Running,
+ /// 正常完成
+ Completed,
+ /// 用户停止
+ Stopped,
+ /// 异常终止
+ Error
+ }
+
+ ///
+ /// 运行事件类型枚举
+ ///
+ public enum InspectionRunEventType
+ {
+ /// 运行开始
+ RunStarted,
+ /// 节点开始执行
+ NodeStarted,
+ /// 节点执行完成
+ NodeCompleted,
+ /// 节点执行失败
+ NodeFailed,
+ /// 运行被用户停止
+ RunStopped,
+ /// 运行正常完成
+ RunCompleted,
+ /// 运行异常终止
+ RunError
+ }
+
public enum InspectionNodeStatus
{
Succeeded,
@@ -27,6 +65,7 @@ namespace XplorePlane.Models
public DateTime StartedAt { get; set; } = DateTime.UtcNow;
public DateTime? CompletedAt { get; set; }
public bool OverallPass { get; set; }
+ public InspectionRunStatus Status { get; set; } = InspectionRunStatus.Pending;
public string SourceImagePath { get; set; } = string.Empty;
public string ResultRootPath { get; set; } = string.Empty;
public int NodeCount { get; set; }
@@ -112,5 +151,19 @@ namespace XplorePlane.Models
public IReadOnlyList Metrics { get; set; } = Array.Empty();
public IReadOnlyList Assets { get; set; } = Array.Empty();
public IReadOnlyList PipelineSnapshots { get; set; } = Array.Empty();
+ public IReadOnlyList Events { get; set; } = Array.Empty();
+ }
+
+ ///
+ /// 运行事件记录,用于完整回放一次 CNC 运行的时间线
+ ///
+ public class InspectionRunEvent
+ {
+ public long Id { get; set; }
+ public Guid RunId { get; set; }
+ public Guid? NodeId { get; set; }
+ public InspectionRunEventType EventType { get; set; }
+ public DateTime EventTime { get; set; } = DateTime.UtcNow;
+ public string PayloadJson { get; set; } = string.Empty;
}
}
diff --git a/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs b/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs
index 7944723..794afdc 100644
--- a/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs
+++ b/XplorePlane/Services/InspectionResults/IInspectionResultStore.cs
@@ -17,6 +17,16 @@ namespace XplorePlane.Services.InspectionResults
Task CompleteRunAsync(Guid runId, bool? overallPass = null, DateTime? completedAt = null);
+ ///
+ /// 追加运行事件记录,用于完整回放一次 CNC 运行的时间线。
+ ///
+ Task AppendRunEventAsync(Guid runId, Guid? nodeId, InspectionRunEventType eventType, string payloadJson = null);
+
+ ///
+ /// 查询指定运行的所有事件记录。
+ ///
+ Task> QueryRunEventsAsync(Guid runId);
+
Task> QueryRunsAsync(InspectionRunQuery query = null);
Task GetRunDetailAsync(Guid runId);
diff --git a/XplorePlane/Services/InspectionResults/InspectionResultStore.cs b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs
index e4237d0..f825299 100644
--- a/XplorePlane/Services/InspectionResults/InspectionResultStore.cs
+++ b/XplorePlane/Services/InspectionResults/InspectionResultStore.cs
@@ -37,6 +37,7 @@ CREATE TABLE IF NOT EXISTS inspection_runs (
started_at TEXT NOT NULL,
completed_at TEXT NULL,
overall_pass INTEGER NOT NULL DEFAULT 0,
+ status TEXT NOT NULL DEFAULT 'pending',
source_image_path TEXT NOT NULL,
result_root_path TEXT NOT NULL,
node_count INTEGER NOT NULL DEFAULT 0
@@ -45,6 +46,7 @@ CREATE INDEX IF NOT EXISTS idx_inspection_runs_started_at ON inspection_runs(sta
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 INDEX IF NOT EXISTS idx_inspection_runs_status ON inspection_runs(status);
CREATE TABLE IF NOT EXISTS inspection_node_results (
run_id TEXT NOT NULL,
@@ -99,15 +101,27 @@ CREATE TABLE IF NOT EXISTS pipeline_execution_snapshots (
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);";
+CREATE INDEX IF NOT EXISTS idx_pipeline_snapshots_run_id ON pipeline_execution_snapshots(run_id);
+
+CREATE TABLE IF NOT EXISTS inspection_run_events (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ run_id TEXT NOT NULL,
+ node_id TEXT NULL,
+ event_type TEXT NOT NULL,
+ event_time TEXT NOT NULL,
+ payload_json TEXT NULL,
+ FOREIGN KEY (run_id) REFERENCES inspection_runs(run_id)
+);
+CREATE INDEX IF NOT EXISTS idx_run_events_run_id ON inspection_run_events(run_id);
+CREATE INDEX IF NOT EXISTS idx_run_events_event_type ON inspection_run_events(event_type);";
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)
+ overall_pass, status, 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)";
+ @overall_pass, @status, @source_image_path, @result_root_path, @node_count)";
private const string InsertNodeSql = @"
INSERT OR REPLACE INTO inspection_node_results (
@@ -140,10 +154,19 @@ VALUES (
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 InsertEventSql = @"
+INSERT INTO inspection_run_events (run_id, node_id, event_type, event_time, payload_json)
+VALUES (@run_id, @node_id, @event_type, @event_time, @payload_json)";
+
+ private const string QueryEventsSql = @"
+SELECT id, run_id, node_id, event_type, event_time, payload_json
+FROM inspection_run_events WHERE run_id = @run_id ORDER BY id ASC";
+
private const string UpdateRunSql = @"
UPDATE inspection_runs
SET completed_at = @completed_at,
overall_pass = @overall_pass,
+ status = @status,
node_count = @node_count,
source_image_path = @source_image_path
WHERE run_id = @run_id";
@@ -207,6 +230,7 @@ WHERE run_id = @run_id";
["started_at"] = runRecord.StartedAt.ToString("o"),
["completed_at"] = runRecord.CompletedAt?.ToString("o"),
["overall_pass"] = runRecord.OverallPass ? 1 : 0,
+ ["status"] = runRecord.Status.ToString().ToLowerInvariant(),
["source_image_path"] = runRecord.SourceImagePath,
["result_root_path"] = runRecord.ResultRootPath,
["node_count"] = runRecord.NodeCount
@@ -217,6 +241,9 @@ WHERE run_id = @run_id";
_logger.Error(result.Exception!, "创建检测实例失败 | Failed to create inspection run: {Message}", result.Message);
throw new InvalidOperationException($"创建检测实例失败: {result.Message}", result.Exception);
}
+
+ // 记录运行开始事件
+ await AppendRunEventAsync(runRecord.RunId, null, InspectionRunEventType.RunStarted).ConfigureAwait(false);
}
public async Task AppendNodeResultAsync(
@@ -392,11 +419,22 @@ WHERE run_id = @run_id";
run.NodeCount = detail.Nodes.Count;
run.OverallPass = overallPass ?? detail.Nodes.All(node => node.NodePass);
+ // 根据 overallPass 参数确定最终状态
+ if (overallPass == null)
+ {
+ run.Status = InspectionRunStatus.Stopped;
+ }
+ else
+ {
+ run.Status = InspectionRunStatus.Completed;
+ }
+
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,
+ ["status"] = run.Status.ToString().ToLowerInvariant(),
["node_count"] = run.NodeCount,
["source_image_path"] = run.SourceImagePath
}).ConfigureAwait(false);
@@ -407,6 +445,10 @@ WHERE run_id = @run_id";
throw new InvalidOperationException($"完成检测实例失败: {update.Message}", update.Exception);
}
+ // 记录运行完成/停止事件
+ var eventType = overallPass == null ? InspectionRunEventType.RunStopped : InspectionRunEventType.RunCompleted;
+ await AppendRunEventAsync(runId, null, eventType).ConfigureAwait(false);
+
detail.Run = run;
await WriteManifestAsync(detail).ConfigureAwait(false);
}
@@ -540,6 +582,66 @@ WHERE run_id = @run_id";
};
}
+ ///
+ /// 追加运行事件记录,用于完整回放一次 CNC 运行的时间线。
+ ///
+ public async Task AppendRunEventAsync(Guid runId, Guid? nodeId, InspectionRunEventType eventType, string payloadJson = null)
+ {
+ await EnsureInitializedAsync().ConfigureAwait(false);
+
+ var result = await _db.ExecuteNonQueryAsync(InsertEventSql, new Dictionary
+ {
+ ["run_id"] = runId.ToString("D"),
+ ["node_id"] = nodeId?.ToString("D"),
+ ["event_type"] = eventType.ToString(),
+ ["event_time"] = DateTime.UtcNow.ToString("o"),
+ ["payload_json"] = payloadJson
+ }).ConfigureAwait(false);
+
+ if (!result.IsSuccess)
+ {
+ _logger.Warn("记录运行事件失败 | Failed to append run event: {Message}", result.Message);
+ }
+ }
+
+ ///
+ /// 查询指定运行的所有事件记录。
+ ///
+ public async Task> QueryRunEventsAsync(Guid runId)
+ {
+ await EnsureInitializedAsync().ConfigureAwait(false);
+
+ var result = await _db.QueryListAsync(QueryEventsSql, new Dictionary
+ {
+ ["run_id"] = runId.ToString("D")
+ }).ConfigureAwait(false);
+
+ if (!result.Result.IsSuccess)
+ {
+ return Array.Empty();
+ }
+
+ var events = new List();
+ foreach (var row in result.Data)
+ {
+ events.Add(MapEvent(row));
+ }
+ return events;
+ }
+
+ private static InspectionRunEvent MapEvent(InspectionRunEventRow row)
+ {
+ return new InspectionRunEvent
+ {
+ Id = row.id,
+ RunId = Guid.Parse(row.run_id),
+ NodeId = string.IsNullOrWhiteSpace(row.node_id) ? null : Guid.Parse(row.node_id),
+ EventType = Enum.TryParse(row.event_type, out var et) ? et : InspectionRunEventType.RunStarted,
+ EventTime = DateTime.Parse(row.event_time, null, System.Globalization.DateTimeStyles.RoundtripKind),
+ PayloadJson = row.payload_json ?? string.Empty
+ };
+ }
+
private async Task EnsureInitializedAsync()
{
if (_initialized)
@@ -813,6 +915,9 @@ WHERE run_id = @run_id";
runRecord.StartedAt = DateTime.UtcNow;
}
+ // BeginRun 时状态应为 Running
+ runRecord.Status = InspectionRunStatus.Running;
+
runRecord.ResultRootPath = Path.Combine(
"Results",
runRecord.StartedAt.ToString("yyyy"),
@@ -860,12 +965,25 @@ WHERE run_id = @run_id";
? null
: DateTime.Parse(row.completed_at, null, System.Globalization.DateTimeStyles.RoundtripKind),
OverallPass = row.overall_pass != 0,
+ Status = ParseRunStatus(row.status),
SourceImagePath = row.source_image_path,
ResultRootPath = row.result_root_path,
NodeCount = row.node_count
};
}
+ private static InspectionRunStatus ParseRunStatus(string status)
+ {
+ return status?.ToLowerInvariant() switch
+ {
+ "running" => InspectionRunStatus.Running,
+ "completed" => InspectionRunStatus.Completed,
+ "stopped" => InspectionRunStatus.Stopped,
+ "error" => InspectionRunStatus.Error,
+ _ => InspectionRunStatus.Pending
+ };
+ }
+
private static InspectionNodeResult MapNode(InspectionNodeRow row)
{
_ = Enum.TryParse(row.status, out var status);
@@ -957,6 +1075,7 @@ WHERE run_id = @run_id";
public string started_at { get; set; } = string.Empty;
public string completed_at { get; set; } = string.Empty;
public int overall_pass { get; set; }
+ public string status { get; set; } = "pending";
public string source_image_path { get; set; } = string.Empty;
public string result_root_path { get; set; } = string.Empty;
public int node_count { get; set; }
@@ -1011,5 +1130,15 @@ WHERE run_id = @run_id";
public string pipeline_definition_json { get; set; } = string.Empty;
public string pipeline_hash { get; set; } = string.Empty;
}
+
+ internal class InspectionRunEventRow
+ {
+ public long id { get; set; }
+ public string run_id { get; set; } = string.Empty;
+ public string node_id { get; set; } = string.Empty;
+ public string event_type { get; set; } = string.Empty;
+ public string event_time { get; set; } = string.Empty;
+ public string payload_json { get; set; } = string.Empty;
+ }
}
}