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,66 +1,293 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue';
import { ref, computed, onMounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import api from '../api';
import { useBetSlipStore } from '../stores/betSlip';
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
import OutrightPanel from '../components/outright/OutrightPanel.vue';
import ParlayPanel from '../components/parlay/ParlayPanel.vue';
import emptyMatchesImg from '../assets/images/empty-matches.svg';
const router = useRouter();
const matches = ref<Match[]>([]);
type MainTab = 'matches' | 'outright' | 'parlay';
type TimeTab = 'today' | 'early';
interface Match {
id: string;
leagueId?: string;
homeTeamName: string;
awayTeamName: string;
homeTeamCode?: string;
awayTeamCode?: string;
startTime: string;
leagueName: string;
markets?: Array<{ marketType: string; selections: Selection[] }>;
}
interface Selection {
id: string;
selectionCode: string;
selectionName: string;
odds: string;
oddsVersion: string;
interface LeagueGroup {
leagueId: string;
leagueName: string;
matches: Match[];
}
const { t } = useI18n();
const router = useRouter();
const slip = useBetSlipStore();
const mainTab = ref<MainTab>('matches');
const timeTab = ref<TimeTab>('early');
const matches = ref<Match[]>([]);
const loading = ref(true);
const expandedLeagues = ref<Set<string>>(new Set());
onMounted(async () => {
const { data } = await api.get('/player/matches');
matches.value = data.data;
loading.value = true;
try {
const { data } = await api.get('/player/matches');
matches.value = data.data ?? [];
} finally {
loading.value = false;
}
});
function dayStart(d: Date) {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}
function isKickoffToday(startTime: string) {
const kick = new Date(startTime);
const now = new Date();
const start = dayStart(now);
const end = new Date(start);
end.setDate(end.getDate() + 1);
return kick >= start && kick < end;
}
const filteredMatches = computed(() => {
if (mainTab.value !== 'matches') return [];
return matches.value.filter((m) => {
const today = isKickoffToday(m.startTime);
return timeTab.value === 'today' ? today : !today;
});
});
const leagueGroups = computed<LeagueGroup[]>(() => {
const map = new Map<string, LeagueGroup>();
for (const m of filteredMatches.value) {
const id = m.leagueId ?? m.leagueName;
if (!map.has(id)) {
map.set(id, { leagueId: id, leagueName: m.leagueName, matches: [] });
}
map.get(id)!.matches.push(m);
}
return [...map.values()];
});
watch(leagueGroups, (groups) => {
const ids = new Set(expandedLeagues.value);
for (const id of [...ids]) {
if (!groups.some((g) => g.leagueId === id)) ids.delete(id);
}
if (ids.size !== expandedLeagues.value.size) expandedLeagues.value = ids;
});
function isLeagueExpanded(leagueId: string) {
return expandedLeagues.value.has(leagueId);
}
function toggleLeague(leagueId: string) {
const next = new Set(expandedLeagues.value);
if (next.has(leagueId)) next.delete(leagueId);
else next.add(leagueId);
expandedLeagues.value = next;
}
function selectMainTab(tab: MainTab) {
mainTab.value = tab;
}
function goMatch(id: string) {
router.push(`/match/${id}`);
}
</script>
<template>
<div>
<h2 class="title">足球赛事</h2>
<div v-for="match in matches" :key="match.id" class="card">
<div class="league">{{ match.leagueName }}</div>
<div class="teams" @click="goMatch(match.id)">
{{ match.homeTeamName }} vs {{ match.awayTeamName }}
</div>
<div class="time">{{ new Date(match.startTime).toLocaleString() }}</div>
<div v-if="match.markets?.length" class="odds-row">
<template v-for="market in match.markets.filter(m => m.marketType === 'FT_1X2')" :key="market.marketType">
<div v-for="sel in market.selections" :key="sel.id" class="odds-btn" @click="goMatch(match.id)">
<div class="label">{{ sel.selectionName }}</div>
<div class="value">{{ sel.odds }}</div>
</div>
</template>
</div>
<div class="bet-page">
<div class="main-tabs">
<button
type="button"
class="main-tab"
:class="{ active: mainTab === 'matches', 'tab-gold-active': mainTab === 'matches' }"
@click="selectMainTab('matches')"
>
<span class="tab-icon"></span>
{{ t('bet.tab_matches') }}
</button>
<button
type="button"
class="main-tab"
:class="{ active: mainTab === 'outright', 'tab-gold-active': mainTab === 'outright' }"
@click="selectMainTab('outright')"
>
<span class="tab-icon">🏆</span>
{{ t('bet.tab_outright') }}
</button>
<button
type="button"
class="main-tab parlay-tab"
:class="{ active: mainTab === 'parlay', 'tab-gold-active': mainTab === 'parlay' }"
@click="selectMainTab('parlay')"
>
<span class="tab-icon">+</span>
{{ t('bet.tab_parlay') }}
<span v-if="slip.count" class="tab-badge">{{ slip.count }}</span>
</button>
</div>
<div v-if="!matches.length" class="empty">暂无赛事</div>
<template v-if="mainTab === 'matches'">
<div class="time-tabs">
<button
type="button"
class="time-tab"
:class="{ active: timeTab === 'today', 'tab-gold-active': timeTab === 'today' }"
@click="timeTab = 'today'"
>
{{ t('bet.tab_today') }}
</button>
<button
type="button"
class="time-tab"
:class="{ active: timeTab === 'early', 'tab-gold-active': timeTab === 'early' }"
@click="timeTab = 'early'"
>
{{ t('bet.tab_early') }}
</button>
</div>
<div v-if="loading" class="state">{{ t('bet.loading') }}</div>
<div v-else-if="leagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in leagueGroups"
:key="group.leagueId"
:league-id="group.leagueId"
:league-name="group.leagueName"
:matches="group.matches"
:expanded="isLeagueExpanded(group.leagueId)"
@toggle="toggleLeague(group.leagueId)"
@bet="goMatch"
/>
</div>
<div v-else class="empty">
<img :src="emptyMatchesImg" alt="" class="empty-icon" />
<p>{{ t('bet.no_matches') }}</p>
</div>
</template>
<OutrightPanel v-else-if="mainTab === 'outright'" />
<ParlayPanel v-else-if="mainTab === 'parlay'" />
</div>
</template>
<style scoped>
.title { font-size: 18px; margin-bottom: 16px; }
.league { font-size: 11px; color: var(--primary); margin-bottom: 4px; }
.teams { font-weight: 600; cursor: pointer; margin-bottom: 4px; }
.time { font-size: 12px; color: var(--text-muted); margin-bottom: 12px; }
.odds-row { display: flex; gap: 8px; }
.empty { text-align: center; color: var(--text-muted); padding: 40px; }
.bet-page {
margin: 0 -16px;
padding-bottom: 8px;
}
.main-tabs {
display: flex;
gap: 8px;
padding: 0 12px 12px;
}
.main-tab {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 12px 8px;
border-radius: 10px;
background: #1a1a1a;
border: 1px solid #2a2a2a;
color: var(--text-muted);
font-size: 13px;
font-weight: 700;
position: relative;
}
.main-tab.active {
font-weight: 800;
}
.tab-icon {
font-size: 16px;
line-height: 1;
}
.tab-badge {
position: absolute;
top: 4px;
right: 6px;
min-width: 16px;
padding: 0 4px;
border-radius: 8px;
background: #1a1000;
color: var(--primary-light);
font-size: 10px;
font-weight: 800;
line-height: 16px;
}
.time-tabs {
display: flex;
gap: 10px;
padding: 0 12px 14px;
}
.time-tab {
flex: 1;
padding: 10px 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 800;
color: var(--primary-light);
background: #141414;
border: 1px solid var(--primary);
}
.time-tab.active {
font-weight: 800;
}
.league-list {
padding: 4px 12px 0;
}
.state,
.placeholder,
.empty {
text-align: center;
color: var(--text-muted);
padding: 48px 20px;
font-weight: 600;
}
.empty-icon {
width: 96px;
height: 96px;
margin-bottom: 14px;
}
.placeholder {
padding: 80px 20px;
}
.parlay-tab.tab-gold-active {
flex: 1.15;
}
</style>