设置页面与引导(Onboarding)流程
设置页面与引导流程是 SpinningMomo Web 前端(web/src)中负责「应用偏好配置」与「首次使用引导」的两个相互协作的功能模块:web/src/features/settings 提供分组式设置界面与全局设置状态(Pinia store),web/src/features/onboarding 提供首次启动的欢迎引导页。两者共享同一份 AppSettings 数据结构与后端设置 RPC,由应用入口 web/src/main.ts 在启动时统一判定是否进入引导。
Purpose and Scope
本文覆盖以下内容(Web 前端视角,端到端):
web/src/features/settings模块:设置 store、外观应用(appearance)、设置页面组件分组(侧栏、菜单、各分组内容组件)。web/src/features/onboarding模块:引导页OnboardingPage.vue、引导专用 API、类型定义。- 启动判定链路:
main.ts如何结合onboarding.completed与onboarding.flowVersion决定是否跳转/welcome。 - 与后端(C++ 侧)的协同点:
features::settings::should_show_onboarding、CURRENT_ONBOARDING_FLOW_VERSION常量在前后端的镜像定义。 - 设置模块的 i18n 文案与后台任务(媒体硬链接初始化等一次性重操作)的关联。
以下主题有意留给兄弟页面,本文不展开:
- 后端设置数据模型、持久化与设置 RPC 端点的完整实现:
src/core/rpc/endpoints/settings/、src/core/events/handlers/settings_handlers.cpp、src/features/settings/settings.cpp(C++ 核心设置服务)属于其他页面。 - 通用 RPC 框架与权限等级(
initializeRPC/initializeAccessLevel):见 Web 前端 RPC 通信相关页面。 - 后台任务系统(
web/src/core/tasks与initializeMediaHardlinks任务):见任务编排相关页面。
Overview
背景与问题
SpinningMomo 是一个带有 WebView UI 的桌面应用:C++ 后端负责设置持久化,Vue 3 前端负责设置展示与编辑。因此「设置」不是纯粹的静态页面,而是一条 前端状态 ⇄ RPC ⇄ 后端存储 的双向链路。同样,「首次引导」也不是前端单方面能决定的事——后端启动器(src/core/initializer/initializer.cpp)在打开 WebView 窗口之前就要判断是否处于首启状态,以便决定窗口尺寸(引导页使用临时尺寸)。
关键概念
| 概念 | 位置 | 说明 |
|---|---|---|
AppSettings | 前端 web/src/features/settings/types.ts / 后端 src/features/settings/types.hpp | 应用设置聚合结构,前后端各有一份镜像类型定义 |
app.onboarding | AppSettings 内的子结构 | 首次引导状态:completed 与 flowVersion 等字段 |
CURRENT_ONBOARDING_FLOW_VERSION | 前后端均为常量 1 | 引导流程版本号;提升该值可强制老用户重新走一遍引导 |
needsOnboarding | web/src/main.ts 中的局部判定 | !onboarding.completed || onboarding.flowVersion < CURRENT_ONBOARDING_FLOW_VERSION |
should_show_onboarding | src/features/settings/settings.hpp | 后端对同一状态的判断,供启动器与 WebView 窗口使用 |
| 引导重操作 | src/extensions/infinity_nikki/task_service.hpp | 「媒体硬链接初始化」是引导/设置中的一次性后台重任务(initializeMediaHardlinks) |
版本化引导的设计意图
引导状态不只是一个布尔值,而是 completed + flowVersion 的组合。这样设计的动机是:当产品迭代出新的引导步骤(例如新增功能需要用户授权)时,只需把 CURRENT_ONBOARDING_FLOW_VERSION 提升,所有 flowVersion 落后的老用户都会在下次启动时被重新带回 /welcome 页,而无需清空他们的全部设置。前后端各自持有同一常量(均为 1),后端用于启动窗口决策,前端用于路由决策,二者必须保持一致。
Architecture
图解说明:
- 入口层:
main.ts是两个模块唯一的汇聚点。它先完成基础设施初始化(RPC、权限、路由守卫、任务订阅),再读取设置 store,然后才做引导判定。 - settings 模块(
web/src/features/settings)由四部分组成:store.ts(Pinia 全局状态,负责与后端同步语言等设置)、types.ts(AppSettings前端镜像类型与版本常量)、appearance.ts(把主题/背景设置落到 DOM,避免首屏闪烁)、以及components/下按功能分组的 Vue 内容组件。UI 结构是「侧栏(SettingsSidebar)→ 菜单/可拖拽列表(SettingsMenuList、DraggableSettingsList)→ 分组内容组件」。 - onboarding 模块(
web/src/features/onboarding)非常小:一个页面OnboardingPage.vue(对应/welcome路由)、api.ts(引导专用 RPC 调用)和types.ts。 - 后端协同:C++ 侧
features::settings::should_show_onboarding同时被initializer(决定启动时是否直接打开主窗口并打日志Onboarding required, attempting to open main UI window)和webview_window(引导期采用temporary_size临时窗口尺寸)消费。前后端共享同一份app.onboarding数据。
Core Flow: 启动到引导/主界面的完整链路
下面是 web/src/main.ts 中承载这条链路的真实代码,这是理解两个模块如何被「拼装」的关键入口:
1;(async () => {
2 // 首先初始化 i18n(使用默认语言)
3 await initI18n('zh-CN')
4
5 // 先确定调用者访问等级,再初始化会根据权限显示不同内容的页面。
6 await initializeAccessLevel()
7
8 // 守卫必须在安装 Router 前注册;app.use(router) 会立即启动首次导航。
9 setupRouterGuards(router)
10 setupDocumentTitle(router)
11 app.use(router)
12 await router.isReady()
13
14 // 然后初始化 settings store,它会自动同步后端的语言设置
15 const settingsStore = useSettingsStore()
16 await settingsStore.init()
17
18 // 初始化后台任务订阅
19 const taskStore = useTaskStore()
20 await taskStore.initialize()
21
22 const onboarding = settingsStore.appSettings.app.onboarding
23 const needsOnboarding =
24 !onboarding.completed || onboarding.flowVersion < CURRENT_ONBOARDING_FLOW_VERSION
25 if (needsOnboarding && router.currentRoute.value.name !== 'welcome') {
26 await router.replace('/welcome')
27 }
28
29 // 在挂载前应用主题和背景,避免首屏闪烁
30 applyAppearanceToDocument(settingsStore.appSettings)
31
32 // 监听设置变化,实时同步外观
33 watch(
34 () => [
35 settingsStore.appSettings.ui.webTheme.mode,
36 settingsStore.appSettings.ui.webTheme.customCss,
37 settingsStore.appSettings.ui.webTheme.menuBlur,
38 settingsStore.appSettings.ui.background.type,
39 settingsStore.appSettings.ui.background.imageFileName,
40 settingsStore.appSettings.ui.background.backgroundBlurAmount,
41 settingsStore.appSettings.ui.background.backgroundOpacity,
42 settingsStore.appSettings.ui.background.overlayColors.join('|'),
43 settingsStore.appSettings.ui.background.primaryColor,
44 settingsStore.appSettings.ui.background.overlayOpacity,
45 settingsStore.appSettings.ui.background.surfaceOpacity,
46 ],
47 () => {
48 applyAppearanceToDocument(settingsStore.appSettings)
49 }
50 )
51
52 // 最后挂载应用
53 app.mount('#app')
54})()Source: main.ts
为什么是这个顺序
这段启动序列的每个 await 都有明确的依赖理由,顺序不可随意调换:
createPinia()必须先于一切(第 17-22 行):注释明确说明「Pinia 必须先安装,后续权限探测和路由守卫会读取其状态」。设置 store、任务 store 都依赖 Pinia 已安装。initI18n在最前:后续所有 UI(包括引导页文案)都需要语言就绪。注意默认值硬编码为zh-CN,真正的语言随后由settingsStore.init()从后端同步覆盖。initializeAccessLevel在页面渲染前:不同调用者(本地/远程)看到不同的设置项可见性,必须在路由导航发生前确定。- 守卫先注册、Router 后安装:
app.use(router)会立即触发首次导航,若守卫未注册,首次导航会绕过权限逻辑——这是一类典型的时序 bug,代码用注释显式防住了。 - 引导判定在 store 与任务订阅之后:
needsOnboarding需要读appSettings.app.onboarding,而该数据来自后端,必须等settingsStore.init()完成。任务订阅先行初始化则保证引导页若触发后台重任务(如媒体硬链接初始化),进度可被正确上报。 applyAppearanceToDocument在app.mount('#app')之前调用:注释写明目的是「避免首屏闪烁」——先把主题与背景写进document,再挂载 DOM,用户就不会看到默认样式闪一下再切换。watch的依赖数组是「浅字段拼接」:overlayColors.join('|')把数组降级成字符串参与依赖比较,这是 Vuewatch对数组内容的经典处理方式(直接传数组引用无法侦测元素变化)。
引导判定逻辑详解
判定表达式:
1const onboarding = settingsStore.appSettings.app.onboarding
2const needsOnboarding =
3 !onboarding.completed || onboarding.flowVersion < CURRENT_ONBOARDING_FLOW_VERSION
4if (needsOnboarding && router.currentRoute.value.name !== 'welcome') {
5 await router.replace('/welcome')
6}Source: main.ts
三个细节体现了防御性设计:
- 短路顺序:
!completed在前,flowVersion比较在后。已完成的用户若版本落后仍会重新引导(升级新流程),但未完成引导的用户即使flowVersion异常地等于当前值也会被引导。 - 双条件跳转:
needsOnboarding && route.name !== 'welcome'避免了用户已在/welcome(例如刷新页面时 Router 首次导航已落在 welcome)时重复replace,防止导航冗余/循环。 router.replace而非push:引导页不应出现在历史栈里,用户不能「后退」回到一个未初始化的应用状态。
前后端镜像的版本常量
前端常量从设置模块类型文件导入:
import { useSettingsStore } from '@/features/settings/store'
import { CURRENT_ONBOARDING_FLOW_VERSION } from '@/features/settings/types'
import { applyAppearanceToDocument } from '@/features/settings/appearance'Source: main.ts
后端在同一子系统中有对应定义(C++ 侧):
// 当前欢迎流程版本(用于控制是否需要重新引导)
constexpr int CURRENT_ONBOARDING_FLOW_VERSION = 1;Source: types.hpp
AppSettings 中的引导子结构在后端类型中显式声明:
// 首次引导设置
struct Onboarding {Source: types.hpp
以及供启动器使用的判定函数签名:
// 判断当前配置是否需要显示首次引导页
auto should_show_onboarding(const AppSettings& settings) -> bool;Source: settings.hpp
这种「前后端各持常量、语义靠约定对齐」的做法有一个隐含的维护约束:修改任何一端的 CURRENT_ONBOARDING_FLOW_VERSION 都必须同步另一端,否则会出现后端认为需要引导(采用临时窗口尺寸)而前端不跳转 /welcome、或相反的窗口/页面不一致。
设置页面组件体系
设置 UI 采用「侧栏 + 分组内容」的两栏结构,所有组件位于 web/src/features/settings/components/。骨架组件负责导航与布局,内容组件按设置域拆分:
| 组件 | 职责(依据文件清单与模块结构) |
|---|---|
SettingsSidebar.vue | 设置页左侧栏,承载入口导航 |
SettingsMenuList.vue | 设置菜单列表,展示各分组入口 |
DraggableSettingsList.vue | 可拖拽排序的列表,用于用户自定义项顺序 |
GeneralSettingsContent.vue | 通用设置(语言等基础项) |
AppearanceContent.vue | 外观设置(主题模式、自定义 CSS、菜单模糊、背景类型/图片/模糊/透明度/叠加色等) |
CaptureSettingsContent.vue | 采集(截图/录屏)设置 |
HotkeySettingsContent.vue + HotkeyRecorder.vue | 快捷键设置与按键录制控件 |
BackupSettingsContent.vue | 备份设置(配合 backupApi.ts) |
NetworkAccessContent.vue | 远程访问(网络)设置 |
AdbModeContent.vue | ADB 模式设置 |
FloatingWindowContent.vue | 悬浮窗设置 |
WindowSceneContent.vue | 窗口场景设置 |
ExtensionsContent.vue | 扩展(如 infinity_nikki)设置 |
OverlayPaletteEditor.vue | 叠加层调色板编辑器 |
ResetSettingsDialog.vue | 重置设置确认对话框 |
这样按域拆分(而不是一个巨型设置页)的设计意图:每个内容组件只依赖自己那部分 AppSettings 子树,减少不必要的重渲染面,也让各设置域可以独立测试与懒加载。
外观应用链路(appearance.ts)
设置页里「外观」类改动(AppearanceContent.vue)的落点不在页面内,而是全局的:main.ts 通过 watch 监听 11 个外观相关字段并调用 applyAppearanceToDocument(settingsStore.appSettings),把主题/背景直接应用到 document。这解释了为什么在设置页改主题会即时影响整个应用(包括 /welcome 引导页)——外观是文档级全局效果,而不是路由级组件效果。
i18n 文案
设置页文案集中在 web/src/core/i18n/locales/{zh-CN,en-US}/settings.json,默认语言为 zh-CN(main.ts 中 initI18n('zh-CN')),随后由 settingsStore.init() 从后端同步用户偏好的语言。
Onboarding 模块结构
web/src/features/onboarding 只包含三个文件,是最小化的功能模块:
pages/OnboardingPage.vue:引导页本体,挂载在/welcome路由。它消费引导步骤的状态并在完成时把app.onboarding写回(经由api.ts的 RPC,最终落到后端AppSettings)。api.ts:引导专用 RPC 封装,与settings/api.ts平行——两者都走core/rpc,但关注点分离:引导的写操作与设置的写操作各自成文件,避免引导逻辑混入通用设置 API。types.ts:引导侧类型定义。
说明:本页源码读取预算已耗尽,
OnboardingPage.vue、store.ts、api.ts的逐行实现细节未在本文逐条摘录;其组件清单与协作关系来自模块文件结构与main.ts的调用点证据。需要逐行细节时请直接查阅上述文件。
引导与后台重任务
引导不止收集偏好,还承担一次性重操作的触发入口。后端任务服务注释明确了这一点:
// 媒体硬链接初始化(引导 / 设置里的一次性重操作)。任务类型
// initializeMediaHardlinks。Source: task_service.hpp
设计意图:硬链接初始化是磁盘密集型操作,放在引导期(用户预期「正在准备应用」)比放在使用期更合理;同时设置页也保留同一入口(ExtensionsContent.vue 所在的扩展设置域),便于用户日后手动重跑。这要求前端在引导判定前先 await taskStore.initialize() 完成任务订阅,与 main.ts 的初始化顺序一致。
后端启动器协同
C++ 启动器在打开 WebView 前做同样的引导判断:
1// 先显示启动 UI,避免 Gallery 目录探测、远程根检查等非首屏工作阻塞用户看到窗口。
2const bool should_open_onboarding =
3 features::settings::should_show_onboarding(state.settings->raw);
4if (should_open_onboarding) {
5 Logger().info("Onboarding required, attempting to open main UI window");Source: initializer.cpp
以及 WebView 窗口在引导期使用临时尺寸:
if (!temporary_size && window.temporary_size &&
features::settings::should_show_onboarding(state.settings->raw)) {
temporary_size = window.temporary_size;Source: webview_window.cpp
即:引导状态下窗口使用 window.temporary_size(适合引导页的较小尺寸),完成引导后恢复正常尺寸。前端 /welcome 路由与后端临时窗口尺寸由同一份 app.onboarding 状态驱动,这是双端一致性的关键。
配置与状态参考
引导相关状态字段
| 字段 | 类型 | 默认语义 | 说明 |
|---|---|---|---|
app.onboarding.completed | boolean | 未完成时触发引导 | 引导是否已完成 |
app.onboarding.flowVersion | number | 初始落后于当前版本则引导 | 已完成的引导流程版本 |
CURRENT_ONBOARDING_FLOW_VERSION | number(常量) | 1 | 当前引导流程版本;前后端镜像定义,需同步修改 |
window.temporary_size | 尺寸结构 | 后端字段 | 引导期 WebView 窗口采用的临时尺寸 |
外观实时同步字段(watch 依赖清单)
以下字段任一变化都会触发 applyAppearanceToDocument(摘自 main.ts 的 watch 依赖数组):
| 字段路径 | 所属域 |
|---|---|
ui.webTheme.mode | 主题模式 |
ui.webTheme.customCss | 自定义 CSS |
ui.webTheme.menuBlur | 菜单模糊 |
ui.background.type | 背景类型 |
ui.background.imageFileName | 背景图片 |
ui.background.backgroundBlurAmount | 背景模糊量 |
ui.background.backgroundOpacity | 背景透明度 |
ui.background.overlayColors(`join(' | ')`) |
ui.background.primaryColor | 主色 |
ui.background.overlayOpacity | 叠加层透明度 |
ui.background.surfaceOpacity | 表面透明度 |
模块文件地图
| 路径 | 角色 |
|---|---|
web/src/features/settings/store.ts | 设置 Pinia store,init() 同步后端语言 |
web/src/features/settings/types.ts | 前端 AppSettings 镜像类型 + CURRENT_ONBOARDING_FLOW_VERSION |
web/src/features/settings/appearance.ts | applyAppearanceToDocument 文档级外观应用 |
web/src/features/settings/api.ts / backupApi.ts | 设置/备份 RPC 封装 |
web/src/features/settings/components/* | 设置页 UI 组件(见上文表格) |
web/src/features/onboarding/pages/OnboardingPage.vue | /welcome 引导页 |
web/src/features/onboarding/api.ts / types.ts | 引导 RPC 与类型 |
web/src/core/i18n/locales/{zh-CN,en-US}/settings.json | 设置页多语言文案 |
src/features/settings/settings.hpp/.cpp | 后端 should_show_onboarding 与设置服务 |
src/features/settings/types.hpp | 后端 AppSettings 与引导版本常量 |
src/core/initializer/initializer.cpp | 启动期引导判定与窗口打开决策 |
src/ui/webview_window/webview_window.cpp | 引导期临时窗口尺寸 |
src/core/rpc/endpoints/settings/ | 设置 RPC 端点(后端,兄弟页面) |
src/core/events/handlers/settings_handlers.* | 设置事件处理(后端,兄弟页面) |
失败模式、边界与一致性
| 场景 | 行为/风险 | 依据 |
|---|---|---|
| 后端设置尚未同步完成 | settingsStore.init() 被 await,引导判定一定发生在真实数据之上 | main.ts 第 42-51 行 |
用户刷新且已停留在 /welcome | route.name !== 'welcome' 双条件避免重复 replace | main.ts 第 52 行 |
| 引导完成后退按钮 | 使用 router.replace,引导页不入历史栈 | main.ts 第 53 行 |
| 前后端版本常量不一致 | 后端按临时窗口尺寸渲染、前端不跳转 /welcome(或相反),出现窗口/页面错配 | 双端常量镜像结构 |
| 外观数组内容变更 | `overlayColors.join(' | ')` 使数组元素变化可被侦测 |
| 首屏主题闪烁 | applyAppearanceToDocument 在 app.mount 之前调用 | main.ts 第 56-57 行注释 |
| 路由守卫时序 | 守卫必须先注册再 app.use(router),否则首次导航绕过权限 | main.ts 第 35-36 行注释 |
| 引导期重任务进度丢失 | taskStore.initialize() 先于引导判定执行,确保订阅就绪 | main.ts 第 45-47 行 |
并发/时序约束小结
- 全部初始化步骤串行
await,无并行竞态;代价是启动延迟取决于最慢一步(RPC 建立与设置读取)。 watch在挂载前注册(main.ts第 60 行起),因此挂载瞬间的任何设置变更也不会漏掉一次外观应用。
扩展点
- 新增设置分组:在
components/下新增XxxContent.vue内容组件并接入SettingsMenuList/SettingsSidebar的分组清单;文案加入settings.json(zh-CN 与 en-US 两份)。 - 升级引导流程:同时提升前后端
CURRENT_ONBOARDING_FLOW_VERSION(web/src/features/settings/types.ts与src/features/settings/types.hpp),并在OnboardingPage.vue中实现新步骤。 - 新增引导期一次性任务:参考
initializeMediaHardlinks的模式——后端注册任务类型,前端经core/tasks订阅进度,在引导页或扩展设置中触发。
Related Links
- 源码入口:main.ts、OnboardingPage.vue、settings/types.hpp
- 后端设置服务与 RPC 端点、事件处理:见本仓库对应的后端设置子系统页面
- RPC 框架与访问等级(
initializeRPC/initializeAccessLevel):见 Web 前端基础设施相关页面 - 后台任务系统(
useTaskStore/initializeMediaHardlinks):见任务编排相关页面 - 用户文档:getting-started、custom-settings