Compare commits

...

33 Commits

Author SHA1 Message Date
dzl
427039e7c3 Merge branch 'master' of http://49.232.99.129:3000/admin/jurong_circle_agent_backstage 2025-10-23 14:30:58 +08:00
dzl
24f416d6c8 修bug 2025-10-23 14:28:06 +08:00
873c9ba125 提交 2025-09-25 11:03:55 +08:00
dzl
5dc508eb99 调整余额显示 2025-09-17 14:19:58 +08:00
dzl
35f62a6b3e 页面更改 2025-09-17 14:10:22 +08:00
dzl
a083dcfbd4 接口变量对接 2025-09-16 15:06:23 +08:00
dzl
15f06c2ebe 调整 2025-09-16 11:47:21 +08:00
dzl
c8b3fe0d04 更改样式 2025-09-16 09:50:29 +08:00
dzl
2e14978455 更改 2025-09-15 21:00:34 +08:00
dzl
f3645ce610 仪表盘显示调整 2025-09-15 17:18:22 +08:00
dzl
307f5be716 仪表盘显示调整 2025-09-15 17:18:04 +08:00
dzl
9da9ec72fa 更改直营代理和代理的页面显示 2025-09-15 10:31:41 +08:00
dzl
6d4b1ba1eb 更改导航栏和部分样式 2025-09-12 16:53:42 +08:00
dzl
a97aaedda3 细节修改 2025-09-11 17:00:50 +08:00
dzl
cc42cda34d 文本更改 2025-09-11 16:01:16 +08:00
dzl
dd465d1ff1 更改测试数据 2025-09-11 09:30:14 +08:00
dzl
58b7c0f98e 更新显示判定 2025-09-10 16:41:05 +08:00
faf61bfa6c 2025-09-10
[修改]直营列表用户提现
2025-09-10 16:10:15 +08:00
b933a46fb8 2025-09-10
[修改]直营列表
2025-09-10 15:54:43 +08:00
a1328ab8ff 2025-09-10
[修改]直营列表
2025-09-10 15:15:13 +08:00
5ce6d005ed 2025-09-10
[修改]用户管理icon
2025-09-10 14:55:04 +08:00
dzl
aa2de05831 删除未使用的接口 2025-09-10 09:57:14 +08:00
dzl
2a85c782ae 更改导航栏显示规则 2025-09-10 09:42:07 +08:00
8cb66058ae 2025-09-09
修改创建直营用户
2025-09-09 19:21:51 +08:00
dzl
383f6d63c2 更改样式 2025-09-09 15:44:33 +08:00
dzl
c1397a38c3 调整布局 2025-09-09 15:01:13 +08:00
dzl
d6baee4da9 修改搜索 2025-09-09 14:57:07 +08:00
dzl
54cb92d52c 修改搜索关键词 2025-09-09 14:52:47 +08:00
dzl
8b40dcceca 新增直营列表 2025-09-09 14:42:37 +08:00
2d7d81e1b6 2025-09-08
转账管理完成
其他部分修改
去掉个人资料
2025-09-08 11:41:26 +08:00
61511e1fc6 2025-09-08
getImageUrl
2025-09-08 09:10:30 +08:00
bcefd2ba71 2025-09-05
仪表盘修改(接口对接完成)
用户管理(接口对接完成)
转账管理(暂未完成)
2025-09-07 15:37:39 +08:00
73456f6ecf 2025-09-05
仪表盘修改(接口对接完成)
用户管理(接口对接完成)
转账管理(暂未完成)
2025-09-05 16:55:43 +08:00
14 changed files with 2783 additions and 923 deletions

4
package-lock.json generated
View File

@@ -1,11 +1,11 @@
{
"name": "admin-system",
"name": "agent-admin-system",
"version": "0.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "admin-system",
"name": "agent-admin-system",
"version": "0.0.0",
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",

View File

@@ -15,12 +15,18 @@
router
class="sidebar-menu"
>
<el-menu-item index="/dashboard">
<el-menu-item index="/dashboard_agent" v-if="userType === 'agent'">
<el-icon><Odometer /></el-icon>
<template #title>仪表盘</template>
<template #title>仪表盘代理</template>
</el-menu-item>
<el-menu-item index="/dashboard_directly" v-if="userType === 'agent_directly'">
<!-- <el-menu-item index="/dashboard_directly"> -->
<el-icon><Odometer /></el-icon>
<template #title>仪表盘直营代理</template>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/users">
<el-menu-item index="/users" v-if="userType === 'agent'">
<el-icon><User /></el-icon>
<template #title>用户管理</template>
</el-menu-item>
@@ -52,10 +58,15 @@
<el-menu-item v-if="userStore.isAdmin" index="/transfers">
<el-menu-item index="/transfers" v-if="userType === 'agent'">
<el-icon><Money /></el-icon>
<template #title>转账管理</template>
</el-menu-item>
<el-menu-item index="/direct-sale" v-if="userType === 'agent_directly'">
<el-icon><Coin /></el-icon>
<template #title>直营列表</template>
</el-menu-item>
<el-menu-item v-if="userStore.isAdmin" index="/daily-transfer-stats">
<el-icon><DataAnalysis /></el-icon>
@@ -87,7 +98,7 @@
<template #title>数据库监控</template>
</el-menu-item> -->
<el-menu-item index="/profile">
<el-menu-item v-if="false" index="/profile">
<el-icon><UserFilled /></el-icon>
<template #title>个人资料</template>
</el-menu-item>
@@ -141,7 +152,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>
@@ -149,15 +160,15 @@
</div>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="profile">
<el-dropdown-item v-if="false" command="profile">
<el-icon><UserFilled /></el-icon>
个人资料
</el-dropdown-item>
<el-dropdown-item command="changePassword">
<el-dropdown-item v-if="false" command="changePassword">
<el-icon><Lock /></el-icon>
修改密码
</el-dropdown-item>
<el-dropdown-item divided command="logout">
<el-dropdown-item command="logout">
<el-icon><SwitchButton /></el-icon>
退出登录
</el-dropdown-item>
@@ -228,6 +239,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,
@@ -257,12 +270,24 @@ import {
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
const userType = computed(() => {
try {
const adminUser = localStorage.getItem('admin_user')
if (adminUser) {
return JSON.parse(adminUser).user_type
}
} catch (error) {
console.log('获取用户类型失败', error)
}
return null
})
// 组件挂载时不再自动验证token避免登录后立即触发401错误
// token验证交给具体的API调用时处理
onMounted(() => {
// 仅确保用户状态已正确加载
console.log('Layout组件已挂载用户状态:', userStore.isAuthenticated)
console.log('userStore:', userStore)
})
// 侧边栏折叠状态

View File

@@ -2,6 +2,20 @@ import { createRouter, createWebHistory } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { ElMessage } from 'element-plus'
import NProgress from 'nprogress'
import { computed } from 'vue'
const userType = computed(() => {
try {
const adminUser = localStorage.getItem('admin_user')
if (adminUser) {
console.log('user_type:', JSON.parse(adminUser).user_type)
return JSON.parse(adminUser).user_type
}
} catch (error) {
console.log('获取用户类型失败', error)
}
return null
})
const routes = [
{
@@ -16,20 +30,29 @@ const routes = [
{
path: '/',
component: () => import('@/layout/Layout.vue'),
redirect: '/dashboard',
redirect: userType.value === 'agent' ? '/dashboard_agent' : userType.value === 'agent_directly' ? '/dashboard_directly' : '/login',
meta: {
requiresAuth: true
},
children: [
{
path: 'dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
path: 'dashboard_agent',
name: 'DashboardAgent',
component: () => import('@/views/DashboardAgent.vue'),
meta: {
title: '数据统计 - 代理后台管理系统',
icon: 'Odometer'
}
},
{
path: 'dashboard_directly',
name: 'DashboardDirectly',
component: () => import('@/views/DashboardDirectly.vue'),
meta: {
title: '数据统计 - 直营代理后台管理系统',
icon: 'Odometer'
}
},
{
path: 'users',
name: 'Users',
@@ -48,6 +71,15 @@ const routes = [
icon: 'Money'
}
},
{
path: 'direct-sale',
name: 'DirectSale',
component: () => import('@/views/DirectSale.vue'),
meta: {
title: '直接销售 - 代理后台管理系统',
icon: 'Coin'
}
},
{
path: 'commissions',
name: 'Commissions',

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,223 @@ 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 400:
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"),
// 直营代理数据统计
getStatsAgentDirectly: () => request.get('/agent/stats_agent_directly'),
},
// 佣金管理
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')
},
// 直营列表
directSale: {
getStats: () => request.get('/direct-sale/stats'),// 获取整体数据
getDirectSales: (params) => request.get('/direct-sale', {params}),// 获取直营列表
withdraw: (data) => request.post(`/users/withdraw`, data),// 提现
// 直营用户
addUser: (data) => request.post(`/users/create`, data),
listUsers: (params) => request.get('/users/directly_operated', {params}),
},
// 文件上传
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,13 +2,13 @@
const config = {
development: {
baseURL: 'https://minio.zrbjr.com',
uploadURL: 'http://localhost:3002/api/upload',
apiURL: 'http://localhost:3002/api'
uploadURL: 'http://192.168.0.11:3002/api/upload',
apiURL: 'http://192.168.0.11:3002/api'
},
production: {
baseURL: 'https://minio.zrbjr.com',
uploadURL: `${window.location.origin}/api/upload`,
apiURL: `${window.location.origin}/api`
uploadURL: `/api/upload`,
apiURL: `/api`
}
}
@@ -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,21 @@
<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>
</div>
<div class="quick-stat">
<span class="stat-value">{{ statsData.todayCommission }}</span>
<span class="stat-label">今日佣金</span>
<span class="stat-value">{{ statsData.users.today_new_users }}</span>
<span class="stat-label">今日新增用户</span>
</div>
</div>
<div class="header-actions">
@@ -32,24 +30,30 @@
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-section">
<el-row :gutter="24" class="stats-row">
<el-col :xs="24" :sm="12" :md="6" :lg="6" v-for="stat in stats" :key="stat.key">
<el-row :gutter="20" class="stats-row">
<el-col
v-for="stat in stats"
:key="stat.key"
class="stat-col"
>
<div class="stat-card" :class="stat.class" @click="handleStatClick(stat.key)">
<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,59 +154,60 @@
</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-avatar :size="40" :src="getImageUrl(user.avatar)">
<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-title">{{ maskPhoneNumber(user.username) }}</div>
<div class="activity-desc">{{ maskPhoneNumber(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">
<template #header>
<div class="card-header">
<span class="card-title">最新佣金</span>
<el-link type="primary" @click="$router.push('/commissions')">查看全部</el-link>
<span class="card-title">最新营收</span>
<!-- <el-link type="primary" @click="$router.push('/commissions')">查看全部</el-link> -->
</div>
</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">{{ maskPhoneNumber(commission.username) + ''+ maskPhoneNumber(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,12 @@
</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 {getImageUrl} from '@/utils/config'
import {ElMessage} from 'element-plus'
import dayjs from 'dayjs'
import {
User,
@@ -223,15 +238,16 @@ 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,
LegendComponent,
GridComponent
} from 'echarts/components'
import {maskPhoneNumber} from "../utils/public_method";
// ECharts
use([
@@ -251,14 +267,19 @@ 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,
today_new_users: 0,
active_users: 0,
},
commissions: {
total_commission: 0,
monthly_commission: 0
}
})
//
@@ -275,44 +296,39 @@ const userActivityData = ref([])
const stats = computed(() => [
{
key: 'users',
label: '下级用户总数',
value: statsData.value.totalUsers,
label: '直推用户数量',
value: statsData.value.users.total_users,
icon: User,
class: 'stat-users',
change: '+12.5%',
changeClass: 'positive',
changeIcon: ArrowUp
},
{
key: 'active_users',
label: '分享用户数量',
value: statsData.value.users.active_users || 0,
icon: TrendCharts,
class: 'stat-users'
},
{
key: 'commission',
label: '累计佣金',
value: `¥${statsData.value.totalCommission}`,
label: '营收',
value: `¥${statsData.value.commissions.total_commission}`,
icon: Money,
class: 'stat-commission',
change: '+8.2%',
changeClass: 'positive',
changeIcon: ArrowUp
},
{
key: 'today',
label: '今日佣金',
value: `¥${statsData.value.todayCommission}`,
icon: Coin,
class: 'stat-today',
change: '+15.3%',
changeClass: 'positive',
changeIcon: ArrowUp
class: 'stat-commission'
},
{
key: 'pending',
label: '待发放佣金',
value: `¥${statsData.value.pendingCommission}`,
label: '本月营收',
value: `¥${statsData.value.commissions.monthly_commission}`,
icon: Clock,
class: 'stat-pending',
change: '-2.1%',
changeClass: 'negative',
changeIcon: ArrowDown
}
class: 'stat-pending'
},
// {
// key: 'nature_users',
// label: '',
// value: `${statsData.value.users.nature_users || 0}`,
// icon: Clock,
// class: 'stat-pending'
// }
])
//
@@ -377,7 +393,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 +412,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 +471,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 +508,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',statsData.value)
} catch (error) {
ElMessage.error('获取统计数据失败')
}
@@ -506,13 +535,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 +549,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 +616,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 +634,7 @@ const refreshData = async () => {
await Promise.all([
loadStats(),
loadChartData(),
loadRecentData()
// loadRecentData()
])
ElMessage.success('数据刷新成功')
} catch (error) {
@@ -573,6 +652,7 @@ watch(userChartPeriod, () => {
//
onMounted(() => {
refreshData()
// console.log('statsData.users:',statsData.value.users)
})
</script>
@@ -652,6 +732,43 @@ onMounted(() => {
margin-bottom: 24px;
}
.stats-row {
display: flex;
flex-wrap: wrap;
}
.stat-col {
flex: 1;
min-width: 200px; /* 根据需要调整最小宽度 */
max-width: 25%; /* 5个卡片就是 100% / 5 = 20% */
margin-bottom: 20px;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.stat-col {
max-width: 25%; /* 4个一行 */
}
}
@media (max-width: 992px) {
.stat-col {
max-width: 33.333%; /* 3个一行 */
}
}
@media (max-width: 768px) {
.stat-col {
max-width: 50%; /* 2个一行 */
}
}
@media (max-width: 480px) {
.stat-col {
max-width: 100%; /* 1个一行 */
}
}
.stat-card {
background: white;
border-radius: 12px;
@@ -749,6 +866,7 @@ onMounted(() => {
.chart-container {
height: 320px;
width: 100%;
}
.activity-list {
@@ -759,9 +877,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 +944,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

@@ -0,0 +1,960 @@
<template>
<div class="dashboard">
<div class="dashboard-header">
<div class="header-content">
<div class="header-left">
<div class="welcome-section">
<div class="greeting-icon">
<el-icon>
<View/>
</el-icon>
</div>
<div class="greeting-text">
<h1 class="page-title">直营代理数据统计</h1>
<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.users.total_users }}</span>
<span class="stat-label">总用户数</span>
</div> -->
<div class="quick-stat">
<span class="stat-value">{{ statsData.commissions.total_commission }}</span>
<span class="stat-label">总收入</span>
</div>
</div>
<div class="header-actions">
<el-button type="primary" :icon="Refresh" @click="refreshData" :loading="loading" class="refresh-btn">
刷新数据
</el-button>
</div>
</div>
</div>
</div>
<!-- 统计卡片 -->
<div class="stats-section">
<el-row :gutter="20" class="stats-row">
<el-col
v-for="stat in stats"
:key="stat.key"
class="stat-col"
>
<div class="stat-card" :class="stat.class">
<div class="stat-background"></div>
<div class="stat-content">
<div class="stat-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>
</div>
<div class="stat-decoration"></div>
</div>
</el-col>
</el-row>
</div>
<!-- 图表区域 -->
<el-row :gutter="20" class="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-select v-model="userChartPeriod" size="small" style="width: 100px" @change="loadUserChart">
<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">
<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>
<el-tag type="success" size="small">近30天</el-tag>
</div>
</template>
<div class="chart-container">
<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-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-row :gutter="20" class="activity-row">
<!-- 最新用户 -->
<el-col :xs="24" :lg="12">
<el-card class="activity-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">最新下级商户</span>
</div>
</template>
<div class="activity-list">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="3" animated/>
</div>
<div v-else-if="recentUsers.length === 0" class="empty-state">
<el-empty description="暂无数据"/>
</div>
<div v-else>
<div v-for="user in recentUsers" :key="user.id" class="activity-item">
<el-avatar :size="40" :src="getImageUrl(user.avatar)">
<el-icon>
<UserFilled/>
</el-icon>
</el-avatar>
<div class="activity-info">
<div class="activity-title">{{ maskPhoneNumber(user.username) }}</div>
<div class="activity-desc">{{ maskPhoneNumber(user.phone) }}</div>
<div class="activity-time">{{ formatTime(user.created_at) }}</div>
</div>
</div>
</div>
</div>
</el-card>
</el-col>
<!-- 最新营收 -->
<el-col :xs="24" :lg="12">
<el-card class="activity-card" shadow="hover">
<template #header>
<div class="card-header">
<span class="card-title">最新营收</span>
<!-- <el-link type="primary" @click="$router.push('/commissions')">查看全部</el-link> -->
</div>
</template>
<div class="activity-list">
<div v-if="loading" class="loading-container">
<el-skeleton :rows="3" animated/>
</div>
<div v-else-if="recentCommissions.length === 0" class="empty-state">
<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>
</div>
<div class="activity-info">
<div class="activity-title">+{{ commission.commission_amount }}</div>
<div class="activity-desc">{{ maskPhoneNumber(commission.username) + ''+ maskPhoneNumber(commission.real_name) +'' }}</div>
<div class="activity-time">{{ formatTime(commission.created_at) }}</div>
</div>
<!-- <el-tag type="info" size="small">-->
<!-- {{ commission.commission_type }}-->
<!-- </el-tag>-->
</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup>
import {ref, reactive, onMounted, computed, watch} from 'vue'
import {useUserStore} from '@/stores/user'
import {useRouter} from 'vue-router'
import api from '@/utils/api'
import {getImageUrl} from '@/utils/config'
import {ElMessage} from 'element-plus'
import dayjs from 'dayjs'
import {
User,
View,
TrendCharts,
ArrowUp,
ArrowDown,
Refresh,
UserFilled,
Money,
Clock,
Coin,
Avatar,
Watermelon,
Sell
} 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 {
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent
} from 'echarts/components'
import {maskPhoneNumber} from "../utils/public_method";
// 注册 ECharts 组件
use([
CanvasRenderer,
LineChart,
PieChart,
BarChart,
TitleComponent,
TooltipComponent,
LegendComponent,
GridComponent
])
const userStore = useUserStore()
const router = useRouter()
// 响应式数据
const loading = ref(true)
const chartLoading = ref(false)
const userChartPeriod = ref('30')
// 统计数据
const statsData = ref({
users: {
// total_users: 0,
total_operated: 0,
agent_share_users: 0,
operated_share_users: 0,
total_directly_agents: 0
},
commissions: {
total_commission: 0,
directly_agents_income: 0,
active_users_income: 0,
operated_commission: 0,
get_commission: 0,
loading_commission: 0,
}
})
// 最新数据
const recentUsers = ref([])
const recentCommissions = ref([])
// 图表数据
const userChartData = ref([])
const commissionChartData = ref([])
const commissionTypeData = ref([])
const userActivityData = ref([])
// 统计卡片配置
const stats = computed(() => [
{
key: 'users',
label: '直销商户人数',
value: statsData.value.users.total_directly_agents,
icon: Avatar,
class: 'stat-users1',
change: `直销商户利润${statsData.value.commissions.directly_agents_income}`,
changeClass: statsData.value.commissions.directly_agents_income >= 0 ? 'positive' : 'negative',
changeIcon: statsData.value.commissions.directly_agents_income >= 0 ? 'ArrowUp' : 'ArrowDown'
},
{
key: 'users',
label: '直销商户分享人数',
value: statsData.value.users.agent_share_users,
icon: User,
class: 'stat-users2',
change: `直销商户分享利润${statsData.value.commissions.active_users_income}`,
changeClass: statsData.value.commissions.active_users_income >= 0 ? 'positive' : 'negative',
changeIcon: statsData.value.commissions.active_users_income >= 0 ? 'ArrowUp' : 'ArrowDown'
},
{
key: 'users',
label: '直营人数',
value: `${statsData.value.users.total_operated}`,
icon: Money,
class: 'stat-users3',
change: `直营营收${statsData.value.commissions.operated_commission}`,
changeClass: statsData.value.commissions.operated_commission >= 0 ? 'positive' : 'negative',
changeIcon: statsData.value.commissions.operated_commission >= 0 ? 'ArrowUp' : 'ArrowDown'
},
{
key: 'users',
label: '直营商户人数',
value: `${statsData.value.users.active_users}`,
icon: Watermelon,
class: 'stat-users4',
change: `已提现营收${statsData.value.commissions.get_commission}`,
changeClass: statsData.value.commissions.get_commission >= 0 ? 'positive' : 'negative',
changeIcon: statsData.value.commissions.get_commission >= 0 ? 'ArrowUp' : 'ArrowDown'
},
{
key: 'users',
label: '直营商户分享人数',
value: `${statsData.value.users.operated_share_users}`,
icon: Sell,
class: 'stat-users5',
change: `待提现营收${statsData.value.commissions.loading_commission}`,
changeClass: statsData.value.commissions.loading_commission >= 0 ? 'positive' : 'negative',
changeIcon: statsData.value.commissions.loading_commission >= 0 ? 'ArrowUp' : 'ArrowDown'
}
])
// 用户增长图表配置
const userChartOption = computed(() => ({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: userChartData.value.map(item => item.date)
},
yAxis: {
type: 'value'
},
series: [{
name: '新增用户',
type: 'line',
smooth: true,
data: userChartData.value.map(item => item.count),
itemStyle: {
color: '#409EFF'
},
areaStyle: {
color: {
type: 'linear',
x: 0,
y: 0,
x2: 0,
y2: 1,
colorStops: [{
offset: 0, color: 'rgba(64, 158, 255, 0.3)'
}, {
offset: 1, color: 'rgba(64, 158, 255, 0.1)'
}]
}
}
}]
}))
// 营收收入图表配置
const commissionChartOption = computed(() => ({
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'cross'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: commissionChartData.value.map(item => item.date),
},
yAxis: {
type: 'value'
},
series: [{
name: '营收收入',
type: 'bar',
data: commissionChartData.value.map(item => item.amount),
itemStyle: {
color: '#67C23A'
}
}]
}))
// 营收类型分布图表配置
const commissionTypeOption = computed(() => ({
tooltip: {
trigger: 'item',
formatter: '{a}{b}<br/>数量: {c}<br/>占比:{d}%<br/>营收金额:{e}'
},
legend: {
orient: 'vertical',
left: 'left'
},
series: [{
name: '营收类型',
type: 'pie',
radius: '50%',
data: commissionTypeData.value,
emphasis: {
itemStyle: {
shadowBlur: 10,
shadowOffsetX: 0,
shadowColor: 'rgba(0, 0, 0, 0.5)'
}
}
}]
}))
// 用户活跃度图表配置
const userActivityOption = computed(() => ({
tooltip: {
trigger: 'axis'
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: userActivityData.value.map(item => item.date)
},
yAxis: {
type: 'value'
},
series: [{
name: '活跃用户',
type: 'line',
data: userActivityData.value.map(item => item.count),
itemStyle: {
color: '#E6A23C'
}
}]
}))
// 获取问候语
const getGreeting = () => {
const hour = new Date().getHours()
if (hour < 12) return '早上好'
if (hour < 18) return '下午好'
return '晚上好'
}
// 格式化时间
const formatTime = (time) => {
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')
}
}
// 加载统计数据
const loadStats = async () => {
try {
const response = await api.dashboard.getStatsAgentDirectly()
statsData.value = response.data.data
console.log('statsData',statsData.value)
} catch (error) {
ElMessage.error('获取统计数据失败')
}
}
// 加载图表数据
const loadChartData = async () => {
chartLoading.value = true
try {
await loadUserChart()
await loadCommissionTrendData()
await loadCommissionDistributionData()
await loadRecentUsersData()
await loadRecentCommissionsData()
} catch (error) {
ElMessage.error('获取图表数据失败')
} finally {
chartLoading.value = false
}
}
// 加载用户图表
const loadUserChart = async () => {
try {
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 {
}
}
// 加载最新数据
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'})
])
recentUsers.value = usersResponse.data.list || []
recentCommissions.value = commissionsResponse.data.list || []
} catch (error) {
ElMessage.error('获取最新数据失败')
}
}
// 刷新数据
const refreshData = async () => {
loading.value = true
try {
await Promise.all([
loadStats(),
loadChartData(),
// loadRecentData()
])
ElMessage.success('数据刷新成功')
} catch (error) {
ElMessage.error('数据刷新失败')
} finally {
loading.value = false
}
}
// 监听图表周期变化
watch(userChartPeriod, () => {
loadUserChart()
})
// 组件挂载时加载数据
onMounted(() => {
refreshData()
})
</script>
<style scoped>
.dashboard {
padding: 20px;
background-color: #f5f7fa;
min-height: calc(100vh - 60px);
}
.dashboard-header {
margin-bottom: 24px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 24px 32px;
border-radius: 12px;
color: white;
box-shadow: 0 8px 32px rgba(102, 126, 234, 0.3);
}
.welcome-section {
display: flex;
align-items: center;
}
.greeting-icon {
font-size: 48px;
margin-right: 20px;
opacity: 0.9;
}
.page-title {
margin: 0 0 8px 0;
font-size: 28px;
font-weight: 600;
}
.page-subtitle {
margin: 0;
font-size: 16px;
opacity: 0.9;
}
.header-right {
display: flex;
align-items: center;
gap: 24px;
}
.header-stats {
display: flex;
gap: 32px;
}
.quick-stat {
text-align: center;
}
.stat-value {
display: block;
font-size: 24px;
font-weight: 600;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
opacity: 0.8;
}
.stats-section {
margin-bottom: 24px;
}
.stats-row {
display: flex;
flex-wrap: wrap;
}
.stat-col {
flex: 1;
min-width: 200px; /* 根据需要调整最小宽度 */
max-width: 20%; /* 5个卡片就是 100% / 5 = 20% */
margin-bottom: 20px;
}
/* 响应式调整 */
@media (max-width: 1200px) {
.stat-col {
max-width: 25%; /* 4个一行 */
}
}
@media (max-width: 992px) {
.stat-col {
max-width: 33.333%; /* 3个一行 */
}
}
@media (max-width: 768px) {
.stat-col {
max-width: 50%; /* 2个一行 */
}
}
@media (max-width: 480px) {
.stat-col {
max-width: 100%; /* 1个一行 */
}
}
.stat-card {
background: white;
border-radius: 12px;
padding: 24px;
cursor: pointer;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
}
.stat-card:hover {
transform: translateY(-4px);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.15);
}
.stat-content {
display: flex;
align-items: center;
position: relative;
z-index: 2;
}
.stat-icon {
font-size: 48px;
margin-right: 16px;
}
.stat-users1 .stat-icon {
color: #409EFF;
}
.stat-users2 .stat-icon {
color: #89ce49;
}
.stat-users3 .stat-icon {
color: #3ac2b0;
}
.stat-users4 .stat-icon {
color: #E6A23C;
}
.stat-users5 .stat-icon {
color: #F56C6C;
}
.stat-number {
font-size: 32px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.stat-label {
font-size: 14px;
color: #909399;
margin-bottom: 8px;
}
.stat-change {
font-size: 12px;
display: flex;
align-items: center;
gap: 4px;
}
.stat-change.positive {
color: #67C23A;
}
.stat-change.negative {
color: #F56C6C;
}
.charts-row,
.business-charts-row,
.activity-row {
margin-bottom: 24px;
}
.chart-card,
.activity-card {
height: 400px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.chart-container {
height: 320px;
width: 100%;
}
.activity-list {
height: 320px;
overflow-y: auto;
}
.activity-item {
display: flex;
align-items: center;
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;
}
.activity-info {
flex: 1;
margin-left: 12px;
}
.activity-title {
font-size: 14px;
font-weight: 500;
color: #303133;
margin-bottom: 4px;
}
.activity-desc {
font-size: 12px;
color: #909399;
margin-bottom: 4px;
}
.activity-time {
font-size: 12px;
color: #C0C4CC;
}
.commission-icon {
width: 40px;
height: 40px;
border-radius: 50%;
background-color: #67C23A;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 18px;
}
.loading-container,
.empty-state {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.refresh-btn {
border-radius: 8px;
}
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: 16px;
text-align: center;
}
.header-stats {
gap: 16px;
}
.stat-card {
padding: 16px;
}
.stat-icon {
font-size: 36px;
}
.stat-number {
font-size: 24px;
}
}
</style>

638
src/views/DirectSale.vue Normal file
View File

@@ -0,0 +1,638 @@
<template>
<div class="direct-sale-container">
<div class="page-header">
<div class="header-left">
<h1>直营列表</h1>
<p>查看代理下的直营用户</p>
</div>
<el-button type="primary" @click="createDirectSaler">
<el-icon>
<Plus/>
</el-icon>
创建直营
</el-button>
</div>
<!-- 搜索和筛选 -->
<div class="search-section">
<el-card>
<el-form :model="searchForm" inline>
<el-form-item label="关键字">
<el-input
v-model="searchForm.search"
placeholder="请输入姓名、用户名或手机号"
clearable
style="width: 250px"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">
<el-icon>
<Search/>
</el-icon>
搜索
</el-button>
<el-button @click="handleReset">
<el-icon>
<Refresh/>
</el-icon>
重置
</el-button>
</el-form-item>
</el-form>
</el-card>
</div>
<!-- 统计信息 -->
<div class="stats-section">
<el-row :gutter="20">
<el-col :span="6">
<el-card class="stats-card">
<div class="stats-content">
<div class="stats-icon">
<el-icon color="#409EFF">
<User/>
</el-icon>
</div>
<div class="stats-info">
<div class="stats-value">{{ directSaleStats.total || 0 }}</div>
<div class="stats-label">总用户数</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stats-card">
<div class="stats-content">
<div class="stats-icon">
<el-icon color="#67C23A">
<Coin/>
</el-icon>
</div>
<div class="stats-info">
<div class="stats-value">{{ directSaleStats.total_beans || 0 }}</div>
<div class="stats-label">总融豆数量</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stats-card">
<div class="stats-content">
<div class="stats-icon">
<el-icon color="#E6A23C">
<Money/>
</el-icon>
</div>
<div class="stats-info">
<div class="stats-value">{{ directSaleStats.today_withdrawals || 0 }}</div>
<div class="stats-label">今日提现</div>
</div>
</div>
</el-card>
</el-col>
<el-col :span="6">
<el-card class="stats-card">
<div class="stats-content">
<div class="stats-icon">
<el-icon color="#F56C6C">
<TrendCharts/>
</el-icon>
</div>
<div class="stats-info">
<div class="stats-value">{{ directSaleStats.total_withdrawals || 0 }}</div>
<div class="stats-label">总提现金额</div>
</div>
</div>
</el-card>
</el-col>
</el-row>
</div>
<!-- 直营列表表格 -->
<div class="table-section">
<el-card>
<el-table
v-loading="loading"
:data="directSaleList"
stripe
style="width: 100%"
>
<el-table-column prop="id" label="ID"/>
<el-table-column prop="real_name" label="姓名"/>
<el-table-column label="手机号">
<template #default="{ row }">
{{ maskPhoneNumber(row.phone) }}
</template>
</el-table-column>
<el-table-column prop="balance" label="融豆数量">
<template #default="{ row }">
<span class="beans-amount">{{ Math.abs(row.balance) }}</span>
</template>
</el-table-column>
<el-table-column prop="level" label="等级">
<template #default="{ row }">
<el-tag :type="getLevelType(row.level)">
{{ getLevelText(row.level) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="created_at" label="注册时间">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="{ row }">
<el-button
type="primary"
size="small"
@click="handleWithdraw(row)"
>
提现
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
</div>
<!-- 直营用户 -->
<el-dialog
v-model="dialogVisible"
:title="isEdit ? '编辑用户' : '添加直营用户'"
width="600px"
:before-close="handleDialogClose"
>
<el-form
ref="userFormRef"
:model="userForm"
:rules="userRules"
label-width="100px"
>
<el-form-item label="手机号" prop="phone">
<el-input
v-model="userForm.phone"
placeholder="请输入手机号"
maxlength="11"
/>
</el-form-item>
<el-form-item label="真实姓名" prop="real_name">
<el-input
v-model="userForm.real_name"
placeholder="请输入真实姓名"
/>
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="userForm.password"
type="password"
:placeholder="isEdit ? '留空则不修改密码' : '请输入密码'"
show-password
/>
</el-form-item>
<el-form-item label="头像" prop="avatar">
<el-upload
class="avatar-uploader"
:action="uploadAction"
:headers="uploadHeaders"
:show-file-list="false"
:on-success="handleAvatarSuccess"
:before-upload="beforeAvatarUpload"
>
<img v-if="userForm.avatarReal" :src="getImageUrl(userForm.avatar)" class="avatar"/>
<el-icon v-else class="avatar-uploader-icon">
<Plus/>
</el-icon>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit" :loading="submitting">
{{ isEdit ? '更新' : '创建' }}
</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import {ref, reactive, onMounted, computed} from 'vue'
import {ElMessage, ElMessageBox} from 'element-plus'
import {
Search,
Refresh,
User,
Coin,
Money,
TrendCharts
} from '@element-plus/icons-vue'
import api from '@/utils/api'
import {maskPhoneNumber} from '@/utils/public_method'
import {useUserStore} from "@/stores/user";
import {getImageUrl} from "@/utils/config";
// 响应式数据
const loading = ref(false)
const directSaleList = ref([])
const directSaleStats = ref({
total: 0,
total_beans: 0,
today_withdrawals: 0,
total_withdrawals: 0
})
// 搜索表单
const searchForm = reactive({
search: '',
})
// 分页信息
const pagination = reactive({
page: 1,
size: 20,
total: 0
})
// 创建直营
const createDirectSaler = async () => {
isEdit.value = false
resetUserForm()
dialogVisible.value = true
}
// 获取直营列表
const getDirectSaleList = async () => {
loading.value = true
try {
const params = {
page: pagination.page,
size: pagination.size,
search: searchForm.search,
}
const response = await api.directSale.listUsers(params)
directSaleList.value = response.data.data.users
pagination.total = response.data.data.pagination.total
// TODO 统计
directSaleStats.value.total = response.data.data.pagination.total
directSaleStats.value.total_beans = response.data.data.all_total.balance_total
directSaleStats.value.today_withdrawals = response.data.data.all_total.withdraw_num_total
directSaleStats.value.total_withdrawals = response.data.data.all_total.withdraw_total
//directSaleStats.value = response.data.data.pagination
} catch (error) {
console.log(error)
ElMessage.error('获取直营列表失败')
} finally {
loading.value = false
}
}
// 获取统计数据
const getDirectSaleStats = async () => {
try {
const response = await api.directSale.getStats()
directSaleStats.value = response.data.data
} catch (error) {
console.log(error)
ElMessage.error('获取统计数据失败')
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
getDirectSaleList()
}
// 重置
const handleReset = () => {
searchForm.search = ''
pagination.page = 1
getDirectSaleList()
}
// 分页大小改变
const handleSizeChange = (size) => {
pagination.size = size
pagination.page = 1
getDirectSaleList()
}
// 当前页改变
const handleCurrentChange = (page) => {
pagination.page = page
getDirectSaleList()
}
// 处理提现
const handleWithdraw = async (row) => {
try {
ElMessageBox.prompt(
`请输入提现金额`,
'提现确认',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info',
inputPattern:
/^(\d+(\.\d*)?|\.\d+)([eE][+-]?\d+)?$/,
inputErrorMessage: '请输入正确提现金额',
}
).then(({value}) => {
if (value > 0){
var params = {
userId: row.id,
amount: value,
}
api.directSale.withdraw(params).then((res) => {
ElMessage.success('提现操作成功')
// 刷新列表和统计数据
getDirectSaleList()
})
} else {
ElMessage.error('请输入正确提现金额')
}
})
} catch (error) {
if (error !== 'cancel') {
console.log(error)
ElMessage.error('提现操作失败')
}
}
}
// 格式化日期
const formatDate = (date) => {
if (!date) return '-'
return new Date(date).toLocaleString('zh-CN')
}
// 获取等级类型
const getLevelType = (level) => {
const levelMap = {
'normal': '',
'vip': 'success',
'svip': 'warning'
}
return levelMap[level] || ''
}
// 获取等级文本
const getLevelText = (level) => {
const levelMap = {
'normal': '普通',
'vip': 'VIP',
'svip': 'SVIP'
}
return levelMap[level] || '普通'
}
// 直营用户
const userStore = useUserStore()
const dialogVisible = ref(false)
const isEdit = ref(false)
const submitting = ref(false)
const userFormRef = ref()
const userForm = reactive({
username: '',
phone: '',
password: '',
avatar: '',
invite: '',
real_name: '',
avatarReal: '', // 上传临时查看
})
const userRules = computed(() => ({
password: [
{required: !isEdit.value, message: '请输入密码', trigger: 'blur'},
{min: 6, message: '密码长度不能少于6位', trigger: 'blur'}
],
real_name: [
{required: true, message: '请输入真实姓名', trigger: 'blur'},
{min: 2, max: 10, message: '姓名长度在 2 到 10 个字符', trigger: 'blur'}
],
phone: [
{required: !isEdit.value, message: '请输入电话', trigger: 'blur'},
{pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号格式', trigger: 'blur'}
],
}))
// 头像上传
const uploadAction = '/api/upload/image'
const uploadHeaders = computed(() => {
const token = userStore.token || localStorage.getItem('admin_token')
if (!token) {
console.warn('上传时未找到认证令牌')
return {}
}
return {
Authorization: `Bearer ${token}`
}
})
// 头像上传前验证
const beforeAvatarUpload = (file) => {
const isJPG = file.type === 'image/jpeg' || file.type === 'image/png'
const isLt2M = file.size / 1024 / 1024 < 2
if (!isJPG) {
ElMessage.error('头像只能是 JPG/PNG 格式!')
}
if (!isLt2M) {
ElMessage.error('头像大小不能超过 2MB!')
}
return isJPG && isLt2M
}
// 头像上传成功
const handleAvatarSuccess = (response) => {
if (response.success) {
userForm.avatar = response.data.path
userForm.avatarReal = response.data.url
ElMessage.success('头像上传成功')
} else {
ElMessage.error('头像上传失败')
}
}
const handleSubmit = () => {
userFormRef.value.validate((valid) => {
if (valid) {
submitting.value = true
userForm.username = userForm.phone
if (!isEdit.value) {
// 新增
api.directSale.addUser(userForm).then((res) => {
console.log(res)
if (res.data.success) {
ElMessage.success('用户创建成功')
}
})
} else {
// TODO 编辑
}
dialogVisible.value = false
getDirectSaleList()
}
})
}
// 清空内容
const resetUserForm = () => {
Object.assign(userForm, {
username: '',
phone: '',
password: '',
avatar: '',
invite: '',
real_name: '',
avatarReal: ''
})
}
// 关闭对话框
const handleDialogClose = () => {
resetUserForm()
dialogVisible.value = false
}
// 组件挂载时获取数据
onMounted(() => {
getDirectSaleList()
// getDirectSaleStats()
})
</script>
<style scoped lang="scss">
.direct-sale-container {
padding: 20px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 20px;
}
.header-left {
display: block;
align-items: center;
gap: 16px;
}
.header-left h1 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #303133;
white-space: nowrap;
}
.header-left p {
margin: 0;
color: #909399;
font-size: 14px;
padding-top: 4px; /* 微调垂直对齐 */
}
.search-section {
margin-bottom: 20px;
}
.stats-section {
margin-bottom: 20px;
}
.stats-card {
height: 100px;
}
.stats-content {
display: flex;
align-items: center;
height: 100%;
}
.stats-icon {
font-size: 32px;
margin-right: 16px;
}
.stats-info {
flex: 1;
}
.stats-value {
font-size: 24px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
}
.stats-label {
font-size: 14px;
color: #909399;
}
.table-section {
margin-bottom: 20px;
}
.beans-amount {
color: #67C23A;
font-weight: 600;
}
.pagination-container {
display: flex;
justify-content: center;
margin-top: 20px;
}
/* 头像上传 */
.avatar-uploader {
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
transition: border-color 0.3s;
.avatar {
width: 100px;
height: 100px;
}
}
.avatar-uploader:hover {
border-color: #409eff;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 100px;
height: 100px;
line-height: 100px;
text-align: center;
display: block;
}
</style>

View File

@@ -25,7 +25,7 @@
<el-form-item prop="username">
<el-input
v-model="loginForm.username"
placeholder="用户名或邮箱"
placeholder="电话号码"
size="large"
prefix-icon="User"
clearable
@@ -103,7 +103,7 @@ const loginForm = reactive({
// 表单验证规则
const loginRules = {
username: [
{ required: true, message: '请输入用户名或邮箱', trigger: 'blur' },
{ required: true, message: '请输入电话号码', trigger: 'blur' },
{ min: 3, message: '用户名长度不能少于3位', trigger: 'blur' }
],
password: [
@@ -125,8 +125,11 @@ const handleLogin = async () => {
captchaId: captchaInfo.captchaId,
captchaText: captchaInfo.captchaText
})
console.log(result,'result');
if (result.success) {
console.log('登录信息:',JSON.parse(localStorage.getItem('admin_user')))
// 保存记住我状态
if (loginForm.remember) {
localStorage.setItem('admin_remember', 'true')
@@ -137,7 +140,7 @@ const handleLogin = async () => {
}
// 跳转到仪表盘
router.push('/dashboard')
JSON.parse(localStorage.getItem('admin_user')).user_type === 'agent' ? router.push('/dashboard_agent') : router.push('/dashboard_directly')
}
} catch (error) {
console.error('登录失败:', error)

View File

@@ -9,44 +9,64 @@
<div class="search-section">
<el-card>
<el-form :model="searchForm" inline>
<el-form-item label="用户手机号">
<el-form-item label="关键词">
<el-input
v-model="searchForm.phone"
placeholder="请输入用户手机号"
clearable
style="width: 200px"
v-model="searchForm.search"
placeholder="请输入用户名或手机号"
clearable
style="width: 250px"
/>
</el-form-item>
<el-form-item label="转账类型">
<el-select
v-model="searchForm.type"
placeholder="请选择转账类型"
clearable
style="width: 150px"
>
<el-option label="全部" value="" />
<el-option label="转入" value="in" />
<el-option label="转出" value="out" />
</el-select>
<!-- <el-form-item label="转账类型">-->
<!-- <el-select-->
<!-- v-model="searchForm.type"-->
<!-- placeholder="请选择转账类型"-->
<!-- clearable-->
<!-- style="width: 150px"-->
<!-- >-->
<!-- <el-option label="全部" value=""/>-->
<!-- <el-option label="转入" value="in"/>-->
<!-- <el-option label="转出" value="out"/>-->
<!-- </el-select>-->
<!-- </el-form-item>-->
<el-form-item label="最小金额">
<el-input
type="number"
v-model="searchForm.min_amount"
placeholder="请输入最小金额"
clearable
/>
</el-form-item>
<el-form-item label="最大金额">
<el-input
type="number"
v-model="searchForm.max_amount"
placeholder="请输入最大金额"
clearable
/>
</el-form-item>
<el-form-item label="时间范围">
<el-date-picker
v-model="searchForm.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
v-model="searchForm.dateRange"
type="daterange"
range-separator=""
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD HH:mm:ss"
/>
</el-form-item>
<el-form-item>
<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-form-item>
@@ -61,10 +81,12 @@
<el-card class="stats-card">
<div class="stats-content">
<div class="stats-icon">
<el-icon color="#409EFF"><Money /></el-icon>
<el-icon color="#409EFF">
<Money/>
</el-icon>
</div>
<div class="stats-info">
<div class="stats-value">{{ transferStats.totalAmount || 0 }}</div>
<div class="stats-value">{{ transferStats.total_amount || 0 }}</div>
<div class="stats-label">总转账金额</div>
</div>
</div>
@@ -74,10 +96,12 @@
<el-card class="stats-card">
<div class="stats-content">
<div class="stats-icon">
<el-icon color="#67C23A"><TrendCharts /></el-icon>
<el-icon color="#67C23A">
<TrendCharts/>
</el-icon>
</div>
<div class="stats-info">
<div class="stats-value">{{ transferStats.totalCount || 0 }}</div>
<div class="stats-value">{{ transferStats.total_transfers || 0 }}</div>
<div class="stats-label">总转账笔数</div>
</div>
</div>
@@ -87,11 +111,13 @@
<el-card class="stats-card">
<div class="stats-content">
<div class="stats-icon">
<el-icon color="#E6A23C"><ArrowUp /></el-icon>
<el-icon color="#E6A23C">
<Coin/>
</el-icon>
</div>
<div class="stats-info">
<div class="stats-value">{{ transferStats.inAmount || 0 }}</div>
<div class="stats-label">转入金额</div>
<div class="stats-value">{{ transferStats.today_amount || 0 }}</div>
<div class="stats-label">今日账金额</div>
</div>
</div>
</el-card>
@@ -100,11 +126,13 @@
<el-card class="stats-card">
<div class="stats-content">
<div class="stats-icon">
<el-icon color="#F56C6C"><ArrowDown /></el-icon>
<el-icon color="#F56C6C">
<List/>
</el-icon>
</div>
<div class="stats-info">
<div class="stats-value">{{ transferStats.outAmount || 0 }}</div>
<div class="stats-label">转出金额</div>
<div class="stats-value">{{ transferStats.today_transfers || 0 }}</div>
<div class="stats-label">今日账笔数</div>
</div>
</div>
</el-card>
@@ -115,51 +143,42 @@
<!-- 转账记录表格 -->
<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"
stripe
style="width: 100%"
v-loading="loading"
:data="transferList"
stripe
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="转账类型" width="100">
<template #default="{ row }">
<el-tag :type="row.type === 'in' ? 'success' : 'danger'">
{{ row.type === 'in' ? '转入' : '转出' }}
</el-tag>
<el-table-column prop="id" label="记录ID" 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="amount" label="转账金额" width="120">
<!-- <el-table-column prop="userName" label="用户姓名" width="100" />-->
<!-- <el-table-column label="转账类型" width="100">-->
<!-- <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>-->
<el-table-column prop="amount" label="转账金额">
<template #default="{ row }">
<span :class="row.type === 'in' ? 'amount-in' : 'amount-out'">
{{ row.type === 'in' ? '+' : '-' }}{{ row.amount }}
<!-- :class="row.type === 'in' ? 'amount-in' : 'amount-out'"-->
<span>
{{ row.amount }}
</span>
</template>
</el-table-column>
<el-table-column prop="balance" label="余额" width="120" />
<el-table-column prop="description" label="转账说明" min-width="150" />
<el-table-column prop="description" label="转账说明"/>
<el-table-column prop="createdAt" label="转账时间" width="180">
<template #default="{ row }">
{{ formatDate(row.createdAt) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="getStatusType(row.status)">
{{ getStatusText(row.status) }}
</el-tag>
{{ row.created_at }}
</template>
</el-table-column>
</el-table>
@@ -167,13 +186,13 @@
<!-- 分页 -->
<div class="pagination-container">
<el-pagination
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
v-model:current-page="pagination.page"
v-model:page-size="pagination.size"
:page-sizes="[10, 20, 50, 100]"
:total="pagination.total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
@@ -182,8 +201,8 @@
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage } from 'element-plus'
import {ref, reactive, onMounted} from 'vue'
import {ElMessage} from 'element-plus'
import {
Search,
Refresh,
@@ -194,23 +213,33 @@ import {
Download
} from '@element-plus/icons-vue'
import api from '@/utils/api'
import {formatIsoToCustom, maskPhoneNumber} from '@/utils/public_method'
// 响应式数据
const loading = ref(false)
const transferList = ref([])
const transferStats = ref({})
const transferStats = ref({
completed_transfers: 0, // 已完成转移
failed_transfers: 0, // 失败传输
pending_transfers: 0, // 等待传输
today_amount: 0, // 今日数量
today_transfers: 0, // 今日转账
total_amount: 0, // 总计数量
total_transfers: 0 // 总计传输次数
})
// 搜索表单
const searchForm = reactive({
phone: '',
type: '',
dateRange: []
search: '',
dateRange: [],
min_amount: null,
max_amount: null,
})
// 分页信息
const pagination = reactive({
page: 1,
size: 20,
size: 50,
total: 0
})
@@ -221,60 +250,46 @@ const getTransferList = async () => {
const params = {
page: pagination.page,
size: pagination.size,
phone: searchForm.phone,
type: searchForm.type
search: searchForm.search,
min_amount: searchForm.min_amount,
max_amount: searchForm.max_amount
}
if (searchForm.dateRange && searchForm.dateRange.length === 2) {
params.startDate = searchForm.dateRange[0]
params.endDate = searchForm.dateRange[1]
params.start_date = searchForm.dateRange[0]
params.end_date = searchForm.dateRange[1].split(" ")[0] + ' 23:59:59'
}
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
transferStats.value = response.data.data.stats
} catch (error) {
console.log(error)
ElMessage.error('获取转账记录失败')
} finally {
loading.value = false
}
}
// 获取转账统计
const getTransferStats = async () => {
try {
const params = {
phone: searchForm.phone,
type: searchForm.type
}
if (searchForm.dateRange && searchForm.dateRange.length === 2) {
params.startDate = searchForm.dateRange[0]
params.endDate = searchForm.dateRange[1]
}
const response = await api.transfers.getStats(params)
transferStats.value = response.data
} catch (error) {
ElMessage.error('获取统计数据失败')
}
}
// 搜索
const handleSearch = () => {
pagination.page = 1
getTransferList()
getTransferStats()
if (searchForm.min_amount > searchForm.max_amount) {
ElMessage.error('最小金额不能超过最大金额')
} else {
getTransferList()
}
}
// 重置
const handleReset = () => {
searchForm.phone = ''
searchForm.type = ''
searchForm.search = ''
searchForm.dateRange = []
searchForm.min_amount = null
searchForm.max_amount = null
pagination.page = 1
getTransferList()
getTransferStats()
}
// 分页大小改变
@@ -324,7 +339,6 @@ const getStatusText = (status) => {
// 组件挂载时获取数据
onMounted(() => {
getTransferList()
getTransferStats()
})
</script>

View File

@@ -4,292 +4,215 @@
<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-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">
<div class="stat-content">
<div class="stat-value">{{ stats.totalUsers }}</div>
<div class="stat-label">下级用户总数</div>
<div class="stats-icon">
<el-icon color="#409EFF">
<User/>
</el-icon>
</div>
<div class="stats-info">
<div class="stat-value">{{ stats.total_users }}</div>
<div class="stat-label">下级用户总数</div>
</div>
</div>
<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-label">活跃用户</div>
<div class="stats-icon">
<el-icon class="stat-icon" color="#67C23A">
<UserFilled/>
</el-icon>
</div>
<div class="stats-info">
<div class="stat-value">{{ stats.active_users }}</div>
<div class="stat-label">活跃用户</div>
</div>
</div>
<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-label">用户总余额</div>
<div class="stats-icon">
<el-icon class="stat-icon" color="#E6A23C">
<Money/>
</el-icon>
</div>
<div class="stats-info">
<div class="stat-value">¥{{ Math.abs(formatBalance(stats.total_balance)) }}</div>
<div class="stat-label">用户总余额</div>
</div>
</div>
<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-label">今日新增</div>
<div class="stats-icon">
<el-icon class="stat-icon" color="#F56C6C">
<Top/>
</el-icon>
</div>
<div class="stats-info">
<div class="stat-value">{{ stats.today_new_users }}</div>
<div class="stat-label">今日新增</div>
</div>
</div>
<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="余额">¥{{ Math.abs(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 +220,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 +264,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 +293,6 @@ const fetchUsers = async () => {
const fetchStats = async () => {
try {
const response = await api.users.getStats()
stats.value = response.data
} catch (error) {
console.error('获取统计数据失败:', error)
}
@@ -377,10 +308,6 @@ const handleSearch = () => {
const handleReset = () => {
Object.assign(searchForm, {
keyword: '',
status: '',
city: '',
district: '',
sort: 'created_at_desc'
})
pagination.page = 1
fetchUsers()
@@ -399,7 +326,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 +377,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 +389,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 +398,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 +416,7 @@ const formatDate = (date) => {
// 初始化
onMounted(() => {
fetchUsers()
fetchStats()
// fetchStats()
})
</script>
@@ -526,22 +454,32 @@ onMounted(() => {
gap: 8px;
}
.stats-row {
.stats-section {
margin-bottom: 20px;
}
.stat-card {
position: relative;
overflow: hidden;
.stats-card {
height: 100px;
}
.stat-content {
position: relative;
z-index: 2;
display: flex;
align-items: center;
height: 100%;
}
.stats-icon {
font-size: 32px;
margin-right: 16px;
}
.stats-info {
flex: 1;
}
.stat-value {
font-size: 28px;
font-size: 24px;
font-weight: 600;
color: #303133;
margin-bottom: 4px;
@@ -552,31 +490,10 @@ onMounted(() => {
color: #909399;
}
.stat-icon {
position: absolute;
right: 16px;
top: 50%;
transform: translateY(-50%);
font-size: 32px;
opacity: 0.1;
}
.table-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.card-title {
font-size: 16px;
font-weight: 600;
color: #303133;
}
.user-info {
display: flex;
flex-direction: column;

View File

@@ -1,6 +1,6 @@
import { defineConfig } from 'vite'
import {defineConfig} from 'vite'
import vue from '@vitejs/plugin-vue'
import { resolve } from 'path'
import {resolve} from 'path'
// https://vitejs.dev/config/
export default defineConfig({
@@ -16,7 +16,7 @@ export default defineConfig({
port: 5174,
proxy: {
'/api': {
target: 'http://localhost:3000',
target: 'http://192.168.1.43:3002',
changeOrigin: true
},
// '/admin': {
@@ -24,7 +24,7 @@ export default defineConfig({
// changeOrigin: true
// },
'/uploads': {
target: 'http://localhost:3000',
target: 'http://192.168.1.43:3002',
changeOrigin: true
}
}