跳转到主内容

SeatFlow 基于 JSON 声明式配置的引导系统设计,包括启动引导、页面引导、演示数据注入和窗口状态同步

引导系统

概述

SeatFlow 的引导系统帮助新用户快速上手核心功能的操作。系统完全由 JSON 配置驱动(当前配置 v3.4:24 步启动引导 + 页面引导机制),分为两种触发模式:启动引导(首次启动时触发完整工作流)和页面引导(首次访问某页面时触发)。页面演示数据注入已接口化(IGuideSeedTarget),OnboardingService 不持有任何静态状态。

架构

Guide 控件 (CodeWF)
  步骤列表 + 高亮定位 + 前进/后退 + 完成/关闭
       │
       │ StepOpening 事件 → 解析 Target
       ▼
OnboardingService
  ├── StartOnboarding() — 启动引导
  ├── TryShowPageGuide(page) — 页面引导
  ├── BuildStepsFromDefs() — JSON → GuideStepOption 转换
  └── SeedPageDataAsync() — 等待 IPageLifecycle.InitializationTask 后
                            按 IGuideSeedTarget 注入/清理演示数据
       │
       ▼
onboarding_config.json(嵌入资源)
  { startupPhases: [...], pageGuides: {} }

关键文件

文件 作用
Data/onboarding_config.json 唯一的数据源——所有阶段、步骤、资源键引用
Services/IOnboardingService.cs 接口定义
Services/OnboardingService.cs 核心实现——加载 JSON、构建步骤、处理事件、按接口等待初始化并注入演示数据
Services/IGuideSeedTarget.cs 演示数据注入契约(SeedGuideData() / ClearGuideData(),由页面自行实现)
Services/OnboardingPhaseDefinition.cs 数据模型
Views/MainWindow.axaml Guide 控件声明
Views/MainWindow.axaml.cs 事件处理器转发到 IOnboardingService
App.axaml.cs 启动时调用 CheckAndStartOnboardingAsync()
ViewModels/MainShellViewModel.cs 导航完成后触发 TryShowPageGuide
ViewModels/SettingsViewModel.cs “重新开始引导”按钮
Core/Models/AppSettings.cs IsFirstLaunch + CompletedPageGuides 字典
Styles/Guide.axaml Guide 控件的 ControlTheme

两种触发模式

启动引导(startupPhases)

  • 触发时机:首次启动时,IsFirstLaunch == true 或设置文件不存在
  • 触发路径:App.axaml.cs → CheckAndStartOnboardingAsync()
  • 范围:24 步完整工作流,覆盖 9 个阶段
  • 完成标记:AppSettings.IsFirstLaunch = false(启动时立即持久化,崩溃安全)

触发流程:

App.axaml.cs → CheckAndStartOnboardingAsync()
  ├── 检测 IsFirstLaunch == true 或设置文件不存在
  ├── 立即持久化 IsFirstLaunch = false(崩溃安全)
  └── Dispatcher.UIThread.Post(Background) → onboarding.StartOnboarding()
       ├── 加载 JSON 配置 + 平铺步骤列表
       ├── 设置 MainShellViewModel.IsOnboardingActive = true
       ├── 展开侧边栏(欢迎阶段留在默认入口「排座工作台」)
       ├── 订阅 Guide.StepOpening 事件
       └── Dispatcher.UIThread.Post(Loaded) → Guide.Show()

页面引导(pageGuides)

  • 触发时机:用户首次进入某个页面
  • 触发路径:MainShellViewModel.SchedulePageGuideCheck()(导航完成后触发)
  • 范围:仅展示该页面的关键操作
  • 完成标记:持久化到 AppSettings.CompletedPageGuides 字典

触发流程:

MainShellViewModel.SchedulePageGuideCheck()
  └── Dispatcher.UIThread.Post(Background) → onboarding.TryShowPageGuide(page)
       ├── 守卫:IsActive == false(启动引导或已有页面引导活跃则跳过)
       ├── 守卫:该 page 在 pageGuides 中有定义
       ├── 守卫:CompletedPageGuides 中不存在
       ├── 设置 IsActive = true, _currentPageGuide = pageKey
       ├── 构建该页面的步骤列表
       └── Guide.Show()

JSON 配置结构

顶层结构

{
  "version": "3.4",
  "startupPhases": [ OnboardingPhaseDefinition, ... ],
  "pageGuides": {}
}

OnboardingPhaseDefinition

字段 类型 必填 说明
page string? 否 要导航到的页面(PageKey 枚举名称)。null = 留在当前页
seedData bool 否 跨阶段导航时是否注入演示数据。默认 false
steps array 是 OnboardingStepDefinition 数组

OnboardingStepDefinition

字段 类型 默认值 说明
titleKey string (必填) Resources.resx 中标题文本的键名
descKey string (必填) Resources.resx 中描述文本的键名
target string "" 目标控件的 x:Name,支持分号分隔候选项;空字符串 = 居中模态
placement string "Right" 弹窗方向:Top/Bottom/Left/Right/Center
showMask bool true 是否显示深色遮罩
showArrow bool true 是否显示指向箭头

启动引导步骤一览

当前启动引导包含 9 个阶段共 24 步:

阶段 页面 seedData 步数 目标控件
欢迎 (留在当前页,默认「排座工作台」) — 2 (居中模态), ToggleSidebarButton
成员管理(导入) MemberManagement false 2 ExportTemplateButton, ImportButton
成员管理(更新) MemberManagement true 3 UpdateFromFileButton, StudentListBox, NewStudentRow
会场配置 VenueConfiguration true 3 NewVenueButton, LayoutTypePanel, SaveVenueButton
策略配置 StrategyConfiguration true 4 StrategyListBox, EditEnabledSwitch, (居中), SaveAllButton
排座生成 SeatingArrangement true 4 VenueListBox, DatasetListBox, GenerateButton, ExportButton
快照历史 SnapshotHistory true 2 VenueComboBox, SnapshotListBox
快捷键设置 Settings false 3 KeyboardShortcutsSection, UndoShortcutSwitch, SaveSettingsButton
结束 (留在当前页) — 1 (居中模态)

工作流阶段转换

MemberManagement 分两次进入(M3 起中间改经默认入口「排座工作台」中转):

  • Phase 1: MemberManagement(seedData: false)→ ImportButton 可见,无演示数据
  • 代码内排座工作台往返:Phase 2 过渡时自动执行 OnboardingNavigateTo(SeatingArrangement) → OnboardingNavigateTo(MemberManagement),强制页面离开-重入(Home 页已移除)
  • Phase 2: MemberManagement(seedData: true)→ 等待 InitializationTask 后注入演示数据,UpdateFromFileButton 可见

页面引导一览

当前 v3.4 的 pageGuides 为空,机制保留;新增条目后按「首次访问 + 未完成标记」触发。

演示数据注入(M5 接口化)

声明式控制

通过 OnboardingPhaseDefinition.SeedData(JSON bool,默认 false)声明式控制。HandleStepOpening 在检测到阶段过渡时仅当 phase.SeedData == true 才调用 SeedPageDataAsync()。原运行状态标志(_memberManagementDataSeeded 等)已删除,改为纯 JSON 声明式控制;OnboardingService 不再持有任何演示注入静态字段。

接口与等待

  • 页面 ViewModel 实现 IGuideSeedTarget(SeedGuideData() / ClearGuideData()),自行保存注入前状态,注入/清理均为纯内存且幂等。
  • SeedPageDataAsync() 先等待目标页 IPageLifecycle.InitializationTask(10s 兜底超时),确保 OnEnterAsync 的异步加载完成后才注入,避免注入数据被后续加载整体替换。
  • 构造器 fire-and-forget 加载已全面禁止(见 IPageLifecycle 约定),因此不再需要 DispatcherPriority.Background 时序补偿。

注入详情

页面 注入内容
MemberManagement 6 名示例学生 → Students ObservableCollection + 演示数据集 → SavedDatasets(仅在无用户数据时;已有数据则跳过覆盖)
VenueConfiguration 执行 NewVenueCommand 创建演示会场
StrategyConfiguration 选中 Strategies[0](首个策略)
SeatingArrangement 演示会场+数据集 + 4×3 座位;注入后调用公开 UpdateCanvasSnapshot() 让画布渲染
SnapshotHistory 1 个演示快照 → Snapshots(Singleton 下不误清用户浏览状态)

注入原则

  • 零磁盘痕迹:引导结束无任何残留数据文件
  • 不依赖 Infrastructure 层:仅使用 Core 模型 + ViewModel public API
  • 实现下沉到页面:注入/清理由各页 IGuideSeedTarget 实现,OnboardingService 只按接口调用

完成与清理

完成流程

OnboardingService.CompleteOnboardingAsync()
  ├── 取消订阅 StepOpening 事件
  ├── 捕获 wasPageGuide(在 await 前保存,防竞态)
  ├── 立即设置 IsActive = false, _currentPageGuide = null
  ├── 若是页面引导 → 持久化到 AppSettings.CompletedPageGuides
  ├── 若是启动引导 → 调用 vm.CompleteOnboardingAsync() 恢复 UI
  └── 关闭 Guide 控件,清空 StepsSource

ClearGuideData 清理

CompleteOnboardingAsync() 中对启动引导涉及的 5 个页面调用 IGuideSeedTarget.ClearGuideData():

页面 清理内容
MemberManagement 恢复注入前的学生/数据集状态;仅在实际注入过演示数据时才清理,防止误清用户已导入的真实数据
VenueConfiguration 清理演示会场及注入前状态
StrategyConfiguration 恢复注入前的选中策略
SeatingArrangement 清除演示会场/数据集与预览
SnapshotHistory 清除演示快照

各页在 ClearGuideData() 内部自行判断是否实际注入过(幂等),不再使用静态标志。

关闭确认

用户点击 × 或按 Esc:

  • 若已正常完成(_isCompleting == true)→ 直接关闭
  • 否则弹出 ShowConfirmAsync 确认对话框
  • 确认 → 执行 CompleteOnboardingAsync()
  • 取消 → 调用 Guide.Show() 恢复显示

窗口状态同步(v3.1)

MainWindow 订阅 Activated/Deactivated 事件,转发到 OnboardingService:

事件 行为
Deactivated(最小化/Alt+Tab) _isWindowObscured=true,Guide.Close() 静默关闭 Popup(无确认对话框,无完成标记)
Activated(恢复) 重置标志,从保留的 CurrentIndex 恢复 Guide

这解决了 Guide 的 3 个 Popup(ShouldUseOverlayLayer=False,原生 OS 窗口)在窗口最小化时可能残留为孤儿窗口的问题。

导航顺序(Phase 1 修复)

HandleStepOpening 必须在解析目标控件的 x:Name 之前导航到新页面。

正确顺序:导航 → 解析目标
错误顺序:解析目标 → 导航

原始顺序(解析 → 导航)导致 ContentPresenter.Child 引用旧页面,使每个阶段第一步的 NameScope 查找失败。

配合 OnboardingNavigateTo 的同步 CurrentViewModel 设置(M3 起切页即时化、无过渡动画),目标控件在首次尝试时即可正确解析。

阶段点按阶段(而非步骤)呈现(v3.4)

引导卡片左下角的步骤指示点默认按每个子步骤渲染(~24 个点),视觉上过于密集。v3.4 改为按阶段(9 个点)呈现。

实现原理

查阅 Guide 控件(CodeWF.AvaloniaControls)源码后发现:

  1. Guide.StepCount 是 DirectProperty 且仅注册 getter、CLR setter 为 private,外部无法修改
  2. RefreshStepCollection() 每次 StepsSource 赋值时强制重置 StepCount = _activeSteps.Count
  3. SyncIndicator() 在每次步骤切换时执行 Indicator.StepCount = StepCount 和 Indicator.ActiveIndex = CurrentIndex
  4. GoTo(int index) 内部校验 index < StepCount——若设 StepCount=9 会导致步骤导航在第 9 步后失效

方案

在 OnStepOpened 事件中(SyncIndicator() 执行 之后),通过 Dispatcher.UIThread.Post(..., Background) 延迟覆盖 _guide.Indicator.StepCount 和 _guide.Indicator.ActiveIndex。

private void OnStepOpened(object? sender, GuideStepEventArgs e)
{
    int phaseCount = _activePhaseBoundaries.Count - 1;
    int phaseIndex = GetPhaseIndex(e.Index);
    var guide = _guide;

    Dispatcher.UIThread.Post(() =>
    {
        if (guide.Indicator is null) return;
        guide.Indicator.StepCount = phaseCount;
        guide.Indicator.ActiveIndex = phaseIndex;
    }, DispatcherPriority.Background);
}
  • Background 优先级保证在所有同步操作之后执行
  • 不影响 Guide 内部的步骤导航(Next/Previous 仍按 ~24 步切换)
  • GetPhaseIndex() 利用已有的 _activePhaseBoundaries 数组查找当前步骤所属阶段

详见主仓库 docs/adr/ADR-011-keyboard-shortcuts.md 第 5 节(引导阶段点按页面而非步骤呈现)。

添加/修改引导步骤

添加新步骤

只需编辑 JSON + 资源键,不修改 C# 代码:

  1. 在 onboarding_config.json 中添加步骤定义

  2. 用脚本新增资源键(三文件 + Designer.cs 自动同步):

    python3 scripts/i18n.py add Guide_Members_Save_Title --zh "保存数据集" --en "Save Dataset"
    python3 scripts/i18n.py add Guide_Members_Save_Desc --zh "点击保存当前名单" --en "Click to save the current roster"

如果新步骤需要高亮某个控件,先在对应的 .axaml 文件中给控件添加 x:Name。

示例——在 MemberManagement 阶段添加一步”保存数据集”:

// 在 Members 阶段的 steps 数组末尾添加
{ "titleKey": "Guide_Members_Save_Title",
  "descKey": "Guide_Members_Save_Desc",
  "target": "SaveButton",
  "placement": "Bottom" }

脚本会自动把键写入两个 .resx 并重新生成 Resources.Designer.cs,无需手工编辑。

添加新页面的页面引导

在 pageGuides 字典中添加新条目:

"SnapshotHistory": {
  "page": "SnapshotHistory",
  "steps": [
    { "titleKey": "Guide_Snapshot_XXX_Title", "descKey": "Guide_Snapshot_XXX_Desc", "target": "..." }
  ]
}

键名必须与 PageKey 枚举值完全匹配(区分大小写)。

修改现有步骤

只需编辑 JSON 中的字段,或修改 resx 中的文本内容。无需改 C# 代码。

删除步骤

从 JSON 中移除步骤定义即可。旧的 resx 键可以保留(无害)。

目标控件清单

MainWindow 中(可跨页面访问)

x
控件 用途
ToggleSidebarButton Button 侧边栏折叠/展开

MemberManagementView

x
控件 用途
ExportTemplateButton Button 导出导入模板
ImportButton Button 导入学生数据
UpdateFromFileButton Button 从文件更新学生数据
StudentListBox ListBox 学生列表
NewStudentRow Border 新增学生行
SaveButton Button 保存数据集

VenueConfigurationView

x
控件 用途
NewVenueButton Button 新建会场
LayoutTypePanel StackPanel 布局类型选择
SaveVenueButton Button 保存会场
ImportCsvButton Button 导入自由布局 CSV(原自由点管理页并入本页)
AddPointButton Button 添加坐标点

StrategyConfigurationView

x
控件 用途
StrategyListBox ListBox 策略列表
EditEnabledSwitch ToggleSwitch 启用/禁用策略
SaveAllButton Button 保存全部策略

SeatingArrangementView

x
控件 用途
VenueListBox ListBox 会场选择
DatasetListBox ListBox 数据集选择
GenerateButton Button 生成座位
ExportButton Button 导出结果
SaveSnapshotButton Button 保存为快照
UndoButton Button 撤销操作

SnapshotHistoryView

x
控件 用途
VenueComboBox ComboBox 选择会场
SnapshotListBox ListBox 快照列表
RollbackButton Button 回滚快照

SettingsView

x
控件 用途
SaveSettingsButton Button 保存设置
KeyboardShortcutsSection StackPanel 键盘快捷键卡片标题行
UndoShortcutSwitch ToggleSwitch Ctrl+Z 快捷键开关

资源键命名约定

引导文本的命名模式:Guide_{Page}_{Action}_{Type}

键名 说明
Guide_Members_Import_Title 成员管理页面,导入操作,标题
Guide_Venue_Layout_Desc 会场配置页面,布局操作,描述
Guide_Strategy_Conflict_Title 策略配置页面,冲突提示,标题

注意:代码不做任何键名拼接或推断,BuildStepsFromDefs() 仅通过 ResourceManager.GetString(jsonKey) 进行纯粹查找。

文本风格指南

引导步骤的文本应遵循指令式风格——直接告诉用户要做什么:

推荐(指令式) 避免(描述式)
“点击此按钮从文件导入学生名单” “首先需要导入学生数据。点击「导入」按钮…”
“在此下拉框选择会场” “在此页面可以查看历史记录、预览座位表…”
“点击此开关启用策略” “使用开关启用/禁用策略”

每一步的 title 提示要执行的操作,description 提供简短的上文说明。

DI 注册

// Program.cs(桌面/浏览器双壳共用)
services.AddSingleton<IOnboardingService, OnboardingService>();
services.AddSingleton<IOnboardingStarter>(sp =>
    (IOnboardingStarter)sp.GetRequiredService<IOnboardingService>());

services.AddGuideSeedTargets();                 // 注册 5 个 IGuideSeedTarget(与页面 VM 同实例)

services.AddSingleton<MainWindow>();            // 依赖 IOnboardingService
services.AddSingleton<MainShellViewModel>();    // 依赖 IOnboardingService
services.AddSingleton<SettingsViewModel>();     // 依赖 IOnboardingService(通过 IOnboardingStarter 桥接)

IOnboardingStarter 作为 IOnboardingService 的受限接口,仅暴露 CheckAndStartOnboardingAsync(),供 App.axaml.cs 启动时调用。IOnboardingService 的完整接口暴露给 ViewModel。AddGuideSeedTargets()(Services/GuideSeedTargetRegistration.cs)把 MemberManagement / VenueConfiguration / StrategyConfiguration / SeatingArrangement / SnapshotHistory 五个页面 VM 注册为 IGuideSeedTarget,避免双壳漏注册。

测试验证

由于引导系统依赖 UI 交互,主要通过以下方式验证:

验证方式 说明
完整性校验 交叉验证 JSON 中的资源键、目标控件名、页面引用
构建验证 dotnet build 确保编译通过
测试回归 dotnet test 全量运行 4 个测试项目(448 用例,含 GuideSeedTargetTests 与 PageLifecycleTests 对 seed/clear 与 InitializationTask 契约的验证)
手动验证 删除 AppSettings.json → 启动 → 验证 24 步启动引导(M5 已实测 24/24);页面引导当前配置为空,新增 pageGuides 后进入对应页验证触发与去重;设置页「重新开始引导」验证完整重启;切换英文语言验证英文文本

相关文档

  • 主仓库 docs/ONBOARDING_GUIDE.md(完整的引导系统设计文档)
  • 主仓库 docs/adr/ADR-008(引导系统纯内存示例数据注入)
  • onboarding_config.json 源码