#0050 新增CNC视图
This commit is contained in:
@@ -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-1(record 不可变,使用 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; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user