初始版本
This commit is contained in:
321
app/pages/admin/users.vue
Normal file
321
app/pages/admin/users.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<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 @click="loadUsers" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
|
||||
<!-- 统计卡片 -->
|
||||
<a-row :gutter="[16, 16]" class="mb-6">
|
||||
<a-col :xs="12" :md="6" v-for="stat in stats" :key="stat.label">
|
||||
<div class="stat-card" :class="stat.color">
|
||||
<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"
|
||||
@change="handleTableChange"
|
||||
size="middle"
|
||||
>
|
||||
<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 type="link" size="small" @click="handleView(record)">详情</a-button>
|
||||
<a-popconfirm
|
||||
:title="record.status === 0 ? '确认冻结此用户账号?' : '确认解冻此用户账号?'"
|
||||
@confirm="handleToggleStatus(record)"
|
||||
>
|
||||
<a-button type="link" size="small" :danger="record.status === 0">
|
||||
{{ record.status === 0 ? '冻结' : '解冻' }}
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
<a-popconfirm title="确认重置密码为 123456?" @confirm="handleResetPassword(record)">
|
||||
<a-button type="link" size="small">重置密码</a-button>
|
||||
</a-popconfirm>
|
||||
</a-space>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
|
||||
<!-- 详情弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showDetailModal"
|
||||
:title="`用户详情:${currentUser?.nickname || currentUser?.username || ''}`"
|
||||
width="680px"
|
||||
:footer="null"
|
||||
>
|
||||
<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 label="注册时间" :span="2">{{ currentUser.createTime || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item v-if="currentUser.address" label="地址" :span="2">
|
||||
{{ [currentUser.province, currentUser.city, currentUser.region, currentUser.address].filter(Boolean).join(' ') }}
|
||||
</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</template>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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>
|
||||
Reference in New Issue
Block a user