Skip to content

管理后台 - 前端

安装 Nuxt UI

bash
npm install @nuxt/ui
ts
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxt/ui'],
})

为什么选 Nuxt UI?

Nuxt UI 是 Nuxt 官方团队维护的组件库,与 Nuxt 深度集成(自动导入、暗色模式、响应式)。对于管理后台这类 CRUD 场景,它的 Table、Form、Modal 组件覆盖了绝大部分需求。

管理后台布局

vue
<!-- 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: falseSSR 或混合
功能数据表格、表单、图表展示、交互、支付

通过 definePageMeta({ layout: 'admin' }) 指定管理后台页面使用 admin 布局,与用户端隔离。

管理后台首页

vue
<!-- 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 的执行时机:

text
SSR 模式(普通页面)         CSR 模式(管理后台)
─────────────────          ─────────────────
服务端执行 useFetch         服务端不执行 useFetch
→ 数据注入 HTML             → 页面先渲染骨架/加载状态
→ 客户端直接使用             → 客户端 mount 后执行 useFetch

这意味着管理后台的 useFetch 会在客户端执行

用户可能短暂看到空数据。建议用 v-if="stats" 或骨架屏处理加载状态。

认证中间件

ts
// 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') 在页面刷新后会被重置为 nulluseState 不持久化)。这意味着刷新页面后,即使 Cookie 仍然有效,前端中间件也会认为用户未登录。

解决方案:在 app/plugins/ 中初始化用户状态:

ts
// 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 每次导入都创建新实例。

管理后台登录页

vue
<!-- 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 中间件——否则未登录用户永远无法访问登录页(死循环)。

ts
const tokenCookie = useCookie('auth-token')
tokenCookie.value = token

useCookie 设置的 Cookie 会自动与服务端 setCookie 保持一致。注意:客户端设置的 Cookie 默认没有 httpOnly(JS 可读写),而服务端 setCookie 设置的有 httpOnly(JS 不可读)。

最佳实践:Token 由服务端 setCookie 设置(httpOnly),登录页只需调用登录 API,服务端自动设置 Cookie。客户端 useCookie('auth-token') 只用于读取(判断是否已登录),不需要手动写入。

统计数据 API

ts
// 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),
  }
})

统计查询的缓存

仪表盘数据不需要实时,可以缓存几分钟:

ts
export default defineEventHandler(async (event) => {
  requireAdmin(event)

  return await getCachedOrFetch('admin:stats', async () => {
    // ... 查询逻辑
  }, 300) // 缓存 5 分钟
})

缓存失效

当有新订单或新用户注册时,可以主动删除缓存键 admin:stats,确保下次查询获取最新数据。

coalesce 的作用

ts
sql`coalesce(sum(amount), 0)`

coalesce 返回第一个非 NULL 值。如果 sum(amount) 为 NULL(如没有已支付订单),返回 0 而非 null。避免前端显示 NaN

基于 Nuxt 4 官方文档整理编写