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 |
| 播放状态 | 进度、音量 | ❌ 不需要 | 客户端内存 |
| 临时 UI | Toast、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 服务引擎。