重构
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user