appointment_system/admin/src/views/users/index.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>