管理后台 - 用户管理
用户列表页
<!-- 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>useFetch 的 query 参数响应式
const { data, refresh } = await useFetch('/api/admin/users', {
query: { page, pageSize, keyword },
})当 page、pageSize、keyword 任一变化时,useFetch 会自动重新请求。这是 Nuxt useFetch 的响应式特性——query 中的 ref 变化会触发重新获取。
搜索防抖
keyword 变化会立即触发请求,用户快速输入时会发送大量请求。建议用 watchDebounced 或 useDebounceFn:
const debouncedKeyword = ref('')
watch(keyword, useDebounceFn((val) => {
debouncedKeyword.value = val
}, 300))
const { data } = await useFetch('/api/admin/users', {
query: { page, pageSize, keyword: debouncedKeyword },
})操作确认
if (!confirm(`确定${row.status === 1 ? '禁用' : '启用'}用户 ${row.nickname}?`)) return为什么操作前要确认?
禁用用户是敏感操作,误操作会影响用户使用。confirm 是最简单的防护。生产环境建议用 Modal 组件替代 confirm,可以显示更详细的信息(如"禁用后该用户将无法登录")。
错误处理
try {
await $fetch(...)
refresh()
} catch (error: any) {
alert(error.data?.message || '操作失败')
}$fetch 抛出的错误中 error.data 包含服务端返回的响应体(如 { statusCode: 403, statusMessage: 'Forbidden', message: '需要管理员权限' })。
为什么用 $fetch 而不是 useFetch?
$fetch 适合一次性操作(如提交表单、删除记录),不需要响应式状态。useFetch 适合需要持续跟踪的数据获取(如列表查询)。
用户详情页
<!-- 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。需要添加:
// 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" 的作用
<div v-if="user">useFetch 在数据加载完成前 user 为 null。v-if 确保数据加载后才渲染详情区域,避免模板中 user.id 等访问报错。
也可以用 v-if="!user" 显示加载状态
<div v-if="!user" class="text-center py-8">加载中...</div>
<div v-else>
<!-- 用户详情 -->
</div>常见问题
列表页切换分页后关键词搜索不生效?
keyword 变化时会自动重新请求,但如果同时修改了 page,可能出现竞态条件。建议搜索时重置分页:
watch(keyword, () => {
page.value = 1 // 搜索时回到第一页
})用户列表返回了 passwordHash?
确保 API 的 select 显式指定字段,不要用 db.select().from(users)。参考"用户管理模块"章节的安全说明。