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

@@ -19,6 +19,8 @@ import { usePullToRefresh } from '../composables/usePullToRefresh';
import vsImg from '../assets/images/vs.png';
import GoldSpinner from '../components/GoldSpinner.vue';
import BetSuccessOverlay from '../components/BetSuccessOverlay.vue';
import StatusWatermark from '../components/StatusWatermark.vue';
import { matchPhaseLabel, matchPhaseVariant, type MatchPhase } from '../utils/matchPhase';
import cardBg from '../assets/images/card-bg.png';
const heroCardBg = `url(${cardBg})`;
@@ -59,6 +61,15 @@ interface MatchDetail {
startTime: string;
stage?: string | null;
groupName?: string | null;
status?: string;
bettingOpen?: boolean;
matchPhase?: MatchPhase;
score?: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
} | null;
markets: Market[];
}
@@ -96,6 +107,21 @@ const kickoff = computed(() => {
});
});
const bettingOpen = computed(() => match.value?.bettingOpen !== false);
const matchPhase = computed(
(): MatchPhase =>
match.value?.matchPhase ?? (bettingOpen.value ? 'open' : 'closed_pending'),
);
const phaseLabel = computed(() => matchPhaseLabel(t, matchPhase.value));
const liveScoreText = computed(() => {
const s = match.value?.score;
if (!s) return '';
return `${s.ftHome} - ${s.ftAway}`;
});
function marketLabel(marketType: string) {
const key = MARKET_I18N_KEY[marketType];
return key ? t(key) : marketType;
@@ -170,6 +196,7 @@ async function confirmCorrectScoreBets() {
}
async function placeCorrectScoreBets(marketType: string) {
if (!bettingOpen.value) return;
const market = marketsByType.value.get(marketType);
if (!market || !match.value) return;
const entries = market.selections.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0);
@@ -228,7 +255,7 @@ function isSelected(id: string) {
}
function toggleSelection(sel: Selection, market: Market) {
if (!match.value) return;
if (!match.value || !bettingOpen.value) return;
slip.addItem({
selectionId: sel.id,
oddsVersion: String(sel.oddsVersion),
@@ -295,7 +322,13 @@ function hasSlipPickForMarket(marketType: string) {
<GoldSpinner :size="36" />
</div>
<template v-else-if="match">
<section class="match-hero">
<section class="match-hero" :class="{ 'match-hero--phase': matchPhase !== 'open' }">
<StatusWatermark
v-if="matchPhase !== 'open'"
:label="phaseLabel"
:variant="matchPhaseVariant(matchPhase)"
size="lg"
/>
<div class="hero-teams">
<!-- home -->
<div class="hero-team">
@@ -348,6 +381,7 @@ function hasSlipPickForMarket(marketType: string) {
</div>
<p class="kickoff">{{ t('bet.kickoff_time') }}{{ kickoff }}</p>
<p v-if="liveScoreText" class="live-score">{{ liveScoreText }}</p>
</section>
<section class="markets-section">
@@ -379,14 +413,22 @@ function hasSlipPickForMarket(marketType: string) {
@toggle="toggleMarket(marketType)"
/>
<template v-if="isExpanded(marketType) && marketsByType.get(marketType)">
<div class="market-panel-wrap" :class="{ locked: !bettingOpen }">
<StatusWatermark
v-if="!bettingOpen"
:label="phaseLabel"
:variant="matchPhaseVariant(matchPhase)"
size="md"
/>
<CorrectScorePanel
v-if="isCorrectScoreMarket(marketType)"
:market-type="marketType"
:selections="marketsByType.get(marketType)!.selections"
:locked="!bettingOpen"
v-model:stakes="correctScoreStakes"
/>
<button
v-if="isCorrectScoreMarket(marketType)"
v-if="isCorrectScoreMarket(marketType) && bettingOpen"
type="button"
class="market-foot-btn"
@click="openCorrectScoreConfirm(marketType)"
@@ -396,18 +438,20 @@ function hasSlipPickForMarket(marketType: string) {
<MarketSelectionsPanel
v-else
compact
:locked="!bettingOpen"
:selections="marketsByType.get(marketType)!.selections"
:is-selected="isSelected"
@pick="onPickSelection($event, marketType)"
/>
<button
v-if="!isCorrectScoreMarket(marketType) && hasSlipPickForMarket(marketType)"
v-if="!isCorrectScoreMarket(marketType) && bettingOpen && hasSlipPickForMarket(marketType)"
type="button"
class="market-foot-btn"
@click="openBetSlipDrawer"
>
{{ t('bet.cs_confirm_cell') }}
</button>
</div>
</template>
</div>
</div>
@@ -501,6 +545,19 @@ function hasSlipPickForMarket(marketType: string) {
pointer-events: none;
}
.match-hero--phase {
opacity: 0.98;
}
.market-panel-wrap {
position: relative;
overflow: hidden;
}
.market-panel-wrap.locked {
opacity: 0.82;
}
.kickoff {
position: relative;
z-index: 1;
@@ -511,6 +568,17 @@ function hasSlipPickForMarket(marketType: string) {
padding-left: 2px;
}
.live-score {
position: relative;
z-index: 2;
margin: 8px 0 0;
font-size: 28px;
font-weight: 900;
color: #fff;
text-align: center;
letter-spacing: 0.06em;
}
.hero-teams {
position: relative;
z-index: 1;