feat(player): 完善 H5 投注端与 API 演示数据

- 球赛/串关/优胜冠军、赛事详情、历史投注与个人资料编辑
- 固定顶栏、公告与底栏,仅内容区滚动
- 底部导航与站点 favicon 使用 logo,登录页精简
- API 种子、冠军盘与历史注单增强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-02 17:18:11 +08:00
parent 7af2e418c3
commit b5dca1bfb1
75 changed files with 7077 additions and 384 deletions

View File

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