AI 视频项目状态架构设计

理论讲完了,本章用 AI 视频平台的完整状态需求作为实战——从用户认证、视频播放、创作工作台到通知系统,每个模块的状态该用 useState 还是 Pinia?哪些数据需要持久化?服务端和客户端的状态边界在哪里?本章给出一套经过生产验证的分层状态架构方案。

1. 用户认证状态

1.1 需求分析

  • Token 需要持久化(Cookie),SSR 时服务端可读
  • 用户信息需要全局共享
  • 登录/登出/刷新 Token 逻辑
  • 权限判断(普通用户 / 创作者 / 管理员)

1.2 完整实现

// app/stores/useAuthStore.ts
export const useAuthStore = defineStore('auth', () => {
  // 使用 useCookie 实现 SSR 兼容的 Token 持久化
  const tokenCookie = useCookie('auth-token', {
    maxAge: 60 * 60 * 24 * 7,  // 7 天
    secure: true,
    sameSite: 'lax',
  })
 
  const user = ref<User | null>(null)
  const token = computed({
    get: () => tokenCookie.value,
    set: (v) => { tokenCookie.value = v },
  })
 
  // Getters
  const isLoggedIn = computed(() => !!token.value && !!user.value)
  const isCreator = computed(() => user.value?.role === 'creator' || user.value?.role === 'admin')
  const isAdmin = computed(() => user.value?.role === 'admin')
  const displayName = computed(() => user.value?.name || '游客')
  const avatar = computed(() => user.value?.avatar || '/default-avatar.png')
 
  // Actions
  async function login(credentials: { email: string; password: string }) {
    const result = await $fetch('/api/auth/login', {
      method: 'POST',
      body: credentials,
    })
    user.value = result.user
    token.value = result.token
  }
 
  async function fetchUser() {
    if (!token.value) return
    try {
      user.value = await $fetch('/api/auth/me', {
        headers: useRequestHeaders(['cookie']),
      })
    } catch {
      // Token 过期或无效
      logout()
    }
  }
 
  function logout() {
    user.value = null
    token.value = null
    navigateTo('/login')
  }
 
  function hasPermission(permission: string): boolean {
    return user.value?.permissions?.includes(permission) ?? false
  }
 
  return {
    user, token, isLoggedIn, isCreator, isAdmin,
    displayName, avatar,
    login, fetchUser, logout, hasPermission,
  }
})

1.3 初始化认证状态

// app/plugins/auth.ts
export default defineNuxtPlugin(async () => {
  const authStore = useAuthStore()
 
  // 如果有 Token 但没有用户信息,自动获取
  if (authStore.token && !authStore.user) {
    await authStore.fetchUser()
  }
})

2. 视频播放状态

2.1 需求分析

  • 当前播放视频的信息
  • 播放进度、音量、画质等控制状态
  • 播放历史记录
  • 迷你播放器支持(跨页面保持播放)

2.2 完整实现

// app/stores/usePlayerStore.ts
export const usePlayerStore = defineStore('player', () => {
  // 当前播放状态
  const currentVideo = ref<Video | null>(null)
  const isPlaying = ref(false)
  const currentTime = ref(0)
  const duration = ref(0)
  const volume = ref(0.8)
  const isMuted = ref(false)
  const quality = ref<'auto' | '1080p' | '720p' | '480p'>('auto')
  const playbackRate = ref(1)
 
  // 播放列表
  const playlist = ref<Video[]>([])
  const playlistIndex = ref(-1)
 
  // 迷你播放器
  const isMiniPlayer = ref(false)
 
  // Getters
  const progress = computed(() =>
    duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0
  )
  const hasNext = computed(() => playlistIndex.value < playlist.value.length - 1)
  const hasPrev = computed(() => playlistIndex.value > 0)
  const remainingTime = computed(() => duration.value - currentTime.value)
 
  // Actions
  function play(video: Video) {
    currentVideo.value = video
    isPlaying.value = true
 
    // 记录播放历史
    addToHistory(video)
  }
 
  function pause() { isPlaying.value = false }
  function resume() { isPlaying.value = true }
  function togglePlay() { isPlaying.value = !isPlaying.value }
 
  function seek(time: number) {
    currentTime.value = Math.max(0, Math.min(time, duration.value))
  }
 
  function setVolume(v: number) {
    volume.value = Math.max(0, Math.min(1, v))
    if (v > 0) isMuted.value = false
  }
 
  function toggleMute() { isMuted.value = !isMuted.value }
 
  function playNext() {
    if (hasNext.value) {
      playlistIndex.value++
      play(playlist.value[playlistIndex.value])
    }
  }
 
  function playPrev() {
    if (hasPrev.value) {
      playlistIndex.value--
      play(playlist.value[playlistIndex.value])
    }
  }
 
  function setPlaylist(videos: Video[], startIndex = 0) {
    playlist.value = videos
    playlistIndex.value = startIndex
    play(videos[startIndex])
  }
 
  // 播放历史(最近 50 条)
  const history = ref<Array<{ video: Video; watchedAt: string; progress: number }>>([])
 
  function addToHistory(video: Video) {
    // 移除已有记录
    history.value = history.value.filter(h => h.video.id !== video.id)
    // 添加到最前面
    history.value.unshift({
      video,
      watchedAt: new Date().toISOString(),
      progress: 0,
    })
    // 保留最近 50 条
    if (history.value.length > 50) {
      history.value = history.value.slice(0, 50)
    }
  }
 
  function updateHistoryProgress() {
    if (!currentVideo.value) return
    const entry = history.value.find(h => h.video.id === currentVideo.value!.id)
    if (entry) {
      entry.progress = progress.value
    }
  }
 
  return {
    // State
    currentVideo, isPlaying, currentTime, duration,
    volume, isMuted, quality, playbackRate,
    playlist, playlistIndex, isMiniPlayer, history,
    // Getters
    progress, hasNext, hasPrev, remainingTime,
    // Actions
    play, pause, resume, togglePlay, seek,
    setVolume, toggleMute, playNext, playPrev,
    setPlaylist, updateHistoryProgress,
  }
})

3. Pinia Store 分层设计

3.1 Store 分层架构

app/stores/
├── useAuthStore.ts           ← 认证层:用户登录/权限
├── usePlayerStore.ts         ← 播放层:播放控制/历史
├── useVideoStore.ts          ← 数据层:视频列表/搜索
├── useStudioStore.ts         ← 业务层:创作工作台
├── useNotificationStore.ts   ← UI 层:通知/弹窗
└── useSettingsStore.ts       ← 配置层:用户偏好

3.2 数据层 Store

// app/stores/useVideoStore.ts
export const useVideoStore = defineStore('video', () => {
  // 列表数据
  const videos = ref<Video[]>([])
  const page = ref(1)
  const total = ref(0)
  const loading = ref(false)
 
  // 筛选条件
  const filters = ref({
    category: '',
    sort: 'newest' as 'newest' | 'trending' | 'popular',
    duration: '' as '' | 'short' | 'medium' | 'long',
  })
 
  // Getters
  const hasMore = computed(() => videos.value.length < total.value)
  const isEmpty = computed(() => !loading.value && videos.value.length === 0)
 
  // Actions
  async function fetchVideos(reset = false) {
    if (reset) {
      page.value = 1
      videos.value = []
    }
    loading.value = true
    try {
      const result = await $fetch('/api/videos', {
        query: { page: page.value, ...filters.value },
      })
      if (reset) {
        videos.value = result.items
      } else {
        videos.value.push(...result.items)
      }
      total.value = result.total
    } finally {
      loading.value = false
    }
  }
 
  async function loadMore() {
    if (!hasMore.value || loading.value) return
    page.value++
    await fetchVideos()
  }
 
  // 筛选条件变化时自动重新获取
  watch(filters, () => fetchVideos(true), { deep: true })
 
  return { videos, page, total, loading, filters, hasMore, isEmpty, fetchVideos, loadMore }
})

3.3 UI 层 Store

// app/stores/useNotificationStore.ts
export const useNotificationStore = defineStore('notification', () => {
  const notifications = ref<Notification[]>([])
  const unreadCount = computed(() => notifications.value.filter(n => !n.read).length)
 
  let nextId = 0
 
  function notify(message: string, type: 'success' | 'error' | 'info' | 'warning' = 'info') {
    const id = nextId++
    notifications.value.push({ id, message, type, read: false, createdAt: new Date().toISOString() })
    // 3 秒后自动关闭
    setTimeout(() => dismiss(id), 3000)
  }
 
  function dismiss(id: number) {
    notifications.value = notifications.value.filter(n => n.id !== id)
  }
 
  function markAllRead() {
    notifications.value.forEach(n => { n.read = true })
  }
 
  // 便捷方法
  const success = (msg: string) => notify(msg, 'success')
  const error = (msg: string) => notify(msg, 'error')
  const info = (msg: string) => notify(msg, 'info')
  const warning = (msg: string) => notify(msg, 'warning')
 
  return { notifications, unreadCount, notify, dismiss, markAllRead, success, error, info, warning }
})

3.4 配置层 Store

// app/stores/useSettingsStore.ts
export const useSettingsStore = defineStore('settings', () => {
  const theme = ref<'light' | 'dark' | 'system'>('system')
  const locale = ref('zh-CN')
  const autoplay = ref(true)
  const defaultQuality = ref<'auto' | '1080p' | '720p'>('auto')
  const subtitlesEnabled = ref(true)
 
  const resolvedTheme = computed(() => {
    if (theme.value !== 'system') return theme.value
    if (import.meta.client) {
      return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
    }
    return 'light'
  })
 
  return { theme, locale, autoplay, defaultQuality, subtitlesEnabled, resolvedTheme }
}, {
  persist: {
    storage: piniaPluginPersistedstate.cookies(),
    pick: ['theme', 'locale'],
  },
})

4. 服务端与客户端状态边界划分

4.1 状态分类

分类示例SSR 需要?存储方式
认证状态Token、用户角色✅ 需要(鉴权)Cookie
页面数据视频列表、详情✅ 需要(SEO)useFetch Payload
UI 偏好主题、语言✅ 需要(首屏)Cookie
播放状态进度、音量❌ 不需要客户端内存
临时 UIToast、Modal❌ 不需要客户端内存
本地数据搜索历史、草稿❌ 不需要localStorage

4.2 SSR 状态初始化时序

1. 服务端收到请求
2. 读取 Cookie → 恢复 Token、主题、语言
3. auth 插件 → 用 Token 获取用户信息
4. 页面组件 → useFetch 获取数据(利用 Token 做鉴权)
5. Pinia Store 状态 → 序列化到 Payload
6. HTML 返回给客户端

7. 客户端 Hydration
8. 从 Payload 恢复 Pinia 状态
9. 持久化插件 → 恢复 localStorage 中的数据
10. 客户端专属 Store → 初始化(播放器、通知等)

4.3 避免 SSR 状态泄漏

// ❌ 错误:在 Store 中使用浏览器 API
export const usePlayerStore = defineStore('player', () => {
  const volume = ref(parseFloat(localStorage.getItem('volume') || '0.8'))
  // 💥 服务端没有 localStorage
})
 
// ✅ 正确:延迟到客户端
export const usePlayerStore = defineStore('player', () => {
  const volume = ref(0.8)
 
  if (import.meta.client) {
    // 客户端恢复上次的音量
    const saved = localStorage.getItem('player-volume')
    if (saved) volume.value = parseFloat(saved)
 
    // 监听变化并保存
    watch(volume, (v) => localStorage.setItem('player-volume', String(v)))
  }
 
  return { volume }
})

4.4 完整状态架构图

┌─────────────────────────────────────────────┐
│                 Cookie 持久化                │
│  Token │ Theme │ Locale                      │
├─────────────────────────────────────────────┤
│              SSR Payload 传递                │
│  Pinia State │ useFetch Data │ useState      │
├─────────────────────────────────────────────┤
│             客户端 Pinia Store               │
│  Auth │ Video │ Player │ Studio │ Settings   │
├─────────────────────────────────────────────┤
│              localStorage                    │
│  搜索历史 │ 播放进度 │ 草稿 │ 音量偏好       │
├─────────────────────────────────────────────┤
│              客户端内存                       │
│  Toast 队列 │ Modal 状态 │ 临时 UI 状态       │
└─────────────────────────────────────────────┘

本章小结

  • 认证状态useCookie + Pinia Store,SSR 时自动透传 Token
  • 播放状态:纯客户端 Pinia Store,播放历史和音量存 localStorage
  • 分层设计:认证层 → 数据层 → 业务层 → UI 层 → 配置层
  • SSR 边界:影响首屏渲染的状态(Token、主题、页面数据)走 Cookie/Payload;客户端专属状态(播放、通知)延迟初始化
  • 状态初始化时序:Cookie → auth 插件 → useFetch → Payload → Hydration → localStorage 恢复

至此,第五篇"状态管理精讲"全部 3 章已完成。下一篇将深入 Nitro 服务引擎