This commit is contained in:
wchino
2026-06-13 17:38:25 +08:00
parent e7e938f261
commit 7b33d9f9fa
190 changed files with 23222 additions and 4336 deletions

View File

@@ -7,21 +7,17 @@ import { formatMoney } from '../utils/localeDisplay';
import { useBetSlipStore } from '../stores/betSlip';
import { useAuthStore } from '../stores/auth';
import TeamEmblem from '../components/TeamEmblem.vue';
import { DETAIL_MARKET_TYPES, MARKET_I18N_KEY } from '../utils/marketCatalog';
import MatchBetGuide from '../components/match-detail/MatchBetGuide.vue';
import MarketTypeTile from '../components/match-detail/MarketTypeTile.vue';
import MarketSelectionsPanel from '../components/match-detail/MarketSelectionsPanel.vue';
import CorrectScorePanel from '../components/match-detail/CorrectScorePanel.vue';
import CorrectScoreConfirmModal, {
type CsConfirmLine,
} from '../components/match-detail/CorrectScoreConfirmModal.vue';
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
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 { matchPhaseLabel, type MatchPhase } from '../utils/matchPhase';
import { formatLocalMatchDateTime } from '@thebet365/shared';
const route = useRoute();
const router = useRouter();
@@ -36,8 +32,11 @@ function goLogin() {
interface Market {
id: string;
marketType: string;
marketDisplayName?: string;
lineKey?: string | null;
period: string;
lineValue?: string | number | null;
allowSingle?: boolean;
allowParlay?: boolean;
promoLabel?: string | null;
selections: Selection[];
@@ -47,6 +46,7 @@ interface Selection {
id: string;
selectionCode: string;
selectionName: string;
selectionDisplayName?: string;
odds: string;
oddsVersion: string;
}
@@ -67,25 +67,25 @@ interface MatchDetail {
status?: string;
bettingOpen?: boolean;
matchPhase?: MatchPhase;
correctScoreEnabled?: boolean;
score?: {
htHome: number;
htAway: number;
ftHome: number;
ftAway: number;
htHome: number | null;
htAway: number | null;
ftHome: number | null;
ftAway: number | null;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
} | null;
markets: Market[];
}
const match = ref<MatchDetail | null>(null);
const loading = ref(true);
const expandedKey = ref<string | null>(null);
const correctScoreStakes = ref<Record<string, number>>({});
const placingCs = ref(false);
const csMessage = ref('');
const showCsSuccess = ref(false);
const csConfirmOpen = ref(false);
const csConfirmMarketType = ref<string | null>(null);
interface MyBet {
betNo: string;
@@ -131,37 +131,19 @@ const statusClass = (status: string) => {
if (s === 'LOST' || s === 'LOSE') return 'bet-status-lost';
return 'bet-status-pending';
};
const marketsByType = computed(() => {
const marketsById = computed(() => {
const map = new Map<string, Market>();
for (const m of match.value?.markets ?? []) {
map.set(m.marketType, m);
map.set(m.id, m);
}
return map;
});
const CS_MARKET_TYPES = new Set(['FT_CORRECT_SCORE', 'HT_CORRECT_SCORE', 'SH_CORRECT_SCORE']);
const visibleMarketTypes = computed(() => {
const csEnabled = match.value?.correctScoreEnabled ?? true;
if (csEnabled) return DETAIL_MARKET_TYPES;
return DETAIL_MARKET_TYPES.filter((t) => !CS_MARKET_TYPES.has(t));
});
function marketPromoLabel(marketType: string) {
const m = marketsByType.value.get(marketType);
return m?.promoLabel?.trim() || '';
}
const visibleMarkets = computed(() => match.value?.markets ?? []);
const kickoff = computed(() => {
if (!match.value) return '';
return new Date(match.value.startTime).toLocaleString(locale.value, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
});
return formatLocalMatchDateTime(match.value.startTime, locale.value, { variant: 'full' });
});
const bettingOpen = computed(() => match.value?.bettingOpen !== false);
@@ -175,119 +157,44 @@ const phaseLabel = computed(() => matchPhaseLabel(t, matchPhase.value));
const liveScoreText = computed(() => {
const s = match.value?.score;
if (!s) return '';
if (!s || s.ftHome == null || s.ftAway == null) return '';
return `${s.ftHome} - ${s.ftAway}`;
});
function marketLabel(marketType: string) {
const key = MARKET_I18N_KEY[marketType];
return key ? t(key) : marketType;
function statValue(value: number | null | undefined) {
return value == null ? '-' : String(value);
}
function expandKey(marketType: string) {
return marketType;
function statPair(home: number | null | undefined, away: number | null | undefined) {
return `${statValue(home)} - ${statValue(away)}`;
}
function isExpanded(marketType: string) {
return expandedKey.value === expandKey(marketType);
}
function openMarket(marketType: string) {
if (!marketsByType.value.has(marketType)) return;
expandedKey.value = expandKey(marketType);
}
function closeMarket() {
expandedKey.value = null;
}
function toggleMarket(marketType: string) {
if (!marketsByType.value.has(marketType)) return;
if (isExpanded(marketType)) closeMarket();
else openMarket(marketType);
}
function genRequestId() {
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
const csConfirmLines = computed((): CsConfirmLine[] => {
const marketType = csConfirmMarketType.value;
if (!marketType) return [];
const market = marketsByType.value.get(marketType);
if (!market) return [];
return market.selections
.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0)
.map((s) => {
const parsed = parseScoreCode(s.selectionCode, t);
return {
scoreDisplay: parsed?.display ?? s.selectionName,
odds: s.odds,
stake: correctScoreStakes.value[s.id],
};
});
const resultStats = computed(() => {
const s = match.value?.score;
if (!s || matchPhase.value !== 'settled') return [];
return [
{ key: 'ht', label: t('bet.result_ht'), value: statPair(s.htHome, s.htAway) },
{ key: 'ft', label: t('bet.result_ft'), value: statPair(s.ftHome, s.ftAway) },
{ key: 'corners', label: t('bet.result_corners'), value: statPair(s.homeCorners, s.awayCorners) },
{
key: 'yellow',
label: t('bet.result_yellow_cards'),
value: statPair(s.homeYellowCards, s.awayYellowCards),
},
{ key: 'red', label: t('bet.result_red_cards'), value: statPair(s.homeRedCards, s.awayRedCards) },
];
});
function openCorrectScoreConfirm(marketType: string) {
const market = marketsByType.value.get(marketType);
if (!market || !match.value) return;
const hasStake = market.selections.some((s) => (correctScoreStakes.value[s.id] ?? 0) > 0);
if (!hasStake) {
csMessage.value = t('bet.cs_stake_required');
return;
}
csMessage.value = '';
csConfirmMarketType.value = marketType;
csConfirmOpen.value = true;
const showResultStats = computed(() => resultStats.value.length > 0);
function marketLabel(market: Market | null | undefined) {
return market?.marketDisplayName?.trim() || market?.marketType || '';
}
function closeCorrectScoreConfirm() {
csConfirmOpen.value = false;
}
async function confirmCorrectScoreBets() {
const marketType = csConfirmMarketType.value;
if (!marketType) return;
csConfirmOpen.value = false;
await placeCorrectScoreBets(marketType);
}
async function placeCorrectScoreBets(marketType: string) {
if (!bettingOpen.value) return;
if (!auth.token) {
goLogin();
return;
}
const market = marketsByType.value.get(marketType);
if (!market || !match.value) return;
const entries = market.selections.filter((s) => (correctScoreStakes.value[s.id] ?? 0) > 0);
if (!entries.length) {
csMessage.value = t('bet.cs_stake_required');
return;
}
placingCs.value = true;
csMessage.value = '';
try {
for (const sel of entries) {
await api.post('/player/bets/single', {
selectionId: sel.id,
oddsVersion: String(sel.oddsVersion),
stake: correctScoreStakes.value[sel.id],
requestId: genRequestId(),
});
}
csMessage.value = t('bet.cs_place_success');
const next = { ...correctScoreStakes.value };
for (const sel of entries) delete next[sel.id];
correctScoreStakes.value = next;
showCsSuccess.value = true;
} catch (e: unknown) {
csMessage.value =
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
t('bet.cs_place_failed');
} finally {
placingCs.value = false;
}
function selectionLabel(sel: Selection) {
const parsedScore = parseScoreCode(sel.selectionCode, t);
if (parsedScore) return parsedScore.display;
return sel.selectionDisplayName?.trim() || sel.selectionName;
}
async function loadMatch() {
@@ -313,17 +220,24 @@ const pullIndicatorStyle = () => ({
});
function isSelected(id: string) {
return slip.items.some((i) => i.selectionId === id);
return slip.singleItem?.selectionId === id || slip.parlayItems.some((i) => i.selectionId === id);
}
function toggleSelection(sel: Selection, market: Market) {
if (!match.value || !bettingOpen.value) return;
if (market.allowSingle === false) return;
if (!auth.token) {
goLogin();
return;
}
slip.addItem({
selectionId: sel.id,
oddsVersion: String(sel.oddsVersion),
matchId: match.value.id,
matchName: `${match.value.homeTeamName} vs ${match.value.awayTeamName}`,
selectionName: sel.selectionName,
marketId: market.id,
marketName: marketLabel(market),
selectionName: selectionLabel(sel),
odds: parseFloat(sel.odds),
marketType: market.marketType,
lineValue:
@@ -332,29 +246,14 @@ function toggleSelection(sel: Selection, market: Market) {
: null,
allowParlay: market.allowParlay,
});
}
function onPickSelection(selId: string, marketType: string) {
const market = marketsByType.value.get(marketType);
const sel = market?.selections.find((s) => s.id === selId);
if (!market || !sel) return;
toggleSelection(sel, market);
}
function openBetSlipDrawer() {
if (!auth.token) {
goLogin();
return;
}
slip.openDrawer();
}
/** 当前玩法是否已有选项加入投注单 */
function hasSlipPickForMarket(marketType: string) {
if (!match.value || slip.mode !== 'single') return false;
return slip.items.some(
(item) => item.matchId === match.value!.id && item.marketType === marketType,
);
function onPickSelection(selId: string, marketId: string) {
const market = marketsById.value.get(marketId);
const sel = market?.selections.find((s) => s.id === selId);
if (!market || !sel) return;
toggleSelection(sel, market);
}
</script>
@@ -446,6 +345,19 @@ function hasSlipPickForMarket(marketType: string) {
<p v-if="liveScoreText" class="live-score">{{ liveScoreText }}</p>
</section>
<section v-if="showResultStats" class="result-stats-section">
<div class="result-stats-head">
<h3>{{ t('bet.result_stats_title') }}</h3>
<span>{{ match.homeTeamName }} / {{ match.awayTeamName }}</span>
</div>
<div class="result-stats-grid">
<div v-for="item in resultStats" :key="item.key" class="result-stat-item">
<span class="result-stat-label">{{ item.label }}</span>
<span class="result-stat-value">{{ item.value }}</span>
</div>
</div>
</section>
<!-- 我的投注 -->
<section v-if="myBets.length" class="my-bets-section">
<h3 class="my-bets-title">{{ t('history.my_bets') || '我的投注' }}</h3>
@@ -466,77 +378,45 @@ function hasSlipPickForMarket(marketType: string) {
</section>
<section class="markets-section">
<CorrectScoreConfirmModal
:open="csConfirmOpen"
:market-label="csConfirmMarketType ? marketLabel(csConfirmMarketType) : ''"
:home-team-name="match.homeTeamName"
:away-team-name="match.awayTeamName"
:lines="csConfirmLines"
:loading="placingCs"
@close="closeCorrectScoreConfirm"
@confirm="confirmCorrectScoreBets"
/>
<p v-if="csMessage" class="cs-toast">{{ csMessage }}</p>
<div class="market-list">
<div
v-for="marketType in visibleMarketTypes"
:key="marketType"
v-for="market in visibleMarkets"
:key="market.id"
class="market-group"
:class="{ open: isExpanded(marketType) }"
:class="{ open: true }"
>
<MarketTypeTile
:label="marketLabel(marketType)"
:promo-label="marketPromoLabel(marketType)"
:has-market="marketsByType.has(marketType)"
:expanded="isExpanded(marketType)"
@toggle="toggleMarket(marketType)"
:label="marketLabel(market)"
:promo-label="market.promoLabel?.trim() || ''"
:has-market="true"
:expanded="true"
/>
<template v-if="isExpanded(marketType) && marketsByType.get(marketType)">
<div class="market-panel-wrap" :class="{ locked: !bettingOpen }">
<span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span>
<span v-else-if="!bettingOpen" class="market-status-tag market-status-tag--pending">{{ phaseLabel }}</span>
<div class="market-panel-wrap" :class="{ locked: !bettingOpen || market.allowSingle === false }">
<span v-if="!bettingOpen && matchPhase === 'settled'" class="market-status-tag market-status-tag--settled">{{ phaseLabel }}</span>
<span v-else-if="!bettingOpen" class="market-status-tag market-status-tag--pending">{{ phaseLabel }}</span>
<CorrectScorePanel
v-if="isCorrectScoreMarket(marketType)"
:market-type="marketType"
:selections="marketsByType.get(marketType)!.selections"
:locked="!bettingOpen"
v-model:stakes="correctScoreStakes"
v-if="isCorrectScoreMarket(market.marketType)"
:market-type="market.marketType"
:selections="market.selections"
:locked="!bettingOpen || market.allowSingle === false"
:is-selected="isSelected"
@pick="onPickSelection($event, market.id)"
/>
<button
v-if="isCorrectScoreMarket(marketType) && bettingOpen"
type="button"
class="market-foot-btn"
@click="openCorrectScoreConfirm(marketType)"
>
{{ t('bet.cs_confirm_cell') }}
</button>
<MarketSelectionsPanel
v-else
compact
:locked="!bettingOpen"
:selections="marketsByType.get(marketType)!.selections"
:locked="!bettingOpen || market.allowSingle === false"
:line-value="market.lineValue"
:selections="market.selections"
:is-selected="isSelected"
@pick="onPickSelection($event, marketType)"
@pick="onPickSelection($event, market.id)"
/>
<button
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>
</div>
</section>
</template>
</div>
<BetSuccessOverlay :show="showCsSuccess" @done="showCsSuccess = false" />
</template>
<style scoped>
@@ -704,6 +584,76 @@ function hasSlipPickForMarket(marketType: string) {
letter-spacing: 0.06em;
}
.result-stats-section {
margin: 0 10px 12px;
padding: 12px;
border: 1px solid rgba(212, 175, 55, 0.22);
border-radius: 8px;
background: linear-gradient(180deg, #1b1b1b 0%, #121212 100%);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
}
.result-stats-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.result-stats-head h3 {
margin: 0;
font-size: 14px;
line-height: 1.2;
font-weight: 900;
color: var(--primary-light);
}
.result-stats-head span {
min-width: 0;
max-width: 58%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: var(--text-muted);
}
.result-stats-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.result-stat-item {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 38px;
gap: 8px;
padding: 8px 10px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.06);
}
.result-stat-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
color: var(--text-muted);
}
.result-stat-value {
flex-shrink: 0;
font-size: 14px;
line-height: 1;
font-weight: 900;
color: #fff;
}
.hero-teams {
position: relative;
z-index: 1;