2025-09-05

仪表盘修改(接口对接完成)
用户管理(接口对接完成)
转账管理(暂未完成)
This commit is contained in:
2025-09-05 16:55:43 +08:00
parent f7dbaf1b71
commit 73456f6ecf
8 changed files with 921 additions and 739 deletions

View File

@@ -20,7 +20,7 @@
<template #title>仪表盘</template>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/users">
<el-menu-item index="/users">
<el-icon><User /></el-icon>
<template #title>用户管理</template>
</el-menu-item>
@@ -52,7 +52,7 @@
<el-menu-item v-if="userStore.isAdmin" index="/transfers">
<el-menu-item index="/transfers">
<el-icon><Money /></el-icon>
<template #title>转账管理</template>
</el-menu-item>
@@ -141,7 +141,7 @@
<!-- 用户菜单 -->
<el-dropdown @command="handleCommand" class="user-dropdown">
<div class="user-info">
<el-avatar :size="32" :src="userStore.user?.avatar">
<el-avatar :size="32" :src="userStore.user?getImageUrl(userStore.user.avatar):userStore.user">
<el-icon><UserFilled /></el-icon>
</el-avatar>
<span class="username">{{ userStore.user?.username }}</span>
@@ -228,6 +228,8 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage, ElMessageBox } from 'element-plus'
import { getImageUrl } from '@/utils/config'
import {
Odometer,
User,

View File

@@ -1,188 +1,187 @@
import { defineStore } from 'pinia'
import { ElMessage } from 'element-plus'
import {defineStore} from 'pinia'
import {ElMessage} from 'element-plus'
import api from '@/utils/api'
export const useUserStore = defineStore('user', {
state: () => ({
user: null,
token: localStorage.getItem('admin_token') || null,
loading: false,
statusCheckInterval: null
}),
getters: {
isAuthenticated: (state) => !!state.token && !!state.user,
isAdmin: (state) => state.user?.role === 'admin'
},
actions: {
// 登录
async login(credentials) {
this.loading = true
try {
const response = await api.auth.login(credentials)
const { token, user } = response.data
this.token = token
this.user = user
// 存储到本地存储
localStorage.setItem('admin_token', token)
localStorage.setItem('admin_user', JSON.stringify(user))
this.startStatusCheck() // 登录成功后开始状态检查
ElMessage.success(`欢迎回来,${user.username}`)
return { success: true }
} catch (error) {
const message = error.response?.data?.message || '登录失败'
ElMessage.error(message)
return { success: false, message }
} finally {
this.loading = false
}
state: () => ({
user: null,
token: localStorage.getItem('admin_token') || null,
loading: false,
statusCheckInterval: null
}),
getters: {
isAuthenticated: (state) => !!state.token && !!state.user,
isAdmin: (state) => state.user?.role === 'admin'
},
// 登出
async logout() {
try {
this.stopStatusCheck() // 登出时停止状态检查
// 清除状态
this.user = null
this.token = null
// 清除本地存储
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
ElMessage.success('退出成功,期待您的再次光临!')
} catch (error) {
console.error('登出失败:', error)
}
},
// 检查认证状态仅在需要时验证token有效性
async checkAuth() {
const token = localStorage.getItem('admin_token')
const userStr = localStorage.getItem('admin_user')
if (!token || !userStr) {
return false
}
try {
// 先从本地存储恢复状态
const user = JSON.parse(userStr)
this.token = token
this.user = user
// 可选验证token是否仍然有效仅在必要时调用
// 这里不主动验证让具体的API调用时自然验证
return true
} catch (error) {
console.error('认证检查失败:', error)
// 清除无效的本地存储数据
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
return false
}
},
// 更新个人信息
async updateProfile(profileData) {
this.loading = true
try {
const response = await api.users.updateUser(this.user.id, profileData)
this.user = { ...this.user, ...response.data }
localStorage.setItem('admin_user', JSON.stringify(this.user))
ElMessage.success('个人信息更新成功')
return { success: true }
} catch (error) {
const message = error.response?.data?.message || '更新失败'
ElMessage.error(message)
return { success: false, message }
} finally {
this.loading = false
}
},
// 修改密码
async changePassword(passwordData) {
this.loading = true
try {
await api.auth.changePassword(passwordData)
ElMessage.success('密码修改成功,请重新登录')
// 修改密码后需要重新登录
setTimeout(() => {
this.logout()
}, 1500)
return { success: true }
} catch (error) {
const message = error.response?.data?.message || '密码修改失败'
ElMessage.error(message)
return { success: false, message }
} finally {
this.loading = false
}
},
// 获取用户详情
async getUserDetails(userId) {
try {
const response = await api.users.getUserById(userId)
return response.data
} catch (error) {
const message = error.response?.data?.message || '获取用户详情失败'
ElMessage.error(message)
throw error
}
},
// 获取当前用户信息
async fetchUserInfo() {
try {
const response = await api.auth.getCurrentUser()
this.user = response.data.user
localStorage.setItem('admin_user', JSON.stringify(this.user))
return { success: true }
} catch (error) {
const message = error.response?.data?.message || '获取用户信息失败'
console.error('获取用户信息失败:', error)
return { success: false, message }
}
},
// 开始状态检查
startStatusCheck() {
// 如果已经有定时器在运行,先清除
if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval)
}
// 每5分钟检查一次用户状态
this.statusCheckInterval = setInterval(async () => {
if (this.isAuthenticated) {
try {
await api.auth.getCurrentUser()
} catch (error) {
// 如果请求失败说明token可能已失效或用户被拉黑
// api拦截器会自动处理这些情况
console.log('用户状态检查失败可能已被拉黑或token失效')
}
actions: {
// 登录
async login(credentials) {
this.loading = true
try {
const response = await api.auth.login(credentials)
const {token, agent} = response.data.data
this.token = token
this.user = agent
// 存储到本地存储
localStorage.setItem('admin_token', token)
localStorage.setItem('admin_user', JSON.stringify(agent))
this.startStatusCheck() // 登录成功后开始状态检查
ElMessage.success(`欢迎回来,${agent.realName}`)
return {success: true}
} catch (error) {
const message = error.response?.data?.message || '登录失败'
ElMessage.error(message)
return {success: false, message}
} finally {
this.loading = false
}
},
// 登出
async logout() {
try {
this.stopStatusCheck() // 登出时停止状态检查
// 清除状态
this.user = null
this.token = null
// 清除本地存储
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
ElMessage.success('退出成功,期待您的再次光临!')
} catch (error) {
console.error('登出失败:', error)
}
},
// 检查认证状态仅在需要时验证token有效性
async checkAuth() {
const token = localStorage.getItem('admin_token')
const userStr = localStorage.getItem('admin_user')
if (!token || !userStr) {
return false
}
try {
// 先从本地存储恢复状态
const user = JSON.parse(userStr)
this.token = token
this.user = user
// 可选验证token是否仍然有效仅在必要时调用
// 这里不主动验证让具体的API调用时自然验证
return true
} catch (error) {
console.error('认证检查失败:', error)
// 清除无效的本地存储数据
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
return false
}
},
// 更新个人信息
async updateProfile(profileData) {
this.loading = true
try {
const response = await api.users.updateUser(this.user.id, profileData)
this.user = {...this.user, ...response.data}
localStorage.setItem('admin_user', JSON.stringify(this.user))
ElMessage.success('个人信息更新成功')
return {success: true}
} catch (error) {
const message = error.response?.data?.message || '更新失败'
ElMessage.error(message)
return {success: false, message}
} finally {
this.loading = false
}
},
// 修改密码
async changePassword(passwordData) {
this.loading = true
try {
await api.auth.changePassword(passwordData)
ElMessage.success('密码修改成功,请重新登录')
// 修改密码后需要重新登录
setTimeout(() => {
this.logout()
}, 1500)
return {success: true}
} catch (error) {
const message = error.response?.data?.message || '密码修改失败'
ElMessage.error(message)
return {success: false, message}
} finally {
this.loading = false
}
},
// 获取用户详情
async getUserDetails(userId) {
try {
const response = await api.users.getUserById(userId)
return response.data
} catch (error) {
const message = error.response?.data?.message || '获取用户详情失败'
ElMessage.error(message)
throw error
}
},
// 获取当前用户信息
async fetchUserInfo() {
try {
const response = await api.auth.getCurrentUser()
this.user = response.data.user
localStorage.setItem('admin_user', JSON.stringify(this.user))
return {success: true}
} catch (error) {
const message = error.response?.data?.message || '获取用户信息失败'
console.error('获取用户信息失败:', error)
return {success: false, message}
}
},
// 开始状态检查
startStatusCheck() {
// 如果已经有定时器在运行,先清除
if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval)
}
// 每5分钟检查一次用户状态
this.statusCheckInterval = setInterval(async () => {
if (this.isAuthenticated) {
try {
await api.auth.getCurrentUser()
} catch (error) {
// 如果请求失败说明token可能已失效或用户被拉黑
// api拦截器会自动处理这些情况
console.log('用户状态检查失败可能已被拉黑或token失效')
}
}
}, 5 * 60 * 1000) // 5分钟
},
// 停止状态检查
stopStatusCheck() {
if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval)
this.statusCheckInterval = null
}
}
}, 5 * 60 * 1000) // 5分钟
},
// 停止状态检查
stopStatusCheck() {
if (this.statusCheckInterval) {
clearInterval(this.statusCheckInterval)
this.statusCheckInterval = null
}
}
}
})

View File

@@ -1,15 +1,15 @@
import axios from 'axios'
import { ElMessage, ElLoading } from 'element-plus'
import {ElMessage, ElLoading} from 'element-plus'
import NProgress from 'nprogress'
import { apiURL } from './config.js'
import {apiURL} from './config.js'
// 创建axios实例
const request = axios.create({
baseURL: apiURL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
baseURL: apiURL,
timeout: 10000,
headers: {
'Content-Type': 'application/json'
}
})
let loadingInstance = null
@@ -18,203 +18,209 @@ let isLoggingOut = false // 防止重复登出
// 显示加载
const showLoading = () => {
if (requestCount === 0) {
loadingInstance = ElLoading.service({
text: '加载中...',
background: 'rgba(0, 0, 0, 0.7)'
})
}
requestCount++
if (requestCount === 0) {
loadingInstance = ElLoading.service({
text: '加载中...',
background: 'rgba(0, 0, 0, 0.7)'
})
}
requestCount++
}
// 隐藏加载
const hideLoading = () => {
requestCount--
if (requestCount <= 0) {
requestCount = 0
if (loadingInstance) {
loadingInstance.close()
loadingInstance = null
requestCount--
if (requestCount <= 0) {
requestCount = 0
if (loadingInstance) {
loadingInstance.close()
loadingInstance = null
}
}
}
}
// 请求拦截器
request.interceptors.request.use(
(config) => {
// 开始进度条
NProgress.start()
// 显示加载动画(除了某些不需要的请求)
if (!config.hideLoading) {
showLoading()
(config) => {
// 开始进度条
NProgress.start()
// 显示加载动画(除了某些不需要的请求)
if (!config.hideLoading) {
showLoading()
}
// 添加认证token
const token = localStorage.getItem('admin_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
hideLoading()
NProgress.done()
return Promise.reject(error)
}
// 添加认证token
const token = localStorage.getItem('admin_token')
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
},
(error) => {
hideLoading()
NProgress.done()
return Promise.reject(error)
}
)
// 响应拦截器
request.interceptors.response.use(
(response) => {
hideLoading()
NProgress.done()
return response
},
(error) => {
hideLoading()
NProgress.done()
const { response } = error
if (response) {
switch (response.status) {
case 401:
// 防止重复处理401错误
if (!isLoggingOut) {
isLoggingOut = true
// 只在非登录页面显示错误消息
if (window.location.pathname !== '/admin/login') {
ElMessage.error('登录已过期,请重新登录')
(response) => {
hideLoading()
NProgress.done()
return response
},
(error) => {
hideLoading()
NProgress.done()
const {response} = error
if (response) {
switch (response.status) {
case 401:
// 防止重复处理401错误
if (!isLoggingOut) {
isLoggingOut = true
// 只在非登录页面显示错误消息
if (window.location.pathname !== '/admin/login') {
ElMessage.error('登录已过期,请重新登录')
}
// 清除本地存储
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
// 立即跳转到登录页,减少延迟
setTimeout(() => {
if (typeof window !== 'undefined' && window.location.pathname !== '/admin/login') {
window.location.href = '/admin/login'
}
// 重置标志
setTimeout(() => {
isLoggingOut = false
}, 1000)
}, 500)
}
break
case 403:
// 检查是否是用户被拉黑
if (response.data.code === 'USER_BLACKLISTED') {
// 防止重复处理拉黑错误
if (!isLoggingOut) {
isLoggingOut = true
ElMessage.error(response.data.message || '账户已被拉黑,请联系管理员')
// 清除本地存储
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
// 跳转到登录页
setTimeout(() => {
if (typeof window !== 'undefined' && window.location.pathname !== '/admin/login') {
window.location.href = '/admin/login'
}
// 重置标志
setTimeout(() => {
isLoggingOut = false
}, 1000)
}, 500)
}
} else {
ElMessage.error('没有权限访问此资源')
}
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 422:
ElMessage.error(response.data.message || '请求参数错误')
break
case 429:
ElMessage.error('请求过于频繁,请稍后再试')
break
case 500:
ElMessage.error('服务器内部错误')
break
default:
ElMessage.error(response.data.error.message || '请求失败')
}
// 清除本地存储
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
// 立即跳转到登录页,减少延迟
setTimeout(() => {
if (typeof window !== 'undefined' && window.location.pathname !== '/admin/login') {
window.location.href = '/admin/login'
}
// 重置标志
setTimeout(() => {
isLoggingOut = false
}, 1000)
}, 500)
}
break
case 403:
// 检查是否是用户被拉黑
if (response.data.code === 'USER_BLACKLISTED') {
// 防止重复处理拉黑错误
if (!isLoggingOut) {
isLoggingOut = true
ElMessage.error(response.data.message || '账户已被拉黑,请联系管理员')
// 清除本地存储
localStorage.removeItem('admin_token')
localStorage.removeItem('admin_user')
// 跳转到登录页
setTimeout(() => {
if (typeof window !== 'undefined' && window.location.pathname !== '/admin/login') {
window.location.href = '/admin/login'
}
// 重置标志
setTimeout(() => {
isLoggingOut = false
}, 1000)
}, 500)
}
} else {
ElMessage.error('没有权限访问此资源')
}
break
case 404:
ElMessage.error('请求的资源不存在')
break
case 422:
ElMessage.error(response.data.message || '请求参数错误')
break
case 429:
ElMessage.error('请求过于频繁,请稍后再试')
break
case 500:
ElMessage.error('服务器内部错误')
break
default:
ElMessage.error(response.data.error.message || '请求失败')
}
} else {
ElMessage.error('网络错误,请检查网络连接')
} else {
ElMessage.error('网络错误,请检查网络连接')
}
return Promise.reject(error)
}
return Promise.reject(error)
}
)
// API接口定义
const api = {
// 代理认证相关
auth: {
login: (data) => request.post('/agents/auth/login', data),
getCurrentUser: () => request.get('/agents/auth/me'),
changePassword: (data) => request.put('/agents/auth/change-password', data)
},
// 代理下用户管理(只读)
users: {
getUsers: (params) => request.get('/agents/users', { params }),
getUserById: (id) => request.get(`/agents/users/${id}`),
getUserStats: () => request.get('/agents/users/stats')
},
// 代理数据统计
dashboard: {
getStats: () => request.get('/agents/dashboard/stats'),
getChartData: (type) => request.get(`/agents/dashboard/charts/${type}`)
},
// 代理认证相关
auth: {
login: (data) => request.post('/agents/auth/login', data),
getCurrentUser: () => request.get('/agents/auth/me'),
changePassword: (data) => request.put('/agents/auth/change-password', data)
},
// 佣金管理
commissions: {
// 获取佣金列表
getList: (params) => request.get('/agents/commissions', { params }),
// 获取佣金统计
getStats: (params) => request.get('/agents/commissions/stats', { params }),
// 发放单个佣金
pay: (id) => request.post(`/agents/commissions/${id}/pay`),
// 批量发放佣金
batchPay: (data) => request.post('/agents/commissions/batch-pay', data)
},
// 代理下用户管理(只读)
users: {
getUsers: (params) => request.get('/users', {params}),
getUserById: (id) => request.get(`/agents/users/${id}`),
getUserStats: () => request.get('/agents/users/stats')
},
// 转账记录管理(只读)
transfers: {
getTransfers: (params) => request.get('/agents/transfers', { params }),
getTransferStats: () => request.get('/agents/transfers/stats')
},
// 文件上传
upload: {
uploadImage: (file) => {
const formData = new FormData()
formData.append('image', file)
return request.post('/upload/image', formData, {
headers: {
'Content-Type': 'multipart/form-data'
// 代理数据统计
dashboard: {
getStats: () => request.get('/agent/stats'),
getChartData: (type) => request.get(`/agents/dashboard/charts/${type}`),
getUserChart: (params) => request.get("/agent/user-growth-trend", {params}),
getCommissionTrend: () => request.get("/agent/commission-trend"),
getCommissionDistribution: () => request.get("/agent/commission-distribution"),
getRecentUsers: () => request.get("/agent/recent-users"),
getRecentCommissions: () => request.get("/agent/recent-commissions"),
},
// 佣金管理
commissions: {
// 获取佣金列表
getList: (params) => request.get('/agents/commissions/list', {params}),
// 获取佣金统计
getStats: (params) => request.get('/agents/commissions/stats', {params}),
// 发放单个佣金
pay: (id) => request.post(`/agents/commissions/${id}/pay`),
// 批量发放佣金
batchPay: (data) => request.post('/agents/commissions/batch-pay', data)
},
// 转账记录管理(只读)
transfers: {
getList: (params) => request.get('/transfers', {params}),
getTransfers: (params) => request.get('/agents/transfers', {params}),
getTransferStats: () => request.get('/agents/transfers/stats')
},
// 文件上传
upload: {
uploadImage: (file) => {
const formData = new FormData()
formData.append('image', file)
return request.post('/upload/image', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
}
})
}
},
// 为了向后兼容,添加直接的 get、post 等方法
get: (url, config) => request.get(url, config),
post: (url, data, config) => request.post(url, data, config),
put: (url, data, config) => request.put(url, data, config),
delete: (url, config) => request.delete(url, config)
},
// 为了向后兼容,添加直接的 get、post 等方法
get: (url, config) => request.get(url, config),
post: (url, data, config) => request.post(url, data, config),
put: (url, data, config) => request.put(url, data, config),
delete: (url, config) => request.delete(url, config)
}
export default api

View File

@@ -2,8 +2,8 @@
const config = {
development: {
baseURL: 'https://minio.zrbjr.com',
uploadURL: 'http://localhost:3002/api/upload',
apiURL: 'http://localhost:3002/api'
uploadURL: 'http://192.168.1.43:3002/api/upload',
apiURL: 'http://192.168.1.43:3002/api'
},
production: {
baseURL: 'https://minio.zrbjr.com',
@@ -27,34 +27,31 @@ export const { baseURL, uploadURL, apiURL } = config[env]
* @returns {string} 完整的图片URL
*/
export const getImageUrl = (imagePath) => {
const cleanBaseURL = baseURL.replace(/\/$/, '')
if (!imagePath) return ''
if (imagePath.startsWith('http')) return imagePath
// 在开发环境下,使用代理路径
if (env === 'development') {
// 如果路径已经包含uploads直接使用
if (imagePath.startsWith('/uploads/') || imagePath.startsWith('uploads/')) {
const cleanPath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`
return cleanPath
// console.log('getImageUrl called with:', imagePath)
if (!imagePath) return ''
if (imagePath.startsWith('http')) return imagePath
const cleanBaseURL = baseURL.replace(/\/$/, '')
// 如果图片路径以/uploads开头直接返回原路径
if (imagePath.startsWith('/uploads')) {
// console.log('Image starts with /uploads, returning original path:', imagePath)
return `${cleanBaseURL}/jurongquan${imagePath}`
}
// 否则添加uploads前缀
return `${cleanBaseURL}${imagePath}`
}
// 生产环境下使用完整URL
let cleanImagePath
// 如果路径已经包含uploads直接使用
if (imagePath.startsWith('/uploads/') || imagePath.startsWith('uploads/')) {
cleanImagePath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`
} else {
// 否则添加uploads前缀
cleanImagePath = `/uploads/${imagePath}`
}
return `${cleanBaseURL}${cleanImagePath}`
// 在开发环境下,也需要根据路径前缀处理
if (env === 'development') {
const cleanBaseURL = baseURL.replace(/\/$/, '')
const cleanImagePath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`
const fullUrl = `${cleanBaseURL}${cleanImagePath}`
// console.log('Development environment, returning:', fullUrl)
return fullUrl
}
// 生产环境下使用完整URL
const cleanImagePath = imagePath.startsWith('/') ? imagePath : `/${imagePath}`
const fullUrl = `${cleanBaseURL}${cleanImagePath}`
return fullUrl
}
/**

131
src/utils/public_method.js Normal file
View File

@@ -0,0 +1,131 @@
/**
* 验证并脱敏电话号码
* @param {string} phone - 需要验证和脱敏的电话号码
* @returns {string|boolean} - 脱敏后的电话号码无效则返回false
*/
export function maskPhoneNumber(phone) {
// 检查是否为字符串
if (typeof phone !== 'string') {
return phone;
}
// 移除所有非数字字符
const cleaned = phone.replace(/\D/g, '');
// 验证常见的电话号码格式
// 支持: 11位手机号(中国大陆)、带区号的固定电话
const phoneRegex = /^(1[3-9]\d{9})$|^(\d{3,4}-\d{7,8})$|^(\d{3,4}\d{7,8})$/;
if (!phoneRegex.test(cleaned) && !phoneRegex.test(phone)) {
return phone; // 不是有效的电话号码
}
// 根据不同格式进行脱敏
if (cleaned.length === 11) {
// 手机号: 保留前3位和后4位中间4位用*代替
return cleaned.replace(/^(\d{3})(\d{4})(\d{4})$/, '$1****$3');
} else if (phone.includes('-')) {
// 带区号的固定电话: 区号不变,号码中间用*代替
const [areaCode, number] = phone.split('-');
if (number.length <= 4) {
return `${areaCode}-****`;
}
return `${areaCode}-${number.substr(0, 2)}****${number.substr(-2)}`;
} else {
// 不带区号的固定电话
if (cleaned.length <= 4) {
return '****';
}
return `${cleaned.substr(0, 2)}****${cleaned.substr(-2)}`;
}
}
/**
* 姓名脱敏处理
* @param {string} name - 需要脱敏的姓名
* @returns {string} - 脱敏后的姓名
*/
export function maskName(name) {
// 检查输入是否为有效字符串
if (!name || typeof name !== 'string') {
return '';
}
// 去除前后空格
const trimmedName = name.trim();
// 检查是否为英文姓名(包含空格)
if (trimmedName.includes(' ')) {
const parts = trimmedName.split(' ').filter(part => part);
// 处理英文名:名全显,姓只显首字母
if (parts.length >= 2) {
const firstName = parts.slice(0, -1).join(' ');
const lastName = parts[parts.length - 1];
return `${firstName} ${lastName.charAt(0)}*`;
}
}
// 处理中文姓名
const length = trimmedName.length;
switch (length) {
case 1:
// 单字名,不脱敏
return trimmedName;
case 2:
// 双字名,隐藏第二个字
return `${trimmedName[0]}*`;
case 3:
// 三字名,隐藏中间字
return `${trimmedName[0]}*${trimmedName[2]}`;
case 4:
// 四字名(如复姓),隐藏中间两个字
return `${trimmedName[0]}**${trimmedName[3]}`;
default:
// 更长的姓名,显示首尾各两个字,中间用*代替
if (length > 4) {
return `${trimmedName.substr(0, 2)}${'*'.repeat(length - 4)}${trimmedName.substr(-2)}`;
}
}
return trimmedName;
}
/**
* 转换日期 yyyy-HH-mm
*/
export const convertToDateOnly = (dateString) => {
// 创建Date对象
const date = new Date(dateString);
// 获取年、月、日
const year = date.getFullYear();
// 月份从0开始所以需要加1
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
// 拼接成YYYY-MM-DD格式
return `${year}-${month}-${day - 1}`;
}
/**
* 将ISO格式日期时间转换为yyyy-MM-dd HH:mm:ss格式
* @param {string} isoString - ISO格式的日期时间字符串如2025-09-04T06:18:08.000Z
* @returns {string} - 转换后的日期时间字符串
*/
export function formatIsoToCustom(utcString) {
// 创建Date对象
const date = new Date(utcString);
// 使用UTC方法获取各时间部分确保使用UTC时间而非本地时间
const year = date.getUTCFullYear();
const month = String(date.getUTCMonth() + 1).padStart(2, '0'); // 月份从0开始
const day = String(date.getUTCDate()).padStart(2, '0');
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
const seconds = String(date.getUTCSeconds()).padStart(2, '0');
// 拼接成目标格式
return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
}

View File

@@ -5,23 +5,25 @@
<div class="header-left">
<div class="welcome-section">
<div class="greeting-icon">
<el-icon><View /></el-icon>
<el-icon>
<View/>
</el-icon>
</div>
<div class="greeting-text">
<h1 class="page-title">代理数据统计</h1>
<p class="page-subtitle">{{ getGreeting() }}{{ userStore.user?.username }}</p>
<p class="page-subtitle">{{ getGreeting() }}{{ userStore.user?.realName }}</p>
</div>
</div>
</div>
<div class="header-right">
<div class="header-stats">
<div class="quick-stat">
<span class="stat-value">{{ statsData.totalUsers }}</span>
<span class="stat-label">下级用户</span>
<span class="stat-value">{{ statsData.users.total_users }}</span>
<span class="stat-label">用户</span>
</div>
<div class="quick-stat">
<span class="stat-value">{{ statsData.todayCommission }}</span>
<span class="stat-label">今日佣金</span>
<span class="stat-value">{{ statsData.commissions.total_commission }}</span>
<span class="stat-label">总佣金金额</span>
</div>
</div>
<div class="header-actions">
@@ -32,7 +34,7 @@
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-section">
<el-row :gutter="24" class="stats-row">
@@ -41,15 +43,17 @@
<div class="stat-background"></div>
<div class="stat-content">
<div class="stat-icon">
<el-icon><component :is="stat.icon" /></el-icon>
<el-icon>
<component :is="stat.icon"/>
</el-icon>
</div>
<div class="stat-info">
<div class="stat-number" :data-value="stat.value">{{ stat.value }}</div>
<div class="stat-label">{{ stat.label }}</div>
<div class="stat-change" :class="stat.changeClass">
<el-icon><component :is="stat.changeIcon" /></el-icon>
{{ stat.change }}
</div>
<!-- <div class="stat-change" :class="stat.changeClass">-->
<!-- <el-icon><component :is="stat.changeIcon" /></el-icon>-->
<!-- {{ stat.change }}-->
<!-- </div>-->
</div>
</div>
<div class="stat-decoration"></div>
@@ -57,7 +61,7 @@
</el-col>
</el-row>
</div>
<!-- 图表区域 -->
<el-row :gutter="20" class="charts-row">
<!-- 用户增长趋势 -->
@@ -65,69 +69,78 @@
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">下级用户增长趋势</span>
<span class="card-title">用户增长趋势</span>
<el-select v-model="userChartPeriod" size="small" style="width: 100px" @change="loadUserChart">
<el-option label="7天" value="7d" />
<el-option label="30天" value="30d" />
<el-option label="90天" value="90d" />
<el-option label="7天" value="7"/>
<el-option label="30天" value="30"/>
<el-option label="90天" value="90"/>
</el-select>
</div>
</template>
<div class="chart-container">
<v-chart :option="userChartOption" :loading="chartLoading" />
<div v-if="userChartData.length==0" class="empty-state">
<el-empty description="暂无数据"/>
</div>
<v-chart :option="userChartOption" :loading="chartLoading"/>
</div>
</el-card>
</el-col>
<!-- 佣金收入趋势 -->
<el-col :xs="24" :lg="12">
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">佣金收入趋势</span>
<span class="card-title">用户转账流水</span>
<el-tag type="success" size="small">近30天</el-tag>
</div>
</template>
<div class="chart-container">
<v-chart :option="commissionChartOption" :loading="chartLoading" />
<div v-if="commissionChartData.length==0" class="empty-state">
<el-empty description="暂无数据"/>
</div>
<v-chart v-else :option="commissionChartOption" :loading="chartLoading"/>
</div>
</el-card>
</el-col>
</el-row>
<!-- 业务分析图表 -->
<el-row :gutter="20" class="business-charts-row">
<!-- <el-row :gutter="20" class="business-charts-row">-->
<!-- 佣金类型分布 -->
<el-col :xs="24" :lg="12">
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">佣金类型分布</span>
<el-tag type="primary" size="small">总览</el-tag>
</div>
</template>
<div class="chart-container">
<v-chart :option="commissionTypeOption" :loading="chartLoading" />
</div>
</el-card>
</el-col>
<!-- <el-col :xs="24" :lg="12">-->
<!-- <el-card class="chart-card" shadow="hover">-->
<!-- <template #header>-->
<!-- <div class="card-header">-->
<!-- <span class="card-title">佣金类型分布</span>-->
<!-- <el-tag type="primary" size="small">总览</el-tag>-->
<!-- </div>-->
<!-- </template>-->
<!-- <div class="chart-container">-->
<!-- <div v-if="commissionTypeData.length==0" class="empty-state">-->
<!-- <el-empty description="暂无数据"/>-->
<!-- </div>-->
<!-- <v-chart :option="commissionTypeOption" :loading="chartLoading"/>-->
<!-- </div>-->
<!-- </el-card>-->
<!-- </el-col>-->
<!-- 用户活跃度 -->
<el-col :xs="24" :lg="12">
<el-card class="chart-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">用户活跃度</span>
<el-tag type="info" size="small">近7天</el-tag>
</div>
</template>
<div class="chart-container">
<v-chart :option="userActivityOption" :loading="chartLoading" />
</div>
</el-card>
</el-col>
</el-row>
<!-- <el-col :xs="24" :lg="12">-->
<!-- <el-card class="chart-card" shadow="hover">-->
<!-- <template #header>-->
<!-- <div class="card-header">-->
<!-- <span class="card-title">用户活跃度</span>-->
<!-- <el-tag type="info" size="small">近7天</el-tag>-->
<!-- </div>-->
<!-- </template>-->
<!-- <div class="chart-container">-->
<!-- <v-chart :option="userActivityOption" :loading="chartLoading"/>-->
<!-- </div>-->
<!-- </el-card>-->
<!-- </el-col>-->
<!-- </el-row>-->
<!-- 最新动态 -->
<el-row :gutter="20" class="activity-row">
<!-- 最新用户 -->
@@ -141,30 +154,29 @@
</template>
<div class="activity-list">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="3" animated />
<el-skeleton :rows="3" animated/>
</div>
<div v-else-if="recentUsers.length === 0" class="empty-state">
<el-empty description="暂无数据" />
<el-empty description="暂无数据"/>
</div>
<div v-else>
<div v-for="user in recentUsers" :key="user.id" class="activity-item">
<el-avatar :size="40" :src="user.avatar">
<el-icon><UserFilled /></el-icon>
<el-icon>
<UserFilled/>
</el-icon>
</el-avatar>
<div class="activity-info">
<div class="activity-title">{{ user.username }}</div>
<div class="activity-desc">{{ user.phone }}</div>
<div class="activity-time">{{ formatTime(user.created_at) }}</div>
</div>
<el-tag type="success" size="small">
新用户
</el-tag>
</div>
</div>
</div>
</el-card>
</el-col>
<!-- 最新佣金 -->
<el-col :xs="24" :lg="12">
<el-card class="activity-card" shadow="hover">
@@ -176,24 +188,26 @@
</template>
<div class="activity-list">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="3" animated />
<el-skeleton :rows="3" animated/>
</div>
<div v-else-if="recentCommissions.length === 0" class="empty-state">
<el-empty description="暂无数据" />
<el-empty description="暂无数据"/>
</div>
<div v-else>
<div v-for="commission in recentCommissions" :key="commission.id" class="activity-item">
<div class="commission-icon">
<el-icon class="default-icon"><Money /></el-icon>
<el-icon class="default-icon">
<Money/>
</el-icon>
</div>
<div class="activity-info">
<div class="activity-title">+{{ commission.amount }}</div>
<div class="activity-desc">{{ commission.description }}</div>
<div class="activity-title">+{{ commission.commission_amount }}</div>
<div class="activity-desc">{{ commission.username+''+commission.real_name+'' }}</div>
<div class="activity-time">{{ formatTime(commission.created_at) }}</div>
</div>
<el-tag :type="getCommissionStatusType(commission.status)" size="small">
{{ getCommissionStatusText(commission.status) }}
</el-tag>
<!-- <el-tag type="info" size="small">-->
<!-- {{ commission.commission_type }}-->
<!-- </el-tag>-->
</div>
</div>
</div>
@@ -204,11 +218,11 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useUserStore } from '@/stores/user'
import { useRouter } from 'vue-router'
import {ref, reactive, onMounted, computed, watch} from 'vue'
import {useUserStore} from '@/stores/user'
import {useRouter} from 'vue-router'
import api from '@/utils/api'
import { ElMessage } from 'element-plus'
import {ElMessage} from 'element-plus'
import dayjs from 'dayjs'
import {
User,
@@ -223,9 +237,9 @@ import {
Coin
} from '@element-plus/icons-vue'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, PieChart, BarChart } from 'echarts/charts'
import {use} from 'echarts/core'
import {CanvasRenderer} from 'echarts/renderers'
import {LineChart, PieChart, BarChart} from 'echarts/charts'
import {
TitleComponent,
TooltipComponent,
@@ -251,14 +265,28 @@ const router = useRouter()
// 响应式数据
const loading = ref(true)
const chartLoading = ref(false)
const userChartPeriod = ref('30d')
const userChartPeriod = ref('30')
// 统计数据
const statsData = ref({
totalUsers: 0,
totalCommission: 0,
todayCommission: 0,
pendingCommission: 0
users: {
total_users: 0,
total_balance: 0,
today_new_users: 0,
active_users: 0
},
commissions: {
total_commission: 0,
today_commission: 0,
paid_commission: 0,
pending_commission: 0
},
transfers: {
total_transfers: 0,
today_transfers: 0,
total_amount: 0,
today_amount: 0
}
})
// 最新数据
@@ -275,18 +303,18 @@ const userActivityData = ref([])
const stats = computed(() => [
{
key: 'users',
label: '下级用户总数',
value: statsData.value.totalUsers,
label: '活跃人数',
value: statsData.value.users.active_users,
icon: User,
class: 'stat-users',
change: '+12.5%',
changeClass: 'positive',
changeIcon: ArrowUp
change: `${statsData.value.users.today_new_users > 0 ? '+' : ''}${statsData.value.users.today_new_users}`,
changeClass: statsData.value.users.today_new_users >= 0 ? 'positive' : 'negative',
changeIcon: statsData.value.users.today_new_users >= 0 ? 'ArrowUp' : 'ArrowDown'
},
{
key: 'commission',
label: '累计佣金',
value: `¥${statsData.value.totalCommission}`,
label: '佣金',
value: `¥${statsData.value.commissions.total_commission}`,
icon: Money,
class: 'stat-commission',
change: '+8.2%',
@@ -296,7 +324,7 @@ const stats = computed(() => [
{
key: 'today',
label: '今日佣金',
value: `¥${statsData.value.todayCommission}`,
value: `¥${statsData.value.commissions.today_commission}`,
icon: Coin,
class: 'stat-today',
change: '+15.3%',
@@ -306,7 +334,7 @@ const stats = computed(() => [
{
key: 'pending',
label: '待发放佣金',
value: `¥${statsData.value.pendingCommission}`,
value: `¥${statsData.value.commissions.pending_commission}`,
icon: Clock,
class: 'stat-pending',
change: '-2.1%',
@@ -377,7 +405,7 @@ const commissionChartOption = computed(() => ({
},
xAxis: {
type: 'category',
data: commissionChartData.value.map(item => item.date)
data: commissionChartData.value.map(item => item.date),
},
yAxis: {
type: 'value'
@@ -396,7 +424,7 @@ const commissionChartOption = computed(() => ({
const commissionTypeOption = computed(() => ({
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
formatter: '{a}{b}<br/>数量: {c}<br/>占比:{d}%<br/>佣金金额:{e}'
},
legend: {
orient: 'vertical',
@@ -455,7 +483,19 @@ const getGreeting = () => {
// 格式化时间
const formatTime = (time) => {
return dayjs(time).format('MM-DD HH:mm')
const date = dayjs(time)
const now = dayjs()
const diff = now.diff(date)
if (diff < 60000) {
return '刚刚'
} else if (diff < 3600000) {
return `${Math.floor(diff / 60000)}分钟前`
} else if (diff < 86400000) {
return `${Math.floor(diff / 3600000)}小时前`
} else {
return date.format('YYYY-MM-DD')
}
}
// 获取佣金状态类型
@@ -480,23 +520,24 @@ const getCommissionStatusText = (status) => {
// 处理统计卡片点击
const handleStatClick = (key) => {
switch (key) {
case 'users':
router.push('/users')
break
case 'commission':
case 'today':
case 'pending':
router.push('/commissions')
break
}
// switch (key) {
// case 'users':
// router.push('/users')
// break
// case 'commission':
// case 'today':
// case 'pending':
// router.push('/commissions')
// break
// }
}
// 加载统计数据
const loadStats = async () => {
try {
const response = await api.dashboard.getStats()
statsData.value = response.data
statsData.value = response.data.data
console.log(statsData.value)
} catch (error) {
ElMessage.error('获取统计数据失败')
}
@@ -506,13 +547,11 @@ const loadStats = async () => {
const loadChartData = async () => {
chartLoading.value = true
try {
const response = await api.dashboard.getChartData()
const data = response.data
userChartData.value = data.userChart || []
commissionChartData.value = data.commissionChart || []
commissionTypeData.value = data.commissionType || []
userActivityData.value = data.userActivity || []
await loadUserChart()
await loadCommissionTrendData()
await loadCommissionDistributionData()
await loadRecentUsersData()
await loadRecentCommissionsData()
} catch (error) {
ElMessage.error('获取图表数据失败')
} finally {
@@ -522,14 +561,66 @@ const loadChartData = async () => {
// 加载用户图表
const loadUserChart = async () => {
chartLoading.value = true
try {
const response = await api.dashboard.getUserChart({ period: userChartPeriod.value })
userChartData.value = response.data
const response = await api.dashboard.getUserChart({days: userChartPeriod.value})
userChartData.value = response.data.data
} catch (error) {
ElMessage.error('获取用户图表数据失败')
} finally {
}
}
// 加载佣金收入趋势数据
const loadCommissionTrendData = async () => {
try {
const response = await api.dashboard.getCommissionTrend()
commissionChartData.value = response.data.data
} catch (error) {
ElMessage.error('获取用户图表数据失败')
} finally {
}
}
// 加载佣金类型分布数据
const loadCommissionDistributionData = async () => {
try {
const response = await api.dashboard.getCommissionDistribution()
var tmpData = []
if (response.data.data.length > 0) {
await response.data.data.forEach((item, index) => {
tmpData.push({
name: item.type,
value: item.count,
amount: item.amount,
})
})
commissionTypeData.value = tmpData
}
} catch (error) {
ElMessage.error('获取用户图表数据失败')
} finally {
}
}
// 加载最新下级用户数据
const loadRecentUsersData = async () => {
try {
const response = await api.dashboard.getRecentUsers()
recentUsers.value = response.data.data
} catch (error) {
ElMessage.error('获取用户图表数据失败')
} finally {
}
}
// 加载最新佣金
const loadRecentCommissionsData = async () => {
try {
const response = await api.dashboard.getRecentCommissions()
recentCommissions.value = response.data.data
} catch (error) {
ElMessage.error('获取用户图表数据失败')
} finally {
chartLoading.value = false
}
}
@@ -537,10 +628,10 @@ const loadUserChart = async () => {
const loadRecentData = async () => {
try {
const [usersResponse, commissionsResponse] = await Promise.all([
api.users.getList({ page: 1, size: 5, sort: 'created_at', order: 'desc' }),
api.commissions.getList({ page: 1, size: 5, sort: 'created_at', order: 'desc' })
api.users.getList({page: 1, size: 5, sort: 'created_at', order: 'desc'}),
api.commissions.getList({page: 1, size: 5, sort: 'created_at', order: 'desc'})
])
recentUsers.value = usersResponse.data.list || []
recentCommissions.value = commissionsResponse.data.list || []
} catch (error) {
@@ -555,7 +646,7 @@ const refreshData = async () => {
await Promise.all([
loadStats(),
loadChartData(),
loadRecentData()
// loadRecentData()
])
ElMessage.success('数据刷新成功')
} catch (error) {
@@ -749,6 +840,7 @@ onMounted(() => {
.chart-container {
height: 320px;
width: 100%;
}
.activity-list {
@@ -759,9 +851,15 @@ onMounted(() => {
.activity-item {
display: flex;
align-items: center;
padding: 12px 0;
padding: 12px 12px 10px 12px;
border-bottom: 1px solid #f0f0f0;
}
.activity-item:hover {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
transform: translateX(8px);
padding-left: 16px;
border-bottom-color: transparent;
}
.activity-item:last-child {
border-bottom: none;
@@ -820,19 +918,19 @@ onMounted(() => {
gap: 16px;
text-align: center;
}
.header-stats {
gap: 16px;
}
.stat-card {
padding: 16px;
}
.stat-icon {
font-size: 36px;
}
.stat-number {
font-size: 24px;
}

View File

@@ -115,16 +115,6 @@
<!-- 转账记录表格 -->
<div class="table-section">
<el-card>
<template #header>
<div class="card-header">
<span>转账记录列表</span>
<el-button type="primary" @click="handleExport">
<el-icon><Download /></el-icon>
导出记录
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="transferList"
@@ -132,12 +122,18 @@
style="width: 100%"
>
<el-table-column prop="id" label="记录ID" width="100" />
<el-table-column prop="userPhone" label="用户手机号" width="120" />
<el-table-column prop="userName" label="用户姓名" width="100" />
<el-table-column label="用户手机号" >
<template v-slot="scope">
{{scope.row.from_real_name + '('+ maskPhoneNumber(scope.row.from_phone) +')->' + scope.row.to_real_name + '('+maskPhoneNumber(scope.row.to_phone)+')' }}
</template>
</el-table-column>
<!-- <el-table-column prop="userName" label="用户姓名" width="100" />-->
<el-table-column label="转账类型" width="100">
<template #default="{ row }">
<el-tag :type="row.type === 'in' ? 'success' : 'danger'">
{{ row.type === 'in' ? '转入' : '转出' }}
<template v-slot="scope">
<el-tag>
<span v-if="scope.row.type=='deposit'">押金</span>
<span v-if="scope.row.type=='withdraw'">撤回</span>
<span v-if="scope.row.type=='transfer'">转账</span>
</el-tag>
</template>
</el-table-column>
@@ -152,7 +148,7 @@
<el-table-column prop="description" label="转账说明" min-width="150" />
<el-table-column prop="createdAt" label="转账时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
{{ formatIsoToCustom(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
@@ -194,6 +190,7 @@ import {
Download
} from '@element-plus/icons-vue'
import api from '@/utils/api'
import {formatIsoToCustom, maskPhoneNumber} from '@/utils/public_method'
// 响应式数据
const loading = ref(false)
@@ -231,8 +228,9 @@ const getTransferList = async () => {
}
const response = await api.transfers.getList(params)
transferList.value = response.data.list
pagination.total = response.data.total
transferList.value = response.data.data.transfers
pagination.total = response.data.data.pagination.total
stats.value = response.data.data.stats
} catch (error) {
ElMessage.error('获取转账记录失败')
} finally {
@@ -264,7 +262,7 @@ const getTransferStats = async () => {
const handleSearch = () => {
pagination.page = 1
getTransferList()
getTransferStats()
// getTransferStats()
}
// 重置
@@ -274,7 +272,7 @@ const handleReset = () => {
searchForm.dateRange = []
pagination.page = 1
getTransferList()
getTransferStats()
// getTransferStats()
}
// 分页大小改变
@@ -324,7 +322,7 @@ const getStatusText = (status) => {
// 组件挂载时获取数据
onMounted(() => {
getTransferList()
getTransferStats()
// getTransferStats()
})
</script>

View File

@@ -4,292 +4,234 @@
<h1 class="page-title">用户管理</h1>
<p class="page-subtitle">查看我的下级用户信息</p>
</div>
<!-- 搜索和操作栏 -->
<el-card class="search-card" shadow="never">
<el-row :gutter="20" class="search-row">
<!-- 第一行主要搜索条件 -->
<el-col :xs="24" :sm="12" :md="8" :lg="6">
<el-input
v-model="searchForm.keyword"
placeholder="搜索用户名或真实姓名"
:prefix-icon="Search"
clearable
@keyup.enter="handleSearch"
v-model="searchForm.keyword"
placeholder="搜索用户名或真实姓名"
:prefix-icon="Search"
clearable
@keyup.enter="handleSearch"
/>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="6">
<el-select
v-model="searchForm.status"
placeholder="选择状态"
clearable
style="width: 100%"
>
<el-option label="全部" value="" />
<el-option label="活跃" value="active" />
<el-option label="非活跃" value="inactive" />
</el-select>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="6">
<el-select
v-model="searchForm.sort"
placeholder="排序方式"
style="width: 100%"
>
<el-option label="注册时间(新到旧)" value="created_at_desc" />
<el-option label="注册时间(旧到新)" value="created_at_asc" />
<el-option label="用户名A-Z" value="username_asc" />
<el-option label="用户名Z-A" value="username_desc" />
</el-select>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="6" class="button-group">
<el-button type="primary" @click="handleSearch">
<el-icon><Search /></el-icon>
<el-icon>
<Search/>
</el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon><Refresh /></el-icon>
<el-icon>
<Refresh/>
</el-icon>
重置
</el-button>
</el-col>
<!-- 第二行地区筛选 -->
<el-col :xs="24" :sm="12" :md="8" :lg="6">
<el-select
v-model="searchForm.city"
placeholder="选择城市"
clearable
style="width: 100%"
@change="onCityChange"
>
<el-option label="全部" value="" />
<el-option
v-for="city in availableCities"
:key="city"
:label="city"
:value="city"
/>
</el-select>
</el-col>
<el-col :xs="24" :sm="12" :md="8" :lg="6">
<el-select
v-model="searchForm.district"
placeholder="选择地区"
clearable
style="width: 100%"
:disabled="!searchForm.city"
>
<el-option label="全部" value="" />
<el-option
v-for="district in searchDistricts"
:key="district.id"
:label="district.district_name"
:value="district.id"
/>
</el-select>
</el-col>
<!-- <el-col :xs="24" :sm="12" :md="8" :lg="6">-->
<!-- <el-select-->
<!-- v-model="searchForm.city"-->
<!-- placeholder="选择城市"-->
<!-- clearable-->
<!-- style="width: 100%"-->
<!-- @change="onCityChange"-->
<!-- >-->
<!-- <el-option label="全部" value=""/>-->
<!-- <el-option-->
<!-- v-for="city in availableCities"-->
<!-- :key="city"-->
<!-- :label="city"-->
<!-- :value="city"-->
<!-- />-->
<!-- </el-select>-->
<!-- </el-col>-->
<!-- <el-col :xs="24" :sm="12" :md="8" :lg="6">-->
<!-- <el-select-->
<!-- v-model="searchForm.district"-->
<!-- placeholder="选择地区"-->
<!-- clearable-->
<!-- style="width: 100%"-->
<!-- :disabled="!searchForm.city"-->
<!-- >-->
<!-- <el-option label="全部" value=""/>-->
<!-- <el-option-->
<!-- v-for="district in searchDistricts"-->
<!-- :key="district.id"-->
<!-- :label="district.district_name"-->
<!-- :value="district.id"-->
<!-- />-->
<!-- </el-select>-->
<!-- </el-col>-->
</el-row>
</el-card>
<!-- 统计信息 -->
<el-row :gutter="20" class="stats-row">
<el-row :gutter="20" class="stats-section">
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card" shadow="never">
<el-card class="stats-card" shadow="never">
<div class="stat-content">
<div class="stat-value">{{ stats.totalUsers }}</div>
<div class="stat-value">{{ stats.total_users }}</div>
<div class="stat-label">下级用户总数</div>
</div>
<el-icon class="stat-icon" color="#409EFF"><User /></el-icon>
<!-- <el-icon class="stat-icon" color="#409EFF">-->
<!-- <User/>-->
<!-- </el-icon>-->
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card" shadow="never">
<el-card class="stats-card" shadow="never">
<div class="stat-content">
<div class="stat-value">{{ stats.activeUsers }}</div>
<div class="stat-value">{{ stats.active_users }}</div>
<div class="stat-label">活跃用户</div>
</div>
<el-icon class="stat-icon" color="#67C23A"><UserFilled /></el-icon>
<!-- <el-icon class="stat-icon" color="#67C23A">-->
<!-- <UserFilled/>-->
<!-- </el-icon>-->
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card" shadow="never">
<el-card class="stats-card" shadow="never">
<div class="stat-content">
<div class="stat-value">¥{{ formatBalance(stats.totalBalance) }}</div>
<div class="stat-value">¥{{ formatBalance(stats.total_balance) }}</div>
<div class="stat-label">用户总余额</div>
</div>
<el-icon class="stat-icon" color="#E6A23C"><Money /></el-icon>
<!-- <el-icon class="stat-icon" color="#E6A23C">-->
<!-- <Money/>-->
<!-- </el-icon>-->
</el-card>
</el-col>
<el-col :xs="24" :sm="12" :md="6">
<el-card class="stat-card" shadow="never">
<el-card class="stats-card" shadow="never">
<div class="stat-content">
<div class="stat-value">{{ stats.todayNewUsers }}</div>
<div class="stat-value">{{ stats.today_new_users }}</div>
<div class="stat-label">今日新增</div>
</div>
<el-icon class="stat-icon" color="#F56C6C"><Plus /></el-icon>
<!-- <el-icon class="stat-icon" color="#F56C6C">-->
<!-- <Plus/>-->
<!-- </el-icon>-->
</el-card>
</el-col>
</el-row>
<!-- 用户列表 -->
<el-card class="table-card" shadow="never">
<template #header>
<div class="card-header">
<span class="card-title">用户列表 ({{ pagination.total }})</span>
<el-button type="primary" @click="exportUsers">
<el-icon><Download /></el-icon>
导出数据
</el-button>
</div>
</template>
<el-table
v-loading="loading"
:data="users"
stripe
style="width: 100%"
@sort-change="handleSortChange"
v-loading="loading"
:data="users"
stripe
style="width: 100%"
@sort-change="handleSortChange"
>
<el-table-column type="index" label="#" width="60" />
<el-table-column type="index" label="#" width="60"/>
<el-table-column label="头像" width="80">
<template #default="{ row }">
<el-avatar :size="40" :src="row.avatar">
<el-icon><UserFilled /></el-icon>
<el-avatar :size="40" :src="row.avatar?getImageUrl(row.avatar):''">
<el-icon>
<UserFilled/>
</el-icon>
</el-avatar>
</template>
</el-table-column>
<el-table-column
prop="username"
label="用户名"
sortable="custom"
min-width="120"
prop="username"
label="用户名"
min-width="120"
>
<template #default="{ row }">
<div class="user-info">
<div class="username">{{ row.username }}</div>
<div class="username">{{ maskPhoneNumber(row.username) }}</div>
<div class="user-id">ID: {{ row.id }}</div>
</div>
</template>
</el-table-column>
<el-table-column
prop="real_name"
label="姓名"
min-width="120"
prop="real_name"
label="姓名"
min-width="120"
>
<template #default="{ row }">
<span>{{ row.real_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="城市" width="100">
<template #default="{ row }">
<span>{{ row.city_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="地区" width="120">
<template #default="{ row }">
<span>{{ row.district_name || '-' }}</span>
</template>
</el-table-column>
<el-table-column label="余额" width="120" sortable="custom">
<el-table-column label="余额" width="120">
<template #default="{ row }">
<span class="balance-amount">¥{{ formatBalance(row.balance) }}</span>
</template>
</el-table-column>
<el-table-column label="积分" width="100" sortable="custom">
<el-table-column label="积分" width="100">
<template #default="{ row }">
<span class="points-amount">{{ formatPoints(row.points) }}</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getUserStatusType(row)" size="small">
{{ getUserStatusText(row) }}
</el-tag>
</template>
</el-table-column>
<el-table-column
prop="created_at"
label="注册时间"
sortable="custom"
width="180"
prop="created_at"
label="注册时间"
width="180"
>
<template #default="{ row }">
{{ formatDate(row.created_at) }}
{{ convertToDateOnly(row.created_at) }}
</template>
</el-table-column>
<el-table-column
prop="last_login_at"
label="最后登录"
width="180"
>
<template #default="{ row }">
{{ row.last_login_at ? formatDate(row.last_login_at) : '从未登录' }}
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="showUserDetail(row)"
type="primary"
size="small"
@click="showUserDetail(row)"
>
查看详情
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
v-model:current-page="pagination.page"
v-model:page-size="pagination.limit"
:total="pagination.total"
:page-sizes="[10, 20, 50, 100]"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handlePageChange"
/>
</el-card>
<!-- 用户详情对话框 -->
<el-dialog
v-model="detailDialogVisible"
title="用户详情"
width="800px"
v-model="detailDialogVisible"
title="用户详情"
width="800px"
>
<div v-if="selectedUser" class="user-detail">
<el-descriptions :column="2" border>
<el-descriptions-item label="头像">
<el-avatar :size="60" :src="selectedUser.avatar">
<el-icon><UserFilled /></el-icon>
<el-avatar :size="60" :src="getImageUrl(selectedUser.avatar)">
<el-icon>
<UserFilled/>
</el-icon>
</el-avatar>
</el-descriptions-item>
<el-descriptions-item label="用户名">{{ selectedUser.username }}</el-descriptions-item>
<el-descriptions-item label="用户名">{{ maskPhoneNumber(selectedUser.username) }}</el-descriptions-item>
<el-descriptions-item label="真实姓名">{{ selectedUser.real_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ selectedUser.phone || '-' }}</el-descriptions-item>
<el-descriptions-item label="手机号">{{ maskPhoneNumber(selectedUser.phone) || '-' }}</el-descriptions-item>
<el-descriptions-item label="身份证号">{{ selectedUser.id_card || '-' }}</el-descriptions-item>
<el-descriptions-item label="城市">{{ selectedUser.city_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="地区">{{ selectedUser.district_name || '-' }}</el-descriptions-item>
<el-descriptions-item label="余额">¥{{ formatBalance(selectedUser.balance) }}</el-descriptions-item>
<el-descriptions-item label="积分">{{ formatPoints(selectedUser.points) }}</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ formatDate(selectedUser.created_at) }}</el-descriptions-item>
<el-descriptions-item label="最后登录">{{ selectedUser.last_login_at ? formatDate(selectedUser.last_login_at) : '从未登录' }}</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="getUserStatusType(selectedUser)" size="small">
{{ getUserStatusText(selectedUser) }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="注册时间">{{ convertToDateOnly(selectedUser.created_at) }}</el-descriptions-item>
</el-descriptions>
</div>
</el-dialog>
@@ -297,19 +239,21 @@
</template>
<script setup>
import { ref, reactive, onMounted, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Search, Refresh, User, UserFilled, Money, Plus, Download } from '@element-plus/icons-vue'
import {ref, reactive, onMounted, computed} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import {Search, Refresh, User, UserFilled, Money, Plus, Download} from '@element-plus/icons-vue'
import api from '@/utils/api'
import {getImageUrl} from "@/utils/config";
import {maskPhoneNumber,convertToDateOnly} from "@/utils/public_method"
// 响应式数据
const loading = ref(false)
const users = ref([])
const stats = ref({
totalUsers: 0,
activeUsers: 0,
totalBalance: 0,
todayNewUsers: 0
total_users: 0,
active_users: 0,
total_balance: 0,
today_new_users: 0
})
const availableCities = ref([])
const searchDistricts = ref([])
@@ -339,16 +283,23 @@ const fetchUsers = async () => {
const params = {
page: pagination.page,
limit: pagination.limit,
keyword: searchForm.keyword,
status: searchForm.status,
city: searchForm.city,
district: searchForm.district,
sort: searchForm.sort
search: searchForm.keyword,
sort_by: 'created_at_desc',
}
/**
*
* status: searchForm.status,
* city: searchForm.city,
* district: searchForm.district,
* sort: searchForm.sort
* @type {axios.AxiosResponse<any>}
*/
const response = await api.users.getUsers(params)
users.value = response.data.users
pagination.total = response.data.total
users.value = response.data.data.users
pagination.total = response.data.data.pagination.total
stats.value = response.data.data.stats
console.log(stats.value)
} catch (error) {
console.error('获取用户列表失败:', error)
ElMessage.error('获取用户列表失败')
@@ -361,7 +312,6 @@ const fetchUsers = async () => {
const fetchStats = async () => {
try {
const response = await api.users.getStats()
stats.value = response.data
} catch (error) {
console.error('获取统计数据失败:', error)
}
@@ -399,7 +349,7 @@ const handleSizeChange = (size) => {
}
// 排序变化
const handleSortChange = ({ prop, order }) => {
const handleSortChange = ({prop, order}) => {
if (order === 'ascending') {
searchForm.sort = `${prop}_asc`
} else if (order === 'descending') {
@@ -450,7 +400,7 @@ const getUserStatusType = (user) => {
const lastLogin = new Date(user.last_login_at)
const now = new Date()
const daysDiff = (now - lastLogin) / (1000 * 60 * 60 * 24)
if (daysDiff <= 7) return 'success'
if (daysDiff <= 30) return 'warning'
return 'danger'
@@ -462,7 +412,7 @@ const getUserStatusText = (user) => {
const lastLogin = new Date(user.last_login_at)
const now = new Date()
const daysDiff = (now - lastLogin) / (1000 * 60 * 60 * 24)
if (daysDiff <= 1) return '今日活跃'
if (daysDiff <= 7) return '本周活跃'
if (daysDiff <= 30) return '本月活跃'
@@ -471,7 +421,8 @@ const getUserStatusText = (user) => {
// 格式化余额
const formatBalance = (balance) => {
return (balance || 0).toFixed(2)
if (balance === null || balance === undefined) return '0.00'
return Number(balance).toFixed(2)
}
// 格式化积分
@@ -488,7 +439,7 @@ const formatDate = (date) => {
// 初始化
onMounted(() => {
fetchUsers()
fetchStats()
// fetchStats()
})
</script>
@@ -526,7 +477,7 @@ onMounted(() => {
gap: 8px;
}
.stats-row {
.stats-section {
margin-bottom: 20px;
}