feat(player): 优化赛事详情与串关下注交互
- 合并玩法列表、单选投注单与玩法内确认下单 - 波胆底部确认与核对弹窗;串关底栏固定 - 帮助弹窗与投注单逻辑拆分(详情单关/串关页串关) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,16 +5,15 @@ import { useI18n } from 'vue-i18n';
|
||||
import api from '../api';
|
||||
import { useBetSlipStore } from '../stores/betSlip';
|
||||
import { teamFlagUrl } from '../utils/teamFlag';
|
||||
import {
|
||||
FEATURED_MARKET_TYPES,
|
||||
GRID_MARKET_TYPES,
|
||||
MARKET_I18N_KEY,
|
||||
} from '../utils/marketCatalog';
|
||||
import FeaturedMarketRow from '../components/match-detail/FeaturedMarketRow.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 { isCorrectScoreMarket } from '../utils/correctScoreLayout';
|
||||
import CorrectScoreConfirmModal, {
|
||||
type CsConfirmLine,
|
||||
} from '../components/match-detail/CorrectScoreConfirmModal.vue';
|
||||
import { isCorrectScoreMarket, parseScoreCode } from '../utils/correctScoreLayout';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
@@ -53,7 +52,8 @@ const expandedKey = ref<string | null>(null);
|
||||
const correctScoreStakes = ref<Record<string, number>>({});
|
||||
const placingCs = ref(false);
|
||||
const csMessage = ref('');
|
||||
|
||||
const csConfirmOpen = ref(false);
|
||||
const csConfirmMarketType = ref<string | null>(null);
|
||||
const marketsByType = computed(() => {
|
||||
const map = new Map<string, Market>();
|
||||
for (const m of match.value?.markets ?? []) {
|
||||
@@ -103,27 +103,57 @@ function closeMarket() {
|
||||
expandedKey.value = null;
|
||||
}
|
||||
|
||||
function clearMarketSlip(marketType: string) {
|
||||
if (!match.value) return;
|
||||
const toRemove = slip.items.filter(
|
||||
(i) => i.matchId === match.value!.id && i.marketType === marketType,
|
||||
);
|
||||
for (const item of toRemove) slip.removeItem(item.selectionId);
|
||||
if (isCorrectScoreMarket(marketType)) {
|
||||
const market = marketsByType.value.get(marketType);
|
||||
if (market) {
|
||||
const next = { ...correctScoreStakes.value };
|
||||
for (const s of market.selections) delete next[s.id];
|
||||
correctScoreStakes.value = next;
|
||||
}
|
||||
}
|
||||
if (expandedKey.value === expandKey(marketType)) 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);
|
||||
return {
|
||||
scoreDisplay: parsed?.display ?? s.selectionName,
|
||||
odds: s.odds,
|
||||
stake: correctScoreStakes.value[s.id],
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
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) {
|
||||
const market = marketsByType.value.get(marketType);
|
||||
if (!market || !match.value) return;
|
||||
@@ -188,20 +218,37 @@ function toggleSelection(sel: Selection, market: Market) {
|
||||
function onPickSelection(selId: string, marketType: string) {
|
||||
const market = marketsByType.value.get(marketType);
|
||||
const sel = market?.selections.find((s) => s.id === selId);
|
||||
if (market && sel) toggleSelection(sel, market);
|
||||
if (!market || !sel) return;
|
||||
toggleSelection(sel, market);
|
||||
}
|
||||
|
||||
function openBetSlipDrawer() {
|
||||
slip.openDrawer();
|
||||
}
|
||||
|
||||
/** 当前玩法是否已选入投注单(单关、仅一项) */
|
||||
function hasSlipPickForMarket(marketType: string) {
|
||||
if (!match.value || slip.mode !== 'single' || slip.items.length !== 1) return false;
|
||||
const item = slip.items[0];
|
||||
return item.matchId === match.value.id && item.marketType === marketType;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<header class="toolbar">
|
||||
<button type="button" class="btn-back btn-gold-outline" @click="router.back()">{{ t('bet.back') }}</button>
|
||||
<div class="toolbar-right">
|
||||
<button type="button" class="btn-tool btn-gold-outline" :aria-label="t('bet.refresh')" @click="loadMatch">
|
||||
<button type="button" class="icon-btn" :aria-label="t('bet.back')" @click="router.back()">←</button>
|
||||
<div class="toolbar-actions">
|
||||
<MatchBetGuide />
|
||||
<button
|
||||
type="button"
|
||||
class="icon-btn"
|
||||
:aria-label="t('bet.refresh')"
|
||||
:disabled="loading"
|
||||
@click="loadMatch"
|
||||
>
|
||||
↻
|
||||
</button>
|
||||
<button type="button" class="btn-tool btn-gold-outline" :aria-label="t('bet.download')">↓</button>
|
||||
<span class="fifa-badge">FIFA</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -210,65 +257,76 @@ function onPickSelection(selId: string, marketType: string) {
|
||||
<template v-else-if="match">
|
||||
<section class="match-hero">
|
||||
<p class="kickoff">{{ kickoff }}</p>
|
||||
<div class="flags-row">
|
||||
<img v-if="homeFlag" :src="homeFlag" alt="" class="flag-lg" />
|
||||
<span class="vs-pill">vs</span>
|
||||
<img v-if="awayFlag" :src="awayFlag" alt="" class="flag-lg" />
|
||||
</div>
|
||||
<div class="teams-row">
|
||||
<span class="team-name">{{ match.homeTeamName }}</span>
|
||||
<span class="vs-text">VS</span>
|
||||
<span class="team-name">{{ match.awayTeamName }}</span>
|
||||
<div class="match-line">
|
||||
<img v-if="homeFlag" :src="homeFlag" alt="" class="flag" />
|
||||
<div class="names">
|
||||
<span class="team">{{ match.homeTeamName }}</span>
|
||||
<span class="vs">vs</span>
|
||||
<span class="team">{{ match.awayTeamName }}</span>
|
||||
</div>
|
||||
<img v-if="awayFlag" :src="awayFlag" alt="" class="flag" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="markets-section">
|
||||
<FeaturedMarketRow
|
||||
v-for="marketType in FEATURED_MARKET_TYPES"
|
||||
:key="marketType"
|
||||
:label="marketLabel(marketType)"
|
||||
:has-market="marketsByType.has(marketType)"
|
||||
:expanded="isExpanded(marketType)"
|
||||
:reward-active="marketsByType.has(marketType)"
|
||||
@bet="isCorrectScoreMarket(marketType) ? placeCorrectScoreBets(marketType) : openMarket(marketType)"
|
||||
@expand="openMarket(marketType)"
|
||||
@collapse="clearMarketSlip(marketType)"
|
||||
>
|
||||
<CorrectScorePanel
|
||||
v-if="marketsByType.get(marketType) && isCorrectScoreMarket(marketType)"
|
||||
:market-type="marketType"
|
||||
:home-team-name="match.homeTeamName"
|
||||
:away-team-name="match.awayTeamName"
|
||||
:selections="marketsByType.get(marketType)!.selections"
|
||||
v-model:stakes="correctScoreStakes"
|
||||
/>
|
||||
<MarketSelectionsPanel
|
||||
v-else-if="marketsByType.get(marketType)"
|
||||
:selections="marketsByType.get(marketType)!.selections"
|
||||
:is-selected="isSelected"
|
||||
@pick="onPickSelection($event, marketType)"
|
||||
/>
|
||||
</FeaturedMarketRow>
|
||||
<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-grid">
|
||||
<template v-for="marketType in GRID_MARKET_TYPES" :key="marketType">
|
||||
<div class="market-list">
|
||||
<div
|
||||
v-for="marketType in DETAIL_MARKET_TYPES"
|
||||
:key="marketType"
|
||||
class="market-group"
|
||||
:class="{ open: isExpanded(marketType) }"
|
||||
>
|
||||
<MarketTypeTile
|
||||
:label="marketLabel(marketType)"
|
||||
:has-market="marketsByType.has(marketType)"
|
||||
:active="isExpanded(marketType)"
|
||||
@expand="openMarket(marketType)"
|
||||
@collapse="clearMarketSlip(marketType)"
|
||||
:expanded="isExpanded(marketType)"
|
||||
@toggle="toggleMarket(marketType)"
|
||||
/>
|
||||
<MarketSelectionsPanel
|
||||
v-if="isExpanded(marketType) && marketsByType.get(marketType)"
|
||||
class="grid-panel"
|
||||
:selections="marketsByType.get(marketType)!.selections"
|
||||
:is-selected="isSelected"
|
||||
@pick="onPickSelection($event, marketType)"
|
||||
/>
|
||||
</template>
|
||||
<template v-if="isExpanded(marketType) && marketsByType.get(marketType)">
|
||||
<CorrectScorePanel
|
||||
v-if="isCorrectScoreMarket(marketType)"
|
||||
:market-type="marketType"
|
||||
:selections="marketsByType.get(marketType)!.selections"
|
||||
v-model:stakes="correctScoreStakes"
|
||||
/>
|
||||
<button
|
||||
v-if="isCorrectScoreMarket(marketType)"
|
||||
type="button"
|
||||
class="market-foot-btn"
|
||||
@click="openCorrectScoreConfirm(marketType)"
|
||||
>
|
||||
{{ t('bet.cs_confirm_cell') }}
|
||||
</button>
|
||||
<MarketSelectionsPanel
|
||||
v-else
|
||||
compact
|
||||
:selections="marketsByType.get(marketType)!.selections"
|
||||
:is-selected="isSelected"
|
||||
@pick="onPickSelection($event, marketType)"
|
||||
/>
|
||||
<button
|
||||
v-if="!isCorrectScoreMarket(marketType) && hasSlipPickForMarket(marketType)"
|
||||
type="button"
|
||||
class="market-foot-btn"
|
||||
@click="openBetSlipDrawer"
|
||||
>
|
||||
{{ t('bet.cs_confirm_cell') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
@@ -278,120 +336,110 @@ function onPickSelection(selId: string, marketType: string) {
|
||||
<style scoped>
|
||||
.detail-page {
|
||||
margin: 0 -16px;
|
||||
padding-bottom: 24px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px 12px;
|
||||
gap: 12px;
|
||||
padding: 4px 12px 8px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
.icon-btn {
|
||||
flex-shrink: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
color: var(--primary-light);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.icon-btn:disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.match-hero {
|
||||
padding: 2px 12px 10px;
|
||||
}
|
||||
|
||||
.kickoff {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.match-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-tool {
|
||||
.flag {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 6px;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.fifa-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--text-muted);
|
||||
margin-left: 4px;
|
||||
}
|
||||
|
||||
.match-hero {
|
||||
text-align: center;
|
||||
padding: 8px 16px 20px;
|
||||
}
|
||||
|
||||
.kickoff {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--primary-light);
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.flags-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.flag-lg {
|
||||
width: 72px;
|
||||
height: 48px;
|
||||
height: 24px;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
border-radius: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.vs-pill {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
text-transform: lowercase;
|
||||
.names {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.teams-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.team-name {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
color: var(--primary-light);
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.vs-text {
|
||||
.team {
|
||||
display: block;
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--primary-light);
|
||||
}
|
||||
|
||||
.vs {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.markets-section {
|
||||
padding: 0 12px;
|
||||
padding: 0 10px 8px;
|
||||
}
|
||||
|
||||
.market-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 8px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.grid-panel {
|
||||
grid-column: 1 / -1;
|
||||
margin-top: -4px;
|
||||
margin-bottom: 4px;
|
||||
padding: 0 4px 8px;
|
||||
background: #101010;
|
||||
.market-list {
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2a2a2a;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.market-group + .market-group {
|
||||
border-top: 1px solid #252525;
|
||||
}
|
||||
|
||||
.market-foot-btn {
|
||||
display: block;
|
||||
width: calc(100% - 16px);
|
||||
margin: 0 8px 10px;
|
||||
padding: 9px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--border-gold-soft);
|
||||
background: rgba(212, 175, 55, 0.1);
|
||||
color: var(--primary-light);
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.state {
|
||||
@@ -402,9 +450,9 @@ function onPickSelection(selId: string, marketType: string) {
|
||||
|
||||
.cs-toast {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
font-size: 12px;
|
||||
color: var(--primary-light);
|
||||
padding: 4px 12px 8px;
|
||||
padding: 2px 0 6px;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user