- 玩家/代理/赛事/注单/审计列表分页,默认每页 10 条,无页面滚动条布局 - ECharts 控制台概览、注单管理中文化与列宽优化 - zhibo 赛事字段迁移与导入,玩家编辑可改所属代理 - 管理端 API 分页与 dashboard 统计接口 Co-authored-by: Cursor <cursoragent@cursor.com>
330 lines
9.1 KiB
Vue
330 lines
9.1 KiB
Vue
<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>
|