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 模式规范
2.1.0(M0,ADR-014)起 SeatFlow 采用双范式共存:
- CommunityToolkit.Mvvm 8.4.2 源生成器:存量 VM 与
ViewModelBase的基础([ObservableProperty]/[RelayCommand])。 - ReactiveUI.Avalonia 12.1.5(传递 ReactiveUI 25.1.1,无 System.Reactive 依赖):新代码优先,用于去抖、取消、派生状态与订阅作用域(
ReactiveObject/[Reactive]/ReactiveCommand/WhenAnyValue)。 - 视图层保持普通
UserControl:不使用ReactiveUserControl<T>(其反射激活在裁剪下产生 IL2026),生命周期用OnAttachedToVisualTree/OnDetachedFromVisualTree+CompositeDisposable实现。
决策全文见主仓库 docs/adr/ADR-014-reactiveui-and-canvas-architecture.md。
继承体系
ObservableObject(CommunityToolkit.Mvvm)
└── ViewModelBase
├── MainShellViewModel
├── MemberManagementViewModel
├── VenueConfigurationViewModel
├── StrategyConfigurationViewModel
├── SeatingArrangementViewModel
├── SnapshotHistoryViewModel
├── SettingsViewModel
├── AboutViewModel
└── (对话框/选择器等辅助 VM)
7 个页面(
PageKey)为:排座工作台(默认入口)/ 人员管理 / 会场与布局 / 策略配置 / 历史快照 / 设置 / 关于。原Home与FreeformManagement页面已移除。
ReactiveUI 示例(新代码优先)
public partial class VenueConfigurationViewModel : ViewModelBase
{
// 派生状态:任一所选参数变化自动触发(替代手写 OnXxxChanged)
this.WhenAnyValue(x => x.SelectedLayoutType, x => x.Rows, x => x.Columns)
.Throttle(TimeSpan.FromMilliseconds(120))
.Subscribe(_ => RebuildPreview())
.DisposeWith(_disposables);
}
[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以支持源代码生成 - 构造函数注入
IDialogService与ILogger(无对话框场景传NullDialogService.Instance);禁止任何静态可变状态(ViewModelBase.Dialog等已于 M0 移除) - UI 线程操作使用
Dispatcher.UIThread.Post/InvokeAsync - 异步错误处理使用
SafeExecuteAsync/SafeCancelableAsync - 页面级 VM 实现
IPageLifecycle,加载在OnEnterAsync中进行;构造器禁止 fire-and-forget 加载
ViewModelBase.SafeExecuteAsync
ViewModelBase 提供两个 SafeExecuteAsync 重载用于异步操作的错误处理(均为 protected 实例方法),以及一个静默处理取消的 SafeCancelableAsync。
简单重载
protected async Task<bool> SafeExecuteAsync(Func<Task> action, string? errorTitle = null)
- try-catch 包装,自动捕获异常并显示错误对话框
errorTitle默认为Resources.Common_OperationFailed- 返回
true表示执行成功
带超时重载
protected async Task<bool> SafeExecuteAsync(
Func<CancellationToken, Task> action,
TimeSpan timeout,
string? errorTitle = null)
protected async Task<bool> SafeCancelableAsync(
Func<Task> action,
string? errorTitle = null) // 用户主动取消静默返回,其余异常照常弹窗
- 通过
CancellationTokenSource实现超时自动取消 - 超时时显示超时对话框
- 优先用于长时间运行的导出或导入操作
- 超时时间应远低于 WatchdogService 阈值(45 秒)
- 页面离开会取消在途任务时,使用
SafeCancelableAsync避免误报错误
使用示例
[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);
}
构造注入(无静态状态)
M0 起 ViewModelBase 不再持有静态 Dialog / logger,全部改为构造注入:
public class MemberManagementViewModel : ViewModelBase
{
public MemberManagementViewModel(
IApplicationFacade facade,
IDialogService dialog,
ILogger<MemberManagementViewModel>? logger = null)
: base(dialog, logger)
{
// 仅做同步字段赋值;异步加载放 OnEnterAsync
}
}
// 隔离测试:传 NullDialogService.Instance 避免依赖 UI
var vm = new MemberManagementViewModel(facade, NullDialogService.Instance);
ViewModelBase.CanLeaveAsync
public virtual Task<bool> CanLeaveAsync()
由 NavigationService 在导航离开前调用。重写以提示用户未保存的更改:
public override async Task<bool> CanLeaveAsync()
{
if (IsDirty)
{
return await Dialog.ShowConfirmAsync(
Resources.Common_UnsavedChanges,
Resources.Common_SaveBeforeLeave);
}
return true;
}
统一的脏检查请使用
DirtyTracker(JSON 快照对比),不要在页面内手写IsDirty维护逻辑。
Axaml 绑定规范
编译绑定
项目已开启 AvaloniaUseCompiledBindingsByDefault = true,所有绑定默认编译绑定;当前代码库中 ReflectionBinding 实际使用为 0。根元素必须声明 x:DataType:
<!-- 必须设置 x:DataType -->
<UserControl x:Class="SeatFlow.Presentation.Avalonia.Views.VenueConfigurationView"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:SeatFlow.Presentation.Avalonia.ViewModels"
x:DataType="vm:VenueConfigurationViewModel">
- 静态数据优先
Mode=OneTime;列表使用ObservableCollection增量更新 - 视图与 VM 由
ViewLocator按命名约定解析,并按 VM 实例弱引用缓存(切页不重建 XAML)
x 资源引用
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.Settings}" FontSize="18" />
命名空间:
xmlns:fic="using:FluentIcons.Avalonia"
xmlns:ficEnum="clr-namespace:FluentIcons.Common;assembly=FluentIcons.Common"
按钮图标使用规范
侧栏导航按钮
<Button Command="{Binding NavigateCommand}"
CommandParameter="Settings"
Classes="sf-nav"
Classes.sf-active="{Binding IsSettingsPage}"
ToolTip.Tip="{x:Static lang:Resources.Nav_Settings}">
<StackPanel Orientation="Horizontal" Spacing="8">
<fic:FluentIcon Icon="{x:Static ficEnum:Icon.Settings}" FontSize="18" />
<TextBlock Text="{x:Static lang:Resources.Nav_Settings}" />
</StackPanel>
</Button>
操作按钮
<Button Content="{x:Static lang:Resources.Common_OK}"
Command="{Binding SaveCommand}"
Classes="Primary" />
侧栏页面开关
页面启停由嵌入式资源 Data/page_navigation.json 控制;MainShellViewModel.NavigateAsync 通过 IsPageEnabled(pageName) 拦截禁用页。新增禁用页时,侧栏按钮使用 Opacity(不要用 IsEnabled——Disabled 状态的控件在 Avalonia 中会隐藏 ToolTip)+ ToolTip.Tip 绑定提示键 Nav_{Page}Disabled:
<Button Opacity="{Binding SomePageOpacity}"
ToolTip.Tip="{Binding SomePageDisabledTip}"
Command="{Binding NavigateCommand}"
CommandParameter="SomePage" />
ViewModel 属性:
public double SomePageOpacity => IsPageEnabled("SomePage") ? 1.0 : 0.4;
public string? SomePageDisabledTip =>
IsPageEnabled("SomePage") ? null : Resources.Nav_SomePageDisabled;
对话框窗口的按钮模式
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 的子元素填充。
主题与令牌规范
令牌体系(方向 B「方格纸」)
颜色、间距、圆角、字阶只允许使用令牌,禁止硬编码色值:
| 令牌文件 | 内容 | 引用方式 |
|---|---|---|
Resources/Tokens/Colors.axaml |
Sf*Color + Sf*Brush,含 Light/Dark ThemeDictionaries |
{DynamicResource Sf*Brush} |
Resources/Tokens/Spacing.axaml |
SfSpace*、SfRadius*、页边距/控件尺寸 |
{StaticResource SfSpace*} / {StaticResource SfRadius*} |
Resources/Tokens/Typography.axaml |
SfFont* 字阶 |
{StaticResource SfFont*} |
Tokens/MotionTokens.cs |
动效时长/缓动常量(code-behind) | C# 引用 |
Styles/Components.axaml |
sf-* 组件样式类(sf-card、sf-nav、sf-h1…) |
Classes="sf-card" |
<!-- 正确:颜色走 DynamicResource,尺寸走 StaticResource -->
<Border Classes="sf-card"
Background="{DynamicResource SfSurfaceBrush}"
BorderBrush="{DynamicResource SfBorderBrush}"
Padding="{StaticResource SfCardPadding}" />
<!-- 错误:硬编码颜色 -->
<Border Background="#FFE0E0E0" />
明暗主题同等维护:每个 Sf*Color 在 Light/Dark 两个 ThemeDictionaries 中都有定义;组件样式统一收敛在 Styles/Components.axaml,页面不再内联色值。主题色可调(默认 #83B6DE / 跟随系统)由 AccentColorApplier 在运行时替换强调色令牌。
阴影与字体
全局 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
M0 起禁止在 ViewModel 构造器中启动异步加载(时序不可控,且 WASM/IndexedDB 下会晚于页面切换完成)。页面级 VM 实现 IPageLifecycle,由导航服务驱动:
public class VenueConfigurationViewModel : ViewModelBase, IPageLifecycle
{
private TaskCompletionSource _enterCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
private CancellationTokenSource? _loadCts;
public bool IsDirty => _dirtyTracker.IsDirty;
// 进入流程完成后置位;离开时换新未完成实例(供引导等待)
public Task InitializationTask => _enterCompletion.Task;
public async Task OnEnterAsync(CancellationToken ct)
{
_loadCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
try
{
await LoadInitialDataAsync(_loadCts.Token);
_enterCompletion.TrySetResult(); // 成功才置位
}
catch (OperationCanceledException)
{
// 离开页面:不置位,下次进入自动重试
}
}
public Task OnLeaveAsync()
{
_loadCts?.Cancel(); // 取消在途任务
_enterCompletion = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
return Task.CompletedTask;
}
}
依赖「加载完成」时机的逻辑(如引导演示数据注入)应等待 IPageLifecycle.InitializationTask(10s 兜底),而不是用 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();
}
页面生命周期与横切服务
| 服务/契约 | 位置 | 作用 |
|---|---|---|
IPageLifecycle |
Services/IPageLifecycle.cs |
OnEnterAsync(ct) / OnLeaveAsync() / IsDirty / InitializationTask,由导航服务驱动 |
DirtyTracker |
Services/DirtyTracker.cs |
统一 JSON 快照脏检查,勿再手写 |
IDialogGate |
Services/IDialogGate.cs |
基于 Interlocked 的对话框门,替代 _dialogLock + Task.Delay(150) |
IBusyScope |
Services/IBusyScope.cs |
繁忙态计数作用域(支持嵌套),统一驱动遮罩与命令禁用 |
IShellLayoutService |
Services/IShellLayoutService.cs |
≤900px 全局紧凑断点(内联面板 ↔ 左右抽屉) |
IGuideSeedTarget |
Services/IGuideSeedTarget.cs |
引导演示数据注入契约(5 个页面实现) |
InitializationTask仅在初始化成功时置位;离开页面换新未完成实例,取消/失败后下次进入自动重试。- 无静态可变状态:
ViewModelBase.Dialog、KeyboardShortcutHandler.ShortcutConfig、FileDropHandler._host、引导静态缓存等全部已移除,改为构造注入/DI。
文件头部和注释规范
文件头部
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)空行分隔
多目标框架与平台条件编译
共享类库与启动壳涉及多个目标框架:
| 项目 | 目标框架 |
|---|---|
SeatFlow.Presentation.Avalonia |
net10.0;net10.0-browser |
SeatFlow.Desktop |
net10.0 |
SeatFlow.Browser |
net10.0-browser |
csproj 条件
平台专属的资源与包引用通过 MSBuild 条件限定:
<!-- 仅桌面目标引用 PDF 导出依赖 -->
<PackageReference Include="QuestPDF" Condition="'$(TargetFramework)' != 'net10.0-browser'" />
<!-- 仅浏览器目标内嵌 CJK 字体 -->
<EmbeddedResource Include="Assets\Fonts\NotoSansSC.ttf"
Condition="'$(TargetFramework)' == 'net10.0-browser'" />
代码条件编译
浏览器专属代码路径使用 BROWSER 编译符号:
#if BROWSER
// 浏览器端实现(IndexedDB 存储、控制台日志等)
#endif
约定:优先通过接口 + DI 注入平台实现,仅在无法抽象时使用条件编译;条件编译代码应保持最小,避免在共享逻辑中出现大段分支。
其他规范
面向接口编程
- 通过 DI 容器管理生命周期,不直接
new依赖对象 - 使用
IApplicationFacade作为 UI 层唯一入口
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 = "已保存"; // 无法国际化
新增/修改资源键一律使用脚本(自动同步三文件 + Designer.cs,并生成备份):
python3 scripts/i18n.py add Settings_AutoSave --zh "自动保存" --en "Auto Save"
python3 scripts/i18n.py check
相关文档
- 主仓库
AGENTS.md(关键模式章节) - 主仓库
CONTRIBUTING.md(编码约定章节) - 主仓库
docs/adr/ADR-014(ReactiveUI 共存与自绘 SeatingCanvas) - CommunityToolkit.Mvvm 官方文档
- Avalonia UI 绑定文档