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 说明。
添加新页面
- 在
INavigationService.cs的PageKey枚举中添加新值 - 创建
ViewModels/NewThingViewModel.cs(继承ViewModelBase) - 创建
Views/NewThingView.axaml+.axaml.cs - 在启动壳的
Program.cs中注册:services.AddSingleton<NewThingViewModel>() - 在
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() 在启动时调用:
- 读取
AppSettings.Language - 设置
CultureInfo.CurrentUICulture和Resources.Culture - 必须在
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)。