核心功能实现

前面三章搭好了地基——架构、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-utils OAuth 认证,requireAuth/requireAdmin 中间件鉴权
  • 搜索:PostgreSQL to_tsvector 全文搜索 + ts_rank 相关性排序 + 标签筛选
  • Optimistic UI:点赞先乐观更新再确认,失败回滚 + Toast 提示,消除交互延迟感
  • 无限滚动IntersectionObserver + 哨兵元素 + 200px 提前量,几乎无感加载
  • 视频网格:响应式栅格 + 懒加载图片 + 悬停缩放 + 时长/播放量/时间信息