Files
thebet365/apps/admin/src/views/Dashboard.vue
Mars 80adc0e928 feat(admin): 管理端列表分页、控制台图表与赛事导入
- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局

- ECharts 控制台概览、注单管理中文化与列宽优化

- zhibo 赛事字段迁移与导入,玩家编辑可改所属代理

- 管理端 API 分页与 dashboard 统计接口

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-03 13:49:31 +08:00

330 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue';
import api from '../api';
import { formatAmount, formatAmountFull } from '../utils/format-amount';
import type { AdminDashboard } from './dashboard-types';
import EChartPanel from '../components/dashboard/EChartPanel.vue';
import { buildCombinedTrendOption, buildTriplePieOption } from '../utils/dashboard-charts';
import { betStatusLabel } from '../utils/bet-labels';
const stats = ref<AdminDashboard | null>(null);
const loading = ref(true);
onMounted(load);
async function load() {
loading.value = true;
try {
const { data } = await api.get('/admin/dashboard');
stats.value = data.data as AdminDashboard;
} finally {
loading.value = false;
}
}
const s = computed(() => stats.value);
function fmtCount(val: number | undefined) {
return (val ?? 0).toLocaleString('zh-CN', { maximumFractionDigits: 0 });
}
function formatTime(v: string) {
return new Date(v).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function toNum(v: string | number | undefined) {
const n = typeof v === 'number' ? v : parseFloat(v ?? '0');
return Number.isFinite(n) ? n : 0;
}
function pctChange(today: string | number, yesterday: string | number) {
const t = toNum(today);
const y = toNum(yesterday);
if (y === 0) return t > 0 ? '+100%' : '—';
const p = ((t - y) / y) * 100;
const sign = p > 0 ? '+' : '';
return `${sign}${p.toFixed(1)}%`;
}
const trendLabels = computed(() => s.value?.trend7d?.map((d) => d.label) ?? []);
const mainTrendOption = computed(() =>
buildCombinedTrendOption(
trendLabels.value,
[
{
name: '投注额',
color: '#248f54',
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
},
{
name: '派彩',
color: '#60a5fa',
values: s.value?.trend7d?.map((d) => toNum(d.payout)) ?? [],
},
{
name: '毛利',
color: '#a78bfa',
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
},
],
s.value?.trend7d?.map((d) => d.betCount) ?? [],
),
);
const distributionOption = computed(() => {
const m = s.value?.matches;
const u = s.value?.users;
const raw = s.value?.bets.todayByStatus ?? {};
const betColors: Record<string, string> = {
PENDING: '#fb923c',
WON: '#248f54',
LOST: '#f87171',
VOID: '#6b7280',
REFUNDED: '#60a5fa',
};
const matchSegs = m
? [
{ label: '草稿', value: m.draft, color: '#6b7280' },
{ label: '已发布', value: m.published, color: '#248f54' },
{ label: '已封盘', value: m.closed, color: '#60a5fa' },
{ label: '待结算', value: m.pendingSettlement, color: '#fb923c' },
{ label: '已结算', value: m.settled ?? 0, color: '#5eead4' },
].filter((x) => x.value > 0)
: [];
const betSegs = ['PENDING', 'WON', 'LOST', 'VOID', 'REFUNDED']
.filter((k) => raw[k]?.count)
.map((k) => ({
label: betStatusLabel(k),
value: raw[k].count,
color: betColors[k] ?? '#888',
}));
const userSegs = u
? [
{ label: '正常玩家', value: u.playersActive, color: '#248f54' },
{ label: '停用', value: u.playersSuspended, color: '#f87171' },
{ label: '直属', value: u.playersDirect, color: '#60a5fa' },
{ label: '代理', value: u.agentsTotal, color: '#a78bfa' },
].filter((x) => x.value > 0)
: [];
return buildTriplePieOption([
{ title: '赛事', segments: matchSegs },
{ title: '今日注单', segments: betSegs },
{ title: '用户', segments: userSegs },
]);
});
const kpiPrimary = computed(() => {
if (!s.value) return [];
const t = s.value.today;
const y = s.value.yesterday;
return [
{ label: '今日投注笔数', value: fmtCount(t.betCount), sub: `昨日 ${fmtCount(y.betCount)}`, delta: pctChange(t.betCount, y.betCount) },
{ label: '今日投注额', value: formatAmount(t.stake), sub: formatAmountFull(t.stake), delta: pctChange(t.stake, y.stake) },
{ label: '今日派彩', value: formatAmount(t.payout), sub: `昨日 ${formatAmount(y.payout)}`, delta: pctChange(t.payout, y.payout) },
{ label: '今日毛利', value: formatAmount(t.ggr), sub: `昨日 ${formatAmount(y.ggr)}`, delta: pctChange(t.ggr, y.ggr) },
];
});
const kpiSecondary = computed(() => {
if (!s.value) return [];
return [
{ label: '玩家 / 代理', value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`, sub: `今日新增 ${fmtCount(s.value.today.newPlayers)}` },
{ label: '待结算', value: `${fmtCount(s.value.bets.pendingTotal)}`, sub: `${fmtCount(s.value.matches.pendingSettlement)} 场赛事` },
{ label: '玩家余额', value: formatAmount(s.value.wallets.totalAvailable), sub: `冻结 ${formatAmount(s.value.wallets.totalFrozen)}` },
{ label: '代理授信', value: formatAmount(s.value.agents.totalAvailableCredit), sub: `已用 ${formatAmount(s.value.agents.totalUsedCredit)}` },
];
});
</script>
<template>
<div class="dashboard-page" v-loading="loading">
<div class="page-header">
<div>
<h2 class="page-title">控制台</h2>
<span class="page-desc">
平台整体运行概况
<template v-if="s?.generatedAt"> · 更新于 {{ formatTime(s.generatedAt) }}</template>
</span>
</div>
</div>
<template v-if="s">
<el-card class="overview-board" shadow="never">
<div class="board-head">
<span class="board-title">整体概览</span>
<span class="board-hint">一屏查看经营趋势与平台分布</span>
</div>
<div class="kpi-grid kpi-primary">
<div v-for="item in kpiPrimary" :key="item.label" class="kpi-cell">
<span class="kpi-label">{{ item.label }}</span>
<span class="kpi-value">{{ item.value }}</span>
<span class="kpi-sub">{{ item.sub }}</span>
<span
class="kpi-delta"
:class="{ up: item.delta.startsWith('+'), down: item.delta.startsWith('-') }"
>
较昨日 {{ item.delta }}
</span>
</div>
</div>
<div class="kpi-grid kpi-secondary">
<div v-for="item in kpiSecondary" :key="item.label" class="kpi-cell compact">
<span class="kpi-label">{{ item.label }}</span>
<span class="kpi-value sm">{{ item.value }}</span>
<span class="kpi-sub">{{ item.sub }}</span>
</div>
</div>
<div class="charts-stack">
<EChartPanel
title=""
:option="mainTrendOption"
height="300px"
class="chart-main"
/>
<div class="chart-main-caption"> 7 日经营趋势金额折线 + 注单柱</div>
<EChartPanel title="" :option="distributionOption" height="200px" class="chart-dist" />
</div>
</el-card>
</template>
</div>
</template>
<style scoped>
.dashboard-page { padding-bottom: 32px; }
.page-header { margin-bottom: 20px; }
.page-title { font-size: 20px; font-weight: 700; color: #e0e0e0; margin: 0 0 4px; }
.page-desc { font-size: 13px; color: #666; }
.overview-board {
border-radius: 14px;
border: 1px solid #1e1e1e;
background: linear-gradient(180deg, rgba(36, 143, 84, 0.06) 0%, rgba(0, 0, 0, 0) 120px);
margin-bottom: 28px;
}
.overview-board :deep(.el-card__body) {
padding: 20px 22px 16px;
}
.board-head {
display: flex;
align-items: baseline;
gap: 12px;
margin-bottom: 18px;
}
.board-title {
font-size: 16px;
font-weight: 700;
color: #e8e8e8;
}
.board-hint {
font-size: 12px;
color: #555;
}
.kpi-grid {
display: grid;
gap: 10px;
margin-bottom: 10px;
}
.kpi-primary {
grid-template-columns: repeat(4, 1fr);
}
.kpi-secondary {
grid-template-columns: repeat(4, 1fr);
margin-bottom: 18px;
}
.kpi-cell {
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #222;
background: rgba(255, 255, 255, 0.03);
}
.kpi-cell.compact {
padding: 10px 12px;
}
.kpi-label {
display: block;
font-size: 11px;
color: #666;
margin-bottom: 6px;
}
.kpi-value {
display: block;
font-size: 22px;
font-weight: 800;
color: var(--green-text);
line-height: 1.15;
letter-spacing: -0.5px;
}
.kpi-value.sm {
font-size: 17px;
}
.kpi-sub {
display: block;
font-size: 11px;
color: #555;
margin-top: 4px;
}
.kpi-delta {
display: inline-block;
margin-top: 6px;
font-size: 10px;
font-weight: 600;
color: #888;
padding: 2px 6px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.04);
}
.kpi-delta.up { color: #4ade80; }
.kpi-delta.down { color: #f87171; }
.charts-stack {
border-top: 1px solid #1a1a1a;
padding-top: 12px;
}
.chart-main-caption {
font-size: 11px;
color: #555;
text-align: center;
margin: -8px 0 8px;
}
.charts-stack :deep(.chart-panel) {
border: none;
background: transparent;
padding: 8px 0 0;
}
.charts-stack :deep(.chart-title:empty) {
display: none;
}
.chart-dist {
margin-top: 4px;
}
@media (max-width: 1200px) {
.kpi-primary,
.kpi-secondary {
grid-template-columns: repeat(2, 1fr);
}
}
@media (max-width: 640px) {
.kpi-primary,
.kpi-secondary {
grid-template-columns: 1fr;
}
}
</style>