Skip to content

Data Flow And State

gxxk-dev edited this page Mar 1, 2026 · 4 revisions

数据流与状态管理

本文档详细介绍 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
Loading

数据流类型

1. 单向数据流(Props → Events)

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 })
}

2. 响应式数据流(Composables)

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 变化时,模板自动更新

3. 事件驱动数据流(Player Events)

播放器使用观察者模式进行事件通信:

// 播放器适配器
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
Loading

UI 状态(组件级)

使用 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)
  }
}

特点

  • 所有导入该模块的代码共享同一份状态
  • 页面刷新后重置
  • 需要手动同步到持久化层

持久化状态(Storage)

根据数据特性选择合适的存储方案:

存储类型 适用场景 大小限制 访问方式
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 保护

使用 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
依赖 需安装 pinia
学习成本
DevTools
时间旅行调试
SSR 支持 需手动处理 内置
适用规模 小到中型 任意规模

本项目选择模块级单例的原因

  1. 项目规模适中,状态管理需求简单
  2. 减少依赖,降低复杂度
  3. Composable 方式更贴近 Vue 3 的设计理念

持久化策略

localStorage 键名规范

所有 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'))

IndexedDB 使用场景

仅用于存储 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
}

OPFS(Origin Private File System)

用于存储完整的音频文件(托管模式):

export const PLAYLIST_CONFIG = {
  OPFS_AUDIO_DIR: 'audio'  // 目录名
}

// 文件路径结构
// OPFS:/audio/local-audio-abc123

Cache API 缓存名

export 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 集中管理所有配置常量。

常量分类

1. 缓存配置

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
}

2. API 配置

export const API_CONFIG = {
  METING_API: 'https://api.injahow.cn/meting/',
  FETCH_TIMEOUT: 10000, // 10秒
  DEFAULT_PLAYLIST_ID: '17543418420',
  DEFAULT_SPOTIFY_PLAYLIST_ID: '37i9dQZF1DXcBWIGoYBM5M'
}

3. WebSocket 配置

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
}

4. 歌单系统配置

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
}

5. UI 配置

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
}

6. 音频配置

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
}

7. 播放器配置

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'
  }
}

命名规范

  1. 常量组:使用 XXX_CONFIGXXX_KEYSXXX_NAMES 后缀
  2. 常量名:全大写,下划线分隔
  3. 时间值:使用毫秒,添加注释说明
  4. 大小限制:使用字节,乘法表达式提高可读性
// ✅ 好的命名
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
}

工具函数

安全的 localStorage 操作

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 }

最佳实践

1. 状态定义

// ✅ 使用模块顶层定义共享状态
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 }
}

2. 持久化同步

// ✅ 修改状态后立即同步到 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 }
)

3. 常量使用

// ✅ 从 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) { }

4. 错误处理

// ✅ 使用安全函数
const data = safeLocalStorageGetJSON(key, defaultValue)

// ❌ 直接操作可能抛出异常
const data = JSON.parse(localStorage.getItem(key))

5. 初始化保护

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
Loading

下载数据流

flowchart LR
    Request["GET /api/data/:type"]
    Fetch["从 D1 读取"]
    Decode["Protobuf 解码"]
    Merge["与本地数据合并"]
    Save["写入 localStorage"]

    Request --> Fetch --> Decode --> Merge --> Save
Loading

冲突解决数据流

flowchart TB
    Upload["客户端上传 (version=N)"]
    Check{"服务端版本 == N?"}
    Accept["接受写入"]
    Conflict["返回 409 + 服务端数据"]
    Resolve["客户端合并两份数据"]
    ForceUpload["强制上传 (version=null)"]

    Upload --> Check
    Check -->|"是"| Accept
    Check -->|"否"| Conflict --> Resolve --> ForceUpload --> Accept
Loading

各数据类型的冲突解决策略不同(ID 去重+LWW、Deep Merge 等),详见 Cloud-Sync#冲突解决


认证 Token 存储模式

认证系统采用分层存储策略,将不同安全等级的数据存放在不同位置:

数据 存储位置 安全考虑
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 属性提示用户。

Clone this wiki locally