feat: split admin dashboard, improve match ops, and player closed-match UX

Admin: add match/player overview sub-nav; refine settlement flow and league
match management UI; improve action button enabled/disabled styles; enhance
logo upload and outright odds sync.

API: expose matchPhase/bettingOpen for closed matches; league publish guards;
settlement preview with auto score save; outright team auto-sync.

Player: watermark for closed/settled states; keep match and bet details visible;
remove default login credentials.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-10 13:00:14 +08:00
parent 6124313369
commit 03f54ca689
43 changed files with 2787 additions and 519 deletions

View File

@@ -0,0 +1,198 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useRouter } from 'vue-router';
import { useAdminDashboard } from '../../composables/useAdminDashboard';
import EChartPanel from '../../components/dashboard/EChartPanel.vue';
import { buildCombinedTrendOption, buildTriplePieOption } from '../../utils/dashboard-charts';
import { betStatusLabel } from '../../utils/bet-labels';
import { useAdminLocale } from '../../composables/useAdminLocale';
const { t } = useAdminLocale();
const router = useRouter();
const {
s,
loading,
loadError,
load,
formatTime,
toNum,
chartI18n,
trendLabels,
kpiPrimary,
fmtCount,
} = useAdminDashboard();
onMounted(() => {
void load();
});
type KpiLink = { path: string; query?: Record<string, string> };
function goKpiLink(link: KpiLink) {
router.push(link.query ? { path: link.path, query: link.query } : link.path);
}
const mainTrendOption = computed(() =>
buildCombinedTrendOption(
trendLabels.value,
[
{
name: t('dash.chart_stake'),
color: '#248f54',
values: s.value?.trend7d?.map((d) => toNum(d.stake)) ?? [],
},
{
name: t('dash.chart_payout'),
color: '#60a5fa',
values: s.value?.trend7d?.map((d) => toNum(d.payout)) ?? [],
},
{
name: t('dash.chart_ggr'),
color: '#a78bfa',
values: s.value?.trend7d?.map((d) => toNum(d.ggr)) ?? [],
},
],
s.value?.trend7d?.map((d) => d.betCount) ?? [],
chartI18n.value,
),
);
const distributionOption = computed(() => {
const m = s.value?.matches;
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: t('dash.match_draft'), value: m.draft, color: '#6b7280' },
{ label: t('dash.match_published'), value: m.published, color: '#248f54' },
{ label: t('dash.match_closed'), value: m.closed, color: '#60a5fa' },
{ label: t('dash.match_pending_settle'), value: m.pendingSettlement, color: '#fb923c' },
{ label: t('dash.match_settled'), 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',
}));
return buildTriplePieOption(
[
{ title: t('dash.pie_matches'), segments: matchSegs },
{ title: t('dash.pie_bets'), segments: betSegs },
{ title: '', segments: [] },
],
chartI18n.value,
);
});
const kpiMatch = computed(() => {
if (!s.value) return [];
const m = s.value.matches;
const pendingMatches = m.pendingSettlement ?? 0;
return [
{
label: t('dash.kpi_match_total'),
value: fmtCount(m.total),
sub: t('dash.kpi_match_total_sub', {
draft: fmtCount(m.draft),
published: fmtCount(m.published),
}),
},
{
label: t('dash.kpi_match_closed'),
value: fmtCount(m.closed),
sub: t('dash.kpi_match_closed_sub', { n: fmtCount(m.cancelled ?? 0) }),
},
{
label: t('dash.kpi_pending'),
value: `${fmtCount(s.value.bets.pendingTotal)} ${t('common.bets_unit')}`,
sub: t('dash.kpi_pending_sub', {
bets: fmtCount(s.value.bets.pendingTotal),
matches: fmtCount(pendingMatches),
}),
link:
pendingMatches > 0
? { path: '/matches', query: { status: 'PENDING_SETTLEMENT' } }
: undefined,
},
{
label: t('dash.kpi_match_settled'),
value: fmtCount(m.settled ?? 0),
sub: t('dash.kpi_match_settled_sub'),
},
];
});
</script>
<template>
<div class="dashboard-page" v-loading="loading">
<el-card v-if="!loading && loadError" class="state-card" shadow="never">
<p class="state-title">{{ t('msg.load_failed') }}</p>
<p class="state-hint">{{ t('dash.load_error_hint') }}</p>
<el-button type="primary" size="small" @click="load(true)">{{ t('common.retry') }}</el-button>
</el-card>
<template v-else-if="s">
<el-card class="overview-board" shadow="never">
<div v-if="s.generatedAt" class="board-head">
<span class="board-hint">{{ t('dash.section_matches_hint') }}</span>
<span class="dash-updated">
{{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
</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('-') }"
>
{{ t('common.vs_yesterday') }} {{ item.delta }}
</span>
</div>
</div>
<div class="kpi-grid kpi-secondary">
<div
v-for="item in kpiMatch"
:key="item.label"
class="kpi-cell compact"
:class="{ 'kpi-cell--link': item.link }"
:role="item.link ? 'button' : undefined"
:tabindex="item.link ? 0 : undefined"
@click="item.link && goKpiLink(item.link)"
@keydown.enter.prevent="item.link && goKpiLink(item.link)"
>
<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">{{ t('dash.trend_caption') }}</div>
<EChartPanel title="" :option="distributionOption" height="200px" class="chart-dist" />
</div>
</el-card>
</template>
</div>
</template>
<style scoped>
@import './dashboard-board.css';
</style>

View File

@@ -0,0 +1,191 @@
<script setup lang="ts">
import { computed, onMounted } from 'vue';
import { useAdminDashboard } from '../../composables/useAdminDashboard';
import EChartPanel from '../../components/dashboard/EChartPanel.vue';
import { buildTriplePieOption } from '../../utils/dashboard-charts';
import { betStatusLabel } from '../../utils/bet-labels';
import { formatAmount } from '../../utils/format-amount';
import { useAdminLocale } from '../../composables/useAdminLocale';
const { t, locale, localeTag } = useAdminLocale();
const {
s,
loading,
loadError,
load,
formatTime,
chartI18n,
fmtCount,
} = useAdminDashboard();
onMounted(() => {
void load();
});
const kpiPlayer = computed(() => {
if (!s.value) return [];
return [
{
label: t('dash.kpi_users'),
value: `${fmtCount(s.value.users.playersTotal)} / ${fmtCount(s.value.users.agentsTotal)}`,
sub: t('dash.kpi_new_players', { n: fmtCount(s.value.today.newPlayers) }),
},
{
label: t('dash.kpi_agents_active'),
value: fmtCount(s.value.users.agentsActive),
sub: t('dash.kpi_agents_active_sub', {
suspended: fmtCount(s.value.users.playersSuspended),
}),
},
{
label: t('dash.kpi_wallet'),
value: formatAmount(s.value.wallets.totalAvailable, 2, locale.value),
sub: `${t('common.frozen')} ${formatAmount(s.value.wallets.totalFrozen, 2, locale.value)}`,
},
{
label: t('dash.kpi_credit'),
value: formatAmount(s.value.agents.totalAvailableCredit, 2, locale.value),
sub: `${t('common.used')} ${formatAmount(s.value.agents.totalUsedCredit, 2, locale.value)}`,
},
];
});
const userDistributionOption = computed(() => {
const u = s.value?.users;
const userSegs = u
? [
{ label: t('dash.user_active'), value: u.playersActive, color: '#248f54' },
{ label: t('dash.user_suspended'), value: u.playersSuspended, color: '#f87171' },
{ label: t('dash.user_direct'), value: u.playersDirect, color: '#60a5fa' },
{ label: t('dash.user_agents'), value: u.agentsTotal, color: '#a78bfa' },
].filter((x) => x.value > 0)
: [];
return buildTriplePieOption(
[
{ title: t('dash.pie_users'), segments: userSegs },
{ title: '', segments: [] },
{ title: '', segments: [] },
],
chartI18n.value,
);
});
function formatBetTime(v: string) {
return new Date(v).toLocaleString(localeTag.value, {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
</script>
<template>
<div class="dashboard-page" v-loading="loading">
<el-card v-if="!loading && loadError" class="state-card" shadow="never">
<p class="state-title">{{ t('msg.load_failed') }}</p>
<p class="state-hint">{{ t('dash.load_error_hint') }}</p>
<el-button type="primary" size="small" @click="load(true)">{{ t('common.retry') }}</el-button>
</el-card>
<template v-else-if="s">
<el-card class="overview-board" shadow="never">
<div v-if="s.generatedAt" class="board-head">
<span class="board-hint">{{ t('dash.section_players_hint') }}</span>
<span class="dash-updated">
{{ t('common.updated_at') }} {{ formatTime(s.generatedAt) }}
</span>
</div>
<div class="kpi-grid kpi-secondary">
<div v-for="item in kpiPlayer" :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="userDistributionOption" height="200px" class="chart-dist" />
</div>
</el-card>
<div class="recent-grid">
<el-card class="recent-card" shadow="never">
<h3 class="recent-title">{{ t('dash.recent_players') }}</h3>
<el-table :data="s.recentPlayers" stripe empty-text="" size="small">
<el-table-column prop="username" :label="t('user.col.username')" min-width="120" />
<el-table-column prop="parentUsername" :label="t('dash.col_parent')" min-width="100">
<template #default="{ row }">
{{ row.parentUsername || t('common.platform_direct') }}
</template>
</el-table-column>
<el-table-column prop="status" :label="t('common.status')" width="88" />
<el-table-column prop="createdAt" :label="t('user.col.created')" min-width="120">
<template #default="{ row }">
{{ formatBetTime(row.createdAt) }}
</template>
</el-table-column>
</el-table>
</el-card>
<el-card class="recent-card" shadow="never">
<h3 class="recent-title">{{ t('dash.recent_bets') }}</h3>
<el-table :data="s.recentBets" stripe empty-text="" size="small">
<el-table-column prop="betNo" :label="t('dash.col_bet_no')" min-width="120" />
<el-table-column prop="username" :label="t('user.col.username')" min-width="100" />
<el-table-column prop="stake" :label="t('dash.col_stake')" min-width="88">
<template #default="{ row }">
{{ formatAmount(row.stake, 2, locale) }}
</template>
</el-table-column>
<el-table-column prop="status" :label="t('common.status')" width="88">
<template #default="{ row }">
{{ betStatusLabel(row.status) }}
</template>
</el-table-column>
<el-table-column prop="placedAt" :label="t('dash.col_placed_at')" min-width="120">
<template #default="{ row }">
{{ formatBetTime(row.placedAt) }}
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
</div>
</template>
<style scoped>
@import './dashboard-board.css';
.recent-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 16px;
}
.recent-card {
border-radius: 14px;
border: 1px solid #1e1e1e;
background: rgba(255, 255, 255, 0.02);
}
.recent-card :deep(.el-card__body) {
padding: 16px 18px 12px;
}
.recent-title {
margin: 0 0 12px;
font-size: 14px;
font-weight: 700;
color: #ccc;
}
@media (max-width: 960px) {
.recent-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,179 @@
.dashboard-page {
padding-bottom: 32px;
}
.state-card {
border-radius: 14px;
border: 1px solid #2a2220;
background: rgba(255, 69, 58, 0.06);
text-align: center;
padding: 8px 0 4px;
}
.state-title {
font-size: 15px;
font-weight: 700;
color: #ff8a80;
margin-bottom: 6px;
}
.state-hint {
font-size: 13px;
color: #888;
margin-bottom: 14px;
}
.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;
justify-content: space-between;
align-items: center;
gap: 12px;
margin: -4px 0 14px;
flex-wrap: wrap;
}
.board-hint {
font-size: 12px;
color: #666;
}
.dash-updated {
font-size: 11px;
color: #555;
letter-spacing: 0.02em;
}
.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-cell--link {
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.kpi-cell--link:hover,
.kpi-cell--link:focus-visible {
border-color: rgba(77, 214, 138, 0.35);
background: rgba(36, 143, 84, 0.1);
outline: none;
}
.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;
}
}