#0050 新增CNC视图

This commit is contained in:
zhengxuan.zhang
2026-03-27 09:54:03 +08:00
parent acc9b11942
commit 08fd25cdd0
32 changed files with 3182 additions and 13 deletions
@@ -0,0 +1,386 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
namespace XplorePlane.Services.Cnc
{
/// <summary>
/// CNC 程序管理服务实现。
/// 负责程序创建、节点编辑(插入/删除/移动 + 自动重编号)、JSON 序列化/反序列化和 .xp 文件读写。
/// CNC program management service implementation.
/// Handles program creation, node editing (insert/remove/move + auto-renumber), JSON serialization and .xp file I/O.
/// </summary>
public class CncProgramService : ICncProgramService
{
private readonly IAppStateService _appStateService;
private readonly ILoggerService _logger;
// ── 序列化配置 | Serialization options ──
private static readonly JsonSerializerOptions CncJsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public CncProgramService(
IAppStateService appStateService,
ILoggerService logger)
{
ArgumentNullException.ThrowIfNull(appStateService);
ArgumentNullException.ThrowIfNull(logger);
_appStateService = appStateService;
_logger = logger.ForModule<CncProgramService>();
_logger.Info("CncProgramService 已初始化 | CncProgramService initialized");
}
/// <inheritdoc />
public CncProgram CreateProgram(string name)
{
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("程序名称不能为空 | Program name cannot be empty", nameof(name));
var now = DateTime.UtcNow;
var program = new CncProgram(
Id: Guid.NewGuid(),
Name: name,
CreatedAt: now,
UpdatedAt: now,
Nodes: Array.Empty<CncNode>());
_logger.Info("已创建 CNC 程序 | Created CNC program: {ProgramName}, Id={ProgramId}", name, program.Id);
return program;
}
/// <inheritdoc />
public CncNode CreateNode(CncNodeType type)
{
var id = Guid.NewGuid();
const int defaultIndex = 0;
CncNode node = type switch
{
// 参考点:捕获 6 轴位置 | Reference point: capture 6-axis position
CncNodeType.ReferencePoint => CreateReferencePointNode(id, defaultIndex),
// 保存节点(含图像):捕获全部设备状态 | Save node with image: capture all device states
CncNodeType.SaveNodeWithImage => CreateSaveNodeWithImageNode(id, defaultIndex),
// 保存节点(不含图像):捕获全部设备状态 | Save node: capture all device states
CncNodeType.SaveNode => CreateSaveNodeNode(id, defaultIndex),
// 保存位置:仅捕获运动状态 | Save position: capture motion state only
CncNodeType.SavePosition => CreateSavePositionNode(id, defaultIndex),
// 检测模块:空 Pipeline | Inspection module: empty pipeline
CncNodeType.InspectionModule => new InspectionModuleNode(
id, defaultIndex, $"检测模块_{defaultIndex}",
Pipeline: new PipelineModel()),
// 检测标记:默认标记值 | Inspection marker: default marker values
CncNodeType.InspectionMarker => new InspectionMarkerNode(
id, defaultIndex, $"检测标记_{defaultIndex}",
MarkerType: "Default",
MarkerX: 0.0, MarkerY: 0.0),
// 停顿对话框:默认标题/消息 | Pause dialog: default title/message
CncNodeType.PauseDialog => new PauseDialogNode(
id, defaultIndex, $"停顿_{defaultIndex}",
DialogTitle: "暂停",
DialogMessage: "请确认后继续"),
// 等待延时:默认 1000ms | Wait delay: default 1000ms
CncNodeType.WaitDelay => new WaitDelayNode(
id, defaultIndex, $"延时_{defaultIndex}",
DelayMilliseconds: 1000),
// 完成程序:默认名称 | Complete program: default name
CncNodeType.CompleteProgram => new CompleteProgramNode(
id, defaultIndex, "完成程序"),
_ => throw new ArgumentOutOfRangeException(nameof(type), type, $"不支持的节点类型 | Unsupported node type: {type}")
};
_logger.Info("已创建节点 | Created node: Type={NodeType}, Id={NodeId}", type, id);
return node;
}
/// <inheritdoc />
public CncProgram InsertNode(CncProgram program, int afterIndex, CncNode node)
{
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(node);
// 防止重复插入 CompleteProgram | Prevent duplicate CompleteProgram insertion
if (node.NodeType == CncNodeType.CompleteProgram &&
program.Nodes.Any(n => n.NodeType == CncNodeType.CompleteProgram))
{
throw new InvalidOperationException(
"CNC 程序中已存在完成程序节点,不允许重复插入 | A CompleteProgram node already exists, duplicate insertion is not allowed");
}
var nodes = new List<CncNode>(program.Nodes);
// afterIndex = -1 表示插入到开头 | afterIndex = -1 means insert at the beginning
int insertAt = afterIndex + 1;
if (insertAt < 0) insertAt = 0;
if (insertAt > nodes.Count) insertAt = nodes.Count;
nodes.Insert(insertAt, node);
var updated = program with
{
Nodes = RenumberNodes(nodes),
UpdatedAt = DateTime.UtcNow
};
_logger.Info("已插入节点 | Inserted node: Type={NodeType}, AfterIndex={AfterIndex}, Program={ProgramName}",
node.NodeType, afterIndex, program.Name);
return updated;
}
/// <inheritdoc />
public CncProgram RemoveNode(CncProgram program, int index)
{
ArgumentNullException.ThrowIfNull(program);
if (index < 0 || index >= program.Nodes.Count)
throw new ArgumentOutOfRangeException(nameof(index),
$"索引超出范围 | Index out of range: {index}, Count={program.Nodes.Count}");
var nodes = new List<CncNode>(program.Nodes);
nodes.RemoveAt(index);
var updated = program with
{
Nodes = RenumberNodes(nodes),
UpdatedAt = DateTime.UtcNow
};
_logger.Info("已移除节点 | Removed node: Index={Index}, Program={ProgramName}",
index, program.Name);
return updated;
}
/// <inheritdoc />
public CncProgram MoveNode(CncProgram program, int oldIndex, int newIndex)
{
ArgumentNullException.ThrowIfNull(program);
if (oldIndex < 0 || oldIndex >= program.Nodes.Count)
throw new ArgumentOutOfRangeException(nameof(oldIndex),
$"旧索引超出范围 | Old index out of range: {oldIndex}, Count={program.Nodes.Count}");
if (newIndex < 0 || newIndex >= program.Nodes.Count)
throw new ArgumentOutOfRangeException(nameof(newIndex),
$"新索引超出范围 | New index out of range: {newIndex}, Count={program.Nodes.Count}");
var nodes = new List<CncNode>(program.Nodes);
var node = nodes[oldIndex];
nodes.RemoveAt(oldIndex);
nodes.Insert(newIndex, node);
var updated = program with
{
Nodes = RenumberNodes(nodes),
UpdatedAt = DateTime.UtcNow
};
_logger.Info("已移动节点 | Moved node: OldIndex={OldIndex} -> NewIndex={NewIndex}, Program={ProgramName}",
oldIndex, newIndex, program.Name);
return updated;
}
/// <inheritdoc />
public async Task SaveAsync(CncProgram program, string filePath)
{
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(filePath);
// 路径安全性验证:检查路径遍历 | Path safety: check for path traversal
if (filePath.Contains("..", StringComparison.Ordinal))
throw new UnauthorizedAccessException(
$"文件路径包含不安全的路径遍历字符 | File path contains unsafe path traversal characters: {filePath}");
var json = Serialize(program);
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
_logger.Info("已保存 CNC 程序到文件 | Saved CNC program to file: {FilePath}, Program={ProgramName}",
filePath, program.Name);
}
/// <inheritdoc />
public async Task<CncProgram> LoadAsync(string filePath)
{
ArgumentNullException.ThrowIfNull(filePath);
// 路径安全性验证 | Path safety check
if (filePath.Contains("..", StringComparison.Ordinal))
throw new UnauthorizedAccessException(
$"文件路径包含不安全的路径遍历字符 | File path contains unsafe path traversal characters: {filePath}");
if (!File.Exists(filePath))
throw new FileNotFoundException(
$"CNC 程序文件不存在 | CNC program file not found: {filePath}", filePath);
string json;
try
{
json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not FileNotFoundException)
{
throw new InvalidDataException(
$"无法读取 CNC 程序文件 | Cannot read CNC program file: {filePath}", ex);
}
var program = Deserialize(json);
_logger.Info("已加载 CNC 程序 | Loaded CNC program: {FilePath}, Program={ProgramName}, Nodes={NodeCount}",
filePath, program.Name, program.Nodes.Count);
return program;
}
/// <inheritdoc />
public string Serialize(CncProgram program)
{
ArgumentNullException.ThrowIfNull(program);
return JsonSerializer.Serialize(program, CncJsonOptions);
}
/// <inheritdoc />
public CncProgram Deserialize(string json)
{
if (string.IsNullOrWhiteSpace(json))
throw new InvalidDataException("JSON 字符串为空 | JSON string is empty");
CncProgram program;
try
{
program = JsonSerializer.Deserialize<CncProgram>(json, CncJsonOptions);
}
catch (JsonException ex)
{
throw new InvalidDataException(
$"CNC 程序 JSON 格式无效 | Invalid CNC program JSON format: {ex.Message}", ex);
}
if (program is null)
throw new InvalidDataException("反序列化结果为 null | Deserialization result is null");
if (string.IsNullOrWhiteSpace(program.Name))
throw new InvalidDataException("CNC 程序数据不完整,缺少名称 | CNC program data incomplete, missing Name");
if (program.Nodes is null)
throw new InvalidDataException("CNC 程序数据不完整,缺少节点列表 | CNC program data incomplete, missing Nodes");
// 对节点应用值域校验(如 WaitDelay 截断)| Apply value range validation to nodes
var validatedNodes = ValidateNodes(program.Nodes);
return program with { Nodes = validatedNodes };
}
// ── 常量 | Constants ──
/// <summary>等待延时最小值(毫秒)| Wait delay minimum (ms)</summary>
private const int WaitDelayMin = 0;
/// <summary>等待延时最大值(毫秒)| Wait delay maximum (ms)</summary>
private const int WaitDelayMax = 300000;
// ── 内部辅助方法 | Internal helper methods ──
/// <summary>
/// 将等待延时值截断到 [0, 300000] 范围 | Clamp wait delay value to [0, 300000] range
/// </summary>
internal static int ClampWaitDelay(int value) =>
Math.Clamp(value, WaitDelayMin, WaitDelayMax);
/// <summary>
/// 对反序列化后的节点列表应用值域校验(如 WaitDelay 截断)
/// Apply value range validation to deserialized nodes (e.g. WaitDelay clamping)
/// </summary>
private static IReadOnlyList<CncNode> ValidateNodes(IReadOnlyList<CncNode> nodes)
{
var result = new List<CncNode>(nodes.Count);
foreach (var node in nodes)
{
if (node is WaitDelayNode wdn)
{
var clamped = ClampWaitDelay(wdn.DelayMilliseconds);
result.Add(clamped != wdn.DelayMilliseconds ? wdn with { DelayMilliseconds = clamped } : wdn);
}
else
{
result.Add(node);
}
}
return result.AsReadOnly();
}
/// <summary>
/// 重新编号节点索引为连续序列 0, 1, 2, ..., N-1record 不可变,使用 with 语法)
/// Renumber node indices to consecutive sequence 0, 1, 2, ..., N-1 (records are immutable, use with syntax)
/// </summary>
private static IReadOnlyList<CncNode> RenumberNodes(List<CncNode> nodes)
{
var result = new List<CncNode>(nodes.Count);
for (int i = 0; i < nodes.Count; i++)
{
result.Add(nodes[i] with { Index = i });
}
return result.AsReadOnly();
}
/// <summary>创建参考点节点 | Create reference point node</summary>
private ReferencePointNode CreateReferencePointNode(Guid id, int index)
{
var motion = _appStateService.MotionState;
return new ReferencePointNode(
id, index, $"参考点_{index}",
XM: motion.XM,
YM: motion.YM,
ZT: motion.ZT,
ZD: motion.ZD,
TiltD: motion.TiltD,
Dist: motion.Dist);
}
/// <summary>创建保存节点(含图像)| Create save node with image</summary>
private SaveNodeWithImageNode CreateSaveNodeWithImageNode(Guid id, int index)
{
return new SaveNodeWithImageNode(
id, index, $"保存节点(图像)_{index}",
MotionState: _appStateService.MotionState,
RaySourceState: _appStateService.RaySourceState,
DetectorState: _appStateService.DetectorState,
ImageFileName: "");
}
/// <summary>创建保存节点(不含图像)| Create save node without image</summary>
private SaveNodeNode CreateSaveNodeNode(Guid id, int index)
{
return new SaveNodeNode(
id, index, $"保存节点_{index}",
MotionState: _appStateService.MotionState,
RaySourceState: _appStateService.RaySourceState,
DetectorState: _appStateService.DetectorState);
}
/// <summary>创建保存位置节点 | Create save position node</summary>
private SavePositionNode CreateSavePositionNode(Guid id, int index)
{
return new SavePositionNode(
id, index, $"保存位置_{index}",
MotionState: _appStateService.MotionState);
}
}
}
@@ -0,0 +1,39 @@
using System.Threading.Tasks;
using XplorePlane.Models;
namespace XplorePlane.Services.Cnc
{
/// <summary>
/// CNC 程序管理服务接口,负责程序的创建、节点编辑、序列化/反序列化和文件读写
/// CNC program management service interface for creation, node editing, serialization and file I/O
/// </summary>
public interface ICncProgramService
{
/// <summary>创建空的 CNC 程序 | Create an empty CNC program</summary>
CncProgram CreateProgram(string name);
/// <summary>根据节点类型创建节点(从 IAppStateService 捕获设备状态)| Create a node by type (captures device state from IAppStateService)</summary>
CncNode CreateNode(CncNodeType type);
/// <summary>在指定索引之后插入节点并重新编号 | Insert a node after the given index and renumber</summary>
CncProgram InsertNode(CncProgram program, int afterIndex, CncNode node);
/// <summary>移除指定索引的节点并重新编号 | Remove the node at the given index and renumber</summary>
CncProgram RemoveNode(CncProgram program, int index);
/// <summary>将节点从旧索引移动到新索引并重新编号 | Move a node from old index to new index and renumber</summary>
CncProgram MoveNode(CncProgram program, int oldIndex, int newIndex);
/// <summary>将 CNC 程序保存到 .xp 文件 | Save CNC program to .xp file</summary>
Task SaveAsync(CncProgram program, string filePath);
/// <summary>从 .xp 文件加载 CNC 程序 | Load CNC program from .xp file</summary>
Task<CncProgram> LoadAsync(string filePath);
/// <summary>将 CNC 程序序列化为 JSON 字符串 | Serialize CNC program to JSON string</summary>
string Serialize(CncProgram program);
/// <summary>从 JSON 字符串反序列化 CNC 程序 | Deserialize CNC program from JSON string</summary>
CncProgram Deserialize(string json);
}
}
@@ -0,0 +1,39 @@
using System.Threading.Tasks;
using XplorePlane.Models;
namespace XplorePlane.Services.Matrix
{
/// <summary>
/// 矩阵编排管理服务接口,负责矩阵布局的创建、编辑、序列化/反序列化和文件读写
/// Matrix layout management service interface for creation, editing, serialization and file I/O
/// </summary>
public interface IMatrixService
{
/// <summary>创建矩阵布局 | Create a matrix layout</summary>
MatrixLayout CreateLayout(int rows, int columns, double rowSpacing, double columnSpacing);
/// <summary>更新矩阵布局参数并重新计算单元格偏移 | Update layout parameters and recalculate cell offsets</summary>
MatrixLayout UpdateLayout(MatrixLayout layout, int rows, int columns, double rowSpacing, double columnSpacing);
/// <summary>获取指定行列的单元格 | Get the cell at the specified row and column</summary>
MatrixCell GetCell(MatrixLayout layout, int row, int column);
/// <summary>切换单元格启用/禁用状态 | Toggle cell enabled/disabled state</summary>
MatrixLayout ToggleCellEnabled(MatrixLayout layout, int row, int column);
/// <summary>关联 CNC 程序到矩阵布局 | Associate a CNC program with the matrix layout</summary>
MatrixLayout AssociateCncProgram(MatrixLayout layout, CncProgram program);
/// <summary>将矩阵布局保存到 JSON 文件 | Save matrix layout to JSON file</summary>
Task SaveAsync(MatrixLayout layout, string filePath);
/// <summary>从 JSON 文件加载矩阵布局 | Load matrix layout from JSON file</summary>
Task<MatrixLayout> LoadAsync(string filePath);
/// <summary>将矩阵布局序列化为 JSON 字符串 | Serialize matrix layout to JSON string</summary>
string Serialize(MatrixLayout layout);
/// <summary>从 JSON 字符串反序列化矩阵布局 | Deserialize matrix layout from JSON string</summary>
MatrixLayout Deserialize(string json);
}
}
@@ -0,0 +1,280 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
namespace XplorePlane.Services.Matrix
{
/// <summary>
/// 矩阵编排管理服务实现。
/// 负责矩阵布局的创建、编辑、单元格偏移计算、JSON 序列化/反序列化和文件读写。
/// Matrix layout management service implementation.
/// Handles layout creation, editing, cell offset calculation, JSON serialization and file I/O.
/// </summary>
public class MatrixService : IMatrixService
{
private readonly ILoggerService _logger;
// ── 序列化配置 | Serialization options ──
private static readonly JsonSerializerOptions MatrixJsonOptions = new()
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public MatrixService(ILoggerService logger)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger.ForModule<MatrixService>();
_logger.Info("MatrixService 已初始化 | MatrixService initialized");
}
/// <inheritdoc />
public MatrixLayout CreateLayout(int rows, int columns, double rowSpacing, double columnSpacing)
{
ValidateDimensions(rows, columns);
var layout = new MatrixLayout(
Id: Guid.NewGuid(),
Rows: rows,
Columns: columns,
RowSpacing: rowSpacing,
ColumnSpacing: columnSpacing,
StartOffsetX: 0.0,
StartOffsetY: 0.0,
CncProgramPath: string.Empty,
Cells: BuildCells(rows, columns, rowSpacing, columnSpacing, 0.0, 0.0));
_logger.Info("已创建矩阵布局 | Created matrix layout: Id={LayoutId}, Rows={Rows}, Columns={Columns}",
layout.Id, rows, columns);
return layout;
}
/// <inheritdoc />
public MatrixLayout UpdateLayout(MatrixLayout layout, int rows, int columns, double rowSpacing, double columnSpacing)
{
ArgumentNullException.ThrowIfNull(layout);
ValidateDimensions(rows, columns);
var updated = layout with
{
Rows = rows,
Columns = columns,
RowSpacing = rowSpacing,
ColumnSpacing = columnSpacing,
Cells = BuildCells(rows, columns, rowSpacing, columnSpacing, layout.StartOffsetX, layout.StartOffsetY)
};
_logger.Info("已更新矩阵布局 | Updated matrix layout: Id={LayoutId}, Rows={Rows}, Columns={Columns}",
layout.Id, rows, columns);
return updated;
}
/// <inheritdoc />
public MatrixCell GetCell(MatrixLayout layout, int row, int column)
{
ArgumentNullException.ThrowIfNull(layout);
ValidateCellCoordinates(layout, row, column);
int index = row * layout.Columns + column;
return layout.Cells[index];
}
/// <inheritdoc />
public MatrixLayout ToggleCellEnabled(MatrixLayout layout, int row, int column)
{
ArgumentNullException.ThrowIfNull(layout);
ValidateCellCoordinates(layout, row, column);
int targetIndex = row * layout.Columns + column;
var cells = new List<MatrixCell>(layout.Cells.Count);
for (int i = 0; i < layout.Cells.Count; i++)
{
if (i == targetIndex)
{
var cell = layout.Cells[i];
cells.Add(cell with { IsEnabled = !cell.IsEnabled });
}
else
{
cells.Add(layout.Cells[i]);
}
}
var updated = layout with { Cells = cells.AsReadOnly() };
_logger.Info("已切换单元格启用状态 | Toggled cell enabled state: Row={Row}, Column={Column}, IsEnabled={IsEnabled}",
row, column, updated.Cells[targetIndex].IsEnabled);
return updated;
}
/// <inheritdoc />
public MatrixLayout AssociateCncProgram(MatrixLayout layout, CncProgram program)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(program);
var updated = layout with { CncProgramPath = program.Name };
_logger.Info("已关联 CNC 程序到矩阵布局 | Associated CNC program with matrix layout: LayoutId={LayoutId}, Program={ProgramName}",
layout.Id, program.Name);
return updated;
}
/// <inheritdoc />
public async Task SaveAsync(MatrixLayout layout, string filePath)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(filePath);
// 路径安全性验证:检查路径遍历 | Path safety: check for path traversal
if (filePath.Contains("..", StringComparison.Ordinal))
throw new UnauthorizedAccessException(
$"文件路径包含不安全的路径遍历字符 | File path contains unsafe path traversal characters: {filePath}");
var json = Serialize(layout);
await File.WriteAllTextAsync(filePath, json).ConfigureAwait(false);
_logger.Info("已保存矩阵布局到文件 | Saved matrix layout to file: {FilePath}, LayoutId={LayoutId}",
filePath, layout.Id);
}
/// <inheritdoc />
public async Task<MatrixLayout> LoadAsync(string filePath)
{
ArgumentNullException.ThrowIfNull(filePath);
// 路径安全性验证 | Path safety check
if (filePath.Contains("..", StringComparison.Ordinal))
throw new UnauthorizedAccessException(
$"文件路径包含不安全的路径遍历字符 | File path contains unsafe path traversal characters: {filePath}");
if (!File.Exists(filePath))
throw new FileNotFoundException(
$"矩阵布局文件不存在 | Matrix layout file not found: {filePath}", filePath);
string json;
try
{
json = await File.ReadAllTextAsync(filePath).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not FileNotFoundException)
{
throw new InvalidDataException(
$"无法读取矩阵布局文件 | Cannot read matrix layout file: {filePath}", ex);
}
var layout = Deserialize(json);
_logger.Info("已加载矩阵布局 | Loaded matrix layout: {FilePath}, LayoutId={LayoutId}, Rows={Rows}, Columns={Columns}",
filePath, layout.Id, layout.Rows, layout.Columns);
return layout;
}
/// <inheritdoc />
public string Serialize(MatrixLayout layout)
{
ArgumentNullException.ThrowIfNull(layout);
return JsonSerializer.Serialize(layout, MatrixJsonOptions);
}
/// <inheritdoc />
public MatrixLayout Deserialize(string json)
{
if (string.IsNullOrWhiteSpace(json))
throw new InvalidDataException("JSON 字符串为空 | JSON string is empty");
MatrixLayout layout;
try
{
layout = JsonSerializer.Deserialize<MatrixLayout>(json, MatrixJsonOptions);
}
catch (JsonException ex)
{
throw new InvalidDataException(
$"矩阵布局 JSON 格式无效 | Invalid matrix layout JSON format: {ex.Message}", ex);
}
if (layout is null)
throw new InvalidDataException("反序列化结果为 null | Deserialization result is null");
if (layout.Rows <= 0)
throw new InvalidDataException("矩阵布局数据不完整,行数无效 | Matrix layout data incomplete, invalid Rows");
if (layout.Columns <= 0)
throw new InvalidDataException("矩阵布局数据不完整,列数无效 | Matrix layout data incomplete, invalid Columns");
if (layout.Cells is null)
throw new InvalidDataException("矩阵布局数据不完整,缺少单元格列表 | Matrix layout data incomplete, missing Cells");
return layout;
}
// ── 内部辅助方法 | Internal helper methods ──
/// <summary>
/// 构建矩阵单元格列表,自动计算每个单元格的偏移坐标
/// Build matrix cell list with auto-calculated offset coordinates
/// OffsetX = StartOffsetX + col * ColumnSpacing
/// OffsetY = StartOffsetY + row * RowSpacing
/// </summary>
internal static IReadOnlyList<MatrixCell> BuildCells(
int rows, int columns,
double rowSpacing, double columnSpacing,
double startOffsetX, double startOffsetY)
{
var cells = new List<MatrixCell>(rows * columns);
for (int row = 0; row < rows; row++)
{
for (int col = 0; col < columns; col++)
{
cells.Add(new MatrixCell(
Row: row,
Column: col,
OffsetX: startOffsetX + col * columnSpacing,
OffsetY: startOffsetY + row * rowSpacing,
IsEnabled: true,
Status: MatrixCellStatus.NotExecuted,
ErrorMessage: string.Empty));
}
}
return cells.AsReadOnly();
}
/// <summary>
/// 验证行列数参数 | Validate row and column dimensions
/// </summary>
private static void ValidateDimensions(int rows, int columns)
{
if (rows <= 0)
throw new ArgumentOutOfRangeException(nameof(rows),
$"行数必须大于 0 | Rows must be greater than 0: {rows}");
if (columns <= 0)
throw new ArgumentOutOfRangeException(nameof(columns),
$"列数必须大于 0 | Columns must be greater than 0: {columns}");
}
/// <summary>
/// 验证单元格坐标是否在布局范围内 | Validate cell coordinates are within layout bounds
/// </summary>
private static void ValidateCellCoordinates(MatrixLayout layout, int row, int column)
{
if (row < 0 || row >= layout.Rows)
throw new ArgumentOutOfRangeException(nameof(row),
$"行索引超出范围 | Row index out of range: {row}, Rows={layout.Rows}");
if (column < 0 || column >= layout.Columns)
throw new ArgumentOutOfRangeException(nameof(column),
$"列索引超出范围 | Column index out of range: {column}, Columns={layout.Columns}");
}
}
}
@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using XplorePlane.Models;
namespace XplorePlane.Services.Measurement
{
/// <summary>
/// 测量数据存储服务接口,负责测量记录的持久化、查询和统计
/// Measurement data storage service interface for persistence, querying and statistics of measurement records
/// </summary>
public interface IMeasurementDataService
{
/// <summary>初始化数据库表和索引 | Initialize database tables and indexes</summary>
Task InitializeAsync();
/// <summary>保存单条测量记录 | Save a single measurement record</summary>
Task SaveMeasurementAsync(MeasurementRecord record);
/// <summary>按配方名称和时间范围查询测量记录 | Query measurement records by recipe name and time range</summary>
Task<IReadOnlyList<MeasurementRecord>> QueryAsync(string recipeName, DateTime? from, DateTime? to);
/// <summary>按配方名称和时间范围获取统计数据 | Get statistics by recipe name and time range</summary>
Task<MeasurementStatistics> GetStatisticsAsync(string recipeName, DateTime? from, DateTime? to);
}
}
@@ -0,0 +1,252 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using XP.Common.Database.Interfaces;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
namespace XplorePlane.Services.Measurement
{
/// <summary>
/// 测量数据存储服务实现,使用 SQLite(通过 IDbContext)持久化测量记录并提供统计查询。
/// Measurement data storage service implementation using SQLite (via IDbContext) to persist records and provide statistics.
/// </summary>
public class MeasurementDataService : IMeasurementDataService
{
private readonly IDbContext _db;
private readonly ILoggerService _logger;
// ── SQL 常量 | SQL constants ──
private const string CreateTableSql = @"
CREATE TABLE IF NOT EXISTS measurement_data (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_name TEXT NOT NULL,
step_index INTEGER NOT NULL,
timestamp TEXT NOT NULL,
result_value REAL NOT NULL,
is_pass INTEGER NOT NULL DEFAULT 0
);
CREATE INDEX IF NOT EXISTS idx_measurement_recipe ON measurement_data(recipe_name);
CREATE INDEX IF NOT EXISTS idx_measurement_timestamp ON measurement_data(timestamp);";
private const string InsertSql = @"
INSERT INTO measurement_data (recipe_name, step_index, timestamp, result_value, is_pass)
VALUES (@recipe_name, @step_index, @timestamp, @result_value, @is_pass)";
// ── 构造函数 | Constructor ──
public MeasurementDataService(IDbContext db, ILoggerService logger)
{
ArgumentNullException.ThrowIfNull(db);
ArgumentNullException.ThrowIfNull(logger);
_db = db;
_logger = logger.ForModule<MeasurementDataService>();
_logger.Info("MeasurementDataService 已初始化 | MeasurementDataService initialized");
}
// ── 公共方法 | Public methods ──
/// <inheritdoc />
public async Task InitializeAsync()
{
_logger.Info("正在初始化测量数据表 | Initializing measurement data table");
var result = await _db.ExecuteNonQueryAsync(CreateTableSql).ConfigureAwait(false);
if (!result.IsSuccess)
{
_logger.Error(result.Exception!, "初始化测量数据表失败 | Failed to initialize measurement data table: {Message}", result.Message);
throw new InvalidOperationException(
$"初始化测量数据表失败 | Failed to initialize measurement data table: {result.Message}",
result.Exception);
}
_logger.Info("测量数据表初始化完成 | Measurement data table initialized");
}
/// <inheritdoc />
public async Task SaveMeasurementAsync(MeasurementRecord record)
{
ArgumentNullException.ThrowIfNull(record);
var parameters = new Dictionary<string, object>
{
["@recipe_name"] = record.RecipeName,
["@step_index"] = record.StepIndex,
["@timestamp"] = record.Timestamp.ToString("o"), // ISO 8601 往返格式 | ISO 8601 round-trip format
["@result_value"] = record.ResultValue,
["@is_pass"] = record.IsPass ? 1 : 0
};
var result = await _db.ExecuteNonQueryAsync(InsertSql, parameters).ConfigureAwait(false);
if (!result.IsSuccess)
{
_logger.Error(result.Exception!, "保存测量记录失败 | Failed to save measurement record: {Message}", result.Message);
throw new InvalidOperationException(
$"保存测量记录失败 | Failed to save measurement record: {result.Message}",
result.Exception);
}
_logger.Debug("已保存测量记录 | Saved measurement record: Recipe={RecipeName}, Step={StepIndex}, IsPass={IsPass}",
record.RecipeName, record.StepIndex, record.IsPass);
}
/// <inheritdoc />
public async Task<IReadOnlyList<MeasurementRecord>> QueryAsync(string recipeName, DateTime? from, DateTime? to)
{
var (sql, parameters) = BuildFilteredQuery(
"SELECT id, recipe_name, step_index, timestamp, result_value, is_pass FROM measurement_data",
recipeName, from, to,
orderBy: "ORDER BY timestamp ASC");
var (result, data) = await _db.QueryListAsync<MeasurementDataRow>(sql, parameters).ConfigureAwait(false);
if (!result.IsSuccess)
{
_logger.Error(result.Exception!, "查询测量记录失败 | Failed to query measurement records: {Message}", result.Message);
throw new InvalidOperationException(
$"查询测量记录失败 | Failed to query measurement records: {result.Message}",
result.Exception);
}
// 将数据库行映射为领域模型 | Map database rows to domain model
var records = new List<MeasurementRecord>(data.Count);
foreach (var row in data)
{
records.Add(MapRow(row));
}
_logger.Debug("查询到 {Count} 条测量记录 | Queried {Count} measurement records", records.Count);
return records.AsReadOnly();
}
/// <inheritdoc />
public async Task<MeasurementStatistics> GetStatisticsAsync(string recipeName, DateTime? from, DateTime? to)
{
var (sql, parameters) = BuildFilteredQuery(
"SELECT COUNT(*) AS total_count, SUM(CASE WHEN is_pass = 1 THEN 1 ELSE 0 END) AS pass_count FROM measurement_data",
recipeName, from, to);
var (result, data) = await _db.QueryListAsync<StatisticsRow>(sql, parameters).ConfigureAwait(false);
if (!result.IsSuccess)
{
_logger.Error(result.Exception!, "获取测量统计失败 | Failed to get measurement statistics: {Message}", result.Message);
throw new InvalidOperationException(
$"获取测量统计失败 | Failed to get measurement statistics: {result.Message}",
result.Exception);
}
// 无数据时返回全零统计 | Return zero statistics when no data
if (data.Count == 0)
return new MeasurementStatistics(0, 0, 0, 0.0);
var row = data[0];
int total = row.total_count;
int pass = row.pass_count;
int fail = total - pass;
double passRate = total > 0 ? (double)pass / total : 0.0;
_logger.Debug("统计结果 | Statistics: Total={Total}, Pass={Pass}, Fail={Fail}, PassRate={PassRate:P2}",
total, pass, fail, passRate);
return new MeasurementStatistics(total, pass, fail, passRate);
}
// ── 内部辅助方法 | Internal helper methods ──
/// <summary>
/// 根据筛选条件动态构建 SQL 和参数字典
/// Dynamically build SQL and parameter dictionary based on filter conditions
/// </summary>
private static (string Sql, Dictionary<string, object>? Parameters) BuildFilteredQuery(
string selectClause,
string? recipeName,
DateTime? from,
DateTime? to,
string? orderBy = null)
{
var conditions = new List<string>();
Dictionary<string, object>? parameters = null;
// 按配方名称筛选 | Filter by recipe name
if (!string.IsNullOrWhiteSpace(recipeName))
{
conditions.Add("recipe_name = @recipe_name");
parameters ??= new Dictionary<string, object>();
parameters["@recipe_name"] = recipeName;
}
// 按起始时间筛选 | Filter by start time
if (from.HasValue)
{
conditions.Add("timestamp >= @from");
parameters ??= new Dictionary<string, object>();
parameters["@from"] = from.Value.ToString("o");
}
// 按结束时间筛选 | Filter by end time
if (to.HasValue)
{
conditions.Add("timestamp <= @to");
parameters ??= new Dictionary<string, object>();
parameters["@to"] = to.Value.ToString("o");
}
// 组装完整 SQL | Assemble complete SQL
var sql = selectClause;
if (conditions.Count > 0)
{
sql += " WHERE " + string.Join(" AND ", conditions);
}
if (!string.IsNullOrEmpty(orderBy))
{
sql += " " + orderBy;
}
return (sql, parameters);
}
/// <summary>
/// 将数据库行 DTO 映射为领域模型
/// Map database row DTO to domain model
/// </summary>
private static MeasurementRecord MapRow(MeasurementDataRow row)
{
return new MeasurementRecord
{
Id = row.id,
RecipeName = row.recipe_name,
StepIndex = row.step_index,
Timestamp = DateTime.Parse(row.timestamp, null, System.Globalization.DateTimeStyles.RoundtripKind),
ResultValue = row.result_value,
IsPass = row.is_pass != 0
};
}
// ── 内部 DTO(用于 QueryListAsync 自动映射)| Internal DTOs for QueryListAsync auto-mapping ──
/// <summary>数据库行 DTO,属性名与 SQL 列名一致以支持自动映射</summary>
internal class MeasurementDataRow
{
public long id { get; set; }
public string recipe_name { get; set; } = string.Empty;
public int step_index { get; set; }
public string timestamp { get; set; } = string.Empty;
public double result_value { get; set; }
public int is_pass { get; set; }
}
/// <summary>统计查询 DTO</summary>
internal class StatisticsRow
{
public int total_count { get; set; }
public int pass_count { get; set; }
}
}
}