-
Notifications
You must be signed in to change notification settings - Fork 0
Architecture
本文档介绍 StudyWithMiku 项目的整体架构设计、目录结构和核心设计模式。
StudyWithMiku/
├── src/ # 前端源码
│ ├── components/ # Vue 组件
│ │ ├── common/ # 通用组件
│ │ │ └── UserAvatar.vue # 多源头像组件
│ │ ├── settings/ # 设置相关组件
│ │ │ └── tabs/ # 设置面板选项卡
│ │ │ ├── stats/ # 统计相关组件
│ │ │ ├── hooks/ # 钩子系统设置组件
│ │ │ └── account/ # 账号相关组件
│ │ │ ├── AccountLoginPanel.vue
│ │ │ ├── AccountProfilePanel.vue
│ │ │ ├── AccountDeviceList.vue
│ │ │ ├── AccountSyncPanel.vue
│ │ │ ├── OAuthButton.vue
│ │ │ └── AccountDeleteConfirm.vue
│ │ ├── Toast.vue # 通知组件
│ │ ├── StatusPill.vue # 状态胶囊
│ │ └── ...
│ │
│ ├── composables/ # Vue Composables(组合式函数)
│ │ ├── focus/ # 番茄钟子模块
│ │ │ ├── constants.js # 常量定义
│ │ │ ├── useTimer.js # 计时器实现
│ │ │ ├── useSession.js # 状态机管理
│ │ │ ├── useRecords.js # 记录管理
│ │ │ ├── useStats.js # 统计计算
│ │ │ ├── useSyncEngine.js # 专注记录同步引擎
│ │ │ └── eventBus.js # 专注事件总线
│ │ │
│ │ ├── hooks/ # 钩子系统
│ │ │ ├── constants.js # 枚举和存储键
│ │ │ ├── hookEngine.js # 匹配与分发
│ │ │ ├── providerRegistry.js # Provider 注册表
│ │ │ ├── useHooks.js # 主 composable
│ │ │ ├── presets.js # 默认钩子和预设
│ │ │ └── providers/ # Provider 实现(4 个)
│ │ │
│ │ ├── useFocus.js # 番茄钟统一入口(外观模式)
│ │ ├── usePlayer.js # 播放器状态管理
│ │ ├── useMusic.js # 音乐源管理
│ │ ├── usePlaylistManager.js # 歌单管理
│ │ ├── useToast.js # 通知系统
│ │ ├── useAuth.js # 认证状态管理
│ │ ├── useDataSync.js # 云端数据同步
│ │ ├── useAppBootstrap.js # 应用启动引导
│ │ ├── usePWA.js # PWA 功能管理
│ │ └── ...
│ │
│ ├── player/ # 播放器抽象层
│ │ ├── PlayerAdapter.js # 抽象基类
│ │ ├── adapters/ # 具体适配器
│ │ │ ├── APlayerAdapter.js
│ │ │ └── SpotifyAdapter.js
│ │ ├── mediaSessionBridge.js # Media Session 桥接
│ │ └── constants.js # 播放器常量
│ │
│ ├── services/ # 服务层
│ │ ├── meting.js # Meting API 集成
│ │ ├── spotify.js # Spotify API
│ │ ├── localAudioStorage.js # 本地音频存储
│ │ ├── playlistImportExport.js # 导入导出
│ │ ├── onlineServer.js # 在线计数服务
│ │ ├── runtimeConfig.js # 运行时配置
│ │ ├── auth.js # 认证 API 服务
│ │ ├── dataSync.js # 数据同步 API 服务
│ │ └── migration.js # 数据迁移服务
│ │
│ ├── config/ # 配置
│ │ ├── constants.js # 全局常量
│ │ └── oauthProviders.js # OAuth 提供商配置
│ │
│ ├── dev/ # 开发者控制台
│ │ ├── index.js # swm_dev 入口
│ │ └── help/ # 帮助系统
│ │ ├── index.js
│ │ ├── introspection.js
│ │ └── formatter.js
│ │
│ ├── utils/ # 工具函数
│ │ ├── storage.js # localStorage 封装
│ │ ├── cache.js # 缓存工具
│ │ ├── exportUtils.js # 导出工具
│ │ ├── authStorage.js # Token/认证信息存储
│ │ ├── protobufClient.js # Protobuf 编解码客户端
│ │ ├── syncConflictResolver.js # 同步冲突解决
│ │ └── webauthnHelper.js # WebAuthn 浏览器 API 封装
│ │
│ ├── types/ # 类型定义(JSDoc)
│ │ ├── playlist.js
│ │ └── music.js
│ │
│ ├── styles/ # 全局样式
│ └── main.js # 应用入口
│
├── shared/ # 前后端共享代码
│ └── proto/ # Protobuf 编解码(前后端共享)
│ ├── studymiku.proto # Proto3 消息定义
│ ├── gen/studymiku_pb.js # 自动生成的 JS 绑定
│ └── index.js # 编解码 API、枚举映射
│
├── workers/ # Cloudflare Workers 后端
│ ├── index.js # Worker 入口(Hono 应用)
│ ├── constants.js # 后端常量
│ ├── auth-challenge.js # Durable Object: WebAuthn 挑战
│ ├── rate-limiter.js # Durable Object: 速率限制
│ ├── online-counter.js # Durable Object: 在线计数
│ ├── focus-notifier.js # Durable Object: 番茄钟推送通知
│ ├── db/ # 数据库
│ │ ├── index.js # Drizzle 客户端工厂
│ │ └── schema.js # 表定义(6 张表)
│ ├── middleware/ # 中间件(5 个)
│ │ ├── envDefaults.js
│ │ ├── securityHeaders.js
│ │ ├── cors.js
│ │ ├── auth.js
│ │ └── rateLimit.js
│ ├── routes/ # 路由
│ │ ├── auth/ # 认证路由组(7 个文件)
│ │ ├── oauth.js # OAuth 路由
│ │ ├── data.js # 数据同步路由
│ │ └── push.js # Web Push 推送路由
│ ├── services/ # 业务逻辑(9+1 个服务)
│ ├── schemas/ # Zod 验证模式
│ │ └── auth.js
│ └── utils/ # 工具函数
│ ├── authHelpers.js
│ ├── cookie.js
│ ├── avatar.js
│ └── protobufServer.js # Protobuf 编解码封装
│
├── migrations/ # D1 数据库迁移
│ ├── 0000_strong_lily_hollister.sql
│ └── meta/
│
├── tests/ # 测试
│ ├── unit/ # 单元测试
│ ├── integration/ # 集成测试
│ ├── e2e/ # E2E 测试
│ └── setup/ # 测试配置
│
├── public/ # 静态资源
└── scripts/ # 构建脚本
flowchart TB
subgraph VueComponents["Vue Components"]
Settings["Settings"]
StatusPill["StatusPill"]
Toast["Toast"]
Player["Player"]
AccountPanels["Account Panels"]
Others["..."]
end
subgraph ComposablesLayer["Composables Layer"]
useFocus["useFocus (Facade)"]
usePlayer["usePlayer (State Mgmt)"]
useMusic["useMusic (Cache Layer)"]
useAppBootstrap["useAppBootstrap"]
subgraph FocusSub["Focus 子模块"]
useSession["useSession"]
useTimer["useTimer"]
useRecords["useRecords"]
useStats["useStats"]
end
PlayerAdapter["PlayerAdapter (Adapter)"]
MetingService["Meting Service"]
subgraph AuthLayer["Auth Layer"]
useAuth["useAuth (Singleton)"]
authStorage["authStorage"]
end
subgraph SyncLayer["Sync Layer"]
useDataSync["useDataSync"]
useSyncEngine["useSyncEngine"]
protobufClient["protobufClient"]
end
subgraph HooksLayer["Hooks Layer"]
useHooks["useHooks (Singleton)"]
hookEngine["hookEngine"]
providerRegistry["providerRegistry"]
focusEventBus["focusEventBus"]
end
end
subgraph ServicesLayer["Services Layer"]
localStorage["localStorage (Settings)"]
IndexedDB["IndexedDB (Handles)"]
OPFS["OPFS (Audio)"]
CacheAPI["Cache API (SW Cache)"]
D1Cloud["D1 Cloud (Sync)"]
end
VueComponents --> ComposablesLayer
useFocus --> FocusSub
usePlayer --> PlayerAdapter
useMusic --> MetingService
AccountPanels --> AuthLayer
AccountPanels --> SyncLayer
useSyncEngine --> useDataSync
useDataSync --> protobufClient
useFocus --> focusEventBus
focusEventBus --> useHooks
useHooks --> hookEngine
hookEngine --> providerRegistry
ComposablesLayer --> ServicesLayer
AuthLayer -->|"HTTPS"| D1Cloud
SyncLayer -->|"Protobuf"| D1Cloud
flowchart LR
subgraph Browser["Browser"]
VueApp["Vue App"]
SwmDev["swm_dev"]
end
subgraph CFWorkers["Cloudflare Workers"]
subgraph MiddlewareStack["Middleware"]
envDefaults["envDefaults"]
securityHeaders["securityHeaders"]
cors["CORS"]
authMW["Auth (JWT)"]
rateLimitMW["Rate Limit"]
end
HonoRouter["Hono.js Router"]
subgraph DurableObjects["Durable Objects"]
OnlineCounter["OnlineCounter"]
AuthChallenge["AuthChallenge"]
RateLimiter["RateLimiter"]
FocusNotifier["FocusNotifier"]
end
subgraph D1DB["D1 Database"]
Users["users"]
Credentials["credentials"]
OAuthAccounts["oauth_accounts"]
UserData["user_data"]
TokenBlacklist["token_blacklist"]
PushSubscriptions["push_subscriptions"]
end
MiddlewareStack --> HonoRouter
HonoRouter --> DurableObjects
HonoRouter --> D1DB
end
subgraph ThirdParty["Third Party"]
MetingAPI["网易云/QQ音乐"]
OAuthProviders["OAuth Providers"]
end
Browser <-->|"WebSocket"| CFWorkers
Browser <-->|"REST API"| CFWorkers
Browser -->|"Meting API"| MetingAPI
CFWorkers <-->|"OAuth 2.0"| OAuthProviders
应用场景:播放器系统
问题:不同的音乐播放器(APlayer、Spotify Embed)有完全不同的 API 接口,如何让上层代码使用统一的方式控制它们?
解决方案:定义统一的 PlayerAdapter 抽象基类,所有具体播放器实现同一套接口。
// src/player/PlayerAdapter.js
export class PlayerAdapter {
// 统一接口定义
async play() { throw new Error('子类必须实现') }
async pause() { throw new Error('子类必须实现') }
async seek(time) { throw new Error('子类必须实现') }
async loadPlaylist(tracks) { throw new Error('子类必须实现') }
// 内置事件系统
on(event, callback) { /* ... */ }
emit(event, data) { /* ... */ }
// 能力查询
supportsSeek() { return true }
hasBuiltInUI() { return false }
}
// src/player/adapters/APlayerAdapter.js
export class APlayerAdapter extends PlayerAdapter {
async play() {
this.aplayer.play()
}
// ... 其他方法实现
}
// src/player/adapters/SpotifyAdapter.js
export class SpotifyAdapter extends PlayerAdapter {
async play() {
this.embedController.play()
}
// ... 其他方法实现
}优势:
- 上层代码无需关心底层播放器的差异
- 新增播放器只需实现
PlayerAdapter接口 - 便于测试和 mock
应用场景:番茄钟系统
问题:番茄钟系统由多个子模块组成(计时器、状态机、记录、统计),直接暴露所有模块会增加使用复杂度。
解决方案:useFocus.js 作为外观,整合所有子模块,提供统一的简洁 API。
// src/composables/useFocus.js
export const useFocus = () => {
// 内部整合多个子模块
const session = useSession() // 状态机管理
const records = useRecords() // 记录管理
const stats = useStats() // 统计计算
// 对外暴露统一 API
return {
// 状态
state: session.state,
mode: session.mode,
elapsed: session.elapsed,
remaining: session.remaining,
// 操作
start: session.start,
pause: session.pause,
resume: session.resume,
// 统计
todayStats: stats.todayStats,
weekStats: stats.weekStats,
// 记录
records: records.records,
queryRecords: records.queryRecords,
// 导入导出
exportData,
importData
}
}优势:
- 使用者只需调用
useFocus()即可获取所有功能 - 隐藏内部实现复杂性
- 便于后续重构内部模块
应用场景:番茄钟会话管理
问题:番茄钟有多种状态(空闲、运行、暂停)和模式(专注、短休息、长休息),状态转换规则复杂。
解决方案:使用状态机管理状态转换,确保状态变更的合法性。
stateDiagram-v2
[*] --> IDLE
IDLE --> RUNNING : start()
RUNNING --> PAUSED : pause()
RUNNING --> IDLE : cancel()
PAUSED --> RUNNING : resume()
PAUSED --> IDLE : cancel()
RUNNING --> IDLE : 完成时自动回到 IDLE 并切换模式
flowchart LR
FOCUS1["FOCUS"] -->|"完成"| SHORT_BREAK
SHORT_BREAK -->|"完成"| FOCUS2["FOCUS"]
FOCUS1 -->|"每4次专注后"| LONG_BREAK
LONG_BREAK -->|"完成"| FOCUS2
// src/composables/focus/useSession.js
// 状态定义
const FocusState = {
IDLE: 'idle',
RUNNING: 'running',
PAUSED: 'paused'
}
// 状态转换
const start = () => {
if (state.value !== FocusState.IDLE) {
return { success: false, error: 'Session already in progress' }
}
state.value = FocusState.RUNNING
timer.start(duration)
return { success: true }
}
const pause = () => {
if (state.value !== FocusState.RUNNING) {
return { success: false, error: 'Session not running' }
}
timer.pause()
state.value = FocusState.PAUSED
return { success: true }
}优势:
- 状态转换规则清晰明确
- 防止非法状态转换
- 便于添加新状态或修改转换规则
应用场景:模块级状态管理
问题:多个组件需要共享同一份状态(如番茄钟状态、播放器状态),如何确保状态一致性?
解决方案:利用 ES Module 的特性,在模块顶层定义 ref(),所有导入该模块的代码共享同一份状态。
// src/composables/focus/useSession.js
// 模块级单例状态(在模块顶层定义)
const state = ref(FocusState.IDLE)
const mode = ref(FocusMode.FOCUS)
const settings = ref({ ...DEFAULT_SETTINGS })
const sessionCount = ref(0)
let initialized = false
export const useSession = () => {
// 首次调用时初始化
if (!initialized) {
initializeSettings()
initialized = true
}
// 返回共享状态的引用
return {
state: readonly(state),
mode: readonly(mode),
settings: readonly(settings),
// ...
}
}为什么不用 Pinia/Vuex?
- 项目规模适中,模块级单例足够
- 减少依赖,降低复杂度
- Composable 方式更贴近 Vue 3 的设计理念
优势:
- 零依赖,无需额外状态管理库
- 代码简洁,易于理解
- 与 Vue 响应式系统无缝集成
应用场景:Media Session 桥接、播放器事件系统
问题:播放器状态变化时,需要同步更新 Media Session(系统媒体控制);系统媒体控制操作时,需要反向控制播放器。
解决方案:
-
播放器事件系统:
PlayerAdapter内置on/emit/off事件机制(实现见 Player-System) -
Media Session 桥接:使用 Vue
watch监听响应式状态变化
// src/player/mediaSessionBridge.js
export function setupMediaSession(player) {
const unwatchers = []
// 播放器状态 → Media Session
unwatchers.push(
watch(
() => player.currentTrack.value,
(track) => {
navigator.mediaSession.metadata = new MediaMetadata({
title: track.name,
artist: track.artist,
artwork: [{ src: track.cover }]
})
}
)
)
// Media Session 操作 → 播放器
navigator.mediaSession.setActionHandler('play', () => player.play())
navigator.mediaSession.setActionHandler('pause', () => player.pause())
navigator.mediaSession.setActionHandler('nexttrack', () => player.skipNext())
return () => {
unwatchers.forEach(unwatch => unwatch())
}
}双向绑定流程:
flowchart LR
usePlayer["usePlayer (Vue Refs)"]
MediaSession["Media Session (System API)"]
usePlayer -->|"watch()"| MediaSession
MediaSession -->|"setActionHandler()"| usePlayer
优势:
- 松耦合,播放器不依赖 Media Session
- 基于 Vue 响应式,自动追踪依赖
- 便于清理和资源释放
应用场景:OAuth 集成
问题:系统支持多个 OAuth 提供商,每个提供商有不同的 URL、Scope 和 UI 元数据,如何统一管理?
解决方案:将提供商配置集中到注册表对象中,通过 provider key 查找:
// src/config/oauthProviders.js — 前端 UI 元数据
const OAUTH_PROVIDERS = {
github: { label: 'GitHub', icon: 'mdi:github', hoverBg: '...' },
google: { label: 'Google', icon: 'flat-color-icons:google', ... },
// ...
}
export const getProviderMeta = (provider) =>
OAUTH_PROVIDERS[provider] || { label: provider, icon: 'mdi:account' }// workers/constants.js — 后端 OAuth 配置
const OAUTH_CONFIG = {
GITHUB: { AUTHORIZE_URL, TOKEN_URL, USER_URL, SCOPE },
GOOGLE: { ... },
// ...
}新增 OAuth 提供商只需在两个注册表中添加配置条目。
应用场景:后端数据验证
问题:不同 API 端点接受不同的数据格式,如何避免在每个路由中重复编写验证逻辑?
解决方案:使用 Zod 定义 Schema,通过数据类型映射表自动选择对应的 Schema:
// workers/schemas/auth.js
const dataTypeSchemas = {
focus_records: focusRecordsSchema,
focus_settings: focusSettingsSchema,
playlists: playlistsDataSchema,
user_settings: userSettingsSchema,
share_config: shareConfigSchema
}
// 路由中使用
const schema = dataTypeSchemas[dataType]
const result = schema.safeParse(data)新增数据类型只需在 Schema 映射表中添加条目,路由代码无需修改。
应用场景:专注系统与钩子系统的解耦
问题:番茄钟状态机的状态转换需要通知外部系统(通知、提示音、推送、电刺激),但不应直接依赖这些系统。
解决方案:focusEventBus 作为轻量级 pub/sub 中间层。
// src/composables/focus/eventBus.js
export const focusEventBus = {
on(event, callback) { /* 订阅 */ },
emit(event, payload) { /* 发布 */ },
clear(event) { /* 清理 */ }
}
// 发布方 (useSession)
focusEventBus.emit('transition', { action: 'complete', mode: 'focus' })
// 订阅方 (useHooks)
focusEventBus.on('transition', (payload) => {
handleFocusEvent(payload.action, payload)
})优势:
- 发布方和订阅方完全解耦,钩子系统可独立加载/卸载
应用场景:钩子系统的 Action Provider 管理
问题:钩子系统需支持多种通知方式(通知、提示音、推送、电刺激),每种方式有不同的实现和可用性条件。
解决方案:providerRegistry 作为 Map 单例,统一管理 Provider 的注册、查找和生命周期。
// Provider 接口
const provider = {
id: 'notification',
init() { /* 初始化 */ },
isAvailable() { return 'Notification' in window },
execute(hook, context) { /* 执行 */ },
destroy() { /* 清理 */ }
}
// 注册
providerRegistry.register(provider)
// 使用
const p = providerRegistry.get(hook.provider)
if (p?.isAvailable?.()) p.execute(hook, context)优势:
- 新增 Provider 只需实现接口并注册,核心调度逻辑无需修改。estim provider 按需延迟加载。