Files
webman-buildadmin/web/src/views/backend/dashboard.vue
2026-04-18 16:14:47 +08:00

556 lines
18 KiB
Vue

<template>
<div class="default-main dashboard-page" v-loading="loading">
<div class="small-panel-box">
<el-row :gutter="20">
<el-col :sm="12" :lg="6">
<div class="small-panel user-reg suspension">
<div class="small-panel-title">{{ t('dashboard.stat_user_total') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#8595F4" size="20" name="fa fa-users" />
<el-statistic :value="stats.user_total" :value-style="statisticValueStyle" />
</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel file suspension">
<div class="small-panel-title">{{ t('dashboard.stat_new_today') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#AD85F4" size="20" name="fa fa-user-plus" />
<el-statistic :value="stats.user_new_today" :value-style="statisticValueStyle" />
</div>
<div class="content-right" :class="growthClass">{{ growthText }}</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel users suspension">
<div class="small-panel-title">{{ t('dashboard.stat_deposit_today') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#74A8B5" size="20" name="fa fa-credit-card" />
<el-statistic :value="depositTodayDisplay" :value-style="statisticValueStyle" />
</div>
<div class="content-right color-success">{{ depositCountText }}</div>
</div>
</div>
</el-col>
<el-col :sm="12" :lg="6">
<div class="small-panel addons suspension">
<div class="small-panel-title">{{ t('dashboard.stat_withdraw_pending') }}</div>
<div class="small-panel-content">
<div class="content-left">
<Icon color="#F48595" size="20" name="fa fa-clock-o" />
<el-statistic :value="stats.withdraw_pending" :value-style="statisticValueStyle" />
</div>
<div class="content-right color-warning">{{ t('dashboard.stat_hint_pending') }}</div>
</div>
</div>
</el-col>
</el-row>
</div>
<div class="growth-chart">
<el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
<el-card shadow="hover" :header="t('dashboard.chart_new_user_deposit')">
<div class="user-growth-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="12" :lg="9">
<el-card shadow="hover" :header="t('dashboard.chart_bet_7d')">
<div class="file-growth-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="24" :lg="6">
<el-card class="new-user-card" shadow="hover" :header="t('dashboard.recent_users')">
<div class="new-user-growth">
<el-empty v-if="recentUsers.length === 0" :description="t('dashboard.no_data')" />
<el-scrollbar v-else>
<div v-for="u in recentUsers" :key="u.id" class="new-user-item">
<img
class="new-user-avatar"
:src="u.head_image ? fullUrl(u.head_image) : fullUrl('/static/images/avatar.png')"
alt=""
/>
<div class="new-user-base">
<div class="new-user-name">{{ u.username || '-' }}</div>
<div class="new-user-time">{{ formatUserTime(u.create_time) }}</div>
<div v-if="u.channel_name" class="new-user-channel">{{ u.channel_name }}</div>
</div>
<Icon class="new-user-arrow" color="#8595F4" name="fa fa-angle-right" />
</div>
</el-scrollbar>
</div>
</el-card>
</el-col>
</el-row>
</div>
<div class="growth-chart">
<el-row :gutter="20">
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
<el-card shadow="hover" :header="t('dashboard.chart_channel_users')">
<div class="user-source-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
<el-col class="lg-mb-20" :xs="24" :sm="24" :md="24" :lg="12">
<el-card shadow="hover" :header="t('dashboard.chart_deposit_amount_channel')">
<div class="user-surname-chart" :ref="chartRefs.set"></div>
</el-card>
</el-col>
</el-row>
</div>
</div>
</template>
<script setup lang="ts">
import { useEventListener, useTemplateRefsList } from '@vueuse/core'
import * as echarts from 'echarts'
import type { EChartsType } from 'echarts/core'
import { CSSProperties, computed, nextTick, onActivated, onMounted, onUnmounted, reactive, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { index } from '/@/api/backend/dashboard'
import type { DashboardDepositAmountChannelItem, DashboardPayload, DashboardRecentUser } from '/@/api/backend/dashboard'
import { useNavTabs } from '/@/stores/navTabs'
import { fullUrl } from '/@/utils/common'
defineOptions({
name: 'dashboard',
})
const { t } = useI18n()
const navTabs = useNavTabs()
const chartRefs = useTemplateRefsList<HTMLDivElement>()
const loading = ref(true)
const state: {
charts: EChartsType[]
} = reactive({
charts: [],
})
const stats = reactive({
user_total: 0,
user_new_today: 0,
user_new_yesterday: 0,
user_new_growth_pct: null as number | null,
deposit_today_amount: '0.00',
deposit_today_count: 0,
withdraw_pending: 0,
bet_today_amount: '0.00',
bet_today_count: 0,
})
const trend = reactive({
days: [] as string[],
new_users: [] as number[],
deposit_amount: [] as string[],
bet_amount: [] as string[],
})
const channelShare = ref<{ name: string; value: number }[]>([])
const depositAmountChannelShare = ref<DashboardDepositAmountChannelItem[]>([])
const recentUsers = ref<DashboardRecentUser[]>([])
const depositTodayDisplay = computed(() => {
const n = parseFloat(stats.deposit_today_amount)
if (!Number.isFinite(n)) {
return stats.deposit_today_amount
}
return n.toFixed(2)
})
const growthText = computed(() => {
const p = stats.user_new_growth_pct
if (p === null || p === undefined) {
return '—'
}
const sign = p > 0 ? '+' : ''
return sign + p.toFixed(1) + '%'
})
const growthClass = computed(() => {
const p = stats.user_new_growth_pct
if (p === null || p === undefined) {
return 'color-info'
}
if (p > 0) {
return 'color-success'
}
if (p < 0) {
return 'color-danger'
}
return 'color-info'
})
const depositCountText = computed(() => {
return t('dashboard.deposit_orders_today', { n: stats.deposit_today_count })
})
const statisticValueStyle: CSSProperties = {
fontSize: '28px',
}
const disposeCharts = () => {
for (const c of state.charts) {
c.dispose()
}
state.charts = []
}
const parseMoneySeries = (arr: string[]) => arr.map((s) => parseFloat(String(s)) || 0)
const initTrendDepositChart = () => {
const el = chartRefs.value[0] as HTMLElement
if (!el) {
return
}
const chart = echarts.init(el)
chart.setOption({
color: ['#8595F4', '#67C23A'],
grid: { top: 48, right: 56, bottom: 24, left: 48 },
tooltip: { trigger: 'axis' },
legend: {
data: [t('dashboard.series_new_users'), t('dashboard.series_deposit_amount')],
textStyle: { color: '#73767a' },
top: 0,
},
xAxis: { type: 'category', data: trend.days, boundaryGap: false },
yAxis: [
{ type: 'value', name: t('dashboard.series_new_users'), splitLine: { lineStyle: { type: 'dashed' } } },
{
type: 'value',
name: t('dashboard.series_deposit_amount'),
position: 'right',
splitLine: { show: false },
},
],
series: [
{
name: t('dashboard.series_new_users'),
type: 'line',
smooth: true,
data: trend.new_users,
areaStyle: { color: 'rgba(133,149,244,0.15)' },
},
{
name: t('dashboard.series_deposit_amount'),
type: 'line',
smooth: true,
yAxisIndex: 1,
data: parseMoneySeries(trend.deposit_amount),
areaStyle: { color: 'rgba(103,194,58,0.12)' },
},
],
})
state.charts.push(chart)
}
const initBetTrendChart = () => {
const el = chartRefs.value[1] as HTMLElement
if (!el) {
return
}
const chart = echarts.init(el)
chart.setOption({
color: ['#E6A23C'],
grid: { top: 36, right: 16, bottom: 24, left: 48 },
tooltip: { trigger: 'axis' },
xAxis: { type: 'category', data: trend.days },
yAxis: { type: 'value', splitLine: { lineStyle: { type: 'dashed' } } },
series: [
{
name: t('dashboard.series_bet_amount'),
type: 'bar',
data: parseMoneySeries(trend.bet_amount),
itemStyle: { borderRadius: [4, 4, 0, 0] },
},
],
})
state.charts.push(chart)
}
const initChannelPie = () => {
const el = chartRefs.value[2] as HTMLElement
if (!el) {
return
}
const chart = echarts.init(el)
const data = channelShare.value.map((x) => ({ name: x.name, value: x.value }))
chart.setOption({
tooltip: { trigger: 'item' },
legend: { type: 'scroll', bottom: 0, textStyle: { color: '#73767a' } },
series: [
{
type: 'pie',
radius: ['36%', '62%'],
center: ['50%', '46%'],
data,
emphasis: {
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.15)' },
},
},
],
})
state.charts.push(chart)
}
const initDepositAmountChannelPie = () => {
const el = chartRefs.value[3] as HTMLElement
if (!el) {
return
}
const chart = echarts.init(el)
const data = depositAmountChannelShare.value.map((x) => {
const v = parseFloat(String(x.value))
return {
name: x.name,
value: Number.isFinite(v) ? v : 0,
}
})
chart.setOption({
tooltip: {
trigger: 'item',
formatter: (p: { name?: string; value?: number; percent?: number }) => {
const name = p.name ?? ''
const val = typeof p.value === 'number' && Number.isFinite(p.value) ? p.value : 0
const pct = typeof p.percent === 'number' && Number.isFinite(p.percent) ? p.percent : 0
return name + '<br/>' + val.toFixed(2) + ' (' + pct.toFixed(1) + '%)'
},
},
legend: { type: 'scroll', bottom: 0, textStyle: { color: '#73767a' } },
series: [
{
type: 'pie',
radius: ['32%', '58%'],
center: ['50%', '46%'],
data,
emphasis: {
itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.15)' },
},
},
],
})
state.charts.push(chart)
}
const echartsResize = () => {
nextTick(() => {
for (const key in state.charts) {
state.charts[key].resize()
}
})
}
const applyPayload = (payload: DashboardPayload) => {
const s = payload.stats
stats.user_total = s.user_total
stats.user_new_today = s.user_new_today
stats.user_new_yesterday = s.user_new_yesterday
stats.user_new_growth_pct = s.user_new_growth_pct
stats.deposit_today_amount = s.deposit_today_amount
stats.deposit_today_count = s.deposit_today_count
stats.withdraw_pending = s.withdraw_pending
stats.bet_today_amount = s.bet_today_amount
stats.bet_today_count = s.bet_today_count
trend.days = payload.trend.days
trend.new_users = payload.trend.new_users
trend.deposit_amount = payload.trend.deposit_amount
trend.bet_amount = payload.trend.bet_amount
channelShare.value = payload.channel_share
depositAmountChannelShare.value = payload.deposit_amount_channel_share ?? []
recentUsers.value = payload.recent_users
}
const loadDashboard = async () => {
loading.value = true
try {
const res = await index()
const payload = res.data as unknown as DashboardPayload
applyPayload(payload)
await nextTick()
disposeCharts()
initTrendDepositChart()
initBetTrendChart()
initChannelPie()
initDepositAmountChannelPie()
echartsResize()
} catch (_e) {
// 统计数据加载失败时由 axios 拦截器提示;此处不重复展示
} finally {
loading.value = false
}
}
function formatUserTime(ts: number): string {
if (!ts || ts <= 0) {
return '—'
}
const now = Math.floor(Date.now() / 1000)
const diff = now - ts
if (diff < 60) {
return diff + t('dashboard.seconds_ago')
}
if (diff < 3600) {
return Math.floor(diff / 60) + t('dashboard.minutes_ago')
}
if (diff < 86400) {
return Math.floor(diff / 3600) + t('dashboard.hours_ago')
}
const d = new Date(ts * 1000)
const pad = (n: number) => (n < 10 ? '0' + n : String(n))
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
}
onActivated(() => {
echartsResize()
})
onMounted(() => {
loadDashboard()
useEventListener(window, 'resize', echartsResize)
})
onUnmounted(() => {
disposeCharts()
})
watch(
() => navTabs.state.tabFullScreen,
() => {
echartsResize()
}
)
</script>
<style scoped lang="scss">
.small-panel-box {
margin-top: 0;
}
.small-panel {
background-color: #e9edf2;
border-radius: var(--el-border-radius-base);
padding: 25px;
margin-bottom: 20px;
.small-panel-title {
color: #92969a;
font-size: 15px;
}
.small-panel-content {
display: flex;
align-items: flex-end;
margin-top: 20px;
color: #2c3f5d;
.content-left {
display: flex;
align-items: center;
font-size: 24px;
.icon {
margin-right: 10px;
}
}
.content-right {
font-size: 14px;
margin-left: auto;
text-align: right;
max-width: 48%;
line-height: 1.3;
}
.color-success {
color: var(--el-color-success);
}
.color-warning {
color: var(--el-color-warning);
}
.color-danger {
color: var(--el-color-danger);
}
.color-info {
color: var(--el-text-color-secondary);
}
}
}
.growth-chart {
margin-bottom: 20px;
}
.user-growth-chart,
.file-growth-chart {
height: 260px;
}
.user-source-chart,
.user-surname-chart {
height: 360px;
}
.new-user-growth {
height: 300px;
}
.new-user-item {
display: flex;
align-items: center;
padding: 16px 20px;
margin: 8px 12px;
box-shadow: 0 0 30px 0 rgba(82, 63, 105, 0.05);
background-color: var(--ba-bg-color-overlay);
.new-user-avatar {
height: 48px;
width: 48px;
border-radius: 50%;
object-fit: cover;
}
.new-user-base {
margin-left: 10px;
flex: 1;
min-width: 0;
color: #2c3f5d;
.new-user-name {
font-size: 15px;
font-weight: 500;
}
.new-user-time {
font-size: 13px;
color: var(--el-text-color-secondary);
}
.new-user-channel {
font-size: 12px;
color: var(--el-text-color-placeholder);
margin-top: 2px;
}
}
.new-user-arrow {
margin-left: auto;
flex-shrink: 0;
}
}
.new-user-card :deep(.el-card__body) {
padding: 0;
}
@media screen and (max-width: 1200px) {
.lg-mb-20 {
margin-bottom: 20px;
}
}
html.dark {
.small-panel {
background-color: var(--ba-bg-color-overlay);
.small-panel-content {
color: var(--el-text-color-regular);
}
}
.new-user-item {
.new-user-base {
color: var(--el-text-color-regular);
}
}
}
</style>