跳转到主内容

SeatFlow.Presentation.Avalonia 共享 UI 层的 MVVM 架构、导航、主题、启动序列和关键服务

Presentation 层详解

SeatFlow.Presentation.Avalonia 是 SeatFlow 的共享 UI 层(桌面与浏览器共用),基于 Avalonia UI 12 构建,采用 MVVM 模式与 CommunityToolkit.Mvvm 8.4 源码生成器。该库目标框架为 net10.0;net10.0-browser,由 SeatFlow.Desktop 与 SeatFlow.Browser 两个启动壳承载。

共享 UI 外壳

共享类库通过 MainView : UserControl 承载整个应用外壳(侧边栏 + 内容区),两个启动壳分别将其挂载到窗口或单视图:

壳 承载方式
SeatFlow.Desktop MainWindow : Window
SeatFlow.Browser ISingleViewApplicationLifetime.MainView

页面导航、侧边栏折叠、引导覆盖层等外壳行为均在共享层实现,保证两端体验一致。

MVVM 架构

ViewModelBase

所有 ViewModel 继承自 ViewModelBase(扩展 ObservableObject),提供核心基础设施:

public abstract class ViewModelBase : ObservableObject
{
    // 静态 Dialog 服务(需在 App 启动时初始化)
    public static IDialogService Dialog { get; private set; }
    public static void InitializeDialogService(IDialogService dialog);

    // SafeExecuteAsync — 两个重载
    protected async Task<bool> SafeExecuteAsync(Func<Task> action, string? errorTitle = null);
    protected async Task<bool> SafeExecuteAsync(Func<CancellationToken, Task> action,
        TimeSpan timeout, string? errorTitle = null);

    // CanLeaveAsync — 导航离开前检查未保存更改
    public virtual Task<bool> CanLeaveAsync();
}

SafeExecuteAsync 提供:

  • 简洁重载:try-catch,自动错误对话框
  • 超时重载:CancellationTokenSource 自动取消,超时对话框
  • 超时值应远低于 WatchdogService 阈值(45 秒)

CommunityToolkit.Mvvm 源码生成器

public class ExampleViewModel : ViewModelBase
{
    [ObservableProperty]              // 生成 public 属性 + OnXChanged 分部方法
    private string _name = string.Empty;

    [RelayCommand]                    // 生成 ICommand 属性
    private async Task SaveAsync() { }

    [ObservableProperty]
    [NotifyPropertyChangedFor(nameof(IsValid))]  // 依赖属性
    private int _count;
}

所有 ViewModel 使用源码生成器,无需手写 INotifyPropertyChanged 样板代码。

ViewLocator

ViewLocator 通过反射按命名约定自动解析 View:将 XXXViewModel 中的 "ViewModel" 替换为 "View",查找对应类型。这意味着 HomeViewModel 自动关联 HomeView,无需显式注册。

导航系统

INavigationService

位于 SeatFlow.Presentation.Avalonia/Services/INavigationService.cs:

public interface INavigationService
{
    PageKey CurrentPage { get; }
    void NavigateTo(PageKey page);
    Task<bool> NavigateToAsync(PageKey page);  // 先调用 CanLeaveAsync()
}

PageKey 枚举

管理 9 个页面:

值 ViewModel View 用途
Home HomeViewModel HomeView 首页概览
MemberManagement MemberManagementViewModel MemberManagementView 成员管理(数据集)
VenueConfiguration VenueConfigurationViewModel VenueConfigurationView 会场配置
FreeformManagement FreeformManagementViewModel FreeformManagementView 自由点管理
StrategyConfiguration StrategyConfigurationViewModel StrategyConfigurationView 策略配置
SeatingArrangement SeatingArrangementViewModel SeatingArrangementView 排座执行与微调
SnapshotHistory SnapshotHistoryViewModel SnapshotHistoryView 快照历史
Settings SettingsViewModel SettingsView 设置
About AboutViewModel AboutView 关于

MainShellViewModel

主壳 ViewModel,管理页面切换:

  • CurrentViewModel — 当前活动页 ViewModel
  • 页面切换使用过渡动画(RunTransitionAsync),引导模式下跳过动画(IsOnboardingActive 守卫)
  • SidebarWidth — 侧边栏宽度(展开 140px / 折叠 64px)
  • ToggleSidebar() — 侧边栏手动切换
  • 当窗口宽度 < 750px 时自动折叠

页面导航可见性

JSON 配置 Data/page_navigation.json(嵌入资源)控制哪些页面启用/禁用:

{ "version": "1.0", "pages": { "Home": true, "FreeformManagement": false } }

MainShellViewModel 使用 IsPageEnabled("PageKeyName") 检查。禁用页面在侧边栏中以低透明度显示(Opacity=0.4),并附加 ToolTip 说明。

添加新页面

  1. 在 INavigationService.cs 的 PageKey 枚举中添加新值
  2. 创建 ViewModels/NewThingViewModel.cs(继承 ViewModelBase)
  3. 创建 Views/NewThingView.axaml + .axaml.cs
  4. 在启动壳的 Program.cs 中注册:services.AddSingleton<NewThingViewModel>()
  5. 在 MainView.axaml 侧边栏添加导航按钮

UI 服务

位于 SeatFlow.Presentation.Avalonia/Services/,平台专属实现由各启动壳注册:

服务 接口 平台 用途
NavigationService INavigationService 共享 页面切换,NavigateToAsync 运行 CanLeaveAsync()
DialogService IDialogService 桌面 错误/信息对话框,需 SetTopLevel(TopLevel) 初始化
FileService IFileService 桌面 文件打开/保存选择器,需 SetTopLevel() 初始化
WatchdogService — 桌面 UI 线程卡顿检测,默认超时 45 秒
ArrangementCounterService IArrangementCounterService 共享 排座次数内存计数,离开排座页面时上报到后端 API
WebDialogService IDialogService 浏览器 窗口内 overlay 对话框,不创建独立 OS 窗口
WebFileService IFileService 浏览器 文件选择/下载,经 files.js(sf.files)桥接
BrowserConsoleLoggerProvider ILoggerProvider 浏览器 日志转发到浏览器 DevTools Console

WatchdogService

仅桌面壳注册,通过后台轮询检测 UI 线程卡顿:

  • UI 线程必须定期调用 Ping()(App.axaml.cs 中 DispatcherTimer 每 3 秒执行)
  • 超时后:转储线程诊断到 err_<timestamp>.log,强制退出应用
  • 长时间操作(导出、导入)应保持在 WatchdogService 阈值以下

平台条件编译

共享类库同时面向 net10.0 与 net10.0-browser,浏览器专属代码通过 BROWSER 编译符号隔离:

#if BROWSER
    // 浏览器端实现,例如控制台日志、IndexedDB 专属逻辑
#endif

csproj 中通过 Condition="'$(TargetFramework)' == 'net10.0-browser'" 为浏览器目标单独引入资源(如内嵌 CJK 字体)。

浏览器端适配

  • 文件选择/下载经 files.js(sf.files)桥接,替代桌面文件选择器
  • 语言在 Avalonia 启动前异步预加载,保证 {x:Static} 首次解析正确
  • 日志转发到浏览器 DevTools Console(BrowserConsoleLoggerProvider)
  • 启用 JsonSerializerIsReflectionEnabledByDefault=true,保证 WASM 下序列化反射可用
  • 数据存储使用 IndexedDB(IndexedDbDataStore);数据目录设置隐藏

辅助窗口

以下独立窗口仅桌面壳使用;浏览器端不创建独立 OS 窗口,WebDialogService 以窗口内 overlay 形式呈现同类对话框。

窗口 文件 用途
DialogWindow Windows/DialogWindow.axaml 通用模态对话框(Confirm/Error/Warning/Info/MultiOption)
InputWindow Windows/InputWindow.axaml 单行文本输入对话框

DialogWindow 按钮绑定

按钮使用 Content="{x:Static lang:Resources.Common_OK}" 属性语法。 注意:在继承自 Window 的类中,Resources 解析为 Window.Resources(IResourceDictionary),需使用完全限定 Lang.Resources.xxx。

行为 (Behaviors)

位于 SeatFlow.Presentation.Avalonia/Behaviors/:

行为 文件 用途
CanvasZoomPan CanvasZoomPan.cs Canvas 缩放手势(平移/缩放)。拖放时通过 NaN 哨兵机制跳过平移
ZoomOnScroll ZoomOnScroll.cs Ctrl+滚轮缩放(需同时检查 KeyboardShortcutConfig.ZoomWithCtrlEnabled)
ChineseInputNormalizer ChineseInputNormalizer.cs 全角数字/符号 → 半角自动转换
KeyboardShortcutHandler KeyboardShortcutHandler.cs 全局键盘快捷键——根据 KeyboardShortcutConfig 开关 + 当前页 ViewModel 分派 Ctrl+Z/Y/S/Delete/Esc 命令

图标系统

使用 FluentUI 图标:

<fic:FluentIcon Icon="{x:Static ficEnum:Icon.DataBarVertical20}" FontSize="18"/>

完整图标列表见 SeatFlow.Presentation.Avalonia/docs/Fluent_Icons.md。

编译绑定

<!-- 项目设置中 AvaloniaUseCompiledBindingsByDefault=true -->
<UserControl xmlns:vm="clr-namespace:SeatFlow.Presentation.Avalonia.ViewModels"
             x:DataType="vm:HomeViewModel">    <!-- 必选 -->
    <TextBlock Text="{Binding Title}" />
</UserControl>
  • 始终在根元素设置 x:DataType
  • 编译绑定在 XAML 解析时验证类型安全性,运行时无反射开销
  • 转换器位于 Converters/:BoolConverters.cs(Negate、TrueWhenNull 等)、ValueConverters.cs

主题系统

主题字典

App.axaml 定义 ResourceDictionary.ThemeDictionaries:

  • Light 和 Dark 变体
  • 侧边栏颜色
  • 语义颜色:Success、Warning、Error、Info
  • 表面颜色和阴影

自定义资源

资源类型 使用方式
Brushes {StaticResource SuccessBrush} (通过 DynamicResource 引用主题色)
BoxShadows CardShadowNone、CardShadowLarge、CardShadowSmall
Typography Typography.axaml 排版样式
Spacing Spacing.axaml 间距系统
Colors Colors.axaml 色板

原则:始终使用 Brush 资源,切勿硬编码十六进制颜色。

样式包含顺序

App.axaml ──→ FluentTheme
          ──→ Colors.axaml
          ──→ Spacing.axaml
          ──→ Typography.axaml
          ──→ 自定义控件样式

字体

桌面端全局 Window 样式设置 CJK 友好的字体回退链:

<FontFamily>Inter,Microsoft YaHei UI,PingFang SC,
    Noto Sans CJK SC,WenQuanYi Micro Hei,sans-serif</FontFamily>

浏览器端在共享字体链基础上内嵌 Noto Sans SC(仅 net10.0-browser 目标框架包含该资源),确保系统无中文字体时正常显示。

i18n / 本地化

位于 SeatFlow.Presentation.Avalonia/Lang/:

  • Resources.resx — 中性语言 (zh-CN),约 700 键
  • Resources.en-US.resx — 英文卫星资源
  • Resources.Designer.cs — 手动维护的强类型访问器类

XAML 用法(仅属性语法)

<TextBlock Text="{x:Static lang:Resources.Settings_Title}" />
<Button Content="{x:Static lang:Resources.Common_OK}" />

C# 用法

StatusMessage = Resources.Settings_Saved;
StatusMessage = string.Format(Resources.Snapshot_VenuesLoadedFmt, count);

管理工具

python3 scripts/i18n.py list                     # 列出所有键
python3 scripts/i18n.py add KEY --zh "中文" --en "EN"  # 添加键
python3 scripts/i18n.py sync                     # 从 .resx 重新生成 Designer.cs

语言切换

App.ApplyLanguageFromSettings() 在启动时调用:

  1. 读取 AppSettings.Language
  2. 设置 CultureInfo.CurrentUICulture 和 Resources.Culture
  3. 必须在 AvaloniaXamlLoader.Load(this) 之前调用,以确保 {x:Static} 正确定位

浏览器端因应用设置需异步读取,语言会在 Avalonia 启动之前异步预加载,再执行上述步骤。

启动序列

桌面壳

App.axaml.cs 中的共享启动流程与桌面壳的窗口初始化:

1. StartupGuard.CheckEnvironment()
   ├── 验证 .NET 运行时 >= 10
   └── 验证操作系统:Windows 10+ / macOS 12+ / Linux

2. App.Initialize()
   ├── ApplyLanguageFromSettings()  ← 设置 CurrentUICulture
   └── AvaloniaXamlLoader.Load(this) ← 加载 XAML

3. OnFrameworkInitializationCompleted
   ├── DI 解析 MainShellViewModel, MainWindow
   ├── 绑定 DataContext: MainWindow.DataContext = mainShellViewModel
   ├── MainWindow 承载共享 MainView
   ├── IFileService.SetTopLevel(mainWindow)
   ├── IDialogService.SetTopLevel(mainWindow)
   ├── ViewModelBase.InitializeDialogService(dialogService)
   ├── 初始化 ViewModelBase 日志记录器
   ├── 启动 WatchdogService (3s DispatcherTimer)
   ├── 附加 ChineseInputNormalizer
   ├── 附加 KeyboardShortcutHandler(全局快捷键 Ctrl+Z/Y/S/Del/Esc)
   └── RestoreSettingsAsync()
       ├── 恢复主题
       └── 恢复窗口位置和大小

浏览器壳

浏览器壳使用 Avalonia 单视图生命周期,不涉及窗口、Watchdog 与单实例:

1. 异步预加载语言设置(在 Avalonia 启动之前完成)
2. App.Initialize() / Avalonia 初始化

3. OnFrameworkInitializationCompleted
   ├── DI 解析 MainShellViewModel
   ├── ISingleViewApplicationLifetime.MainView = MainView  ← 共享 UI 外壳
   ├── 初始化 WebFileService / WebDialogService
   └── 日志经 BrowserConsoleLoggerProvider 转发到浏览器 DevTools Console

侧边栏

  • 展开宽度:140px | 折叠宽度:64px
  • MainShellViewModel.SidebarWidth 控制
  • 窗口宽度 < 750px 时自动折叠
  • ToggleSidebar() 手动切换

代码约定

DockPanel 子元素顺序

LastChildFill="True"(默认)时,最后个子元素填充剩余空间。Dock 子元素必须在填充元素之前:

<DockPanel>
    <Sidebar DockPanel.Dock="Left" />
    <ScrollViewer>    <!-- 填充剩余空间 -->
        <ContentPresenter />
    </ScrollViewer>
</DockPanel>

关于页面版本

版本格式:"{about.json.version}+{git commit hash}"

GitCommit.Hash 是 GitCommit.g.cs 中的常量,由 MSBuild 目标在构建前自动生成(git rev-parse --short HEAD)。