跳转到主内容

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

相关文档