Files
template-nuxt4/app/pages/admin/users.vue
2026-04-29 01:33:33 +08:00

322 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<div class="users-page">
<div class="page-header">
<div>
<h2 class="page-title">👥 用户管理</h2>
<p class="page-desc">管理平台所有注册用户可查看用户信息调整状态</p>
</div>
<a-space>
<a-button :loading="loading" @click="loadUsers">
<template #icon><ReloadOutlined /></template>
刷新
</a-button>
</a-space>
</div>
<!-- 统计卡片 -->
<a-row :gutter="[16, 16]" class="mb-6">
<a-col v-for="stat in stats" :key="stat.label" :md="6" :xs="12">
<div :class="stat.color" class="stat-card">
<div class="stat-icon">{{ stat.icon }}</div>
<div class="stat-info">
<div class="stat-value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
</div>
</div>
</a-col>
</a-row>
<!-- 列表 -->
<div class="panel">
<div class="panel-header">
<span class="panel-title">📋 用户列表</span>
<a-space wrap>
<a-select v-model:value="filterStatus" style="width: 120px" @change="handleSearch">
<a-select-option :value="undefined">全部状态</a-select-option>
<a-select-option :value="0">正常</a-select-option>
<a-select-option :value="1">已冻结</a-select-option>
</a-select>
<a-input-search
v-model:value="searchKeyword"
placeholder="搜索用户名/手机/邮箱"
style="width: 220px"
@search="handleSearch"
/>
</a-space>
</div>
<a-table
:columns="columns"
:data-source="users"
:loading="loading"
:pagination="pagination"
row-key="userId"
size="middle"
@change="handleTableChange"
>
<template #bodyCell="{ column, record }">
<!-- 用户信息 -->
<template v-if="column.key === 'userInfo'">
<div class="user-info-cell">
<a-avatar :size="38" :src="record.avatar || record.avatarUrl">
<template #icon><UserOutlined /></template>
</a-avatar>
<div class="user-info-text">
<div class="user-name">
{{ record.nickname || record.username }}
<a-tag v-if="record.isAdmin" color="red" style="margin-left:6px;font-size:10px">管理员</a-tag>
</div>
<div class="user-sub">@{{ record.username }}</div>
</div>
</div>
</template>
<!-- 联系方式 -->
<template v-if="column.key === 'contact'">
<div style="font-size:13px">
<div v-if="record.phone || record.mobile">📱 {{ record.phone || record.mobile }}</div>
<div v-if="record.email" style="color:rgba(0,0,0,0.45);font-size:12px">{{ record.email }}</div>
<span v-if="!record.phone && !record.mobile && !record.email" class="text-gray-400">-</span>
</div>
</template>
<!-- 状态 -->
<template v-if="column.key === 'status'">
<a-badge :status="record.status === 0 ? 'success' : 'error'" :text="record.status === 0 ? '正常' : '已冻结'" />
</template>
<!-- 余额/积分 -->
<template v-if="column.key === 'balance'">
<div style="font-size:13px">
<div v-if="record.balance !== undefined" style="color:#059669">💰 ¥{{ (record.balance / 100).toFixed(2) }}</div>
<div v-if="record.points !== undefined" style="color:rgba(0,0,0,0.45);font-size:12px">🏆 {{ record.points }} 积分</div>
</div>
</template>
<!-- 注册时间 -->
<template v-if="column.key === 'createTime'">
<span class="text-sm text-gray">{{ record.createTime?.substring(0, 10) || '-' }}</span>
</template>
<!-- 操作 -->
<template v-if="column.key === 'action'">
<a-space>
<a-button size="small" type="link" @click="handleView(record)">详情</a-button>
<a-popconfirm
:title="record.status === 0 ? '确认冻结此用户账号?' : '确认解冻此用户账号?'"
@confirm="handleToggleStatus(record)"
>
<a-button :danger="record.status === 0" size="small" type="link">
{{ record.status === 0 ? '冻结' : '解冻' }}
</a-button>
</a-popconfirm>
<a-popconfirm title="确认重置密码为 123456" @confirm="handleResetPassword(record)">
<a-button size="small" type="link">重置密码</a-button>
</a-popconfirm>
</a-space>
</template>
</template>
</a-table>
</div>
<!-- 详情弹窗 -->
<a-modal
v-model:open="showDetailModal"
:footer="null"
:title="`用户详情:${currentUser?.nickname || currentUser?.username || ''}`"
width="680px"
>
<template v-if="currentUser">
<div class="user-detail-header">
<a-avatar :size="64" :src="currentUser.avatar || currentUser.avatarUrl">
<template #icon><UserOutlined /></template>
</a-avatar>
<div>
<div class="detail-name">{{ currentUser.nickname || currentUser.username }}</div>
<div class="detail-sub">@{{ currentUser.username }}</div>
<a-space style="margin-top:8px">
<a-tag v-if="currentUser.isAdmin" color="red">管理员</a-tag>
<a-badge :status="currentUser.status === 0 ? 'success' : 'error'" :text="currentUser.status === 0 ? '账号正常' : '已冻结'" />
</a-space>
</div>
</div>
<a-divider />
<a-descriptions :column="2" size="small">
<a-descriptions-item label="用户ID">{{ currentUser.userId }}</a-descriptions-item>
<a-descriptions-item label="手机号">{{ currentUser.phone || currentUser.mobile || '-' }}</a-descriptions-item>
<a-descriptions-item label="邮箱">{{ currentUser.email || '-' }}</a-descriptions-item>
<a-descriptions-item label="性别">{{ currentUser.sex === '1' ? '男' : currentUser.sex === '2' ? '女' : '-' }}</a-descriptions-item>
<a-descriptions-item label="余额">
<span style="color:#059669">¥{{ ((currentUser.balance || 0) / 100).toFixed(2) }}</span>
</a-descriptions-item>
<a-descriptions-item label="积分">{{ currentUser.points ?? '-' }}</a-descriptions-item>
<a-descriptions-item :span="2" label="注册时间">{{ currentUser.createTime || '-' }}</a-descriptions-item>
<a-descriptions-item v-if="currentUser.address" :span="2" label="地址">
{{ [currentUser.province, currentUser.city, currentUser.region, currentUser.address].filter(Boolean).join(' ') }}
</a-descriptions-item>
</a-descriptions>
</template>
</a-modal>
</div>
</template>
<script lang="ts" setup>
import { ReloadOutlined, UserOutlined } from '@ant-design/icons-vue'
import { message } from 'ant-design-vue'
import { pageUsers, updateUserStatus, updateUserPassword } from '@/api/system/user/index'
import type { User } from '@/api/system/user/model'
definePageMeta({ layout: 'admin' })
useHead({ title: '用户管理 - 平台管理' })
const loading = ref(false)
const users = ref<User[]>([])
const filterStatus = ref<number | undefined>(undefined)
const searchKeyword = ref('')
const pagination = reactive({
current: 1,
pageSize: 20,
total: 0,
showSizeChanger: true,
showQuickJumper: true,
})
const stats = reactive([
{ icon: '👥', label: '总用户数', value: 0, color: 'blue' },
{ icon: '✅', label: '正常用户', value: 0, color: 'green' },
{ icon: '🔒', label: '冻结用户', value: 0, color: 'red' },
{ icon: '🛡️', label: '管理员', value: 0, color: 'orange' },
])
const columns = [
{ title: '用户信息', key: 'userInfo', width: 220 },
{ title: '联系方式', key: 'contact', width: 180 },
{ title: '账号状态', key: 'status', width: 110 },
{ title: '余额/积分', key: 'balance', width: 140 },
{ title: '注册时间', key: 'createTime', width: 110 },
{ title: '操作', key: 'action', width: 220 },
]
const showDetailModal = ref(false)
const currentUser = ref<User | null>(null)
async function loadUsers() {
loading.value = true
try {
const res = await pageUsers({
page: pagination.current,
limit: pagination.pageSize,
status: filterStatus.value,
keywords: searchKeyword.value || undefined,
})
users.value = res?.list || []
pagination.total = res?.count || 0
loadStats()
} catch {
message.error('加载用户列表失败')
} finally {
loading.value = false
}
}
async function loadStats() {
try {
const [allRes, normalRes, frozenRes, adminRes] = await Promise.allSettled([
pageUsers({ page: 1, limit: 1 }),
pageUsers({ page: 1, limit: 1, status: 0 }),
pageUsers({ page: 1, limit: 1, status: 1 }),
pageUsers({ page: 1, limit: 1, isAdmin: 1 }),
])
if (allRes.status === 'fulfilled') stats[0].value = allRes.value?.count || 0
if (normalRes.status === 'fulfilled') stats[1].value = normalRes.value?.count || 0
if (frozenRes.status === 'fulfilled') stats[2].value = frozenRes.value?.count || 0
if (adminRes.status === 'fulfilled') stats[3].value = adminRes.value?.count || 0
} catch { /* ignore */ }
}
function handleSearch() {
pagination.current = 1
loadUsers()
}
function handleTableChange(pag: any) {
pagination.current = pag.current
pagination.pageSize = pag.pageSize
loadUsers()
}
function handleView(record: User) {
currentUser.value = record
showDetailModal.value = true
}
async function handleToggleStatus(record: User) {
const newStatus = record.status === 0 ? 1 : 0
try {
await updateUserStatus(record.userId, newStatus)
message.success(newStatus === 1 ? '用户已冻结' : '用户已解冻')
loadUsers()
} catch (e: any) {
message.error(e?.message || '操作失败')
}
}
async function handleResetPassword(record: User) {
try {
await updateUserPassword(record.userId, '123456')
message.success(`已重置「${record.nickname || record.username}」的密码为 123456`)
} catch (e: any) {
message.error(e?.message || '重置失败')
}
}
onMounted(() => loadUsers())
</script>
<style scoped>
.users-page { min-height: 100%; }
.page-header {
display: flex; align-items: center;
justify-content: space-between; margin-bottom: 20px;
}
.page-title { font-size: 18px; font-weight: 700; color: #1f2937; margin: 0; }
.page-desc { font-size: 13px; color: #9ca3af; margin: 2px 0 0; }
.stat-card {
display: flex; align-items: center;
gap: 12px; padding: 16px;
border-radius: 12px; border: 2px solid transparent; transition: all 0.2s;
}
.stat-card:hover { transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.stat-card.blue { background: #eff6ff; border-color: #dbeafe; }
.stat-card.green { background: #f0fdf4; border-color: #bbf7d0; }
.stat-card.red { background: #fff1f2; border-color: #fecdd3; }
.stat-card.orange { background: #fff7ed; border-color: #fed7aa; }
.stat-icon { font-size: 28px; flex-shrink: 0; }
.stat-value { font-size: 22px; font-weight: 700; color: rgba(0,0,0,0.85); line-height: 1.2; }
.stat-label { font-size: 12px; color: rgba(0,0,0,0.45); margin-top: 2px; }
.panel { background: #fff; border: 1px solid #f0f0f0; border-radius: 12px; overflow: hidden; }
.panel-header {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid #f5f5f5; flex-wrap: wrap; gap: 10px;
}
.panel-title { font-size: 14px; font-weight: 600; color: rgba(0,0,0,0.85); }
.user-info-cell { display: flex; align-items: center; gap: 10px; }
.user-info-text { flex: 1; min-width: 0; }
.user-name { font-size: 14px; font-weight: 500; color: rgba(0,0,0,0.85); display: flex; align-items: center; }
.user-sub { font-size: 12px; color: rgba(0,0,0,0.45); }
.user-detail-header { display: flex; align-items: center; gap: 20px; margin-bottom: 4px; }
.detail-name { font-size: 18px; font-weight: 700; color: #1f2937; }
.detail-sub { font-size: 13px; color: rgba(0,0,0,0.45); margin-top: 2px; }
.text-sm { font-size: 12px; }
.text-gray { color: rgba(0,0,0,0.45); }
.text-gray-400 { color: #9ca3af; }
.mb-6 { margin-bottom: 24px; }
</style>