10 Commits

Author SHA1 Message Date
zhengxuan.zhang 6684143dc9 1、CncProgramPath 现在存储绝对路径——AssociateCncProgram 接受 filePath 参数,ViewModel 传入 OpenFileDialog 选择的完整路径
2、偏移量影响所有 SavePositionNode——执行时模板中每个位置节点的 StageX/StageY 都会叠加单元格偏移,实现整体平移
2026-06-02 11:43:25 +08:00
zhengxuan.zhang dd62d04124 问题根因和修复
问题:MatrixPageView 使用 Prism 的 AutoWireViewModel,但 Prism 默认按命名约定查找 MatrixPageViewModel(不存在),导致 DataContext 为 null,所有按钮绑定都不工作。

修复:将 RegisterForNavigation<MatrixPageView>() 改为 RegisterForNavigation<MatrixPageView, MatrixEditorViewModel>(),显式告诉 Prism 使用 MatrixEditorViewModel 作为该 View 的 ViewModel。
2026-06-02 11:32:50 +08:00
zhengxuan.zhang dee9359c5c 矩阵编排允许用户通过界面设定矩阵参数(行数、列数、行间距、列间距),将一个已编写好的 CNC 模板程序(.xp 文件)自动扩展为覆盖所有矩阵位置的完整检测序列,并按行优先顺序依次完成移轴→采图→检测的闭环执行 2026-06-02 11:25:11 +08:00
zhengxuan.zhang df50000e6a 启动时的登录对话框,应用启动后直接以 管理员 (Admin) 角色自动登录 2026-06-01 17:37:38 +08:00
zhengxuan.zhang 4be032918d 修复用户登录功能 2026-06-01 17:32:08 +08:00
zhengxuan.zhang 741874e85d 基于角色的权限控制
1、用户角色枚举、权限枚举、结果记录和密码存储模型
IPermissionService 接口及包含认证、权限检查、密码管理和登出功能的 PermissionService 单例
2、支持层级化角色-权限映射的权限矩阵(SuperAdmin ⊇ Admin ⊇ User)
密码持久化至 passwords.json 文件,并提供工厂默认值回退机制
3、UI 层
LoginDialog — 启动时弹出模态登录对话框,支持密码掩码输入、错误提示以及取消退出功能
RibbonStatusAreaView — 在Ribbon右侧区域始终显示角色标签和“切换用户”按钮
权限感知的CncEditorViewModel — 用户角色无法使用CNC编辑控件
权限感知的CncInspectionModulePipelineViewModel — 用户角色无法进行流程编辑
设置导航可见性 — Admin/User角色隐藏Factory_Settings,User角色隐藏Report_Settings
PasswordManagementView — 仅SuperAdmin可访问的修改角色密码对话框
PermissionTooltipHelper — 附加属性,在禁用控件上显示“当前角色无权访问此功能”提示
2026-06-01 17:15:59 +08:00
zhengxuan.zhang acbed526f6 修复保存高级模块ROI时,有几个点保存几个点,而不是将列表中32个值中0的部分也保存 2026-06-01 15:15:40 +08:00
zhengxuan.zhang ad1fdb0230 实时过程中禁用图像预览窗体的滚轮缩放功能 2026-06-01 15:07:28 +08:00
zhengxuan.zhang 5a11af9ab1 优化调试面板 2026-06-01 14:35:37 +08:00
zhengxuan.zhang 4301f8a5f7 任务1 — 调试窗体全中文化
新建 DebugPanelLocalization.cs 集中维护中英文映射(类别 / 事件类型 / 字段名),显示用中文、内部匹配仍用英文标识,二者解耦。

涉及改动:

状态显示树:根节点 MotionState→运动状态、RaySourceState→射线源状态 等;字段 StageX→载物台X (μm) 等。StateNodeViewModel 新增 DisplayName,绑定到 UI;Name 保留英文供 FindNode 匹配
事件日志:EventLogEntry 新增 EventTypeDisplay/CategoryDisplay/FieldNameDisplay 派生属性;GridView 三列改绑中文;事件类型如 MotionStateChanged→运动状态变化,(No changes)→(无变化)
快照管理:详情树节点本地化
性能监控:PerformanceMetric 新增 StateTypeDisplay,状态类型列显示中文
快照差异窗口:标题 Snapshot Differences→快照差异对比,列头和按钮中文化
各处 MessageBox/文件对话框提示语全部翻译为中文
任务2 — 布局优化

主布局(顶部工具栏 / 左状态树 / 右上事件日志 / 右下快照·性能 Tab / 底部状态栏)符合"可视化查看系统状态与事件触发"的调试目标,结构保留。优化点:将顶部 ToolBarTray+ToolBar(带多余的拖动手柄和溢出箭头,即截图顶部那个浮动小工具条)替换为简洁的 Border+StackPanel,去掉无意义的拖拽/溢出交互。
2026-06-01 14:23:44 +08:00
66 changed files with 3353 additions and 401 deletions
@@ -176,6 +176,20 @@ namespace XP.ImageProcessing.RoiControl.Controls
control.UpdateAdorner();
}
/// <summary>
/// 是否允许滚轮缩放(默认允许)。
/// 实时图像显示过程中可设为 false 以禁用滚轮缩放。
/// </summary>
public static readonly DependencyProperty IsWheelZoomEnabledProperty =
DependencyProperty.Register(nameof(IsWheelZoomEnabled), typeof(bool), typeof(PolygonRoiCanvas),
new PropertyMetadata(true));
public bool IsWheelZoomEnabled
{
get => (bool)GetValue(IsWheelZoomEnabledProperty);
set => SetValue(IsWheelZoomEnabledProperty, value);
}
public static readonly DependencyProperty PanOffsetXProperty =
DependencyProperty.Register(nameof(PanOffsetX), typeof(double), typeof(PolygonRoiCanvas),
new PropertyMetadata(0.0, OnPanOffsetChanged));
@@ -2209,6 +2223,13 @@ namespace XP.ImageProcessing.RoiControl.Controls
private void Canvas_MouseWheel(object sender, MouseWheelEventArgs e)
{
// 实时显示过程中禁用滚轮缩放
if (!IsWheelZoomEnabled)
{
e.Handled = true;
return;
}
double oldZoom = ZoomScale;
double newZoom = e.Delta > 0 ? oldZoom * ZoomStep : oldZoom / ZoomStep;
newZoom = Math.Max(0.1, Math.Min(10.0, newZoom));
@@ -2,8 +2,10 @@ using System;
using System.IO;
using System.Threading.Tasks;
using Moq;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.Permission;
using XplorePlane.Services.Storage;
using XplorePlane.Tests.Helpers;
using Xunit;
@@ -24,7 +26,11 @@ namespace XplorePlane.Tests.Pipeline
Directory.CreateDirectory(_tempDir);
var mockImageSvc = TestHelpers.CreateMockImageService(new[] { "Blur", "Sharpen" });
_svc = new PipelinePersistenceService(mockImageSvc.Object, _tempDir);
var mockPermissionSvc = new Mock<IPermissionService>();
mockPermissionSvc.Setup(p => p.HasPermission(It.IsAny<Permission>())).Returns(true);
var mockLogger = new Mock<ILoggerService>();
mockLogger.Setup(l => l.ForModule<PipelinePersistenceService>()).Returns(mockLogger.Object);
_svc = new PipelinePersistenceService(mockImageSvc.Object, mockPermissionSvc.Object, mockLogger.Object, _tempDir);
}
public void Dispose()
@@ -206,10 +212,14 @@ namespace XplorePlane.Tests.Pipeline
public async Task LoadAllAsync_UsesToolsPath_WhenConstructedWithDataPathService()
{
var mockImageSvc = TestHelpers.CreateMockImageService(new[] { "Blur" });
var mockPermissionSvc = new Mock<IPermissionService>();
mockPermissionSvc.Setup(p => p.HasPermission(It.IsAny<Permission>())).Returns(true);
var mockLogger = new Mock<ILoggerService>();
mockLogger.Setup(l => l.ForModule<PipelinePersistenceService>()).Returns(mockLogger.Object);
var mockDataPathSvc = new Mock<IXpDataPathService>();
mockDataPathSvc.SetupGet(s => s.ToolsPath).Returns(_tempDir);
var service = new PipelinePersistenceService(mockImageSvc.Object, mockDataPathSvc.Object);
var service = new PipelinePersistenceService(mockImageSvc.Object, mockPermissionSvc.Object, mockLogger.Object, mockDataPathSvc.Object);
await service.SaveAsync(BuildModel("P3", "Blur"), Path.Combine(_tempDir, "p3.xpm"));
var result = await service.LoadAllAsync(null);
@@ -12,6 +12,7 @@ using System.Threading.Tasks;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.Permission;
using XplorePlane.Services.Storage;
using XplorePlane.Tests.Helpers;
using XplorePlane.ViewModels;
@@ -181,7 +182,11 @@ namespace XplorePlane.Tests.Pipeline
try
{
var mockImageSvc = TestHelpers.CreateMockImageService(new[] { "Blur", "Sharpen" });
var svc = new PipelinePersistenceService(mockImageSvc.Object, tempDir);
var mockPermissionSvc = new Mock<IPermissionService>();
mockPermissionSvc.Setup(p => p.HasPermission(It.IsAny<Permission>())).Returns(true);
var mockLogger = new Mock<ILoggerService>();
mockLogger.Setup(l => l.ForModule<PipelinePersistenceService>()).Returns(mockLogger.Object);
var svc = new PipelinePersistenceService(mockImageSvc.Object, mockPermissionSvc.Object, mockLogger.Object, tempDir);
var model = new PipelineModel { Name = $"Pipeline_{nodeCount}" };
for (int i = 0; i < nodeCount; i++)
@@ -6,6 +6,7 @@ using XP.Hardware.RaySource.Services;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.Services.Cnc;
using XplorePlane.Services.Permission;
using Xunit;
namespace XplorePlane.Tests.Services
@@ -24,7 +25,10 @@ namespace XplorePlane.Tests.Services
var logger = new Mock<ILoggerService>();
logger.Setup(l => l.ForModule<CncProgramService>()).Returns(logger.Object);
var service = new CncProgramService(appState.Object, raySource.Object, logger.Object);
var permissionService = new Mock<IPermissionService>();
permissionService.Setup(p => p.HasPermission(It.IsAny<Permission>())).Returns(true);
var service = new CncProgramService(appState.Object, raySource.Object, logger.Object, permissionService.Object);
var program = new CncProgram(
Guid.NewGuid(),
"Program",
@@ -18,6 +18,7 @@ using XplorePlane.Services.AppState;
using XplorePlane.Services.Cnc;
using XplorePlane.Services.Storage;
using XplorePlane.Services;
using XplorePlane.Services.Permission;
using XplorePlane.ViewModels.Cnc;
using Xunit;
@@ -36,10 +37,14 @@ namespace XplorePlane.Tests.ViewModels
var mockAppState = new Mock<IAppStateService>();
var mockLogger = new Mock<ILoggerService>();
var mockDataPathService = new Mock<IXpDataPathService>();
var mockPermissionService = new Mock<IPermissionService>();
mockPipelinePersistenceService ??= new Mock<IPipelinePersistenceService>();
mockLogger.Setup(l => l.ForModule<CncEditorViewModel>()).Returns(mockLogger.Object);
mockDataPathService.SetupGet(s => s.PlanPath).Returns(System.IO.Path.GetTempPath());
// Default: grant all permissions (Admin/SuperAdmin behavior)
mockPermissionService.Setup(p => p.HasPermission(It.IsAny<XplorePlane.Models.Permission>())).Returns(true);
mockExecSvc ??= new Mock<ICncExecutionService>();
// Setup CreateProgram so ExecuteNewProgram works
@@ -95,7 +100,8 @@ namespace XplorePlane.Tests.ViewModels
mockLogger.Object,
mockExecSvc.Object,
mockDataPathService.Object,
mockPipelinePersistenceService.Object);
mockPipelinePersistenceService.Object,
mockPermissionService.Object);
if (initialProgram != null)
{
+2 -1
View File
@@ -1,7 +1,8 @@
<Application x:Class="XplorePlane.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:converters="clr-namespace:XplorePlane.Converters">
xmlns:converters="clr-namespace:XplorePlane.Converters"
ShutdownMode="OnMainWindowClose">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
+28 -6
View File
@@ -42,6 +42,7 @@ using XP.Hardware.RaySource.Services;
using XP.ReportEngine;
using XplorePlane.Services;
using XplorePlane.Services.AppState;
using XplorePlane.Services.Permission;
using XplorePlane.Services.Camera;
using XplorePlane.Services.Cnc;
using XplorePlane.Services.Debug;
@@ -56,6 +57,7 @@ using XplorePlane.ViewModels;
using XplorePlane.ViewModels.Cnc;
using XplorePlane.ViewModels.Debug;
using XplorePlane.ViewModels.ImageProcessing;
using XplorePlane.ViewModels.Main;
using XplorePlane.Views;
using XplorePlane.Views.Cnc;
using XplorePlane.Views.Debug;
@@ -340,11 +342,17 @@ namespace XplorePlane
}
// 执行授权检查 | Perform license authorization check
if (!PerformLicenseCheck())
{
Application.Current.Shutdown();
return null;
}
//if (!PerformLicenseCheck())
//{
// Application.Current.Shutdown();
// return null;
//}
// ── 登录认证:跳过登录对话框,默认以管理员角色登录 ──
var permissionService = Container.Resolve<IPermissionService>();
permissionService.Authenticate("xpuser"); // 默认登录为管理员 (Admin)
Log.Information("默认登录为管理员角色,跳过登录对话框");
var shell = Container.Resolve<MainWindow>();
@@ -624,6 +632,8 @@ namespace XplorePlane
containerRegistry.RegisterSingleton<IInspectionResultStore, InspectionResultStore>();
containerRegistry.RegisterSingleton<IImagePersistenceService, ImagePersistenceService>();
containerRegistry.RegisterSingleton<ICncExecutionService, CncExecutionService>();
containerRegistry.RegisterSingleton<IMatrixOrchestrationService, MatrixOrchestrationService>();
containerRegistry.RegisterSingleton<MatrixSummaryWriter>();
// ── 主界面实时图像 / 探测器双队列服务(单例)──
containerRegistry.RegisterSingleton<IMainViewportService, MainViewportService>();
@@ -637,7 +647,7 @@ namespace XplorePlane
// ── CNC / 矩阵导航视图 ──
containerRegistry.RegisterForNavigation<CncPageView>();
containerRegistry.RegisterForNavigation<MatrixPageView>();
containerRegistry.RegisterForNavigation<MatrixPageView, MatrixEditorViewModel>();
containerRegistry.Register<InspectionReportViewerWindow>();
// ── 导航相机服务(单例)──
@@ -668,6 +678,18 @@ namespace XplorePlane
// ── 录制服务(单例)──
containerRegistry.RegisterSingleton<IViewportRecordingService, ViewportRecordingService>();
// ── 权限管理服务(单例)──
containerRegistry.RegisterSingleton<IPermissionService, PermissionService>();
// ── 登录对话框 ViewModel(瞬态)──
containerRegistry.Register<LoginDialogViewModel>();
// ── Ribbon 右侧状态区域 ViewModel(单例,跟随 MainViewModel 生命周期)──
containerRegistry.RegisterSingleton<RibbonStatusAreaViewModel>();
// ── 密码管理 ViewModel(瞬态,每次打开对话框创建新实例)──
containerRegistry.Register<ViewModels.Setting.PasswordManagementViewModel>();
Log.Information("依赖注入容器配置完成");
}
@@ -0,0 +1,24 @@
using System;
using System.Globalization;
using System.Windows.Data;
namespace XplorePlane.Converters
{
/// <summary>
/// 布尔值取反转换器,用于将 true 转为 false、false 转为 true
/// Inverse boolean converter: converts true to false and false to true
/// </summary>
[ValueConversion(typeof(bool), typeof(bool))]
public class InverseBoolConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is bool b ? !b : true;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
return value is bool b ? !b : false;
}
}
}
+18
View File
@@ -0,0 +1,18 @@
using Prism.Events;
using XplorePlane.Models;
namespace XplorePlane.Events
{
/// <summary>
/// 角色变更事件,通过 Prism EventAggregator 发布。
/// 订阅方:所有需要根据角色刷新 UI 状态的 ViewModel。
/// </summary>
public class RoleChangedEvent : PubSubEvent<RoleChangedPayload>
{
}
/// <summary>角色变更载荷。</summary>
/// <param name="OldRole">变更前角色(首次登录时为 null)。</param>
/// <param name="NewRole">变更后角色。</param>
public record RoleChangedPayload(UserRole? OldRole, UserRole NewRole);
}
@@ -0,0 +1,148 @@
using System.Windows;
using System.Windows.Controls;
namespace XplorePlane.Helpers
{
/// <summary>
/// 权限提示附加属性。当控件因权限不足被禁用时,显示提示信息。
/// 用法:helpers:PermissionTooltipHelper.IsPermissionRestricted="True"
/// 当 IsEnabled=False 时自动显示 "当前角色无权访问此功能" 的 ToolTip。
/// </summary>
public static class PermissionTooltipHelper
{
private const string PermissionDeniedTooltip = "当前角色无权访问此功能";
#region IsPermissionRestricted Attached Property
public static readonly DependencyProperty IsPermissionRestrictedProperty =
DependencyProperty.RegisterAttached(
"IsPermissionRestricted",
typeof(bool),
typeof(PermissionTooltipHelper),
new PropertyMetadata(false, OnIsPermissionRestrictedChanged));
public static bool GetIsPermissionRestricted(DependencyObject obj)
{
return (bool)obj.GetValue(IsPermissionRestrictedProperty);
}
public static void SetIsPermissionRestricted(DependencyObject obj, bool value)
{
obj.SetValue(IsPermissionRestrictedProperty, value);
}
#endregion
#region OriginalTooltip (internal storage)
private static readonly DependencyProperty OriginalTooltipProperty =
DependencyProperty.RegisterAttached(
"OriginalTooltip",
typeof(object),
typeof(PermissionTooltipHelper),
new PropertyMetadata(null));
private static object GetOriginalTooltip(DependencyObject obj)
{
return obj.GetValue(OriginalTooltipProperty);
}
private static void SetOriginalTooltip(DependencyObject obj, object value)
{
obj.SetValue(OriginalTooltipProperty, value);
}
#endregion
#region HasStoredOriginal (internal flag)
private static readonly DependencyProperty HasStoredOriginalProperty =
DependencyProperty.RegisterAttached(
"HasStoredOriginal",
typeof(bool),
typeof(PermissionTooltipHelper),
new PropertyMetadata(false));
private static bool GetHasStoredOriginal(DependencyObject obj)
{
return (bool)obj.GetValue(HasStoredOriginalProperty);
}
private static void SetHasStoredOriginal(DependencyObject obj, bool value)
{
obj.SetValue(HasStoredOriginalProperty, value);
}
#endregion
private static void OnIsPermissionRestrictedChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
{
if (d is not FrameworkElement element)
return;
var isRestricted = (bool)e.NewValue;
if (isRestricted)
{
// Subscribe to IsEnabledChanged to update tooltip dynamically
element.IsEnabledChanged += OnIsEnabledChanged;
// Enable showing tooltip on disabled controls
ToolTipService.SetShowOnDisabled(element, true);
// Apply tooltip based on current state
UpdateTooltip(element);
}
else
{
// Unsubscribe
element.IsEnabledChanged -= OnIsEnabledChanged;
// Restore original tooltip
RestoreOriginalTooltip(element);
// Reset ShowOnDisabled
ToolTipService.SetShowOnDisabled(element, false);
}
}
private static void OnIsEnabledChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (sender is FrameworkElement element)
{
UpdateTooltip(element);
}
}
private static void UpdateTooltip(FrameworkElement element)
{
if (!element.IsEnabled)
{
// Store original tooltip if not already stored
if (!GetHasStoredOriginal(element))
{
SetOriginalTooltip(element, element.ToolTip);
SetHasStoredOriginal(element, true);
}
// Set permission denied tooltip
element.ToolTip = PermissionDeniedTooltip;
}
else
{
// Restore original tooltip when enabled
RestoreOriginalTooltip(element);
}
}
private static void RestoreOriginalTooltip(FrameworkElement element)
{
if (GetHasStoredOriginal(element))
{
element.ToolTip = GetOriginalTooltip(element);
SetHasStoredOriginal(element, false);
SetOriginalTooltip(element, null);
}
}
}
}
@@ -0,0 +1,4 @@
namespace XplorePlane.Models
{
public record AuthenticationResult(bool Success, UserRole? Role, string ErrorMessage = null);
}
+34 -7
View File
@@ -16,9 +16,12 @@ namespace XplorePlane.Models
private bool _isHighlighted;
private string _highlightColor;
/// <summary>字段名称 | Field name</summary>
/// <summary>字段名称(英文标识,用于内部匹配)| Field name (English key for matching)</summary>
public string Name { get; set; }
/// <summary>显示名称(中文)| Display name (Chinese for UI)</summary>
public string DisplayName { get; set; }
/// <summary>字段值(格式化字符串)| Field value (formatted string)</summary>
public string Value
{
@@ -56,10 +59,10 @@ namespace XplorePlane.Models
/// <summary>时间戳 | Timestamp</summary>
public DateTime Timestamp { get; set; }
/// <summary>事件类型(MotionStateChanged, RaySourceStateChanged 等)| Event type</summary>
/// <summary>事件类型(MotionStateChanged 等,英文标识| Event type</summary>
public string EventType { get; set; }
/// <summary>字段名称 | Field name</summary>
/// <summary>字段名称(英文标识)| Field name</summary>
public string FieldName { get; set; }
/// <summary>旧值 | Old value</summary>
@@ -68,8 +71,20 @@ namespace XplorePlane.Models
/// <summary>新值 | New value</summary>
public string NewValue { get; set; }
/// <summary>状态类别(用于过滤)| State category (for filtering)</summary>
/// <summary>状态类别(用于过滤,英文标识| State category (for filtering)</summary>
public string Category { get; set; }
/// <summary>事件类型中文显示名 | Event type Chinese display name</summary>
public string EventTypeDisplay =>
XplorePlane.ViewModels.Debug.DebugPanelLocalization.EventType(EventType);
/// <summary>类别中文显示名 | Category Chinese display name</summary>
public string CategoryDisplay =>
XplorePlane.ViewModels.Debug.DebugPanelLocalization.Category(Category);
/// <summary>字段中文显示名 | Field Chinese display name</summary>
public string FieldNameDisplay =>
XplorePlane.ViewModels.Debug.DebugPanelLocalization.Field(FieldName);
}
/// <summary>
@@ -115,10 +130,10 @@ namespace XplorePlane.Models
/// </summary>
public class StateDifference
{
/// <summary>状态类别 | State category</summary>
/// <summary>状态类别(英文标识)| State category</summary>
public string Category { get; set; }
/// <summary>字段名称 | Field name</summary>
/// <summary>字段名称(英文标识)| Field name</summary>
public string FieldName { get; set; }
/// <summary>快照1的值 | Value from snapshot 1</summary>
@@ -126,6 +141,14 @@ namespace XplorePlane.Models
/// <summary>快照2的值 | Value from snapshot 2</summary>
public string Value2 { get; set; }
/// <summary>类别中文显示名 | Category Chinese display name</summary>
public string CategoryDisplay =>
XplorePlane.ViewModels.Debug.DebugPanelLocalization.Category(Category);
/// <summary>字段中文显示名 | Field Chinese display name</summary>
public string FieldNameDisplay =>
XplorePlane.ViewModels.Debug.DebugPanelLocalization.Field(FieldName);
}
/// <summary>
@@ -137,9 +160,13 @@ namespace XplorePlane.Models
private double _eventsPerSecond;
private double _averageLatency;
/// <summary>状态类型 | State type</summary>
/// <summary>状态类型(英文标识)| State type</summary>
public string StateType { get; set; }
/// <summary>状态类型中文显示名 | State type Chinese display name</summary>
public string StateTypeDisplay =>
XplorePlane.ViewModels.Debug.DebugPanelLocalization.Category(StateType);
/// <summary>事件频率(事件/秒)| Events per second</summary>
public double EventsPerSecond
{
+35
View File
@@ -42,4 +42,39 @@ namespace XplorePlane.Models
string CncProgramPath,
IReadOnlyList<MatrixCell> Cells
);
// ── 矩阵执行摘要模型(用于 matrix_summary.json 序列化)────────────
/// <summary>矩阵执行摘要文件根对象 | Matrix execution summary file root object</summary>
public class MatrixSummaryFile
{
public MatrixSummaryConfig Config { get; set; }
public string ProgramName { get; set; }
public string StartTime { get; set; } // ISO 8601
public double DurationSeconds { get; set; }
public int TotalCells { get; set; }
public int EnabledCells { get; set; }
public int CompletedCells { get; set; }
public int FailedCells { get; set; }
public List<MatrixCellSummaryEntry> Cells { get; set; }
}
/// <summary>矩阵配置信息 | Matrix configuration</summary>
public class MatrixSummaryConfig
{
public int Rows { get; set; }
public int Columns { get; set; }
public double RowSpacing { get; set; }
public double ColumnSpacing { get; set; }
}
/// <summary>矩阵单元格摘要条目 | Matrix cell summary entry</summary>
public class MatrixCellSummaryEntry
{
public string RunId { get; set; }
public int Row { get; set; }
public int Column { get; set; }
public string Status { get; set; }
public bool? Pass { get; set; }
}
}
@@ -0,0 +1,4 @@
namespace XplorePlane.Models
{
public record PasswordChangeResult(bool Success, string ErrorMessage = null);
}
@@ -0,0 +1,12 @@
using System;
using System.Collections.Generic;
namespace XplorePlane.Models
{
public class PasswordStorageModel
{
public int Version { get; set; } = 1;
public Dictionary<string, string> Passwords { get; set; } = new();
public DateTime LastModified { get; set; }
}
}
+44
View File
@@ -0,0 +1,44 @@
namespace XplorePlane.Models
{
/// <summary>系统权限标识,每个可控功能对应一个枚举值。</summary>
public enum Permission
{
// ── 厂家级设置 ──
AccessPlcSettings,
AccessHardwareSettings,
AccessMotionControlSettings,
AccessDetectorSettings,
AccessRaySourceSettings,
// ── 报告设定 ──
AccessReportSettings,
// ── CNC 程序编辑 ──
CncInsertNode,
CncDeleteNode,
CncRenameNode,
CncReorderNode,
CncNewProgram,
CncSaveProgram,
CncDeleteProgramFile,
// ── 检测模块编辑 ──
InspectionAddOperator,
InspectionRemoveOperator,
InspectionReorderOperator,
InspectionToggleOperator,
InspectionEditParameters,
// ── CNC 程序查看与运行(所有角色)──
CncViewProgram,
CncRunProgram,
CncStopProgram,
ViewInspectionResults,
// ── 密码管理 ──
ManagePasswords,
// ── 用户切换 ──
SwitchUser
}
}
@@ -0,0 +1,22 @@
using System;
namespace XplorePlane.Models
{
/// <summary>
/// 角色变更事件参数(供不使用 EventAggregator 的组件直接订阅)。
/// </summary>
public class RoleChangedEventArgs : EventArgs
{
/// <summary>变更前角色(首次登录时为 null)。</summary>
public UserRole? OldRole { get; }
/// <summary>变更后角色。</summary>
public UserRole NewRole { get; }
public RoleChangedEventArgs(UserRole? oldRole, UserRole newRole)
{
OldRole = oldRole;
NewRole = newRole;
}
}
}
+10
View File
@@ -0,0 +1,10 @@
namespace XplorePlane.Models
{
/// <summary>系统角色定义,层级从高到低。</summary>
public enum UserRole
{
SuperAdmin = 0, // 超级管理员
Admin = 1, // 管理员
User = 2 // 普通用户
}
}
+49 -1
View File
@@ -9,6 +9,8 @@ using XP.Common.Logging.Interfaces;
using XP.Hardware.RaySource.Services;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.Services.Permission;
using PermissionEnum = XplorePlane.Models.Permission;
namespace XplorePlane.Services.Cnc
{
@@ -23,6 +25,7 @@ namespace XplorePlane.Services.Cnc
private readonly IAppStateService _appStateService;
private readonly IRaySourceService _raySourceService;
private readonly ILoggerService _logger;
private readonly IPermissionService _permissionService;
// ── 序列化配置 | Serialization options ──
private static readonly JsonSerializerOptions CncJsonOptions = new()
@@ -35,15 +38,18 @@ namespace XplorePlane.Services.Cnc
public CncProgramService(
IAppStateService appStateService,
IRaySourceService raySourceService,
ILoggerService logger)
ILoggerService logger,
IPermissionService permissionService)
{
ArgumentNullException.ThrowIfNull(appStateService);
ArgumentNullException.ThrowIfNull(raySourceService);
ArgumentNullException.ThrowIfNull(logger);
ArgumentNullException.ThrowIfNull(permissionService);
_appStateService = appStateService;
_raySourceService = raySourceService;
_logger = logger.ForModule<CncProgramService>();
_permissionService = permissionService;
_logger.Info("CncProgramService 已初始化 | CncProgramService initialized");
}
@@ -51,6 +57,13 @@ namespace XplorePlane.Services.Cnc
/// <inheritdoc />
public CncProgram CreateProgram(string name)
{
if (!_permissionService.HasPermission(PermissionEnum.CncNewProgram))
{
_logger.Warn("权限拒绝: 尝试执行 CreateProgram,当前角色无权限 | Permission denied: CreateProgram, Role={Role}",
_permissionService.CurrentRole);
return null;
}
if (string.IsNullOrWhiteSpace(name))
throw new ArgumentException("程序名称不能为空 | Program name cannot be empty", nameof(name));
@@ -122,6 +135,13 @@ namespace XplorePlane.Services.Cnc
/// <inheritdoc />
public CncProgram InsertNode(CncProgram program, int afterIndex, CncNode node)
{
if (!_permissionService.HasPermission(PermissionEnum.CncInsertNode))
{
_logger.Warn("权限拒绝: 尝试执行 InsertNode,当前角色无权限 | Permission denied: InsertNode, Role={Role}",
_permissionService.CurrentRole);
return program;
}
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(node);
@@ -156,6 +176,13 @@ namespace XplorePlane.Services.Cnc
/// <inheritdoc />
public CncProgram RemoveNode(CncProgram program, int index)
{
if (!_permissionService.HasPermission(PermissionEnum.CncDeleteNode))
{
_logger.Warn("权限拒绝: 尝试执行 RemoveNode,当前角色无权限 | Permission denied: RemoveNode, Role={Role}",
_permissionService.CurrentRole);
return program;
}
ArgumentNullException.ThrowIfNull(program);
if (index < 0 || index >= program.Nodes.Count)
@@ -179,6 +206,13 @@ namespace XplorePlane.Services.Cnc
/// <inheritdoc />
public CncProgram MoveNode(CncProgram program, int oldIndex, int newIndex)
{
if (!_permissionService.HasPermission(PermissionEnum.CncReorderNode))
{
_logger.Warn("权限拒绝: 尝试执行 MoveNode,当前角色无权限 | Permission denied: MoveNode, Role={Role}",
_permissionService.CurrentRole);
return program;
}
ArgumentNullException.ThrowIfNull(program);
if (oldIndex < 0 || oldIndex >= program.Nodes.Count)
@@ -208,6 +242,13 @@ namespace XplorePlane.Services.Cnc
/// <inheritdoc />
public CncProgram UpdateNode(CncProgram program, int index, CncNode node)
{
if (!_permissionService.HasPermission(PermissionEnum.CncRenameNode))
{
_logger.Warn("权限拒绝: 尝试执行 UpdateNode,当前角色无权限 | Permission denied: UpdateNode, Role={Role}",
_permissionService.CurrentRole);
return program;
}
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(node);
@@ -234,6 +275,13 @@ namespace XplorePlane.Services.Cnc
/// <inheritdoc />
public async Task SaveAsync(CncProgram program, string filePath)
{
if (!_permissionService.HasPermission(PermissionEnum.CncSaveProgram))
{
_logger.Warn("权限拒绝: 尝试执行 SaveAsync,当前角色无权限 | Permission denied: SaveAsync, Role={Role}",
_permissionService.CurrentRole);
return;
}
ArgumentNullException.ThrowIfNull(program);
ArgumentNullException.ThrowIfNull(filePath);
@@ -0,0 +1,25 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using XplorePlane.Models;
namespace XplorePlane.Services.Matrix
{
/// <summary>
/// 矩阵编排执行服务接口。
/// 按行优先顺序展开矩阵并执行每个启用单元格。
/// 对每个单元格:深度克隆模板程序 → 叠加偏移量 → 调用 ICncExecutionService。
/// </summary>
public interface IMatrixOrchestrationService
{
/// <summary>
/// 按行优先顺序展开矩阵并执行每个启用单元格。
/// 对每个单元格:深度克隆模板程序 → 叠加偏移量 → 调用 ICncExecutionService。
/// </summary>
Task ExecuteAsync(
MatrixLayout layout,
CncProgram templateProgram,
IProgress<MatrixCellExecutionProgress> progress,
CancellationToken cancellationToken);
}
}
@@ -22,7 +22,7 @@ namespace XplorePlane.Services.Matrix
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);
MatrixLayout AssociateCncProgram(MatrixLayout layout, CncProgram program, string filePath = null);
/// <summary>将矩阵布局保存到 JSON 文件 | Save matrix layout to JSON file</summary>
Task SaveAsync(MatrixLayout layout, string filePath);
@@ -0,0 +1,16 @@
using XplorePlane.Models;
namespace XplorePlane.Services.Matrix
{
/// <summary>
/// 矩阵单元格执行进度报告。
/// </summary>
public record MatrixCellExecutionProgress(
int Row,
int Column,
MatrixCellStatus Status,
int CurrentIndex, // 当前启用单元格序号(从 1 开始)
int TotalEnabled, // 总启用单元格数
string ErrorMessage = null
);
}
@@ -0,0 +1,261 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.Cnc;
using XplorePlane.Services.InspectionResults;
using XplorePlane.Services.Storage;
namespace XplorePlane.Services.Matrix
{
/// <summary>
/// 矩阵编排执行服务实现。
/// 按行优先顺序展开矩阵并执行每个启用单元格:深度克隆模板程序 → 叠加偏移量 → 调用 ICncExecutionService。
/// Matrix orchestration execution service implementation.
/// Expands matrix in row-major order and executes each enabled cell: deep clone template → apply offset → call ICncExecutionService.
/// </summary>
public class MatrixOrchestrationService : IMatrixOrchestrationService
{
private readonly ICncExecutionService _cncExecutionService;
private readonly IInspectionResultStore _inspectionResultStore;
private readonly MatrixSummaryWriter _matrixSummaryWriter;
private readonly IXpDataPathService _dataPathService;
private readonly ILoggerService _logger;
public MatrixOrchestrationService(
ICncExecutionService cncExecutionService,
IInspectionResultStore inspectionResultStore,
MatrixSummaryWriter matrixSummaryWriter,
IXpDataPathService dataPathService,
ILoggerService logger)
{
ArgumentNullException.ThrowIfNull(cncExecutionService);
ArgumentNullException.ThrowIfNull(inspectionResultStore);
ArgumentNullException.ThrowIfNull(matrixSummaryWriter);
ArgumentNullException.ThrowIfNull(dataPathService);
ArgumentNullException.ThrowIfNull(logger);
_cncExecutionService = cncExecutionService;
_inspectionResultStore = inspectionResultStore;
_matrixSummaryWriter = matrixSummaryWriter;
_dataPathService = dataPathService;
_logger = logger.ForModule<MatrixOrchestrationService>();
}
/// <inheritdoc />
public async Task ExecuteAsync(
MatrixLayout layout,
CncProgram templateProgram,
IProgress<MatrixCellExecutionProgress> progress,
CancellationToken cancellationToken)
{
// 筛选启用的单元格,按行优先(Row 升序,Column 升序)排列
var enabledCells = layout.Cells
.Where(c => c.IsEnabled)
.OrderBy(c => c.Row)
.ThenBy(c => c.Column)
.ToList();
if (enabledCells.Count == 0)
{
throw new InvalidOperationException("没有已启用的位置,请至少启用一个单元格");
}
var totalEnabled = enabledCells.Count;
var startTime = DateTime.UtcNow;
var stopwatch = Stopwatch.StartNew();
int completedCount = 0;
int failedCount = 0;
var cellEntries = new List<MatrixCellSummaryEntry>();
_logger.Info(
"矩阵执行开始 | Matrix execution started: Program={ProgramName}, EnabledCells={EnabledCount}, TotalCells={TotalCells}",
templateProgram.Name, totalEnabled, layout.Rows * layout.Columns);
for (int i = 0; i < enabledCells.Count; i++)
{
var cell = enabledCells[i];
var currentIndex = i + 1;
// 检查取消请求
cancellationToken.ThrowIfCancellationRequested();
// 报告 Executing 状态
progress?.Report(new MatrixCellExecutionProgress(
cell.Row,
cell.Column,
MatrixCellStatus.Executing,
currentIndex,
totalEnabled));
// 生成单元格专用程序
var cellProgram = ApplyOffset(templateProgram, cell);
try
{
// 调用 CNC 执行服务(内部已处理 BeginRunAsync 归档)
await _cncExecutionService.ExecuteAsync(
cellProgram,
new Progress<CncNodeExecutionProgress>(),
cancellationToken).ConfigureAwait(false);
// 成功完成
completedCount++;
progress?.Report(new MatrixCellExecutionProgress(
cell.Row,
cell.Column,
MatrixCellStatus.Completed,
currentIndex,
totalEnabled));
cellEntries.Add(new MatrixCellSummaryEntry
{
RunId = cellProgram.Id.ToString(),
Row = cell.Row,
Column = cell.Column,
Status = nameof(MatrixCellStatus.Completed),
Pass = true
});
_logger.Info(
"单元格执行完成 | Cell completed: R{Row}C{Col} ({Current}/{Total})",
cell.Row, cell.Column, currentIndex, totalEnabled);
}
catch (OperationCanceledException)
{
// 探测器断连或用户取消 — 标记当前单元格为 Error,传播异常退出循环
failedCount++;
progress?.Report(new MatrixCellExecutionProgress(
cell.Row,
cell.Column,
MatrixCellStatus.Error,
currentIndex,
totalEnabled,
"操作已取消(探测器断连或用户停止)"));
cellEntries.Add(new MatrixCellSummaryEntry
{
RunId = cellProgram.Id.ToString(),
Row = cell.Row,
Column = cell.Column,
Status = nameof(MatrixCellStatus.Error),
Pass = false
});
_logger.Info(
"矩阵执行被取消 | Matrix execution cancelled at R{Row}C{Col} ({Current}/{Total})",
cell.Row, cell.Column, currentIndex, totalEnabled);
// 写入摘要(即使被取消也尝试写入已有结果)
stopwatch.Stop();
await WriteSummaryAsync(layout, templateProgram.Name, startTime, cellEntries, cancellationToken: default).ConfigureAwait(false);
LogSummary(templateProgram.Name, totalEnabled, completedCount, failedCount, stopwatch.Elapsed);
throw; // 传播 OperationCanceledException
}
catch (Exception ex)
{
// 普通异常 — 标记 Error,继续下一个单元格
failedCount++;
var errorMessage = ex.Message;
progress?.Report(new MatrixCellExecutionProgress(
cell.Row,
cell.Column,
MatrixCellStatus.Error,
currentIndex,
totalEnabled,
errorMessage));
cellEntries.Add(new MatrixCellSummaryEntry
{
RunId = cellProgram.Id.ToString(),
Row = cell.Row,
Column = cell.Column,
Status = nameof(MatrixCellStatus.Error),
Pass = false
});
_logger.Error(ex,
"单元格执行失败 | Cell execution failed: R{Row}C{Col} ({Current}/{Total})",
cell.Row, cell.Column, currentIndex, totalEnabled);
}
}
// 全部完成后写入摘要
stopwatch.Stop();
await WriteSummaryAsync(layout, templateProgram.Name, startTime, cellEntries, cancellationToken: default).ConfigureAwait(false);
LogSummary(templateProgram.Name, totalEnabled, completedCount, failedCount, stopwatch.Elapsed);
}
/// <summary>
/// 写入矩阵执行摘要文件。
/// </summary>
private async Task WriteSummaryAsync(
MatrixLayout layout,
string templateProgramName,
DateTime startTime,
IReadOnlyList<MatrixCellSummaryEntry> cellEntries,
CancellationToken cancellationToken)
{
var outputDirectory = System.IO.Path.Combine(_dataPathService.DataPath, "MatrixResults");
await _matrixSummaryWriter.WriteAsync(
layout,
templateProgramName,
startTime,
cellEntries,
outputDirectory,
cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// 记录汇总日志。
/// </summary>
private void LogSummary(string programName, int totalCells, int completed, int failed, TimeSpan duration)
{
_logger.Info(
"矩阵执行汇总 | Matrix execution summary: Program={ProgramName}, TotalCells={TotalCells}, Completed={Completed}, Failed={Failed}, Duration={Duration:F1}s",
programName, totalCells, completed, failed, duration.TotalSeconds);
}
/// <summary>
/// 将模板程序按单元格偏移量生成该单元格专用的 CNC 程序副本。
/// 使用 record with 表达式深度克隆,对每个 SavePositionNode 叠加偏移量(μm),
/// 生成新 Id 和新程序名,原模板对象不被修改。
///
/// Generates a cell-specific CNC program copy by applying cell offset to the template.
/// Uses record with expression for deep clone, adds offset (μm) to each SavePositionNode,
/// generates new Id and program name. Original template is NOT modified.
/// </summary>
internal static CncProgram ApplyOffset(CncProgram template, MatrixCell cell)
{
var offsetXum = cell.OffsetX * 1000.0;
var offsetYum = cell.OffsetY * 1000.0;
var newNodes = template.Nodes
.Select(node => node is SavePositionNode sp
? sp with
{
MotionState = sp.MotionState with
{
StageX = sp.MotionState.StageX + offsetXum,
StageY = sp.MotionState.StageY + offsetYum
}
}
: node)
.ToList()
.AsReadOnly();
return template with
{
Id = Guid.NewGuid(),
Name = $"{template.Name}_R{cell.Row}C{cell.Column}",
Nodes = newNodes
};
}
}
}
+36 -4
View File
@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Tasks;
@@ -24,6 +25,7 @@ namespace XplorePlane.Services.Matrix
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Converters = { new JsonStringEnumConverter() }
};
@@ -40,6 +42,8 @@ namespace XplorePlane.Services.Matrix
public MatrixLayout CreateLayout(int rows, int columns, double rowSpacing, double columnSpacing)
{
ValidateDimensions(rows, columns);
ValidateSpacing(rowSpacing);
ValidateSpacing(columnSpacing);
var layout = new MatrixLayout(
Id: Guid.NewGuid(),
@@ -62,6 +66,8 @@ namespace XplorePlane.Services.Matrix
{
ArgumentNullException.ThrowIfNull(layout);
ValidateDimensions(rows, columns);
ValidateSpacing(rowSpacing);
ValidateSpacing(columnSpacing);
var updated = layout with
{
@@ -117,15 +123,17 @@ namespace XplorePlane.Services.Matrix
}
/// <inheritdoc />
public MatrixLayout AssociateCncProgram(MatrixLayout layout, CncProgram program)
public MatrixLayout AssociateCncProgram(MatrixLayout layout, CncProgram program, string filePath = null)
{
ArgumentNullException.ThrowIfNull(layout);
ArgumentNullException.ThrowIfNull(program);
var updated = layout with { CncProgramPath = program.Name };
// 优先使用传入的文件路径(绝对路径),否则回退到程序名
var programPath = !string.IsNullOrWhiteSpace(filePath) ? filePath : program.Name;
var updated = layout with { CncProgramPath = programPath };
_logger.Info("已关联 CNC 程序到矩阵布局 | Associated CNC program with matrix layout: LayoutId={LayoutId}, Program={ProgramName}",
layout.Id, program.Name);
_logger.Info("已关联 CNC 程序到矩阵布局 | Associated CNC program with matrix layout: LayoutId={LayoutId}, Program={ProgramPath}",
layout.Id, programPath);
return updated;
}
@@ -249,8 +257,24 @@ namespace XplorePlane.Services.Matrix
return cells.AsReadOnly();
}
/// <summary>
/// 验证间距参数 | Validate spacing parameter
/// Spacing must be in [0.0, 1000.0].
/// </summary>
private static void ValidateSpacing(double spacing)
{
if (spacing < 0)
throw new ArgumentOutOfRangeException(nameof(spacing),
$"间距不能为负数 | Spacing must not be negative: {spacing}");
if (spacing > 1000.0)
throw new ArgumentOutOfRangeException(nameof(spacing),
$"间距不能超过 1000mm | Spacing must not exceed 1000mm: {spacing}");
}
/// <summary>
/// 验证行列数参数 | Validate row and column dimensions
/// Rows and Columns must be in [1, 50].
/// </summary>
private static void ValidateDimensions(int rows, int columns)
{
@@ -258,9 +282,17 @@ namespace XplorePlane.Services.Matrix
throw new ArgumentOutOfRangeException(nameof(rows),
$"行数必须大于 0 | Rows must be greater than 0: {rows}");
if (rows > 50)
throw new ArgumentOutOfRangeException(nameof(rows),
$"行数不能超过 50 | Rows must not exceed 50: {rows}");
if (columns <= 0)
throw new ArgumentOutOfRangeException(nameof(columns),
$"列数必须大于 0 | Columns must be greater than 0: {columns}");
if (columns > 50)
throw new ArgumentOutOfRangeException(nameof(columns),
$"列数不能超过 50 | Columns must not exceed 50: {columns}");
}
/// <summary>
@@ -0,0 +1,93 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
using System.Threading.Tasks;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
namespace XplorePlane.Services.Matrix
{
/// <summary>
/// 矩阵执行摘要写入器,负责将矩阵执行结果序列化为 matrix_summary.json 文件。
/// Matrix execution summary writer, responsible for serializing matrix execution results to matrix_summary.json.
/// </summary>
public class MatrixSummaryWriter
{
private readonly ILoggerService _logger;
private static readonly JsonSerializerOptions SummaryJsonOptions = new()
{
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
Converters = { new JsonStringEnumConverter() }
};
public MatrixSummaryWriter(ILoggerService logger)
{
ArgumentNullException.ThrowIfNull(logger);
_logger = logger.ForModule<MatrixSummaryWriter>();
}
/// <summary>
/// 将矩阵执行摘要写入 JSON 文件。写入失败时记录错误日志,不重新抛出异常。
/// Write matrix execution summary to JSON file. Logs error on failure without rethrowing.
/// </summary>
/// <param name="layout">矩阵布局 | Matrix layout</param>
/// <param name="templateProgramName">模板程序名称 | Template program name</param>
/// <param name="startTime">执行开始时间 | Execution start time</param>
/// <param name="cellEntries">各单元格摘要条目 | Cell summary entries</param>
/// <param name="outputDirectory">输出目录 | Output directory</param>
/// <param name="ct">取消令牌 | Cancellation token</param>
public async Task WriteAsync(
MatrixLayout layout,
string templateProgramName,
DateTime startTime,
IReadOnlyList<MatrixCellSummaryEntry> cellEntries,
string outputDirectory,
CancellationToken ct = default)
{
try
{
var duration = DateTime.UtcNow - startTime;
var summaryFile = new MatrixSummaryFile
{
Config = new MatrixSummaryConfig
{
Rows = layout.Rows,
Columns = layout.Columns,
RowSpacing = layout.RowSpacing,
ColumnSpacing = layout.ColumnSpacing
},
ProgramName = templateProgramName,
StartTime = startTime.ToString("o"),
DurationSeconds = Math.Round(duration.TotalSeconds, 2),
TotalCells = layout.Rows * layout.Columns,
EnabledCells = layout.Cells.Count(c => c.IsEnabled),
CompletedCells = cellEntries.Count(e => e.Status == nameof(MatrixCellStatus.Completed)),
FailedCells = cellEntries.Count(e => e.Status == nameof(MatrixCellStatus.Error)),
Cells = cellEntries.ToList()
};
var json = JsonSerializer.Serialize(summaryFile, SummaryJsonOptions);
var filePath = Path.Combine(outputDirectory, "matrix_summary.json");
Directory.CreateDirectory(outputDirectory);
await File.WriteAllTextAsync(filePath, json, ct).ConfigureAwait(false);
_logger.Info("已写入矩阵执行摘要 | Matrix summary written: {FilePath}", filePath);
}
catch (Exception ex)
{
_logger.Error(ex, "写入矩阵执行摘要失败 | Failed to write matrix summary to {OutputDirectory}", outputDirectory);
// 不重新抛出,确保不影响已完成的单元格检测结果归档
}
}
}
}
@@ -0,0 +1,13 @@
namespace XplorePlane.Services.Permission
{
/// <summary>
/// 工厂默认密码(编译时常量)。
/// 当密码文件缺失或损坏时,系统回退到这些默认值。
/// </summary>
internal static class DefaultPasswords
{
public const string SuperAdmin = "xpadmin";
public const string Admin = "xpuser";
public const string User = "1234";
}
}
@@ -0,0 +1,50 @@
using System;
using XplorePlane.Models;
using PermissionEnum = XplorePlane.Models.Permission;
namespace XplorePlane.Services.Permission
{
/// <summary>
/// 权限管理服务接口。单例注册,管理用户认证、角色状态和权限校验。
/// </summary>
public interface IPermissionService
{
/// <summary>当前登录角色。未认证时为 null。</summary>
UserRole? CurrentRole { get; }
/// <summary>角色变更事件(供不使用 EventAggregator 的组件直接订阅)。</summary>
event EventHandler<RoleChangedEventArgs> RoleChanged;
/// <summary>
/// 验证密码并设置当前角色。
/// </summary>
/// <param name="password">用户输入的密码。</param>
/// <returns>认证结果,包含是否成功和匹配的角色。</returns>
AuthenticationResult Authenticate(string password);
/// <summary>
/// 检查当前角色是否拥有指定权限。
/// </summary>
bool HasPermission(PermissionEnum permission);
/// <summary>
/// 检查指定角色是否拥有指定权限(用于测试和预判)。
/// </summary>
bool HasPermission(UserRole role, PermissionEnum permission);
/// <summary>
/// 修改指定角色的密码。仅 Super_Admin 可调用。
/// </summary>
PasswordChangeResult ChangePassword(UserRole targetRole, string newPassword);
/// <summary>
/// 当前是否允许切换用户(CNC 执行中不允许)。
/// </summary>
bool CanSwitchUser { get; }
/// <summary>
/// 注销当前用户(切换用户前调用)。
/// </summary>
void Logout();
}
}
@@ -0,0 +1,72 @@
using System.Collections.Generic;
using XplorePlane.Models;
namespace XplorePlane.Services.Permission
{
/// <summary>
/// 角色-权限静态映射表。层级关系:SuperAdmin ⊇ Admin ⊇ User。
/// </summary>
internal static class PermissionMatrix
{
/// <summary>
/// 每个角色独有的权限集合(不含继承)。
/// 层级检查通过 HasPermission 方法从当前角色向下累积实现。
/// </summary>
private static readonly Dictionary<UserRole, HashSet<Models.Permission>> RolePermissions = new()
{
[UserRole.User] = new HashSet<Models.Permission>
{
Models.Permission.CncViewProgram,
Models.Permission.CncRunProgram,
Models.Permission.CncStopProgram,
Models.Permission.ViewInspectionResults,
Models.Permission.SwitchUser
},
[UserRole.Admin] = new HashSet<Models.Permission>
{
// 继承 User 所有权限 +
Models.Permission.CncInsertNode,
Models.Permission.CncDeleteNode,
Models.Permission.CncRenameNode,
Models.Permission.CncReorderNode,
Models.Permission.CncNewProgram,
Models.Permission.CncSaveProgram,
Models.Permission.CncDeleteProgramFile,
Models.Permission.InspectionAddOperator,
Models.Permission.InspectionRemoveOperator,
Models.Permission.InspectionReorderOperator,
Models.Permission.InspectionToggleOperator,
Models.Permission.InspectionEditParameters,
Models.Permission.AccessReportSettings
},
[UserRole.SuperAdmin] = new HashSet<Models.Permission>
{
// 继承 Admin 所有权限 +
Models.Permission.AccessPlcSettings,
Models.Permission.AccessHardwareSettings,
Models.Permission.AccessMotionControlSettings,
Models.Permission.AccessDetectorSettings,
Models.Permission.AccessRaySourceSettings,
Models.Permission.ManagePasswords
}
};
/// <summary>
/// 检查指定角色是否拥有指定权限(含层级继承)。
/// 从当前角色向下(User 方向)逐级检查,任一级别包含该权限即返回 true。
/// </summary>
/// <param name="role">要检查的角色。</param>
/// <param name="permission">要检查的权限。</param>
/// <returns>如果角色拥有该权限则返回 true,否则返回 false。</returns>
public static bool HasPermission(UserRole role, Models.Permission permission)
{
// 层级检查:从当前角色向下(User)累积权限
for (var r = role; r <= UserRole.User; r++)
{
if (RolePermissions.TryGetValue(r, out var perms) && perms.Contains(permission))
return true;
}
return false;
}
}
}
@@ -0,0 +1,276 @@
using System;
using System.Collections.Generic;
using System.IO;
using Newtonsoft.Json;
using Prism.Events;
using XP.Common.Logging.Interfaces;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using PermissionEnum = XplorePlane.Models.Permission;
namespace XplorePlane.Services.Permission
{
/// <summary>
/// 权限管理服务实现。单例注册,管理用户认证、角色状态和权限校验。
/// </summary>
public class PermissionService : IPermissionService
{
private readonly IEventAggregator _eventAggregator;
private readonly IAppStateService _appStateService;
private readonly ILoggerService _logger;
private Dictionary<string, UserRole> _passwordToRoleMap;
/// <summary>密码文件路径。</summary>
private static readonly string PasswordFilePath = Path.Combine(
AppDomain.CurrentDomain.BaseDirectory, "passwords.json");
/// <inheritdoc />
public UserRole? CurrentRole { get; private set; }
/// <inheritdoc />
public event EventHandler<RoleChangedEventArgs> RoleChanged;
/// <inheritdoc />
public bool CanSwitchUser
{
get
{
var systemState = _appStateService.SystemState;
return systemState.OperationMode == OperationMode.Idle;
}
}
public PermissionService(
IEventAggregator eventAggregator,
IAppStateService appStateService,
ILoggerService logger)
{
ArgumentNullException.ThrowIfNull(eventAggregator);
ArgumentNullException.ThrowIfNull(appStateService);
ArgumentNullException.ThrowIfNull(logger);
_eventAggregator = eventAggregator;
_appStateService = appStateService;
_logger = logger.ForModule<PermissionService>();
LoadPasswords();
_logger.Info("PermissionService 已初始化");
}
/// <inheritdoc />
public AuthenticationResult Authenticate(string password)
{
if (string.IsNullOrEmpty(password))
{
_logger.Warn("认证失败:密码为空");
return new AuthenticationResult(false, null, "密码不能为空");
}
if (_passwordToRoleMap.TryGetValue(password, out var role))
{
var oldRole = CurrentRole;
CurrentRole = role;
_logger.Info("用户以 {Role} 角色登录", role);
// 发布 Prism EventAggregator 事件
_eventAggregator.GetEvent<RoleChangedEvent>()
.Publish(new RoleChangedPayload(oldRole, role));
// 发布直接事件
RoleChanged?.Invoke(this, new RoleChangedEventArgs(oldRole, role));
return new AuthenticationResult(true, role);
}
_logger.Warn("认证失败,密码不匹配");
return new AuthenticationResult(false, null, "认证失败");
}
/// <inheritdoc />
public bool HasPermission(PermissionEnum permission)
{
if (CurrentRole == null)
{
_logger.Warn("权限拒绝: 未认证用户尝试访问 Permission={Permission}", permission);
return false;
}
var result = PermissionMatrix.HasPermission(CurrentRole.Value, permission);
if (!result)
{
_logger.Warn("权限拒绝: Role={Role}, Permission={Permission}", CurrentRole.Value, permission);
}
return result;
}
/// <inheritdoc />
public bool HasPermission(UserRole role, PermissionEnum permission)
{
return PermissionMatrix.HasPermission(role, permission);
}
/// <inheritdoc />
public PasswordChangeResult ChangePassword(UserRole targetRole, string newPassword)
{
// 权限校验:仅 SuperAdmin 可修改密码
if (CurrentRole != UserRole.SuperAdmin)
{
_logger.Warn("密码修改被拒绝: 当前角色 {Role} 无权修改密码", CurrentRole);
return new PasswordChangeResult(false, "仅超级管理员可修改密码");
}
// 密码长度校验
if (string.IsNullOrEmpty(newPassword) || newPassword.Length < 4 || newPassword.Length > 32)
{
return new PasswordChangeResult(false, "密码长度必须在 4-32 个字符之间");
}
// 更新内存中的密码映射
// 先移除旧密码映射
string oldPassword = null;
foreach (var kvp in _passwordToRoleMap)
{
if (kvp.Value == targetRole)
{
oldPassword = kvp.Key;
break;
}
}
if (oldPassword != null)
{
_passwordToRoleMap.Remove(oldPassword);
}
_passwordToRoleMap[newPassword] = targetRole;
// 持久化到文件
if (!SavePasswords())
{
// 回滚内存状态
_passwordToRoleMap.Remove(newPassword);
if (oldPassword != null)
{
_passwordToRoleMap[oldPassword] = targetRole;
}
return new PasswordChangeResult(false, "密码文件写入失败");
}
_logger.Info("角色 {Role} 密码已更新", targetRole);
return new PasswordChangeResult(true);
}
/// <inheritdoc />
public void Logout()
{
var oldRole = CurrentRole;
CurrentRole = null;
if (oldRole != null)
{
_logger.Info("用户已注销,原角色: {Role}", oldRole);
}
}
/// <summary>
/// 从密码文件加载密码,失败时回退到工厂默认密码。
/// </summary>
private void LoadPasswords()
{
_passwordToRoleMap = new Dictionary<string, UserRole>();
try
{
if (File.Exists(PasswordFilePath))
{
var json = File.ReadAllText(PasswordFilePath);
var storage = JsonConvert.DeserializeObject<PasswordStorageModel>(json);
if (storage?.Passwords != null && storage.Passwords.Count > 0)
{
foreach (var kvp in storage.Passwords)
{
if (Enum.TryParse<UserRole>(kvp.Key, out var role))
{
_passwordToRoleMap[kvp.Value] = role;
}
}
// 验证三个角色都有密码映射
if (_passwordToRoleMap.Count >= 3)
{
_logger.Info("密码文件加载成功: {Path}", PasswordFilePath);
return;
}
_logger.Warn("密码文件不完整,使用默认密码");
}
else
{
_logger.Warn("密码文件内容无效,使用默认密码");
}
}
else
{
_logger.Info("密码文件不存在,使用默认密码: {Path}", PasswordFilePath);
}
}
catch (Exception ex)
{
_logger.Warn("密码文件读取失败,使用默认密码: {Exception}", ex.Message);
}
// 回退到工厂默认密码
LoadDefaultPasswords();
}
/// <summary>
/// 加载工厂默认密码。
/// </summary>
private void LoadDefaultPasswords()
{
_passwordToRoleMap = new Dictionary<string, UserRole>
{
[DefaultPasswords.SuperAdmin] = UserRole.SuperAdmin,
[DefaultPasswords.Admin] = UserRole.Admin,
[DefaultPasswords.User] = UserRole.User
};
}
/// <summary>
/// 将当前密码保存到文件。
/// </summary>
/// <returns>是否保存成功。</returns>
private bool SavePasswords()
{
try
{
var passwords = new Dictionary<string, string>();
foreach (var kvp in _passwordToRoleMap)
{
passwords[kvp.Value.ToString()] = kvp.Key;
}
var storage = new PasswordStorageModel
{
Version = 1,
Passwords = passwords,
LastModified = DateTime.Now
};
var json = JsonConvert.SerializeObject(storage, Formatting.Indented);
File.WriteAllText(PasswordFilePath, json);
return true;
}
catch (Exception ex)
{
_logger.Error(ex, "密码文件写入失败: {Path}", PasswordFilePath);
return false;
}
}
}
}
@@ -5,14 +5,19 @@ 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.Permission;
using XplorePlane.Services.Storage;
using PermissionEnum = XplorePlane.Models.Permission;
namespace XplorePlane.Services
{
public class PipelinePersistenceService : IPipelinePersistenceService
{
private readonly IImageProcessingService _imageProcessingService;
private readonly IPermissionService _permissionService;
private readonly ILoggerService _logger;
private readonly string _baseDirectory;
private static readonly JsonSerializerOptions JsonOptions = new()
@@ -23,21 +28,38 @@ namespace XplorePlane.Services
Converters = { new JsonStringEnumConverter() }
};
public PipelinePersistenceService(IImageProcessingService imageProcessingService, string baseDirectory = null)
public PipelinePersistenceService(
IImageProcessingService imageProcessingService,
IPermissionService permissionService,
ILoggerService logger,
string baseDirectory = null)
{
_imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService));
_permissionService = permissionService ?? throw new ArgumentNullException(nameof(permissionService));
_logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule<PipelinePersistenceService>();
_baseDirectory = baseDirectory ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"XplorePlane", "Pipelines");
}
public PipelinePersistenceService(IImageProcessingService imageProcessingService, IXpDataPathService dataPathService)
: this(imageProcessingService, dataPathService?.ToolsPath)
public PipelinePersistenceService(
IImageProcessingService imageProcessingService,
IPermissionService permissionService,
ILoggerService logger,
IXpDataPathService dataPathService)
: this(imageProcessingService, permissionService, logger, dataPathService?.ToolsPath)
{
}
public async Task SaveAsync(PipelineModel pipeline, string filePath)
{
if (!_permissionService.HasPermission(PermissionEnum.InspectionEditParameters))
{
_logger.Warn("权限拒绝: 尝试执行 SaveAsync (Pipeline),当前角色无权限 | Permission denied: Pipeline SaveAsync, Role={Role}",
_permissionService.CurrentRole);
return;
}
if (pipeline == null) throw new ArgumentNullException(nameof(pipeline));
ValidatePath(filePath);
@@ -17,7 +17,9 @@ using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.Services.Cnc;
using XplorePlane.Services;
using XplorePlane.Services.Permission;
using XplorePlane.Services.Storage;
using PermissionEnum = XplorePlane.Models.Permission;
namespace XplorePlane.ViewModels.Cnc
{
@@ -33,6 +35,7 @@ namespace XplorePlane.ViewModels.Cnc
private readonly IXpDataPathService _dataPathService;
private readonly IPipelinePersistenceService _pipelinePersistenceService;
private readonly IImageProcessingService _imageProcessingService;
private readonly IPermissionService _permissionService;
private CncProgram _currentProgram;
private ObservableCollection<CncNodeViewModel> _nodes;
@@ -59,6 +62,7 @@ namespace XplorePlane.ViewModels.Cnc
ICncExecutionService cncExecutionService,
IXpDataPathService dataPathService,
IPipelinePersistenceService pipelinePersistenceService,
IPermissionService permissionService,
IImageProcessingService imageProcessingService = null)
{
_cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService));
@@ -68,6 +72,7 @@ namespace XplorePlane.ViewModels.Cnc
_cncExecutionService = cncExecutionService ?? throw new ArgumentNullException(nameof(cncExecutionService));
_dataPathService = dataPathService ?? throw new ArgumentNullException(nameof(dataPathService));
_pipelinePersistenceService = pipelinePersistenceService ?? throw new ArgumentNullException(nameof(pipelinePersistenceService));
_permissionService = permissionService ?? throw new ArgumentNullException(nameof(permissionService));
_imageProcessingService = imageProcessingService; // optional — used for pipeline step display names
_nodes = new ObservableCollection<CncNodeViewModel>();
@@ -77,15 +82,15 @@ namespace XplorePlane.ViewModels.Cnc
new CncProgramTreeRootViewModel(_programDisplayName, _treeNodes)
};
InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint), () => !IsRunning);
InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage), () => !IsRunning);
InsertSaveNodeCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNode), () => !IsRunning);
InsertSavePositionCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SavePosition), () => !IsRunning);
InsertInspectionModuleCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionModule), () => !IsRunning);
InsertInspectionMarkerCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionMarker), () => !IsRunning);
InsertPauseDialogCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.PauseDialog), () => !IsRunning);
InsertWaitDelayCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.WaitDelay), () => !IsRunning);
InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram), () => !IsRunning);
InsertReferencePointCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.ReferencePoint), () => !IsRunning && CanEditCncProgram);
InsertSaveNodeWithImageCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNodeWithImage), () => !IsRunning && CanEditCncProgram);
InsertSaveNodeCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SaveNode), () => !IsRunning && CanEditCncProgram);
InsertSavePositionCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.SavePosition), () => !IsRunning && CanEditCncProgram);
InsertInspectionModuleCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionModule), () => !IsRunning && CanEditCncProgram);
InsertInspectionMarkerCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.InspectionMarker), () => !IsRunning && CanEditCncProgram);
InsertPauseDialogCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.PauseDialog), () => !IsRunning && CanEditCncProgram);
InsertWaitDelayCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.WaitDelay), () => !IsRunning && CanEditCncProgram);
InsertCompleteProgramCommand = new DelegateCommand(() => ExecuteInsertNode(CncNodeType.CompleteProgram), () => !IsRunning && CanEditCncProgram);
DeleteNodeCommand = new DelegateCommand(ExecuteDeleteNode, CanExecuteDeleteNode)
.ObservesProperty(() => SelectedNode);
@@ -94,14 +99,17 @@ namespace XplorePlane.ViewModels.Cnc
PrepareInsertAboveCommand = new DelegateCommand<CncNodeViewModel>(nodeVm => SetPendingInsertAnchor(nodeVm, insertAfter: false));
PrepareInsertBelowCommand = new DelegateCommand<CncNodeViewModel>(nodeVm => SetPendingInsertAnchor(nodeVm, insertAfter: true));
SaveProgramCommand = new DelegateCommand(async () => await ExecuteSaveProgramAsync());
SaveProgramCommand = new DelegateCommand(async () => await ExecuteSaveProgramAsync(), () => CanEditCncProgram);
LoadProgramCommand = new DelegateCommand(async () => await ExecuteLoadProgramAsync());
NewProgramCommand = new DelegateCommand(ExecuteNewProgram);
NewProgramCommand = new DelegateCommand(ExecuteNewProgram, () => CanEditCncProgram);
ExportCsvCommand = new DelegateCommand(ExecuteExportCsv);
RunCncCommand = new DelegateCommand(async () => await ExecuteRunAsync(), CanExecuteRun);
StopCncCommand = new DelegateCommand(ExecuteStop, CanExecuteStop);
// Subscribe to role changes to refresh permission-dependent properties
_eventAggregator.GetEvent<RoleChangedEvent>().Subscribe(OnRoleChanged);
_logger.Info("CncEditorViewModel initialized");
}
@@ -191,6 +199,12 @@ namespace XplorePlane.ViewModels.Cnc
private set => SetProperty(ref _hasExecutionError, value);
}
/// <summary>当前角色是否允许编辑 CNC 程序(插入、删除、重命名、重排序、新建、保存、删除文件)。</summary>
public bool CanEditCncProgram => _permissionService.HasPermission(PermissionEnum.CncInsertNode);
/// <summary>当前角色是否允许编辑检测模块(添加、删除、重排序、启用/禁用、编辑参数)。</summary>
public bool CanEditInspectionModule => _permissionService.HasPermission(PermissionEnum.InspectionAddOperator);
public DelegateCommand InsertReferencePointCommand { get; }
public DelegateCommand InsertSaveNodeWithImageCommand { get; }
public DelegateCommand InsertSaveNodeCommand { get; }
@@ -274,6 +288,7 @@ namespace XplorePlane.ViewModels.Cnc
private bool CanExecuteDeleteNode()
{
return !IsRunning
&& CanEditCncProgram
&& SelectedNode != null
&& _currentProgram != null
&& _currentProgram.Nodes.Count > 1;
@@ -281,7 +296,7 @@ namespace XplorePlane.ViewModels.Cnc
private void ExecuteMoveNodeUp(CncNodeViewModel nodeVm)
{
if (IsRunning || _currentProgram == null || nodeVm == null || nodeVm.Index <= 0)
if (IsRunning || !CanEditCncProgram || _currentProgram == null || nodeVm == null || nodeVm.Index <= 0)
return;
try
@@ -305,7 +320,7 @@ namespace XplorePlane.ViewModels.Cnc
private void ExecuteMoveNodeDown(CncNodeViewModel nodeVm)
{
if (IsRunning || _currentProgram == null || nodeVm == null || nodeVm.Index >= _currentProgram.Nodes.Count - 1)
if (IsRunning || !CanEditCncProgram || _currentProgram == null || nodeVm == null || nodeVm.Index >= _currentProgram.Nodes.Count - 1)
return;
try
@@ -615,10 +630,19 @@ namespace XplorePlane.ViewModels.Cnc
DeleteNodeCommand.RaiseCanExecuteChanged();
MoveNodeUpCommand.RaiseCanExecuteChanged();
MoveNodeDownCommand.RaiseCanExecuteChanged();
SaveProgramCommand.RaiseCanExecuteChanged();
NewProgramCommand.RaiseCanExecuteChanged();
RunCncCommand.RaiseCanExecuteChanged();
StopCncCommand.RaiseCanExecuteChanged();
}
private void OnRoleChanged(RoleChangedPayload payload)
{
RaisePropertyChanged(nameof(CanEditCncProgram));
RaisePropertyChanged(nameof(CanEditInspectionModule));
RaiseEditCommandsCanExecuteChanged();
}
private void OnProgramEdited()
{
IsModified = true;
@@ -17,8 +17,10 @@ using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services;
using XplorePlane.Services.MainViewport;
using XplorePlane.Services.Permission;
using XP.Common.Logging.Interfaces;
using Prism.Events;
using PermissionEnum = XplorePlane.Models.Permission;
namespace XplorePlane.ViewModels.Cnc
{
@@ -30,6 +32,7 @@ namespace XplorePlane.ViewModels.Cnc
private readonly IPipelineExecutionService _executionService;
private readonly IMainViewportService _mainViewportService;
private readonly IEventAggregator _eventAggregator;
private readonly IPermissionService _permissionService;
private readonly ILoggerService _logger;
private CncNodeViewModel _activeModuleNode;
@@ -50,7 +53,8 @@ namespace XplorePlane.ViewModels.Cnc
ILoggerService logger,
IPipelineExecutionService executionService = null,
IMainViewportService mainViewportService = null,
IEventAggregator eventAggregator = null)
IEventAggregator eventAggregator = null,
IPermissionService permissionService = null)
{
_editorViewModel = editorViewModel ?? throw new ArgumentNullException(nameof(editorViewModel));
_imageProcessingService = imageProcessingService ?? throw new ArgumentNullException(nameof(imageProcessingService));
@@ -59,6 +63,7 @@ namespace XplorePlane.ViewModels.Cnc
_executionService = executionService;
_mainViewportService = mainViewportService;
_eventAggregator = eventAggregator;
_permissionService = permissionService;
PipelineNodes = new ObservableCollection<PipelineNodeViewModel>();
@@ -84,6 +89,9 @@ namespace XplorePlane.ViewModels.Cnc
_eventAggregator?.GetEvent<AddOperatorRequestedEvent>()
.Subscribe(key => { if (CanAddOperator(key)) AddOperator(key); });
_eventAggregator?.GetEvent<RoleChangedEvent>()
.Subscribe(OnRoleChanged, ThreadOption.UIThread);
}
public ObservableCollection<PipelineNodeViewModel> PipelineNodes { get; }
@@ -150,6 +158,12 @@ namespace XplorePlane.ViewModels.Cnc
public Visibility EmptyStateVisibility => HasActiveModule ? Visibility.Collapsed : Visibility.Visible;
/// <summary>
/// 当前角色是否有权编辑检测模块流水线(添加/删除/排序/启停算子、编辑参数)。
/// </summary>
public bool CanEditPipeline =>
_permissionService?.HasPermission(PermissionEnum.InspectionAddOperator) ?? true;
public ICommand AddOperatorCommand { get; }
public ICommand RemoveOperatorCommand { get; }
@@ -573,11 +587,38 @@ namespace XplorePlane.ViewModels.Cnc
OperatorKey = node.OperatorKey,
Order = index,
IsEnabled = node.IsEnabled,
Parameters = node.Parameters.ToDictionary(parameter => parameter.Name, parameter => parameter.Value)
Parameters = BuildNodeParameters(node)
}).ToList()
};
}
/// <summary>
/// 构建节点参数字典,过滤掉超出 PolyCount 的多余坐标参数。
/// 只保存实际有效的 PolyX/Y 值,避免写入大量无意义的 0。
/// </summary>
private static Dictionary<string, object> BuildNodeParameters(PipelineNodeViewModel node)
{
// 获取实际多边形点数
var polyCountParam = node.Parameters.FirstOrDefault(p => p.Name == "PolyCount");
int polyCount = 0;
if (polyCountParam?.Value != null)
int.TryParse(polyCountParam.Value.ToString(), out polyCount);
var result = new Dictionary<string, object>();
foreach (var param in node.Parameters)
{
// 过滤超出实际点数的 PolyX/Y 参数
if (param.Name.StartsWith("PolyX") || param.Name.StartsWith("PolyY"))
{
var indexStr = param.Name.StartsWith("PolyX") ? param.Name[5..] : param.Name[5..];
if (int.TryParse(indexStr, out int idx) && idx >= polyCount)
continue; // 跳过超出实际点数的坐标
}
result[param.Name] = param.Value;
}
return result;
}
private IEnumerable<PipelineNodeViewModel> GetNodesInExecutionScope()
{
var orderedNodes = PipelineNodes.OrderBy(node => node.Order);
@@ -659,6 +700,11 @@ namespace XplorePlane.ViewModels.Cnc
(AddOperatorCommand as DelegateCommand<string>)?.RaiseCanExecuteChanged();
}
private void OnRoleChanged(RoleChangedPayload payload)
{
RaisePropertyChanged(nameof(CanEditPipeline));
}
private static object ConvertSavedValue(object savedValue, Type targetType)
{
if (savedValue is not JsonElement jsonElement)
@@ -875,17 +921,27 @@ namespace XplorePlane.ViewModels.Cnc
polyCountParam.Value = count;
}
// 更新坐标(最多 32 个点)
for (int i = 0; i < 32; i++)
// 更新坐标:只写入实际点数,不写多余的 0 值
for (int i = 0; i < count; i++)
{
var px = node.Parameters.FirstOrDefault(p => p.Name == $"PolyX{i}");
var py = node.Parameters.FirstOrDefault(p => p.Name == $"PolyY{i}");
double x = (points != null && i < points.Count) ? points[i].X : 0;
double y = (points != null && i < points.Count) ? points[i].Y : 0;
double x = points[i].X;
double y = points[i].Y;
if (px != null) px.Value = (int)x;
if (py != null) py.Value = (int)y;
}
// 清除超出实际点数的旧坐标值(防止残留)
for (int i = count; i < 32; i++)
{
var px = node.Parameters.FirstOrDefault(p => p.Name == $"PolyX{i}");
var py = node.Parameters.FirstOrDefault(p => p.Name == $"PolyY{i}");
if (px == null && py == null) break; // 参数不存在则停止
if (px != null) px.Value = 0;
if (py != null) py.Value = 0;
}
// 写入后验证
var verifyRoiMode = node.Parameters.FirstOrDefault(p => p.Name == "RoiMode")?.Value;
var verifyPolyCount = node.Parameters.FirstOrDefault(p => p.Name == "PolyCount")?.Value;
@@ -1,10 +1,14 @@
using Microsoft.Win32;
using Prism.Commands;
using Prism.Events;
using Prism.Mvvm;
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.Cnc;
@@ -20,12 +24,16 @@ namespace XplorePlane.ViewModels.Cnc
{
private readonly IMatrixService _matrixService;
private readonly ICncProgramService _cncProgramService;
private readonly IMatrixOrchestrationService _orchestrationService;
private readonly IEventAggregator _eventAggregator;
private readonly ILoggerService _logger;
// 当前矩阵布局 | Current matrix layout
private MatrixLayout _currentLayout;
// 执行取消令牌源 | Execution cancellation token source
private CancellationTokenSource _executionCts;
private int _rows = 1;
private int _columns = 1;
private double _rowSpacing;
@@ -34,29 +42,51 @@ namespace XplorePlane.ViewModels.Cnc
private MatrixCellViewModel _selectedCell;
private string _associatedProgramPath;
// ── 验证错误属性 | Validation error properties ──
private string _rowsError;
private string _columnsError;
private string _rowSpacingError;
private string _colSpacingError;
private bool _canUpdateLayout = true;
// ── 显示与状态属性 | Display and state properties ──
private string _associatedProgramName;
private int _enabledCount;
private int _totalCount;
private bool _isExecuting;
private string _statusText;
/// <summary>
/// 构造函数 | Constructor
/// </summary>
public MatrixEditorViewModel(
IMatrixService matrixService,
ICncProgramService cncProgramService,
IMatrixOrchestrationService orchestrationService,
IEventAggregator eventAggregator,
ILoggerService logger)
{
_matrixService = matrixService ?? throw new ArgumentNullException(nameof(matrixService));
_cncProgramService = cncProgramService ?? throw new ArgumentNullException(nameof(cncProgramService));
_orchestrationService = orchestrationService ?? throw new ArgumentNullException(nameof(orchestrationService));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_logger = (logger ?? throw new ArgumentNullException(nameof(logger))).ForModule<MatrixEditorViewModel>();
_cells = new ObservableCollection<MatrixCellViewModel>();
// ── 命令初始化 | Command initialization ──
UpdateLayoutCommand = new DelegateCommand(ExecuteUpdateLayout);
UpdateLayoutCommand = new DelegateCommand(ExecuteUpdateLayout, () => CanUpdateLayout)
.ObservesProperty(() => CanUpdateLayout);
ToggleCellCommand = new DelegateCommand<MatrixCellViewModel>(ExecuteToggleCell);
SelectCellCommand = new DelegateCommand<MatrixCellViewModel>(ExecuteSelectCell);
AssociateProgramCommand = new DelegateCommand(async () => await ExecuteAssociateProgramAsync());
SaveLayoutCommand = new DelegateCommand(async () => await ExecuteSaveLayoutAsync());
LoadLayoutCommand = new DelegateCommand(async () => await ExecuteLoadLayoutAsync());
RunMatrixCommand = new DelegateCommand(async () => await ExecuteRunMatrixAsync(), CanExecuteRunMatrix)
.ObservesProperty(() => IsExecuting)
.ObservesProperty(() => AssociatedProgramPath);
StopCommand = new DelegateCommand(ExecuteStop, () => IsExecuting)
.ObservesProperty(() => IsExecuting);
_logger.Info("MatrixEditorViewModel 已初始化 | MatrixEditorViewModel initialized");
}
@@ -109,7 +139,89 @@ namespace XplorePlane.ViewModels.Cnc
public string AssociatedProgramPath
{
get => _associatedProgramPath;
set => SetProperty(ref _associatedProgramPath, value);
set
{
if (SetProperty(ref _associatedProgramPath, value))
{
AssociatedProgramName = string.IsNullOrWhiteSpace(value)
? null
: Path.GetFileName(value);
}
}
}
// ── 验证错误属性 | Validation error properties ──────────────────
/// <summary>行数输入错误提示 | Rows input error message</summary>
public string RowsError
{
get => _rowsError;
set => SetProperty(ref _rowsError, value);
}
/// <summary>列数输入错误提示 | Columns input error message</summary>
public string ColumnsError
{
get => _columnsError;
set => SetProperty(ref _columnsError, value);
}
/// <summary>行间距输入错误提示 | Row spacing input error message</summary>
public string RowSpacingError
{
get => _rowSpacingError;
set => SetProperty(ref _rowSpacingError, value);
}
/// <summary>列间距输入错误提示 | Column spacing input error message</summary>
public string ColSpacingError
{
get => _colSpacingError;
set => SetProperty(ref _colSpacingError, value);
}
/// <summary>是否可以更新布局(输入合法时为 true| Whether layout can be updated (true when inputs are valid)</summary>
public bool CanUpdateLayout
{
get => _canUpdateLayout;
set => SetProperty(ref _canUpdateLayout, value);
}
// ── 显示与状态属性 | Display and state properties ────────────────
/// <summary>关联程序的文件名(不含路径)| Associated program filename (without path)</summary>
public string AssociatedProgramName
{
get => _associatedProgramName;
set => SetProperty(ref _associatedProgramName, value);
}
/// <summary>已启用的单元格数量 | Number of enabled cells</summary>
public int EnabledCount
{
get => _enabledCount;
set => SetProperty(ref _enabledCount, value);
}
/// <summary>单元格总数 | Total number of cells</summary>
public int TotalCount
{
get => _totalCount;
set => SetProperty(ref _totalCount, value);
}
/// <summary>是否正在执行矩阵 | Whether matrix execution is in progress</summary>
public bool IsExecuting
{
get => _isExecuting;
set => SetProperty(ref _isExecuting, value);
}
/// <summary>底部状态栏文本 | Bottom status bar text</summary>
public string StatusText
{
get => _statusText;
set => SetProperty(ref _statusText, value);
}
// ── 命令 | Commands ────────────────────────────────────────────
@@ -132,6 +244,12 @@ namespace XplorePlane.ViewModels.Cnc
/// <summary>加载矩阵布局命令 | Load matrix layout command</summary>
public DelegateCommand LoadLayoutCommand { get; }
/// <summary>运行矩阵执行命令 | Run matrix execution command</summary>
public DelegateCommand RunMatrixCommand { get; }
/// <summary>停止执行命令 | Stop execution command</summary>
public DelegateCommand StopCommand { get; }
// ── 命令执行方法 | Command execution methods ────────────────────
/// <summary>
@@ -140,6 +258,13 @@ namespace XplorePlane.ViewModels.Cnc
/// </summary>
private void ExecuteUpdateLayout()
{
// 清除之前的错误 | Clear previous errors
RowsError = null;
ColumnsError = null;
RowSpacingError = null;
ColSpacingError = null;
CanUpdateLayout = true;
try
{
if (_currentLayout == null)
@@ -154,12 +279,56 @@ namespace XplorePlane.ViewModels.Cnc
RefreshCells();
_logger.Info("已更新矩阵布局 | Updated matrix layout: Rows={Rows}, Columns={Columns}", Rows, Columns);
}
catch (ArgumentOutOfRangeException ex)
{
HandleValidationError(ex);
_logger.Error(ex, "更新矩阵布局失败 | Failed to update matrix layout");
}
catch (Exception ex)
{
_logger.Error(ex, "更新矩阵布局失败 | Failed to update matrix layout");
}
}
/// <summary>
/// 处理参数验证异常,设置对应的错误属性
/// Handle parameter validation exception and set corresponding error properties
/// </summary>
private void HandleValidationError(ArgumentOutOfRangeException ex)
{
CanUpdateLayout = false;
var paramName = ex.ParamName?.ToLowerInvariant() ?? string.Empty;
if (paramName.Contains("row") && paramName.Contains("spacing"))
{
RowSpacingError = "间距不能为负数";
}
else if (paramName.Contains("col") && paramName.Contains("spacing"))
{
ColSpacingError = "间距不能为负数";
}
else if (paramName.Contains("spacing"))
{
// Generic spacing error — set both
RowSpacingError = "间距不能为负数";
ColSpacingError = "间距不能为负数";
}
else if (paramName.Contains("row"))
{
RowsError = "行数/列数必须在 1 到 50 之间";
}
else if (paramName.Contains("col"))
{
ColumnsError = "行数/列数必须在 1 到 50 之间";
}
else
{
// Fallback: try to determine from message or set both
RowsError = "行数/列数必须在 1 到 50 之间";
ColumnsError = "行数/列数必须在 1 到 50 之间";
}
}
/// <summary>
/// 选中指定单元格,更新高亮状态
/// Select the specified cell and update highlight state
@@ -199,8 +368,8 @@ namespace XplorePlane.ViewModels.Cnc
}
/// <summary>
/// 关联 CNC 程序到当前矩阵布局(占位:从 AssociatedProgramPath 加载)
/// Associate a CNC program with the current layout (placeholder: loads from AssociatedProgramPath)
/// 关联 CNC 程序:使用 OpenFileDialog 选择 .xp 文件,验证文件有效性
/// Associate CNC program: use OpenFileDialog to select .xp file, validate file
/// </summary>
private async Task ExecuteAssociateProgramAsync()
{
@@ -210,26 +379,50 @@ namespace XplorePlane.ViewModels.Cnc
return;
}
if (string.IsNullOrWhiteSpace(AssociatedProgramPath))
var dialog = new OpenFileDialog
{
_logger.Warn("无法关联程序:程序路径为空 | Cannot associate program: program path is empty");
Title = "选择 CNC 模板程序",
Filter = "CNC 程序文件 (*.xp)|*.xp",
CheckFileExists = true
};
if (dialog.ShowDialog() != true)
return;
}
var filePath = dialog.FileName;
var fileName = Path.GetFileName(filePath);
try
{
var program = await _cncProgramService.LoadAsync(AssociatedProgramPath);
_currentLayout = _matrixService.AssociateCncProgram(_currentLayout, program);
_logger.Info("已关联 CNC 程序 | Associated CNC program: {ProgramPath}", AssociatedProgramPath);
if (!File.Exists(filePath))
{
MessageBox.Show($"无法加载程序文件:{fileName}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
var program = await _cncProgramService.LoadAsync(filePath);
// 验证程序包含至少一个 SavePositionNode
if (!program.Nodes.OfType<SavePositionNode>().Any())
{
MessageBox.Show("所选程序不包含位置节点,无法用作矩阵模板", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
_currentLayout = _matrixService.AssociateCncProgram(_currentLayout, program, filePath);
AssociatedProgramPath = filePath;
_logger.Info("已关联 CNC 程序 | Associated CNC program: {ProgramPath}", filePath);
}
catch (Exception ex)
{
_logger.Error(ex, "关联 CNC 程序失败 | Failed to associate CNC program");
MessageBox.Show($"无法加载程序文件:{fileName}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
_logger.Error(ex, "关联 CNC 程序失败 | Failed to associate CNC program: {FileName}", fileName);
}
}
/// <summary>
/// 保存当前矩阵布局到文件 | Save current matrix layout to file
/// 保存当前矩阵布局:使用 SaveFileDialog 选择保存路径
/// Save current matrix layout: use SaveFileDialog to select save path
/// </summary>
private async Task ExecuteSaveLayoutAsync()
{
@@ -239,12 +432,21 @@ namespace XplorePlane.ViewModels.Cnc
return;
}
var dialog = new SaveFileDialog
{
Title = "保存矩阵方案",
Filter = "JSON 文件 (*.json)|*.json",
DefaultExt = ".json",
FileName = $"matrix_{_currentLayout.Id}"
};
if (dialog.ShowDialog() != true)
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);
await _matrixService.SaveAsync(_currentLayout, dialog.FileName);
_logger.Info("矩阵布局已保存 | Matrix layout saved: {FilePath}", dialog.FileName);
}
catch (Exception ex)
{
@@ -253,15 +455,24 @@ namespace XplorePlane.ViewModels.Cnc
}
/// <summary>
/// 从文件加载矩阵布局 | Load matrix layout from file
/// 从文件加载矩阵布局:使用 OpenFileDialog 选择文件
/// Load matrix layout from file: use OpenFileDialog to select file
/// </summary>
private async Task ExecuteLoadLayoutAsync()
{
var dialog = new OpenFileDialog
{
Title = "加载矩阵方案",
Filter = "JSON 文件 (*.json)|*.json",
CheckFileExists = true
};
if (dialog.ShowDialog() != true)
return;
try
{
// 占位:实际应通过文件对话框选择路径 | Placeholder: should use file dialog to select path
var filePath = $"matrix_layout.json";
_currentLayout = await _matrixService.LoadAsync(filePath);
_currentLayout = await _matrixService.LoadAsync(dialog.FileName);
// 同步属性到 ViewModel | Sync properties to ViewModel
Rows = _currentLayout.Rows;
@@ -271,14 +482,132 @@ namespace XplorePlane.ViewModels.Cnc
AssociatedProgramPath = _currentLayout.CncProgramPath;
RefreshCells();
// 检查关联的 CNC 程序文件是否存在 | Check if associated CNC program file exists
if (!string.IsNullOrWhiteSpace(_currentLayout.CncProgramPath)
&& !File.Exists(_currentLayout.CncProgramPath))
{
MessageBox.Show("关联的 CNC 程序文件已移动或删除,请重新关联", "警告", MessageBoxButton.OK, MessageBoxImage.Warning);
}
_logger.Info("矩阵布局已加载 | Matrix layout loaded: Rows={Rows}, Columns={Columns}", Rows, Columns);
}
catch (InvalidDataException ex)
{
MessageBox.Show($"方案文件格式无效:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
_logger.Error(ex, "加载矩阵布局失败:格式无效 | Failed to load matrix layout: invalid format");
}
catch (Exception ex)
{
_logger.Error(ex, "加载矩阵布局失败 | Failed to load matrix layout");
}
}
/// <summary>
/// 判断 RunMatrixCommand 是否可执行
/// Determine if RunMatrixCommand can execute
/// </summary>
private bool CanExecuteRunMatrix()
{
return !IsExecuting && !string.IsNullOrWhiteSpace(AssociatedProgramPath);
}
/// <summary>
/// 执行矩阵运行:加载模板程序 → 重置状态 → 执行 → 更新 UI
/// Execute matrix run: load template → reset states → execute → update UI
/// </summary>
private async Task ExecuteRunMatrixAsync()
{
if (_currentLayout == null || string.IsNullOrWhiteSpace(AssociatedProgramPath))
return;
CncProgram templateProgram;
try
{
templateProgram = await _cncProgramService.LoadAsync(AssociatedProgramPath);
}
catch (Exception ex)
{
MessageBox.Show($"无法加载程序文件:{AssociatedProgramName}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
_logger.Error(ex, "运行矩阵失败:无法加载模板程序 | Run matrix failed: cannot load template program");
return;
}
IsExecuting = true;
// 重置所有单元格状态为 NotExecuted | Reset all cell statuses to NotExecuted
foreach (var cell in Cells)
{
cell.Status = MatrixCellStatus.NotExecuted;
cell.ErrorMessage = null;
}
StatusText = null;
_executionCts = new CancellationTokenSource();
var progress = new Progress<MatrixCellExecutionProgress>(p =>
{
Application.Current.Dispatcher.Invoke(() =>
{
// 更新对应单元格的状态 | Update the corresponding cell's status
var cellVm = Cells.FirstOrDefault(c => c.Row == p.Row && c.Column == p.Column);
if (cellVm != null)
{
cellVm.Status = p.Status;
if (!string.IsNullOrEmpty(p.ErrorMessage))
cellVm.ErrorMessage = p.ErrorMessage;
}
// 更新状态文本 | Update status text
if (p.Status == MatrixCellStatus.Executing)
{
StatusText = $"正在执行:{AssociatedProgramName} | 当前位置:R{p.Row}C{p.Column}{p.CurrentIndex}/{p.TotalEnabled}";
}
});
});
try
{
await _orchestrationService.ExecuteAsync(_currentLayout, templateProgram, progress, _executionCts.Token);
// 执行完成 | Execution completed
var completedCount = Cells.Count(c => c.Status == MatrixCellStatus.Completed);
var failedCount = Cells.Count(c => c.Status == MatrixCellStatus.Error);
var totalEnabled = Cells.Count(c => c.IsEnabled);
StatusText = $"执行完成:共 {totalEnabled} 个位置,{completedCount} 成功,{failedCount} 失败";
}
catch (OperationCanceledException)
{
// 用户停止或外部取消 | User stopped or external cancellation
var completedCount = Cells.Count(c => c.Status == MatrixCellStatus.Completed || c.Status == MatrixCellStatus.Error);
var notExecutedCount = Cells.Count(c => c.IsEnabled && c.Status == MatrixCellStatus.NotExecuted);
StatusText = $"执行已停止:已完成 {completedCount} 个位置,{notExecutedCount} 个位置未执行";
}
catch (InvalidOperationException ex)
{
MessageBox.Show(ex.Message, "错误", MessageBoxButton.OK, MessageBoxImage.Error);
_logger.Error(ex, "运行矩阵失败 | Run matrix failed");
}
catch (Exception ex)
{
_logger.Error(ex, "矩阵执行异常 | Matrix execution exception");
}
finally
{
IsExecuting = false;
_executionCts?.Dispose();
_executionCts = null;
}
}
/// <summary>
/// 停止矩阵执行 | Stop matrix execution
/// </summary>
private void ExecuteStop()
{
_executionCts?.Cancel();
}
// ── 辅助方法 | Helper methods ───────────────────────────────────
/// <summary>
@@ -290,13 +619,21 @@ namespace XplorePlane.ViewModels.Cnc
Cells.Clear();
if (_currentLayout?.Cells == null)
{
EnabledCount = 0;
TotalCount = 0;
return;
}
foreach (var cell in _currentLayout.Cells)
{
Cells.Add(new MatrixCellViewModel(cell));
}
// 更新统计 | Update statistics
TotalCount = Cells.Count;
EnabledCount = Cells.Count(c => c.IsEnabled);
// 尝试保持选中状态 | Try to preserve selection
if (SelectedCell != null)
{
@@ -305,4 +642,4 @@ namespace XplorePlane.ViewModels.Cnc
}
}
}
}
}
@@ -0,0 +1,107 @@
using System.Collections.Generic;
namespace XplorePlane.ViewModels.Debug
{
/// <summary>
/// 调试面板本地化映射:将状态类别、事件类型、字段名等英文标识翻译为中文显示名。
/// 显示用中文,内部匹配/过滤仍使用英文标识,二者解耦。
/// </summary>
internal static class DebugPanelLocalization
{
/// <summary>状态类别中文名(MotionState → 运动状态)</summary>
public static readonly IReadOnlyDictionary<string, string> Categories = new Dictionary<string, string>
{
["MotionState"] = "运动状态",
["RaySourceState"] = "射线源状态",
["DetectorState"] = "探测器状态",
["SystemState"] = "系统状态",
["CameraState"] = "相机状态",
["LinkedViewState"] = "联动视图状态",
["RecipeExecutionState"] = "配方执行状态",
["CalibrationMatrix"] = "标定矩阵"
};
/// <summary>事件类型中文名(MotionStateChanged → 运动状态变化)</summary>
public static readonly IReadOnlyDictionary<string, string> EventTypes = new Dictionary<string, string>
{
["MotionStateChanged"] = "运动状态变化",
["RaySourceStateChanged"] = "射线源状态变化",
["DetectorStateChanged"] = "探测器状态变化",
["SystemStateChanged"] = "系统状态变化",
["CameraStateChanged"] = "相机状态变化",
["LinkedViewStateChanged"] = "联动视图状态变化",
["RecipeExecutionStateChanged"] = "配方执行状态变化"
};
/// <summary>状态字段中文名(StageX → 载物台X位置 等)</summary>
public static readonly IReadOnlyDictionary<string, string> Fields = new Dictionary<string, string>
{
// MotionState
["StageX"] = "载物台X (μm)",
["StageY"] = "载物台Y (μm)",
["SourceZ"] = "射线源Z (μm)",
["DetectorZ"] = "探测器Z (μm)",
["DetectorSwing"] = "探测器摆角 (°)",
["FDD"] = "焦点-探测器距离 (μm)",
["StageXSpeed"] = "载物台X速度 (μm/s)",
["StageYSpeed"] = "载物台Y速度 (μm/s)",
["SourceZSpeed"] = "射线源Z速度 (μm/s)",
["DetectorZSpeed"] = "探测器Z速度 (μm/s)",
["DetectorSwingSpeed"] = "摆角速度 (°/s)",
["FDDSpeed"] = "FDD速度 (μm/s)",
["StageRotation"] = "载台旋转 (°)",
["FixtureRotation"] = "夹具旋转 (°)",
["FOD"] = "焦点-物体距离 (μm)",
["Magnification"] = "放大倍率",
["StageRotationSpeed"] = "载台旋转速度 (°/s)",
["FixtureRotationSpeed"] = "夹具旋转速度 (°/s)",
// RaySourceState
["IsOn"] = "是否开启",
["Voltage"] = "电压 (kV)",
["Power"] = "功率 (W)",
// DetectorState
["IsConnected"] = "是否已连接",
["IsAcquiring"] = "是否正在采集",
["FrameRate"] = "帧率 (fps)",
["Resolution"] = "分辨率",
// SystemState
["OperationMode"] = "操作模式",
["HasError"] = "是否存在错误",
["ErrorMessage"] = "错误信息",
// CameraState
["IsStreaming"] = "是否正在推流",
["CurrentFrame"] = "当前帧",
["Width"] = "图像宽度 (px)",
["Height"] = "图像高度 (px)",
// LinkedViewState
["TargetPosition"] = "目标物理位置",
["IsExecuting"] = "是否正在执行",
["LastRequestTime"] = "最近请求时间",
// CalibrationMatrix
["M11"] = "M11", ["M12"] = "M12", ["M13"] = "M13",
["M21"] = "M21", ["M22"] = "M22", ["M23"] = "M23",
["M31"] = "M31", ["M32"] = "M32", ["M33"] = "M33",
// 通用
["(No changes)"] = "(无变化)"
};
/// <summary>获取类别中文名,未匹配时返回原值</summary>
public static string Category(string key) =>
key != null && Categories.TryGetValue(key, out var v) ? v : key;
/// <summary>获取事件类型中文名,未匹配时返回原值</summary>
public static string EventType(string key) =>
key != null && EventTypes.TryGetValue(key, out var v) ? v : key;
/// <summary>获取字段中文名,未匹配时返回原值</summary>
public static string Field(string key) =>
key != null && Fields.TryGetValue(key, out var v) ? v : key;
}
}
@@ -278,7 +278,7 @@ namespace XplorePlane.ViewModels.Debug
{
try
{
var path = PromptSavePath("Export event log as JSON", "JSON files (*.json)|*.json");
var path = PromptSavePath("导出事件日志为 JSON", "JSON 文件 (*.json)|*.json");
if (string.IsNullOrWhiteSpace(path))
{
return;
@@ -292,12 +292,12 @@ namespace XplorePlane.ViewModels.Debug
};
File.WriteAllText(path, JsonSerializer.Serialize(payload, _jsonOptions), Encoding.UTF8);
MessageBox.Show("Event log exported successfully.", "Export", MessageBoxButton.OK, MessageBoxImage.Information);
MessageBox.Show("事件日志已成功导出。", "导出", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
_logger.Error(ex, "导出事件日志 JSON 失败 | Failed to export event log as JSON");
MessageBox.Show($"Failed to export JSON: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
MessageBox.Show($"导出 JSON 失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -305,7 +305,7 @@ namespace XplorePlane.ViewModels.Debug
{
try
{
var path = PromptSavePath("Export event log as CSV", "CSV files (*.csv)|*.csv");
var path = PromptSavePath("导出事件日志为 CSV", "CSV 文件 (*.csv)|*.csv");
if (string.IsNullOrWhiteSpace(path))
{
return;
@@ -328,12 +328,12 @@ namespace XplorePlane.ViewModels.Debug
}
File.WriteAllText(path, builder.ToString(), Encoding.UTF8);
MessageBox.Show("Event log exported successfully.", "Export", MessageBoxButton.OK, MessageBoxImage.Information);
MessageBox.Show("事件日志已成功导出。", "导出", MessageBoxButton.OK, MessageBoxImage.Information);
}
catch (Exception ex)
{
_logger.Error(ex, "导出事件日志 CSV 失败 | Failed to export event log as CSV");
MessageBox.Show($"Failed to export CSV: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
MessageBox.Show($"导出 CSV 失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -25,7 +25,6 @@ namespace XplorePlane.ViewModels.Debug
private bool _disposed;
public ObservableCollection<PerformanceMetric> Metrics { get; } = new();
public ObservableCollection<TrendDataPoint> TrendData { get; } = new();
public double MaxLatency
{
@@ -63,7 +62,6 @@ namespace XplorePlane.ViewModels.Debug
_timer.Tick += (_, _) =>
{
UpdateFrequencyMetrics();
UpdateTrendData();
};
}
@@ -95,7 +93,6 @@ namespace XplorePlane.ViewModels.Debug
_timer.Stop();
Metrics.Clear();
TrendData.Clear();
_disposed = true;
}
@@ -150,19 +147,5 @@ namespace XplorePlane.ViewModels.Debug
MaxLatency = Metrics.Count == 0 ? 0 : Metrics.Max(m => m.AverageLatency);
IsLatencyWarning = MaxLatency > 500;
}
private void UpdateTrendData()
{
TrendData.Add(new TrendDataPoint
{
Timestamp = DateTime.Now,
Value = Metrics.Sum(m => m.EventsPerSecond)
});
while (TrendData.Count > 60)
{
TrendData.RemoveAt(0);
}
}
}
}
@@ -141,7 +141,7 @@ namespace XplorePlane.ViewModels.Debug
var selected = Snapshots.Where(s => s.IsSelected).Take(2).ToList();
if (selected.Count != 2)
{
MessageBox.Show("Please select exactly two snapshots to compare.", "Compare Snapshots", MessageBoxButton.OK, MessageBoxImage.Information);
MessageBox.Show("请勾选恰好两个快照进行对比。", "快照对比", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
@@ -159,7 +159,7 @@ namespace XplorePlane.ViewModels.Debug
catch (Exception ex)
{
_logger.Error(ex, "比较快照失败 | Failed to compare snapshots");
MessageBox.Show($"Failed to compare snapshots: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
MessageBox.Show($"对比快照失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -169,14 +169,14 @@ namespace XplorePlane.ViewModels.Debug
{
if (SelectedSnapshot?.Snapshot == null)
{
MessageBox.Show("Please select a snapshot first.", "Export Snapshot", MessageBoxButton.OK, MessageBoxImage.Information);
MessageBox.Show("请先选择一个快照。", "导出快照", MessageBoxButton.OK, MessageBoxImage.Information);
return;
}
var dialog = new SaveFileDialog
{
Title = "Export Snapshot",
Filter = "JSON files (*.json)|*.json",
Title = "导出快照",
Filter = "JSON 文件 (*.json)|*.json",
FileName = $"snapshot-{SelectedSnapshot.Snapshot.Timestamp:yyyyMMdd-HHmmss}.json"
};
@@ -191,7 +191,7 @@ namespace XplorePlane.ViewModels.Debug
catch (Exception ex)
{
_logger.Error(ex, "导出快照失败 | Failed to export snapshot");
MessageBox.Show($"Failed to export snapshot: {ex.Message}", "Error", MessageBoxButton.OK, MessageBoxImage.Error);
MessageBox.Show($"导出快照失败:{ex.Message}", "错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
@@ -204,7 +204,7 @@ namespace XplorePlane.ViewModels.Debug
return;
}
var result = MessageBox.Show("Delete selected snapshot?", "Delete Snapshot", MessageBoxButton.YesNo, MessageBoxImage.Question);
var result = MessageBox.Show("确定删除选中的快照?", "删除快照", MessageBoxButton.YesNo, MessageBoxImage.Question);
if (result != MessageBoxResult.Yes)
{
return;
@@ -244,6 +244,7 @@ namespace XplorePlane.ViewModels.Debug
var root = new StateNodeViewModel
{
Name = category,
DisplayName = DebugPanelLocalization.Category(category),
Category = category
};
@@ -254,6 +255,7 @@ namespace XplorePlane.ViewModels.Debug
root.Children.Add(new StateNodeViewModel
{
Name = property.Name,
DisplayName = DebugPanelLocalization.Field(property.Name),
Category = category,
Value = DebugPanelStateFormatter.FormatValue(property.GetValue(value))
});
@@ -94,6 +94,7 @@ namespace XplorePlane.ViewModels.Debug
var root = new StateNodeViewModel
{
Name = category,
DisplayName = DebugPanelLocalization.Category(category),
Category = category
};
@@ -102,6 +103,7 @@ namespace XplorePlane.ViewModels.Debug
root.Children.Add(new StateNodeViewModel
{
Name = property.Name,
DisplayName = DebugPanelLocalization.Field(property.Name),
Category = category,
Value = string.Empty
});
@@ -15,6 +15,11 @@ namespace XplorePlane.ViewModels
string PipelineFileDisplayName { get; }
/// <summary>
/// 当前角色是否有权编辑流水线。为 false 时禁用所有编辑操作。
/// </summary>
bool CanEditPipeline { get; }
ICommand AddOperatorCommand { get; }
ICommand RemoveOperatorCommand { get; }
@@ -243,6 +243,9 @@ namespace XplorePlane.ViewModels
ICommand IPipelineEditorHostViewModel.SaveAsPipelineCommand => SaveAsPipelineCommand;
ICommand IPipelineEditorHostViewModel.LoadPipelineCommand => LoadPipelineCommand;
/// <summary>独立流水线编辑器始终允许编辑(不受角色限制)。</summary>
public bool CanEditPipeline => true;
// ── Command Implementations ───────────────────────────────────
private bool CanAddOperator(string operatorKey) =>
@@ -0,0 +1,96 @@
using Prism.Commands;
using Prism.Mvvm;
using XplorePlane.Services.Permission;
namespace XplorePlane.ViewModels
{
/// <summary>
/// 登录对话框 ViewModel。
/// 负责密码输入验证和调用 IPermissionService 进行认证。
/// </summary>
public class LoginDialogViewModel : BindableBase
{
private readonly IPermissionService _permissionService;
private string _password = string.Empty;
private string _errorMessage = string.Empty;
private bool _hasError;
private bool? _dialogResult;
public LoginDialogViewModel(IPermissionService permissionService)
{
_permissionService = permissionService;
LoginCommand = new DelegateCommand(ExecuteLogin);
CancelCommand = new DelegateCommand(ExecuteCancel);
}
/// <summary>密码输入(通过 attached behavior 绑定 PasswordBox)。</summary>
public string Password
{
get => _password;
set => SetProperty(ref _password, value);
}
/// <summary>错误消息,认证失败或空密码时显示。</summary>
public string ErrorMessage
{
get => _errorMessage;
set
{
if (SetProperty(ref _errorMessage, value))
{
HasError = !string.IsNullOrEmpty(value);
}
}
}
/// <summary>是否存在错误(ErrorMessage 非空时为 true)。</summary>
public bool HasError
{
get => _hasError;
private set => SetProperty(ref _hasError, value);
}
/// <summary>对话框结果。设置后关闭对话框(true=认证成功,false=取消)。</summary>
public bool? DialogResult
{
get => _dialogResult;
set => SetProperty(ref _dialogResult, value);
}
/// <summary>登录命令:校验非空 → 调用认证 → 成功关闭/失败显示错误。</summary>
public DelegateCommand LoginCommand { get; }
/// <summary>取消命令:关闭对话框。</summary>
public DelegateCommand CancelCommand { get; }
private void ExecuteLogin()
{
// 空密码前端拦截,不调用服务
if (string.IsNullOrEmpty(Password))
{
ErrorMessage = "请输入密码";
return;
}
var result = _permissionService.Authenticate(Password);
if (result.Success)
{
ErrorMessage = string.Empty;
DialogResult = true;
}
else
{
ErrorMessage = result.ErrorMessage ?? "认证失败";
Password = string.Empty;
}
}
private void ExecuteCancel()
{
DialogResult = false;
}
}
}
+157 -12
View File
@@ -15,34 +15,29 @@ using System.Threading.Tasks;
using System.Windows;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Threading;
using XP.Camera.Calibration;
using XP.Common.GeneralForm.Views;
using XP.Common.Logging.Interfaces;
using XP.Common.PdfViewer.Interfaces;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Services;
using XP.ImageProcessing.Processors;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.MainViewport;
using XplorePlane.Services.Permission;
using XplorePlane.Services.Recording;
using XplorePlane.Services.Storage;
using XplorePlane.ViewModels.Cnc;
using XplorePlane.ViewModels.Debug;
using XplorePlane.ViewModels.ImageProcessing;
using XplorePlane.ViewModels.Main;
using XplorePlane.Views;
using XplorePlane.Views.Cnc;
using XplorePlane.Views.Debug;
using XP.Common.Logging.Interfaces;
using XP.Common.GeneralForm.Views;
using XP.Common.PdfViewer.Interfaces;
using XP.Hardware.MotionControl.Abstractions;
using XP.Hardware.MotionControl.Services;
using System.Windows.Threading;
using XP.ImageProcessing.Processors;
using XplorePlane.Services.Storage;
using XplorePlane.ViewModels.Cnc;
using XplorePlane.ViewModels.ImageProcessing;
using XplorePlane.Views;
using XplorePlane.Views.Cnc;
using XplorePlane.Views.ImageProcessing;
using PermissionEnum = XplorePlane.Models.Permission;
namespace XplorePlane.ViewModels
{
@@ -54,6 +49,7 @@ namespace XplorePlane.ViewModels
private readonly IEventAggregator _eventAggregator;
private readonly IMainViewportService _mainViewportService;
private readonly IXpDataPathService _xpDataPathService;
private readonly IPermissionService _permissionService;
private readonly CncEditorViewModel _cncEditorViewModel;
private readonly CncPageView _cncPageView;
private readonly PipelineEditorViewModel _pipelineEditorViewModel;
@@ -199,6 +195,30 @@ namespace XplorePlane.ViewModels
public DelegateCommand OpenRealTimeLogViewerCommand { get; }
public DelegateCommand UseLiveDetectorSourceCommand { get; }
public DelegateCommand OpenPasswordManagementCommand { get; }
// ── 权限相关设置可见性属性 ──
private bool _isFactorySettingsVisible = true;
/// <summary>
/// 厂家级设置导航项是否可见。仅 SuperAdmin 可见。
/// 包括:射线源、探测器、运动控制、相机设置、PLC 地址等硬件设置。
/// </summary>
public bool IsFactorySettingsVisible
{
get => _isFactorySettingsVisible;
private set => SetProperty(ref _isFactorySettingsVisible, value);
}
private bool _isReportSettingsVisible = true;
/// <summary>
/// 报告设定导航项是否可见。SuperAdmin 和 Admin 可见,User 不可见。
/// </summary>
public bool IsReportSettingsVisible
{
get => _isReportSettingsVisible;
private set => SetProperty(ref _isReportSettingsVisible, value);
}
public bool IsMainViewportRealtimeEnabled
{
@@ -303,6 +323,7 @@ namespace XplorePlane.ViewModels
IEventAggregator eventAggregator,
IMainViewportService mainViewportService,
IXpDataPathService xpDataPathService,
IPermissionService permissionService,
IViewportRecordingService recordingService)
{
_logger = logger?.ForModule<MainViewModel>() ?? throw new ArgumentNullException(nameof(logger));
@@ -310,6 +331,7 @@ namespace XplorePlane.ViewModels
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_mainViewportService = mainViewportService ?? throw new ArgumentNullException(nameof(mainViewportService));
_xpDataPathService = xpDataPathService ?? throw new ArgumentNullException(nameof(xpDataPathService));
_permissionService = permissionService ?? throw new ArgumentNullException(nameof(permissionService));
_cncEditorViewModel = _containerProvider.Resolve<CncEditorViewModel>();
_cncPageView = new CncPageView { DataContext = _cncEditorViewModel };
_pipelineEditorViewModel = _containerProvider.Resolve<PipelineEditorViewModel>();
@@ -431,6 +453,7 @@ namespace XplorePlane.ViewModels
OpenLanguageSwitcherCommand = new DelegateCommand(ExecuteOpenLanguageSwitcher);
OpenRealTimeLogViewerCommand = new DelegateCommand(ExecuteOpenRealTimeLogViewer);
UseLiveDetectorSourceCommand = new DelegateCommand(ExecuteUseLiveDetectorSource);
OpenPasswordManagementCommand = new DelegateCommand(ExecuteOpenPasswordManagement);
ImagePanelContent = _pipelineEditorView;
ViewportPanelWidth = new GridLength(1, GridUnitType.Star);
@@ -445,9 +468,22 @@ namespace XplorePlane.ViewModels
ToggleRecordingCommand = new DelegateCommand(ExecuteToggleRecording, CanExecuteToggleRecording);
// 订阅角色变更事件,刷新设置导航可见性
_eventAggregator.GetEvent<RoleChangedEvent>()
.Subscribe(OnRoleChangedForSettings, ThreadOption.UIThread);
// 初始化设置导航可见性
RefreshSettingsVisibility();
// 初始化 Ribbon 右侧状态区域 ViewModel
RibbonStatusArea = _containerProvider.Resolve<RibbonStatusAreaViewModel>();
_logger.Info("MainViewModel 已初始化");
}
/// <summary>Ribbon 右侧状态区域 ViewModel(角色显示 + 切换用户按钮)。</summary>
public RibbonStatusAreaViewModel RibbonStatusArea { get; private set; }
public string CncStatusMessage => _cncEditorViewModel.StatusMessage;
public bool CncHasExecutionError => _cncEditorViewModel.HasExecutionError;
@@ -622,6 +658,9 @@ namespace XplorePlane.ViewModels
private void ExecuteOpenCameraSettings()
{
if (!ConfirmFactorySettingsNavigation("CameraSettings"))
return;
try
{
var vm = _containerProvider.Resolve<NavigationPropertyPanelViewModel>();
@@ -909,6 +948,9 @@ namespace XplorePlane.ViewModels
private void ExecuteOpenDetectorConfig()
{
if (!ConfirmFactorySettingsNavigation("DetectorConfig"))
return;
try
{
ShowOrActivate(_detectorConfigWindow, w => _detectorConfigWindow = w,
@@ -924,24 +966,36 @@ namespace XplorePlane.ViewModels
private void ExecuteOpenMotionDebug()
{
if (!ConfirmFactorySettingsNavigation("MotionControl"))
return;
ShowOrActivate(_motionDebugWindow, w => _motionDebugWindow = w,
() => new XP.Hardware.MotionControl.Views.MotionDebugWindow(), "运动调试");
}
private void ExecuteOpenPlcAddrConfig()
{
if (!ConfirmFactorySettingsNavigation("PlcAddrConfig"))
return;
ShowOrActivate(_plcAddrConfigWindow, w => _plcAddrConfigWindow = w,
() => _containerProvider.Resolve<XP.Hardware.PLC.Views.PlcAddrConfigEditorWindow>(), "PLC 地址配置");
}
private void ExecuteOpenRaySourceConfig()
{
if (!ConfirmFactorySettingsNavigation("RaySourceConfig"))
return;
ShowOrActivate(_raySourceConfigWindow, w => _raySourceConfigWindow = w,
() => new XP.Hardware.RaySource.Views.RaySourceConfigWindow(), "Ray Source Config");
}
private void ExecuteOpenReportConfig()
{
if (!ConfirmReportSettingsNavigation("ReportConfig"))
return;
ShowOrActivate(_reportConfigWindow, w => _reportConfigWindow = w,
() =>
{
@@ -950,6 +1004,28 @@ namespace XplorePlane.ViewModels
}, "报告配置");
}
private void ExecuteOpenPasswordManagement()
{
if (!ConfirmFactorySettingsNavigation("PasswordManagement"))
return;
try
{
var viewModel = _containerProvider.Resolve<ViewModels.Setting.PasswordManagementViewModel>();
var window = new Views.Setting.PasswordManagementView(viewModel)
{
Owner = Application.Current.MainWindow
};
window.ShowDialog();
}
catch (Exception ex)
{
_logger.Error(ex, "Failed to open password management window");
MessageBox.Show($"打开密码管理窗口失败: {ex.Message}",
"错误", MessageBoxButton.OK, MessageBoxImage.Error);
}
}
private void ExecuteLoadImage()
{
var dialog = new OpenFileDialog
@@ -1632,5 +1708,74 @@ namespace XplorePlane.ViewModels
}
#endregion
#region
/// <summary>
/// 角色变更时刷新设置导航可见性。
/// </summary>
private void OnRoleChangedForSettings(RoleChangedPayload payload)
{
RefreshSettingsVisibility();
}
/// <summary>
/// 根据当前角色刷新设置导航项的可见性。
/// Factory_Settings: 仅 SuperAdmin 可见。
/// Report_Settings: SuperAdmin 和 Admin 可见,User 不可见。
/// </summary>
private void RefreshSettingsVisibility()
{
IsFactorySettingsVisible = _permissionService.HasPermission(PermissionEnum.AccessPlcSettings);
IsReportSettingsVisible = _permissionService.HasPermission(PermissionEnum.AccessReportSettings);
}
/// <summary>
/// 检查是否允许导航到厂家级设置页面。
/// 如果权限不足,记录日志并显示通知。
/// </summary>
/// <param name="targetPage">目标页面标识。</param>
/// <returns>是否允许导航。</returns>
private bool ConfirmFactorySettingsNavigation(string targetPage)
{
if (_permissionService.HasPermission(PermissionEnum.AccessPlcSettings))
{
return true;
}
var role = _permissionService.CurrentRole?.ToString() ?? "未认证";
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
_logger.Warn("权限拒绝导航: Role={Role}, Target={Target}, Timestamp={Timestamp}",
role, targetPage, timestamp);
MessageBox.Show("当前角色无权访问此功能", "权限不足",
MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
/// <summary>
/// 检查是否允许导航到报告设定页面。
/// 如果权限不足,记录日志并显示通知。
/// </summary>
/// <param name="targetPage">目标页面标识。</param>
/// <returns>是否允许导航。</returns>
private bool ConfirmReportSettingsNavigation(string targetPage)
{
if (_permissionService.HasPermission(PermissionEnum.AccessReportSettings))
{
return true;
}
var role = _permissionService.CurrentRole?.ToString() ?? "未认证";
var timestamp = DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss");
_logger.Warn("权限拒绝导航: Role={Role}, Target={Target}, Timestamp={Timestamp}",
role, targetPage, timestamp);
MessageBox.Show("当前角色无权访问此功能", "权限不足",
MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
#endregion
}
}
@@ -0,0 +1,130 @@
using System.Windows;
using Prism.Commands;
using Prism.Events;
using Prism.Ioc;
using Prism.Mvvm;
using Serilog;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.AppState;
using XplorePlane.Services.Permission;
using XplorePlane.Views;
namespace XplorePlane.ViewModels.Main
{
/// <summary>
/// Ribbon 右侧状态区域 ViewModel。
/// 显示当前角色中文名称标签和"切换用户"按钮。
/// 位置:Ribbon 右侧区域(类似 Office 账户区域),认证成功后始终可见。
/// </summary>
public class RibbonStatusAreaViewModel : BindableBase
{
private readonly IPermissionService _permissionService;
private readonly IEventAggregator _eventAggregator;
private readonly IAppStateService _appStateService;
private readonly IContainerProvider _containerProvider;
private string _currentRoleDisplayName = string.Empty;
private bool _canSwitchUser;
/// <summary>当前角色中文显示名(超级管理员 / 管理员 / 用户)。</summary>
public string CurrentRoleDisplayName
{
get => _currentRoleDisplayName;
set => SetProperty(ref _currentRoleDisplayName, value);
}
/// <summary>是否可切换用户(绑定按钮 IsEnabled)。</summary>
public bool CanSwitchUser
{
get => _canSwitchUser;
set => SetProperty(ref _canSwitchUser, value);
}
/// <summary>切换用户命令。CNC 执行中禁用。</summary>
public DelegateCommand SwitchUserCommand { get; }
public RibbonStatusAreaViewModel(
IPermissionService permissionService,
IEventAggregator eventAggregator,
IAppStateService appStateService,
IContainerProvider containerProvider)
{
_permissionService = permissionService ?? throw new System.ArgumentNullException(nameof(permissionService));
_eventAggregator = eventAggregator ?? throw new System.ArgumentNullException(nameof(eventAggregator));
_appStateService = appStateService ?? throw new System.ArgumentNullException(nameof(appStateService));
_containerProvider = containerProvider ?? throw new System.ArgumentNullException(nameof(containerProvider));
SwitchUserCommand = new DelegateCommand(ExecuteSwitchUser, () => CanSwitchUser);
// Subscribe to RoleChangedEvent to update CurrentRoleDisplayName
_eventAggregator.GetEvent<RoleChangedEvent>()
.Subscribe(OnRoleChanged, ThreadOption.UIThread);
// Subscribe to SystemState changes to update CanSwitchUser
_appStateService.SystemStateChanged += OnSystemStateChanged;
// Initialize from current state
InitializeFromCurrentState();
}
private void InitializeFromCurrentState()
{
if (_permissionService.CurrentRole.HasValue)
{
CurrentRoleDisplayName = MapRoleToDisplayName(_permissionService.CurrentRole.Value);
}
CanSwitchUser = _permissionService.CanSwitchUser;
}
private void OnRoleChanged(RoleChangedPayload payload)
{
CurrentRoleDisplayName = MapRoleToDisplayName(payload.NewRole);
}
private void OnSystemStateChanged(object sender, StateChangedEventArgs<SystemState> e)
{
CanSwitchUser = _permissionService.CanSwitchUser;
SwitchUserCommand.RaiseCanExecuteChanged();
}
private void ExecuteSwitchUser()
{
// 检查是否允许切换用户(CNC 执行中不允许)
if (!_permissionService.CanSwitchUser)
{
MessageBox.Show("CNC 执行中不允许切换用户", "提示", MessageBoxButton.OK, MessageBoxImage.Warning);
return;
}
// 显示登录对话框进行重新认证
// 不先调用 Logout(),这样取消时旧角色自动保留(满足需求 6.4)
var loginViewModel = _containerProvider.Resolve<LoginDialogViewModel>();
var loginDialog = new LoginDialog(loginViewModel);
var result = loginDialog.ShowDialog();
if (result == true)
{
// 认证成功:Authenticate() 已更新 CurrentRole 并发布 RoleChangedEvent
// UI 会自动刷新(满足需求 6.3)
Log.Information("用户切换成功,新角色: {Role}", _permissionService.CurrentRole);
}
// 如果取消(result == false 或 null),什么都不做,旧角色保持不变(满足需求 6.4)
}
/// <summary>
/// 将 UserRole 枚举映射为中文显示名。
/// </summary>
private static string MapRoleToDisplayName(UserRole role)
{
return role switch
{
UserRole.SuperAdmin => "超级管理员",
UserRole.Admin => "管理员",
UserRole.User => "用户",
_ => string.Empty
};
}
}
}
@@ -94,10 +94,18 @@ namespace XplorePlane.ViewModels
set
{
if (SetProperty(ref _isRealtimeEnabled, value))
{
_mainViewportService.SetRealtimeDisplayEnabled(value);
RaisePropertyChanged(nameof(IsWheelZoomEnabled));
}
}
}
/// <summary>
/// 是否允许滚轮缩放:实时显示过程中禁用,停止实时后可缩放。
/// </summary>
public bool IsWheelZoomEnabled => !_isRealtimeEnabled;
// Task 5.3: IsDetectorConnected property (read-only, private setter)
public bool IsDetectorConnected
{
@@ -227,6 +235,7 @@ namespace XplorePlane.ViewModels
// Task 5.8: Sync IsRealtimeEnabled from service
_isRealtimeEnabled = _mainViewportService.IsRealtimeDisplayEnabled;
RaisePropertyChanged(nameof(IsRealtimeEnabled));
RaisePropertyChanged(nameof(IsWheelZoomEnabled));
// Task 5.8: Sync _isCncRunning from service and raise IsAnimatedSwitchEnabled
_isCncRunning = _mainViewportService.IsCncRunning;
@@ -0,0 +1,163 @@
using Prism.Commands;
using Prism.Mvvm;
using XP.Common.Logging.Interfaces;
using XplorePlane.Models;
using XplorePlane.Services.Permission;
namespace XplorePlane.ViewModels.Setting
{
/// <summary>
/// 密码管理 ViewModel。
/// 对应 UI 元素:"密码管理" 按钮位于 "全局设置" (Global Settings) Ribbon Group 中。
/// 可见性规则:仅 Super_Admin 可见,Admin/User 时隐藏(与 Factory_Settings 相同规则)。
/// </summary>
public class PasswordManagementViewModel : BindableBase
{
private readonly IPermissionService _permissionService;
private readonly ILoggerService _logger;
private const int MinPasswordLength = 4;
private const int MaxPasswordLength = 32;
public PasswordManagementViewModel(IPermissionService permissionService, ILoggerService logger)
{
_permissionService = permissionService ?? throw new System.ArgumentNullException(nameof(permissionService));
_logger = logger?.ForModule<PasswordManagementViewModel>() ?? throw new System.ArgumentNullException(nameof(logger));
ChangeSuperAdminPasswordCommand = new DelegateCommand(ExecuteChangeSuperAdminPassword);
ChangeAdminPasswordCommand = new DelegateCommand(ExecuteChangeAdminPassword);
ChangeUserPasswordCommand = new DelegateCommand(ExecuteChangeUserPassword);
}
// ── 密码输入属性 ──
private string _superAdminPassword = string.Empty;
public string SuperAdminPassword
{
get => _superAdminPassword;
set => SetProperty(ref _superAdminPassword, value);
}
private string _adminPassword = string.Empty;
public string AdminPassword
{
get => _adminPassword;
set => SetProperty(ref _adminPassword, value);
}
private string _userPassword = string.Empty;
public string UserPassword
{
get => _userPassword;
set => SetProperty(ref _userPassword, value);
}
// ── 反馈消息属性 ──
private string _superAdminMessage = string.Empty;
public string SuperAdminMessage
{
get => _superAdminMessage;
set => SetProperty(ref _superAdminMessage, value);
}
private bool _superAdminMessageIsError;
public bool SuperAdminMessageIsError
{
get => _superAdminMessageIsError;
set => SetProperty(ref _superAdminMessageIsError, value);
}
private string _adminMessage = string.Empty;
public string AdminMessage
{
get => _adminMessage;
set => SetProperty(ref _adminMessage, value);
}
private bool _adminMessageIsError;
public bool AdminMessageIsError
{
get => _adminMessageIsError;
set => SetProperty(ref _adminMessageIsError, value);
}
private string _userMessage = string.Empty;
public string UserMessage
{
get => _userMessage;
set => SetProperty(ref _userMessage, value);
}
private bool _userMessageIsError;
public bool UserMessageIsError
{
get => _userMessageIsError;
set => SetProperty(ref _userMessageIsError, value);
}
// ── 命令 ──
public DelegateCommand ChangeSuperAdminPasswordCommand { get; }
public DelegateCommand ChangeAdminPasswordCommand { get; }
public DelegateCommand ChangeUserPasswordCommand { get; }
// ── 命令执行 ──
private void ExecuteChangeSuperAdminPassword()
{
var result = ValidateAndChangePassword(UserRole.SuperAdmin, SuperAdminPassword);
SuperAdminMessageIsError = !result.Success;
SuperAdminMessage = result.Success ? "密码修改成功" : result.ErrorMessage;
if (result.Success)
SuperAdminPassword = string.Empty;
}
private void ExecuteChangeAdminPassword()
{
var result = ValidateAndChangePassword(UserRole.Admin, AdminPassword);
AdminMessageIsError = !result.Success;
AdminMessage = result.Success ? "密码修改成功" : result.ErrorMessage;
if (result.Success)
AdminPassword = string.Empty;
}
private void ExecuteChangeUserPassword()
{
var result = ValidateAndChangePassword(UserRole.User, UserPassword);
UserMessageIsError = !result.Success;
UserMessage = result.Success ? "密码修改成功" : result.ErrorMessage;
if (result.Success)
UserPassword = string.Empty;
}
/// <summary>
/// 验证密码长度并调用服务修改密码。
/// </summary>
private PasswordChangeResult ValidateAndChangePassword(UserRole targetRole, string newPassword)
{
if (string.IsNullOrEmpty(newPassword))
{
return new PasswordChangeResult(false, "请输入新密码");
}
if (newPassword.Length < MinPasswordLength || newPassword.Length > MaxPasswordLength)
{
return new PasswordChangeResult(false, $"密码长度必须在 {MinPasswordLength}-{MaxPasswordLength} 个字符之间");
}
var result = _permissionService.ChangePassword(targetRole, newPassword);
if (result.Success)
{
_logger.Info("角色 {Role} 密码已更新", targetRole);
}
else
{
_logger.Warn("角色 {Role} 密码修改失败: {Error}", targetRole, result.ErrorMessage);
}
return result;
}
}
}
@@ -1,23 +1,44 @@
using Prism.Commands;
using Prism.Events;
using Prism.Mvvm;
using System;
using System.Configuration;
using System.Windows;
using XP.Common.Logging.Interfaces;
using XplorePlane.Events;
using XplorePlane.Models;
using XplorePlane.Services.Permission;
using PermissionEnum = XplorePlane.Models.Permission;
namespace XplorePlane.ViewModels.Setting
{
public class SettingsViewModel : BindableBase
{
private readonly ILoggerService _logger;
private readonly IPermissionService _permissionService;
private readonly IEventAggregator _eventAggregator;
public DelegateCommand SaveCommand { get; }
public DelegateCommand CancelCommand { get; }
public DelegateCommand ResetToDefaultCommand { get; }
public SettingsViewModel(ILoggerService logger)
// ── 权限相关设置可见性属性 ──
private bool _isFactorySettingsVisible = true;
/// <summary>
/// 厂家级设置标签页是否可见(射线源、探测器、PLC)。仅 SuperAdmin 可见。
/// </summary>
public bool IsFactorySettingsVisible
{
get => _isFactorySettingsVisible;
private set => SetProperty(ref _isFactorySettingsVisible, value);
}
public SettingsViewModel(ILoggerService logger, IPermissionService permissionService, IEventAggregator eventAggregator)
{
_logger = logger?.ForModule<SettingsViewModel>() ?? throw new ArgumentNullException(nameof(logger));
_permissionService = permissionService ?? throw new ArgumentNullException(nameof(permissionService));
_eventAggregator = eventAggregator ?? throw new ArgumentNullException(nameof(eventAggregator));
_logger.Info("SettingsViewModel 构造函数被调用 | SettingsViewModel constructor called");
@@ -28,6 +49,13 @@ namespace XplorePlane.ViewModels.Setting
_logger.Debug("Commands initialized: SaveCommand={SaveCommand}, CancelCommand={CancelCommand}, ResetToDefaultCommand={ResetToDefaultCommand}",
SaveCommand != null, CancelCommand != null, ResetToDefaultCommand != null);
// 订阅角色变更事件
_eventAggregator.GetEvent<RoleChangedEvent>()
.Subscribe(OnRoleChanged, ThreadOption.UIThread);
// 初始化可见性
RefreshSettingsVisibility();
LoadSettings();
}
@@ -439,5 +467,19 @@ namespace XplorePlane.ViewModels.Setting
config.AppSettings.Settings[key].Value = value;
}
}
private void OnRoleChanged(RoleChangedPayload payload)
{
RefreshSettingsVisibility();
}
/// <summary>
/// 根据当前角色刷新设置标签页的可见性。
/// 厂家级设置(射线源、探测器、PLC)仅 SuperAdmin 可见。
/// </summary>
private void RefreshSettingsVisibility()
{
IsFactorySettingsVisible = _permissionService.HasPermission(PermissionEnum.AccessPlcSettings);
}
}
}
+3 -1
View File
@@ -7,6 +7,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:prism="http://prismlibrary.com/"
xmlns:behaviors="clr-namespace:XplorePlane.Controls"
xmlns:helpers="clr-namespace:XplorePlane.Helpers"
xmlns:views="clr-namespace:XplorePlane.Views"
xmlns:vm="clr-namespace:XplorePlane.ViewModels.Cnc"
d:DesignHeight="760"
@@ -269,7 +270,8 @@
Content="完成"
Style="{StaticResource TreeToolbarButtonCompact}" />
</WrapPanel>
<WrapPanel Margin="0,4,0,0">
<WrapPanel Margin="0,4,0,0" IsEnabled="{Binding CanEditCncProgram}"
helpers:PermissionTooltipHelper.IsPermissionRestricted="True">
<WrapPanel.Resources>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
+3 -1
View File
@@ -57,6 +57,7 @@ namespace XplorePlane.Views.Cnc
var executionService = ContainerLocator.Current.Resolve<IPipelineExecutionService>();
var mainViewportService = ContainerLocator.Current.Resolve<XplorePlane.Services.MainViewport.IMainViewportService>();
var eventAggregator = ContainerLocator.Current.Resolve<Prism.Events.IEventAggregator>();
var permissionService = ContainerLocator.Current.Resolve<XplorePlane.Services.Permission.IPermissionService>();
_inspectionModulePipelineViewModel = new CncInspectionModulePipelineViewModel(
editorViewModel,
@@ -65,7 +66,8 @@ namespace XplorePlane.Views.Cnc
logger,
executionService,
mainViewportService,
eventAggregator);
eventAggregator,
permissionService);
InspectionModulePipelineEditor.DataContext = _inspectionModulePipelineViewModel;
}
+396 -216
View File
@@ -6,6 +6,8 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:prism="http://prismlibrary.com/"
xmlns:models="clr-namespace:XplorePlane.Models"
xmlns:converters="clr-namespace:XplorePlane.Converters"
d:DesignHeight="700"
d:DesignWidth="900"
prism:ViewModelLocator.AutoWireViewModel="True"
@@ -19,6 +21,10 @@
<SolidColorBrush x:Key="AccentBlue" Color="#E3F0FF" />
<FontFamily x:Key="CsdFont">Microsoft YaHei UI</FontFamily>
<!-- 转换器 | Converters -->
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<converters:InverseBoolConverter x:Key="InverseBoolConverter" />
<!-- 配置面板标签样式 | Configuration panel label style -->
<Style x:Key="ConfigLabel" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Microsoft YaHei UI" />
@@ -38,6 +44,15 @@
<Setter Property="VerticalContentAlignment" Value="Center" />
</Style>
<!-- 错误提示文本样式 | Error message text style -->
<Style x:Key="ErrorText" TargetType="TextBlock">
<Setter Property="FontFamily" Value="Microsoft YaHei UI" />
<Setter Property="FontSize" Value="10" />
<Setter Property="Foreground" Value="#E53935" />
<Setter Property="Margin" Value="0,0,0,4" />
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<!-- 工具栏按钮样式(与 CncPageView 一致)| Toolbar button style (consistent with CncPageView) -->
<Style x:Key="ToolbarBtn" TargetType="Button">
<Setter Property="Height" Value="28" />
@@ -58,236 +73,401 @@
BorderThickness="1"
CornerRadius="4">
<Grid>
<Grid.ColumnDefinitions>
<!-- 左侧:配置面板 | Left: configuration panel -->
<ColumnDefinition Width="220" />
<!-- 分隔线 | Splitter -->
<ColumnDefinition Width="Auto" />
<!-- 右侧:矩阵网格 | Right: matrix grid -->
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<!-- 主内容区域 | Main content area -->
<RowDefinition Height="*" />
<!-- 底部状态栏 | Bottom status bar -->
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- ═══ 左侧:矩阵配置面板 | Left: matrix configuration panel ═══ -->
<ScrollViewer
Grid.Column="0"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Margin="10,8">
<!-- 面板标题 | Panel title -->
<TextBlock
Margin="0,0,0,10"
FontFamily="{StaticResource CsdFont}"
FontSize="13"
FontWeight="Bold"
Foreground="#333"
Text="矩阵配置 | Matrix Config" />
<Grid Grid.Row="0">
<Grid.ColumnDefinitions>
<!-- 左侧:配置面板 | Left: configuration panel -->
<ColumnDefinition Width="220" />
<!-- 分隔线 | Splitter -->
<ColumnDefinition Width="Auto" />
<!-- 右侧:矩阵网格 | Right: matrix grid -->
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- 行数 | Rows -->
<TextBlock Style="{StaticResource ConfigLabel}" Text="行数 | Rows" />
<TextBox
Margin="0,2,0,8"
Style="{StaticResource ConfigInput}"
Text="{Binding Rows, UpdateSourceTrigger=PropertyChanged}"
ToolTip="矩阵行数 | Number of rows" />
<!-- ═══ 左侧:矩阵配置面板 | Left: matrix configuration panel ═══ -->
<ScrollViewer
Grid.Column="0"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Margin="10,8">
<!-- 面板标题 | Panel title -->
<TextBlock
Margin="0,0,0,10"
FontFamily="{StaticResource CsdFont}"
FontSize="13"
FontWeight="Bold"
Foreground="#333"
Text="矩阵配置 | Matrix Config" />
<!-- 数 | Columns -->
<TextBlock Style="{StaticResource ConfigLabel}" Text="数 | Columns" />
<TextBox
Margin="0,2,0,8"
Style="{StaticResource ConfigInput}"
Text="{Binding Columns, UpdateSourceTrigger=PropertyChanged}"
ToolTip="矩阵数 | Number of columns" />
<!-- 数 | Rows -->
<TextBlock Style="{StaticResource ConfigLabel}" Text="数 | Rows" />
<TextBox
Margin="0,2,0,2"
Style="{StaticResource ConfigInput}"
Text="{Binding Rows, UpdateSourceTrigger=PropertyChanged}"
ToolTip="矩阵数 | Number of rows" />
<!-- 行数错误提示 | Rows error message -->
<TextBlock
Style="{StaticResource ErrorText}"
Text="{Binding RowsError}" />
<!-- 行间距 | Row Spacing -->
<TextBlock Style="{StaticResource ConfigLabel}" Text="行间距 (mm) | Row Spacing" />
<TextBox
Margin="0,2,0,8"
Style="{StaticResource ConfigInput}"
Text="{Binding RowSpacing, UpdateSourceTrigger=PropertyChanged}"
ToolTip="行间距(毫米)| Row spacing in mm" />
<!-- 列数 | Columns -->
<TextBlock Style="{StaticResource ConfigLabel}" Text="列数 | Columns" />
<TextBox
Margin="0,2,0,2"
Style="{StaticResource ConfigInput}"
Text="{Binding Columns, UpdateSourceTrigger=PropertyChanged}"
ToolTip="矩阵列数 | Number of columns" />
<!-- 列数错误提示 | Columns error message -->
<TextBlock
Style="{StaticResource ErrorText}"
Text="{Binding ColumnsError}" />
<!-- 间距 | Column Spacing -->
<TextBlock Style="{StaticResource ConfigLabel}" Text="间距 (mm) | Col Spacing" />
<TextBox
Margin="0,2,0,8"
Style="{StaticResource ConfigInput}"
Text="{Binding ColumnSpacing, UpdateSourceTrigger=PropertyChanged}"
ToolTip="间距(毫米)| Column spacing in mm" />
<!-- 间距 | Row Spacing -->
<TextBlock Style="{StaticResource ConfigLabel}" Text="间距 (mm) | Row Spacing" />
<TextBox
Margin="0,2,0,2"
Style="{StaticResource ConfigInput}"
Text="{Binding RowSpacing, UpdateSourceTrigger=PropertyChanged}"
ToolTip="间距(毫米)| Row spacing in mm" />
<!-- 行间距错误提示 | Row spacing error message -->
<TextBlock
Style="{StaticResource ErrorText}"
Text="{Binding RowSpacingError}" />
<!-- 分隔线 | Separator -->
<Rectangle
Height="1"
Margin="0,4,0,8"
Fill="{StaticResource SeparatorBrush}" />
<!-- 列间距 | Column Spacing -->
<TextBlock Style="{StaticResource ConfigLabel}" Text="列间距 (mm) | Col Spacing" />
<TextBox
Margin="0,2,0,2"
Style="{StaticResource ConfigInput}"
Text="{Binding ColumnSpacing, UpdateSourceTrigger=PropertyChanged}"
ToolTip="列间距(毫米)| Column spacing in mm" />
<!-- 列间距错误提示 | Column spacing error message -->
<TextBlock
Style="{StaticResource ErrorText}"
Text="{Binding ColSpacingError}" />
<!-- 操作按钮 | Action buttons -->
<Button
Command="{Binding UpdateLayoutCommand}"
Content="更新布局 | Update"
Style="{StaticResource ToolbarBtn}"
ToolTip="根据当前参数更新矩阵网格 | Update matrix grid with current parameters" />
<Button
Command="{Binding AssociateProgramCommand}"
Content="关联程序 | Associate"
Style="{StaticResource ToolbarBtn}"
ToolTip="关联 CNC 程序到矩阵 | Associate CNC program to matrix" />
<Button
Command="{Binding SaveLayoutCommand}"
Content="保存方案 | Save"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存矩阵方案 | Save matrix layout" />
<Button
Command="{Binding LoadLayoutCommand}"
Content="加载方案 | Load"
Style="{StaticResource ToolbarBtn}"
ToolTip="加载矩阵方案 | Load matrix layout" />
<!-- 分隔线 | Separator -->
<Rectangle
Height="1"
Margin="0,4,0,8"
Fill="{StaticResource SeparatorBrush}" />
<!-- 分隔线 | Separator -->
<Rectangle
Height="1"
Margin="0,8,0,8"
Fill="{StaticResource SeparatorBrush}" />
<!-- 操作按钮 | Action buttons -->
<Button
Command="{Binding UpdateLayoutCommand}"
Content="更新布局 | Update"
IsEnabled="{Binding IsExecuting, Converter={StaticResource InverseBoolConverter}}"
Style="{StaticResource ToolbarBtn}"
ToolTip="根据当前参数更新矩阵网格 | Update matrix grid with current parameters" />
<Button
Command="{Binding AssociateProgramCommand}"
Content="关联程序 | Associate"
IsEnabled="{Binding IsExecuting, Converter={StaticResource InverseBoolConverter}}"
Style="{StaticResource ToolbarBtn}"
ToolTip="关联 CNC 程序到矩阵 | Associate CNC program to matrix" />
<Button
Command="{Binding SaveLayoutCommand}"
Content="保存方案 | Save"
Style="{StaticResource ToolbarBtn}"
ToolTip="保存矩阵方案 | Save matrix layout" />
<Button
Command="{Binding LoadLayoutCommand}"
Content="加载方案 | Load"
Style="{StaticResource ToolbarBtn}"
ToolTip="加载矩阵方案 | Load matrix layout" />
<!-- 选中单元格详情 | Selected cell details -->
<TextBlock
Margin="0,0,0,6"
FontFamily="{StaticResource CsdFont}"
FontSize="11"
FontWeight="Bold"
Foreground="#555"
Text="选中单元格 | Selected Cell" />
<StackPanel DataContext="{Binding SelectedCell}">
<TextBlock FontFamily="{StaticResource CsdFont}" FontSize="11">
<Run Foreground="#888" Text="位置 | Position: " />
<Run Text="{Binding Row, Mode=OneWay, StringFormat='R{0}'}" />
<Run Text=", " />
<Run Text="{Binding Column, Mode=OneWay, StringFormat='C{0}'}" />
<!-- 分隔线 | Separator -->
<Rectangle
Height="1"
Margin="0,4,0,8"
Fill="{StaticResource SeparatorBrush}" />
<!-- 运行/停止按钮 | Run/Stop buttons -->
<Button
Command="{Binding RunMatrixCommand}"
Content="▶ 运行矩阵 | Run"
Style="{StaticResource ToolbarBtn}"
ToolTip="开始执行矩阵检测 | Start matrix execution" />
<Button
Command="{Binding StopCommand}"
Content="■ 停止执行 | Stop"
Style="{StaticResource ToolbarBtn}"
ToolTip="停止当前矩阵执行 | Stop current matrix execution"
Visibility="{Binding IsExecuting, Converter={StaticResource BoolToVisibilityConverter}}" />
<!-- 分隔线 | Separator -->
<Rectangle
Height="1"
Margin="0,8,0,8"
Fill="{StaticResource SeparatorBrush}" />
<!-- 关联程序显示区域 | Associated program display area -->
<TextBlock
Margin="0,0,0,4"
FontFamily="{StaticResource CsdFont}"
FontSize="11"
FontWeight="Bold"
Foreground="#555"
Text="关联程序 | Program" />
<TextBlock
FontFamily="{StaticResource CsdFont}"
FontSize="11"
Margin="0,0,0,8">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Text" Value="{Binding AssociatedProgramName}" />
<Setter Property="Foreground" Value="#333" />
<Style.Triggers>
<DataTrigger Binding="{Binding AssociatedProgramName}" Value="{x:Null}">
<Setter Property="Text" Value="请先关联 CNC 程序" />
<Setter Property="Foreground" Value="#999" />
<Setter Property="FontStyle" Value="Italic" />
</DataTrigger>
<DataTrigger Binding="{Binding AssociatedProgramName}" Value="">
<Setter Property="Text" Value="请先关联 CNC 程序" />
<Setter Property="Foreground" Value="#999" />
<Setter Property="FontStyle" Value="Italic" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock FontFamily="{StaticResource CsdFont}" FontSize="11">
<Run Foreground="#888" Text="偏移 X | Offset X: " />
<Run Text="{Binding OffsetX, Mode=OneWay, StringFormat='{}{0:F2}'}" />
</TextBlock>
<TextBlock FontFamily="{StaticResource CsdFont}" FontSize="11">
<Run Foreground="#888" Text="偏移 Y | Offset Y: " />
<Run Text="{Binding OffsetY, Mode=OneWay, StringFormat='{}{0:F2}'}" />
</TextBlock>
<TextBlock FontFamily="{StaticResource CsdFont}" FontSize="11">
<Run Foreground="#888" Text="状态 | Enabled: " />
<Run Text="{Binding IsEnabled, Mode=OneWay}" />
<!-- 分隔线 | Separator -->
<Rectangle
Height="1"
Margin="0,0,0,8"
Fill="{StaticResource SeparatorBrush}" />
<!-- 选中单元格详情 | Selected cell details -->
<TextBlock
Margin="0,0,0,6"
FontFamily="{StaticResource CsdFont}"
FontSize="11"
FontWeight="Bold"
Foreground="#555"
Text="选中单元格 | Selected Cell" />
<StackPanel DataContext="{Binding SelectedCell}">
<TextBlock FontFamily="{StaticResource CsdFont}" FontSize="11">
<Run Foreground="#888" Text="位置 | Position: " />
<Run Text="{Binding Row, Mode=OneWay, StringFormat='R{0}'}" />
<Run Text=", " />
<Run Text="{Binding Column, Mode=OneWay, StringFormat='C{0}'}" />
</TextBlock>
<TextBlock FontFamily="{StaticResource CsdFont}" FontSize="11">
<Run Foreground="#888" Text="偏移 X | Offset X: " />
<Run Text="{Binding OffsetX, Mode=OneWay, StringFormat='{}{0:F2}'}" />
</TextBlock>
<TextBlock FontFamily="{StaticResource CsdFont}" FontSize="11">
<Run Foreground="#888" Text="偏移 Y | Offset Y: " />
<Run Text="{Binding OffsetY, Mode=OneWay, StringFormat='{}{0:F2}'}" />
</TextBlock>
<TextBlock FontFamily="{StaticResource CsdFont}" FontSize="11">
<Run Foreground="#888" Text="状态 | Enabled: " />
<Run Text="{Binding IsEnabled, Mode=OneWay}" />
</TextBlock>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- 垂直分隔线 | Vertical separator -->
<Rectangle
Grid.Column="1"
Width="1"
Fill="{StaticResource SeparatorBrush}" />
<!-- ═══ 右侧:矩阵网格视图 | Right: matrix grid view ═══ -->
<Grid Grid.Column="2">
<Grid.RowDefinitions>
<!-- 网格标题与统计 | Grid title and statistics -->
<RowDefinition Height="Auto" />
<!-- 矩阵网格 | Matrix grid -->
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<!-- 矩阵网格标题和统计标签 | Matrix grid title and statistics label -->
<StackPanel
Grid.Row="0"
Margin="8,6,8,2"
Orientation="Horizontal">
<TextBlock
FontFamily="{StaticResource CsdFont}"
FontSize="12"
FontWeight="Bold"
Foreground="#333"
VerticalAlignment="Center"
Text="矩阵网格" />
<TextBlock
Margin="12,0,0,0"
FontFamily="{StaticResource CsdFont}"
FontSize="11"
Foreground="#666"
VerticalAlignment="Center">
<Run Text="已启用:" /><Run Text="{Binding EnabledCount, Mode=OneWay}" /><Run Text=" / 共 " /><Run Text="{Binding TotalCount, Mode=OneWay}" /><Run Text=" 个位置" />
</TextBlock>
</StackPanel>
</StackPanel>
</ScrollViewer>
<!-- 垂直分隔线 | Vertical separator -->
<Rectangle
Grid.Column="1"
Width="1"
Fill="{StaticResource SeparatorBrush}" />
<!-- ═══ 右侧:矩阵网格视图 | Right: matrix grid view ═══ -->
<ScrollViewer
Grid.Column="2"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<ItemsControl
Margin="8"
ItemsSource="{Binding Cells}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<!-- 使用 UniformGrid 按行列排列单元格 | Use UniformGrid to arrange cells by rows and columns -->
<UniformGrid Columns="{Binding Columns}" Rows="{Binding Rows}" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<!-- 单元格视觉模板 | Cell visual template -->
<Border
x:Name="CellBorder"
MinWidth="80"
MinHeight="64"
Margin="2"
Padding="4"
Background="#F0F8FF"
BorderBrush="#B0C4DE"
BorderThickness="1"
CornerRadius="3"
Cursor="Hand">
<Border.InputBindings>
<!-- 左键点击选中单元格 | Left click to select cell -->
<MouseBinding
Command="{Binding DataContext.SelectCellCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
MouseAction="LeftClick" />
</Border.InputBindings>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel
Grid.Row="0"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<!-- 行列索引 | Row/Column index -->
<TextBlock
HorizontalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontSize="11"
FontWeight="SemiBold"
Foreground="#333">
<Run Text="{Binding Row, Mode=OneWay, StringFormat='R{0}'}" />
<Run Text=" " />
<Run Text="{Binding Column, Mode=OneWay, StringFormat='C{0}'}" />
</TextBlock>
<!-- 偏移坐标 | Offset coordinates -->
<TextBlock
HorizontalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontSize="9"
Foreground="#666">
<Run Text="{Binding OffsetX, Mode=OneWay, StringFormat='X:{0:F1}'}" />
<Run Text=" " />
<Run Text="{Binding OffsetY, Mode=OneWay, StringFormat='Y:{0:F1}'}" />
</TextBlock>
</StackPanel>
<!-- 启用/禁用切换按钮 | Enable/Disable toggle button -->
<Button
x:Name="ToggleBtn"
Grid.Row="1"
HorizontalAlignment="Center"
Margin="0,2,0,0"
Padding="4,1"
Background="Transparent"
<!-- 矩阵网格内容 | Matrix grid content -->
<ScrollViewer
Grid.Row="1"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Auto">
<ItemsControl
Margin="8"
ItemsSource="{Binding Cells}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<!-- 使用 UniformGrid 按行列排列单元格 | Use UniformGrid to arrange cells by rows and columns -->
<UniformGrid Columns="{Binding Columns}" Rows="{Binding Rows}" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate>
<!-- 单元格视觉模板 | Cell visual template -->
<Border
x:Name="CellBorder"
MinWidth="80"
MinHeight="64"
Margin="2"
Padding="4"
Background="White"
BorderBrush="#B0C4DE"
BorderThickness="1"
Command="{Binding DataContext.ToggleCellCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
Content="✓"
Cursor="Hand"
FontFamily="Microsoft YaHei UI"
FontSize="9"
ToolTip="切换启用/禁用 | Toggle enabled/disabled" />
</Grid>
</Border>
<DataTemplate.Triggers>
<!-- 禁用状态:灰色背景 | Disabled state: gray background -->
<DataTrigger Binding="{Binding IsEnabled}" Value="False">
<Setter TargetName="CellBorder" Property="Background" Value="#E8E8E8" />
<Setter TargetName="CellBorder" Property="BorderBrush" Value="#CCCCCC" />
<Setter TargetName="CellBorder" Property="Opacity" Value="0.6" />
</DataTrigger>
<!-- 选中状态:蓝色高亮 | Selected state: blue highlight -->
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter TargetName="CellBorder" Property="Background" Value="#E3F0FF" />
<Setter TargetName="CellBorder" Property="BorderBrush" Value="#5B9BD5" />
<Setter TargetName="CellBorder" Property="BorderThickness" Value="2" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
CornerRadius="3"
Cursor="Hand">
<Border.InputBindings>
<!-- 左键点击选中单元格 | Left click to select cell -->
<MouseBinding
Command="{Binding DataContext.SelectCellCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
MouseAction="LeftClick" />
</Border.InputBindings>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<StackPanel
Grid.Row="0"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<!-- 行列索引 | Row/Column index -->
<TextBlock
x:Name="CellIndexText"
HorizontalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontSize="11"
FontWeight="SemiBold"
Foreground="#333">
<Run Text="{Binding Row, Mode=OneWay, StringFormat='R{0}'}" />
<Run Text=" " />
<Run Text="{Binding Column, Mode=OneWay, StringFormat='C{0}'}" />
</TextBlock>
<!-- 偏移坐标 | Offset coordinates -->
<TextBlock
x:Name="CellOffsetText"
HorizontalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontSize="9"
Foreground="#666">
<Run Text="{Binding OffsetX, Mode=OneWay, StringFormat='X:{0:F1}'}" />
<Run Text=" " />
<Run Text="{Binding OffsetY, Mode=OneWay, StringFormat='Y:{0:F1}'}" />
</TextBlock>
</StackPanel>
<!-- 启用/禁用切换按钮 | Enable/Disable toggle button -->
<Button
x:Name="ToggleBtn"
Grid.Row="1"
HorizontalAlignment="Center"
Margin="0,2,0,0"
Padding="4,1"
Background="Transparent"
BorderBrush="#B0C4DE"
BorderThickness="1"
Command="{Binding DataContext.ToggleCellCommand, RelativeSource={RelativeSource AncestorType=ItemsControl}}"
CommandParameter="{Binding}"
Content="✓"
Cursor="Hand"
FontFamily="Microsoft YaHei UI"
FontSize="9"
ToolTip="切换启用/禁用 | Toggle enabled/disabled" />
</Grid>
</Border>
<DataTemplate.Triggers>
<!-- ═══ 执行状态颜色样式 | Execution status color styles ═══ -->
<!-- 未执行状态:默认白色背景 | NotExecuted: default white background -->
<DataTrigger Binding="{Binding Status}" Value="{x:Static models:MatrixCellStatus.NotExecuted}">
<Setter TargetName="CellBorder" Property="Background" Value="White" />
</DataTrigger>
<!-- 执行中状态:蓝色高亮 | Executing: blue highlight -->
<DataTrigger Binding="{Binding Status}" Value="{x:Static models:MatrixCellStatus.Executing}">
<Setter TargetName="CellBorder" Property="Background" Value="#2196F3" />
<Setter TargetName="CellBorder" Property="BorderBrush" Value="#1565C0" />
<Setter TargetName="CellIndexText" Property="Foreground" Value="White" />
<Setter TargetName="CellOffsetText" Property="Foreground" Value="#E3F2FD" />
</DataTrigger>
<!-- 已完成状态:绿色背景 | Completed: green background -->
<DataTrigger Binding="{Binding Status}" Value="{x:Static models:MatrixCellStatus.Completed}">
<Setter TargetName="CellBorder" Property="Background" Value="#4CAF50" />
<Setter TargetName="CellBorder" Property="BorderBrush" Value="#2E7D32" />
<Setter TargetName="CellIndexText" Property="Foreground" Value="White" />
<Setter TargetName="CellOffsetText" Property="Foreground" Value="#E8F5E9" />
</DataTrigger>
<!-- 错误状态:红色背景 + ToolTip 显示错误信息 | Error: red background + ToolTip with error message -->
<DataTrigger Binding="{Binding Status}" Value="{x:Static models:MatrixCellStatus.Error}">
<Setter TargetName="CellBorder" Property="Background" Value="#F44336" />
<Setter TargetName="CellBorder" Property="BorderBrush" Value="#C62828" />
<Setter TargetName="CellBorder" Property="ToolTip" Value="{Binding ErrorMessage}" />
<Setter TargetName="CellIndexText" Property="Foreground" Value="White" />
<Setter TargetName="CellOffsetText" Property="Foreground" Value="#FFEBEE" />
</DataTrigger>
<!-- 已禁用状态:灰色半透明 | Disabled: gray semi-transparent -->
<DataTrigger Binding="{Binding IsEnabled}" Value="False">
<Setter TargetName="CellBorder" Property="Background" Value="#E8E8E8" />
<Setter TargetName="CellBorder" Property="BorderBrush" Value="#CCCCCC" />
<Setter TargetName="CellBorder" Property="Opacity" Value="0.6" />
</DataTrigger>
<!-- 选中状态:蓝色高亮边框 | Selected state: blue highlight border -->
<DataTrigger Binding="{Binding IsSelected}" Value="True">
<Setter TargetName="CellBorder" Property="BorderBrush" Value="#5B9BD5" />
<Setter TargetName="CellBorder" Property="BorderThickness" Value="2" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</Grid>
<!-- ═══ 底部状态栏 | Bottom status bar ═══ -->
<Border
Grid.Row="1"
BorderBrush="{StaticResource SeparatorBrush}"
BorderThickness="0,1,0,0"
Padding="8,4">
<TextBlock
FontFamily="{StaticResource CsdFont}"
FontSize="11"
Foreground="#555"
Text="{Binding StatusText}"
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center" />
</Border>
</Grid>
</Border>
</UserControl>
</UserControl>
@@ -20,15 +20,9 @@
</Grid.RowDefinitions>
<!-- 顶部工具栏 -->
<ToolBarTray Grid.Row="0" Background="#F5F5F5">
<ToolBar Background="Transparent">
<Button Content="💾 保存布局" Command="{Binding SaveLayoutCommand}" Padding="8,4" />
<Separator />
<Button Content="🔄 重置布局" Command="{Binding ResetLayoutCommand}" Padding="8,4" />
<Separator />
<Button Content="📤 导出全部" Command="{Binding ExportAllCommand}" Padding="8,4" />
</ToolBar>
</ToolBarTray>
<Border Grid.Row="0" Background="#F5F5F5" BorderBrush="#E0E0E0" BorderThickness="0,0,0,1">
<StackPanel Orientation="Horizontal" Margin="4"/>
</Border>
<!-- 主内容区:使用 Grid 布局精确控制各面板的位置和大小 -->
<Grid Grid.Row="1" Margin="4">
+3 -3
View File
@@ -34,9 +34,9 @@
IsReadOnly="True">
<telerik:RadGridView.Columns>
<telerik:GridViewDataColumn Header="时间" DataMemberBinding="{Binding Timestamp, Converter={StaticResource TimestampFormatConverter}}" Width="120" />
<telerik:GridViewDataColumn Header="事件" DataMemberBinding="{Binding EventType}" Width="180" />
<telerik:GridViewDataColumn Header="类别" DataMemberBinding="{Binding Category}" Width="140" />
<telerik:GridViewDataColumn Header="字段" DataMemberBinding="{Binding FieldName}" Width="150" />
<telerik:GridViewDataColumn Header="事件" DataMemberBinding="{Binding EventTypeDisplay}" Width="180" />
<telerik:GridViewDataColumn Header="类别" DataMemberBinding="{Binding CategoryDisplay}" Width="140" />
<telerik:GridViewDataColumn Header="字段" DataMemberBinding="{Binding FieldNameDisplay}" Width="150" />
<telerik:GridViewDataColumn Header="旧值" DataMemberBinding="{Binding OldValue}" Width="*" />
<telerik:GridViewDataColumn Header="新值" DataMemberBinding="{Binding NewValue}" Width="*" />
</telerik:RadGridView.Columns>
@@ -21,51 +21,21 @@
<Grid Margin="8">
<Grid.RowDefinitions>
<RowDefinition Height="2*" />
<RowDefinition Height="3*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Text="{Binding MaxLatency, StringFormat=最大延迟: {0:F2} ms}" FontWeight="SemiBold" Margin="0,0,0,8" />
<TextBlock Text="{Binding MaxLatency, StringFormat=最大延迟: {0:F2} ms}" FontWeight="SemiBold" />
<telerik:RadGridView Grid.Row="1"
Margin="0,8,0,0"
ItemsSource="{Binding Metrics}"
AutoGenerateColumns="False"
IsReadOnly="True">
<telerik:RadGridView.Columns>
<telerik:GridViewDataColumn Header="状态类型" DataMemberBinding="{Binding StateType}" Width="*" />
<telerik:GridViewDataColumn Header="频率(Hz)" DataMemberBinding="{Binding EventsPerSecond}" Width="120" />
<telerik:GridViewDataColumn Header="延迟(ms)" DataMemberBinding="{Binding AverageLatency}" Width="140" />
</telerik:RadGridView.Columns>
</telerik:RadGridView>
</Grid>
<telerik:RadChart Grid.Row="1" Margin="0,8,0,0" ItemsSource="{Binding TrendData}">
<telerik:RadChart.DefaultView>
<telerik:ChartDefaultView>
<telerik:ChartDefaultView.ChartArea>
<telerik:ChartArea />
</telerik:ChartDefaultView.ChartArea>
<telerik:ChartDefaultView.ChartLegend>
<telerik:ChartLegend />
</telerik:ChartDefaultView.ChartLegend>
</telerik:ChartDefaultView>
</telerik:RadChart.DefaultView>
<telerik:RadChart.SeriesMappings>
<telerik:SeriesMapping CollectionIndex="0">
<telerik:SeriesMapping.SeriesDefinition>
<telerik:LineSeriesDefinition />
</telerik:SeriesMapping.SeriesDefinition>
<telerik:ItemMapping DataPointMember="XValue" FieldName="Timestamp" />
<telerik:ItemMapping DataPointMember="YValue" FieldName="Value" />
</telerik:SeriesMapping>
</telerik:RadChart.SeriesMappings>
</telerik:RadChart>
<telerik:RadGridView Grid.Row="1"
ItemsSource="{Binding Metrics}"
AutoGenerateColumns="False"
IsReadOnly="True">
<telerik:RadGridView.Columns>
<telerik:GridViewDataColumn Header="状态类型" DataMemberBinding="{Binding StateTypeDisplay}" Width="*" />
<telerik:GridViewDataColumn Header="频率(Hz)" DataMemberBinding="{Binding EventsPerSecond}" Width="120" />
<telerik:GridViewDataColumn Header="延迟(ms)" DataMemberBinding="{Binding AverageLatency}" Width="140" />
</telerik:RadGridView.Columns>
</telerik:RadGridView>
</Grid>
</UserControl>
@@ -5,7 +5,7 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
mc:Ignorable="d"
Title="Snapshot Differences"
Title="快照差异对比"
Width="900"
Height="600"
WindowStartupLocation="CenterOwner">
@@ -17,15 +17,15 @@
<telerik:RadGridView ItemsSource="{Binding Differences}" AutoGenerateColumns="False" IsReadOnly="True">
<telerik:RadGridView.Columns>
<telerik:GridViewDataColumn Header="Category" DataMemberBinding="{Binding Category}" Width="180" />
<telerik:GridViewDataColumn Header="Field" DataMemberBinding="{Binding FieldName}" Width="180" />
<telerik:GridViewDataColumn Header="Snapshot 1" DataMemberBinding="{Binding Value1}" Width="*" />
<telerik:GridViewDataColumn Header="Snapshot 2" DataMemberBinding="{Binding Value2}" Width="*" />
<telerik:GridViewDataColumn Header="类别" DataMemberBinding="{Binding CategoryDisplay}" Width="180" />
<telerik:GridViewDataColumn Header="字段" DataMemberBinding="{Binding FieldNameDisplay}" Width="180" />
<telerik:GridViewDataColumn Header="快照1" DataMemberBinding="{Binding Value1}" Width="*" />
<telerik:GridViewDataColumn Header="快照2" DataMemberBinding="{Binding Value2}" Width="*" />
</telerik:RadGridView.Columns>
</telerik:RadGridView>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,8,0,0">
<Button Content="Export" Command="{Binding ExportDifferencesCommand}" Padding="12,4" />
<Button Content="导出" Command="{Binding ExportDifferencesCommand}" Padding="12,4" />
</StackPanel>
</Grid>
</Window>
@@ -34,10 +34,10 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding Name}"
Text="{Binding DisplayName}"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding Name}"
ToolTip="{Binding DisplayName}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1"
Text="{Binding Value}"
@@ -23,10 +23,10 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0"
Text="{Binding Name}"
Text="{Binding DisplayName}"
FontWeight="SemiBold"
TextTrimming="CharacterEllipsis"
ToolTip="{Binding Name}"
ToolTip="{Binding DisplayName}"
VerticalAlignment="Center" />
<TextBlock Grid.Column="1"
Text="{Binding Value}"
@@ -4,6 +4,7 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:helpers="clr-namespace:XplorePlane.Helpers"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="700"
d:DesignWidth="300"
@@ -80,6 +81,8 @@
<StackPanel
Grid.Row="0"
IsEnabled="{Binding CanEditPipeline}"
helpers:PermissionTooltipHelper.IsPermissionRestricted="True"
Orientation="Horizontal">
<Button
Command="{Binding NewPipelineCommand}"
@@ -287,6 +290,8 @@
<ScrollViewer
Grid.Row="3"
IsEnabled="{Binding CanEditPipeline}"
helpers:PermissionTooltipHelper.IsPermissionRestricted="True"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<StackPanel Margin="8,6">
@@ -153,6 +153,9 @@ namespace XplorePlane.Views
if (vm == null || clickedNode == null || IsInteractiveChild(e.OriginalSource))
return;
if (!vm.CanEditPipeline)
return;
// 双击切换启用/禁用
vm.ToggleOperatorEnabledCommand.Execute(clickedNode);
e.Handled = true;
@@ -163,6 +166,9 @@ namespace XplorePlane.Views
if (e.Key != Key.Delete || DataContext is not IPipelineEditorHostViewModel vm || vm.SelectedNode == null)
return;
if (!vm.CanEditPipeline)
return;
vm.RemoveOperatorCommand.Execute(vm.SelectedNode);
e.Handled = true;
}
@@ -215,11 +221,17 @@ namespace XplorePlane.Views
return;
}
if (!vm.CanEditPipeline)
return;
vm.AddOperatorCommand.Execute(operatorKey);
}
private void OnInternalNodeDropped(IPipelineEditorHostViewModel vm, DragEventArgs e)
{
if (!vm.CanEditPipeline)
return;
if (e.Data.GetData(PipelineNodeDragFormat) is not PipelineNodeViewModel draggedNode
|| !vm.PipelineNodes.Contains(draggedNode))
{
+76
View File
@@ -0,0 +1,76 @@
<Window x:Class="XplorePlane.Views.LoginDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
Title="用户登录"
SizeToContent="WidthAndHeight"
WindowStartupLocation="CenterScreen"
ResizeMode="NoResize"
FontFamily="Microsoft YaHei UI"
FontSize="13"
Background="#F5F5F5">
<Grid Margin="32,24,32,24" MinWidth="300">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- 标题 -->
<TextBlock Grid.Row="0"
Text="请输入密码"
FontSize="16"
FontWeight="SemiBold"
HorizontalAlignment="Center"
Margin="0,0,0,16" />
<!-- 密码输入框 -->
<PasswordBox x:Name="PasswordInput"
Grid.Row="1"
Height="32"
Padding="6,4"
PasswordChanged="PasswordInput_PasswordChanged"
KeyDown="PasswordInput_KeyDown"
Margin="0,0,0,8" />
<!-- 错误消息 -->
<TextBlock Grid.Row="2"
Text="{Binding ErrorMessage}"
Foreground="#D32F2F"
FontSize="12"
TextWrapping="Wrap"
Margin="0,0,0,16">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Visibility" Value="Collapsed" />
<Style.Triggers>
<DataTrigger Binding="{Binding HasError}" Value="True">
<Setter Property="Visibility" Value="Visible" />
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<!-- 按钮区域 -->
<StackPanel Grid.Row="3"
Orientation="Horizontal"
HorizontalAlignment="Right">
<Button Content="登录"
Width="80"
Height="30"
Command="{Binding LoginCommand}"
IsDefault="True"
Margin="0,0,8,0" />
<Button Content="取消"
Width="80"
Height="30"
Command="{Binding CancelCommand}"
IsCancel="True" />
</StackPanel>
</Grid>
</Window>
+65
View File
@@ -0,0 +1,65 @@
using System.ComponentModel;
using System.Windows;
using System.Windows.Input;
using XplorePlane.ViewModels;
namespace XplorePlane.Views
{
/// <summary>
/// LoginDialog.xaml 的交互逻辑
/// </summary>
public partial class LoginDialog : Window
{
public LoginDialog(LoginDialogViewModel viewModel)
{
InitializeComponent();
DataContext = viewModel;
// 订阅 ViewModel 的 PropertyChanged,当 DialogResult 变化时关闭窗口
viewModel.PropertyChanged += ViewModel_PropertyChanged;
// 窗口加载后自动聚焦密码框
Loaded += (_, _) => PasswordInput.Focus();
}
private LoginDialogViewModel ViewModel => DataContext as LoginDialogViewModel;
/// <summary>
/// 处理 PasswordBox 的 PasswordChanged 事件,将密码同步到 ViewModel。
/// WPF 的 PasswordBox 不支持数据绑定,需要通过代码后置处理。
/// </summary>
private void PasswordInput_PasswordChanged(object sender, RoutedEventArgs e)
{
if (ViewModel != null)
{
ViewModel.Password = PasswordInput.Password;
}
}
/// <summary>
/// 处理回车键快捷登录
/// </summary>
private void PasswordInput_KeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter && ViewModel?.LoginCommand?.CanExecute() == true)
{
ViewModel.LoginCommand.Execute();
}
}
/// <summary>
/// 监听 ViewModel 的 DialogResult 属性变化,设置窗口的 DialogResult 以关闭对话框。
/// </summary>
private void ViewModel_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(LoginDialogViewModel.DialogResult))
{
var result = ViewModel?.DialogResult;
if (result.HasValue)
{
DialogResult = result.Value;
}
}
}
}
}
+19 -2
View File
@@ -9,6 +9,7 @@
xmlns:spreadsheetControls="clr-namespace:Telerik.Windows.Controls.Spreadsheet.Controls;assembly=Telerik.Windows.Controls.Spreadsheet"
xmlns:telerik="http://schemas.telerik.com/2008/xaml/presentation"
xmlns:views="clr-namespace:XplorePlane.Views"
xmlns:mainViews="clr-namespace:XplorePlane.Views.Main"
xmlns:views1="clr-namespace:XP.Hardware.RaySource.Views;assembly=XP.Hardware.RaySource"
xmlns:mcViews="clr-namespace:XP.Hardware.MotionControl.Views;assembly=XP.Hardware.MotionControl"
x:Name="ParentWindow"
@@ -444,6 +445,12 @@
SmallImage="/Assets/Icons/setting.png"
Command="{Binding OpenSettingsCommand}"
Text="设置" />
<telerik:RadRibbonButton
Size="Large"
SmallImage="/Assets/Icons/tools.png"
Command="{Binding OpenPasswordManagementCommand}"
Text="密码管理"
Visibility="{Binding IsFactorySettingsVisible, Converter={StaticResource BoolToVisibilityConverter}}" />
</telerik:RadRibbonGroup>
<telerik:RadRibbonGroup
@@ -452,7 +459,8 @@
DialogLauncherCommandParameter="Alignment"
DialogLauncherVisibility="Collapsed"
Header="硬件"
IsEnabled="True">
IsEnabled="True"
Visibility="{Binding IsFactorySettingsVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<telerik:RadRibbonGroup.Variants>
<telerik:GroupVariant Priority="0" Variant="Large" />
</telerik:RadRibbonGroup.Variants>
@@ -593,7 +601,8 @@
Command="{Binding OpenReportConfigCommand}"
Size="Large"
SmallImage="/Assets/Icons/message.png"
Text="报告生成" />
Text="报告生成"
Visibility="{Binding IsReportSettingsVisible, Converter={StaticResource BoolToVisibilityConverter}}" />
</telerik:RadRibbonGroup>
</telerik:RadRibbonTab>
<telerik:RadRibbonTab Header="帮助">
@@ -616,6 +625,14 @@
</telerik:RadRibbonTab>
</telerik:RadRibbonView>
<!-- Ribbon 右侧状态区域:显示当前角色和切换用户按钮 -->
<mainViews:RibbonStatusAreaView
Grid.ColumnSpan="3"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,6,12,0"
DataContext="{Binding RibbonStatusArea}" />
<Grid
Grid.Row="1"
Grid.ColumnSpan="3"
@@ -0,0 +1,32 @@
<UserControl
x:Class="XplorePlane.Views.Main.RibbonStatusAreaView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
d:DesignHeight="30"
d:DesignWidth="200"
mc:Ignorable="d">
<StackPanel
Orientation="Horizontal"
VerticalAlignment="Center"
Margin="0,0,8,0">
<TextBlock
Text="{Binding CurrentRoleDisplayName}"
VerticalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontSize="12"
FontWeight="SemiBold"
Foreground="#333333"
Margin="0,0,8,0" />
<Button
Content="切换用户"
Command="{Binding SwitchUserCommand}"
IsEnabled="{Binding CanSwitchUser}"
VerticalAlignment="Center"
FontFamily="Microsoft YaHei UI"
FontSize="11"
Padding="8,3"
MinWidth="60" />
</StackPanel>
</UserControl>
@@ -0,0 +1,16 @@
using System.Windows.Controls;
namespace XplorePlane.Views.Main
{
/// <summary>
/// Ribbon 右侧状态区域视图。
/// 显示当前角色名称和"切换用户"按钮。
/// </summary>
public partial class RibbonStatusAreaView : UserControl
{
public RibbonStatusAreaView()
{
InitializeComponent();
}
}
}
@@ -35,6 +35,7 @@
x:Name="RoiCanvas"
Background="White"
ImageSource="{Binding ImageSource}"
IsWheelZoomEnabled="{Binding IsWheelZoomEnabled}"
ScaleBarMmPerPixel="0.139"
ShowScaleBar="{Binding DataContext.IsScaleBarVisible, RelativeSource={RelativeSource AncestorType=Window}}" />
</Grid>
@@ -0,0 +1,131 @@
<Window
x:Class="XplorePlane.Views.Setting.PasswordManagementView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="密码管理"
Width="480"
Height="360"
WindowStartupLocation="CenterOwner"
ResizeMode="NoResize"
FontFamily="Microsoft YaHei UI"
FontSize="13">
<Window.Resources>
<Style x:Key="MessageStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="11" />
<Setter Property="Margin" Value="0,2,0,0" />
<Style.Triggers>
<DataTrigger Binding="{Binding Path=Tag, RelativeSource={RelativeSource Self}}" Value="True">
<Setter Property="Foreground" Value="#CC0000" />
</DataTrigger>
<DataTrigger Binding="{Binding Path=Tag, RelativeSource={RelativeSource Self}}" Value="False">
<Setter Property="Foreground" Value="#008000" />
</DataTrigger>
</Style.Triggers>
</Style>
</Window.Resources>
<Grid Margin="24,20,24,20">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="16" />
<RowDefinition Height="Auto" />
<RowDefinition Height="16" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<!-- 超级管理员 -->
<StackPanel Grid.Row="0">
<TextBlock Text="超级管理员密码" FontWeight="SemiBold" Margin="0,0,0,6" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Height="28"
VerticalContentAlignment="Center"
Padding="6,0"
Text="{Binding SuperAdminPassword, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="2"
Content="修改"
Width="60"
Height="28"
Command="{Binding ChangeSuperAdminPasswordCommand}" />
</Grid>
<TextBlock
Text="{Binding SuperAdminMessage}"
Tag="{Binding SuperAdminMessageIsError}"
Style="{StaticResource MessageStyle}" />
</StackPanel>
<!-- 管理员 -->
<StackPanel Grid.Row="2">
<TextBlock Text="管理员密码" FontWeight="SemiBold" Margin="0,0,0,6" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Height="28"
VerticalContentAlignment="Center"
Padding="6,0"
Text="{Binding AdminPassword, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="2"
Content="修改"
Width="60"
Height="28"
Command="{Binding ChangeAdminPasswordCommand}" />
</Grid>
<TextBlock
Text="{Binding AdminMessage}"
Tag="{Binding AdminMessageIsError}"
Style="{StaticResource MessageStyle}" />
</StackPanel>
<!-- 普通用户 -->
<StackPanel Grid.Row="4">
<TextBlock Text="用户密码" FontWeight="SemiBold" Margin="0,0,0,6" />
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="12" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox
Grid.Column="0"
Height="28"
VerticalContentAlignment="Center"
Padding="6,0"
Text="{Binding UserPassword, UpdateSourceTrigger=PropertyChanged}" />
<Button
Grid.Column="2"
Content="修改"
Width="60"
Height="28"
Command="{Binding ChangeUserPasswordCommand}" />
</Grid>
<TextBlock
Text="{Binding UserMessage}"
Tag="{Binding UserMessageIsError}"
Style="{StaticResource MessageStyle}" />
</StackPanel>
<!-- 关闭按钮 -->
<Button
Grid.Row="6"
Content="关闭"
Width="80"
Height="30"
HorizontalAlignment="Right"
Click="CloseButton_Click" />
</Grid>
</Window>
@@ -0,0 +1,18 @@
using System.Windows;
namespace XplorePlane.Views.Setting
{
public partial class PasswordManagementView : Window
{
public PasswordManagementView(object viewModel)
{
InitializeComponent();
DataContext = viewModel;
}
private void CloseButton_Click(object sender, RoutedEventArgs e)
{
Close();
}
}
}
@@ -2,14 +2,16 @@
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="系统设置"
Width="1000"
Width="800"
Height="700"
MinWidth="900"
MinWidth="800"
MinHeight="600"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
Background="#F5F5F5">
<Window.Resources>
<BooleanToVisibilityConverter x:Key="BoolToVisibilityConverter" />
<Style x:Key="SectionTitleStyle" TargetType="TextBlock">
<Setter Property="FontSize" Value="16" />
<Setter Property="FontWeight" Value="SemiBold" />
@@ -294,7 +296,7 @@
</TabItem>
<!-- 射线源设置 -->
<TabItem Header="射线源">
<TabItem Header="射线源" Visibility="{Binding IsFactorySettingsVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<TextBlock Style="{StaticResource SectionTitleStyle}"
@@ -368,7 +370,7 @@
</TabItem>
<!-- 探测器设置 -->
<TabItem Header="探测器">
<TabItem Header="探测器" Visibility="{Binding IsFactorySettingsVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<TextBlock Style="{StaticResource SectionTitleStyle}"
@@ -452,7 +454,7 @@
</TabItem>
<!-- PLC设置 -->
<TabItem Header="PLC">
<TabItem Header="PLC" Visibility="{Binding IsFactorySettingsVisible, Converter={StaticResource BoolToVisibilityConverter}}">
<ScrollViewer VerticalScrollBarVisibility="Auto">
<StackPanel Margin="16">
<TextBlock Style="{StaticResource SectionTitleStyle}"