管理后台实现
一个视频平台没有管理后台就等于裸奔——无法审核违规内容、无法管理用户、无法观察系统状态。管理后台虽然不面向终端用户,但它是平台运营的神经中枢。本章实现一个完整的管理后台: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 ROLES2.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 天趋势图表
- 审计日志:所有管理操作可追溯,记录操作人、目标、原因、时间