管理后台 - 前端
安装 Nuxt UI
npm install @nuxt/ui// nuxt.config.ts
export default defineNuxtConfig({
modules: ['@nuxt/ui'],
})为什么选 Nuxt UI?
Nuxt UI 是 Nuxt 官方团队维护的组件库,与 Nuxt 深度集成(自动导入、暗色模式、响应式)。对于管理后台这类 CRUD 场景,它的 Table、Form、Modal 组件覆盖了绝大部分需求。
管理后台布局
<!-- app/layouts/admin.vue -->
<template>
<div class="admin-layout">
<UContainer>
<div class="flex min-h-screen">
<!-- 侧边栏 -->
<aside class="w-64 border-r p-4">
<div class="mb-6">
<h2 class="text-lg font-bold">管理后台</h2>
</div>
<UNavigationMenu :items="menuItems" />
</aside>
<!-- 主内容 -->
<main class="flex-1 p-6">
<slot />
</main>
</div>
</UContainer>
</div>
</template>
<script setup>
const menuItems = [
{ label: '仪表盘', icon: 'i-heroicons-home', to: '/admin' },
{ label: '用户管理', icon: 'i-heroicons-users', to: '/admin/users' },
{ label: '订单管理', icon: 'i-heroicons-shopping-bag', to: '/admin/orders' },
{ label: '套餐管理', icon: 'i-heroicons-credit-card', to: '/admin/plans' },
]
</script>为什么管理后台用单独的布局?
管理后台和用户端是两套完全不同的 UI 结构:
| 对比项 | 管理后台 | 用户端 |
|---|---|---|
| 布局 | 侧边栏 + 主内容区 | 顶部导航 + 内容 |
| 认证 | Cookie 自动携带 | Bearer Token |
| 渲染模式 | CSR(ssr: false) | SSR 或混合 |
| 功能 | 数据表格、表单、图表 | 展示、交互、支付 |
通过 definePageMeta({ layout: 'admin' }) 指定管理后台页面使用 admin 布局,与用户端隔离。
管理后台首页
<!-- app/pages/admin/index.vue -->
<script setup>
definePageMeta({
layout: 'admin',
middleware: 'admin-auth',
})
const { data: stats } = await useFetch('/api/admin/stats')
</script>
<template>
<div>
<h1 class="text-2xl font-bold mb-6">仪表盘</h1>
<div class="grid grid-cols-4 gap-4 mb-8">
<UCard>
<div class="text-sm text-gray-500">总用户数</div>
<div class="text-3xl font-bold">{{ stats?.totalUsers ?? 0 }}</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500">VIP 用户</div>
<div class="text-3xl font-bold text-green-500">{{ stats?.vipUsers ?? 0 }}</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500">今日订单</div>
<div class="text-3xl font-bold text-blue-500">{{ stats?.todayOrders ?? 0 }}</div>
</UCard>
<UCard>
<div class="text-sm text-gray-500">总收入</div>
<div class="text-3xl font-bold text-orange-500">¥{{ stats?.totalRevenue ?? 0 }}</div>
</UCard>
</div>
</div>
</template>useFetch 在 CSR 模式下的行为
由于 routeRules 设置了 '/admin/**': { ssr: false },管理后台页面全部在客户端渲染。useFetch 的执行时机:
SSR 模式(普通页面) CSR 模式(管理后台)
───────────────── ─────────────────
服务端执行 useFetch 服务端不执行 useFetch
→ 数据注入 HTML → 页面先渲染骨架/加载状态
→ 客户端直接使用 → 客户端 mount 后执行 useFetch这意味着管理后台的 useFetch 会在客户端执行
用户可能短暂看到空数据。建议用 v-if="stats" 或骨架屏处理加载状态。
认证中间件
// app/middleware/admin-auth.ts
export default defineNuxtRouteMiddleware((to) => {
const token = useCookie('auth-token')
const user = useState('currentUser')
if (!token.value) {
return navigateTo('/admin/login')
}
if (user.value?.role !== 'admin') {
return abortNavigation(createError({ statusCode: 403, statusMessage: 'Forbidden', message: '需要管理员权限' }))
}
})前端中间件 vs 服务端中间件
| 对比项 | 前端中间件(app/middleware/) | 服务端中间件(server/middleware/) |
|---|---|---|
| 运行位置 | 浏览器 | 服务器 |
| 拦截对象 | 页面导航 | API 请求 |
| 安全性 | ❌ 可被绕过(直接调 API) | ✅ 无法绕过 |
| 作用 | 用户体验(跳转登录页) | 安全保障(拒绝未授权请求) |
两者必须同时存在
前端中间件提供良好的用户体验(未登录跳转登录页),服务端中间件提供安全保障(拒绝未授权 API 请求)。只靠前端中间件是不安全的——攻击者可以跳过前端直接调 API。
认证状态的初始化问题
当前实现有一个问题:useState('currentUser') 在页面刷新后会被重置为 null(useState 不持久化)。这意味着刷新页面后,即使 Cookie 仍然有效,前端中间件也会认为用户未登录。
解决方案:在 app/plugins/ 中初始化用户状态:
// app/plugins/init-auth.ts
export default defineNuxtPlugin(async () => {
const token = useCookie('auth-token')
const currentUser = useState('currentUser', () => null)
// Cookie 存在但用户状态为空,重新获取用户信息
if (token.value && !currentUser.value) {
try {
const user = await $fetch('/api/user/profile')
currentUser.value = user
} catch {
// Token 过期或无效,清除 Cookie
token.value = null
}
}
})为什么用 useState 而不是 ref?
useState 是 Nuxt 提供的跨组件状态共享方案,确保所有组件访问同一个用户状态实例。普通 ref 每次导入都创建新实例。
管理后台登录页
<!-- app/pages/admin/login.vue -->
<script setup>
const state = reactive({ phone: '', password: '' })
const loading = ref(false)
async function handleLogin() {
loading.value = true
try {
const { token, user } = await $fetch('/api/auth/login', {
method: 'POST',
body: state,
})
const tokenCookie = useCookie('auth-token')
tokenCookie.value = token
const currentUser = useState('currentUser')
currentUser.value = user
navigateTo('/admin')
} catch (error) {
// 显示错误
} finally {
loading.value = false
}
}
</script>
<template>
<div class="min-h-screen flex items-center justify-center">
<UCard class="w-96">
<template #header>
<h1 class="text-xl font-bold text-center">管理后台登录</h1>
</template>
<UForm :state="state" @submit="handleLogin">
<UFormGroup label="手机号" name="phone">
<UInput v-model="state.phone" placeholder="请输入手机号" />
</UFormGroup>
<UFormGroup label="密码" name="password" class="mt-4">
<UInput v-model="state.password" type="password" placeholder="请输入密码" />
</UFormGroup>
<UButton type="submit" :loading="loading" block class="mt-6">登录</UButton>
</UForm>
</UCard>
</div>
</template>为什么登录页不用 admin 布局?
登录页不需要侧边栏,应该用默认布局(或无布局)。同时登录页不需要 admin-auth 中间件——否则未登录用户永远无法访问登录页(死循环)。
Cookie 设置注意事项
const tokenCookie = useCookie('auth-token')
tokenCookie.value = tokenuseCookie 设置的 Cookie 会自动与服务端 setCookie 保持一致。注意:客户端设置的 Cookie 默认没有 httpOnly(JS 可读写),而服务端 setCookie 设置的有 httpOnly(JS 不可读)。
最佳实践:Token 由服务端 setCookie 设置(httpOnly),登录页只需调用登录 API,服务端自动设置 Cookie。客户端 useCookie('auth-token') 只用于读取(判断是否已登录),不需要手动写入。
统计数据 API
// server/api/admin/stats.get.ts
export default defineEventHandler(async (event) => {
if (event.context.userRole !== 'admin') {
throw createError({ statusCode: 403 })
}
const today = new Date()
today.setHours(0, 0, 0, 0)
const [totalUsers, vipUsers, todayOrders, revenue] = await Promise.all([
db.select({ count: sql`count(*)` }).from(users),
db.select({ count: sql`count(*)` }).from(users).where(eq(users.role, 'vip')),
db.select({ count: sql`count(*)` }).from(orders).where(gte(orders.createdAt, today)),
db.select({ total: sql`coalesce(sum(amount), 0)` }).from(orders).where(eq(orders.status, 1)),
])
return {
totalUsers: Number(totalUsers[0].count),
vipUsers: Number(vipUsers[0].count),
todayOrders: Number(todayOrders[0].count),
totalRevenue: Number(revenue[0].total),
}
})统计查询的缓存
仪表盘数据不需要实时,可以缓存几分钟:
export default defineEventHandler(async (event) => {
requireAdmin(event)
return await getCachedOrFetch('admin:stats', async () => {
// ... 查询逻辑
}, 300) // 缓存 5 分钟
})缓存失效
当有新订单或新用户注册时,可以主动删除缓存键 admin:stats,确保下次查询获取最新数据。
coalesce 的作用
sql`coalesce(sum(amount), 0)`coalesce 返回第一个非 NULL 值。如果 sum(amount) 为 NULL(如没有已支付订单),返回 0 而非 null。避免前端显示 NaN。