SeatFlow 项目 C# 编码规范、MVVM 模式约定、Axaml 绑定规范、异步模式和日志规范
本页目录
全部文档
编码规范
C# 命名约定
| 类别 | 规范 | 示例 |
|---|---|---|
| 类名 | PascalCase | SeatingWorkspace |
| 接口名 | I + PascalCase | ISeatingStrategy |
| 方法名 | PascalCase | ExecuteAsync() |
| 属性 | PascalCase | IsAvailable |
| 私有实例字段 | _ + camelCase |
_students |
| 私有静态字段 | PascalCase | JsonOptions |
| 参数 | camelCase | workspace |
| 局部变量 | camelCase | emptySeats |
| 常量 | PascalCase | MaxRetries |
| 枚举值 | PascalCase | Grid, Polar |
文件组织
大型类(Service、Strategy、ViewModel)一个文件一个类
紧密相关的私有类型可共存于同一文件
文件名 = 类名.cs
分部类:每个文件对应一个关注点
MVVM 模式规范
SeatFlow 使用 CommunityToolkit.Mvvm 8.4.2 的源代码生成器实现 MVVM。
继承体系
ObservableObject
└── ViewModelBase
├── MainShellViewModel
├── HomeViewModel
├── MemberManagementViewModel
├── VenueConfigurationViewModel
├── FreeformManagementViewModel
├── StrategyConfigurationViewModel
├── SeatingArrangementViewModel
├── SnapshotHistoryViewModel
├── PluginManagementViewModel
├── SettingsViewModel
└── AboutViewModel
[ObservableProperty]
私有字段上的 [ObservableProperty] 生成公共属性:
public partial class MemberManagementViewModel : ViewModelBase
{
[ObservableProperty]
private string _searchText = string.Empty;
// 自动生成:public string SearchText { get; set; }
// 自动生成分部方法:partial void OnSearchTextChanged(string value)
}
自动生成的分部方法钩子:
partial void OnSearchTextChanged(string value)
{
// 在属性值变更时执行的逻辑
FilterStudents();
}
[RelayCommand]
方法上的 [RelayCommand] 生成 ICommand 属性:
[RelayCommand]
private async Task SaveAsync()
{
// 自动生成:public IAsyncRelayCommand SaveCommand { get; }
}
[RelayCommand]
private void Cancel()
{
// 自动生成:public RelayCommand CancelCommand { get; }
}
命名规则:方法名 DoSomething → 属性名 DoSomethingCommand(自动移除 Async 后缀)。
[NotifyPropertyChangedFor]
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsDirty))]
private string _searchText = string.Empty;
依赖属性自动触发变更通知。
ViewModelBase 必须遵守的规则
- 所有 ViewModel 继承
ViewModelBase(继承ObservableObject) - 使用
partial class以支持源代码生成 - UI 线程操作使用
Dispatcher.UIThread.Post/InvokeAsync - 异步错误处理使用
SafeExecuteAsync
ViewModelBase.SafeExecuteAsync
ViewModelBase 提供两个 SafeExecuteAsync 重载用于异步操作的错误处理。它们均为 protected static 方法。
简单重载
protected static async Task<bool> SafeExecuteAsync(Func<Task> action, string? errorTitle = null)
- try-catch 包装,自动捕获异常并显示错误对话框
errorTitle默认为Resources.Common_OperationFailed- 返回
true表示执行成功
带超时重载
protected static async Task<bool> SafeExecuteAsync(
Func<CancellationToken, Task> action,
TimeSpan timeout,
string? errorTitle = null)
- 通过
CancellationTokenSource实现超时自动取消 - 超时时显示超时对话框
- 优先用于长时间运行的导出或导入操作
- 超时时间应远低于 WatchdogService 阈值(45 秒)
使用示例
[RelayCommand]
private async Task ExportAsync()
{
// 简单异步操作
await SafeExecuteAsync(async () =>
{
await _facade.ExportAsync(format);
});
// 带超时的异步操作
await SafeExecuteAsync(async (ct) =>
{
await _facade.ExportLargeFileAsync(ct);
}, TimeSpan.FromSeconds(30), Resources.Common_ExportFailed);
}
ViewModelBase.Dialog 与 Logger 初始化
ViewModelBase 使用静态 IDialogService 和 ILogger,必须在应用启动时初始化:
// App.axaml.cs OnFrameworkInitializationCompleted
ViewModelBase.InitializeDialogService(dialogService);
ViewModelBase.InitializeLogger(logger);
如果新增窗口或隔离测试 ViewModel,必须先初始化这两个静态实例,否则 SafeExecuteAsync 的对话框和日志将无法工作。
ViewModelBase.CanLeaveAsync
public virtual Task<bool> CanLeaveAsync()
由 NavigationService 在导航离开前调用。重写以提示用户未保存的更改:
public override async Task<bool> CanLeaveAsync()
{
if (IsDirty)
{
var result = await Dialog.ShowConfirmAsync(
Resources.Common_UnsavedChanges,
Resources.Common_SaveBeforeLeave);
return result == DialogResult.Yes;
}
return true;
}
Axaml 绑定规范
编译绑定
项目已开启 AvaloniaUseCompiledBindingsByDefault = true,所有绑定默认编译绑定。
<!-- 必须设置 x:DataType -->
<UserControl x:Class="SeatFlow.Presentation.Avalonia.Views.HomeView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:SeatFlow.Presentation.Avalonia.ViewModels"
x:DataType="vm:HomeViewModel">
x:Static 资源引用
i18n 资源:使用 {x:Static} 引用 Resources 类:
<TextBlock Text="{x:Static lang:Resources.Settings_Title}" />
<Button Content="{x:Static lang:Resources.Common_OK}" />
命名空间声明:
xmlns:lang="using:SeatFlow.Presentation.Avalonia.Lang"
重要:仅支持属性语法(<Button Content="{x:Static ...}" />),不支持元素内容语法(<Button><x:Static ... /></Button>)。
转换器
BoolConverters 使用 {x:Static} 直接引用静态属性,无需实例化:
<Button IsVisible="{Binding HasData, Converter={x:Static conv:BoolConverters.TrueToVisible}}"/>
<Button Opacity="{Binding IsCompact, Converter={x:Static conv:BoolConverters.CompactPanelWidth}}"/>
命名空间声明:
xmlns:conv="using:SeatFlow.Presentation.Avalonia.Converters"
内置转换器位于 Converters/:
| 转换器 | 说明 |
|---|---|
BoolConverters.TrueToVisible |
true → 1.0,false → 0.0 |
BoolConverters.FalseToVisible |
false → 1.0,true → 0.0 |
BoolConverters.Negate |
布尔值取反 |
BoolConverters.CompactPanelWidth |
true → 80,false → NaN |
BoolConverters.TrueWhenEqual |
值与 ConverterParameter 相等返回 true |
BoolConverters.TrueToBold |
true → Bold,false → Normal |
GenderIndexConverter |
Gender? ↔ ComboBox SelectedIndex |
HeightConverter |
float? ↔ string |
FilePathToBitmapConverter |
文件路径 → Bitmap |
后三个为实例转换器,需在 UserControl.Resources 中声明:
<UserControl.Resources>
<conv:GenderIndexConverter x:Key="GenderConverter" />
<conv:HeightConverter x:Key="HeightConverter" />
</UserControl.Resources>
图标
<fic:FluentIcon Icon="{x:Static ficEnum:Icon.Home}" FontSize="18" />
命名空间:
xmlns:fic="using:FluentIcons.Avalonia"
xmlns:ficEnum="clr-namespace:FluentIcons.Common;assembly=FluentIcons.Common"
按钮图标使用规范
侧栏导航按钮
<Button Command="{Binding NavigateToHomeCommand}"
ToolTip.Tip="{x:Static lang:Resources.Nav_Home}">
<StackPanel>
<fic:FluentIcon Icon="{x:Static ficEnum:Icon.Home}" FontSize="20" />
<TextBlock Text="{x:Static lang:Resources.Nav_Home}" />
</StackPanel>
</Button>
操作按钮
<Button Content="{x:Static lang:Resources.Common_OK}"
Command="{Binding SaveCommand}"
Classes="Primary" />
侧栏禁用页面按钮
禁用页面不使用 IsEnabled(Disabled 状态的控件在 Avalonia 中会隐藏 ToolTip),而是使用 Opacity:
<Button Opacity="{Binding PluginManagementOpacity}"
ToolTip.Tip="{Binding PluginManagementDisabledTip}"
Command="{Binding NavigateToPluginManagementCommand}" />
ViewModel 属性:
public double PluginManagementOpacity => IsPageEnabled("PluginManagement") ? 1.0 : 0.4;
public string? PluginManagementDisabledTip =>
IsPageEnabled("PluginManagement") ? null : Resources.Nav_PluginDisabled;
对话框窗口的按钮模式
DialogWindow
<!-- 正确:属性语法 -->
<Button Content="{x:Static lang:Resources.Common_OK}"
Command="{Binding ConfirmCommand}" />
<!-- 错误:元素内容语法 -->
<Button Command="{Binding ConfirmCommand}">
<x:Static xmlns:lang="..." Member="lang:Resources.Common_OK" />
</Button>
InputWindow
使用相同的属性语法模式。代码后置仅控制可见性和 MultiOption 自定义文本。
对话框类型
| 类型 | 用途 | 方法签名 |
|---|---|---|
| 确认 | 是/否操作 | ShowConfirmAsync(title, message) |
| 错误 | 错误提示 | ShowErrorAsync(title, message) |
| 警告 | 警告提示 | ShowWarningAsync(title, message) |
| 信息 | 信息提示 | ShowInfoAsync(title, message) |
| 输入 | 文本输入 | ShowInputAsync(title, prompt, initialValue) |
| 多选项 | 多种操作选择 | ShowMultiOptionAsync(title, message, primary, secondary, cancel) |
DockPanel 子元素顺序规则
在 Avalonia 的 DockPanel 中,LastChildFill="True"(默认)意味着最后一个子元素填充剩余空间。
<DockPanel>
<!-- Dock 子元素放在前面 -->
<StackPanel DockPanel.Dock="Left" Orientation="Vertical">
<!-- 侧栏内容 -->
</StackPanel>
<!-- 填充子元素放在最后 -->
<ScrollViewer>
<!-- 主内容区 -->
</ScrollViewer>
</DockPanel>
规则:始终将带 DockPanel.Dock 的子元素放在不带 Dock 的子元素之前。如果最后一个子元素也有 Dock,则前一个未 Dock 的子元素填充。
颜色/画刷规范
不硬编码颜色
始终使用 DynamicResource 或 StaticResource 引用预定义的画刷资源,不使用十六进制颜色值:
<!-- 正确 -->
<Border Background="{DynamicResource SidebarBackgroundBrush}" />
<!-- 错误 -->
<Border Background="#FFE0E0E0" />
主题字典
App.axaml 定义 ResourceDictionary.ThemeDictionaries,包含 Light/Dark 变体:
| 资源名称 | 用途 |
|---|---|
SidebarBackgroundBrush |
侧栏背景 |
SurfaceCardBrush |
卡片表面色 |
OverlayBackgroundBrush |
遮罩层 |
LoadingOverlayBrush |
加载遮罩 |
PreviewBackgroundBrush |
预览区背景 |
PreviewSeatBorderBrush |
预览座位边框 |
PreviewPodiumFillBrush |
预览讲台填充 |
PreviewDoorFillBrush |
预览门填充 |
SuccessBrush |
成功状态 |
WarningBrush |
警告状态 |
ErrorBrush |
错误状态 |
InfoBrush |
信息状态 |
ErrorBgBrush |
错误背景(低透明度) |
WarningBgBrush |
警告背景(低透明度) |
阴影
| 资源名称 | 用途 |
|---|---|
CardShadowNone |
无阴影 |
CardShadowSmall |
小卡片阴影 |
CardShadowLarge |
大卡片阴影 |
字体
全局 Window 样式设置 CJK 友好字体:
<FontFamily>Inter,Microsoft YaHei UI,PingFang SC,Noto Sans CJK SC,WenQuanYi Micro Hei,sans-serif</FontFamily>
日志规范
日志框架
使用 Serilog 4.4 + Microsoft.Extensions.Logging.ILogger<T> 贯穿 Application 层。日志通过 Serilog.Sinks.File 输出到文件,附带 Serilog.Enrichers.Thread 记录线程信息。
注入方式
public class StrategyExecutionPipeline
{
private readonly ILogger<StrategyExecutionPipeline> _logger;
public StrategyExecutionPipeline(ILogger<StrategyExecutionPipeline>? logger = null)
{
_logger = logger ?? NullLogger<StrategyExecutionPipeline>.Instance;
}
}
- 通过构造函数注入
ILogger<T> - 参数设为可选,未注入时回退到
NullLogger<T>.Instance - 日志等级:Debug(调试)、Info(常规信息)、Warning(可恢复问题)、Error(不可恢复问题)
日志内容
_logger.LogInformation("策略 {StrategyId} 开始执行,优先级 {Priority}",
strategy.Id, strategy.Priority);
_logger.LogWarning("策略 {StrategyId} 重试次数已达上限 {MaxRerolls},强制分配",
strategy.Id, maxRerolls);
_logger.LogError(e, "策略 {StrategyId} 执行异常", strategy.Id);
策略消息日志
策略在执行期间通过 workspace 记录用户可见的消息,使用内联 i18n 格式:
workspace.LogWarning(strategyId, displayName, "DeskMate_Split", groupName, memberName);
workspace.LogError(strategyId, displayName, "DeskMate_NoSeats", groupName);
这些消息收集在 SeatingWorkspace.Messages 中,管道执行后在 UI 侧栏展示。
异步模式
异步方法命名
- 异步方法以
Async后缀结尾:LoadAsync()、SaveAsync() [RelayCommand]修饰的异步方法自动处理Async后缀
Fire-and-Forget
ViewModel 构造函数中的异步初始化使用 fire-and-forget 模式:
public class VenueConfigurationViewModel : ViewModelBase
{
public VenueConfigurationViewModel(/* ... */)
{
_ = LoadInitialDataAsync();
}
}
当需要确保在异步完成后执行某操作(如引导系统中的演示数据注入),使用 DispatcherPriority.Background:
Dispatcher.UIThread.Post(async () =>
{
await SeedPageDataAsync();
}, DispatcherPriority.Background);
取消令牌
长时间运行的异步操作应支持取消:
private CancellationTokenSource? _selectVenueCts;
[RelayCommand]
private async Task SelectVenueAsync(VenueItem venue, CancellationToken ct)
{
// NewVenue 中已调用 _selectVenueCts?.Cancel()
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(ct, _selectVenueCts.Token);
try
{
await Task.Delay(100, linkedCts.Token);
// 加载逻辑...
}
catch (OperationCanceledException)
{
// 已取消,静默返回
}
}
竞态条件防护
NewVenue() 必须取消前一个进行中的 SelectVenueAsync:
[RelayCommand]
private void NewVenue()
{
_selectVenueCts?.Cancel();
_selectVenueCts = new CancellationTokenSource();
ResetParameters();
}
文件头部和注释规范
文件头部
C# 文件不要求特定的版权头部块,但类和方法应有有意义的 XML 文档注释:
/// <summary>
/// 将 Grid 布局座位从列主序重排为行主序。
/// 外层循环按 Row 遍历,内层循环按 Column 遍历。
/// </summary>
public JsonNode Migrate(JsonNode root)
注释语言
注释使用中文,与项目语言一致:
// 检查座位是否已被固定
if (seat.IsFixed)
continue;
代码注释要点
- 公共 API 应提供 XML 文档注释
- 复杂算法应注释设计原因,而非逐行翻译代码
- 临时的修复或 workaround 应标注
// TODO:或// HACK: - 测试使用 AAA(Arrange-Act-Assert)空行分隔
其他规范
面向接口编程
- 通过 DI 容器管理生命周期,不直接
new依赖对象 - 使用
IApplicationFacade作为 UI 层唯一入口 - Plugin 策略通过
IPluginWorkspace访问 workspace
JSON 序列化
项目提供集中式 JsonOptions 静态类(SeatFlow.Infrastructure.Serialization),避免每次分配:
// 写入:CamelCase + 缩进
var json = JsonSerializer.Serialize(obj, JsonOptions.WriteIndentedCamelCase);
// 读取:大小写不敏感
var obj = JsonSerializer.Deserialize<T>(json, JsonOptions.CaseInsensitiveRead);
资源键命名
在 C# 中使用资源键时使用 Resources.xxx 访问器(非字符串):
// 正确:编译时类型安全
StatusMessage = Resources.Settings_Saved;
// 避免:运行时才能发现问题
StatusMessage = "已保存"; // 无法国际化