SeatFlow 基于 JSON 声明式配置的引导系统设计,包括启动引导、页面引导、演示数据注入和窗口状态同步
本页目录
全部文档
引导系统
概述
SeatFlow 的引导系统帮助新用户快速上手核心功能的操作。系统完全由 JSON 配置驱动,分为两种触发模式:启动引导(首次启动时触发完整工作流)和页面引导(首次访问某页面时触发)。
架构
Guide 控件 (CodeWF)
步骤列表 + 高亮定位 + 前进/后退 + 完成/关闭
│
│ StepOpening 事件 → 解析 Target
▼
OnboardingService
├── StartOnboarding() — 启动引导
├── TryShowPageGuide(page) — 页面引导
└── BuildStepsFromDefs() — JSON → GuideStepOption 转换
│
▼
onboarding_config.json(嵌入资源)
{ startupPhases: [...], pageGuides: {...} }
关键文件
| 文件 | 作用 |
|---|---|
Data/onboarding_config.json |
唯一的数据源——所有阶段、步骤、资源键引用 |
Services/IOnboardingService.cs |
接口定义 |
Services/OnboardingService.cs |
核心实现——加载 JSON、构建步骤、处理事件 |
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
├── 展开侧边栏,导航到 Home
├── 订阅 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": {
"FreeformManagement": OnboardingPhaseDefinition,
"PluginManagement": OnboardingPhaseDefinition
}
}
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 | 步数 | 目标控件 |
|---|---|---|---|---|
| 欢迎 | Home | — | 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 |
| 结束 | Home | — | 1 | (居中模态) |
工作流阶段转换
MemberManagement 分两次进入(中间隔 Home 过渡阶段):
- Phase 1: MemberManagement(
seedData: false)→ ImportButton 可见,无演示数据 - 代码内 Home 往返:Phase 2 过渡时自动执行
OnboardingNavigateTo(Home)→OnboardingNavigateTo(MemberManagement),强制页面离开-重入 - Phase 2: MemberManagement(
seedData: true)→ 注入演示数据后,UpdateFromFileButton 可见
页面引导一览
| 页面 | 步数 | 目标控件 |
|---|---|---|
| FreeformManagement | 3 | ImportCsvButton, AddPointButton, SaveLayoutButton |
| PluginManagement | 2 | PluginListBox, PluginEnabledSwitch |
演示数据注入(v3.2)
声明式控制
通过 OnboardingPhaseDefinition.SeedData(JSON bool,默认 false)声明式控制。HandleStepOpening 在检测到阶段过渡时仅当 phase.SeedData == true 才调用 SeedPageData()。
原运行状态标志 _memberManagementDataSeeded 已完全删除,改为纯 JSON 声明式控制。
注入详情
| 页面 | 注入内容 | 延迟策略 |
|---|---|---|
| MemberManagement | 6 名示例学生 → Students ObservableCollection + 演示数据集 → SavedDatasets |
同步 |
| VenueConfiguration | 执行 NewVenueCommand 创建演示会场 |
DispatcherPriority.Background |
| StrategyConfiguration | 选中 Strategies[0](首个策略) |
DispatcherPriority.Background |
| SeatingArrangement | 演示会场+数据集 + 4×3 座位预览 | DispatcherPriority.Background |
| SnapshotHistory | 1 个演示快照 → Snapshots |
同步 |
延迟注入的必要性
多个 ViewModel 在构造函数中 fire-and-forget 启动异步初始化(_ = LoadXxxAsync())。这些异步方法完成后会创建全新 ObservableCollection,覆盖同步注入的数据。DispatcherPriority.Background 是最低优先级的调度,保证在异步 continuation 之后执行。
注入原则
- 零磁盘痕迹:引导结束无任何残留数据文件
- 不依赖 Infrastructure 层:仅使用 Core 模型 + ViewModel public API
- 不修改 ViewModel:纯外部注入(例外:
MemberManagementViewModel暴露了SetSuppressDatasetLoad()和ResetDirtyState()两个 internal 方法)
完成与清理
完成流程
OnboardingService.CompleteOnboardingAsync()
├── 取消订阅 StepOpening 事件
├── 捕获 wasPageGuide(在 await 前保存,防竞态)
├── 立即设置 IsActive = false, _currentPageGuide = null
├── 若是页面引导 → 持久化到 AppSettings.CompletedPageGuides
├── 若是启动引导 → 调用 vm.CompleteOnboardingAsync() 恢复 UI
└── 关闭 Guide 控件,清空 StepsSource
ClearPageData 清理
CompleteOnboardingAsync() 中调用 ClearPageData():
| 页面 | 清理内容 |
|---|---|
| SeatingArrangement | 清除 SeatItems、VenueItems、DatasetItems、HasGenerated |
| SnapshotHistory | 清除 Snapshots、Venues |
| MemberManagement | 使用 _memberManagementDemoInjected 静态标志判断——仅在实际注入过演示数据时才清理 |
_memberManagementDemoInjected 静态标志防止误清用户已导入的真实数据。
关闭确认
用户点击 × 或按 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 设置(通过 IsOnboardingActive 守卫跳过 RunTransitionAsync 动画),目标控件在首次尝试时即可正确解析。
阶段点按页面呈现(v3.4)
引导卡片左下角的步骤指示点默认按每个子步骤渲染(~24 个点),视觉上过于密集。v3.4 改为按阶段(9 个点)呈现。
实现原理
查阅 Guide 控件(CodeWF.AvaloniaControls)源码后发现:
Guide.StepCount是DirectProperty且仅注册 getter、CLR setter 为private,外部无法修改RefreshStepCollection()每次StepsSource赋值时强制重置StepCount = _activeSteps.CountSyncIndicator()在每次步骤切换时执行Indicator.StepCount = StepCount和Indicator.ActiveIndex = CurrentIndexGoTo(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数组查找当前步骤所属阶段
添加/修改引导步骤
添加新步骤
只需编辑 2 个文件,不修改 C# 代码:
- 在
onboarding_config.json中添加步骤定义 - 在
Resources.resx和Resources.en-US.resx中添加对应文本 - 在
Resources.Designer.cs中添加强类型访问器属性
如果新步骤需要高亮某个控件,先在对应的 .axaml 文件中给控件添加 x:Name。
示例——在 MemberManagement 阶段添加一步"保存数据集":
// 在 Members 阶段的 steps 数组末尾添加
{ "titleKey": "Guide_Members_Save_Title",
"descKey": "Guide_Members_Save_Desc",
"target": "SaveButton",
"placement": "Bottom" }
然后在 resx 中添加对应的 <data> 条目。
添加新页面的页面引导
在 pageGuides 字典中添加新条目:
"SnapshotHistory": {
"page": "SnapshotHistory",
"steps": [
{ "titleKey": "Guide_Snapshot_XXX_Title", "descKey": "Guide_Snapshot_XXX_Desc", "target": "..." }
]
}
键名必须与 PageKey 枚举值完全匹配(区分大小写)。
修改现有步骤
只需编辑 JSON 中的字段,或修改 resx 中的文本内容。无需改 C# 代码。
删除步骤
从 JSON 中移除步骤定义即可。旧的 resx 键可以保留(无害)。
目标控件清单
MainWindow 中(可跨页面访问)
| x:Name | 控件 | 用途 |
|---|---|---|
ToggleSidebarButton |
Button | 侧边栏折叠/展开 |
MemberManagementView
| x:Name | 控件 | 用途 |
|---|---|---|
ExportTemplateButton |
Button | 导出导入模板 |
ImportButton |
Button | 导入学生数据 |
UpdateFromFileButton |
Button | 从文件更新学生数据 |
StudentListBox |
ListBox | 学生列表 |
NewStudentRow |
Border | 新增学生行 |
SaveButton |
Button | 保存数据集 |
VenueConfigurationView
| x:Name | 控件 | 用途 |
|---|---|---|
NewVenueButton |
Button | 新建会场 |
LayoutTypePanel |
StackPanel | 布局类型选择 |
SaveVenueButton |
Button | 保存会场 |
StrategyConfigurationView
| x:Name | 控件 | 用途 |
|---|---|---|
StrategyListBox |
ListBox | 策略列表 |
EditEnabledSwitch |
ToggleSwitch | 启用/禁用策略 |
SaveAllButton |
Button | 保存全部策略 |
SeatingArrangementView
| x:Name | 控件 | 用途 |
|---|---|---|
VenueListBox |
ListBox | 会场选择 |
DatasetListBox |
ListBox | 数据集选择 |
GenerateButton |
Button | 生成座位 |
ExportButton |
Button | 导出结果 |
SaveSnapshotButton |
Button | 保存为快照 |
UndoButton |
Button | 撤销操作 |
SnapshotHistoryView
| x:Name | 控件 | 用途 |
|---|---|---|
VenueComboBox |
ComboBox | 选择会场 |
SnapshotListBox |
ListBox | 快照列表 |
RollbackButton |
Button | 回滚快照 |
FreeformManagementView
| x:Name | 控件 | 用途 |
|---|---|---|
ImportCsvButton |
Button | 导入自由布局 |
AddPointButton |
Button | 添加坐标点 |
SaveLayoutButton |
Button | 保存自由布局 |
SettingsView
| x:Name | 控件 | 用途 |
|---|---|---|
SaveSettingsButton |
Button | 保存设置 |
KeyboardShortcutsSection |
StackPanel | 键盘快捷键卡片标题行 |
UndoShortcutSwitch |
ToggleSwitch | Ctrl+Z 快捷键开关 |
PluginManagementView
| x:Name | 控件 | 用途 |
|---|---|---|
PluginListBox |
ListBox | 插件列表 |
PluginEnabledSwitch |
ToggleSwitch | 启用/禁用插件 |
资源键命名约定
引导文本的命名模式: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.AddSingleton<MainWindow>(); // 依赖 IOnboardingService
services.AddSingleton<MainShellViewModel>(); // 依赖 IOnboardingService
services.AddSingleton<SettingsViewModel>(); // 依赖 IOnboardingService(通过 IOnboardingStarter 桥接)
IOnboardingStarter 作为 IOnboardingService 的受限接口,仅暴露 CheckAndStartOnboardingAsync(),供 App.axaml.cs 启动时调用。IOnboardingService 的完整接口暴露给 ViewModel。
测试验证
由于引导系统依赖 UI 交互,主要通过以下方式验证:
| 验证方式 | 说明 |
|---|---|
| 完整性校验 | 交叉验证 JSON 中的资源键、目标控件名、页面引用 |
| 构建验证 | dotnet build 确保编译通过 |
| 测试回归 | dotnet test 确保现有测试无回归 |
| 手动验证 | 删除 AppSettings.json → 启动 → 验证启动引导;进入页面 → 验证页面引导;切换英文语言 → 验证英文文本 |