fix(player): ????????????????

?????????????????+??+?????????????????????????????????/???????????????

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 16:25:27 +08:00
parent 06d85953ba
commit f34fe54489
6 changed files with 367 additions and 141 deletions

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import MatchBetCard from './MatchBetCard.vue'; import MatchBetCard from './MatchBetCard.vue';
import saishiImg from '../assets/images/saishi.webp'; import saishiImg from '../assets/images/saishi.webp';
import cardBg from '../assets/images/card-bg.webp'; import cardBg from '../assets/images/card-bg.webp';
@@ -40,6 +41,7 @@ const props = defineProps<{
}>(); }>();
const emit = defineEmits<{ toggle: []; bet: [id: string] }>(); const emit = defineEmits<{ toggle: []; bet: [id: string] }>();
const { t } = useI18n();
const totalCount = computed(() => props.matches.length); const totalCount = computed(() => props.matches.length);
const liveCount = computed(() => props.matches.filter(m => m.matchPhase === 'closed_pending').length); const liveCount = computed(() => props.matches.filter(m => m.matchPhase === 'closed_pending').length);
@@ -55,9 +57,9 @@ const openCount = computed(() => props.matches.filter(m => m.matchPhase === 'ope
<span class="league-info"> <span class="league-info">
<span class="league-title">*{{ leagueName }}</span> <span class="league-title">*{{ leagueName }}</span>
<span class="league-stats"> <span class="league-stats">
<span class="stat-item">{{ totalCount }}</span> <span class="stat-item">{{ totalCount }} {{ t('bet.match_unit') }}</span>
<span v-if="liveCount > 0" class="stat-item stat-live">{{ liveCount }}进行中</span> <span v-if="liveCount > 0" class="stat-item stat-live">{{ liveCount }} {{ t('bet.status_live') }}</span>
<span v-if="openCount > 0" class="stat-item stat-open">{{ openCount }}待开赛</span> <span v-if="openCount > 0" class="stat-item stat-open">{{ openCount }} {{ t('bet.status_open') }}</span>
</span> </span>
</span> </span>
<img <img

View File

@@ -78,7 +78,7 @@ const liveScoreText = computed(() => {
:class="{ 'match-card--phase': phase !== 'open' }" :class="{ 'match-card--phase': phase !== 'open' }"
@click="emit('bet', match.id)" @click="emit('bet', match.id)"
> >
<span v-if="phase === 'open'" class="status-tag status-tag--open">待开赛</span> <span v-if="phase === 'open'" class="status-tag status-tag--open">{{ t('bet.status_open') }}</span>
<span v-else-if="phase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span> <span v-else-if="phase === 'settled'" class="status-tag status-tag--settled">{{ phaseLabel }}</span>
<span v-else class="status-tag status-tag--pending">{{ phaseLabel }}</span> <span v-else class="status-tag status-tag--pending">{{ phaseLabel }}</span>

View File

@@ -266,6 +266,13 @@ export default {
tab_outright: 'Outright', tab_outright: 'Outright',
tab_today: 'Today', tab_today: 'Today',
tab_early: 'Early', tab_early: 'Early',
filter_status_all: 'All',
filter_status_open: 'Open',
filter_status_settled: 'Settled',
filter_leagues_all: 'All Leagues',
status_open: 'Open',
status_live: 'Live',
match_unit: 'matches',
show_open_only: 'Open only', show_open_only: 'Open only',
show_all_matches: 'Show all', show_all_matches: 'Show all',
today: 'Today', today: 'Today',

View File

@@ -272,6 +272,13 @@ export default {
tab_outright: 'Juara', tab_outright: 'Juara',
tab_today: 'Hari Ini', tab_today: 'Hari Ini',
tab_early: 'Awal', tab_early: 'Awal',
filter_status_all: 'Semua',
filter_status_open: 'Buka',
filter_status_settled: 'Selesai',
filter_leagues_all: 'Semua Liga',
status_open: 'Buka',
status_live: 'Sedang berlangsung',
match_unit: 'perlawanan',
show_open_only: 'Buka sahaja', show_open_only: 'Buka sahaja',
show_all_matches: 'Tunjuk semua', show_all_matches: 'Tunjuk semua',
today: 'Hari Ini', today: 'Hari Ini',

View File

@@ -266,6 +266,13 @@ export default {
tab_outright: '优胜冠军', tab_outright: '优胜冠军',
tab_today: '今日', tab_today: '今日',
tab_early: '早盘', tab_early: '早盘',
filter_status_all: '全部',
filter_status_open: '待开赛',
filter_status_settled: '已结束',
filter_leagues_all: '全部联赛',
status_open: '待开赛',
status_live: '进行中',
match_unit: '场',
show_open_only: '仅显示待开赛', show_open_only: '仅显示待开赛',
show_all_matches: '显示全部', show_all_matches: '显示全部',
today: '今日', today: '今日',

View File

@@ -16,7 +16,6 @@ import {
} from '@thebet365/shared'; } from '@thebet365/shared';
type MainTab = 'matches' | 'outright'; type MainTab = 'matches' | 'outright';
type TimeTab = 'today' | 'early';
interface Match { interface Match {
id: string; id: string;
@@ -62,17 +61,16 @@ const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const mainTab = ref<MainTab>('matches'); const mainTab = ref<MainTab>('matches');
const timeTab = ref<TimeTab>('today'); const filterState = ref({
const showAll = ref(false); time: 'all' as 'all' | 'today' | 'early',
status: 'open' as 'all' | 'open' | 'settled',
leagueIds: [] as string[]
});
const outrightActivated = ref(false); const outrightActivated = ref(false);
const filterNow = ref(new Date()); const filterNow = ref(new Date());
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches(); const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
const matches = summaryMatches; const matches = summaryMatches;
const loading = summaryLoading; const loading = summaryLoading;
const expandedLeagues = ref<Record<TimeTab, Set<string>>>({
today: new Set(),
early: new Set(),
});
async function loadMatches() { async function loadMatches() {
filterNow.value = new Date(); filterNow.value = new Date();
@@ -90,18 +88,81 @@ const pullIndicatorStyle = () => ({
opacity: Math.min(pullDistance.value / 48, 1), opacity: Math.min(pullDistance.value / 48, 1),
}); });
function filterMatchesForTab(tab: TimeTab) { function normalizeLeagueName(name: string): string {
return name
.replace(/\.unit$/i, '')
.replace(/[-_]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
const filteredMatches = computed(() => {
if (mainTab.value !== 'matches') return []; if (mainTab.value !== 'matches') return [];
const now = filterNow.value; const now = filterNow.value;
return matches.value.filter((m) => { return matches.value.filter((m) => {
const timeMatch = let timeMatch = true;
tab === 'today' if (filterState.value.time === 'today') {
? isInTodayMatchWindow(m.startTime, now) timeMatch = isInTodayMatchWindow(m.startTime, now);
: isAfterTodayMatchWindow(m.startTime, now); } else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, now);
}
if (!timeMatch) return false; if (!timeMatch) return false;
if (!showAll.value && m.matchPhase !== 'open' && m.matchPhase !== undefined) return false;
if (filterState.value.status === 'open' && m.matchPhase !== 'open' && m.matchPhase !== undefined) return false;
if (filterState.value.status === 'settled' && m.matchPhase !== 'settled') return false;
if (filterState.value.leagueIds.length > 0) {
const id = m.leagueId ?? m.leagueName;
if (!filterState.value.leagueIds.includes(id)) return false;
}
return true; return true;
}); });
});
const availableLeagues = computed(() => {
const map = new Map<string, { id: string, name: string }>();
for (const m of matches.value) {
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, filterNow.value);
} else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, filterNow.value);
}
if (!timeMatch) continue;
const id = m.leagueId ?? m.leagueName;
if (!map.has(id)) {
map.set(id, { id, name: normalizeLeagueName(m.leagueName) });
}
}
return [...map.values()].sort((a, b) => a.name.localeCompare(b.name));
});
watch(() => filterState.value.time, () => {
filterState.value.leagueIds = [];
});
const dropdownOpen = ref(false);
const selectedLeagueName = computed(() => {
if (filterState.value.leagueIds.length === 0) return '';
const firstId = filterState.value.leagueIds[0];
const lg = availableLeagues.value.find(l => l.id === firstId);
return lg ? lg.name : '';
});
function selectLeague(id: string) {
if (id === '') {
filterState.value.leagueIds = [];
} else {
filterState.value.leagueIds = [id];
}
dropdownOpen.value = false;
}
function toggleStatusOpen() {
filterState.value.status = filterState.value.status === 'open' ? 'all' : 'open';
} }
function buildLeagueGroups(source: Match[]): LeagueGroup[] { function buildLeagueGroups(source: Match[]): LeagueGroup[] {
@@ -111,7 +172,7 @@ function buildLeagueGroups(source: Match[]): LeagueGroup[] {
if (!map.has(id)) { if (!map.has(id)) {
map.set(id, { map.set(id, {
leagueId: id, leagueId: id,
leagueName: m.leagueName, leagueName: normalizeLeagueName(m.leagueName),
leagueLogoUrl: m.leagueLogoUrl ?? null, leagueLogoUrl: m.leagueLogoUrl ?? null,
matches: [], matches: [],
}); });
@@ -133,32 +194,28 @@ function buildLeagueGroups(source: Match[]): LeagueGroup[] {
); );
} }
const todayLeagueGroups = computed(() => buildLeagueGroups(filterMatchesForTab('today'))); const leagueGroups = computed(() => buildLeagueGroups(filteredMatches.value));
const earlyLeagueGroups = computed(() => buildLeagueGroups(filterMatchesForTab('early')));
function syncExpandedLeagues(groups: LeagueGroup[], tab: TimeTab) { const expandedLeagues = ref(new Set<string>());
const current = expandedLeagues.value[tab];
const ids = new Set(current); watch(leagueGroups, (groups) => {
const ids = new Set(expandedLeagues.value);
for (const id of [...ids]) { for (const id of [...ids]) {
if (!groups.some((g) => g.leagueId === id)) ids.delete(id); if (!groups.some((g) => g.leagueId === id)) ids.delete(id);
} }
if (groups.length > 0 && ids.size === 0) { if (groups.length > 0 && ids.size === 0) {
ids.add(groups[0].leagueId); ids.add(groups[0].leagueId);
} }
if (ids.size !== current.size || [...ids].some((id) => !current.has(id))) { if (ids.size !== expandedLeagues.value.size || [...ids].some((id) => !expandedLeagues.value.has(id))) {
expandedLeagues.value = { ...expandedLeagues.value, [tab]: ids }; expandedLeagues.value = ids;
} }
} });
watch(todayLeagueGroups, (groups) => syncExpandedLeagues(groups, 'today'));
watch(earlyLeagueGroups, (groups) => syncExpandedLeagues(groups, 'early'));
function toggleLeague(leagueId: string) { function toggleLeague(leagueId: string) {
const tab = timeTab.value; const next = new Set(expandedLeagues.value);
const next = new Set(expandedLeagues.value[tab]);
if (next.has(leagueId)) next.delete(leagueId); if (next.has(leagueId)) next.delete(leagueId);
else next.add(leagueId); else next.add(leagueId);
expandedLeagues.value = { ...expandedLeagues.value, [tab]: next }; expandedLeagues.value = next;
} }
function selectMainTab(tab: MainTab) { function selectMainTab(tab: MainTab) {
@@ -168,7 +225,7 @@ function selectMainTab(tab: MainTab) {
onActivated(() => { onActivated(() => {
filterNow.value = new Date(); filterNow.value = new Date();
timeTab.value = 'today'; filterState.value.time = 'all';
void loadSummary(true, true); void loadSummary(true, true);
}); });
@@ -208,72 +265,90 @@ function goMatch(id: string) {
</div> </div>
<div :class="['tab-panel', { 'tab-panel--hidden': mainTab !== 'matches' }]"> <div :class="['tab-panel', { 'tab-panel--hidden': mainTab !== 'matches' }]">
<div class="time-tabs"> <div class="filters-bar">
<button <div class="phase-filter">
type="button" <div class="league-dropdown">
class="time-tab" <button type="button" class="dropdown-trigger" @click="dropdownOpen = !dropdownOpen">
:class="{ active: timeTab === 'today', 'tab-gold-active': timeTab === 'today' }" <span class="dropdown-text">{{ selectedLeagueName || t('bet.filter_leagues_all') }}</span>
@click="timeTab = 'today'" <span class="arrow-icon" :class="{ open: dropdownOpen }"></span>
> </button>
{{ t('bet.tab_today') }} <div v-if="dropdownOpen" class="dropdown-backdrop" @click="dropdownOpen = false"></div>
</button> <div v-if="dropdownOpen" class="dropdown-menu">
<button <div
type="button" class="dropdown-item"
class="time-tab" :class="{ active: filterState.leagueIds.length === 0 }"
:class="{ active: timeTab === 'early', 'tab-gold-active': timeTab === 'early' }" @click="selectLeague('')"
@click="timeTab = 'early'" >
> {{ t('bet.filter_leagues_all') }}
{{ t('bet.tab_early') }} </div>
</button> <div
</div> v-for="lg in availableLeagues"
:key="lg.id"
class="dropdown-item"
:class="{ active: filterState.leagueIds.includes(lg.id) }"
@click="selectLeague(lg.id)"
>
{{ lg.name }}
</div>
</div>
</div>
</div>
<div class="phase-filter"> <div class="right-filters">
<button <div class="time-tabs">
type="button" <button
class="phase-toggle" type="button"
:class="{ 'phase-toggle--active': !showAll }" class="time-tab"
:aria-pressed="!showAll" :class="{ active: filterState.time === 'all' }"
@click="showAll = !showAll" @click="filterState.time = 'all'"
> >
<span class="phase-toggle-dot" /> {{ t('bet.filter_status_all') }}
{{ t('bet.show_open_only') }} </button>
</button> <button
type="button"
class="time-tab"
:class="{ active: filterState.time === 'today' }"
@click="filterState.time = 'today'"
>
{{ t('bet.tab_today') }}
</button>
<button
type="button"
class="time-tab"
:class="{ active: filterState.time === 'early' }"
@click="filterState.time = 'early'"
>
{{ t('bet.tab_early') }}
</button>
</div>
<div class="divider"></div>
<button
type="button"
class="status-toggle-btn"
:class="{ active: filterState.status === 'open' }"
@click="toggleStatusOpen"
>
{{ t('bet.filter_status_open') }}
</button>
</div>
</div> </div>
<div v-if="loading" class="state"> <div v-if="loading" class="state">
<GoldSpinner :size="36" /> <GoldSpinner :size="36" />
</div> </div>
<template v-else> <template v-else>
<div v-show="timeTab === 'today'"> <div>
<div v-if="todayLeagueGroups.length" class="league-list"> <div v-if="leagueGroups.length" class="league-list">
<LeagueAccordionItem <LeagueAccordionItem
v-for="group in todayLeagueGroups" v-for="group in leagueGroups"
:key="`today-${group.leagueId}`" :key="group.leagueId"
:league-id="group.leagueId" :league-id="group.leagueId"
:league-name="group.leagueName" :league-name="group.leagueName"
:league-logo-url="group.leagueLogoUrl" :league-logo-url="group.leagueLogoUrl"
:matches="group.matches" :matches="group.matches"
:expanded="expandedLeagues.today.has(group.leagueId)" :expanded="expandedLeagues.has(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>
<div v-show="timeTab === 'early'">
<div v-if="earlyLeagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in earlyLeagueGroups"
:key="`early-${group.leagueId}`"
:league-id="group.leagueId"
:league-name="group.leagueName"
:league-logo-url="group.leagueLogoUrl"
:matches="group.matches"
:expanded="expandedLeagues.early.has(group.leagueId)"
@toggle="toggleLeague(group.leagueId)" @toggle="toggleLeague(group.leagueId)"
@bet="goMatch" @bet="goMatch"
/> />
@@ -292,6 +367,8 @@ function goMatch(id: string) {
:activated="mainTab === 'outright'" :activated="mainTab === 'outright'"
/> />
</div> </div>
</template> </template>
@@ -307,9 +384,10 @@ function goMatch(id: string) {
.bet-page { .bet-page {
margin: 0 -16px; margin: 0 -16px;
padding-bottom: 8px; padding-bottom: 8px;
background: var(--bg-body);
} }
/* Tab 面板切换:不用 display:none避免浏览器释放图片资源导致重新加载 */ /* Tab panel toggle: avoid display:none to prevent browser releasing image resources */
.tab-panel { .tab-panel {
display: block; display: block;
} }
@@ -339,18 +417,23 @@ function goMatch(id: string) {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 6px; gap: 6px;
padding: 12px 8px; padding: 10px 8px;
border-radius: 10px; border-radius: var(--radius-sm);
background: #1a1a1a; background: var(--bg-card);
border: 1px solid #2a2a2a; border: 1px solid var(--border);
color: var(--text-muted); color: var(--text-muted);
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 600;
position: relative; position: relative;
box-shadow: var(--shadow);
transition: background 0.2s, border-color 0.2s, color 0.2s;
} }
.main-tab.active { .main-tab.active {
font-weight: 800; font-weight: 700;
background: rgba(244, 162, 97, 0.1);
border-color: var(--primary);
color: var(--primary-light);
} }
.tab-icon { .tab-icon {
@@ -358,69 +441,189 @@ function goMatch(id: string) {
line-height: 1; line-height: 1;
} }
.time-tabs { .filters-bar {
display: flex; display: flex;
gap: 10px; align-items: center;
padding: 0 12px 14px; justify-content: flex-start;
} gap: 8px;
padding: 0 12px 12px;
.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 { .phase-filter {
padding: 0 12px 10px; padding: 0;
display: flex;
align-items: center;
flex: 1;
min-width: 0;
} }
.phase-toggle { .right-filters {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.time-tabs {
display: flex;
align-items: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px;
height: 24px;
box-sizing: border-box;
flex-shrink: 0;
}
.time-tab {
padding: 0 8px;
height: 100%;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
flex-shrink: 0;
}
.time-tab.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.15);
font-weight: 600;
}
.right-filters .divider {
width: 1px;
height: 12px;
background: var(--border);
}
.status-toggle-btn {
padding: 0 8px;
height: 24px;
box-sizing: border-box;
font-size: 11px;
font-weight: 500;
color: var(--text-muted);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
display: inline-flex;
align-items: center;
justify-content: center;
white-space: nowrap;
flex-shrink: 0;
}
.status-toggle-btn.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.15);
border-color: var(--primary);
font-weight: 600;
}
/* League Dropdown Selector */
.league-dropdown {
position: relative;
display: inline-block;
width: 100%;
}
.dropdown-trigger {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
padding: 6px 14px; padding: 0 10px;
border-radius: 999px; font-size: 11px;
font-size: 12px; font-weight: 500;
font-weight: 600; color: var(--text);
color: #999; background: var(--bg-card);
background: #141414; border: 1px solid var(--border);
border: 1px solid #333; border-radius: 6px;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; height: 24px;
box-sizing: border-box;
width: 100%;
max-width: 100%;
} }
.phase-toggle--active { .dropdown-text {
color: var(--primary-light); min-width: 0;
border-color: var(--primary); overflow: hidden;
background: rgba(200, 168, 78, 0.08); text-overflow: ellipsis;
white-space: nowrap;
} }
.phase-toggle-dot { .dropdown-trigger .arrow-icon {
width: 6px; font-size: 8px;
height: 6px; color: var(--text-muted);
border-radius: 50%; transition: transform 0.2s ease;
background: #555; display: inline-block;
transition: background 0.2s ease; transform: scale(0.8);
} }
.phase-toggle--active .phase-toggle-dot { .dropdown-trigger .arrow-icon.open {
background: var(--primary-light); transform: scale(0.8) rotate(180deg);
}
.dropdown-backdrop {
position: fixed;
inset: 0;
z-index: 98;
background: transparent;
}
.dropdown-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 99;
min-width: 170px;
max-width: 240px;
max-height: 200px;
overflow-y: auto;
background: #0f1217;
border: 1px solid #2a3240;
border-radius: 8px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.65);
padding: 6px 0;
}
.dropdown-item {
padding: 8px 14px;
font-size: 13px;
color: #e5e7eb;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
border-bottom: 1px solid #1f2937;
}
.dropdown-item:last-child {
border-bottom: none;
}
.dropdown-item:hover,
.dropdown-item:active {
background: #1b2330;
}
.dropdown-item.active {
color: #f4d06f;
background: #242f42;
font-weight: 700;
} }
.league-list { .league-list {