跳转到主内容

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 使用静态 IDialogServiceILogger,必须在应用启动时初始化:

// 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 的子元素填充。

颜色/画刷规范

不硬编码颜色

始终使用 DynamicResourceStaticResource 引用预定义的画刷资源,不使用十六进制颜色值:

<!-- 正确 -->
<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 = "已保存"; // 无法国际化

相关文档