核心功能实现
前面三章搭好了地基——架构、AI 接口、播放器、上传流水线。本章进入业务功能实现阶段:AI 生成工作流(从输入 prompt 到查看结果的完整用户旅程)、用户系统(认证、个人主页、社交互动)、搜索推荐(全文搜索 + 标签筛选)、以及两个关键的前端体验模式——Optimistic UI 和无限滚动。
1. AI 生成工作流
1.1 用户旅程
选择模型 → 输入 prompt → 选择风格/参数 → 提交 → 等待生成 → 查看结果 → 编辑信息 → 发布
这个流程涉及多个页面和状态转换。我们用一个完整的页面组件来编排:
1.2 生成向导
<!-- pages/create/index.vue -->
<script setup>
definePageMeta({ middleware: 'auth' })
const step = ref<'input' | 'generating' | 'result'>('input')
const taskId = ref<string | null>(null)
// 表单数据
const form = reactive({
prompt: '',
model: 'kling' as 'kling' | 'runway',
style: 'realistic' as 'realistic' | 'anime' | 'cinematic',
duration: 5,
aspectRatio: '16:9' as '16:9' | '9:16' | '1:1',
})
// AI 任务轮询
const { task, startPolling } = useAiTask(taskId)
// 监听任务完成
watch(() => task.value?.status, (status) => {
if (status === 'completed') {
step.value = 'result'
}
})
async function handleSubmit() {
const { data, error } = await useFetch('/api/ai/generate', {
method: 'POST',
body: { ...form },
})
if (error.value) {
useToast().add({ title: error.value.data?.message || '提交失败', color: 'red' })
return
}
taskId.value = data.value!.taskId
step.value = 'generating'
startPolling()
}
</script>
<template>
<div class="max-w-3xl mx-auto px-4 py-8">
<h1 class="text-2xl font-bold mb-8">AI 视频生成</h1>
<!-- 步骤 1:输入参数 -->
<div v-if="step === 'input'" class="space-y-6">
<UFormGroup label="模型选择">
<URadioGroup
v-model="form.model"
:options="[
{ label: '可灵 - 中文优化,生成快', value: 'kling' },
{ label: 'Runway - 画质高,风格多', value: 'runway' },
]"
/>
</UFormGroup>
<UFormGroup label="视频描述">
<UTextarea
v-model="form.prompt"
placeholder="详细描述你想生成的视频内容..."
rows="4"
:maxlength="500"
/>
<template #hint>{{ form.prompt.length }}/500</template>
</UFormGroup>
<div class="grid grid-cols-3 gap-4">
<UFormGroup label="风格">
<USelect v-model="form.style" :options="['realistic', 'anime', 'cinematic']" />
</UFormGroup>
<UFormGroup label="时长(秒)">
<UInput v-model.number="form.duration" type="number" :min="3" :max="60" />
</UFormGroup>
<UFormGroup label="比例">
<USelect v-model="form.aspectRatio" :options="['16:9', '9:16', '1:1']" />
</UFormGroup>
</div>
<UButton @click="handleSubmit" :disabled="form.prompt.length < 10" size="lg" block>
开始生成
</UButton>
</div>
<!-- 步骤 2:生成中 -->
<div v-else-if="step === 'generating'" class="text-center py-16">
<div class="animate-pulse mb-8">
<div class="w-24 h-24 rounded-full bg-primary/20 mx-auto flex items-center justify-center">
<span class="text-4xl">🎬</span>
</div>
</div>
<UProgress :value="task?.progress || 0" class="max-w-md mx-auto mb-4" />
<p class="text-gray-500">AI 正在创作你的视频... {{ task?.progress || 0 }}%</p>
<p class="text-sm text-gray-400 mt-2">通常需要 30 秒到 3 分钟</p>
</div>
<!-- 步骤 3:生成结果 -->
<div v-else-if="step === 'result' && task?.videoId">
<VideoPlayer :src="task.videoUrl!" class="rounded-lg overflow-hidden mb-6" />
<div class="flex gap-3">
<UButton @click="navigateTo(`/video/${task.videoId}/edit`)">
编辑并发布
</UButton>
<UButton variant="outline" @click="step = 'input'; taskId = null">
重新生成
</UButton>
</div>
</div>
</div>
</template>1.3 生成历史
<!-- pages/dashboard/generations.vue -->
<script setup>
const { data: tasks } = await useFetch('/api/ai/tasks', {
query: { page: 1, limit: 20 },
})
</script>
<template>
<div class="space-y-4">
<h2 class="text-xl font-semibold">生成历史</h2>
<div v-for="task in tasks?.items" :key="task.id" class="flex gap-4 p-4 border rounded-lg">
<img
v-if="task.thumbnailUrl"
:src="task.thumbnailUrl"
class="w-32 h-20 object-cover rounded"
/>
<div class="flex-1">
<p class="font-medium line-clamp-1">{{ task.prompt }}</p>
<div class="flex items-center gap-2 mt-1 text-sm text-gray-500">
<UBadge :color="statusColor(task.status)">{{ task.status }}</UBadge>
<span>{{ task.model }}</span>
<span>{{ formatDate(task.createdAt) }}</span>
</div>
</div>
</div>
</div>
</template>2. 用户系统
2.1 认证:nuxt-auth-utils
// nuxt.config.ts
export default defineNuxtConfig({
modules: ['nuxt-auth-utils'],
runtimeConfig: {
oauth: {
github: { clientId: '', clientSecret: '' },
google: { clientId: '', clientSecret: '' },
},
session: {
maxAge: 60 * 60 * 24 * 7, // 7 天
},
},
})// server/routes/auth/github.get.ts
export default defineOAuthGitHubEventHandler({
async onSuccess(event, { user: githubUser }) {
const db = useDB()
// 查找或创建用户
let [user] = await db.select().from(users)
.where(eq(users.email, githubUser.email))
if (!user) {
[user] = await db.insert(users).values({
email: githubUser.email,
name: githubUser.name || githubUser.login,
avatar: githubUser.avatar_url,
}).returning()
}
// 设置 session
await setUserSession(event, { user: { id: user.id, name: user.name, role: user.role } })
return sendRedirect(event, '/')
},
})2.2 认证中间件
// server/utils/auth.ts
export async function requireAuth(event: H3Event) {
const session = await getUserSession(event)
if (!session?.user) {
throw createError({ statusCode: 401, message: '请先登录' })
}
return session
}
export async function requireAdmin(event: H3Event) {
const session = await requireAuth(event)
if (session.user.role !== 'admin') {
throw createError({ statusCode: 403, message: '需要管理员权限' })
}
return session
}2.3 个人主页
<!-- pages/user/[id].vue -->
<script setup>
const route = useRoute()
const userId = route.params.id as string
const { data: profile } = await useFetch(`/api/users/${userId}`)
const { data: userVideos } = await useFetch(`/api/users/${userId}/videos`)
useSeoMeta({
title: `${profile.value?.name} 的主页`,
ogTitle: `${profile.value?.name} 的主页`,
ogImage: profile.value?.avatar,
})
</script>
<template>
<div v-if="profile">
<!-- 用户信息头部 -->
<div class="flex items-center gap-6 mb-8">
<UAvatar :src="profile.avatar" size="xl" />
<div>
<h1 class="text-2xl font-bold">{{ profile.name }}</h1>
<p class="text-gray-500 mt-1">
{{ profile.videoCount }} 个视频 · {{ profile.followerCount }} 粉丝
</p>
</div>
<FollowButton :user-id="userId" class="ml-auto" />
</div>
<!-- 视频列表 -->
<VideoGrid :videos="userVideos?.items || []" />
</div>
</template>2.4 点赞与收藏
// server/api/videos/[id]/like.post.ts
export default defineEventHandler(async (event) => {
const session = await requireAuth(event)
const videoId = getRouterParam(event, 'id')!
const db = useDB()
// 检查是否已点赞
const existing = await db.select().from(likes)
.where(and(eq(likes.videoId, videoId), eq(likes.userId, session.user.id)))
.limit(1)
if (existing.length > 0) {
// 取消点赞
await db.delete(likes)
.where(and(eq(likes.videoId, videoId), eq(likes.userId, session.user.id)))
await db.update(videos)
.set({ likeCount: sql`${videos.likeCount} - 1` })
.where(eq(videos.id, videoId))
return { liked: false }
} else {
// 点赞
await db.insert(likes).values({ videoId, userId: session.user.id })
await db.update(videos)
.set({ likeCount: sql`${videos.likeCount} + 1` })
.where(eq(videos.id, videoId))
return { liked: true }
}
})3. 搜索与推荐
3.1 PostgreSQL 全文搜索
PostgreSQL 内置的全文搜索对中小规模项目足够用,不需要引入 Elasticsearch:
// server/api/search.get.ts
export default defineEventHandler(async (event) => {
const { q, tag, page = 1, limit = 20 } = getQuery(event)
const db = useDB()
const conditions = [eq(videos.status, 'published')]
// 全文搜索
if (q) {
conditions.push(
sql`to_tsvector('chinese', ${videos.title} || ' ' || coalesce(${videos.description}, ''))
@@ plainto_tsquery('chinese', ${q})`
)
}
// 标签筛选
if (tag) {
conditions.push(arrayContains(videos.tags, [tag as string]))
}
const where = and(...conditions)
const [items, [{ count }]] = await Promise.all([
db.select({
id: videos.id,
title: videos.title,
thumbnailUrl: videos.thumbnailUrl,
duration: videos.duration,
viewCount: videos.viewCount,
createdAt: videos.createdAt,
user: { name: users.name, avatar: users.avatar },
})
.from(videos)
.leftJoin(users, eq(videos.userId, users.id))
.where(where)
.orderBy(q
? sql`ts_rank(to_tsvector('chinese', ${videos.title}), plainto_tsquery('chinese', ${q})) DESC`
: desc(videos.publishedAt)
)
.limit(Number(limit))
.offset((Number(page) - 1) * Number(limit)),
db.select({ count: sql<number>`count(*)` }).from(videos).where(where),
])
return { items, total: count }
})3.2 搜索页面
<!-- pages/search.vue -->
<script setup>
const route = useRoute()
const query = computed(() => route.query.q as string || '')
const { data: results, pending } = await useFetch('/api/search', {
query: { q: query, page: 1 },
watch: [query],
})
useSeoMeta({
title: query.value ? `搜索: ${query.value}` : '搜索',
})
</script>
<template>
<div class="max-w-6xl mx-auto px-4 py-6">
<h1 v-if="query" class="text-xl mb-6">
搜索 "{{ query }}" 共 {{ results?.total || 0 }} 个结果
</h1>
<div v-if="pending" class="grid grid-cols-4 gap-4">
<USkeleton v-for="i in 8" :key="i" class="aspect-video rounded-lg" />
</div>
<VideoGrid v-else :videos="results?.items || []" />
</div>
</template>3.3 热门标签
// server/api/tags/popular.get.ts
export default defineEventHandler(async () => {
const db = useDB()
const result = await db.execute(sql`
SELECT unnest(tags) as tag, count(*) as count
FROM videos
WHERE status = 'published'
GROUP BY tag
ORDER BY count DESC
LIMIT 20
`)
return result.rows
})4. Optimistic UI
4.1 什么是 Optimistic UI
点赞是一个高频交互——用户点击后如果要等服务端响应才更新 UI,会感觉"卡"。Optimistic UI 的策略是:先乐观地更新 UI,假设操作会成功;如果服务端返回失败,再回滚。
4.2 点赞的 Optimistic 实现
// composables/useLike.ts
export function useLike(videoId: string, initialLiked: boolean, initialCount: number) {
const liked = ref(initialLiked)
const count = ref(initialCount)
async function toggle() {
// 乐观更新
const previousLiked = liked.value
const previousCount = count.value
liked.value = !liked.value
count.value += liked.value ? 1 : -1
try {
const result = await $fetch(`/api/videos/${videoId}/like`, {
method: 'POST',
})
// 服务端确认
liked.value = result.liked
} catch {
// 回滚
liked.value = previousLiked
count.value = previousCount
useToast().add({ title: '操作失败,请重试', color: 'red' })
}
}
return { liked: readonly(liked), count: readonly(count), toggle }
}<!-- components/LikeButton.vue -->
<script setup>
const { videoId, initialLiked, initialCount } = defineProps<{
videoId: string
initialLiked: boolean
initialCount: number
}>()
const { liked, count, toggle } = useLike(videoId, initialLiked, initialCount)
</script>
<template>
<button
@click="toggle"
class="flex items-center gap-1 transition-colors"
:class="liked ? 'text-red-500' : 'text-gray-500 hover:text-red-400'"
>
<span>{{ liked ? '❤️' : '🤍' }}</span>
<span class="text-sm">{{ count }}</span>
</button>
</template>5. 无限滚动
5.1 Composable
// composables/useInfiniteScroll.ts
export function useInfiniteScroll<T>(
fetchFn: (page: number) => Promise<{ items: T[]; total: number }>
) {
const items = ref<T[]>([]) as Ref<T[]>
const page = ref(1)
const hasMore = ref(true)
const loading = ref(false)
async function loadMore() {
if (loading.value || !hasMore.value) return
loading.value = true
try {
const result = await fetchFn(page.value)
items.value.push(...result.items)
if (items.value.length >= result.total) {
hasMore.value = false
} else {
page.value++
}
} finally {
loading.value = false
}
}
// 初始加载
loadMore()
return { items, hasMore, loading, loadMore }
}5.2 Intersection Observer 触发
<!-- components/VideoFeed.vue -->
<script setup>
const { items, hasMore, loading, loadMore } = useInfiniteScroll(
(page) => $fetch('/api/videos', { query: { page, limit: 20 } })
)
const sentinelRef = useTemplateRef<HTMLElement>('sentinel')
onMounted(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting) loadMore()
},
{ rootMargin: '200px' } // 提前 200px 开始加载
)
if (sentinelRef.value) observer.observe(sentinelRef.value)
onUnmounted(() => observer.disconnect())
})
</script>
<template>
<div>
<VideoGrid :videos="items" />
<!-- 加载指示器 -->
<div v-if="loading" class="flex justify-center py-8">
<UIcon name="i-heroicons-arrow-path" class="animate-spin text-2xl" />
</div>
<!-- 哨兵元素 -->
<div v-if="hasMore" ref="sentinel" class="h-1" />
<!-- 到底了 -->
<p v-if="!hasMore && items.length" class="text-center text-gray-400 py-8">
没有更多了
</p>
</div>
</template>rootMargin: '200px' 让触发点提前 200px——用户还没滚到底就开始加载下一页,几乎消除等待感。
5.3 视频网格组件
<!-- components/VideoGrid.vue -->
<script setup>
defineProps<{ videos: Video[] }>()
</script>
<template>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
<NuxtLink
v-for="video in videos"
:key="video.id"
:to="`/video/${video.id}`"
class="group"
>
<div class="relative aspect-video rounded-lg overflow-hidden bg-gray-100">
<img
:src="video.thumbnailUrl"
:alt="video.title"
class="w-full h-full object-cover group-hover:scale-105 transition-transform"
loading="lazy"
/>
<span class="absolute bottom-2 right-2 bg-black/70 text-white text-xs px-1.5 py-0.5 rounded">
{{ formatDuration(video.duration) }}
</span>
</div>
<div class="mt-2">
<h3 class="font-medium line-clamp-2 text-sm">{{ video.title }}</h3>
<div class="flex items-center gap-2 mt-1 text-xs text-gray-500">
<span>{{ video.user?.name }}</span>
<span>·</span>
<span>{{ video.viewCount }} 次播放</span>
<span>·</span>
<span>{{ formatRelativeTime(video.createdAt) }}</span>
</div>
</div>
</NuxtLink>
</div>
</template>本章小结
- AI 生成工作流:三步向导(参数输入 → 进度等待 → 结果预览),
useAiTask轮询驱动 - 用户系统:
nuxt-auth-utilsOAuth 认证,requireAuth/requireAdmin中间件鉴权 - 搜索:PostgreSQL
to_tsvector全文搜索 +ts_rank相关性排序 + 标签筛选 - Optimistic UI:点赞先乐观更新再确认,失败回滚 + Toast 提示,消除交互延迟感
- 无限滚动:
IntersectionObserver+ 哨兵元素 + 200px 提前量,几乎无感加载 - 视频网格:响应式栅格 + 懒加载图片 + 悬停缩放 + 时长/播放量/时间信息