-
Notifications
You must be signed in to change notification settings - Fork 0
Data Flow And State
本文档详细介绍 StudyWithMiku 项目的数据流架构、状态管理策略和持久化机制。
flowchart TB
subgraph UserInteraction["用户交互层"]
UI["点击按钮、输入设置、拖拽进度条、媒体控制键..."]
end
subgraph VueComponents["Vue 组件层"]
Settings["Settings Panels"]
StatusPill["StatusPill Display"]
PlayerUI["PlayerUI Controls"]
FocusTimer["FocusTimer Display"]
AccountPanels["Account Panels"]
end
subgraph Composables["Composables 层"]
useFocus["useFocus (Facade)"]
usePlayer["usePlayer (Adapter)"]
useMusic["useMusic (Cache)"]
subgraph FocusSubModules["Focus 子模块"]
useSession["useSession"]
useTimer["useTimer"]
useRecords["useRecords"]
useStats["useStats"]
end
subgraph HooksSubModules["Hooks 子模块"]
useHooks["useHooks"]
hookEngine["hookEngine"]
providerRegistry["providerRegistry"]
end
focusEventBus["focusEventBus"]
subgraph AuthSync["Auth / Sync"]
useAuth["useAuth"]
useDataSync["useDataSync"]
useSyncEngine["useSyncEngine"]
end
usePlaylistMgr["usePlaylistMgr (CRUD)"]
PlayerAdapter["PlayerAdapter (Abstract)"]
MetingService["Meting Service"]
end
subgraph Services["Services 层"]
localAudioStorage["localAudioStorage (OPFS/FileHandle)"]
playlistImportExport["playlistImportExport"]
onlineServer["onlineServer (WebSocket)"]
authService["auth.js (API)"]
dataSyncService["dataSync.js (API)"]
end
subgraph Storage["存储层"]
localStorage["localStorage (Settings)"]
IndexedDB["IndexedDB (Handles)"]
OPFS["OPFS (Audio)"]
CacheAPI["Cache API (SW Cache)"]
D1Cloud["D1 Cloud (用户数据)"]
end
subgraph ExternalAPI["外部 API / 服务"]
MetingAPI["Meting API (网易云/QQ音乐)"]
SpotifyEmbed["Spotify Embed Controller"]
CFWorkers["CF Workers (后端)"]
OAuthProviders["OAuth Providers"]
end
UserInteraction --> VueComponents
Settings -->|"v-model @update"| useFocus
StatusPill -->|":prop computed"| useFocus
PlayerUI -->|"@events"| usePlayer
FocusTimer --> useFocus
AccountPanels --> AuthSync
useFocus --> FocusSubModules
useFocus --> focusEventBus
focusEventBus --> useHooks
useHooks --> hookEngine
hookEngine --> providerRegistry
usePlayer --> PlayerAdapter
useMusic --> MetingService
useAuth --> authService
useDataSync --> dataSyncService
useSyncEngine --> useDataSync
usePlaylistMgr --> localAudioStorage
localAudioStorage --> Storage
playlistImportExport --> Storage
onlineServer --> CFWorkers
authService --> CFWorkers
dataSyncService --> CFWorkers
MetingService --> MetingAPI
PlayerAdapter --> SpotifyEmbed
CFWorkers --> D1Cloud
CFWorkers --> OAuthProviders
Vue 组件遵循单向数据流原则:
// 父组件
<SettingsTab
:settings="focusSettings" // Props 向下传递
@update:settings="saveSettings" // Events 向上传递
/>
// 子组件
const props = defineProps(['settings'])
const emit = defineEmits(['update:settings'])
const handleChange = (key, value) => {
emit('update:settings', { ...props.settings, [key]: value })
}Composables 暴露响应式引用,组件自动响应变化:
// composable
const state = ref('idle')
const elapsed = ref(0)
export const useFocus = () => ({
state: readonly(state),
elapsed: readonly(elapsed)
})
// 组件
const { state, elapsed } = useFocus()
// state/elapsed 变化时,模板自动更新播放器使用观察者模式进行事件通信:
// 播放器适配器
this.emit('play', { track: this.currentTrack })
this.emit('timeupdate', { currentTime: this.audio.currentTime })
// 上层监听
player.on('play', (data) => {
isPlaying.value = true
currentTrack.value = data.track
})项目采用三层状态架构,每层有明确的职责:
flowchart TB
subgraph UIState["UI 状态(组件级)"]
direction LR
UI1["模态框开关"]
UI2["表单输入值"]
UI3["动画状态"]
UILife["生命周期:组件挂载 ↔ 组件卸载"]
end
subgraph AppState["应用状态(模块级单例)"]
direction LR
App1["番茄钟状态 (state, mode, elapsed)"]
App2["播放器状态 (isPlaying, currentTrack)"]
App3["通知队列"]
AppLife["生命周期:页面加载 ↔ 页面关闭"]
end
subgraph PersistState["持久化状态(Storage)"]
direction LR
Persist1["用户设置"]
Persist2["番茄钟记录"]
Persist3["歌单数据"]
Persist4["本地音频文件"]
PersistLife["生命周期:永久(跨会话)"]
end
UIState --> AppState
AppState --> PersistState
使用 ref() 或 reactive() 在组件内部定义,不共享:
// SettingsPanel.vue
const isOpen = ref(false) // 面板开关
const activeTab = ref('general') // 当前选项卡
const formData = reactive({}) // 表单临时数据特点:
- 组件销毁后自动清理
- 不影响其他组件
- 无需持久化
使用模块顶层 ref() 实现跨组件共享:
// useSession.js 模块顶层
const state = ref(FocusState.IDLE)
const mode = ref(FocusMode.FOCUS)
export const useSession = () => {
return {
state: readonly(state),
mode: readonly(mode)
}
}特点:
- 所有导入该模块的代码共享同一份状态
- 页面刷新后重置
- 需要手动同步到持久化层
根据数据特性选择合适的存储方案:
| 存储类型 | 适用场景 | 大小限制 | 访问方式 |
|---|---|---|---|
| localStorage | 设置、小型配置 | ~5MB | 同步 |
| IndexedDB | FileHandle 引用 | 无明确限制 | 异步 |
| OPFS | 大文件(音频) | 设备存储空间 | 异步 |
| Cache API | 网络资源缓存 | 配额管理 | 异步 |
| D1 (Cloud) | 用户数据云端同步 | 5MB/用户 | 异步 |
利用 ES Module 的特性:模块只会被执行一次,顶层变量在所有导入者之间共享。
// src/composables/focus/useSession.js
import { ref, readonly, computed } from 'vue'
// ===== 模块顶层定义(单例状态)=====
const state = ref('idle')
const mode = ref('focus')
const elapsed = ref(0)
const settings = ref({
focusDuration: 25 * 60,
shortBreak: 5 * 60,
longBreak: 15 * 60
})
let initialized = false
// ===== Composable 函数 =====
export const useSession = () => {
// 首次调用时初始化
if (!initialized) {
loadSettingsFromStorage()
initialized = true
}
// 返回共享状态的只读引用
return {
state: readonly(state),
mode: readonly(mode),
elapsed: readonly(elapsed),
settings: readonly(settings),
// 修改状态的方法
start,
pause,
resume,
updateSettings
}
}// ComponentA.vue
const { state } = useSession()
console.log(state.value) // 'idle'
// ComponentB.vue
const { state, start } = useSession()
start() // 修改状态
// 回到 ComponentA
console.log(state.value) // 'running' - 状态已同步使用 readonly() 防止外部直接修改状态:
// 正确:通过方法修改
const { start } = useSession()
start() // ✅
// 错误:直接赋值会警告
const { state } = useSession()
state.value = 'running' // ⚠️ Vue 会发出警告let initialized = false
export const useXxx = () => {
if (!initialized) {
// 从 localStorage 加载
// 设置事件监听
// 启动定时器等
initialized = true
}
return { /* ... */ }
}| 特性 | 模块级单例 | Pinia |
|---|---|---|
| 依赖 | 无 | 需安装 pinia |
| 学习成本 | 低 | 中 |
| DevTools | 无 | 有 |
| 时间旅行调试 | 无 | 有 |
| SSR 支持 | 需手动处理 | 内置 |
| 适用规模 | 小到中型 | 任意规模 |
本项目选择模块级单例的原因:
- 项目规模适中,状态管理需求简单
- 减少依赖,降低复杂度
- Composable 方式更贴近 Vue 3 的设计理念
所有 localStorage 键名使用 swm_ 前缀,定义在 src/config/constants.js:
export const STORAGE_KEYS = {
// 用户设置
USER_SETTINGS: 'swm_settings',
// 服务器配置
COUNT_SERVER: 'swm_count_server',
// 音乐平台
MUSIC_PLATFORM: 'swm_music_platform',
MUSIC_ID: 'swm_music_id',
PLAYLIST_ID: 'swm_playlist_id',
// Spotify
SPOTIFY_PLAYLIST_ID: 'swm_spotify_playlist_id',
// 缓存前缀
PLAYLIST_CACHE_PREFIX: 'swm_playlist_cache',
PREFETCH_TIMESTAMP_PREFIX: 'swm_playlist_prefetch',
// 歌单管理
PLAYLISTS: 'swm_playlists',
CURRENT_PLAYLIST: 'swm_current_playlist',
DEFAULT_PLAYLIST: 'swm_default_playlist',
// Focus 番茄钟
FOCUS_RECORDS: 'swm_focus_records',
FOCUS_SETTINGS: 'swm_focus_settings',
FOCUS_CURRENT: 'swm_focus_current',
// 分享卡片
SHARE_CARD_CONFIG: 'swm_share_card_config',
// 自定义视频
CUSTOM_VIDEOS: 'swm_custom_videos',
// 认证
AUTH_ACCESS_TOKEN: 'swm_access_token', // (legacy, 已弃用)
AUTH_REFRESH_TOKEN: 'swm_refresh_token', // (legacy, 已弃用)
AUTH_USER: 'swm_user_info',
// 同步
SYNC_VERSION_PREFIX: 'swm_sync_version', // + _{dataType}
// 钩子系统
HOOKS: 'swm_hooks',
ESTIM_UNLOCKED: 'swm_coyote_unlocked',
PUSH_SUBSCRIBED: 'swm_push_subscribed',
// 数据迁移
DATA_VERSION: 'swm_data_version'
}import { STORAGE_KEYS } from '@/config/constants'
import { safeLocalStorageGetJSON, safeLocalStorageSetJSON } from '@/utils/storage'
// ✅ 正确:使用常量和安全函数
const records = safeLocalStorageGetJSON(STORAGE_KEYS.FOCUS_RECORDS, [])
safeLocalStorageSetJSON(STORAGE_KEYS.FOCUS_RECORDS, records)
// ❌ 错误:硬编码键名
const records = JSON.parse(localStorage.getItem('swm_focus_records'))仅用于存储 FileHandle(文件引用模式):
export const PLAYLIST_CONFIG = {
IDB_DATABASE: 'swm-local-audio',
IDB_VERSION: 1,
IDB_STORE_HANDLES: 'file-handles'
}存储结构:
// IndexedDB: swm-local-audio / file-handles
{
id: 'local-audio-abc123', // 主键
handle: FileSystemFileHandle,
fileName: 'song.mp3',
savedAt: 1706659200000
}用于存储完整的音频文件(托管模式):
export const PLAYLIST_CONFIG = {
OPFS_AUDIO_DIR: 'audio' // 目录名
}
// 文件路径结构
// OPFS:/audio/local-audio-abc123export const CACHE_NAMES = {
VIDEO: 'video-cache', // 视频资源
R2_VIDEO: 'r2-video-cache', // R2 视频
IMAGE_FONT: 'image-font-cache', // 图片和字体
AUDIO: 'audio-cache', // 音频文件
API: 'api-cache', // API 响应
STREAMING_MUSIC: 'streaming-music-cache', // 流媒体
PLAYLIST_API: 'playlist-api-cache', // 歌单 API
PLAYLIST: 'meting-playlist-cache' // Meting 歌单
}// 1. 即时写入(设置变更)
const updateSettings = (newSettings) => {
settings.value = { ...settings.value, ...newSettings }
safeLocalStorageSetJSON(STORAGE_KEYS.FOCUS_SETTINGS, settings.value)
}
// 2. 批量写入(记录添加)
const saveRecord = (record) => {
records.value.push(record)
// 合并写入
safeLocalStorageSetJSON(STORAGE_KEYS.FOCUS_RECORDS, records.value)
}
// 3. 快照写入(中断恢复)
const saveSnapshot = () => {
const snapshot = {
state: state.value,
mode: mode.value,
startedAt: startedAt.value,
elapsed: elapsed.value
}
safeLocalStorageSetJSON(STORAGE_KEYS.FOCUS_CURRENT, snapshot)
}// 应用启动时从 localStorage 恢复
const initializeFromStorage = () => {
// 恢复设置
const savedSettings = safeLocalStorageGetJSON(
STORAGE_KEYS.FOCUS_SETTINGS,
DEFAULT_SETTINGS
)
settings.value = { ...DEFAULT_SETTINGS, ...savedSettings }
// 恢复记录
records.value = safeLocalStorageGetJSON(STORAGE_KEYS.FOCUS_RECORDS, [])
// 检查中断恢复
const snapshot = safeLocalStorageGetJSON(STORAGE_KEYS.FOCUS_CURRENT, null)
if (snapshot && snapshot.state === 'running') {
// 触发恢复流程
recoverFromSnapshot(snapshot)
}
}src/config/constants.js 集中管理所有配置常量。
export const CACHE_CONFIG = {
PLAYLIST_DURATION: 1000 * 60 * 60 * 12, // 12小时
PREFETCH_DURATION: 1000 * 60 * 60 * 12, // 12小时
PREFETCH_TIMEOUT: 60000, // 60秒
MAX_PREFETCH_SONGS: 12
}export const API_CONFIG = {
METING_API: 'https://api.injahow.cn/meting/',
FETCH_TIMEOUT: 10000, // 10秒
DEFAULT_PLAYLIST_ID: '17543418420',
DEFAULT_SPOTIFY_PLAYLIST_ID: '37i9dQZF1DXcBWIGoYBM5M'
}export const WS_CONFIG = {
PING_INTERVAL: 30000, // 30秒
CONNECTION_TIMEOUT: 5000 // 5秒
}
export const RECONNECT_CONFIG = {
MAX_ATTEMPTS: 10,
BASE_DELAY: 1000,
MAX_DELAY: 30000
}export const PLAYLIST_CONFIG = {
EXPORT_VERSION: 1,
OPFS_AUDIO_DIR: 'audio',
IDB_DATABASE: 'swm-local-audio',
IDB_VERSION: 1,
IDB_STORE_HANDLES: 'file-handles',
MAX_LOCAL_FILE_SIZE: 50 * 1024 * 1024, // 50MB
MAX_PLAYLISTS: 50,
MAX_SONGS_PER_COLLECTION: 500
}export const UI_CONFIG = {
TOAST_DEFAULT_DURATION: 3000,
TOAST_ERROR_DURATION: 5000,
TOAST_ANIMATION_DURATION: 300,
TOAST_MAX_COUNT: 5,
INACTIVITY_HIDE_DELAY: 3000,
APLAYER_LOAD_DELAY: 500,
PLAYLIST_APPLY_DELAY: 1000,
TIME_DISPLAY_UPDATE_INTERVAL: 1000,
MEDIA_SEEK_OFFSET: 10,
AUDIO_DURATION_TIMEOUT: 10000,
CACHE_STATS_THROTTLE: 1000,
MEDIA_POSITION_UPDATE_INTERVAL: 1000
}export const AUDIO_CONFIG = {
DEFAULT_VOLUME: 0.7,
VOLUME_FADE_STEPS: 20,
VOLUME_DUCK_RATIO: 0.2,
VOLUME_FADE_DURATION: 300,
DEFAULT_FADE_DURATION: 500,
NOTIFICATION_DURATION: 3000
}export const PLAYER_CONFIG = {
ADAPTER_TYPES: ['aplayer', 'spotify'],
APLAYER_DEFAULTS: {
fixed: true,
autoplay: false,
lrcType: 0,
theme: '#2980b9',
loop: 'all',
order: 'list',
preload: 'auto',
mutex: true,
listFolded: false,
listMaxHeight: '200px',
width: '300px'
}
}-
常量组:使用
XXX_CONFIG、XXX_KEYS、XXX_NAMES后缀 - 常量名:全大写,下划线分隔
- 时间值:使用毫秒,添加注释说明
- 大小限制:使用字节,乘法表达式提高可读性
// ✅ 好的命名
export const CACHE_CONFIG = {
PLAYLIST_DURATION: 1000 * 60 * 60 * 12, // 12小时
MAX_FILE_SIZE: 50 * 1024 * 1024 // 50MB
}
// ❌ 不好的命名
export const config = {
duration: 43200000,
maxSize: 52428800
}src/utils/storage.js 提供错误处理封装:
/**
* 安全获取 localStorage 值
* @param {string} key - 存储键名
* @param {*} defaultValue - 默认值
* @returns {string|null|*} 存储的值或默认值
*/
export const safeLocalStorageGet = (key, defaultValue = null) => {
try {
const value = localStorage.getItem(key)
return value !== null ? value : defaultValue
} catch (err) {
console.warn(`localStorage.getItem 失败 (${key}):`, err)
return defaultValue
}
}
/**
* 安全设置 localStorage 值
* @param {string} key - 存储键名
* @param {string} value - 要存储的值
* @returns {boolean} 是否成功
*/
export const safeLocalStorageSet = (key, value) => {
try {
localStorage.setItem(key, value)
return true
} catch (err) {
console.warn(`localStorage.setItem 失败 (${key}):`, err)
return false
}
}
/**
* 安全获取并解析 JSON
* @template T
* @param {string} key - 存储键名
* @param {T} defaultValue - 默认值
* @returns {T} 解析后的对象或默认值
*/
export const safeLocalStorageGetJSON = (key, defaultValue) => {
try {
const value = localStorage.getItem(key)
if (value === null) {
return defaultValue
}
return JSON.parse(value)
} catch (err) {
console.warn(`localStorage.getItem/JSON.parse 失败 (${key}):`, err)
return defaultValue
}
}
/**
* 安全地将值序列化为 JSON 并存储
* @param {string} key - 存储键名
* @param {*} value - 要存储的值
* @returns {boolean} 是否成功
*/
export const safeLocalStorageSetJSON = (key, value) => {
try {
localStorage.setItem(key, JSON.stringify(value))
return true
} catch (err) {
console.warn(`JSON.stringify/localStorage.setItem 失败 (${key}):`, err)
return false
}
}
/**
* 安全删除 localStorage 值
* @param {string} key - 存储键名
* @returns {boolean} 是否成功
*/
export const safeLocalStorageRemove = (key) => {
try {
localStorage.removeItem(key)
return true
} catch (err) {
console.warn(`localStorage.removeItem 失败 (${key}):`, err)
return false
}
}src/utils/cache.js 提供媒体资源的内存缓存:
const cache = {
videos: new Map(),
audios: new Map()
}
/**
* 获取/加载视频
* @param {string} src - 视频 URL
* @returns {Promise<HTMLVideoElement>}
*/
export const getVideo = (src) => {
return new Promise((resolve, reject) => {
// 检查缓存
if (cache.videos.has(src)) {
resolve(cache.videos.get(src))
return
}
// 创建并加载
const video = document.createElement('video')
video.src = src
video.preload = 'auto'
video.onloadeddata = () => {
cache.videos.set(src, video)
resolve(video)
}
video.onerror = () => {
reject(new Error(`Failed to load video: ${src}`))
}
})
}
/**
* 批量预加载视频
* @param {string[]} urls - 视频 URL 数组
* @returns {Promise<HTMLVideoElement[]>}
*/
export const preloadVideos = (urls) => {
return Promise.allSettled(urls.map(url => getVideo(url)))
.then(results => {
const failed = results.filter(r => r.status === 'rejected')
if (failed.length > 0) {
console.warn(`${failed.length}/${urls.length} videos failed to load`)
}
return results
.filter(r => r.status === 'fulfilled')
.map(r => r.value)
})
}
/**
* 清除指定类型的缓存
* @param {'videos'|'audios'} type
*/
export const clearCache = (type) => {
if (cache[type]) {
cache[type].forEach(media => {
if (media && typeof media.remove === 'function') {
media.remove()
}
})
cache[type].clear()
}
}
/**
* 清除所有缓存
*/
export const clearAllCache = () => {
Object.keys(cache).forEach(type => clearCache(type))
}
export { cache }// ✅ 使用模块顶层定义共享状态
const sharedState = ref(initialValue)
export const useXxx = () => {
return {
state: readonly(sharedState), // 对外只读
// 通过方法修改
updateState: (value) => { sharedState.value = value }
}
}
// ❌ 不要在 Composable 内部定义共享状态
export const useXxx = () => {
const state = ref(initialValue) // 每次调用都会创建新实例
return { state }
}// ✅ 修改状态后立即同步到 Storage
const updateSettings = (newSettings) => {
settings.value = { ...settings.value, ...newSettings }
safeLocalStorageSetJSON(STORAGE_KEYS.SETTINGS, settings.value)
}
// ✅ 使用 watch 自动同步
watch(
() => settings.value,
(newSettings) => {
safeLocalStorageSetJSON(STORAGE_KEYS.SETTINGS, newSettings)
},
{ deep: true }
)// ✅ 从 constants.js 导入
import { STORAGE_KEYS, CACHE_CONFIG } from '@/config/constants'
const records = safeLocalStorageGetJSON(STORAGE_KEYS.FOCUS_RECORDS, [])
if (Date.now() - cachedAt > CACHE_CONFIG.PLAYLIST_DURATION) {
// 缓存过期
}
// ❌ 硬编码魔法数字
if (Date.now() - cachedAt > 43200000) { }// ✅ 使用安全函数
const data = safeLocalStorageGetJSON(key, defaultValue)
// ❌ 直接操作可能抛出异常
const data = JSON.parse(localStorage.getItem(key))let initialized = false
export const useXxx = () => {
if (!initialized) {
// 初始化逻辑只执行一次
loadFromStorage()
setupEventListeners()
initialized = true
}
return { /* ... */ }
}认证用户的数据通过 Cloud-Sync 系统在客户端和服务端之间同步。以下是数据流概览。
flowchart LR
LocalChange["本地数据变更"]
Queue["加入离线队列"]
Encode["Protobuf 编码"]
Upload["PUT /api/data/:type"]
Validate["服务端验证"]
Store["存储到 D1"]
LocalChange --> Queue --> Encode --> Upload --> Validate --> Store
flowchart LR
Request["GET /api/data/:type"]
Fetch["从 D1 读取"]
Decode["Protobuf 解码"]
Merge["与本地数据合并"]
Save["写入 localStorage"]
Request --> Fetch --> Decode --> Merge --> Save
flowchart TB
Upload["客户端上传 (version=N)"]
Check{"服务端版本 == N?"}
Accept["接受写入"]
Conflict["返回 409 + 服务端数据"]
Resolve["客户端合并两份数据"]
ForceUpload["强制上传 (version=null)"]
Upload --> Check
Check -->|"是"| Accept
Check -->|"否"| Conflict --> Resolve --> ForceUpload --> Accept
各数据类型的冲突解决策略不同(ID 去重+LWW、Deep Merge 等),详见 Cloud-Sync#冲突解决。
认证系统采用分层存储策略,将不同安全等级的数据存放在不同位置:
| 数据 | 存储位置 | 安全考虑 |
|---|---|---|
| Access Token | 内存(JS 变量) | XSS 无法窃取,页面刷新后失效 |
| Refresh Token | HttpOnly Cookie | JavaScript 无法读取,防止 XSS 窃取 |
| Token 过期时间 | localStorage (swm_token_expires_at) |
仅用于判断是否需要刷新 |
| Token 类型 | localStorage (swm_token_type) |
非敏感信息 |
| 用户信息 | localStorage (swm_user_info) |
用于 UI 展示,非认证凭据 |
| 设备 ID | localStorage (swm_device_id) |
设备标识符 |
authStorage 模块(src/utils/authStorage.js)封装了这些存储操作,提供 hasValidAuth() 方法快速检查认证状态。
页面刷新后 Access Token 会丢失,此时通过 Cookie 中的 Refresh Token 自动重新获取。详见 Authentication-System#Token 策略。
src/services/migration.js 提供 localStorage 数据结构的版本升级能力:
import { CURRENT_DATA_VERSION, getDataVersion, runMigrations } from '@/services/migration'
// 当前数据版本存储在 localStorage: swm_data_version
const currentVersion = getDataVersion() // 从 localStorage 读取
const latestVersion = CURRENT_DATA_VERSION // 代码中定义的最新版本
if (currentVersion < latestVersion) {
// 需要执行迁移
}迁移前自动创建备份,所有 swm_ 前缀的 localStorage 键值会被复制到 swm_backup_ 前缀下:
-
createBackup()— 创建备份快照 -
restoreFromBackup()— 从备份恢复 -
hasBackup()— 检查备份是否存在 -
clearBackup()— 清理备份
const report = await runMigrations({
autoRollback: true, // 失败时自动回滚
clearBackupOnSuccess: false // 成功后保留备份
})
// report: { success, migratedFrom, migratedTo, errors, rolledBack }usePWA composable 集成了迁移检测,通过 hasPendingMigration computed 属性提示用户。