SeatFlow.Presentation.Avalonia 共享 UI 层的 7 页导航、页面生命周期与横切服务、自绘 SeatingCanvas、令牌主题和启动序列
本页目录
全部文档
Presentation 层详解
SeatFlow.Presentation.Avalonia 是 SeatFlow 的共享 UI 层(桌面与浏览器共用),基于 Avalonia 12.1.3 构建,采用 CommunityToolkit.Mvvm 8.4.2 + ReactiveUI.Avalonia 12.1.5 双范式 MVVM(ADR-014)。该库目标框架为 net10.0;net10.0-browser,由 SeatFlow.Desktop 与 SeatFlow.Browser 两个启动壳承载,视图层保持普通 UserControl。
2.1.0 UI 重构(M0–M6):本层经过彻底重构——7 页信息架构、自绘
SeatingCanvas、页面生命周期与横切服务、令牌化主题。完整记录见docs/UI_REFACTOR.md,技术决策见docs/adr/ADR-014-reactiveui-and-canvas-architecture.md。
共享 UI 外壳
共享类库通过 MainView : UserControl 承载整个应用外壳(侧边栏 + 内容区),两个启动壳分别将其挂载到窗口或单视图:
| 壳 | 承载方式 |
|---|---|
SeatFlow.Desktop |
MainWindow : Window(Views/MainWindow.axaml) |
SeatFlow.Browser |
ISingleViewApplicationLifetime.MainView(不构造 Window) |
7 页信息架构
PageKey 枚举(Services/INavigationService.cs)共 7 个页面,默认入口为排座工作台。PageKey.Home 已移除,原「自由点管理」并入会场与布局(Grid/Polar/Freeform 单页三类布局)。
| PageKey | ViewModel | View | 用途 |
|---|---|---|---|
| SeatingArrangement | SeatingArrangementViewModel |
SeatingArrangementView |
排座工作台(默认入口,三栏布局) |
| MemberManagement | MemberManagementViewModel |
MemberManagementView |
人员管理(名单数据集) |
| VenueConfiguration | VenueConfigurationViewModel |
VenueConfigurationView |
会场与布局(Grid/Polar/Freeform) |
| StrategyConfiguration | StrategyConfigurationViewModel |
StrategyConfigurationView |
策略配置 |
| SnapshotHistory | SnapshotHistoryViewModel |
SnapshotHistoryView |
历史快照 |
| Settings | SettingsViewModel |
SettingsView |
设置(分组卡片) |
| About | AboutViewModel |
AboutView |
关于 |
侧栏按语义分组(MainView.axaml):工作流(排座工作台)、资料(人员管理、会场与布局)、规则(策略配置)、记录(历史快照),底部固定设置/关于;中部可滚动,修复矮视口覆盖。
排座工作台为三栏结构:左「数据选择」→ 中 SeatingCanvas → 右检查器页签(策略 / 未分配 / 记录 / 消息)。紧凑模式下左右两栏分别转为抽屉。
MVVM 双范式
ViewModelBase
所有 ViewModel 继承 ViewModelBase(扩展 ObservableObject),对话框与日志均为构造函数注入(ViewModelBase.Dialog 静态状态已于 2.1.0 移除):
public abstract class ViewModelBase : ObservableObject
{
protected ViewModelBase(IDialogService dialog, ILogger? logger = null);
protected IDialogService Dialog { get; } // 无对话框场景传 NullDialogService.Instance
protected ILogger? Logger { get; }
protected Task<bool> SafeExecuteAsync(Func<Task> action, string? errorTitle = null);
protected Task<bool> SafeExecuteAsync(Func<CancellationToken, Task> action,
TimeSpan timeout, string? errorTitle = null);
protected Task<bool> SafeCancelableAsync(Func<Task> action, string? errorTitle = null);
public virtual Task<bool> CanLeaveAsync(); // 导航离开前的未保存确认
}
- 简洁重载:try-catch,自动错误对话框
- 超时重载:
CancellationTokenSource自动取消,超时对话框;超时值应远低于 Watchdog 阈值(45 秒) - SafeCancelableAsync:页面离开取消长任务时静默返回,不弹错误框
CommunityToolkit.Mvvm 源码生成器(存量 VM)
public partial class ExampleViewModel : ViewModelBase
{
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(IsValid))]
private int _count;
[RelayCommand]
private async Task SaveAsync() { }
}
ReactiveUI(新代码优先)
ADR-014 起新 VM 可使用 ReactiveUI 表达异步、去抖、取消与派生状态:
public partial class NewPageViewModel : ReactiveObject
{
[Reactive] public partial string Query { get; set; }
public ReactiveCommand<Unit, Unit> Search { get; }
public NewPageViewModel()
{
Search = ReactiveCommand.CreateFromTask(SearchAsync);
this.WhenAnyValue(x => x.Query)
.Throttle(TimeSpan.FromMilliseconds(120))
.Subscribe(q => _ = Search.Execute());
}
}
两条范式在同一编译单元共存,不强制一次性迁移;视图层一律保持普通 UserControl,不使用 ReactiveUserControl<T>(规避裁剪下的 IL2026 反射激活警告)。
ViewLocator
ViewLocator(ViewLocator.cs)实现 IDataTemplate,按命名约定将 XXXViewModel 中的 "ViewModel" 替换为 "View" 解析类型;并用 ConditionalWeakTable<ViewModelBase, Control> 按 VM 实例弱引用缓存已构造的 View——切页不重复构建 XAML,Transient 页面离开后可回收。
导航系统
INavigationService
位于 SeatFlow.Presentation.Avalonia/Services/INavigationService.cs:
public interface INavigationService
{
ViewModelBase CurrentViewModel { get; }
PageKey CurrentPage { get; }
event Action? CurrentViewModelChanged;
void NavigateTo(PageKey page);
Task<bool> NavigateToAsync(PageKey page); // 先调用 CanLeaveAsync()
}
NavigationService 构造时默认导航到 PageKey.SeatingArrangement,并从 DI 解析对应页面 ViewModel(全部为单例)。
MainShellViewModel
主壳 ViewModel,管理页面切换与外壳布局:
CurrentViewModel/CurrentPage/CurrentPageTitle— 当前页状态SidebarWidth— 侧边栏宽度(展开 172 / 折叠 64)IsSidebarExpanded/ToggleSidebar()— 桌面侧栏折叠CompactBreakpoint = 900— 由OnWindowWidthChanged驱动IShellLayoutService.IsCompactIsNavDrawerOpen/ToggleNavDrawer()/CloseNavDrawer()— 紧凑模式导航抽屉- 页面切换即时完成(M3 起去除 200ms 淡出与间隔),切换后按
PageKey调度页面引导检查
页面导航可见性
JSON 配置 Data/page_navigation.json(嵌入资源)控制页面启用/禁用:
{ "version": "1.0", "pages": { "SeatingArrangement": true, "SnapshotHistory": true } }
MainShellViewModel 使用 IsPageEnabled("PageKeyName") 检查。禁用页面在侧栏中以低透明度显示(Opacity,而非 IsEnabled,否则 ToolTip 失效),并绑定 Nav_{Page}Disabled 提示。
添加新页面
- 在
Services/INavigationService.cs的PageKey枚举中添加新值 - 创建
ViewModels/NewThingViewModel.cs(继承ViewModelBase,构造注入IDialogService) - 创建
Views/NewThingView.axaml+.axaml.cs,根元素设置x:DataType - 在桌面与浏览器两个壳的
Program.cs中注册services.AddSingleton<NewThingViewModel>() - 在
MainView.axaml侧栏对应分组添加导航入口(桌面MainWindow复用同一外壳)
页面生命周期与横切服务
IPageLifecycle
Services/IPageLifecycle.cs 统一页面生命周期,构造器禁止 fire-and-forget 加载:
public interface IPageLifecycle
{
bool IsDirty { get; }
Task InitializationTask { get; } // OnEnter 完成置位,离开换新
Task OnEnterAsync(CancellationToken ct); // ct 在离开页面时取消
Task OnLeaveAsync();
}
生命周期由各页 View 的 Loaded / Unloaded 桥接驱动;OnLeaveAsync 取消在途加载。数据加载成功标志仅在成功时置位——取消/失败后下次进入自动重试。InitializationTask 同时供引导系统等待页面加载完成后再注入演示数据。
横切服务一览
| 服务 | 文件/类型 | 职责 |
|---|---|---|
IPageLifecycle |
Services/IPageLifecycle.cs |
进入/离开/脏状态/初始化任务契约 |
DirtyTracker |
Services/DirtyTracker.cs |
统一 JSON 快照脏检查(替代 5 套手写实现) |
IDialogGate |
Services/IDialogGate.cs |
Interlocked 对话框门,替代 _dialogLock + Task.Delay(150) |
IBusyScope |
Services/IBusyScope.cs |
页面繁忙作用域(可嵌套计数),驱动遮罩与命令禁用 |
IShellLayoutService |
Services/IShellLayoutService.cs |
全局紧凑断点(IsCompact,≤900px) |
IGuideSeedTarget |
Services/IGuideSeedTarget.cs |
引导演示数据注入契约(SeedGuideData/ClearGuideData),5 页自实现 |
NullDialogService |
Services/NullDialogService.cs |
空对象对话框服务(Instance),供单测/设计时使用 |
AccentColorApplier |
Services/AccentColorApplier.cs |
应用主题色(默认 #83B6DE / 跟随系统),派生悬停/按色与对比前景 |
PreviewSeatSize |
Services/PreviewSeatSize.cs |
会场/快照预览的座位尺寸与坐标等比放大(座位 40×23) |
SideDrawerState |
ViewModels/SideDrawerState.cs |
可复用的「桌面内联面板 ↔ 紧凑右侧抽屉」状态 |
DI 注册:
IDialogGate、IBusyScope、IShellLayoutService等均为 DI 注册:桌面与浏览器两个Program.cs保持一致;双壳共用services.AddGuideSeedTargets()注册 5 个IGuideSeedTarget。
自绘画布 SeatingCanvas
Controls/SeatingCanvas.cs 是一个自绘 Control,取代了逐座位控件方案(ADR-014):
- 统一渲染:
Render(DrawingContext)绘制 Grid/Polar/Freeform 三种布局,复用 CoreSeatGeometryHelper几何;方格纸背景(24px 格线)、座位(38px)、讲台/门等覆盖物 - 不可变输入:
SeatLayoutSnapshot(含Version)整体替换触发重绘;座位以SeatVisualrecord 描述(占用/固定/禁用/拖拽悬停/数据失效/拾取选中,以及Row/Column/Ring/AngleDegrees位置回传字段),不做逐座位绑定 - 交互内置:指针坐标反算座位的几何命中、拖拽交换、座位→垃圾桶(
SeatDropEventArgs.IsOverTrash)、Ctrl+滚轮缩放(0.2–3.0,锚点保持)、空白拖拽平移 - 禁用座位拾取:
IsSeatPicking模式下禁用位可命中且不进入拖拽(位移超过阈值视为误触),供会场配置页在预览中多选/反选禁用座位;草稿高亮区分待禁用(虚线 Accent)与待恢复(实心 Accent) - 可访问性:方向键虚拟焦点 + Enter 激活,配合
AutomationProperties.Name - 性能:按状态合批
GeometryGroup、文本矢量化缓存、视口裁剪、手势期 LOD(座位数超过 150 时仅大布局降级姓名绘制)
相关类型:SeatVisual.cs(SeatVisual / BoardOverlay / SeatLayoutSnapshot);SeatEventArgs / SeatDropEventArgs 定义在 SeatingCanvas.cs。
旧行为已退役:
Behaviors/CanvasZoomPan.cs、Behaviors/ZoomOnScroll.cs已删除,缩放/平移矩阵全部并入SeatingCanvas。
UI 服务
位于 SeatFlow.Presentation.Avalonia/Services/,平台专属实现由各启动壳注册:
| 服务 | 接口 | 平台 | 用途 |
|---|---|---|---|
| NavigationService | INavigationService |
共享 | 页面切换,NavigateToAsync 运行 CanLeaveAsync() |
| DialogService | IDialogService |
桌面 | 错误/信息对话框,需 SetTopLevel(TopLevel) 初始化 |
| FileService | IFileService |
桌面 | 文件打开/保存选择器,需 SetTopLevel() 初始化 |
| DesktopUrlOpener | IUrlOpener |
桌面 | 调用系统浏览器打开外链 |
| WatchdogService | — | 桌面 | UI 线程卡顿检测,默认超时 45 秒 |
| ArrangementCounterService | IArrangementCounterService |
共享 | 排座次数内存计数,离开排座页面时上报后端 API |
| WebDialogService | IDialogService |
浏览器 | 窗口内 overlay 对话框(DialogContent/InputContent),不创建独立 OS 窗口 |
| WebFileService | IFileService |
浏览器 | 文件选择/下载,经 files.js(sf.files)桥接 |
| WebUrlOpener / WebNoopUpdateService | IUrlOpener / IUpdateService |
浏览器 | 外链打开;更新服务空实现 |
| BrowserConsoleLoggerProvider | ILoggerProvider |
浏览器 | 日志转发到浏览器 DevTools Console |
WatchdogService
仅桌面壳注册,通过后台轮询检测 UI 线程卡顿:
- UI 线程必须定期调用
Ping()(App.axaml.cs中 DispatcherTimer 每 3 秒执行) - 超时(45 秒)后转储线程诊断到
err_<timestamp>.log并强制退出 - 长时间操作(导出、导入)应保持在 Watchdog 阈值以下;应用退出亦有 20 秒强制终止兜底
平台条件编译
共享类库同时面向 net10.0 与 net10.0-browser,浏览器专属代码通过 BROWSER 编译符号隔离:
#if BROWSER
// 浏览器端实现,例如控制台日志、IndexedDB 专属逻辑
#endif
csproj 中通过 Condition="'$(TargetFramework)' == 'net10.0-browser'" 为浏览器目标单独引入资源(如内嵌 Noto Sans SC 字体)。
浏览器端适配
- 文件选择/下载经
files.js(sf.files)桥接,替代桌面文件选择器 - 语言在 Avalonia 启动前于
SeatFlow.Browser/Program.Main异步预加载,保证{x:Static}首次解析正确 - 运行时语言切换需
UseSystemResourceKeys=false且main.js预加载卫星资源(否则 en-US 回退中文) - 启用
JsonSerializerIsReflectionEnabledByDefault=true,保证 WASM 下反射序列化可用 - 日志转发到浏览器 DevTools Console(
BrowserConsoleLoggerProvider);数据存储使用 IndexedDB(IndexedDbDataStore),数据目录设置隐藏
辅助窗口
以下独立窗口位于 Views/,仅桌面壳使用;浏览器端不创建独立 OS 窗口,WebDialogService 以窗口内 overlay 形式呈现同类对话框。
| 窗口 | 文件 | 用途 |
|---|---|---|
DialogWindow |
Views/DialogWindow.axaml |
通用模态对话框(Confirm/Error/Warning/Info/MultiOption) |
InputWindow |
Views/InputWindow.axaml |
单行文本输入对话框 |
SeatSetsSelectionWindow |
Views/SeatSetsSelectionWindow.axaml |
.seatsets 导入/导出类别选择 |
UpdateDialogWindow |
Views/UpdateDialogWindow.axaml |
自动更新进度与结果 |
DialogWindow 按钮绑定
按钮使用 Content="{x:Static lang:Resources.Common_OK}" 属性语法。
注意:在继承自 Window 的类中,Resources 解析为 Window.Resources(IResourceDictionary),需使用完全限定 Lang.Resources.xxx。
行为 (Behaviors)
位于 SeatFlow.Presentation.Avalonia/Behaviors/,均为 DI 单例并显式 Attach:
| 行为 | 文件 | 用途 |
|---|---|---|
| ChineseInputNormalizer | ChineseInputNormalizer.cs |
全角数字/符号 → 半角自动转换 |
| KeyboardShortcutHandler | KeyboardShortcutHandler.cs |
全局键盘快捷键——按 KeyboardShortcutConfig 开关 + 当前页 ViewModel 分派 Ctrl+Z/Y/S/Delete/Esc |
| FileDropHandler | FileDropHandler.cs |
全局文件拖放,Tunnel 事件路由到 IFileDropHandler |
拖放路由:排座工作台 .seatsets、人员管理 .csv/.xlsx/.json、会场与布局 .csv/.json、设置 .seatsets。新页面支持拖放 = 实现 IFileDropHandler 三个成员。详见 docs/presentation/DragDrop.md。
图标系统
使用 FluentUI 图标:
<fic:FluentIcon Icon="{x:Static ficEnum:Icon.DataBarVertical20}" FontSize="18"/>
完整图标列表见仓库 docs/presentation/Fluent_Icons.md。
编译绑定
<!-- 项目设置中 AvaloniaUseCompiledBindingsByDefault=true -->
<UserControl xmlns:vm="clr-namespace:SeatFlow.Presentation.Avalonia.ViewModels"
x:DataType="vm:SeatingArrangementViewModel"> <!-- 必选 -->
<TextBlock Text="{Binding Title}" />
</UserControl>
- 所有 XAML 根元素必须设置
x:DataType;全量编译绑定,ReflectionBinding实际使用为 0 - 静态数据优先
Mode=OneTime - 转换器位于
Converters/:BoolConverters.cs(Negate、TrueWhenNull 等)、ValueConverters.cs
主题与令牌系统
令牌文件(唯一真相源)
| 层 | 文件 | 内容 |
|---|---|---|
| 颜色 | Resources/Tokens/Colors.axaml |
ThemeDictionaries(Light/Dark)内为原始 Color;字典外用 SolidColorBrush 经 DynamicResource 引用;同时覆盖 Fluent SystemControl*/SystemAccentColor* |
| 间距/圆角/尺寸 | Resources/Tokens/Spacing.axaml |
SfSpace1..5、SfRadiusXs/Sm/Lg/Pill、页面内边距、侧栏/行高/控件高/座位尺寸等 |
| 字阶 | Resources/Tokens/Typography.axaml |
SfFontXs/Sm/Md/Lg/Xl |
| 动效 | Tokens/MotionTokens.cs |
Quick(120ms) / Normal(180ms) / Modal(120ms),经 {x:Static} 用于 Transitions |
| 组件样式 | Styles/Components.axaml |
sf-* 类(按钮/卡片/页签/表格/设置行/命令栏/状态栏等) |
原则:颜色只用
{DynamicResource Sf*Brush},尺寸只用{StaticResource SfSpace*/SfRadius*},禁止硬编码十六进制色值;明暗双主题同等维护。方向 B「方格纸」设计规范见docs/presentation/Design_Spec.md。
主题色
AccentColorApplier 支持「默认 #83B6DE」与「跟随系统」两种模式,启动时由 RestoreSettingsAsync 应用,并按对比度自动选择主题色按钮前景。
动效政策
- 导航/页面切换:即时(无强制淡出/间隔)
- 仅保留:侧栏折叠(150–180ms)、悬停/按下颜色过渡(120ms)、对话框出现(120ms)
- 尊重 reduced motion,检测到平台设置后全部禁用
字体
桌面端全局 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 目标框架包含该资源),确保系统无中文字体时正常显示。
设置页
SettingsView 采用分组卡片两列网格(WrapPanel,卡片行高独立;≤900px 紧凑模式单列):外观 / 行为 / 键盘快捷键 / 存储 / 日志 / 隐私 / 更新 / 工具。页面结构为命令栏(保存/恢复默认)→ 内容 → 状态栏;全部颜色与尺寸来自令牌。
i18n / 本地化
位于 SeatFlow.Presentation.Avalonia/Lang/:
Resources.resx— 中性语言 (zh-CN)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.Initialize() 中 ApplyLanguageFromSettings() 在 AvaloniaXamlLoader.Load(this) 之前调用:
- 读取
AppSettings.Language - 设置
CultureInfo.CurrentUICulture与Resources.Culture - 再加载 XAML,确保
{x:Static}正确定位
浏览器端应用设置需异步读取,语言在 SeatFlow.Browser/Program.Main 中于 Avalonia 启动前异步预加载,再走同一 App.ApplyLanguage 路径。
启动序列
桌面壳(SeatFlow.Desktop/Program.cs → App.axaml.cs)
1. Velopack 钩子(必须在 Main 最开头)→ 安装 hook / .seatsets 命令行参数处理
2. 单实例 Mutex;非首实例且带 .seatsets 则经命名管道转发后退出
3. 构建 DI:AddSeatFlowApplication(数据目录) → 注册平台/横切服务与全部页面 VM
4. App 构造参数化:new App(serviceProvider, isFirstInstance, seatsetsFilePath, autoImportPath)
5. App.Initialize()
├── ApplyLanguageFromSettings()(同步,UI 调度器就绪前经 Task.Run)
└── AvaloniaXamlLoader.Load(this)
6. OnFrameworkInitializationCompleted
├── 解析 MainShellViewModel / MainWindow,承载共享 MainView
├── IFileService / IDialogService.SetTopLevel(mainWindow)
├── 启动 WatchdogService + 3s DispatcherTimer Ping
├── 附加 ChineseInputNormalizer / KeyboardShortcutHandler / FileDropHandler
└── SafeInitializeAsync()
├── .seatsets 自动导入(首次启动)
├── 首次启动检测
├── RestoreSettingsAsync():主题 / 语言 / 主题色 / 窗口位置与大小
├── 遥测同意 → 启动引导(Background 优先级)
└── 记录启动遥测 → 延迟自动更新检查
SafeInitializeAsync() 为 fire-and-forget 提供异常兜底,避免未观察异常拆毁运行时。
浏览器壳(SeatFlow.Browser/Program.cs → App.axaml.cs)
1. await JSHost.ImportAsync 加载 interop.js(sf.idb)/ files.js(sf.files)
2. 构建 DI:AddSeatFlowApplication(new IndexedDbDataStore()) + 浏览器平台服务
3. await IApplicationFacade.LoadAppSettingsAsync() → App.ApplyLanguage()(Avalonia 启动前)
4. OnFrameworkInitializationCompleted(ISingleViewApplicationLifetime)
├── 解析 MainShellViewModel / MainView,MainView.DataContext = mainShell
├── TopLevel.GetTopLevel(MainView) → SetTopLevel 文件/对话框服务
└── SafeInitializeAsync()
浏览器端限制:浏览器端禁止同步阻塞(
Cannot wait on monitors),因此语言必须异步预加载;浏览器壳也不涉及窗口、Watchdog 与单实例。
紧凑断点与抽屉
- 全局紧凑断点 ≤900px(
MainShellViewModel.CompactBreakpoint→IShellLayoutService.IsCompact) - 桌面侧栏:展开 172px / 折叠 64px,
ToggleSidebar()手动切换 - 紧凑模式:导航转左侧抽屉(
IsNavDrawerOpen),页面上下文面板(数据选择 / 检查器 / 数据集列表等)转右侧抽屉,遮罩点击关闭 - 页面内面板复用
SideDrawerState(构造参数:桌面列/宽度、抽屉宽度、描边),无需各页重复实现
代码约定
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),文件不提交到版本控制。