feat(player): ???????????????

???????????????????????????????????????????????????????????

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 16:10:11 +08:00
parent 4a93fcfce0
commit cb4e48b9f4
8 changed files with 362 additions and 162 deletions

View File

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

View File

@@ -78,7 +78,7 @@ const liveScoreText = computed(() => {
:class="{ 'match-card--phase': phase !== 'open' }"
@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 class="status-tag status-tag--pending">{{ phaseLabel }}</span>

View File

@@ -266,6 +266,13 @@ export default {
tab_outright: 'Outright',
tab_today: 'Today',
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_all_matches: 'Show all',
today: 'Today',

View File

@@ -272,6 +272,13 @@ export default {
tab_outright: 'Juara',
tab_today: 'Hari Ini',
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_all_matches: 'Tunjuk semua',
today: 'Hari Ini',

View File

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

View File

@@ -16,7 +16,6 @@ import {
} from '@thebet365/shared';
type MainTab = 'matches' | 'outright';
type TimeTab = 'today' | 'early';
interface Match {
id: string;
@@ -62,17 +61,16 @@ const { t } = useI18n();
const router = useRouter();
const mainTab = ref<MainTab>('matches');
const timeTab = ref<TimeTab>('today');
const showAll = ref(false);
const filterState = ref({
time: 'all' as 'all' | 'today' | 'early',
status: 'open' as 'all' | 'open' | 'settled',
leagueIds: [] as string[]
});
const outrightActivated = ref(false);
const filterNow = ref(new Date());
const { summaryMatches, summaryLoading, loadSummary } = usePlayerMatches();
const matches = summaryMatches;
const loading = summaryLoading;
const expandedLeagues = ref<Record<TimeTab, Set<string>>>({
today: new Set(),
early: new Set(),
});
async function loadMatches() {
filterNow.value = new Date();
@@ -90,18 +88,81 @@ const pullIndicatorStyle = () => ({
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 [];
const now = filterNow.value;
return matches.value.filter((m) => {
const timeMatch =
tab === 'today'
? isInTodayMatchWindow(m.startTime, now)
: isAfterTodayMatchWindow(m.startTime, now);
let timeMatch = true;
if (filterState.value.time === 'today') {
timeMatch = isInTodayMatchWindow(m.startTime, now);
} else if (filterState.value.time === 'early') {
timeMatch = isAfterTodayMatchWindow(m.startTime, now);
}
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;
});
});
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[] {
@@ -111,7 +172,7 @@ function buildLeagueGroups(source: Match[]): LeagueGroup[] {
if (!map.has(id)) {
map.set(id, {
leagueId: id,
leagueName: m.leagueName,
leagueName: normalizeLeagueName(m.leagueName),
leagueLogoUrl: m.leagueLogoUrl ?? null,
matches: [],
});
@@ -133,32 +194,28 @@ function buildLeagueGroups(source: Match[]): LeagueGroup[] {
);
}
const todayLeagueGroups = computed(() => buildLeagueGroups(filterMatchesForTab('today')));
const earlyLeagueGroups = computed(() => buildLeagueGroups(filterMatchesForTab('early')));
const leagueGroups = computed(() => buildLeagueGroups(filteredMatches.value));
function syncExpandedLeagues(groups: LeagueGroup[], tab: TimeTab) {
const current = expandedLeagues.value[tab];
const ids = new Set(current);
const expandedLeagues = ref(new Set<string>());
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 (groups.length > 0 && ids.size === 0) {
ids.add(groups[0].leagueId);
}
if (ids.size !== current.size || [...ids].some((id) => !current.has(id))) {
expandedLeagues.value = { ...expandedLeagues.value, [tab]: ids };
if (ids.size !== expandedLeagues.value.size || [...ids].some((id) => !expandedLeagues.value.has(id))) {
expandedLeagues.value = ids;
}
}
watch(todayLeagueGroups, (groups) => syncExpandedLeagues(groups, 'today'));
watch(earlyLeagueGroups, (groups) => syncExpandedLeagues(groups, 'early'));
});
function toggleLeague(leagueId: string) {
const tab = timeTab.value;
const next = new Set(expandedLeagues.value[tab]);
const next = new Set(expandedLeagues.value);
if (next.has(leagueId)) next.delete(leagueId);
else next.add(leagueId);
expandedLeagues.value = { ...expandedLeagues.value, [tab]: next };
expandedLeagues.value = next;
}
function selectMainTab(tab: MainTab) {
@@ -168,7 +225,7 @@ function selectMainTab(tab: MainTab) {
onActivated(() => {
filterNow.value = new Date();
timeTab.value = 'today';
filterState.value.time = 'all';
void loadSummary(true, true);
});
@@ -208,72 +265,90 @@ function goMatch(id: string) {
</div>
<div :class="['tab-panel', { 'tab-panel--hidden': 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="filters-bar">
<div class="phase-filter">
<div class="league-dropdown">
<button type="button" class="dropdown-trigger" @click="dropdownOpen = !dropdownOpen">
<span class="dropdown-text">{{ selectedLeagueName || t('bet.filter_leagues_all') }}</span>
<span class="arrow-icon" :class="{ open: dropdownOpen }"></span>
</button>
<div v-if="dropdownOpen" class="dropdown-backdrop" @click="dropdownOpen = false"></div>
<div v-if="dropdownOpen" class="dropdown-menu">
<div
class="dropdown-item"
:class="{ active: filterState.leagueIds.length === 0 }"
@click="selectLeague('')"
>
{{ t('bet.filter_leagues_all') }}
</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">
<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 class="right-filters">
<div class="time-tabs">
<button
type="button"
class="time-tab"
:class="{ active: filterState.time === 'all' }"
@click="filterState.time = 'all'"
>
{{ t('bet.filter_status_all') }}
</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 v-if="loading" class="state">
<GoldSpinner :size="36" />
</div>
<template v-else>
<div v-show="timeTab === 'today'">
<div v-if="todayLeagueGroups.length" class="league-list">
<div>
<div v-if="leagueGroups.length" class="league-list">
<LeagueAccordionItem
v-for="group in todayLeagueGroups"
:key="`today-${group.leagueId}`"
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="expandedLeagues.today.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)"
:expanded="expandedLeagues.has(group.leagueId)"
@toggle="toggleLeague(group.leagueId)"
@bet="goMatch"
/>
@@ -292,6 +367,8 @@ function goMatch(id: string) {
:activated="mainTab === 'outright'"
/>
</div>
</template>
@@ -307,7 +384,7 @@ function goMatch(id: string) {
.bet-page {
margin: 0 -16px;
padding-bottom: 8px;
background: #E8EDF2;
background: var(--bg-body);
}
/* Tab panel toggle: avoid display:none to prevent browser releasing image resources */
@@ -341,22 +418,22 @@ function goMatch(id: string) {
justify-content: center;
gap: 6px;
padding: 10px 8px;
border-radius: 8px;
background: #FFFFFF;
border: 1px solid #CBD5E1;
color: #6B7280;
border-radius: var(--radius-sm);
background: var(--bg-card);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 13px;
font-weight: 600;
position: relative;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
box-shadow: var(--shadow);
transition: background 0.2s, border-color 0.2s, color 0.2s;
}
.main-tab.active {
font-weight: 700;
background: #EBF3FB;
border-color: #003D6B;
color: #003D6B;
background: rgba(244, 162, 97, 0.1);
border-color: var(--primary);
color: var(--primary-light);
}
.tab-icon {
@@ -364,68 +441,174 @@ function goMatch(id: string) {
line-height: 1;
}
.time-tabs {
.filters-bar {
display: flex;
gap: 10px;
padding: 0 12px 14px;
}
.time-tab {
flex: 1;
padding: 9px 16px;
border-radius: 999px;
font-size: 14px;
font-weight: 700;
color: #003D6B;
background: #FFFFFF;
border: 1px solid #CBD5E1;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
transition: background 0.2s, border-color 0.2s, color 0.2s, box-shadow 0.2s;
}
.time-tab.active {
background: #003D6B !important;
border-color: #003D6B !important;
color: #FFFFFF !important;
font-weight: 700;
box-shadow: 0 2px 8px rgba(0, 61, 107, 0.25);
align-items: center;
justify-content: flex-start;
gap: 12px;
padding: 0 16px 12px;
}
.phase-filter {
padding: 0 12px 10px;
padding: 0;
display: flex;
align-items: center;
}
.phase-toggle {
.right-filters {
display: flex;
align-items: center;
gap: 8px;
}
.time-tabs {
display: flex;
align-items: center;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 2px 3px;
height: 26px;
box-sizing: border-box;
}
.time-tab {
padding: 0 10px;
height: 100%;
font-size: 12px;
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;
}
.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 10px;
height: 26px;
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;
}
.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;
}
.dropdown-trigger {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 14px;
border-radius: 999px;
padding: 0 12px;
font-size: 12px;
font-weight: 600;
color: #6B7280;
background: #FFFFFF;
border: 1px solid #CBD5E1;
font-weight: 500;
color: var(--text);
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
height: 24px;
box-sizing: border-box;
max-width: 150px;
}
.phase-toggle--active {
color: #003D6B;
border-color: #003D6B;
background: rgba(0, 61, 107, 0.06);
.dropdown-text {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.phase-toggle-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: #CBD5E1;
transition: background 0.2s ease;
.dropdown-trigger .arrow-icon {
font-size: 8px;
color: var(--text-muted);
transition: transform 0.2s ease;
display: inline-block;
transform: scale(0.8);
}
.phase-toggle--active .phase-toggle-dot {
background: #003D6B;
.dropdown-trigger .arrow-icon.open {
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: #FFFFFF;
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 20px rgba(15, 23, 42, 0.35);
padding: 6px 0;
}
.dropdown-item {
padding: 8px 14px;
font-size: 13px;
color: var(--text);
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dropdown-item:hover,
.dropdown-item:active {
background: var(--bg-body);
}
.dropdown-item.active {
color: var(--primary-light);
background: rgba(244, 162, 97, 0.1);
font-weight: 700;
}
.league-list {
@@ -436,7 +619,7 @@ function goMatch(id: string) {
.placeholder,
.empty {
text-align: center;
color: #6B7280;
color: var(--text-muted);
padding: 48px 20px;
font-weight: 600;
}

View File

@@ -68,12 +68,6 @@ function formatKickoff(startTime: string) {
</span>
<span class="qe-label">{{ t('wallet.view_all') }}</span>
</button>
<button type="button" class="qe-item" @click="router.push('/bet')">
<span class="qe-icon qe-icon--promo">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" width="22" height="22"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>
</span>
<span class="qe-label">{{ t('home.hot_matches') || '热门赛事' }}</span>
</button>
</div>
<h2 class="section-title">{{ t('home.hot_matches') }}</h2>
@@ -147,7 +141,7 @@ function formatKickoff(startTime: string) {
.quick-entries {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin-bottom: 14px;
padding: 0 2px;
@@ -196,11 +190,6 @@ function formatKickoff(startTime: string) {
color: #E8870A;
}
.qe-icon--promo {
background: rgba(220, 38, 38, 0.08);
color: #DC2626;
}
.qe-label {
font-size: 11px;
font-weight: 600;

View File

@@ -142,21 +142,21 @@ const balanceAmountClass = computed(() => {
</div>
<div class="bank-card-balance">
<span class="bank-card-label">当前余额</span>
<span class="bank-card-label">{{ t('wallet.balance') }}</span>
<p class="bank-card-number balance-amount" :class="balanceAmountClass">{{ balanceDisplay }}</p>
</div>
<div class="bank-card-footer">
<div class="bank-card-field">
<span class="bank-card-label">持卡人</span>
<span class="bank-card-label">{{ t('wallet.card_holder') }}</span>
<span class="bank-card-holder">{{ profileRaw?.username }}</span>
</div>
<div class="bank-card-field bank-card-field--center">
<span class="bank-card-label">累计返水</span>
<span class="bank-card-label">{{ t('cashback.total_received') }}</span>
<span class="bank-card-stat">{{ formatMoney(cashbackTotal, locale) }}</span>
</div>
<div class="bank-card-field bank-card-field--right">
<span class="bank-card-label">未结算</span>
<span class="bank-card-label">{{ t('wallet.unsettled') }}</span>
<span class="bank-card-stat">{{ formatMoney(profileRaw?.wallet?.frozenBalance, locale) }}</span>
</div>
</div>
@@ -528,11 +528,12 @@ const balanceAmountClass = computed(() => {
}
.qa-item {
min-width: 0;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 14px 4px 12px;
padding: 12px 4px 10px;
border-radius: 10px;
background: #FFFFFF;
border: 1px solid #CBD5E1;
@@ -577,15 +578,19 @@ const balanceAmountClass = computed(() => {
}
.qa-label {
font-size: 11px;
font-size: 10px;
font-weight: 600;
color: #1A1A2E;
text-align: center;
line-height: 1.2;
white-space: nowrap;
white-space: normal;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
min-height: 2.4em;
max-width: 100%;
word-break: break-word;
}
.settings-group {