Skip to content

管理后台 - 用户管理

用户列表页

vue
<!-- app/pages/admin/users/index.vue -->
<script setup>
definePageMeta({
  layout: 'admin',
  middleware: 'admin-auth',
})

const page = ref(1)
const pageSize = ref(20)
const keyword = ref('')

const { data, refresh } = await useFetch('/api/admin/users', {
  query: { page, pageSize, keyword },
})

const columns = [
  { key: 'id', label: 'ID' },
  { key: 'phone', label: '手机号' },
  { key: 'nickname', label: '昵称' },
  { key: 'role', label: '角色' },
  { key: 'status', label: '状态' },
  { key: 'vipExpiresAt', label: 'VIP过期时间' },
  { key: 'createdAt', label: '注册时间' },
  { key: 'actions', label: '操作' },
]

async function toggleStatus(row: any) {
  if (!confirm(`确定${row.status === 1 ? '禁用' : '启用'}用户 ${row.nickname}?`)) return

  try {
    await $fetch(`/api/admin/users/${row.id}`, {
      method: 'PUT',
      body: { status: row.status === 1 ? 0 : 1 },
    })
    refresh()
  } catch (error: any) {
    alert(error.data?.message || '操作失败')
  }
}
</script>

<template>
  <div>
    <div class="flex justify-between items-center mb-4">
      <h2 class="text-xl font-bold">用户管理</h2>
      <UInput v-model="keyword" placeholder="搜索用户..." icon="i-heroicons-magnifying-glass" />
    </div>

    <UTable :rows="data?.list" :columns="columns">
      <template #role-data="{ row }">
        <UBadge :color="row.role === 'admin' ? 'red' : row.role === 'vip' ? 'yellow' : 'gray'">
          {{ row.role }}
        </UBadge>
      </template>

      <template #status-data="{ row }">
        <UBadge :color="row.status === 1 ? 'green' : 'red'">
          {{ row.status === 1 ? '正常' : '禁用' }}
        </UBadge>
      </template>

      <template #actions-data="{ row }">
        <UButton size="xs" @click="toggleStatus(row)">
          {{ row.status === 1 ? '禁用' : '启用' }}
        </UButton>
      </template>
    </UTable>

    <div class="flex justify-end mt-4">
      <UPagination v-model="page" :total="data?.total ?? 0" :page-size="pageSize" />
    </div>
  </div>
</template>

useFetchquery 参数响应式

ts
const { data, refresh } = await useFetch('/api/admin/users', {
  query: { page, pageSize, keyword },
})

pagepageSizekeyword 任一变化时,useFetch 会自动重新请求。这是 Nuxt useFetch 的响应式特性——query 中的 ref 变化会触发重新获取。

搜索防抖

keyword 变化会立即触发请求,用户快速输入时会发送大量请求。建议用 watchDebounceduseDebounceFn

ts
const debouncedKeyword = ref('')
watch(keyword, useDebounceFn((val) => {
  debouncedKeyword.value = val
}, 300))

const { data } = await useFetch('/api/admin/users', {
  query: { page, pageSize, keyword: debouncedKeyword },
})

操作确认

ts
if (!confirm(`确定${row.status === 1 ? '禁用' : '启用'}用户 ${row.nickname}?`)) return

为什么操作前要确认?

禁用用户是敏感操作,误操作会影响用户使用。confirm 是最简单的防护。生产环境建议用 Modal 组件替代 confirm,可以显示更详细的信息(如"禁用后该用户将无法登录")。

错误处理

ts
try {
  await $fetch(...)
  refresh()
} catch (error: any) {
  alert(error.data?.message || '操作失败')
}

$fetch 抛出的错误中 error.data 包含服务端返回的响应体(如 { statusCode: 403, statusMessage: 'Forbidden', message: '需要管理员权限' })。

为什么用 $fetch 而不是 useFetch

$fetch 适合一次性操作(如提交表单、删除记录),不需要响应式状态。useFetch 适合需要持续跟踪的数据获取(如列表查询)。

用户详情页

vue
<!-- app/pages/admin/users/[id].vue -->
<script setup>
definePageMeta({
  layout: 'admin',
  middleware: 'admin-auth',
})

const route = useRoute()
const id = route.params.id

const { data: user, refresh } = await useFetch(`/api/admin/users/${id}`)

async function updateRole(role: string) {
  if (!confirm(`确定将用户角色修改为 ${role}?`)) return

  try {
    await $fetch(`/api/admin/users/${id}`, {
      method: 'PUT',
      body: { role },
    })
    refresh()
  } catch (error: any) {
    alert(error.data?.message || '操作失败')
  }
}

async function toggleStatus() {
  const action = user.value?.status === 1 ? '禁用' : '启用'
  if (!confirm(`确定${action}该用户?`)) return

  try {
    await $fetch(`/api/admin/users/${id}`, {
      method: 'PUT',
      body: { status: user.value?.status === 1 ? 0 : 1 },
    })
    refresh()
  } catch (error: any) {
    alert(error.data?.message || '操作失败')
  }
}
</script>

<template>
  <div v-if="user">
    <h2 class="text-xl font-bold mb-4">用户详情</h2>

    <UCard>
      <div class="grid grid-cols-2 gap-4">
        <div><span class="text-gray-500">ID:</span> {{ user.id }}</div>
        <div><span class="text-gray-500">手机号:</span> {{ user.phone }}</div>
        <div><span class="text-gray-500">昵称:</span> {{ user.nickname }}</div>
        <div><span class="text-gray-500">角色:</span> {{ user.role }}</div>
        <div><span class="text-gray-500">状态:</span> {{ user.status === 1 ? '正常' : '禁用' }}</div>
        <div><span class="text-gray-500">VIP过期:</span> {{ user.vipExpiresAt ?? '未开通' }}</div>
      </div>

      <div class="flex gap-2 mt-4">
        <UButton @click="updateRole('vip')" size="sm">设为VIP</UButton>
        <UButton @click="updateRole('user')" size="sm" color="gray">取消VIP</UButton>
        <UButton @click="toggleStatus" size="sm" :color="user.status === 1 ? 'red' : 'green'">
          {{ user.status === 1 ? '禁用' : '启用' }}
        </UButton>
      </div>
    </UCard>
  </div>
</template>

用户详情 API(需要补充)

当前项目缺少获取单个用户详情的管理 API。需要添加:

ts
// server/api/admin/users/[id].get.ts
export default defineEventHandler(async (event) => {
  requireAdmin(event)

  const id = Number(getRouterParam(event, 'id'))
  if (!id) throw createError({ statusCode: 400, statusMessage: 'Bad Request', message: '无效的用户 ID' })

  const [user] = await db.select({
    id: users.id,
    phone: users.phone,
    email: users.email,
    nickname: users.nickname,
    avatar: users.avatar,
    role: users.role,
    status: users.status,
    vipExpiresAt: users.vipExpiresAt,
    createdAt: users.createdAt,
    updatedAt: users.updatedAt,
  }).from(users).where(eq(users.id, id)).limit(1)

  if (!user) throw createError({ statusCode: 404, statusMessage: 'Not Found', message: '用户不存在' })

  return user
})

注意 select 不包含 passwordHash

管理后台查看用户详情也不应返回密码哈希。

v-if="user" 的作用

vue
<div v-if="user">

useFetch 在数据加载完成前 usernullv-if 确保数据加载后才渲染详情区域,避免模板中 user.id 等访问报错。

也可以用 v-if="!user" 显示加载状态

vue
<div v-if="!user" class="text-center py-8">加载中...</div>
<div v-else>
  <!-- 用户详情 -->
</div>

常见问题

列表页切换分页后关键词搜索不生效?

keyword 变化时会自动重新请求,但如果同时修改了 page,可能出现竞态条件。建议搜索时重置分页:

ts
watch(keyword, () => {
  page.value = 1  // 搜索时回到第一页
})

用户列表返回了 passwordHash

确保 API 的 select 显式指定字段,不要用 db.select().from(users)。参考"用户管理模块"章节的安全说明。

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