feat(player): 优化赛事详情与串关下注交互

- 合并玩法列表、单选投注单与玩法内确认下单
- 波胆底部确认与核对弹窗;串关底栏固定
- 帮助弹窗与投注单逻辑拆分(详情单关/串关页串关)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-03 10:54:49 +08:00
parent 451cc3984a
commit d432de6fdf
12 changed files with 1089 additions and 540 deletions

View File

@@ -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>