diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index b450e6b..6522a3e 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -8,7 +8,12 @@ using XplorePlane.Views; using XplorePlane.ViewModels; using XplorePlane.Services; using XplorePlane.Services.AppState; +using XplorePlane.Services.Cnc; +using XplorePlane.Services.Matrix; +using XplorePlane.Services.Measurement; using XplorePlane.Services.Recipe; +using XplorePlane.ViewModels.Cnc; +using XplorePlane.Views.Cnc; using Prism.Ioc; using Prism.DryIoc; using Prism.Modularity; @@ -247,6 +252,20 @@ namespace XplorePlane containerRegistry.RegisterSingleton(() => XP.Common.Helpers.ConfigLoader.LoadDumpConfig()); containerRegistry.RegisterSingleton(); + // ── CNC / 矩阵编排 / 测量数据服务(单例)── + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + containerRegistry.RegisterSingleton(); + + // ── CNC / 矩阵 ViewModel(瞬态)── + containerRegistry.Register(); + containerRegistry.Register(); + containerRegistry.Register(); + + // ── CNC / 矩阵导航视图 ── + containerRegistry.RegisterForNavigation(); + containerRegistry.RegisterForNavigation(); + Log.Information("依赖注入容器配置完成"); } diff --git a/XplorePlane/Assets/Icons/cnc.png b/XplorePlane/Assets/Icons/cnc.png new file mode 100644 index 0000000..1cffa09 Binary files /dev/null and b/XplorePlane/Assets/Icons/cnc.png differ diff --git a/XplorePlane/Assets/Icons/matrix.png b/XplorePlane/Assets/Icons/matrix.png new file mode 100644 index 0000000..38fc29f Binary files /dev/null and b/XplorePlane/Assets/Icons/matrix.png differ diff --git a/XplorePlane/Events/CncProgramChangedEvent.cs b/XplorePlane/Events/CncProgramChangedEvent.cs new file mode 100644 index 0000000..892e077 --- /dev/null +++ b/XplorePlane/Events/CncProgramChangedEvent.cs @@ -0,0 +1,15 @@ +using Prism.Events; + +namespace XplorePlane.Events +{ + /// + /// CNC 程序状态变更事件 | CNC program changed event + /// 当 CNC 程序被修改时通过 IEventAggregator 发布 | Published via IEventAggregator when CNC program is modified + /// + public class CncProgramChangedEvent : PubSubEvent + { + } + + /// CNC 程序变更载荷 | CNC program changed payload + public record CncProgramChangedPayload(string ProgramName, bool IsModified); +} diff --git a/XplorePlane/Events/MatrixExecutionProgressEvent.cs b/XplorePlane/Events/MatrixExecutionProgressEvent.cs new file mode 100644 index 0000000..6260c56 --- /dev/null +++ b/XplorePlane/Events/MatrixExecutionProgressEvent.cs @@ -0,0 +1,22 @@ +using Prism.Events; +using XplorePlane.Models; + +namespace XplorePlane.Events +{ + /// + /// 矩阵执行进度事件 | Matrix execution progress event + /// 矩阵执行过程中通过 IEventAggregator 发布进度更新 | Published via IEventAggregator during matrix execution + /// + public class MatrixExecutionProgressEvent : PubSubEvent + { + } + + /// 矩阵执行进度载荷 | Matrix execution progress payload + public record MatrixExecutionProgressPayload( + int CurrentRow, + int CurrentColumn, + int TotalCells, + int CompletedCells, + MatrixCellStatus Status + ); +} diff --git a/XplorePlane/Models/CncModels.cs b/XplorePlane/Models/CncModels.cs new file mode 100644 index 0000000..ac3ef6d --- /dev/null +++ b/XplorePlane/Models/CncModels.cs @@ -0,0 +1,116 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace XplorePlane.Models +{ + // ── CNC 节点类型枚举 | CNC Node Type Enumeration ────────────────── + + /// CNC 节点类型 | CNC node type + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum CncNodeType + { + ReferencePoint, + SaveNodeWithImage, + SaveNode, + SavePosition, + InspectionModule, + InspectionMarker, + PauseDialog, + WaitDelay, + CompleteProgram + } + + // ── CNC 节点基类与派生类型 | CNC Node Base & Derived Types ──────── + + /// + /// CNC 节点抽象基类(不可变)| CNC node abstract base (immutable) + /// 使用 System.Text.Json 多态序列化 | Uses System.Text.Json polymorphic serialization + /// + [JsonDerivedType(typeof(ReferencePointNode), "ReferencePoint")] + [JsonDerivedType(typeof(SaveNodeWithImageNode), "SaveNodeWithImage")] + [JsonDerivedType(typeof(SaveNodeNode), "SaveNode")] + [JsonDerivedType(typeof(SavePositionNode), "SavePosition")] + [JsonDerivedType(typeof(InspectionModuleNode), "InspectionModule")] + [JsonDerivedType(typeof(InspectionMarkerNode), "InspectionMarker")] + [JsonDerivedType(typeof(PauseDialogNode), "PauseDialog")] + [JsonDerivedType(typeof(WaitDelayNode), "WaitDelay")] + [JsonDerivedType(typeof(CompleteProgramNode), "CompleteProgram")] + public abstract record CncNode( + Guid Id, + int Index, + CncNodeType NodeType, + string Name + ); + + /// 参考点节点 | Reference point node + public record ReferencePointNode( + Guid Id, int Index, string Name, + double XM, double YM, double ZT, double ZD, double TiltD, double Dist + ) : CncNode(Id, Index, CncNodeType.ReferencePoint, Name); + + /// 保存节点(含图像)| Save node with image + public record SaveNodeWithImageNode( + Guid Id, int Index, string Name, + MotionState MotionState, + RaySourceState RaySourceState, + DetectorState DetectorState, + string ImageFileName + ) : CncNode(Id, Index, CncNodeType.SaveNodeWithImage, Name); + + /// 保存节点(不含图像)| Save node without image + public record SaveNodeNode( + Guid Id, int Index, string Name, + MotionState MotionState, + RaySourceState RaySourceState, + DetectorState DetectorState + ) : CncNode(Id, Index, CncNodeType.SaveNode, Name); + + /// 保存位置节点 | Save position node + public record SavePositionNode( + Guid Id, int Index, string Name, + MotionState MotionState + ) : CncNode(Id, Index, CncNodeType.SavePosition, Name); + + /// 检测模块节点 | Inspection module node + public record InspectionModuleNode( + Guid Id, int Index, string Name, + PipelineModel Pipeline + ) : CncNode(Id, Index, CncNodeType.InspectionModule, Name); + + /// 检测标记节点 | Inspection marker node + public record InspectionMarkerNode( + Guid Id, int Index, string Name, + string MarkerType, + double MarkerX, double MarkerY + ) : CncNode(Id, Index, CncNodeType.InspectionMarker, Name); + + /// 停顿对话框节点 | Pause dialog node + public record PauseDialogNode( + Guid Id, int Index, string Name, + string DialogTitle, + string DialogMessage + ) : CncNode(Id, Index, CncNodeType.PauseDialog, Name); + + /// 等待延时节点 | Wait delay node + public record WaitDelayNode( + Guid Id, int Index, string Name, + int DelayMilliseconds + ) : CncNode(Id, Index, CncNodeType.WaitDelay, Name); + + /// 完成程序节点 | Complete program node + public record CompleteProgramNode( + Guid Id, int Index, string Name + ) : CncNode(Id, Index, CncNodeType.CompleteProgram, Name); + + // ── CNC 程序 | CNC Program ──────────────────────────────────────── + + /// CNC 程序(不可变)| CNC program (immutable) + public record CncProgram( + Guid Id, + string Name, + DateTime CreatedAt, + DateTime UpdatedAt, + IReadOnlyList Nodes + ); +} diff --git a/XplorePlane/Models/MatrixModels.cs b/XplorePlane/Models/MatrixModels.cs new file mode 100644 index 0000000..fe406a4 --- /dev/null +++ b/XplorePlane/Models/MatrixModels.cs @@ -0,0 +1,45 @@ +using System; +using System.Collections.Generic; +using System.Text.Json.Serialization; + +namespace XplorePlane.Models +{ + // ── 矩阵单元格状态枚举 | Matrix Cell Status Enumeration ────────── + + /// 矩阵单元格执行状态 | Matrix cell execution status + [JsonConverter(typeof(JsonStringEnumConverter))] + public enum MatrixCellStatus + { + NotExecuted, + Executing, + Completed, + Error, + Disabled + } + + // ── 矩阵数据模型 | Matrix Data Models ──────────────────────────── + + /// 矩阵单元格(不可变)| Matrix cell (immutable) + public record MatrixCell( + int Row, + int Column, + double OffsetX, + double OffsetY, + bool IsEnabled, + MatrixCellStatus Status, + string ErrorMessage + ); + + /// 矩阵布局(不可变)| Matrix layout (immutable) + public record MatrixLayout( + Guid Id, + int Rows, + int Columns, + double RowSpacing, + double ColumnSpacing, + double StartOffsetX, + double StartOffsetY, + string CncProgramPath, + IReadOnlyList Cells + ); +} diff --git a/XplorePlane/Models/MeasurementModels.cs b/XplorePlane/Models/MeasurementModels.cs new file mode 100644 index 0000000..0908955 --- /dev/null +++ b/XplorePlane/Models/MeasurementModels.cs @@ -0,0 +1,25 @@ +using System; + +namespace XplorePlane.Models +{ + // ── 测量数据模型 | Measurement Data Models ──────────────────────── + + /// 测量记录 | Measurement record + public class MeasurementRecord + { + public long Id { get; set; } + public string RecipeName { get; set; } = string.Empty; + public int StepIndex { get; set; } + public DateTime Timestamp { get; set; } + public double ResultValue { get; set; } + public bool IsPass { get; set; } + } + + /// 测量统计(不可变)| Measurement statistics (immutable) + public record MeasurementStatistics( + int TotalCount, + int PassCount, + int FailCount, + double PassRate + ); +} diff --git a/XplorePlane/Services/Cnc/CncProgramService.cs b/XplorePlane/Services/Cnc/CncProgramService.cs new file mode 100644 index 0000000..ccad548 --- /dev/null +++ b/XplorePlane/Services/Cnc/CncProgramService.cs @@ -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 +{ + /// + /// 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. + /// + 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(); + + _logger.Info("CncProgramService 已初始化 | CncProgramService initialized"); + } + + /// + 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()); + + _logger.Info("已创建 CNC 程序 | Created CNC program: {ProgramName}, Id={ProgramId}", name, program.Id); + return program; + } + + /// + 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; + } + + /// + 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(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; + } + + /// + 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(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; + } + + /// + 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(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; + } + + /// + 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); + } + + /// + public async Task 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; + } + + /// + public string Serialize(CncProgram program) + { + ArgumentNullException.ThrowIfNull(program); + return JsonSerializer.Serialize(program, CncJsonOptions); + } + + /// + public CncProgram Deserialize(string json) + { + if (string.IsNullOrWhiteSpace(json)) + throw new InvalidDataException("JSON 字符串为空 | JSON string is empty"); + + CncProgram program; + try + { + program = JsonSerializer.Deserialize(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 ── + + /// 等待延时最小值(毫秒)| Wait delay minimum (ms) + private const int WaitDelayMin = 0; + + /// 等待延时最大值(毫秒)| Wait delay maximum (ms) + private const int WaitDelayMax = 300000; + + // ── 内部辅助方法 | Internal helper methods ── + + /// + /// 将等待延时值截断到 [0, 300000] 范围 | Clamp wait delay value to [0, 300000] range + /// + internal static int ClampWaitDelay(int value) => + Math.Clamp(value, WaitDelayMin, WaitDelayMax); + + /// + /// 对反序列化后的节点列表应用值域校验(如 WaitDelay 截断) + /// Apply value range validation to deserialized nodes (e.g. WaitDelay clamping) + /// + private static IReadOnlyList ValidateNodes(IReadOnlyList nodes) + { + var result = new List(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(); + } + + /// + /// 重新编号节点索引为连续序列 0, 1, 2, ..., N-1(record 不可变,使用 with 语法) + /// Renumber node indices to consecutive sequence 0, 1, 2, ..., N-1 (records are immutable, use with syntax) + /// + private static IReadOnlyList RenumberNodes(List nodes) + { + var result = new List(nodes.Count); + for (int i = 0; i < nodes.Count; i++) + { + result.Add(nodes[i] with { Index = i }); + } + return result.AsReadOnly(); + } + + /// 创建参考点节点 | Create reference point node + 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); + } + + /// 创建保存节点(含图像)| Create save node with image + private SaveNodeWithImageNode CreateSaveNodeWithImageNode(Guid id, int index) + { + return new SaveNodeWithImageNode( + id, index, $"保存节点(图像)_{index}", + MotionState: _appStateService.MotionState, + RaySourceState: _appStateService.RaySourceState, + DetectorState: _appStateService.DetectorState, + ImageFileName: ""); + } + + /// 创建保存节点(不含图像)| Create save node without image + private SaveNodeNode CreateSaveNodeNode(Guid id, int index) + { + return new SaveNodeNode( + id, index, $"保存节点_{index}", + MotionState: _appStateService.MotionState, + RaySourceState: _appStateService.RaySourceState, + DetectorState: _appStateService.DetectorState); + } + + /// 创建保存位置节点 | Create save position node + private SavePositionNode CreateSavePositionNode(Guid id, int index) + { + return new SavePositionNode( + id, index, $"保存位置_{index}", + MotionState: _appStateService.MotionState); + } + } +} diff --git a/XplorePlane/Services/Cnc/ICncProgramService.cs b/XplorePlane/Services/Cnc/ICncProgramService.cs new file mode 100644 index 0000000..8aab56b --- /dev/null +++ b/XplorePlane/Services/Cnc/ICncProgramService.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services.Cnc +{ + /// + /// CNC 程序管理服务接口,负责程序的创建、节点编辑、序列化/反序列化和文件读写 + /// CNC program management service interface for creation, node editing, serialization and file I/O + /// + public interface ICncProgramService + { + /// 创建空的 CNC 程序 | Create an empty CNC program + CncProgram CreateProgram(string name); + + /// 根据节点类型创建节点(从 IAppStateService 捕获设备状态)| Create a node by type (captures device state from IAppStateService) + CncNode CreateNode(CncNodeType type); + + /// 在指定索引之后插入节点并重新编号 | Insert a node after the given index and renumber + CncProgram InsertNode(CncProgram program, int afterIndex, CncNode node); + + /// 移除指定索引的节点并重新编号 | Remove the node at the given index and renumber + CncProgram RemoveNode(CncProgram program, int index); + + /// 将节点从旧索引移动到新索引并重新编号 | Move a node from old index to new index and renumber + CncProgram MoveNode(CncProgram program, int oldIndex, int newIndex); + + /// 将 CNC 程序保存到 .xp 文件 | Save CNC program to .xp file + Task SaveAsync(CncProgram program, string filePath); + + /// 从 .xp 文件加载 CNC 程序 | Load CNC program from .xp file + Task LoadAsync(string filePath); + + /// 将 CNC 程序序列化为 JSON 字符串 | Serialize CNC program to JSON string + string Serialize(CncProgram program); + + /// 从 JSON 字符串反序列化 CNC 程序 | Deserialize CNC program from JSON string + CncProgram Deserialize(string json); + } +} diff --git a/XplorePlane/Services/Matrix/IMatrixService.cs b/XplorePlane/Services/Matrix/IMatrixService.cs new file mode 100644 index 0000000..7b045ec --- /dev/null +++ b/XplorePlane/Services/Matrix/IMatrixService.cs @@ -0,0 +1,39 @@ +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services.Matrix +{ + /// + /// 矩阵编排管理服务接口,负责矩阵布局的创建、编辑、序列化/反序列化和文件读写 + /// Matrix layout management service interface for creation, editing, serialization and file I/O + /// + public interface IMatrixService + { + /// 创建矩阵布局 | Create a matrix layout + MatrixLayout CreateLayout(int rows, int columns, double rowSpacing, double columnSpacing); + + /// 更新矩阵布局参数并重新计算单元格偏移 | Update layout parameters and recalculate cell offsets + MatrixLayout UpdateLayout(MatrixLayout layout, int rows, int columns, double rowSpacing, double columnSpacing); + + /// 获取指定行列的单元格 | Get the cell at the specified row and column + MatrixCell GetCell(MatrixLayout layout, int row, int column); + + /// 切换单元格启用/禁用状态 | Toggle cell enabled/disabled state + MatrixLayout ToggleCellEnabled(MatrixLayout layout, int row, int column); + + /// 关联 CNC 程序到矩阵布局 | Associate a CNC program with the matrix layout + MatrixLayout AssociateCncProgram(MatrixLayout layout, CncProgram program); + + /// 将矩阵布局保存到 JSON 文件 | Save matrix layout to JSON file + Task SaveAsync(MatrixLayout layout, string filePath); + + /// 从 JSON 文件加载矩阵布局 | Load matrix layout from JSON file + Task LoadAsync(string filePath); + + /// 将矩阵布局序列化为 JSON 字符串 | Serialize matrix layout to JSON string + string Serialize(MatrixLayout layout); + + /// 从 JSON 字符串反序列化矩阵布局 | Deserialize matrix layout from JSON string + MatrixLayout Deserialize(string json); + } +} diff --git a/XplorePlane/Services/Matrix/MatrixService.cs b/XplorePlane/Services/Matrix/MatrixService.cs new file mode 100644 index 0000000..f5cec78 --- /dev/null +++ b/XplorePlane/Services/Matrix/MatrixService.cs @@ -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 +{ + /// + /// 矩阵编排管理服务实现。 + /// 负责矩阵布局的创建、编辑、单元格偏移计算、JSON 序列化/反序列化和文件读写。 + /// Matrix layout management service implementation. + /// Handles layout creation, editing, cell offset calculation, JSON serialization and file I/O. + /// + 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(); + + _logger.Info("MatrixService 已初始化 | MatrixService initialized"); + } + + /// + 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; + } + + /// + 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; + } + + /// + 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]; + } + + /// + 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(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; + } + + /// + 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; + } + + /// + 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); + } + + /// + public async Task 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; + } + + /// + public string Serialize(MatrixLayout layout) + { + ArgumentNullException.ThrowIfNull(layout); + return JsonSerializer.Serialize(layout, MatrixJsonOptions); + } + + /// + public MatrixLayout Deserialize(string json) + { + if (string.IsNullOrWhiteSpace(json)) + throw new InvalidDataException("JSON 字符串为空 | JSON string is empty"); + + MatrixLayout layout; + try + { + layout = JsonSerializer.Deserialize(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 ── + + /// + /// 构建矩阵单元格列表,自动计算每个单元格的偏移坐标 + /// Build matrix cell list with auto-calculated offset coordinates + /// OffsetX = StartOffsetX + col * ColumnSpacing + /// OffsetY = StartOffsetY + row * RowSpacing + /// + internal static IReadOnlyList BuildCells( + int rows, int columns, + double rowSpacing, double columnSpacing, + double startOffsetX, double startOffsetY) + { + var cells = new List(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(); + } + + /// + /// 验证行列数参数 | Validate row and column dimensions + /// + 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}"); + } + + /// + /// 验证单元格坐标是否在布局范围内 | Validate cell coordinates are within layout bounds + /// + 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}"); + } + } +} diff --git a/XplorePlane/Services/Measurement/IMeasurementDataService.cs b/XplorePlane/Services/Measurement/IMeasurementDataService.cs new file mode 100644 index 0000000..e60033c --- /dev/null +++ b/XplorePlane/Services/Measurement/IMeasurementDataService.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using XplorePlane.Models; + +namespace XplorePlane.Services.Measurement +{ + /// + /// 测量数据存储服务接口,负责测量记录的持久化、查询和统计 + /// Measurement data storage service interface for persistence, querying and statistics of measurement records + /// + public interface IMeasurementDataService + { + /// 初始化数据库表和索引 | Initialize database tables and indexes + Task InitializeAsync(); + + /// 保存单条测量记录 | Save a single measurement record + Task SaveMeasurementAsync(MeasurementRecord record); + + /// 按配方名称和时间范围查询测量记录 | Query measurement records by recipe name and time range + Task> QueryAsync(string recipeName, DateTime? from, DateTime? to); + + /// 按配方名称和时间范围获取统计数据 | Get statistics by recipe name and time range + Task GetStatisticsAsync(string recipeName, DateTime? from, DateTime? to); + } +} diff --git a/XplorePlane/Services/Measurement/MeasurementDataService.cs b/XplorePlane/Services/Measurement/MeasurementDataService.cs new file mode 100644 index 0000000..4d6e5bc --- /dev/null +++ b/XplorePlane/Services/Measurement/MeasurementDataService.cs @@ -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 +{ + /// + /// 测量数据存储服务实现,使用 SQLite(通过 IDbContext)持久化测量记录并提供统计查询。 + /// Measurement data storage service implementation using SQLite (via IDbContext) to persist records and provide statistics. + /// + 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(); + + _logger.Info("MeasurementDataService 已初始化 | MeasurementDataService initialized"); + } + + // ── 公共方法 | Public methods ── + + /// + 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"); + } + + /// + public async Task SaveMeasurementAsync(MeasurementRecord record) + { + ArgumentNullException.ThrowIfNull(record); + + var parameters = new Dictionary + { + ["@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); + } + + /// + public async Task> 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(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(data.Count); + foreach (var row in data) + { + records.Add(MapRow(row)); + } + + _logger.Debug("查询到 {Count} 条测量记录 | Queried {Count} measurement records", records.Count); + return records.AsReadOnly(); + } + + /// + public async Task 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(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 ── + + /// + /// 根据筛选条件动态构建 SQL 和参数字典 + /// Dynamically build SQL and parameter dictionary based on filter conditions + /// + private static (string Sql, Dictionary? Parameters) BuildFilteredQuery( + string selectClause, + string? recipeName, + DateTime? from, + DateTime? to, + string? orderBy = null) + { + var conditions = new List(); + Dictionary? parameters = null; + + // 按配方名称筛选 | Filter by recipe name + if (!string.IsNullOrWhiteSpace(recipeName)) + { + conditions.Add("recipe_name = @recipe_name"); + parameters ??= new Dictionary(); + parameters["@recipe_name"] = recipeName; + } + + // 按起始时间筛选 | Filter by start time + if (from.HasValue) + { + conditions.Add("timestamp >= @from"); + parameters ??= new Dictionary(); + parameters["@from"] = from.Value.ToString("o"); + } + + // 按结束时间筛选 | Filter by end time + if (to.HasValue) + { + conditions.Add("timestamp <= @to"); + parameters ??= new Dictionary(); + 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); + } + + /// + /// 将数据库行 DTO 映射为领域模型 + /// Map database row DTO to domain model + /// + 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 ── + + /// 数据库行 DTO,属性名与 SQL 列名一致以支持自动映射 + 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; } + } + + /// 统计查询 DTO + internal class StatisticsRow + { + public int total_count { get; set; } + public int pass_count { get; set; } + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs new file mode 100644 index 0000000..ae03585 --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs @@ -0,0 +1,378 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Prism.Commands; +using Prism.Events; +using Prism.Mvvm; +using XP.Common.Logging.Interfaces; +using XplorePlane.Events; +using XplorePlane.Models; +using XplorePlane.Services.AppState; +using XplorePlane.Services.Cnc; + +namespace XplorePlane.ViewModels.Cnc +{ + /// + /// CNC 编辑器 ViewModel,管理 CNC 程序的节点列表、编辑操作和文件操作 + /// CNC editor ViewModel that manages the node list, editing operations and file operations + /// + public class CncEditorViewModel : BindableBase + { + private readonly ICncProgramService _cncProgramService; + private readonly IAppStateService _appStateService; + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + + // 当前 CNC 程序 | Current CNC program + private CncProgram _currentProgram; + + private ObservableCollection _nodes; + private CncNodeViewModel _selectedNode; + private bool _isModified; + private string _programName; + + /// + /// 构造函数 | Constructor + /// + public CncEditorViewModel( + ICncProgramService cncProgramService, + IAppStateService appStateService, + IEventAggregator eventAggregator, + ILoggerService logger) + { + _cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService)); + _appStateService = appStateService ?? throw new ArgumentNullException(nameof(appStateService)); + _eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator)); + _logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule(); + + _nodes = new ObservableCollection(); + + // ── 节点插入命令 | Node insertion commands ── + InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint)); + InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage)); + InsertSaveNodeCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNode)); + InsertSavePositionCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SavePosition)); + InsertInspectionModuleCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionModule)); + InsertInspectionMarkerCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionMarker)); + InsertPauseDialogCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.PauseDialog)); + InsertWaitDelayCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.WaitDelay)); + InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram)); + + // ── 节点编辑命令 | Node editing commands ── + DeleteNodeCommand = new DelegateCommand(ExecuteDeleteNode, CanExecuteDeleteNode) + .ObservesProperty(() => SelectedNode); + MoveNodeUpCommand = new DelegateCommand(ExecuteMoveNodeUp); + MoveNodeDownCommand = new DelegateCommand(ExecuteMoveNodeDown); + + // ── 文件操作命令 | File operation commands ── + SaveProgramCommand = new DelegateCommand(async () => await ExecuteSaveProgramAsync()); + LoadProgramCommand = new DelegateCommand(async () => await ExecuteLoadProgramAsync()); + NewProgramCommand = new DelegateCommand(ExecuteNewProgram); + ExportCsvCommand = new DelegateCommand(ExecuteExportCsv); + + _logger.Info("CncEditorViewModel 已初始化 | CncEditorViewModel initialized"); + } + + // ── 属性 | Properties ────────────────────────────────────────── + + /// 节点列表 | Node list + public ObservableCollection Nodes + { + get => _nodes; + set => SetProperty(ref _nodes, value); + } + + /// 当前选中的节点 | Currently selected node + public CncNodeViewModel SelectedNode + { + get => _selectedNode; + set => SetProperty(ref _selectedNode, value); + } + + /// 程序是否已修改 | Whether the program has been modified + public bool IsModified + { + get => _isModified; + set => SetProperty(ref _isModified, value); + } + + /// 当前程序名称 | Current program name + public string ProgramName + { + get => _programName; + set => SetProperty(ref _programName, value); + } + + // ── 节点插入命令 | Node insertion commands ────────────────────── + + /// 插入参考点命令 | Insert reference point command + public DelegateCommand InsertReferencePointCommand { get; } + + /// 插入保存节点(含图像)命令 | Insert save node with image command + public DelegateCommand InsertSaveNodeWithImageCommand { get; } + + /// 插入保存节点命令 | Insert save node command + public DelegateCommand InsertSaveNodeCommand { get; } + + /// 插入保存位置命令 | Insert save position command + public DelegateCommand InsertSavePositionCommand { get; } + + /// 插入检测模块命令 | Insert inspection module command + public DelegateCommand InsertInspectionModuleCommand { get; } + + /// 插入检测标记命令 | Insert inspection marker command + public DelegateCommand InsertInspectionMarkerCommand { get; } + + /// 插入停顿对话框命令 | Insert pause dialog command + public DelegateCommand InsertPauseDialogCommand { get; } + + /// 插入等待延时命令 | Insert wait delay command + public DelegateCommand InsertWaitDelayCommand { get; } + + /// 插入完成程序命令 | Insert complete program command + public DelegateCommand InsertCompleteProgramCommand { get; } + + // ── 节点编辑命令 | Node editing commands ──────────────────────── + + /// 删除选中节点命令 | Delete selected node command + public DelegateCommand DeleteNodeCommand { get; } + + /// 上移节点命令 | Move node up command + public DelegateCommand MoveNodeUpCommand { get; } + + /// 下移节点命令 | Move node down command + public DelegateCommand MoveNodeDownCommand { get; } + + // ── 文件操作命令 | File operation commands ────────────────────── + + /// 保存程序命令 | Save program command + public DelegateCommand SaveProgramCommand { get; } + + /// 加载程序命令 | Load program command + public DelegateCommand LoadProgramCommand { get; } + + /// 新建程序命令 | New program command + public DelegateCommand NewProgramCommand { get; } + + /// 导出 CSV 命令 | Export CSV command + public DelegateCommand ExportCsvCommand { get; } + + // ── 命令执行方法 | Command execution methods ──────────────────── + + /// + /// 插入指定类型的节点到选中节点之后 + /// Insert a node of the specified type after the selected node + /// + private void ExecuteInsertNode(CncNodeType nodeType) + { + if (_currentProgram == null) + { + _logger.Warn("无法插入节点:当前无程序 | Cannot insert node: no current program"); + return; + } + + try + { + var node = _cncProgramService.CreateNode(nodeType); + int afterIndex = SelectedNode?.Index ?? _currentProgram.Nodes.Count - 1; + _currentProgram = _cncProgramService.InsertNode(_currentProgram, afterIndex, node); + + OnProgramEdited(); + _logger.Info("已插入节点 | Inserted node: Type={NodeType}", nodeType); + } + catch (InvalidOperationException ex) + { + // 重复插入 CompleteProgram 等业务规则异常 | Business rule exceptions like duplicate CompleteProgram + _logger.Warn("插入节点被阻止 | Node insertion blocked: {Message}", ex.Message); + } + } + + /// + /// 删除选中节点 | Delete the selected node + /// + private void ExecuteDeleteNode() + { + if (_currentProgram == null || SelectedNode == null) + return; + + try + { + _currentProgram = _cncProgramService.RemoveNode(_currentProgram, SelectedNode.Index); + OnProgramEdited(); + _logger.Info("已删除节点 | Deleted node at index: {Index}", SelectedNode.Index); + } + catch (ArgumentOutOfRangeException ex) + { + _logger.Warn("删除节点失败 | Delete node failed: {Message}", ex.Message); + } + } + + /// + /// 判断是否可以删除节点(至少保留 1 个节点) + /// Determines whether delete is allowed (at least 1 node must remain) + /// + private bool CanExecuteDeleteNode() + { + return SelectedNode != null + && _currentProgram != null + && _currentProgram.Nodes.Count > 1; + } + + /// + /// 上移节点 | Move node up + /// + private void ExecuteMoveNodeUp(CncNodeViewModel nodeVm) + { + if (_currentProgram == null || nodeVm == null || nodeVm.Index <= 0) + return; + + try + { + _currentProgram = _cncProgramService.MoveNode(_currentProgram, nodeVm.Index, nodeVm.Index - 1); + OnProgramEdited(); + _logger.Info("已上移节点 | Moved node up: {OldIndex} -> {NewIndex}", nodeVm.Index, nodeVm.Index - 1); + } + catch (ArgumentOutOfRangeException ex) + { + _logger.Warn("上移节点失败 | Move node up failed: {Message}", ex.Message); + } + } + + /// + /// 下移节点 | Move node down + /// + private void ExecuteMoveNodeDown(CncNodeViewModel nodeVm) + { + if (_currentProgram == null || nodeVm == null || nodeVm.Index >= _currentProgram.Nodes.Count - 1) + return; + + try + { + _currentProgram = _cncProgramService.MoveNode(_currentProgram, nodeVm.Index, nodeVm.Index + 1); + OnProgramEdited(); + _logger.Info("已下移节点 | Moved node down: {OldIndex} -> {NewIndex}", nodeVm.Index, nodeVm.Index + 1); + } + catch (ArgumentOutOfRangeException ex) + { + _logger.Warn("下移节点失败 | Move node down failed: {Message}", ex.Message); + } + } + + /// + /// 保存当前程序到文件 | Save current program to file + /// + private async Task ExecuteSaveProgramAsync() + { + if (_currentProgram == null) + { + _logger.Warn("无法保存:当前无程序 | Cannot save: no current program"); + return; + } + + try + { + // 使用程序名称作为文件名 | Use program name as file name + var filePath = $"{_currentProgram.Name}.xp"; + await _cncProgramService.SaveAsync(_currentProgram, filePath); + IsModified = false; + _logger.Info("程序已保存 | Program saved: {FilePath}", filePath); + } + catch (Exception ex) + { + _logger.Error(ex, "保存程序失败 | Failed to save program"); + } + } + + /// + /// 从文件加载程序 | Load program from file + /// + private async Task ExecuteLoadProgramAsync() + { + try + { + // 占位:实际应通过文件对话框选择路径 | Placeholder: should use file dialog to select path + var filePath = $"{ProgramName}.xp"; + _currentProgram = await _cncProgramService.LoadAsync(filePath); + ProgramName = _currentProgram.Name; + IsModified = false; + RefreshNodes(); + _logger.Info("程序已加载 | Program loaded: {ProgramName}", _currentProgram.Name); + } + catch (Exception ex) + { + _logger.Error(ex, "加载程序失败 | Failed to load program"); + } + } + + /// + /// 创建新程序 | Create a new program + /// + private void ExecuteNewProgram() + { + var name = string.IsNullOrWhiteSpace(ProgramName) ? "新程序" : ProgramName; + _currentProgram = _cncProgramService.CreateProgram(name); + ProgramName = _currentProgram.Name; + IsModified = false; + RefreshNodes(); + _logger.Info("已创建新程序 | Created new program: {ProgramName}", name); + } + + /// + /// 导出 CSV(占位实现)| Export CSV (placeholder) + /// + private void ExecuteExportCsv() + { + // TODO: 实现 CSV 导出功能 | Implement CSV export functionality + _logger.Info("CSV 导出功能尚未实现 | CSV export not yet implemented"); + } + + // ── 辅助方法 | Helper methods ─────────────────────────────────── + + /// + /// 编辑操作后的统一处理:刷新节点、标记已修改、发布变更事件 + /// Unified post-edit handling: refresh nodes, mark modified, publish change event + /// + private void OnProgramEdited() + { + IsModified = true; + RefreshNodes(); + PublishProgramChanged(); + } + + /// + /// 从 _currentProgram.Nodes 重建 Nodes 集合 + /// Rebuild the Nodes collection from _currentProgram.Nodes + /// + private void RefreshNodes() + { + Nodes.Clear(); + + if (_currentProgram?.Nodes == null) + return; + + foreach (var node in _currentProgram.Nodes) + { + Nodes.Add(new CncNodeViewModel(node)); + } + + // 尝试保持选中状态 | Try to preserve selection + if (SelectedNode != null) + { + var match = Nodes.FirstOrDefault(n => n.Index == SelectedNode.Index); + SelectedNode = match ?? Nodes.LastOrDefault(); + } + } + + /// + /// 通过 IEventAggregator 发布 CNC 程序变更事件 + /// Publish CNC program changed event via IEventAggregator + /// + private void PublishProgramChanged() + { + _eventAggregator + .GetEvent() + .Publish(new CncProgramChangedPayload(ProgramName ?? string.Empty, IsModified)); + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs new file mode 100644 index 0000000..19eca8a --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/CncNodeViewModel.cs @@ -0,0 +1,89 @@ +using Prism.Mvvm; +using XplorePlane.Models; + +namespace XplorePlane.ViewModels.Cnc +{ + /// + /// CNC 节点 ViewModel,将 CncNode 模型封装为可绑定的 WPF ViewModel + /// CNC node ViewModel that wraps a CncNode model into a bindable WPF ViewModel + /// + public class CncNodeViewModel : BindableBase + { + private int _index; + private string _name; + private CncNodeType _nodeType; + private string _icon; + + /// + /// 构造函数,从 CncNode 模型初始化 ViewModel + /// Constructor that initializes the ViewModel from a CncNode model + /// + public CncNodeViewModel(CncNode model) + { + Model = model; + _index = model.Index; + _name = model.Name; + _nodeType = model.NodeType; + _icon = GetIconForNodeType(model.NodeType); + } + + /// 底层 CNC 节点模型(只读)| Underlying CNC node model (read-only) + public CncNode Model { get; } + + /// 节点在程序中的索引 | Node index in the program + public int Index + { + get => _index; + set => SetProperty(ref _index, value); + } + + /// 节点显示名称 | Node display name + public string Name + { + get => _name; + set => SetProperty(ref _name, value); + } + + /// 节点类型 | Node type + public CncNodeType NodeType + { + get => _nodeType; + set + { + if (SetProperty(ref _nodeType, value)) + { + // 类型变更时自动更新图标 | Auto-update icon when type changes + Icon = GetIconForNodeType(value); + } + } + } + + /// 节点图标路径 | Node icon path + public string Icon + { + get => _icon; + set => SetProperty(ref _icon, value); + } + + /// + /// 根据节点类型返回对应的图标路径 + /// Returns the icon path for the given node type + /// + public static string GetIconForNodeType(CncNodeType nodeType) + { + return nodeType switch + { + CncNodeType.ReferencePoint => "/Resources/Icons/cnc_reference_point.png", + CncNodeType.SaveNodeWithImage => "/Resources/Icons/cnc_save_with_image.png", + CncNodeType.SaveNode => "/Resources/Icons/cnc_save_node.png", + CncNodeType.SavePosition => "/Resources/Icons/cnc_save_position.png", + CncNodeType.InspectionModule => "/Resources/Icons/cnc_inspection_module.png", + CncNodeType.InspectionMarker => "/Resources/Icons/cnc_inspection_marker.png", + CncNodeType.PauseDialog => "/Resources/Icons/cnc_pause_dialog.png", + CncNodeType.WaitDelay => "/Resources/Icons/cnc_wait_delay.png", + CncNodeType.CompleteProgram => "/Resources/Icons/cnc_complete_program.png", + _ => "/Resources/Icons/cnc_default.png", + }; + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/MatrixCellViewModel.cs b/XplorePlane/ViewModels/Cnc/MatrixCellViewModel.cs new file mode 100644 index 0000000..b87d32e --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/MatrixCellViewModel.cs @@ -0,0 +1,96 @@ +using Prism.Mvvm; +using XplorePlane.Models; + +namespace XplorePlane.ViewModels.Cnc +{ + /// + /// 矩阵单元格 ViewModel,将 MatrixCell 模型封装为可绑定的 WPF ViewModel + /// Matrix cell ViewModel that wraps a MatrixCell model into a bindable WPF ViewModel + /// + public class MatrixCellViewModel : BindableBase + { + private int _row; + private int _column; + private double _offsetX; + private double _offsetY; + private bool _isEnabled; + private MatrixCellStatus _status; + private string _errorMessage; + private bool _isSelected; + + /// + /// 构造函数,从 MatrixCell 模型初始化 ViewModel + /// Constructor that initializes the ViewModel from a MatrixCell model + /// + public MatrixCellViewModel(MatrixCell model) + { + Model = model; + _row = model.Row; + _column = model.Column; + _offsetX = model.OffsetX; + _offsetY = model.OffsetY; + _isEnabled = model.IsEnabled; + _status = model.Status; + _errorMessage = model.ErrorMessage; + } + + /// 底层矩阵单元格模型(只读)| Underlying matrix cell model (read-only) + public MatrixCell Model { get; } + + /// 单元格行索引 | Cell row index + public int Row + { + get => _row; + set => SetProperty(ref _row, value); + } + + /// 单元格列索引 | Cell column index + public int Column + { + get => _column; + set => SetProperty(ref _column, value); + } + + /// 计算后的 X 偏移量 | Calculated X offset + public double OffsetX + { + get => _offsetX; + set => SetProperty(ref _offsetX, value); + } + + /// 计算后的 Y 偏移量 | Calculated Y offset + public double OffsetY + { + get => _offsetY; + set => SetProperty(ref _offsetY, value); + } + + /// 单元格是否启用检测 | Whether the cell is enabled for inspection + public bool IsEnabled + { + get => _isEnabled; + set => SetProperty(ref _isEnabled, value); + } + + /// 单元格执行状态 | Cell execution status + public MatrixCellStatus Status + { + get => _status; + set => SetProperty(ref _status, value); + } + + /// 错误信息 | Error message if any + public string ErrorMessage + { + get => _errorMessage; + set => SetProperty(ref _errorMessage, value); + } + + /// 单元格是否被选中 | Whether the cell is currently selected in the UI + public bool IsSelected + { + get => _isSelected; + set => SetProperty(ref _isSelected, value); + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/MatrixEditorViewModel.cs b/XplorePlane/ViewModels/Cnc/MatrixEditorViewModel.cs new file mode 100644 index 0000000..e2e4ca1 --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/MatrixEditorViewModel.cs @@ -0,0 +1,308 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using System.Threading.Tasks; +using Prism.Commands; +using Prism.Events; +using Prism.Mvvm; +using XP.Common.Logging.Interfaces; +using XplorePlane.Models; +using XplorePlane.Services.Cnc; +using XplorePlane.Services.Matrix; + +namespace XplorePlane.ViewModels.Cnc +{ + /// + /// 矩阵编辑器 ViewModel,管理矩阵布局的配置、单元格网格和文件操作 + /// Matrix editor ViewModel that manages layout configuration, cell grid and file operations + /// + public class MatrixEditorViewModel : BindableBase + { + private readonly IMatrixService _matrixService; + private readonly ICncProgramService _cncProgramService; + private readonly IEventAggregator _eventAggregator; + private readonly ILoggerService _logger; + + // 当前矩阵布局 | Current matrix layout + private MatrixLayout _currentLayout; + + private int _rows = 1; + private int _columns = 1; + private double _rowSpacing; + private double _columnSpacing; + private ObservableCollection _cells; + private MatrixCellViewModel _selectedCell; + private string _associatedProgramPath; + + /// + /// 构造函数 | Constructor + /// + public MatrixEditorViewModel( + IMatrixService matrixService, + ICncProgramService cncProgramService, + IEventAggregator eventAggregator, + ILoggerService logger) + { + _matrixService = matrixService ?? throw new ArgumentNullException(nameof(matrixService)); + _cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService)); + _eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator)); + _logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule(); + + _cells = new ObservableCollection(); + + // ── 命令初始化 | Command initialization ── + UpdateLayoutCommand = new DelegateCommand(ExecuteUpdateLayout); + ToggleCellCommand = new DelegateCommand(ExecuteToggleCell); + SelectCellCommand = new DelegateCommand(ExecuteSelectCell); + AssociateProgramCommand = new DelegateCommand(async () => await ExecuteAssociateProgramAsync()); + SaveLayoutCommand = new DelegateCommand(async () => await ExecuteSaveLayoutAsync()); + LoadLayoutCommand = new DelegateCommand(async () => await ExecuteLoadLayoutAsync()); + + _logger.Info("MatrixEditorViewModel 已初始化 | MatrixEditorViewModel initialized"); + } + + // ── 属性 | Properties ────────────────────────────────────────── + + /// 矩阵行数 | Number of rows + public int Rows + { + get => _rows; + set => SetProperty(ref _rows, value); + } + + /// 矩阵列数 | Number of columns + public int Columns + { + get => _columns; + set => SetProperty(ref _columns, value); + } + + /// 行间距(mm)| Row spacing in mm + public double RowSpacing + { + get => _rowSpacing; + set => SetProperty(ref _rowSpacing, value); + } + + /// 列间距(mm)| Column spacing in mm + public double ColumnSpacing + { + get => _columnSpacing; + set => SetProperty(ref _columnSpacing, value); + } + + /// 矩阵单元格集合 | Matrix cell collection + public ObservableCollection Cells + { + get => _cells; + set => SetProperty(ref _cells, value); + } + + /// 当前选中的单元格 | Currently selected cell + public MatrixCellViewModel SelectedCell + { + get => _selectedCell; + set => SetProperty(ref _selectedCell, value); + } + + /// 关联的 CNC 程序路径 | Associated CNC program path + public string AssociatedProgramPath + { + get => _associatedProgramPath; + set => SetProperty(ref _associatedProgramPath, value); + } + + // ── 命令 | Commands ──────────────────────────────────────────── + + /// 更新矩阵布局命令 | Update matrix layout command + public DelegateCommand UpdateLayoutCommand { get; } + + /// 切换单元格启用状态命令 | Toggle cell enabled state command + public DelegateCommand ToggleCellCommand { get; } + + /// 选中单元格命令 | Select cell command + public DelegateCommand SelectCellCommand { get; } + + /// 关联 CNC 程序命令 | Associate CNC program command + public DelegateCommand AssociateProgramCommand { get; } + + /// 保存矩阵布局命令 | Save matrix layout command + public DelegateCommand SaveLayoutCommand { get; } + + /// 加载矩阵布局命令 | Load matrix layout command + public DelegateCommand LoadLayoutCommand { get; } + + // ── 命令执行方法 | Command execution methods ──────────────────── + + /// + /// 更新矩阵布局:如果当前无布局则创建,否则更新现有布局 + /// Update matrix layout: create if none exists, otherwise update existing + /// + private void ExecuteUpdateLayout() + { + try + { + if (_currentLayout == null) + { + _currentLayout = _matrixService.CreateLayout(Rows, Columns, RowSpacing, ColumnSpacing); + } + else + { + _currentLayout = _matrixService.UpdateLayout(_currentLayout, Rows, Columns, RowSpacing, ColumnSpacing); + } + + RefreshCells(); + _logger.Info("已更新矩阵布局 | Updated matrix layout: Rows={Rows}, Columns={Columns}", Rows, Columns); + } + catch (Exception ex) + { + _logger.Error(ex, "更新矩阵布局失败 | Failed to update matrix layout"); + } + } + + /// + /// 选中指定单元格,更新高亮状态 + /// Select the specified cell and update highlight state + /// + private void ExecuteSelectCell(MatrixCellViewModel cellVm) + { + if (cellVm == null) + return; + + // 取消之前选中的单元格 | Deselect previously selected cell + if (SelectedCell != null) + SelectedCell.IsSelected = false; + + cellVm.IsSelected = true; + SelectedCell = cellVm; + } + + /// + /// 切换指定单元格的启用/禁用状态 + /// Toggle the enabled/disabled state of the specified cell + /// + private void ExecuteToggleCell(MatrixCellViewModel cellVm) + { + if (_currentLayout == null || cellVm == null) + return; + + try + { + _currentLayout = _matrixService.ToggleCellEnabled(_currentLayout, cellVm.Row, cellVm.Column); + RefreshCells(); + _logger.Info("已切换单元格状态 | Toggled cell state: Row={Row}, Column={Column}", cellVm.Row, cellVm.Column); + } + catch (Exception ex) + { + _logger.Error(ex, "切换单元格状态失败 | Failed to toggle cell state"); + } + } + + /// + /// 关联 CNC 程序到当前矩阵布局(占位:从 AssociatedProgramPath 加载) + /// Associate a CNC program with the current layout (placeholder: loads from AssociatedProgramPath) + /// + private async Task ExecuteAssociateProgramAsync() + { + if (_currentLayout == null) + { + _logger.Warn("无法关联程序:当前无矩阵布局 | Cannot associate program: no current layout"); + return; + } + + if (string.IsNullOrWhiteSpace(AssociatedProgramPath)) + { + _logger.Warn("无法关联程序:程序路径为空 | Cannot associate program: program path is empty"); + return; + } + + try + { + var program = await _cncProgramService.LoadAsync(AssociatedProgramPath); + _currentLayout = _matrixService.AssociateCncProgram(_currentLayout, program); + _logger.Info("已关联 CNC 程序 | Associated CNC program: {ProgramPath}", AssociatedProgramPath); + } + catch (Exception ex) + { + _logger.Error(ex, "关联 CNC 程序失败 | Failed to associate CNC program"); + } + } + + /// + /// 保存当前矩阵布局到文件 | Save current matrix layout to file + /// + private async Task ExecuteSaveLayoutAsync() + { + if (_currentLayout == null) + { + _logger.Warn("无法保存:当前无矩阵布局 | Cannot save: no current layout"); + return; + } + + try + { + // 占位:实际应通过文件对话框选择路径 | Placeholder: should use file dialog to select path + var filePath = $"matrix_{_currentLayout.Id}.json"; + await _matrixService.SaveAsync(_currentLayout, filePath); + _logger.Info("矩阵布局已保存 | Matrix layout saved: {FilePath}", filePath); + } + catch (Exception ex) + { + _logger.Error(ex, "保存矩阵布局失败 | Failed to save matrix layout"); + } + } + + /// + /// 从文件加载矩阵布局 | Load matrix layout from file + /// + private async Task ExecuteLoadLayoutAsync() + { + try + { + // 占位:实际应通过文件对话框选择路径 | Placeholder: should use file dialog to select path + var filePath = $"matrix_layout.json"; + _currentLayout = await _matrixService.LoadAsync(filePath); + + // 同步属性到 ViewModel | Sync properties to ViewModel + Rows = _currentLayout.Rows; + Columns = _currentLayout.Columns; + RowSpacing = _currentLayout.RowSpacing; + ColumnSpacing = _currentLayout.ColumnSpacing; + AssociatedProgramPath = _currentLayout.CncProgramPath; + + RefreshCells(); + _logger.Info("矩阵布局已加载 | Matrix layout loaded: Rows={Rows}, Columns={Columns}", Rows, Columns); + } + catch (Exception ex) + { + _logger.Error(ex, "加载矩阵布局失败 | Failed to load matrix layout"); + } + } + + // ── 辅助方法 | Helper methods ─────────────────────────────────── + + /// + /// 从 _currentLayout.Cells 重建 Cells 集合 + /// Rebuild the Cells collection from _currentLayout.Cells + /// + private void RefreshCells() + { + Cells.Clear(); + + if (_currentLayout?.Cells == null) + return; + + foreach (var cell in _currentLayout.Cells) + { + Cells.Add(new MatrixCellViewModel(cell)); + } + + // 尝试保持选中状态 | Try to preserve selection + if (SelectedCell != null) + { + var match = Cells.FirstOrDefault(c => c.Row == SelectedCell.Row && c.Column == SelectedCell.Column); + SelectedCell = match ?? Cells.FirstOrDefault(); + } + } + } +} diff --git a/XplorePlane/ViewModels/Cnc/MeasurementStatsViewModel.cs b/XplorePlane/ViewModels/Cnc/MeasurementStatsViewModel.cs new file mode 100644 index 0000000..07ce0bb --- /dev/null +++ b/XplorePlane/ViewModels/Cnc/MeasurementStatsViewModel.cs @@ -0,0 +1,130 @@ +using System; +using System.Threading.Tasks; +using Prism.Commands; +using Prism.Mvvm; +using XP.Common.Logging.Interfaces; +using XplorePlane.Services.Measurement; + +namespace XplorePlane.ViewModels.Cnc +{ + /// + /// 测量统计 ViewModel,显示测量统计数据并支持筛选 + /// Measurement statistics ViewModel that displays measurement statistics and supports filtering + /// + public class MeasurementStatsViewModel : BindableBase + { + private readonly IMeasurementDataService _measurementDataService; + private readonly ILoggerService _logger; + + // ── 统计属性字段 | Statistics property fields ── + private int _totalCount; + private int _passCount; + private int _failCount; + private double _passRate; + + // ── 筛选属性字段 | Filter property fields ── + private string _recipeName; + private DateTime? _fromDate; + private DateTime? _toDate; + + /// + /// 构造函数 | Constructor + /// + public MeasurementStatsViewModel( + IMeasurementDataService measurementDataService, + ILoggerService logger) + { + _measurementDataService = measurementDataService ?? throw new ArgumentNullException(nameof(measurementDataService)); + _logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule(); + + // ── 命令初始化 | Command initialization ── + RefreshCommand = new DelegateCommand(async () => await ExecuteRefreshAsync()); + + _logger.Info("MeasurementStatsViewModel 已初始化 | MeasurementStatsViewModel initialized"); + } + + // ── 统计属性 | Statistics properties ──────────────────────────── + + /// 总测量数 | Total measurement count + public int TotalCount + { + get => _totalCount; + set => SetProperty(ref _totalCount, value); + } + + /// 合格数 | Pass count + public int PassCount + { + get => _passCount; + set => SetProperty(ref _passCount, value); + } + + /// 不合格数 | Fail count + public int FailCount + { + get => _failCount; + set => SetProperty(ref _failCount, value); + } + + /// 合格率(0.0 ~ 1.0)| Pass rate (0.0 to 1.0) + public double PassRate + { + get => _passRate; + set => SetProperty(ref _passRate, value); + } + + // ── 筛选属性 | Filter properties ──────────────────────────────── + + /// 按配方名称筛选 | Filter by recipe name + public string RecipeName + { + get => _recipeName; + set => SetProperty(ref _recipeName, value); + } + + /// 筛选起始日期 | Filter start date + public DateTime? FromDate + { + get => _fromDate; + set => SetProperty(ref _fromDate, value); + } + + /// 筛选结束日期 | Filter end date + public DateTime? ToDate + { + get => _toDate; + set => SetProperty(ref _toDate, value); + } + + // ── 命令 | Commands ───────────────────────────────────────────── + + /// 刷新统计数据命令 | Refresh statistics command + public DelegateCommand RefreshCommand { get; } + + // ── 命令执行方法 | Command execution methods ──────────────────── + + /// + /// 从服务获取统计数据并更新属性 + /// Fetch statistics from service and update properties + /// + private async Task ExecuteRefreshAsync() + { + try + { + var stats = await _measurementDataService.GetStatisticsAsync(RecipeName, FromDate, ToDate); + + TotalCount = stats.TotalCount; + PassCount = stats.PassCount; + FailCount = stats.FailCount; + PassRate = stats.PassRate; + + _logger.Info("统计数据已刷新 | Statistics refreshed: Total={TotalCount}, Pass={PassCount}, Fail={FailCount}, Rate={PassRate:P1}", + stats.TotalCount, stats.PassCount, stats.FailCount, stats.PassRate); + } + catch (Exception ex) + { + _logger.Error(ex, "刷新统计数据失败 | Failed to refresh statistics"); + } + } + } +} diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index fc35b57..41e79ba 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -26,6 +26,8 @@ namespace XplorePlane.ViewModels public DelegateCommand EditPropertiesCommand { get; set; } public DelegateCommand OpenImageProcessingCommand { get; set; } public DelegateCommand OpenPipelineEditorCommand { get; set; } + public DelegateCommand OpenCncEditorCommand { get; set; } + public DelegateCommand OpenMatrixEditorCommand { get; set; } public MainViewModel(ILoggerService logger) { @@ -53,6 +55,20 @@ namespace XplorePlane.ViewModels _logger.Info("流水线编辑器窗口已打开"); }); + OpenCncEditorCommand = new DelegateCommand(() => + { + var window = new Views.Cnc.CncEditorWindow(); + window.Show(); + _logger.Info("CNC 编辑器窗口已打开"); + }); + + OpenMatrixEditorCommand = new DelegateCommand(() => + { + var window = new Views.Cnc.MatrixEditorWindow(); + window.Show(); + _logger.Info("矩阵编排窗口已打开"); + }); + _logger.Info("MainViewModel 已初始化"); } diff --git a/XplorePlane/Views/Cnc/CncEditorWindow.xaml b/XplorePlane/Views/Cnc/CncEditorWindow.xaml new file mode 100644 index 0000000..29a78ba --- /dev/null +++ b/XplorePlane/Views/Cnc/CncEditorWindow.xaml @@ -0,0 +1,14 @@ + + + diff --git a/XplorePlane/Views/Cnc/CncEditorWindow.xaml.cs b/XplorePlane/Views/Cnc/CncEditorWindow.xaml.cs new file mode 100644 index 0000000..e3ce395 --- /dev/null +++ b/XplorePlane/Views/Cnc/CncEditorWindow.xaml.cs @@ -0,0 +1,12 @@ +using System.Windows; + +namespace XplorePlane.Views.Cnc +{ + public partial class CncEditorWindow : Window + { + public CncEditorWindow() + { + InitializeComponent(); + } + } +} diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml b/XplorePlane/Views/Cnc/CncPageView.xaml new file mode 100644 index 0000000..fc212a7 --- /dev/null +++ b/XplorePlane/Views/Cnc/CncPageView.xaml @@ -0,0 +1,297 @@ + + + + + + + + + + Microsoft YaHei UI + + + + + + + + + + + + + + + + + + + + + +