diff --git a/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs b/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs index 7a6be26..86f8309 100644 --- a/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs +++ b/XplorePlane.Tests/Pipeline/PipelinePersistenceServiceTests.cs @@ -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(); + mockPermissionSvc.Setup(p => p.HasPermission(It.IsAny())).Returns(true); + var mockLogger = new Mock(); + mockLogger.Setup(l => l.ForModule()).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(); + mockPermissionSvc.Setup(p => p.HasPermission(It.IsAny())).Returns(true); + var mockLogger = new Mock(); + mockLogger.Setup(l => l.ForModule()).Returns(mockLogger.Object); var mockDataPathSvc = new Mock(); 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); diff --git a/XplorePlane.Tests/Pipeline/PipelinePropertyTests.cs b/XplorePlane.Tests/Pipeline/PipelinePropertyTests.cs index 8558cc1..64253d0 100644 --- a/XplorePlane.Tests/Pipeline/PipelinePropertyTests.cs +++ b/XplorePlane.Tests/Pipeline/PipelinePropertyTests.cs @@ -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(); + mockPermissionSvc.Setup(p => p.HasPermission(It.IsAny())).Returns(true); + var mockLogger = new Mock(); + mockLogger.Setup(l => l.ForModule()).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++) diff --git a/XplorePlane.Tests/Services/CncProgramServiceTests.cs b/XplorePlane.Tests/Services/CncProgramServiceTests.cs index 0656f42..05e4352 100644 --- a/XplorePlane.Tests/Services/CncProgramServiceTests.cs +++ b/XplorePlane.Tests/Services/CncProgramServiceTests.cs @@ -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(); logger.Setup(l => l.ForModule()).Returns(logger.Object); - var service = new CncProgramService(appState.Object, raySource.Object, logger.Object); + var permissionService = new Mock(); + permissionService.Setup(p => p.HasPermission(It.IsAny())).Returns(true); + + var service = new CncProgramService(appState.Object, raySource.Object, logger.Object, permissionService.Object); var program = new CncProgram( Guid.NewGuid(), "Program", diff --git a/XplorePlane.Tests/ViewModels/CncEditorViewModelTests.cs b/XplorePlane.Tests/ViewModels/CncEditorViewModelTests.cs index 5dba882..baeea44 100644 --- a/XplorePlane.Tests/ViewModels/CncEditorViewModelTests.cs +++ b/XplorePlane.Tests/ViewModels/CncEditorViewModelTests.cs @@ -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(); var mockLogger = new Mock(); var mockDataPathService = new Mock(); + var mockPermissionService = new Mock(); mockPipelinePersistenceService ??= new Mock(); mockLogger.Setup(l => l.ForModule()).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())).Returns(true); + mockExecSvc ??= new Mock(); // 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) { diff --git a/XplorePlane/App.xaml.cs b/XplorePlane/App.xaml.cs index a13c313..98ac0dc 100644 --- a/XplorePlane/App.xaml.cs +++ b/XplorePlane/App.xaml.cs @@ -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; @@ -346,6 +348,22 @@ namespace XplorePlane // return null; //} + // ── 登录认证:在显示主窗口前弹出登录对话框 ── + var loginViewModel = Container.Resolve(); + var loginDialog = new LoginDialog(loginViewModel); + var loginResult = loginDialog.ShowDialog(); + + if (loginResult != true) + { + // 用户取消登录,退出应用 + Log.Information("用户取消登录,应用程序退出"); + Application.Current.Shutdown(); + return null; + } + + Log.Information("登录认证成功,角色: {Role}", + Container.Resolve().CurrentRole); + var shell = Container.Resolve(); // 主窗体加载完成后再连接相机,确保所有模块和原生 DLL 已完成初始化 @@ -668,6 +686,18 @@ namespace XplorePlane // ── 录制服务(单例)── containerRegistry.RegisterSingleton(); + // ── 权限管理服务(单例)── + containerRegistry.RegisterSingleton(); + + // ── 登录对话框 ViewModel(瞬态)── + containerRegistry.Register(); + + // ── Ribbon 右侧状态区域 ViewModel(单例,跟随 MainViewModel 生命周期)── + containerRegistry.RegisterSingleton(); + + // ── 密码管理 ViewModel(瞬态,每次打开对话框创建新实例)── + containerRegistry.Register(); + Log.Information("依赖注入容器配置完成"); } diff --git a/XplorePlane/Events/RoleChangedEvent.cs b/XplorePlane/Events/RoleChangedEvent.cs new file mode 100644 index 0000000..005592d --- /dev/null +++ b/XplorePlane/Events/RoleChangedEvent.cs @@ -0,0 +1,18 @@ +using Prism.Events; +using XplorePlane.Models; + +namespace XplorePlane.Events +{ + /// + /// 角色变更事件,通过 Prism EventAggregator 发布。 + /// 订阅方:所有需要根据角色刷新 UI 状态的 ViewModel。 + /// + public class RoleChangedEvent : PubSubEvent + { + } + + /// 角色变更载荷。 + /// 变更前角色(首次登录时为 null)。 + /// 变更后角色。 + public record RoleChangedPayload(UserRole? OldRole, UserRole NewRole); +} diff --git a/XplorePlane/Helpers/PermissionTooltipHelper.cs b/XplorePlane/Helpers/PermissionTooltipHelper.cs new file mode 100644 index 0000000..6ac74c1 --- /dev/null +++ b/XplorePlane/Helpers/PermissionTooltipHelper.cs @@ -0,0 +1,148 @@ +using System.Windows; +using System.Windows.Controls; + +namespace XplorePlane.Helpers +{ + /// + /// 权限提示附加属性。当控件因权限不足被禁用时,显示提示信息。 + /// 用法:helpers:PermissionTooltipHelper.IsPermissionRestricted="True" + /// 当 IsEnabled=False 时自动显示 "当前角色无权访问此功能" 的 ToolTip。 + /// + 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); + } + } + } +} diff --git a/XplorePlane/Models/AuthenticationResult.cs b/XplorePlane/Models/AuthenticationResult.cs new file mode 100644 index 0000000..a0746b0 --- /dev/null +++ b/XplorePlane/Models/AuthenticationResult.cs @@ -0,0 +1,4 @@ +namespace XplorePlane.Models +{ + public record AuthenticationResult(bool Success, UserRole? Role, string ErrorMessage = null); +} diff --git a/XplorePlane/Models/PasswordChangeResult.cs b/XplorePlane/Models/PasswordChangeResult.cs new file mode 100644 index 0000000..f885e46 --- /dev/null +++ b/XplorePlane/Models/PasswordChangeResult.cs @@ -0,0 +1,4 @@ +namespace XplorePlane.Models +{ + public record PasswordChangeResult(bool Success, string ErrorMessage = null); +} diff --git a/XplorePlane/Models/PasswordStorageModel.cs b/XplorePlane/Models/PasswordStorageModel.cs new file mode 100644 index 0000000..2ebb6f9 --- /dev/null +++ b/XplorePlane/Models/PasswordStorageModel.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; + +namespace XplorePlane.Models +{ + public class PasswordStorageModel + { + public int Version { get; set; } = 1; + public Dictionary Passwords { get; set; } = new(); + public DateTime LastModified { get; set; } + } +} diff --git a/XplorePlane/Models/Permission.cs b/XplorePlane/Models/Permission.cs new file mode 100644 index 0000000..d26b796 --- /dev/null +++ b/XplorePlane/Models/Permission.cs @@ -0,0 +1,44 @@ +namespace XplorePlane.Models +{ + /// 系统权限标识,每个可控功能对应一个枚举值。 + 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 + } +} diff --git a/XplorePlane/Models/RoleChangedEventArgs.cs b/XplorePlane/Models/RoleChangedEventArgs.cs new file mode 100644 index 0000000..d0ab129 --- /dev/null +++ b/XplorePlane/Models/RoleChangedEventArgs.cs @@ -0,0 +1,22 @@ +using System; + +namespace XplorePlane.Models +{ + /// + /// 角色变更事件参数(供不使用 EventAggregator 的组件直接订阅)。 + /// + public class RoleChangedEventArgs : EventArgs + { + /// 变更前角色(首次登录时为 null)。 + public UserRole? OldRole { get; } + + /// 变更后角色。 + public UserRole NewRole { get; } + + public RoleChangedEventArgs(UserRole? oldRole, UserRole newRole) + { + OldRole = oldRole; + NewRole = newRole; + } + } +} diff --git a/XplorePlane/Models/UserRole.cs b/XplorePlane/Models/UserRole.cs new file mode 100644 index 0000000..948daaf --- /dev/null +++ b/XplorePlane/Models/UserRole.cs @@ -0,0 +1,10 @@ +namespace XplorePlane.Models +{ + /// 系统角色定义,层级从高到低。 + public enum UserRole + { + SuperAdmin = 0, // 超级管理员 + Admin = 1, // 管理员 + User = 2 // 普通用户 + } +} diff --git a/XplorePlane/Services/Cnc/CncProgramService.cs b/XplorePlane/Services/Cnc/CncProgramService.cs index 44ee7d3..5879592 100644 --- a/XplorePlane/Services/Cnc/CncProgramService.cs +++ b/XplorePlane/Services/Cnc/CncProgramService.cs @@ -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(); + _permissionService = permissionService; _logger.Info("CncProgramService 已初始化 | CncProgramService initialized"); } @@ -51,6 +57,13 @@ namespace XplorePlane.Services.Cnc /// 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 /// 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 /// 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 /// 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 /// 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 /// 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); diff --git a/XplorePlane/Services/Permission/DefaultPasswords.cs b/XplorePlane/Services/Permission/DefaultPasswords.cs new file mode 100644 index 0000000..272c77f --- /dev/null +++ b/XplorePlane/Services/Permission/DefaultPasswords.cs @@ -0,0 +1,13 @@ +namespace XplorePlane.Services.Permission +{ + /// + /// 工厂默认密码(编译时常量)。 + /// 当密码文件缺失或损坏时,系统回退到这些默认值。 + /// + internal static class DefaultPasswords + { + public const string SuperAdmin = "xpadmin"; + public const string Admin = "xpuser"; + public const string User = "1234"; + } +} diff --git a/XplorePlane/Services/Permission/IPermissionService.cs b/XplorePlane/Services/Permission/IPermissionService.cs new file mode 100644 index 0000000..5857c49 --- /dev/null +++ b/XplorePlane/Services/Permission/IPermissionService.cs @@ -0,0 +1,50 @@ +using System; +using XplorePlane.Models; +using PermissionEnum = XplorePlane.Models.Permission; + +namespace XplorePlane.Services.Permission +{ + /// + /// 权限管理服务接口。单例注册,管理用户认证、角色状态和权限校验。 + /// + public interface IPermissionService + { + /// 当前登录角色。未认证时为 null。 + UserRole? CurrentRole { get; } + + /// 角色变更事件(供不使用 EventAggregator 的组件直接订阅)。 + event EventHandler RoleChanged; + + /// + /// 验证密码并设置当前角色。 + /// + /// 用户输入的密码。 + /// 认证结果,包含是否成功和匹配的角色。 + AuthenticationResult Authenticate(string password); + + /// + /// 检查当前角色是否拥有指定权限。 + /// + bool HasPermission(PermissionEnum permission); + + /// + /// 检查指定角色是否拥有指定权限(用于测试和预判)。 + /// + bool HasPermission(UserRole role, PermissionEnum permission); + + /// + /// 修改指定角色的密码。仅 Super_Admin 可调用。 + /// + PasswordChangeResult ChangePassword(UserRole targetRole, string newPassword); + + /// + /// 当前是否允许切换用户(CNC 执行中不允许)。 + /// + bool CanSwitchUser { get; } + + /// + /// 注销当前用户(切换用户前调用)。 + /// + void Logout(); + } +} diff --git a/XplorePlane/Services/Permission/PermissionMatrix.cs b/XplorePlane/Services/Permission/PermissionMatrix.cs new file mode 100644 index 0000000..287ea6e --- /dev/null +++ b/XplorePlane/Services/Permission/PermissionMatrix.cs @@ -0,0 +1,72 @@ +using System.Collections.Generic; +using XplorePlane.Models; + +namespace XplorePlane.Services.Permission +{ + /// + /// 角色-权限静态映射表。层级关系:SuperAdmin ⊇ Admin ⊇ User。 + /// + internal static class PermissionMatrix + { + /// + /// 每个角色独有的权限集合(不含继承)。 + /// 层级检查通过 HasPermission 方法从当前角色向下累积实现。 + /// + private static readonly Dictionary> RolePermissions = new() + { + [UserRole.User] = new HashSet + { + Models.Permission.CncViewProgram, + Models.Permission.CncRunProgram, + Models.Permission.CncStopProgram, + Models.Permission.ViewInspectionResults, + Models.Permission.SwitchUser + }, + [UserRole.Admin] = new HashSet + { + // 继承 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 + { + // 继承 Admin 所有权限 + + Models.Permission.AccessPlcSettings, + Models.Permission.AccessHardwareSettings, + Models.Permission.AccessMotionControlSettings, + Models.Permission.AccessDetectorSettings, + Models.Permission.AccessRaySourceSettings, + Models.Permission.ManagePasswords + } + }; + + /// + /// 检查指定角色是否拥有指定权限(含层级继承)。 + /// 从当前角色向下(User 方向)逐级检查,任一级别包含该权限即返回 true。 + /// + /// 要检查的角色。 + /// 要检查的权限。 + /// 如果角色拥有该权限则返回 true,否则返回 false。 + 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; + } + } +} diff --git a/XplorePlane/Services/Permission/PermissionService.cs b/XplorePlane/Services/Permission/PermissionService.cs new file mode 100644 index 0000000..5b23909 --- /dev/null +++ b/XplorePlane/Services/Permission/PermissionService.cs @@ -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 +{ + /// + /// 权限管理服务实现。单例注册,管理用户认证、角色状态和权限校验。 + /// + public class PermissionService : IPermissionService + { + private readonly IEventAggregator _eventAggregator; + private readonly IAppStateService _appStateService; + private readonly ILoggerService _logger; + + private Dictionary _passwordToRoleMap; + + /// 密码文件路径。 + private static readonly string PasswordFilePath = Path.Combine( + AppDomain.CurrentDomain.BaseDirectory, "passwords.json"); + + /// + public UserRole? CurrentRole { get; private set; } + + /// + public event EventHandler RoleChanged; + + /// + 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(); + + LoadPasswords(); + + _logger.Info("PermissionService 已初始化"); + } + + /// + 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() + .Publish(new RoleChangedPayload(oldRole, role)); + + // 发布直接事件 + RoleChanged?.Invoke(this, new RoleChangedEventArgs(oldRole, role)); + + return new AuthenticationResult(true, role); + } + + _logger.Warn("认证失败,密码不匹配"); + return new AuthenticationResult(false, null, "认证失败"); + } + + /// + 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; + } + + /// + public bool HasPermission(UserRole role, PermissionEnum permission) + { + return PermissionMatrix.HasPermission(role, permission); + } + + /// + 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); + } + + /// + public void Logout() + { + var oldRole = CurrentRole; + CurrentRole = null; + + if (oldRole != null) + { + _logger.Info("用户已注销,原角色: {Role}", oldRole); + } + } + + /// + /// 从密码文件加载密码,失败时回退到工厂默认密码。 + /// + private void LoadPasswords() + { + _passwordToRoleMap = new Dictionary(); + + try + { + if (File.Exists(PasswordFilePath)) + { + var json = File.ReadAllText(PasswordFilePath); + var storage = JsonConvert.DeserializeObject(json); + + if (storage?.Passwords != null && storage.Passwords.Count > 0) + { + foreach (var kvp in storage.Passwords) + { + if (Enum.TryParse(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(); + } + + /// + /// 加载工厂默认密码。 + /// + private void LoadDefaultPasswords() + { + _passwordToRoleMap = new Dictionary + { + [DefaultPasswords.SuperAdmin] = UserRole.SuperAdmin, + [DefaultPasswords.Admin] = UserRole.Admin, + [DefaultPasswords.User] = UserRole.User + }; + } + + /// + /// 将当前密码保存到文件。 + /// + /// 是否保存成功。 + private bool SavePasswords() + { + try + { + var passwords = new Dictionary(); + 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; + } + } + } +} diff --git a/XplorePlane/Services/Pipeline/PipelinePersistenceService.cs b/XplorePlane/Services/Pipeline/PipelinePersistenceService.cs index 02ede5c..851de11 100644 --- a/XplorePlane/Services/Pipeline/PipelinePersistenceService.cs +++ b/XplorePlane/Services/Pipeline/PipelinePersistenceService.cs @@ -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(); _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); diff --git a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs index fd9a1a4..6c8aeb1 100644 --- a/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncEditorViewModel.cs @@ -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 _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(); @@ -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(nodeVm => SetPendingInsertAnchor(nodeVm, insertAfter: false)); PrepareInsertBelowCommand = new DelegateCommand(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().Subscribe(OnRoleChanged); + _logger.Info("CncEditorViewModel initialized"); } @@ -191,6 +199,12 @@ namespace XplorePlane.ViewModels.Cnc private set => SetProperty(ref _hasExecutionError, value); } + /// 当前角色是否允许编辑 CNC 程序(插入、删除、重命名、重排序、新建、保存、删除文件)。 + public bool CanEditCncProgram => _permissionService.HasPermission(PermissionEnum.CncInsertNode); + + /// 当前角色是否允许编辑检测模块(添加、删除、重排序、启用/禁用、编辑参数)。 + 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; diff --git a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs index 5806999..dcaa593 100644 --- a/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs +++ b/XplorePlane/ViewModels/Cnc/CncInspectionModulePipelineViewModel.cs @@ -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(); @@ -84,6 +89,9 @@ namespace XplorePlane.ViewModels.Cnc _eventAggregator?.GetEvent() .Subscribe(key => { if (CanAddOperator(key)) AddOperator(key); }); + + _eventAggregator?.GetEvent() + .Subscribe(OnRoleChanged, ThreadOption.UIThread); } public ObservableCollection PipelineNodes { get; } @@ -150,6 +158,12 @@ namespace XplorePlane.ViewModels.Cnc public Visibility EmptyStateVisibility => HasActiveModule ? Visibility.Collapsed : Visibility.Visible; + /// + /// 当前角色是否有权编辑检测模块流水线(添加/删除/排序/启停算子、编辑参数)。 + /// + public bool CanEditPipeline => + _permissionService?.HasPermission(PermissionEnum.InspectionAddOperator) ?? true; + public ICommand AddOperatorCommand { get; } public ICommand RemoveOperatorCommand { get; } @@ -686,6 +700,11 @@ namespace XplorePlane.ViewModels.Cnc (AddOperatorCommand as DelegateCommand)?.RaiseCanExecuteChanged(); } + private void OnRoleChanged(RoleChangedPayload payload) + { + RaisePropertyChanged(nameof(CanEditPipeline)); + } + private static object ConvertSavedValue(object savedValue, Type targetType) { if (savedValue is not JsonElement jsonElement) diff --git a/XplorePlane/ViewModels/Debug/DebugPanelLocalization.cs b/XplorePlane/ViewModels/Debug/DebugPanelLocalization.cs new file mode 100644 index 0000000..466d646 --- /dev/null +++ b/XplorePlane/ViewModels/Debug/DebugPanelLocalization.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; + +namespace XplorePlane.ViewModels.Debug +{ + /// + /// 调试面板本地化映射:将状态类别、事件类型、字段名等英文标识翻译为中文显示名。 + /// 显示用中文,内部匹配/过滤仍使用英文标识,二者解耦。 + /// + internal static class DebugPanelLocalization + { + /// 状态类别中文名(MotionState → 运动状态) + public static readonly IReadOnlyDictionary Categories = new Dictionary + { + ["MotionState"] = "运动状态", + ["RaySourceState"] = "射线源状态", + ["DetectorState"] = "探测器状态", + ["SystemState"] = "系统状态", + ["CameraState"] = "相机状态", + ["LinkedViewState"] = "联动视图状态", + ["RecipeExecutionState"] = "配方执行状态", + ["CalibrationMatrix"] = "标定矩阵" + }; + + /// 事件类型中文名(MotionStateChanged → 运动状态变化) + public static readonly IReadOnlyDictionary EventTypes = new Dictionary + { + ["MotionStateChanged"] = "运动状态变化", + ["RaySourceStateChanged"] = "射线源状态变化", + ["DetectorStateChanged"] = "探测器状态变化", + ["SystemStateChanged"] = "系统状态变化", + ["CameraStateChanged"] = "相机状态变化", + ["LinkedViewStateChanged"] = "联动视图状态变化", + ["RecipeExecutionStateChanged"] = "配方执行状态变化" + }; + + /// 状态字段中文名(StageX → 载物台X位置 等) + public static readonly IReadOnlyDictionary Fields = new Dictionary + { + // 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)"] = "(无变化)" + }; + + /// 获取类别中文名,未匹配时返回原值 + public static string Category(string key) => + key != null && Categories.TryGetValue(key, out var v) ? v : key; + + /// 获取事件类型中文名,未匹配时返回原值 + public static string EventType(string key) => + key != null && EventTypes.TryGetValue(key, out var v) ? v : key; + + /// 获取字段中文名,未匹配时返回原值 + public static string Field(string key) => + key != null && Fields.TryGetValue(key, out var v) ? v : key; + } +} diff --git a/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs index d29a005..fc3d45e 100644 --- a/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/IPipelineEditorHostViewModel.cs @@ -15,6 +15,11 @@ namespace XplorePlane.ViewModels string PipelineFileDisplayName { get; } + /// + /// 当前角色是否有权编辑流水线。为 false 时禁用所有编辑操作。 + /// + bool CanEditPipeline { get; } + ICommand AddOperatorCommand { get; } ICommand RemoveOperatorCommand { get; } diff --git a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs index 0bbb6d7..7038462 100644 --- a/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs +++ b/XplorePlane/ViewModels/ImageProcessing/PipelineEditorViewModel.cs @@ -243,6 +243,9 @@ namespace XplorePlane.ViewModels ICommand IPipelineEditorHostViewModel.SaveAsPipelineCommand => SaveAsPipelineCommand; ICommand IPipelineEditorHostViewModel.LoadPipelineCommand => LoadPipelineCommand; + /// 独立流水线编辑器始终允许编辑(不受角色限制)。 + public bool CanEditPipeline => true; + // ── Command Implementations ─────────────────────────────────── private bool CanAddOperator(string operatorKey) => diff --git a/XplorePlane/ViewModels/LoginDialogViewModel.cs b/XplorePlane/ViewModels/LoginDialogViewModel.cs new file mode 100644 index 0000000..c83c82d --- /dev/null +++ b/XplorePlane/ViewModels/LoginDialogViewModel.cs @@ -0,0 +1,96 @@ +using Prism.Commands; +using Prism.Mvvm; +using XplorePlane.Services.Permission; + +namespace XplorePlane.ViewModels +{ + /// + /// 登录对话框 ViewModel。 + /// 负责密码输入验证和调用 IPermissionService 进行认证。 + /// + 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); + } + + /// 密码输入(通过 attached behavior 绑定 PasswordBox)。 + public string Password + { + get => _password; + set => SetProperty(ref _password, value); + } + + /// 错误消息,认证失败或空密码时显示。 + public string ErrorMessage + { + get => _errorMessage; + set + { + if (SetProperty(ref _errorMessage, value)) + { + HasError = !string.IsNullOrEmpty(value); + } + } + } + + /// 是否存在错误(ErrorMessage 非空时为 true)。 + public bool HasError + { + get => _hasError; + private set => SetProperty(ref _hasError, value); + } + + /// 对话框结果。设置后关闭对话框(true=认证成功,false=取消)。 + public bool? DialogResult + { + get => _dialogResult; + set => SetProperty(ref _dialogResult, value); + } + + /// 登录命令:校验非空 → 调用认证 → 成功关闭/失败显示错误。 + public DelegateCommand LoginCommand { get; } + + /// 取消命令:关闭对话框。 + 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; + } + } +} diff --git a/XplorePlane/ViewModels/Main/MainViewModel.cs b/XplorePlane/ViewModels/Main/MainViewModel.cs index 7cab4ef..bd45661 100644 --- a/XplorePlane/ViewModels/Main/MainViewModel.cs +++ b/XplorePlane/ViewModels/Main/MainViewModel.cs @@ -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; + /// + /// 厂家级设置导航项是否可见。仅 SuperAdmin 可见。 + /// 包括:射线源、探测器、运动控制、相机设置、PLC 地址等硬件设置。 + /// + public bool IsFactorySettingsVisible + { + get => _isFactorySettingsVisible; + private set => SetProperty(ref _isFactorySettingsVisible, value); + } + + private bool _isReportSettingsVisible = true; + /// + /// 报告设定导航项是否可见。SuperAdmin 和 Admin 可见,User 不可见。 + /// + 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() ?? 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(); _cncPageView = new CncPageView { DataContext = _cncEditorViewModel }; _pipelineEditorViewModel = _containerProvider.Resolve(); @@ -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() + .Subscribe(OnRoleChangedForSettings, ThreadOption.UIThread); + + // 初始化设置导航可见性 + RefreshSettingsVisibility(); + + // 初始化 Ribbon 右侧状态区域 ViewModel + RibbonStatusArea = _containerProvider.Resolve(); + _logger.Info("MainViewModel 已初始化"); } + /// Ribbon 右侧状态区域 ViewModel(角色显示 + 切换用户按钮)。 + 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(); @@ -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(), "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(); + 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 设置导航权限控制 + + /// + /// 角色变更时刷新设置导航可见性。 + /// + private void OnRoleChangedForSettings(RoleChangedPayload payload) + { + RefreshSettingsVisibility(); + } + + /// + /// 根据当前角色刷新设置导航项的可见性。 + /// Factory_Settings: 仅 SuperAdmin 可见。 + /// Report_Settings: SuperAdmin 和 Admin 可见,User 不可见。 + /// + private void RefreshSettingsVisibility() + { + IsFactorySettingsVisible = _permissionService.HasPermission(PermissionEnum.AccessPlcSettings); + IsReportSettingsVisible = _permissionService.HasPermission(PermissionEnum.AccessReportSettings); + } + + /// + /// 检查是否允许导航到厂家级设置页面。 + /// 如果权限不足,记录日志并显示通知。 + /// + /// 目标页面标识。 + /// 是否允许导航。 + 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; + } + + /// + /// 检查是否允许导航到报告设定页面。 + /// 如果权限不足,记录日志并显示通知。 + /// + /// 目标页面标识。 + /// 是否允许导航。 + 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 设置导航权限控制 } } \ No newline at end of file diff --git a/XplorePlane/ViewModels/Main/RibbonStatusAreaViewModel.cs b/XplorePlane/ViewModels/Main/RibbonStatusAreaViewModel.cs new file mode 100644 index 0000000..15d706e --- /dev/null +++ b/XplorePlane/ViewModels/Main/RibbonStatusAreaViewModel.cs @@ -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 +{ + /// + /// Ribbon 右侧状态区域 ViewModel。 + /// 显示当前角色中文名称标签和"切换用户"按钮。 + /// 位置:Ribbon 右侧区域(类似 Office 账户区域),认证成功后始终可见。 + /// + 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; + + /// 当前角色中文显示名(超级管理员 / 管理员 / 用户)。 + public string CurrentRoleDisplayName + { + get => _currentRoleDisplayName; + set => SetProperty(ref _currentRoleDisplayName, value); + } + + /// 是否可切换用户(绑定按钮 IsEnabled)。 + public bool CanSwitchUser + { + get => _canSwitchUser; + set => SetProperty(ref _canSwitchUser, value); + } + + /// 切换用户命令。CNC 执行中禁用。 + 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() + .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 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(); + 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) + } + + /// + /// 将 UserRole 枚举映射为中文显示名。 + /// + private static string MapRoleToDisplayName(UserRole role) + { + return role switch + { + UserRole.SuperAdmin => "超级管理员", + UserRole.Admin => "管理员", + UserRole.User => "用户", + _ => string.Empty + }; + } + } +} diff --git a/XplorePlane/ViewModels/Setting/PasswordManagementViewModel.cs b/XplorePlane/ViewModels/Setting/PasswordManagementViewModel.cs new file mode 100644 index 0000000..93d6fa9 --- /dev/null +++ b/XplorePlane/ViewModels/Setting/PasswordManagementViewModel.cs @@ -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 +{ + /// + /// 密码管理 ViewModel。 + /// 对应 UI 元素:"密码管理" 按钮位于 "全局设置" (Global Settings) Ribbon Group 中。 + /// 可见性规则:仅 Super_Admin 可见,Admin/User 时隐藏(与 Factory_Settings 相同规则)。 + /// + 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() ?? 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; + } + + /// + /// 验证密码长度并调用服务修改密码。 + /// + 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; + } + } +} diff --git a/XplorePlane/ViewModels/Setting/SettingsViewModel.cs b/XplorePlane/ViewModels/Setting/SettingsViewModel.cs index 5b5ceeb..4609ff4 100644 --- a/XplorePlane/ViewModels/Setting/SettingsViewModel.cs +++ b/XplorePlane/ViewModels/Setting/SettingsViewModel.cs @@ -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; + /// + /// 厂家级设置标签页是否可见(射线源、探测器、PLC)。仅 SuperAdmin 可见。 + /// + public bool IsFactorySettingsVisible + { + get => _isFactorySettingsVisible; + private set => SetProperty(ref _isFactorySettingsVisible, value); + } + + public SettingsViewModel(ILoggerService logger, IPermissionService permissionService, IEventAggregator eventAggregator) { _logger = logger?.ForModule() ?? 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() + .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(); + } + + /// + /// 根据当前角色刷新设置标签页的可见性。 + /// 厂家级设置(射线源、探测器、PLC)仅 SuperAdmin 可见。 + /// + private void RefreshSettingsVisibility() + { + IsFactorySettingsVisible = _permissionService.HasPermission(PermissionEnum.AccessPlcSettings); + } } } diff --git a/XplorePlane/Views/Cnc/CncPageView.xaml b/XplorePlane/Views/Cnc/CncPageView.xaml index d16833a..b481b52 100644 --- a/XplorePlane/Views/Cnc/CncPageView.xaml +++ b/XplorePlane/Views/Cnc/CncPageView.xaml @@ -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}" /> - + + + + + + +