Files
thebet365/apps/player/src/views/FootballView.vue
2026-06-13 17:38:25 +08:00

397 lines
9.5 KiB
Vue

<script setup lang="ts">
import { ref, computed, onActivated, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import { usePlayerMatches } from '../composables/usePlayerMatches';
import LeagueAccordionItem from '../components/LeagueAccordionItem.vue';
import OutrightPanel from '../components/outright/OutrightPanel.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';
import {
isAfterLocalToday as isAfterTodayMatchWindow,
isInLocalToday as isInTodayMatchWindow,
} from '@thebet365/shared';
type MainTab = 'matches' | 'outright';
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 | null;
htAway: number | null;
ftHome: number | null;
ftAway: number | null;
homeCorners?: number | null;
awayCorners?: number | null;
homeYellowCards?: number | null;
awayYellowCards?: number | null;
homeRedCards?: number | null;
awayRedCards?: number | null;
homeCards?: number | null;
awayCards?: number | null;
} | null;
}
interface LeagueGroup {
leagueId: string;
leagueName: string;
leagueLogoUrl?: string | null;
matches: Match[];
}
const { t } = useI18n();
const router = useRouter();
const mainTab = ref<MainTab>('matches');
const timeTab = ref<TimeTab>('today');
const showAll = ref(false);
const filterNow = ref(new Date());
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
const matches = summaryMatches;
const loading = summaryLoading;
const expandedLeagues = ref<Set<string>>(new Set());
async function loadMatches() {
filterNow.value = new Date();
await loadSummary(true);
}
useOnLocaleChange(() => loadSummary(true));
const { pullDistance, refreshing, spinning, progress } = usePullToRefresh({
onRefresh: async () => { await loadMatches(); },
});
const pullIndicatorStyle = () => ({
height: `${pullDistance.value}px`,
opacity: Math.min(pullDistance.value / 48, 1),
});
const filteredMatches = computed(() => {
if (mainTab.value !== 'matches') return [];
const now = filterNow.value;
return matches.value.filter((m) => {
const timeMatch =
timeTab.value === 'today'
? isInTodayMatchWindow(m.startTime, now)
: isAfterTodayMatchWindow(m.startTime, now);
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;
// 默认只展开第一个联赛,减少首屏 DOM
if (groups.length > 0 && expandedLeagues.value.size === 0) {
expandedLeagues.value = new Set([groups[0].leagueId]);
}
});
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;
}
onActivated(() => {
filterNow.value = new Date();
timeTab.value = 'today';
});
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>
</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 }"
:aria-pressed="!showAll"
@click="showAll = !showAll"
>
<span class="phase-toggle-dot" />
{{ 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" />
</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;
}
.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 {
background: var(--gradient-gold) !important;
border-color: #fff1a8 !important;
color: #2a1a00 !important;
font-weight: 800;
box-shadow:
0 0 0 1px rgba(255, 244, 200, 0.28) inset,
0 4px 16px rgba(212, 175, 55, 0.28);
text-shadow: 0 1px 0 rgba(255, 252, 235, 0.75);
}
.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;
}
.outright-tab {
min-height: 0;
}
</style>