--- description: WPF MVVM 核心行为约束(项目结构、命名、ViewModel、属性、命令、服务、DI、对话框、输入限长、Framework 基类等),始终生效 alwaysApply: true --- # WPF 客户端核心规范 ## 项目结构 ### 主项目 ``` 项目/ ├── Views/ # 视图层 │ ├── MainWindow.xaml │ ├── AppSplashScreen.xaml │ └── Dialogs/ ├── ViewModels/ # 视图模型层 │ └── MainWindowViewModel.cs ├── App.xaml └── NLog.config ``` ### Framework 项目 ``` 项目.Framework/ ├── Configs/ Controls/ Converters/ Services/ ├── ViewModelServices/ ViewModels/ Helpers/ MarkupExtensions/ ├── Mappers/ Utilities/ Interfaces/ Resources/ └── Startup.cs ``` ### 模块目录 ``` 项目.<模块名>/ ├── Startup.cs ├── Views/ ├── <模块名>MainView.xaml └── Dialogs/ └── ViewModels/ ``` ## View / ViewModel 命名约定 以功能名称 `Xxx`(不含 View 后缀)为前缀: | 文件类型 | 命名格式 | |---|---| | View 文件 | `XxxView.xaml` | | ViewModel 文件 | `XxxViewModel.cs` | | 对话框 View | `XxxDialog.xaml` | | 对话框 ViewModel | `XxxDialogViewModel.cs` | **严禁**将 View 文件名中的 `View` 后缀保留到 ViewModel 文件名中: | ❌ 错误 | ✅ 正确 | |---|---| | `AlarmLogViewViewModel.cs` | `AlarmLogViewModel.cs` | | `MainConsoleMainViewViewModel.cs` | `MainConsoleMainViewModel.cs` | **推导规则**:从 `{Name}View.xaml` 推导 ViewModel 时,取 `{Name}` 再加 `ViewModel.cs`。 ## ViewModel 继承 | 场景 | 基类 | |---|---| | 普通页面 | `ViewModelBaseEx` | | 对话框 | `DialogViewModelBase` | `UserControlStyle` 与 `ViewModelBaseEx` 强相关,使用其中一个必须搭配另一个。 ```csharp // 普通页面 internal class XxxViewModel : ViewModelBaseEx { protected override void LoadedOnce() { // 首次加载初始化 } } // 对话框 internal class XxxDialogViewModel : DialogViewModelBase { protected override void OnInitialized() { // Parameter 和 ParentViewModel 已就绪 } protected override void Closing(CancelEventArgs args) { // 关闭前校验和保存 } } ``` ### 初始化与主构造函数 实现接口`ViewModelBase` / `ViewModelBaseEx` 时,**必须优先使用 Primary Constructor** 注入依赖。 主构造函数没有函数体。当构造阶段还有默认值赋值、事件订阅、集合填充等初始化逻辑时,**禁止**因此退回传统构造函数并手工 `_field = param`;应 override `OnInitializeInRuntime`,把这些逻辑放进去(该方法由基类构造流程调用,时机等同于原构造函数体): ```csharp internal class XxxViewModel( IXxxService xxxService, IYyyService yyyService ) : ViewModelBaseEx { protected override void OnInitializeInRuntime() { base.OnInitializeInRuntime(); // 原传统构造函数中「除依赖字段赋值外」的初始化逻辑放这里 } } ``` | 时机 | 方法 | 用途 | |---|---|---| | 构造阶段(基类构造内) | `OnInitializeInRuntime` | 依赖就绪后的同步初始化;支撑主构造函数写法 | | 视图首次加载 | `LoadedOnce` | 依赖 View 已就绪的逻辑 | | 对话框 Parameter 就绪 | `OnInitialized`(Dialog) | 对话框专用,勿与 `OnInitializeInRuntime` 混淆 | | | | --- | | | | | | | ## MVVM 属性定义 **禁止使用** **`field`** **关键字定义绑定属性**,必须用 Prism的 父类 `BindableBase `,`SetProperty`: ```csharp // ✅ 正确 private string _title; public string Title { get => _title; set => SetProperty(ref _title, value); } ``` ## Command 写法 使用 `field ??= new ...` 懒加载,handler 与定义共置,禁止分离到文件底部: ```csharp internal class XxxViewModel : BindableBase { //同步命令 public ICommand SaveCommand => field?? new DelegateCommand(Save); private void Save() { throw new NotImplementedException(); } // 异步命令 public ICommand SaveAsyncCommand => field ?? new AsyncDelegateCommand(SaveAsync); private async Task SaveAsync() { throw new NotImplementedException(); } } ``` ## DependencyProperty 模板 ```csharp public static readonly DependencyProperty XxxProperty = DependencyProperty.Register(nameof(Xxx), typeof(string), typeof(MyControl), new PropertyMetadata(default(string))); public string Xxx { get => (string)GetValue(XxxProperty); set => SetValue(XxxProperty, value); } ``` 确认类对话框优先 `IMessageBoxServiceEx.ShowOkCancelWarning` / `ShowOkCancelQuestion`(见 `handycontrol-usage.mdc`),不要默认用 Yes/No。 ## AutoFac 依赖注入 ### 生命周期接口 | 接口 | 用途 | |---|---| | `ISingletonDependency` | 单例服务 | | `IAppInitializeAsync` | 异步启动初始化 | | `IAppInitialize` | 同步启动初始化 | | `IAppClosedHandler` | 应用关闭时清理(已带单例生命周期;`AppClosed(AppCloseParameter)`,按 Priority 在关窗前调用) | ```csharp // 单例服务 internal class XxxService(ILogger logger) : IXxxService, ISingletonDependency { } // 带异步初始化的单例 internal class XxxService(IStatusReport statusReport) : IXxxService, IAppInitializeAsync { string IAppInitializeAsync.Description { get; } = "初始化 Xxx"; int IPriority.Priority { get; } = IntPriority.Normal; async Task IAppInitializeAsync.AppInitializeAsync(IStatusReport statusReport) { // 初始化逻辑 } } // 应用关闭清理 internal class XxxService : IXxxService, IAppClosedHandler { int IAppClosedHandler.Priority { get; } = IntPriority.Normal; string IAppClosedHandler.Description { get; } = "关闭 Xxx"; void IAppClosedHandler.AppClosed(AppCloseParameter parameter) { // 清理资源 } } ``` ### 注入方式 优先使用 Primary Constructor。ViewModel 若有构造阶段初始化逻辑,放到 `OnInitializeInRuntime`(见「初始化与主构造函数」),不要为此改用传统构造函数: ```csharp internal class XxxViewModel(IConfigService configService) : ViewModelBaseEx { // 直接使用 configService } ``` ## IDialogService ```csharp internal class XxxViewModel(IMessageBoxService messageBoxService) : ViewModelBaseEx { private void ShowWarning() { messageBoxService.Show("操作成功", "提示", MessageButton.OK, MessageIcon.Information); } } ``` 复杂对话框使用 `add-dialog` skill 创建自定义 DialogService。 ## LoadingStatus 异步加载统一用 `LoadingStatus`,禁止自建 `IsBusy`/`HasError`: ```csharp internal class XxxViewModel : ViewModelBaseEx { public LoadingStatus LoadingStatus { get; } = new(); public IDelegateCommand LoadCommand => field ??= new AsyncCommand(Load); private async Task Load() { try { LoadingStatus.Initialize("读取文件..."); await DoStuff(); LoadingStatus.Done(); } catch (OperationCanceledException) { LoadingStatus.Reset(); } catch (Exception ex) { Debug.WriteLine(ex); LoadingStatus.Error(ex.Message); } } } ``` XAML 中用 `` 绑定。API 详情见 `reference-loading-status` skill。 ## 表格控件选择 | 场景 | 控件 | |---|---| | 普通配置表格、数据库记录 | 常规 DataGrid | | 百万行级别大数据 | MAX.FastWpfGrid + FastGridModelBase | MAX.FastWpfGrid 完整用法见 `reference-fast-wpf-grid` skill(**禁止**旧包名 `FastWpfGrid`)。 ## 文件对话框服务 **禁止** `Microsoft.Win32.OpenFileDialog` / `SaveFileDialog`,用 DevExpress 服务: ```xml ``` ```csharp // ViewModel 使用 public IOpenFileDialogService OpenFileDialog => GetService(nameof(OpenFileDialog)); public ISaveFileDialogService SaveFileDialog => GetService(nameof(SaveFileDialog)); private void OpenFile() { if (OpenFileDialog.ShowDialog(OnFileOpen)) { // 文件已选择 } } private void OnFileOpen(CancelEventArgs args, IFileInfo fileInfo) { string filePath = fileInfo.GetFullName(); } ``` ## 文件夹选择 **必须使用** `AppFileHelper.PickFolder`,禁止 WinForms `FolderBrowserDialog`: ```csharp using PipeReviewReport.Framework.Helpers; if (AppFileHelper.PickFolder("选择文件夹", Environment.GetFolderPath(Environment.SpecialFolder.Desktop), out string path)) { FolderPath = path; } // 简化重载 if (AppFileHelper.PickFolder(out string path)) { FolderPath = path; } ``` ## 输入框长度限制 **强制遵守:** 用户能打字的控件都必须**显式**写 `MaxLength`,禁止省略。用 `{x:Static shared:InputLimits.Xxx}`,禁止写裸数字。 | 常量 | 值 | 何时用 | |---|---|---| | `InputLimits.Path` | 100 | 会变成文件名、文件夹名、路径一部分的名称 | | `InputLimits.Text` | 250 | 显示名、描述、备注 | 完整路径**不要手输**,用 `AppFileHelper.PickFolder`;展示用 `TextBlock` 或只读 `TextBox`,不要用 `MaxLength` 去卡系统路径长度(长路径远超 260)。 ```xml ``` XAML 细则见 `wpf-xaml.mdc`。 ## 服务端实体与 WPF 展示模型 - 服务端返回的业务实体命名为 `XxxInfo`(如 `UserInfo`),放在独立的 `Store.Management.Models` 项目中,可脱离 API 和 WPF 使用。 - WPF 视图绑定展示模型。单个实体的展示模型命名为 `XxxViewModel`(如 `UserViewModel`),集合中的每个元素命名为 `XxxItemViewModel`(如 `IReadOnlyList` 中的 `UserItemViewModel`)。 - 从服务端实体到展示模型的转换使用 Riok.Mapperly,映射类放在使用方项目的 `Mappers/` 目录。 ## 资源引用(忽略) ```csharp var icon = ImageResources.Icon_XXX_PNG; var text = AppStrings.Xxx_Title; var color = CustomColors.AccentColor; ```