606 lines
19 KiB
Vue
606 lines
19 KiB
Vue
<template>
|
|
<div class="users-container">
|
|
<!-- Search and Filter Bar -->
|
|
<el-card class="filter-card">
|
|
<el-form :inline="true" :model="filters" class="filter-form">
|
|
<el-form-item label="搜索">
|
|
<el-input
|
|
v-model="filters.search"
|
|
placeholder="昵称/姓名/手机/微信号"
|
|
clearable
|
|
@keyup.enter="handleSearch"
|
|
style="width: 200px"
|
|
/>
|
|
</el-form-item>
|
|
<el-form-item label="状态">
|
|
<el-select v-model="filters.status" placeholder="全部" clearable style="width: 120px">
|
|
<el-option label="正常" value="active" />
|
|
<el-option label="已禁用" value="suspended" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item label="语言">
|
|
<el-select v-model="filters.language" placeholder="全部" clearable style="width: 120px">
|
|
<el-option label="中文" value="zh" />
|
|
<el-option label="English" value="en" />
|
|
<el-option label="Español" value="es" />
|
|
</el-select>
|
|
</el-form-item>
|
|
<el-form-item>
|
|
<el-button type="primary" @click="handleSearch">
|
|
<el-icon><Search /></el-icon>
|
|
搜索
|
|
</el-button>
|
|
<el-button @click="handleReset">
|
|
<el-icon><Refresh /></el-icon>
|
|
重置
|
|
</el-button>
|
|
<el-button type="success" @click="handleExport" :loading="exporting">
|
|
<el-icon><Download /></el-icon>
|
|
导出CSV
|
|
</el-button>
|
|
</el-form-item>
|
|
</el-form>
|
|
</el-card>
|
|
|
|
<!-- User Table -->
|
|
<el-card class="table-card">
|
|
<el-table
|
|
v-loading="loading"
|
|
:data="users"
|
|
stripe
|
|
border
|
|
style="width: 100%"
|
|
@sort-change="handleSortChange"
|
|
>
|
|
<el-table-column prop="avatar" label="头像" width="80" align="center">
|
|
<template #default="{ row }">
|
|
<el-avatar :size="40" :src="row.avatar" icon="UserFilled" />
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="uid" label="UID" width="100" align="center">
|
|
<template #default="{ row }">
|
|
{{ row.uid || '-' }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="nickname" label="昵称" min-width="120" show-overflow-tooltip />
|
|
<el-table-column prop="realName" label="真实姓名" min-width="100" show-overflow-tooltip>
|
|
<template #default="{ row }">
|
|
{{ row.realName || '-' }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="phone" label="手机号" min-width="120">
|
|
<template #default="{ row }">
|
|
{{ row.phone || '-' }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="wechatId" label="微信号" min-width="120">
|
|
<template #default="{ row }">
|
|
{{ row.wechatId || '-' }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="language" label="语言" width="100" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag :type="getLanguageTagType(row.language)" size="small">
|
|
{{ getLanguageLabel(row.language) }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="balance" label="余额" width="100" align="right" sortable="custom">
|
|
<template #default="{ row }">
|
|
¥{{ parseFloat(row.balance || 0).toFixed(2) }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="status" label="状态" width="100" align="center">
|
|
<template #default="{ row }">
|
|
<el-tag :type="row.status === 'active' ? 'success' : 'danger'" size="small">
|
|
{{ row.status === 'active' ? '正常' : '已禁用' }}
|
|
</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="createdAt" label="注册时间" width="180" sortable="custom">
|
|
<template #default="{ row }">
|
|
{{ formatDate(row.createdAt) }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="180" fixed="right" align="center">
|
|
<template #default="{ row }">
|
|
<el-button type="primary" link size="small" @click="handleViewDetails(row)">
|
|
<el-icon><View /></el-icon>
|
|
详情
|
|
</el-button>
|
|
<el-button
|
|
:type="row.status === 'active' ? 'danger' : 'success'"
|
|
link
|
|
size="small"
|
|
@click="handleToggleStatus(row)"
|
|
>
|
|
<el-icon><component :is="row.status === 'active' ? 'Lock' : 'Unlock'" /></el-icon>
|
|
{{ row.status === 'active' ? '禁用' : '启用' }}
|
|
</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
|
|
<!-- Pagination -->
|
|
<div class="pagination-container">
|
|
<el-pagination
|
|
v-model:current-page="pagination.page"
|
|
v-model:page-size="pagination.limit"
|
|
:page-sizes="[10, 20, 50, 100]"
|
|
:total="pagination.total"
|
|
layout="total, sizes, prev, pager, next, jumper"
|
|
@size-change="handleSizeChange"
|
|
@current-change="handlePageChange"
|
|
/>
|
|
</div>
|
|
</el-card>
|
|
|
|
<!-- User Details Dialog -->
|
|
<el-dialog
|
|
v-model="detailsDialogVisible"
|
|
title="用户详情"
|
|
width="800px"
|
|
destroy-on-close
|
|
>
|
|
<div v-loading="detailsLoading" class="user-details">
|
|
<template v-if="userDetails">
|
|
<!-- Basic Info -->
|
|
<el-descriptions title="基本信息" :column="2" border>
|
|
<el-descriptions-item label="头像">
|
|
<el-avatar :size="60" :src="userDetails.user.avatar" icon="UserFilled" />
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="昵称">{{ userDetails.user.nickname || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="UID">{{ userDetails.user.uid || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="真实姓名">{{ userDetails.user.realName || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="手机号">{{ userDetails.user.phone || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="WhatsApp">{{ userDetails.user.whatsapp || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="微信号">{{ userDetails.user.wechatId || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="语言偏好">
|
|
<el-tag :type="getLanguageTagType(userDetails.user.language)" size="small">
|
|
{{ getLanguageLabel(userDetails.user.language) }}
|
|
</el-tag>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="账户状态">
|
|
<el-tag :type="userDetails.user.status === 'active' ? 'success' : 'danger'" size="small">
|
|
{{ userDetails.user.status === 'active' ? '正常' : '已禁用' }}
|
|
</el-tag>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="账户余额">
|
|
<span class="balance">¥{{ parseFloat(userDetails.user.balance || 0).toFixed(2) }}</span>
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="邀请码">{{ userDetails.user.invitationCode || '-' }}</el-descriptions-item>
|
|
<el-descriptions-item label="邀请人">
|
|
{{ userDetails.inviter ? (userDetails.inviter.nickname || userDetails.inviter.realName) : '-' }}
|
|
</el-descriptions-item>
|
|
<el-descriptions-item label="注册时间">{{ formatDate(userDetails.user.createdAt) }}</el-descriptions-item>
|
|
</el-descriptions>
|
|
|
|
<!-- Statistics Cards -->
|
|
<div class="stats-section">
|
|
<el-row :gutter="20">
|
|
<!-- Appointment Stats -->
|
|
<el-col :span="8">
|
|
<el-card shadow="hover" class="stats-card">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<el-icon><Document /></el-icon>
|
|
<span>预约统计</span>
|
|
</div>
|
|
</template>
|
|
<div class="stats-content">
|
|
<div class="stat-item">
|
|
<span class="label">总预约</span>
|
|
<span class="value">{{ userDetails.appointments.total }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="label">待处理</span>
|
|
<span class="value warning">{{ userDetails.appointments.pending }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="label">已完成</span>
|
|
<span class="value success">{{ userDetails.appointments.completed }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="label">已取消</span>
|
|
<span class="value danger">{{ userDetails.appointments.cancelled }}</span>
|
|
</div>
|
|
</div>
|
|
</el-card>
|
|
</el-col>
|
|
|
|
<!-- Invitation Stats -->
|
|
<el-col :span="8">
|
|
<el-card shadow="hover" class="stats-card">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<el-icon><UserFilled /></el-icon>
|
|
<span>邀请统计</span>
|
|
</div>
|
|
</template>
|
|
<div class="stats-content">
|
|
<div class="stat-item">
|
|
<span class="label">邀请人数</span>
|
|
<span class="value">{{ userDetails.invitations.totalInvites }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="label">累计奖励</span>
|
|
<span class="value primary">¥{{ parseFloat(userDetails.invitations.totalRewards || 0).toFixed(2) }}</span>
|
|
</div>
|
|
</div>
|
|
</el-card>
|
|
</el-col>
|
|
|
|
<!-- Withdrawal Stats -->
|
|
<el-col :span="8">
|
|
<el-card shadow="hover" class="stats-card">
|
|
<template #header>
|
|
<div class="card-header">
|
|
<el-icon><Wallet /></el-icon>
|
|
<span>提现统计</span>
|
|
</div>
|
|
</template>
|
|
<div class="stats-content">
|
|
<div class="stat-item">
|
|
<span class="label">提现次数</span>
|
|
<span class="value">{{ userDetails.withdrawals.total }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="label">提现金额</span>
|
|
<span class="value primary">¥{{ parseFloat(userDetails.withdrawals.totalAmount || 0).toFixed(2) }}</span>
|
|
</div>
|
|
<div class="stat-item">
|
|
<span class="label">待审核</span>
|
|
<span class="value warning">{{ userDetails.withdrawals.waiting }}</span>
|
|
</div>
|
|
</div>
|
|
</el-card>
|
|
</el-col>
|
|
</el-row>
|
|
</div>
|
|
|
|
<!-- Login History -->
|
|
<div class="login-history-section">
|
|
<h4>最近登录记录</h4>
|
|
<el-table :data="userDetails.loginHistory" stripe border size="small" max-height="250">
|
|
<el-table-column prop="loginAt" label="登录时间" width="180">
|
|
<template #default="{ row }">
|
|
{{ formatDate(row.loginAt) }}
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="ipAddress" label="IP地址" width="140" />
|
|
<el-table-column prop="deviceInfo" label="设备信息" show-overflow-tooltip>
|
|
<template #default="{ row }">
|
|
{{ row.deviceInfo || row.userAgent || '-' }}
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
<template #footer>
|
|
<el-button @click="detailsDialogVisible = false">关闭</el-button>
|
|
</template>
|
|
</el-dialog>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, reactive, onMounted } from 'vue'
|
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
|
import api from '@/utils/api'
|
|
|
|
// State
|
|
const loading = ref(false)
|
|
const exporting = ref(false)
|
|
const users = ref([])
|
|
const pagination = reactive({
|
|
page: 1,
|
|
limit: 20,
|
|
total: 0,
|
|
totalPages: 0
|
|
})
|
|
const filters = reactive({
|
|
search: '',
|
|
status: '',
|
|
language: ''
|
|
})
|
|
const sortConfig = reactive({
|
|
sortBy: 'createdAt',
|
|
sortOrder: 'DESC'
|
|
})
|
|
|
|
// Details dialog state
|
|
const detailsDialogVisible = ref(false)
|
|
const detailsLoading = ref(false)
|
|
const userDetails = ref(null)
|
|
|
|
// Fetch user list
|
|
async function fetchUsers() {
|
|
loading.value = true
|
|
try {
|
|
const params = {
|
|
page: pagination.page,
|
|
limit: pagination.limit,
|
|
...filters,
|
|
...sortConfig
|
|
}
|
|
// Remove empty params
|
|
Object.keys(params).forEach(key => {
|
|
if (params[key] === '' || params[key] === null || params[key] === undefined) {
|
|
delete params[key]
|
|
}
|
|
})
|
|
|
|
const response = await api.get('/api/v1/admin/users', { params })
|
|
if (response.data.code === 0) {
|
|
users.value = response.data.data.users
|
|
pagination.total = response.data.data.pagination.total
|
|
pagination.totalPages = response.data.data.pagination.totalPages
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch users:', error)
|
|
ElMessage.error('获取用户列表失败')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
// Fetch user details
|
|
async function fetchUserDetails(userId) {
|
|
detailsLoading.value = true
|
|
try {
|
|
const response = await api.get(`/api/v1/admin/users/${userId}`)
|
|
if (response.data.code === 0) {
|
|
userDetails.value = response.data.data
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch user details:', error)
|
|
ElMessage.error('获取用户详情失败')
|
|
} finally {
|
|
detailsLoading.value = false
|
|
}
|
|
}
|
|
|
|
// Handle search
|
|
function handleSearch() {
|
|
pagination.page = 1
|
|
fetchUsers()
|
|
}
|
|
|
|
// Handle reset
|
|
function handleReset() {
|
|
filters.search = ''
|
|
filters.status = ''
|
|
filters.language = ''
|
|
pagination.page = 1
|
|
fetchUsers()
|
|
}
|
|
|
|
// Handle page change
|
|
function handlePageChange(page) {
|
|
pagination.page = page
|
|
fetchUsers()
|
|
}
|
|
|
|
// Handle page size change
|
|
function handleSizeChange(size) {
|
|
pagination.limit = size
|
|
pagination.page = 1
|
|
fetchUsers()
|
|
}
|
|
|
|
// Handle sort change
|
|
function handleSortChange({ prop, order }) {
|
|
if (prop && order) {
|
|
sortConfig.sortBy = prop
|
|
sortConfig.sortOrder = order === 'ascending' ? 'ASC' : 'DESC'
|
|
} else {
|
|
sortConfig.sortBy = 'createdAt'
|
|
sortConfig.sortOrder = 'DESC'
|
|
}
|
|
fetchUsers()
|
|
}
|
|
|
|
// Handle view details
|
|
function handleViewDetails(row) {
|
|
userDetails.value = null
|
|
detailsDialogVisible.value = true
|
|
fetchUserDetails(row.id)
|
|
}
|
|
|
|
// Handle toggle status
|
|
async function handleToggleStatus(row) {
|
|
const newStatus = row.status === 'active' ? 'suspended' : 'active'
|
|
const actionText = newStatus === 'active' ? '启用' : '禁用'
|
|
|
|
try {
|
|
await ElMessageBox.confirm(
|
|
`确定要${actionText}用户 "${row.nickname || row.realName || row.id}" 吗?`,
|
|
'确认操作',
|
|
{
|
|
confirmButtonText: '确定',
|
|
cancelButtonText: '取消',
|
|
type: 'warning'
|
|
}
|
|
)
|
|
|
|
const response = await api.put(`/api/v1/admin/users/${row.id}/status`, {
|
|
status: newStatus
|
|
})
|
|
|
|
if (response.data.code === 0) {
|
|
row.status = newStatus
|
|
ElMessage.success(`用户已${actionText}`)
|
|
}
|
|
} catch (error) {
|
|
if (error !== 'cancel') {
|
|
console.error('Failed to update user status:', error)
|
|
ElMessage.error('更新用户状态失败')
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle export CSV
|
|
async function handleExport() {
|
|
exporting.value = true
|
|
try {
|
|
const params = { ...filters }
|
|
Object.keys(params).forEach(key => {
|
|
if (params[key] === '' || params[key] === null || params[key] === undefined) {
|
|
delete params[key]
|
|
}
|
|
})
|
|
|
|
const response = await api.get('/api/v1/admin/users/export/csv', {
|
|
params,
|
|
responseType: 'blob'
|
|
})
|
|
|
|
// Create download link
|
|
const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8;' })
|
|
const url = window.URL.createObjectURL(blob)
|
|
const link = document.createElement('a')
|
|
link.href = url
|
|
link.setAttribute('download', `users-${Date.now()}.csv`)
|
|
document.body.appendChild(link)
|
|
link.click()
|
|
document.body.removeChild(link)
|
|
window.URL.revokeObjectURL(url)
|
|
|
|
ElMessage.success('导出成功')
|
|
} catch (error) {
|
|
console.error('Failed to export users:', error)
|
|
ElMessage.error('导出失败')
|
|
} finally {
|
|
exporting.value = false
|
|
}
|
|
}
|
|
|
|
// Helper functions
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return '-'
|
|
const date = new Date(dateStr)
|
|
return date.toLocaleString('zh-CN', {
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
second: '2-digit'
|
|
})
|
|
}
|
|
|
|
function getLanguageLabel(lang) {
|
|
const labels = {
|
|
zh: '中文',
|
|
en: 'English',
|
|
es: 'Español'
|
|
}
|
|
return labels[lang] || lang
|
|
}
|
|
|
|
function getLanguageTagType(lang) {
|
|
const types = {
|
|
zh: '',
|
|
en: 'success',
|
|
es: 'warning'
|
|
}
|
|
return types[lang] || 'info'
|
|
}
|
|
|
|
// Initialize
|
|
onMounted(() => {
|
|
fetchUsers()
|
|
})
|
|
</script>
|
|
|
|
<style lang="scss" scoped>
|
|
.users-container {
|
|
.filter-card {
|
|
margin-bottom: 20px;
|
|
|
|
.filter-form {
|
|
display: flex;
|
|
flex-wrap: wrap;
|
|
gap: 10px;
|
|
}
|
|
}
|
|
|
|
.table-card {
|
|
.pagination-container {
|
|
margin-top: 20px;
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
}
|
|
}
|
|
|
|
.user-details {
|
|
.stats-section {
|
|
margin-top: 20px;
|
|
|
|
.stats-card {
|
|
.card-header {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.stats-content {
|
|
.stat-item {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
padding: 8px 0;
|
|
border-bottom: 1px solid #ebeef5;
|
|
|
|
&:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
.label {
|
|
color: #909399;
|
|
}
|
|
|
|
.value {
|
|
font-weight: 600;
|
|
color: #303133;
|
|
|
|
&.primary {
|
|
color: #409eff;
|
|
}
|
|
|
|
&.success {
|
|
color: #67c23a;
|
|
}
|
|
|
|
&.warning {
|
|
color: #e6a23c;
|
|
}
|
|
|
|
&.danger {
|
|
color: #f56c6c;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
.login-history-section {
|
|
margin-top: 20px;
|
|
|
|
h4 {
|
|
margin-bottom: 12px;
|
|
color: #303133;
|
|
font-size: 14px;
|
|
}
|
|
}
|
|
|
|
.balance {
|
|
font-weight: 600;
|
|
color: #409eff;
|
|
}
|
|
}
|
|
}
|
|
</style>
|