- 主 Tab 启用 keep-alive,恢复各页滚动位置,避免切页重复加载与重复请求 - 首页数据缓存、余额/头像共用 profile 缓存,冠军盘与串关面板按需加载 - 球赛与串关列表新增「仅显示待开赛」筛选 - 重构历史注单卡片,展示注单类型、赔率与日期 Co-authored-by: Cursor <cursoragent@cursor.com>
415 lines
9.6 KiB
Vue
415 lines
9.6 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, 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';
|
|
import { useOnLocaleChange } from '../composables/useOnLocaleChange';
|
|
import GoldSpinner from '../components/GoldSpinner.vue';
|
|
import { usePullToRefresh } from '../composables/usePullToRefresh';
|
|
import type { MatchPhase } from '../utils/matchPhase';
|
|
|
|
type MainTab = 'matches' | 'outright' | 'parlay';
|
|
type TimeTab = 'today' | 'early';
|
|
|
|
interface Match {
|
|
id: string;
|
|
leagueId?: string;
|
|
homeTeamName: string;
|
|
awayTeamName: string;
|
|
homeTeamCode?: string;
|
|
awayTeamCode?: string;
|
|
homeTeamLogoUrl?: string | null;
|
|
awayTeamLogoUrl?: string | null;
|
|
startTime: string;
|
|
leagueName: string;
|
|
leagueLogoUrl?: string | null;
|
|
displayOrder?: number;
|
|
isHot?: boolean;
|
|
status?: string;
|
|
bettingOpen?: boolean;
|
|
matchPhase?: MatchPhase;
|
|
score?: {
|
|
htHome: number;
|
|
htAway: number;
|
|
ftHome: number;
|
|
ftAway: number;
|
|
} | null;
|
|
}
|
|
|
|
interface LeagueGroup {
|
|
leagueId: string;
|
|
leagueName: string;
|
|
leagueLogoUrl?: string | null;
|
|
matches: Match[];
|
|
}
|
|
|
|
const { t } = useI18n();
|
|
const router = useRouter();
|
|
const slip = useBetSlipStore();
|
|
|
|
const mainTab = ref<MainTab>('matches');
|
|
const timeTab = ref<TimeTab>('early');
|
|
const showAll = ref(false);
|
|
const matches = ref<Match[]>([]);
|
|
const loading = ref(true);
|
|
const expandedLeagues = ref<Set<string>>(new Set());
|
|
|
|
async function loadMatches() {
|
|
loading.value = true;
|
|
try {
|
|
const { data } = await api.get('/player/matches');
|
|
matches.value = data.data ?? [];
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
useOnLocaleChange(loadMatches);
|
|
|
|
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
|
|
onRefresh: async () => { await loadMatches(); },
|
|
});
|
|
|
|
const pullIndicatorStyle = () => ({
|
|
height: `${pullDistance.value}px`,
|
|
opacity: Math.min(pullDistance.value / 48, 1),
|
|
});
|
|
|
|
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);
|
|
const timeMatch = timeTab.value === 'today' ? today : !today;
|
|
if (!timeMatch) return false;
|
|
if (!showAll.value && m.matchPhase !== 'open' && m.matchPhase !== undefined) return false;
|
|
return true;
|
|
});
|
|
});
|
|
|
|
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,
|
|
leagueLogoUrl: m.leagueLogoUrl ?? null,
|
|
matches: [],
|
|
});
|
|
}
|
|
map.get(id)!.matches.push(m);
|
|
}
|
|
const groups = [...map.values()];
|
|
for (const g of groups) {
|
|
g.matches.sort(
|
|
(a, b) =>
|
|
(a.displayOrder ?? 0) - (b.displayOrder ?? 0) ||
|
|
new Date(a.startTime).getTime() - new Date(b.startTime).getTime(),
|
|
);
|
|
}
|
|
return groups.sort(
|
|
(a, b) =>
|
|
(a.matches[0]?.displayOrder ?? 0) - (b.matches[0]?.displayOrder ?? 0) ||
|
|
a.leagueName.localeCompare(b.leagueName),
|
|
);
|
|
});
|
|
|
|
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 class="bet-page">
|
|
<div
|
|
class="pull-indicator"
|
|
:style="pullIndicatorStyle()"
|
|
>
|
|
<GoldSpinner v-if="spinning" :size="28" :progress="progress" :active="spinning" />
|
|
</div>
|
|
|
|
<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-show="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 class="phase-filter">
|
|
<button
|
|
type="button"
|
|
class="phase-toggle"
|
|
:class="{ 'phase-toggle--active': showAll }"
|
|
@click="showAll = !showAll"
|
|
>
|
|
<span class="phase-toggle-dot" />
|
|
{{ showAll ? t('bet.show_all_matches') : t('bet.show_open_only') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div v-if="loading" class="state">
|
|
<GoldSpinner :size="36" />
|
|
</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"
|
|
:league-logo-url="group.leagueLogoUrl"
|
|
: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>
|
|
</div>
|
|
|
|
<OutrightPanel v-if="mainTab === 'outright'" class="outright-tab" />
|
|
|
|
<ParlayPanel v-if="mainTab === 'parlay'" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.pull-indicator {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
overflow: hidden;
|
|
transition: height 0.15s ease;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.phase-filter {
|
|
padding: 0 12px 10px;
|
|
}
|
|
|
|
.phase-toggle {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 6px;
|
|
padding: 6px 14px;
|
|
border-radius: 999px;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
color: #999;
|
|
background: #141414;
|
|
border: 1px solid #333;
|
|
cursor: pointer;
|
|
transition: all 0.2s ease;
|
|
}
|
|
|
|
.phase-toggle--active {
|
|
color: var(--primary-light);
|
|
border-color: var(--primary);
|
|
background: rgba(200, 168, 78, 0.08);
|
|
}
|
|
|
|
.phase-toggle-dot {
|
|
width: 6px;
|
|
height: 6px;
|
|
border-radius: 50%;
|
|
background: #555;
|
|
transition: background 0.2s ease;
|
|
}
|
|
|
|
.phase-toggle--active .phase-toggle-dot {
|
|
background: var(--primary-light);
|
|
}
|
|
|
|
.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;
|
|
}
|
|
|
|
.outright-tab {
|
|
min-height: 0;
|
|
}
|
|
</style>
|