556 lines
18 KiB
Vue
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>
|