跳转到主内容

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.IsCompact
  • IsNavDrawerOpen / 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 提示。

添加新页面

  1. 在 Services/INavigationService.cs 的 PageKey 枚举中添加新值
  2. 创建 ViewModels/NewThingViewModel.cs(继承 ViewModelBase,构造注入 IDialogService)
  3. 创建 Views/NewThingView.axaml + .axaml.cs,根元素设置 x:DataType
  4. 在桌面与浏览器两个壳的 Program.cs 中注册 services.AddSingleton<NewThingViewModel>()
  5. 在 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 三种布局,复用 Core SeatGeometryHelper 几何;方格纸背景(24px 格线)、座位(38px)、讲台/门等覆盖物
  • 不可变输入:SeatLayoutSnapshot(含 Version)整体替换触发重绘;座位以 SeatVisual record 描述(占用/固定/禁用/拖拽悬停/数据失效/拾取选中,以及 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) 之前调用:

  1. 读取 AppSettings.Language
  2. 设置 CultureInfo.CurrentUICulture 与 Resources.Culture
  3. 再加载 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),文件不提交到版本控制。