2025-09-05
仪表盘修改(接口对接完成) 用户管理(接口对接完成) 转账管理(暂未完成)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
368
src/utils/api.js
368
src/utils/api.js
@@ -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
|
||||
@@ -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
131
src/utils/public_method.js
Normal 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}`;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user