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

@@ -69,6 +69,7 @@ const route = useRoute();
const router = useRouter();
const loading = ref(false);
const previewing = ref(false);
const match = ref<AdminMatchDetail | null>(null);
const score = ref({ htHome: 0, htAway: 0, ftHome: 0, ftAway: 0 });
const preview = ref<Record<string, unknown> | null>(null);
@@ -340,20 +341,6 @@ function settlementApiError(e: unknown, fallback: string) {
return raw;
}
async function saveScore() {
await api.post(`/admin/matches/${matchId.value}/settlement/score`, score.value);
}
async function recordScore() {
try {
await saveScore();
ElMessage.success(t('msg.score_recorded'));
await loadMatch();
} catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('msg.save_failed')));
}
}
async function loadPreviewItems(page = previewPage.value, pageSize = previewPageSize.value) {
const batch = preview.value?.batch as { id: string } | undefined;
if (!batch) return;
@@ -374,9 +361,10 @@ async function loadPreviewItems(page = previewPage.value, pageSize = previewPage
async function previewSettlement() {
preview.value = null;
previewPage.value = 1;
previewing.value = true;
try {
await saveScore();
const { data } = await api.post(`/admin/matches/${matchId.value}/settlement/preview`, {
...score.value,
page: 1,
pageSize: previewPageSize.value,
});
@@ -386,6 +374,8 @@ async function previewSettlement() {
previewPageSize.value = itemsPage.pageSize;
} catch (e: unknown) {
ElMessage.error(settlementApiError(e, t('settlement.preview_failed')));
} finally {
previewing.value = false;
}
}
@@ -426,77 +416,76 @@ onMounted(() => {
<el-card v-if="match" class="settle-top-card" shadow="never">
<p v-if="leagueLabel" class="match-league">{{ leagueLabel }}</p>
<div class="settle-top-row">
<div class="match-inline">
<div class="team-chip">
<img
v-if="match.homeTeamLogoUrl"
:src="match.homeTeamLogoUrl"
alt=""
class="team-logo-sm"
loading="lazy"
/>
<span class="team-name-inline">{{ homeLabel }}</span>
<div class="match-inline">
<div class="team-chip">
<img
v-if="match.homeTeamLogoUrl"
:src="match.homeTeamLogoUrl"
alt=""
class="team-logo-sm"
loading="lazy"
/>
<span class="team-name-inline">{{ homeLabel }}</span>
</div>
<span class="vs">VS</span>
<div class="team-chip">
<img
v-if="match.awayTeamLogoUrl"
:src="match.awayTeamLogoUrl"
alt=""
class="team-logo-sm"
loading="lazy"
/>
<span class="team-name-inline">{{ awayLabel }}</span>
</div>
<span class="kickoff-inline">
<span class="meta-k">{{ t('settlement.kickoff') }}</span>
{{ kickoffLabel }}
</span>
</div>
<div class="settle-score-row">
<div class="score-inline-group">
<div class="score-block compact">
<span class="score-title">{{ t('settlement.ht_score') }}</span>
<div class="score-inputs">
<el-input-number v-model="score.htHome" :min="0" controls-position="right" style="width: 88px" />
<span class="score-sep"></span>
<el-input-number v-model="score.htAway" :min="0" controls-position="right" style="width: 88px" />
</div>
</div>
<span class="vs">VS</span>
<div class="team-chip">
<img
v-if="match.awayTeamLogoUrl"
:src="match.awayTeamLogoUrl"
alt=""
class="team-logo-sm"
loading="lazy"
/>
<span class="team-name-inline">{{ awayLabel }}</span>
<div class="score-block compact">
<span class="score-title">{{ t('settlement.ft_score') }}</span>
<div class="score-inputs">
<el-input-number v-model="score.ftHome" :min="0" controls-position="right" style="width: 88px" />
<span class="score-sep"></span>
<el-input-number v-model="score.ftAway" :min="0" controls-position="right" style="width: 88px" />
</div>
</div>
<span class="kickoff-inline">
<span class="meta-k">{{ t('settlement.kickoff') }}</span>
{{ kickoffLabel }}
</span>
</div>
<div class="score-panel">
<div class="score-inline-group">
<div class="score-block compact">
<span class="score-title">{{ t('settlement.ht_score') }}</span>
<div class="score-inputs">
<el-input-number v-model="score.htHome" :min="0" controls-position="right" style="width: 88px" />
<span class="score-sep"></span>
<el-input-number v-model="score.htAway" :min="0" controls-position="right" style="width: 88px" />
</div>
</div>
<div class="score-block compact">
<span class="score-title">{{ t('settlement.ft_score') }}</span>
<div class="score-inputs">
<el-input-number v-model="score.ftHome" :min="0" controls-position="right" style="width: 88px" />
<span class="score-sep"></span>
<el-input-number v-model="score.ftAway" :min="0" controls-position="right" style="width: 88px" />
</div>
</div>
</div>
<div class="action-row">
<!-- 智能比分入口已关闭
<el-button size="small" type="warning" plain @click="openSmartDialog">
{{ t('settlement.smart.btn') }}
</el-button>
-->
<el-button size="small" @click="recordScore">{{ t('settlement.record_score') }}</el-button>
<el-button type="primary" size="small" @click="previewSettlement">
<div class="settle-actions">
<template v-if="!isSettled">
<el-button
type="primary"
:loading="previewing"
@click="previewSettlement"
>
{{ t('settlement.preview_btn') }}
</el-button>
<span class="preview-hint">{{ t('settlement.preview_hint') }}</span>
<template v-if="isSettled">
<el-input
v-model="resettleReason"
size="small"
:placeholder="t('settlement.resettle_reason')"
style="width: 200px"
/>
<el-button type="warning" size="small" plain @click="previewResettlement">
{{ t('settlement.resettle_preview') }}
</el-button>
</template>
</div>
</template>
<template v-else>
<el-input
v-model="resettleReason"
size="small"
:placeholder="t('settlement.resettle_reason')"
class="settle-resettle-reason"
/>
<el-button type="warning" plain @click="previewResettlement">
{{ t('settlement.resettle_preview') }}
</el-button>
</template>
</div>
</div>
</el-card>
@@ -741,12 +730,27 @@ onMounted(() => {
overflow: hidden;
}
.settle-top-row {
.settle-score-row {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px 24px;
flex-wrap: wrap;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #2a2a2a;
}
.settle-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 20px;
gap: 10px;
flex-wrap: wrap;
flex-shrink: 0;
}
.settle-resettle-reason {
width: 220px;
}
.match-inline {
@@ -789,14 +793,6 @@ onMounted(() => {
margin-right: 4px;
}
.score-panel {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px 16px;
flex-shrink: 0;
}
.score-inline-group {
display: flex;
align-items: flex-end;
@@ -949,13 +945,6 @@ onMounted(() => {
font-weight: 300;
}
.action-row {
display: flex;
gap: 8px;
flex-wrap: wrap;
align-items: center;
}
.preview-hint {
font-size: 12px;
color: var(--el-text-color-secondary);