feat(player): 完善 H5 投注端与 API 演示数据
- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑 - 固定顶栏、公告与底栏,仅内容区滚动 - 底部导航与站点 favicon 使用 logo,登录页精简 - API 种子、冠军盘与历史注单增强 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,20 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
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 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';
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t, locale } = useI18n();
|
||||
const slip = useBetSlipStore();
|
||||
const match = ref<MatchDetail | null>(null);
|
||||
|
||||
interface MatchDetail {
|
||||
id: string;
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
startTime: string;
|
||||
markets: Market[];
|
||||
}
|
||||
|
||||
interface Market {
|
||||
id: string;
|
||||
@@ -32,20 +37,137 @@ interface Selection {
|
||||
oddsVersion: string;
|
||||
}
|
||||
|
||||
const marketLabels: Record<string, string> = {
|
||||
FT_1X2: '全场独赢',
|
||||
FT_HANDICAP: '全场让球',
|
||||
FT_OVER_UNDER: '全场大小',
|
||||
FT_ODD_EVEN: '全场单双',
|
||||
HT_1X2: '半场独赢',
|
||||
FT_CORRECT_SCORE: '波胆',
|
||||
};
|
||||
interface MatchDetail {
|
||||
id: string;
|
||||
homeTeamName: string;
|
||||
awayTeamName: string;
|
||||
homeTeamCode?: string;
|
||||
awayTeamCode?: string;
|
||||
startTime: string;
|
||||
markets: Market[];
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const { data } = await api.get(`/player/matches/${route.params.id}`);
|
||||
match.value = data.data;
|
||||
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 marketsByType = computed(() => {
|
||||
const map = new Map<string, Market>();
|
||||
for (const m of match.value?.markets ?? []) {
|
||||
map.set(m.marketType, m);
|
||||
}
|
||||
return map;
|
||||
});
|
||||
|
||||
const homeFlag = computed(() =>
|
||||
teamFlagUrl(match.value?.homeTeamCode, match.value?.homeTeamName),
|
||||
);
|
||||
const awayFlag = computed(() =>
|
||||
teamFlagUrl(match.value?.awayTeamCode, match.value?.awayTeamName),
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
function marketLabel(marketType: string) {
|
||||
const key = MARKET_I18N_KEY[marketType];
|
||||
return key ? t(key) : marketType;
|
||||
}
|
||||
|
||||
function expandKey(marketType: string) {
|
||||
return marketType;
|
||||
}
|
||||
|
||||
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 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 genRequestId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
||||
}
|
||||
|
||||
async function placeCorrectScoreBets(marketType: string) {
|
||||
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;
|
||||
} catch (e: unknown) {
|
||||
csMessage.value =
|
||||
(e as { response?: { data?: { error?: string } } })?.response?.data?.error ||
|
||||
t('bet.cs_place_failed');
|
||||
} finally {
|
||||
placingCs.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMatch() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await api.get(`/player/matches/${route.params.id}`);
|
||||
match.value = data.data;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadMatch);
|
||||
|
||||
function isSelected(id: string) {
|
||||
return slip.items.some((i) => i.selectionId === id);
|
||||
}
|
||||
@@ -54,7 +176,7 @@ function toggleSelection(sel: Selection, market: Market) {
|
||||
if (!match.value) return;
|
||||
slip.addItem({
|
||||
selectionId: sel.id,
|
||||
oddsVersion: sel.oddsVersion,
|
||||
oddsVersion: String(sel.oddsVersion),
|
||||
matchId: match.value.id,
|
||||
matchName: `${match.value.homeTeamName} vs ${match.value.awayTeamName}`,
|
||||
selectionName: sel.selectionName,
|
||||
@@ -63,40 +185,226 @@ function toggleSelection(sel: Selection, market: Market) {
|
||||
});
|
||||
}
|
||||
|
||||
const groupedMarkets = computed(() => {
|
||||
if (!match.value) return [];
|
||||
return match.value.markets;
|
||||
});
|
||||
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);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="match">
|
||||
<div class="match-header card">
|
||||
<h2>{{ match.homeTeamName }} vs {{ match.awayTeamName }}</h2>
|
||||
<p class="time">{{ new Date(match.startTime).toLocaleString() }}</p>
|
||||
</div>
|
||||
|
||||
<div v-for="market in groupedMarkets" :key="market.id" class="card market-group">
|
||||
<h3>{{ marketLabels[market.marketType] || market.marketType }}</h3>
|
||||
<div class="selections">
|
||||
<button
|
||||
v-for="sel in market.selections"
|
||||
:key="sel.id"
|
||||
class="odds-btn"
|
||||
:class="{ selected: isSelected(sel.id) }"
|
||||
@click="toggleSelection(sel, market)"
|
||||
>
|
||||
<div class="label">{{ sel.selectionName }}</div>
|
||||
<div class="value">{{ sel.odds }}</div>
|
||||
<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>
|
||||
<button type="button" class="btn-tool btn-gold-outline" :aria-label="t('bet.download')">↓</button>
|
||||
<span class="fifa-badge">FIFA</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
|
||||
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<p v-if="csMessage" class="cs-toast">{{ csMessage }}</p>
|
||||
|
||||
<div class="market-grid">
|
||||
<template v-for="marketType in GRID_MARKET_TYPES" :key="marketType">
|
||||
<MarketTypeTile
|
||||
:label="marketLabel(marketType)"
|
||||
:has-market="marketsByType.has(marketType)"
|
||||
:active="isExpanded(marketType)"
|
||||
@expand="openMarket(marketType)"
|
||||
@collapse="clearMarketSlip(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>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.match-header h2 { font-size: 18px; margin-bottom: 4px; }
|
||||
.time { color: var(--text-muted); font-size: 13px; }
|
||||
.market-group h3 { font-size: 14px; margin-bottom: 12px; color: var(--text-muted); }
|
||||
.selections { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.detail-page {
|
||||
margin: 0 -16px;
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 12px 12px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.btn-tool {
|
||||
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;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.vs-pill {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.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 {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.markets-section {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.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;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.state {
|
||||
text-align: center;
|
||||
padding: 48px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.cs-toast {
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
color: var(--primary-light);
|
||||
padding: 4px 12px 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user