管理后台实现

一个视频平台没有管理后台就等于裸奔——无法审核违规内容、无法管理用户、无法观察系统状态。管理后台虽然不面向终端用户,但它是平台运营的神经中枢。本章实现一个完整的管理后台:RBAC 权限体系、内容审核工作流、用户管理、数据看板,以及如何用 Nuxt4 的 Layers 架构在同一个 Monorepo 中高效构建。

1. 管理后台架构

1.1 独立应用 vs 同一应用

方案优点缺点
独立 Nuxt 应用独立部署、独立权限、不影响用户端代码重复
同一应用 + 路由守卫共享代码打包体积大、权限泄露风险

选择独立应用——管理后台是 apps/admin/,用 SPA 模式(不需要 SEO),通过 Nuxt Layer 复用共享代码。

1.2 管理后台 Nuxt 配置

// apps/admin/nuxt.config.ts
export default defineNuxtConfig({
  extends: ['../../packages/ui'],  // 复用共享 UI 组件
 
  ssr: false,  // 管理后台不需要 SSR
 
  routeRules: {
    '/api/**': { proxy: 'http://localhost:3000/api/**' },  // 开发时代理到用户端 API
  },
 
  app: {
    head: { title: 'AI 视频平台 - 管理后台' },
  },
})

1.3 布局结构

<!-- apps/admin/layouts/admin.vue -->
<script setup>
const { data: session } = useUserSession()
 
const navigation = [
  { label: '仪表盘', icon: 'i-heroicons-chart-bar', to: '/admin' },
  { label: '内容审核', icon: 'i-heroicons-shield-check', to: '/admin/review' },
  { label: '视频管理', icon: 'i-heroicons-film', to: '/admin/videos' },
  { label: '用户管理', icon: 'i-heroicons-users', to: '/admin/users' },
  { label: 'AI 任务', icon: 'i-heroicons-cpu-chip', to: '/admin/tasks' },
  { label: '系统设置', icon: 'i-heroicons-cog-6-tooth', to: '/admin/settings' },
]
</script>
 
<template>
  <div class="flex h-screen">
    <!-- 侧边栏 -->
    <aside class="w-64 bg-gray-900 text-white flex flex-col">
      <div class="p-4 border-b border-gray-800">
        <h1 class="text-lg font-bold">AI Video Admin</h1>
      </div>
 
      <nav class="flex-1 p-2 space-y-1">
        <NuxtLink
          v-for="item in navigation"
          :key="item.to"
          :to="item.to"
          class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-gray-800 transition-colors"
          active-class="bg-gray-800 text-white"
        >
          <UIcon :name="item.icon" />
          <span>{{ item.label }}</span>
        </NuxtLink>
      </nav>
 
      <div class="p-4 border-t border-gray-800">
        <div class="flex items-center gap-3">
          <UAvatar :src="session?.user?.avatar" size="sm" />
          <span class="text-sm">{{ session?.user?.name }}</span>
        </div>
      </div>
    </aside>
 
    <!-- 主内容区 -->
    <main class="flex-1 overflow-auto bg-gray-50">
      <slot />
    </main>
  </div>
</template>

2. RBAC 权限体系

2.1 角色定义

// packages/shared/types/rbac.ts
export const ROLES = {
  admin: {
    label: '超级管理员',
    permissions: ['*'],  // 所有权限
  },
  moderator: {
    label: '内容审核员',
    permissions: [
      'video:review',
      'video:list',
      'video:detail',
      'user:list',
      'user:warn',
      'comment:delete',
    ],
  },
  operator: {
    label: '运营人员',
    permissions: [
      'video:list',
      'video:detail',
      'video:feature',     // 推荐/置顶
      'user:list',
      'analytics:view',
    ],
  },
} as const
 
export type Role = keyof typeof ROLES

2.2 权限检查中间件

// server/utils/rbac.ts
export function requirePermission(permission: string) {
  return async (event: H3Event) => {
    const session = await requireAuth(event)
    const role = session.user.role as Role
 
    const roleConfig = ROLES[role]
    if (!roleConfig) {
      throw createError({ statusCode: 403, message: '无效的角色' })
    }
 
    const hasPermission = roleConfig.permissions.includes('*')
      || roleConfig.permissions.includes(permission)
 
    if (!hasPermission) {
      throw createError({ statusCode: 403, message: `缺少权限: ${permission}` })
    }
 
    return session
  }
}
// server/api/admin/videos/[id]/review.post.ts
export default defineEventHandler(async (event) => {
  const session = await requirePermission('video:review')(event)
  const videoId = getRouterParam(event, 'id')!
  const body = await readValidatedBody(event, z.object({
    action: z.enum(['approve', 'reject']),
    reason: z.string().optional(),
  }).parse)
 
  const db = useDB()
  await db.update(videos).set({
    status: body.action === 'approve' ? 'published' : 'rejected',
  }).where(eq(videos.id, videoId))
 
  // 记录审核日志
  await db.insert(auditLogs).values({
    action: `video:${body.action}`,
    targetType: 'video',
    targetId: videoId,
    operatorId: session.user.id,
    reason: body.reason,
  })
 
  return { success: true }
})

2.3 前端权限控制

// apps/admin/composables/usePermission.ts
export function usePermission() {
  const { data: session } = useUserSession()
 
  function hasPermission(permission: string): boolean {
    const role = session.value?.user?.role as Role
    if (!role || !ROLES[role]) return false
 
    const roleConfig = ROLES[role]
    return roleConfig.permissions.includes('*')
      || roleConfig.permissions.includes(permission)
  }
 
  return { hasPermission }
}
<!-- 在模板中使用 -->
<template>
  <UButton v-if="hasPermission('video:review')" @click="approve">
    通过审核
  </UButton>
</template>

3. 内容审核

3.1 审核队列

<!-- apps/admin/pages/admin/review.vue -->
<script setup>
definePageMeta({ layout: 'admin' })
 
const page = ref(1)
const { data: queue, refresh } = await useFetch('/api/admin/review/queue', {
  query: { page, limit: 20 },
})
 
async function handleReview(videoId: string, action: 'approve' | 'reject', reason?: string) {
  await $fetch(`/api/admin/videos/${videoId}/review`, {
    method: 'POST',
    body: { action, reason },
  })
  refresh()
}
</script>
 
<template>
  <div class="p-6">
    <div class="flex items-center justify-between mb-6">
      <h1 class="text-2xl font-bold">内容审核</h1>
      <UBadge color="orange" size="lg">
        {{ queue?.total || 0 }} 待审核
      </UBadge>
    </div>
 
    <div class="space-y-4">
      <div
        v-for="video in queue?.items"
        :key="video.id"
        class="bg-white rounded-lg shadow p-4 flex gap-4"
      >
        <!-- 视频预览 -->
        <div class="w-64 flex-shrink-0">
          <video
            :src="video.sourceUrl"
            class="w-full rounded aspect-video object-cover"
            controls
            preload="metadata"
          />
        </div>
 
        <!-- 视频信息 -->
        <div class="flex-1">
          <h3 class="font-semibold">{{ video.title || '未命名' }}</h3>
          <p class="text-sm text-gray-500 mt-1 line-clamp-2">{{ video.description }}</p>
 
          <div class="flex items-center gap-4 mt-2 text-sm text-gray-400">
            <span>上传者: {{ video.user?.name }}</span>
            <span>来源: {{ video.sourceType === 'ai_generated' ? 'AI 生成' : '用户上传' }}</span>
            <span>{{ formatDate(video.createdAt) }}</span>
          </div>
 
          <!-- AI 生成信息 -->
          <div v-if="video.aiTask" class="mt-2 p-2 bg-blue-50 rounded text-sm">
            <p>Prompt: {{ video.aiTask.prompt }}</p>
            <p>模型: {{ video.aiTask.model }}</p>
          </div>
        </div>
 
        <!-- 操作 -->
        <div class="flex flex-col gap-2 flex-shrink-0">
          <UButton color="green" @click="handleReview(video.id, 'approve')">
            通过
          </UButton>
          <UButton color="red" variant="outline" @click="openRejectDialog(video.id)">
            拒绝
          </UButton>
        </div>
      </div>
    </div>
 
    <UPagination v-model="page" :total="queue?.total || 0" :page-count="20" class="mt-6" />
  </div>
</template>

3.2 审核 API

// server/api/admin/review/queue.get.ts
export default defineEventHandler(async (event) => {
  await requirePermission('video:review')(event)
 
  const { page = 1, limit = 20 } = getQuery(event)
  const db = useDB()
 
  const [items, [{ count }]] = await Promise.all([
    db.select({
      id: videos.id,
      title: videos.title,
      description: videos.description,
      sourceUrl: videos.sourceUrl,
      sourceType: videos.sourceType,
      createdAt: videos.createdAt,
      user: { name: users.name, avatar: users.avatar },
    })
      .from(videos)
      .leftJoin(users, eq(videos.userId, users.id))
      .where(eq(videos.status, 'reviewing'))
      .orderBy(asc(videos.createdAt))  // 先进先审
      .limit(Number(limit))
      .offset((Number(page) - 1) * Number(limit)),
    db.select({ count: sql<number>`count(*)` })
      .from(videos)
      .where(eq(videos.status, 'reviewing')),
  ])
 
  return { items, total: count }
})

4. 用户管理

4.1 用户列表

<!-- apps/admin/pages/admin/users.vue -->
<script setup>
definePageMeta({ layout: 'admin' })
 
const search = ref('')
const page = ref(1)
 
const { data: userList, refresh } = await useFetch('/api/admin/users', {
  query: { search, page, limit: 20 },
})
 
async function toggleBan(userId: string, banned: boolean) {
  await $fetch(`/api/admin/users/${userId}/ban`, {
    method: 'POST',
    body: { banned },
  })
  refresh()
}
</script>
 
<template>
  <div class="p-6">
    <h1 class="text-2xl font-bold mb-6">用户管理</h1>
 
    <UInput v-model="search" placeholder="搜索用户..." class="max-w-md mb-6" />
 
    <UTable
      :rows="userList?.items || []"
      :columns="[
        { key: 'name', label: '用户' },
        { key: 'email', label: '邮箱' },
        { key: 'role', label: '角色' },
        { key: 'videoCount', label: '视频数' },
        { key: 'createdAt', label: '注册时间' },
        { key: 'actions', label: '操作' },
      ]"
    >
      <template #name-data="{ row }">
        <div class="flex items-center gap-2">
          <UAvatar :src="row.avatar" size="xs" />
          <span>{{ row.name }}</span>
          <UBadge v-if="row.banned" color="red" size="xs">已封禁</UBadge>
        </div>
      </template>
 
      <template #role-data="{ row }">
        <UBadge :color="row.role === 'admin' ? 'purple' : 'gray'">
          {{ ROLES[row.role]?.label || row.role }}
        </UBadge>
      </template>
 
      <template #createdAt-data="{ row }">
        {{ formatDate(row.createdAt) }}
      </template>
 
      <template #actions-data="{ row }">
        <div class="flex gap-2">
          <UButton
            size="xs"
            :color="row.banned ? 'green' : 'red'"
            variant="outline"
            @click="toggleBan(row.id, !row.banned)"
          >
            {{ row.banned ? '解封' : '封禁' }}
          </UButton>
        </div>
      </template>
    </UTable>
 
    <UPagination v-model="page" :total="userList?.total || 0" :page-count="20" class="mt-6" />
  </div>
</template>

4.2 用户管理 API

// server/api/admin/users/index.get.ts
export default defineEventHandler(async (event) => {
  await requirePermission('user:list')(event)
  const { search, page = 1, limit = 20 } = getQuery(event)
  const db = useDB()
 
  const conditions = []
  if (search) {
    conditions.push(
      or(
        ilike(users.name, `%${search}%`),
        ilike(users.email, `%${search}%`),
      )
    )
  }
 
  const where = conditions.length ? and(...conditions) : undefined
 
  const [items, [{ count }]] = await Promise.all([
    db.select({
      id: users.id,
      name: users.name,
      email: users.email,
      avatar: users.avatar,
      role: users.role,
      banned: users.banned,
      createdAt: users.createdAt,
      videoCount: sql<number>`(SELECT count(*) FROM videos WHERE user_id = ${users.id})`,
    })
      .from(users)
      .where(where)
      .orderBy(desc(users.createdAt))
      .limit(Number(limit))
      .offset((Number(page) - 1) * Number(limit)),
    db.select({ count: sql<number>`count(*)` }).from(users).where(where),
  ])
 
  return { items, total: count }
})
 
// server/api/admin/users/[id]/ban.post.ts
export default defineEventHandler(async (event) => {
  const session = await requirePermission('user:ban')(event)
  const userId = getRouterParam(event, 'id')!
  const { banned } = await readValidatedBody(event, z.object({ banned: z.boolean() }).parse)
 
  const db = useDB()
  await db.update(users).set({ banned }).where(eq(users.id, userId))
 
  await db.insert(auditLogs).values({
    action: banned ? 'user:ban' : 'user:unban',
    targetType: 'user',
    targetId: userId,
    operatorId: session.user.id,
  })
 
  return { success: true }
})

5. 数据看板

5.1 看板 API

// server/api/admin/dashboard/stats.get.ts
export default defineEventHandler(async (event) => {
  await requirePermission('analytics:view')(event)
  const db = useDB()
 
  const now = new Date()
  const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
  const thisWeek = new Date(today.getTime() - 7 * 86400000)
 
  const [
    [{ totalUsers }],
    [{ totalVideos }],
    [{ totalViews }],
    [{ todayVideos }],
    [{ pendingReview }],
    [{ runningTasks }],
  ] = await Promise.all([
    db.select({ totalUsers: sql<number>`count(*)` }).from(users),
    db.select({ totalVideos: sql<number>`count(*)` }).from(videos).where(eq(videos.status, 'published')),
    db.select({ totalViews: sql<number>`coalesce(sum(view_count), 0)` }).from(videos),
    db.select({ todayVideos: sql<number>`count(*)` }).from(videos).where(gte(videos.createdAt, today)),
    db.select({ pendingReview: sql<number>`count(*)` }).from(videos).where(eq(videos.status, 'reviewing')),
    db.select({ runningTasks: sql<number>`count(*)` }).from(aiTasks).where(inArray(aiTasks.status, ['pending', 'running'])),
  ])
 
  return {
    totalUsers, totalVideos, totalViews, todayVideos, pendingReview, runningTasks,
  }
})
 
// server/api/admin/dashboard/trends.get.ts
export default defineEventHandler(async (event) => {
  await requirePermission('analytics:view')(event)
  const db = useDB()
 
  // 过去 30 天每日数据
  const result = await db.execute(sql`
    SELECT
      date_trunc('day', created_at)::date as date,
      count(*) as video_count,
      count(DISTINCT user_id) as active_users
    FROM videos
    WHERE created_at >= now() - interval '30 days'
    GROUP BY date
    ORDER BY date ASC
  `)
 
  return result.rows
})

5.2 看板页面

<!-- apps/admin/pages/admin/index.vue -->
<script setup>
definePageMeta({ layout: 'admin' })
 
const { data: stats } = await useFetch('/api/admin/dashboard/stats')
const { data: trends } = await useFetch('/api/admin/dashboard/trends')
 
const statCards = computed(() => [
  { label: '总用户数', value: stats.value?.totalUsers || 0, icon: 'i-heroicons-users', color: 'blue' },
  { label: '发布视频', value: stats.value?.totalVideos || 0, icon: 'i-heroicons-film', color: 'green' },
  { label: '总播放量', value: formatNumber(stats.value?.totalViews || 0), icon: 'i-heroicons-eye', color: 'purple' },
  { label: '今日新增', value: stats.value?.todayVideos || 0, icon: 'i-heroicons-plus-circle', color: 'orange' },
  { label: '待审核', value: stats.value?.pendingReview || 0, icon: 'i-heroicons-clock', color: 'yellow' },
  { label: 'AI 任务中', value: stats.value?.runningTasks || 0, icon: 'i-heroicons-cpu-chip', color: 'cyan' },
])
</script>
 
<template>
  <div class="p-6">
    <h1 class="text-2xl font-bold mb-6">仪表盘</h1>
 
    <!-- 指标卡片 -->
    <div class="grid grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
      <div
        v-for="card in statCards"
        :key="card.label"
        class="bg-white rounded-lg shadow p-4"
      >
        <div class="flex items-center justify-between">
          <div>
            <p class="text-sm text-gray-500">{{ card.label }}</p>
            <p class="text-2xl font-bold mt-1">{{ card.value }}</p>
          </div>
          <div :class="`p-3 rounded-full bg-${card.color}-100`">
            <UIcon :name="card.icon" :class="`text-${card.color}-600 text-xl`" />
          </div>
        </div>
      </div>
    </div>
 
    <!-- 趋势图表 -->
    <div class="bg-white rounded-lg shadow p-6">
      <h2 class="text-lg font-semibold mb-4">30 天趋势</h2>
      <TrendChart :data="trends || []" />
    </div>
  </div>
</template>

5.3 图表组件

<!-- apps/admin/components/TrendChart.client.vue -->
<script setup>
import { Line } from 'vue-chartjs'
import {
  Chart, CategoryScale, LinearScale, PointElement,
  LineElement, Title, Tooltip, Filler,
} from 'chart.js'
 
Chart.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Filler)
 
const { data } = defineProps<{
  data: { date: string; video_count: number; active_users: number }[]
}>()
 
const chartData = computed(() => ({
  labels: data.map((d) => d.date.slice(5)),  // MM-DD
  datasets: [
    {
      label: '新增视频',
      data: data.map((d) => d.video_count),
      borderColor: '#3b82f6',
      backgroundColor: 'rgba(59, 130, 246, 0.1)',
      fill: true,
      tension: 0.3,
    },
    {
      label: '活跃用户',
      data: data.map((d) => d.active_users),
      borderColor: '#10b981',
      backgroundColor: 'rgba(16, 185, 129, 0.1)',
      fill: true,
      tension: 0.3,
    },
  ],
}))
 
const options = {
  responsive: true,
  plugins: { legend: { position: 'top' as const } },
  scales: {
    y: { beginAtZero: true },
  },
}
</script>
 
<template>
  <Line :data="chartData" :options="options" />
</template>

6. 审计日志

6.1 审计日志 Schema

// packages/database/schema/audit-logs.ts
export const auditLogs = pgTable('audit_logs', {
  id: text('id').primaryKey().$defaultFn(() => createId()),
  action: text('action').notNull(),         // 'video:approve', 'user:ban'
  targetType: text('target_type').notNull(), // 'video', 'user'
  targetId: text('target_id').notNull(),
  operatorId: text('operator_id').notNull().references(() => users.id),
  reason: text('reason'),
  metadata: jsonb('metadata'),
  createdAt: timestamp('created_at').defaultNow().notNull(),
})

6.2 审计日志查询

// server/api/admin/audit-logs.get.ts
export default defineEventHandler(async (event) => {
  await requirePermission('audit:view')(event)
  const { page = 1, limit = 50, action, targetType } = getQuery(event)
  const db = useDB()
 
  const conditions = []
  if (action) conditions.push(eq(auditLogs.action, action as string))
  if (targetType) conditions.push(eq(auditLogs.targetType, targetType as string))
 
  const where = conditions.length ? and(...conditions) : undefined
 
  const items = await db.select({
    id: auditLogs.id,
    action: auditLogs.action,
    targetType: auditLogs.targetType,
    targetId: auditLogs.targetId,
    reason: auditLogs.reason,
    createdAt: auditLogs.createdAt,
    operator: { name: users.name, avatar: users.avatar },
  })
    .from(auditLogs)
    .leftJoin(users, eq(auditLogs.operatorId, users.id))
    .where(where)
    .orderBy(desc(auditLogs.createdAt))
    .limit(Number(limit))
    .offset((Number(page) - 1) * Number(limit))
 
  return { items }
})

所有管理操作(审核、封禁、删除、权限变更)都记录到审计日志——这不仅是运营需要,也是合规要求。

本章小结

  • 独立应用:管理后台是 apps/admin/ 独立 Nuxt 应用,SPA 模式,通过 Layer 复用共享 UI
  • RBAC:三角色(admin / moderator / operator)+ 权限列表,requirePermission() 中间件鉴权
  • 内容审核:审核队列按时间排序(先进先审),通过/拒绝操作 + 审核理由
  • 用户管理:搜索 + 分页 + 封禁/解封,所有操作记录审计日志
  • 数据看板:6 项核心指标(用户/视频/播放/新增/待审/任务)+ 30 天趋势图表
  • 审计日志:所有管理操作可追溯,记录操作人、目标、原因、时间