Files
2026-11-13 19:19:33 +08:00

375 lines
10 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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<TParameter>` |
`UserControlStyle` 与 `ViewModelBaseEx` 强相关,使用其中一个必须搭配另一个。
```csharp
// 普通页面
internal class XxxViewModel : ViewModelBaseEx
{
protected override void LoadedOnce()
{
// 首次加载初始化
}
}
// 对话框
internal class XxxDialogViewModel : DialogViewModelBase<XxxDialogParameter>
{
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<XxxService> 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 中用 `<f:LoadingCover LoadingStatus="{Binding LoadingStatus}" RefreshCommand="{Binding LoadCommand}"/>` 绑定。API 详情见 `reference-loading-status` skill。
## 表格控件选择
| 场景 | 控件 |
|---|---|
| 普通配置表格、数据库记录 | 常规 DataGrid |
| 百万行级别大数据 | MAX.FastWpfGrid + FastGridModelBase |
MAX.FastWpfGrid 完整用法见 `reference-fast-wpf-grid` skill(**禁止**旧包名 `FastWpfGrid`)。
## 文件对话框服务
**禁止** `Microsoft.Win32.OpenFileDialog` / `SaveFileDialog`,用 DevExpress 服务:
```xml
<!-- XAML 注册 -->
<dxmvvm:Interaction.Behaviors>
<dxmvvm:OpenFileDialogService Name="OpenFileDialog"/>
<dxmvvm:SaveFileDialogService Name="SaveFileDialog"/>
</dxmvvm:Interaction.Behaviors>
```
```csharp
// ViewModel 使用
public IOpenFileDialogService OpenFileDialog => GetService<IOpenFileDialogService>(nameof(OpenFileDialog));
public ISaveFileDialogService SaveFileDialog => GetService<ISaveFileDialogService>(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
<TextBox MaxLength="{x:Static shared:InputLimits.Path}"
Text="{Binding FolderName, UpdateSourceTrigger=PropertyChanged}"/>
<TextBox MaxLength="{x:Static shared:InputLimits.Text}"
Text="{Binding Description, UpdateSourceTrigger=PropertyChanged}"/>
```
XAML 细则见 `wpf-xaml.mdc`。
## 服务端实体与 WPF 展示模型
- 服务端返回的业务实体命名为 `XxxInfo`(如 `UserInfo`),放在独立的 `Store.Management.Models` 项目中,可脱离 API 和 WPF 使用。
- WPF 视图绑定展示模型。单个实体的展示模型命名为 `XxxViewModel`(如 `UserViewModel`),集合中的每个元素命名为 `XxxItemViewModel`(如 `IReadOnlyList<UserItemViewModel>` 中的 `UserItemViewModel`)。
- 从服务端实体到展示模型的转换使用 Riok.Mapperly,映射类放在使用方项目的 `Mappers/` 目录。
## 资源引用(忽略)
```csharp
var icon = ImageResources.Icon_XXX_PNG;
var text = AppStrings.Xxx_Title;
var color = CustomColors.AccentColor;
```