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

@@ -6,6 +6,8 @@ import api from '../api';
import { formatMoney } from '../utils/localeDisplay';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import StatusWatermark from '../components/StatusWatermark.vue';
import { matchPhaseLabel, matchPhaseVariant, type MatchPhase } from '../utils/matchPhase';
import type { BetHistoryItem } from '../components/BetHistoryCard.vue';
const route = useRoute();
@@ -122,6 +124,12 @@ const myPick = computed(() => {
if (ci < 0) return raw;
return raw.slice(0, ci + 2) + translateSel(raw.slice(ci + 2));
});
const matchPhase = computed(
(): MatchPhase | null => bet.value?.matchPhase ?? null,
);
const matchPhaseText = computed(() => matchPhaseLabel(t, matchPhase.value));
</script>
<template>
@@ -164,7 +172,13 @@ const myPick = computed(() => {
<div class="match-name">{{ matchTitle }}</div>
<!-- single bet: score comparison -->
<div v-if="!bet.isParlay" class="score-block">
<div v-if="!bet.isParlay" class="score-block" :class="{ 'score-block--phase': matchPhase && matchPhase !== 'open' }">
<StatusWatermark
v-if="matchPhase && matchPhase !== 'open'"
:label="matchPhaseText"
:variant="matchPhaseVariant(matchPhase)"
size="md"
/>
<!-- my pick row -->
<div class="row-label-val">
<span class="row-label">{{ t('history.my_pick') }}</span>
@@ -364,11 +378,17 @@ const myPick = computed(() => {
/* score block */
.score-block {
position: relative;
overflow: hidden;
display: flex;
flex-direction: column;
gap: 8px;
}
.score-block--phase {
padding: 8px 0 4px;
}
.row-label-val {
display: flex;
justify-content: space-between;

View File

@@ -11,6 +11,7 @@ import emptyMatchesImg from '../assets/images/empty-matches.svg';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
import GoldSpinner from '../components/GoldSpinner.vue';
import { usePullToRefresh } from '../composables/usePullToRefresh';
import type { MatchPhase } from '../utils/matchPhase';
type MainTab = 'matches' | 'outright' | 'parlay';
type TimeTab = 'today' | 'early';
@@ -29,6 +30,15 @@ interface Match {
leagueLogoUrl?: string | null;
displayOrder?: number;
isHot?: boolean;
status?: string;
bettingOpen?: boolean;
matchPhase?: MatchPhase;
score?: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
} | null;
}
interface LeagueGroup {

View File

@@ -13,8 +13,8 @@ const { initFromUser } = useAppLocale();
const auth = useAuthStore();
const router = useRouter();
const captchaRef = ref<InstanceType<typeof RobotVerify> | null>(null);
const username = ref('player1');
const password = ref('Player@123');
const username = ref('');
const password = ref('');
const error = ref('');
const loading = ref(false);

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;