Repository Wiki
ChanIok/SpinningMomo

前端应用架构、路由与页面组织

本文档描述 SpinningMomo 桌面端 Web 前端(web/ 子包)的整体应用架构:Vue 3 应用实例的创建与装配顺序、路由(vue-router)与守卫的接入时机、引导流程(Onboarding)重定向逻辑,以及 src/ 下按职责划分的页面与组件组织方式。所有内容均基于仓库实际源码。

目的与范围

本页覆盖:

  • web/src/main.ts 中的应用启动序列(Pinia → RPC → i18n → 权限 → 路由 → Store → 挂载)
  • 路由模块 web/src/router/(路由表入口与守卫模块)的装配方式与守卫注册时机约束
  • Onboarding 引导重定向(flowVersion 版本门控)
  • web/src/ 的分层目录组织(core/、features/、components/、router/)
  • 根 monorepo(pnpm workspace)中与前端相关的构建脚本

有意留给兄弟页面:

  • 设置(Settings)体系与外观(theme/background)实现细节 → 见 features/settings 相关页面
  • 前端与 C++/后端之间的 RPC 通信协议 → 见 core/rpc 相关页面
  • 访问等级(access level)判定逻辑 → 见 core/access 相关页面
  • i18n 语言包与翻译机制 → 见 core/i18n 相关页面
  • 后台任务订阅 → 见 core/tasks 相关页面

概述

web/ 是一个独立的 pnpm workspace 子包(根 package.json 通过 pnpm --filter web 驱动其开发与构建),承载 SpinningMomo 的桌面端界面。应用以 web/index.html 为 HTML 入口,由 web/src/main.ts 完成如下职责:

  1. 创建 Vue 应用并安装 Pinia——状态管理必须最先就绪,因为后续的权限探测与路由守卫都会读取 store 状态;
  2. 初始化与宿主进程的 RPC 通信(initializeRPC);
  3. 依次初始化 i18n(默认 zh-CN)与调用者访问等级,因为页面内容会依权限呈现差异;
  4. 注册路由守卫与文档标题钩子,再安装 Router——顺序不可颠倒,app.use(router) 会立即触发首次导航;
  5. 初始化 settings/task store,执行 Onboarding 版本门控重定向;
  6. 在挂载前应用主题与背景,避免首屏闪烁(FOUC);
  7. 最后挂载到 #app。

关键概念:

概念说明源码依据
初始化顺序约束多个模块之间存在严格的先后依赖,源码内以中文注释显式声明main.ts L21、L35、L56 注释
路由守卫前置注册守卫必须在 app.use(router) 之前注册,否则首次导航不会被拦截main.ts L35-L39
Onboarding flowVersion通过 CURRENT_ONBOARDING_FLOW_VERSION 判断是否需要重新展示引导页main.ts L49-L54
@/ 路径别名导入形如 @/core/rpc,指向 web/src/ 下的模块main.ts L6-L12

架构

分层架构图

Loading diagram...

上图中的每条依赖边均对应 web/src/main.ts 中一次真实的导入或调用:编号 1–9 的顺序即源码中的执行顺序。分层意图非常清晰——core/ 承载与宿主进程及横切关注点(RPC、权限、i18n、任务)相关的基础设施,features/ 承载业务特性(当前采样中可见 settings),components/ 承载可复用组件(layout/ 负责应用外壳,ui/ 负责基础原语),router/ 独立成目录以便路由表与守卫解耦。

目录组织

基于仓库文件清单,web/ 的页面与组件组织如下:

路径角色
web/index.htmlHTML 入口,提供 #app 挂载点
web/src/main.ts应用启动编排(本文档核心)
web/src/App.vue根组件
web/src/router/index.ts(路由表与 router 实例)+ guards.ts(守卫与标题钩子)
web/src/core/rpc · core/access · core/i18n · core/tasks/store横切基础设施(由 main.ts 导入证实)
web/src/features/settings/设置特性:store、types(含 CURRENT_ONBOARDING_FLOW_VERSION)、appearance
web/src/components/layout/AppLayout.vue、AppHeader.vue、ContentArea.vue、GalleryDebugOverlay.vue、WindowResizeOverlay.vue,附 index.ts 桶文件
web/src/components/ui/基础 UI 原语,按组件分目录(如 accordion/Accordion.vue、AccordionContent.vue、AccordionItem.vue、AccordionTrigger.vue、index.ts)
web/src/components/AdbDeviceInput.vue共享业务输入组件(ADB 设备输入)
web/src/assets/SVG 资源(momo-outline.svg、zongzi-momo.svg)
web/public/静态资源(logo_192x192.png,为 192×192 规格的应用标识)
web/components.jsonUI 组件生成器(shadcn 风格)的配置文件,与 components/ui/ 的目录约定相呼应

components/ui/accordion/ 采用「每个原语一个 SFC + index.ts 桶文件」的组织方式,components/layout/ 同样带 index.ts 桶文件——这种约定让消费方可以从目录导入(@/components/ui/accordion),而不必关心内部文件拆分,是 shadcn-vue 生态的典型布局。

说明:路由表的具体路由清单(除下文验证的 welcome 外)定义于 router/index.ts,本次源码采样未展开其内容,故本文不罗列未经验证的路由条目。

核心流程:应用启动序列

启动序列的顺序并非随意排列,而是由模块间真实的数据依赖决定的。下面的时序图逐步还原 web/src/main.ts 的真实控制流:

Loading diagram...

启动代码(完整)

typescript
1import { createApp } from 'vue' 2import { watch } from 'vue' 3import { createPinia } from 'pinia' 4import router from './router' 5import { setupDocumentTitle, setupRouterGuards } from './router/guards' 6import { initializeRPC } from '@/core/rpc' 7import { initializeAccessLevel } from '@/core/access' 8import { initI18n } from '@/core/i18n' 9import { useSettingsStore } from '@/features/settings/store' 10import { CURRENT_ONBOARDING_FLOW_VERSION } from '@/features/settings/types' 11import { applyAppearanceToDocument } from '@/features/settings/appearance' 12import { useTaskStore } from '@/core/tasks/store' 13import './index.css' 14import App from './App.vue' 15 16// 创建 Pinia 实例 17const pinia = createPinia() 18 19const app = createApp(App) 20 21// Pinia 必须先安装,后续权限探测和路由守卫会读取其状态。 22app.use(pinia) 23 24// 初始化 RPC 通信 25initializeRPC() 26 27// 初始化应用 28;(async () => { 29 // 首先初始化 i18n(使用默认语言) 30 await initI18n('zh-CN') 31 32 // 先确定调用者访问等级,再初始化会根据权限显示不同内容的页面。 33 await initializeAccessLevel() 34 35 // 守卫必须在安装 Router 前注册;app.use(router) 会立即启动首次导航。 36 setupRouterGuards(router) 37 setupDocumentTitle(router) 38 app.use(router) 39 await router.isReady() 40 41 // 然后初始化 settings store,它会自动同步后端的语言设置 42 const settingsStore = useSettingsStore() 43 await settingsStore.init() 44 45 // 初始化后台任务订阅 46 const taskStore = useTaskStore() 47 await taskStore.initialize() 48 49 const onboarding = settingsStore.appSettings.app.onboarding 50 const needsOnboarding = 51 !onboarding.completed || onboarding.flowVersion < CURRENT_ONBOARDING_FLOW_VERSION 52 if (needsOnboarding && router.currentRoute.value.name !== 'welcome') { 53 await router.replace('/welcome') 54 } 55 56 // 在挂载前应用主题和背景,避免首屏闪烁 57 applyAppearanceToDocument(settingsStore.appSettings) 58 59 // 监听设置变化,实时同步外观 60 watch( 61 () => [ 62 settingsStore.appSettings.ui.webTheme.mode, 63 settingsStore.appSettings.ui.webTheme.customCss, 64 settingsStore.appSettings.ui.webTheme.menuBlur, 65 settingsStore.appSettings.ui.background.type, 66 settingsStore.appSettings.ui.background.imageFileName, 67 settingsStore.appSettings.ui.background.backgroundBlurAmount, 68 settingsStore.appSettings.ui.background.backgroundOpacity, 69 settingsStore.appSettings.ui.background.overlayColors.join('|'), 70 settingsStore.appSettings.ui.background.primaryColor, 71 settingsStore.appSettings.ui.background.overlayOpacity, 72 settingsStore.appSettings.ui.background.surfaceOpacity, 73 ], 74 () => { 75 applyAppearanceToDocument(settingsStore.appSettings) 76 }, 77 ) 78 79 // 最后挂载应用 80 app.mount('#app') 81})()

Source: main.ts

逐段解析:为什么是这个顺序

(1)Pinia 先于一切(L16-L22)。 源码注释直白地说明原因:「后续权限探测和路由守卫会读取其状态」。initializeAccessLevel() 与 setupRouterGuards() 都可能调用 useXxxStore(),而 Pinia store 的激活依赖于已安装的 pinia 实例;若顺序颠倒会在守卫阶段抛出 "getActivePinia was called with no active Pinia" 类错误。

(2)initializeRPC() 位于同步段(L24-L25)。 RPC 是 Web 前端与宿主进程之间的通信底座(详见兄弟页),它在任何异步初始化之前建立,使后续 initI18n / initializeAccessLevel / store 同步等操作可立即复用该通道。

((3)i18n 先于权限(L28-L33)。 initI18n('zh-CN') 以默认语言先行,保证即使后端语言设置尚未同步,界面也已具备可用文案;随后的 settingsStore.init()(L41-L43)「会自动同步后端的语言设置」再切换为用户偏好语言——这是「先可用、再精确」的渐进式初始化策略。

(4)守卫注册必须先于 app.use(router)(L35-L39)。 这是本模块最关键的时序约束。vue-router 在被安装时会立刻启动首次导航(首次路由解析发生在 install 期间),因此 setupRouterGuards(router) 与 setupDocumentTitle(router) 必须提前挂到 router 实例上,否则首次导航将绕过权限拦截。随后的 await router.isReady() 确保首次导航完成后再进入 store 初始化。

(5)Onboarding 版本门控(L49-L54)。 判定条件是「未完成」或「flowVersion 落后于当前代码所要求的 CURRENT_ONBOARDING_FLOW_VERSION」——后者意味着前端代码升级后引入了新的引导步骤,老用户也会被重新带回 /welcome。重定向使用 router.replace(非 push),避免引导页污染历史栈;同时检查 router.currentRoute.value.name !== 'welcome' 防止在已是 welcome 时重复替换。

(6)挂载前应用外观(L56-L57)。 applyAppearanceToDocument 在 app.mount('#app') 之前执行,注释明确动机是「避免首屏闪烁」:若先挂载再改主题,用户会先看到默认样式再跳变为自定义样式。

(7)外观响应式监听(L59-L77)。 通过 watch 监听 ui.webTheme 与 ui.background 下的具体字段(包括用 overlayColors.join('|') 把数组折叠为可比较字符串),任何变更都重新应用外观。注意该 watch 依赖数组中「字段级」的取值而非整个对象引用,从而实现细粒度触发。

用法示例

示例:守卫注册与 Router 安装的强制顺序

typescript
1 // 守卫必须在安装 Router 前注册;app.use(router) 会立即启动首次导航。 2 setupRouterGuards(router) 3 setupDocumentTitle(router) 4 app.use(router) 5 await router.isReady()

Source: main.ts

这段四行代码浓缩了本架构中最重要的时序契约。任何在 app.use(router) 之后才注册的守卫,都无法拦截第一次路由解析——这是扩展路由(例如新增需要权限的页面)时最容易踩中的陷阱。

示例:Onboarding 门控重定向

typescript
1 const onboarding = settingsStore.appSettings.app.onboarding 2 const needsOnboarding = 3 !onboarding.completed || onboarding.flowVersion < CURRENT_ONBOARDING_FLOW_VERSION 4 if (needsOnboarding && router.currentRoute.value.name !== 'welcome') { 5 await router.replace('/welcome') 6 }

Source: main.ts

两个布尔条件的组合实现了「首次安装」与「版本升级后需要补充引导」两种场景的统一处理;CURRENT_ONBOARDING_FLOW_VERSION 常量来自 features/settings/types。

示例:monorepo 中驱动前端的构建脚本

json
"build:web": "pnpm --filter web run build", "dev:web": "pnpm --filter web run dev", "format:web": "pnpm --filter web exec prettier --write .",

Source: package.json

根包使用 pnpm --filter web 把命令转发给 web/ 子包,前端拥有独立的 web/package.json(例如 build 与 dev 脚本定义在子包内,本页不重复展开)。同时根级 lint-staged 对 web/**/*.{js,ts,vue,json,css,md} 执行 node scripts/format-web.js,保证前端文件在提交前被格式化。

API 参考(启动编排相关)

以下签名均取自 web/src/main.ts 的实际导入,参数与行为以其所属模块的实现为准(详见各兄弟页面):

API来源模块说明
createPinia(): Piniapinia创建 Pinia 实例;必须先 app.use(pinia) 才能安全使用任何 store
initializeRPC(): void@/core/rpc建立与宿主进程的 RPC 通信底座(同步调用,位于异步 IIFE 之前)
initI18n(locale: string): Promise<void>@/core/i18n以指定 locale(启动时固定 'zh-CN')初始化国际化
initializeAccessLevel(): Promise<void>@/core/access探测调用者访问等级,供权限化页面与路由守卫消费
setupRouterGuards(router: Router): void./router/guards向 router 实例注册全局守卫;必须在 app.use(router) 之前调用
setupDocumentTitle(router: Router): void./router/guards注册文档标题钩子,随路由切换更新页面标题
useSettingsStore(): SettingsStore@/features/settings/store设置 store;其 init() 会自动同步后端语言设置
useTaskStore(): TaskStore@/core/tasks/store后台任务 store;initialize() 建立任务订阅
applyAppearanceToDocument(settings: AppSettings): void@/features/settings/appearance将主题/背景设置写入 document,挂载前调用可避免首屏闪烁

失败模式、边界情况与并发

  • 初始化顺序破坏(时序契约):若把 app.use(router) 提前到 setupRouterGuards 之前,首次导航不会经过权限守卫——这是隐式契约,源码通过注释(L35)显式警示而非类型系统保证。
  • Pinia 未安装即用 store:initializeAccessLevel() / setupRouterGuards() 内部读取 store 的前提是 app.use(pinia) 已执行(L21-L22 注释)。
  • Onboarding 死循环风险:重定向前检查 router.currentRoute.value.name !== 'welcome'(L52),防止在 welcome 页上反复 replace 自身造成导航冗余;此外若守卫层也强制跳转 welcome,则两层需保持一致,否则可能出现循环重定向。
  • 首屏闪烁(FOUC):applyAppearanceToDocument 必须先于 app.mount(L56-L57),否则用户先看到默认主题再切换。
  • 启动失败传播:整个异步初始化链包裹在一个 IIFE 中且逐层 await;任何一步抛出异常都会中断后续初始化并阻止挂载(app.mount 不会执行),表现为白屏——排查时应从控制台的首个未捕获异常入手。
  • 外观监听的细粒度依赖:watch 依赖数组逐字段取值(含 overlayColors.join('|') 折叠数组,L59-L77),避免替换整个 settings 对象引用导致的过度触发。

性能与运维要点

  • 关键路径串行化:i18n → access → router ready → settings init → tasks init 全部串行 await,启动时间为其总和。这是以启动耗时换取确定性(权限先于路由、语言先于渲染)的刻意取舍。
  • 挂载前的外观应用消除了主题切换引起的重排/重绘闪烁,对感知性能有直接收益。
  • 构建入口:pnpm dev:web / pnpm build:web(根 package.json L15、L22)经 --filter web 转发至子包;pnpm format:web 用 prettier 统一格式,lint-staged(L39-L41)在提交时对 web/** 生效。
  • 静态资源约定:web/public/logo_192x192.png 位于 public 目录,构建时原样拷贝,通常作为 PWA/图标资源;web/components.json 与 components/ui/ 的目录结构配套,是 UI 原语代码生成的配置。

扩展点

  • 新增页面:在 web/src/router/index.ts 的路由表中登记,并遵循守卫对访问等级的约束(守卫逻辑在 web/src/router/guards.ts);若页面需要登录/权限,务必确认守卫已覆盖新路由。
  • 新增守卫:继续在 setupRouterGuards 内追加,并保持「注册先于 app.use(router)」的调用点不变(当前在 main.ts L36)。
  • 新增引导步骤:提升 features/settings/types 中的 CURRENT_ONBOARDING_FLOW_VERSION,即可让存量用户重新进入 /welcome 完成新步骤——这是设计好的版本化引导机制。
  • 新增基础组件:按 components/ui/<component>/ 目录 + 各 SFC + index.ts 桶文件的既有约定添加(参照 accordion/ 的结构)。
  • 新增应用外壳区块:在 components/layout/(AppLayout / AppHeader / ContentArea / 调试浮层 / 尺寸浮层)内扩展,并通过其 index.ts 桶文件导出。

相关链接

兄弟页面提示:RPC 通信协议见 core/rpc 专题页;访问等级与权限见 core/access 专题页;设置体系与主题/背景实现见 features/settings 专题页;i18n 见 core/i18n 专题页;后台任务见 core/tasks 专题页。

Sources

(2 files)
web/src