feat(player+api): 桌面端钱包/注单栏重构,首页快速下注与移动端体验优化
桌面端: - 新增 DesktopWalletPageHeader 统一钱包子导航,移除重复大标题 - 重构我的余额页布局(居中窄卡片 + 余额/统计/账户设置) - 新增 DesktopBetSlipRail 可折叠注单侧栏,优化 BetSlipPanel 交互 - 首页 upcoming 赛事卡片支持胜平负/让球/大小球快速下注 - 优化 DesktopShell 布局、3D 轮播、各账户/消息/充值页面样式 移动端: - 重构 MobileWalletView 钱包页 UI - 修复 MarketSelectionsPanel 下注区黑色背景问题 - 新增 PageBackButton 与 useSmartBack 智能返回逻辑 - 优化公告/消息/个人中心等详情页导航 API: - listUpcomingPublished 支持 includeMarkets,返回首页卡片所需核心玩法
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, computed, ref } from 'vue';
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, computed, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import DesktopBanner3DCarousel from '../../components/desktop/DesktopBanner3DCarousel.vue';
|
||||
@@ -10,30 +10,50 @@ import GoldSpinner from '../../components/GoldSpinner.vue';
|
||||
import { formatLocalMatchDateTime } from '@thebet365/shared';
|
||||
import type { PlayerHomeMatch } from '../../composables/usePlayerHome';
|
||||
|
||||
type HotTab = 'hot' | 'upcoming';
|
||||
|
||||
const { t, locale } = useI18n();
|
||||
const router = useRouter();
|
||||
const { banners, hotMatches, upcomingMatches, loading, load, announcementItems } = usePlayerHome();
|
||||
const activeTab = ref<HotTab>('hot');
|
||||
const activeFeaturedIndex = ref(0);
|
||||
|
||||
let rotateInterval: number | undefined;
|
||||
|
||||
function startFeaturedRotation() {
|
||||
stopFeaturedRotation();
|
||||
if (hotMatches.value.length > 1) {
|
||||
rotateInterval = window.setInterval(() => {
|
||||
activeFeaturedIndex.value = (activeFeaturedIndex.value + 1) % hotMatches.value.length;
|
||||
}, 4500);
|
||||
}
|
||||
}
|
||||
|
||||
function stopFeaturedRotation() {
|
||||
if (rotateInterval) {
|
||||
window.clearInterval(rotateInterval);
|
||||
rotateInterval = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => hotMatches.value.length, () => {
|
||||
activeFeaturedIndex.value = 0;
|
||||
startFeaturedRotation();
|
||||
});
|
||||
|
||||
const bannerFallbackTo = computed(() => {
|
||||
const id = announcementItems.value[0]?.id;
|
||||
return id ? `/announcements/${id}` : '/announcements';
|
||||
});
|
||||
|
||||
const displayedMatches = computed<PlayerHomeMatch[]>(() =>
|
||||
activeTab.value === 'hot' ? hotMatches.value : upcomingMatches.value,
|
||||
);
|
||||
const displayedMatches = computed<PlayerHomeMatch[]>(() => upcomingMatches.value);
|
||||
|
||||
const emptyMessage = computed(() =>
|
||||
activeTab.value === 'hot' ? t('home.no_matches') : t('home.upcoming_empty'),
|
||||
);
|
||||
const emptyMessage = computed(() => t('home.upcoming_empty'));
|
||||
|
||||
onMounted(() => {
|
||||
void load(true);
|
||||
startFeaturedRotation();
|
||||
});
|
||||
|
||||
onUnmounted(stopFeaturedRotation);
|
||||
|
||||
function goMatch(id: string) {
|
||||
router.push(`/match/${id}`);
|
||||
}
|
||||
@@ -41,6 +61,11 @@ function goMatch(id: string) {
|
||||
function formatKickoff(startTime: string) {
|
||||
return formatLocalMatchDateTime(startTime, locale.value, { variant: 'full' });
|
||||
}
|
||||
|
||||
function setFeaturedIndex(index: number) {
|
||||
activeFeaturedIndex.value = index;
|
||||
startFeaturedRotation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -49,31 +74,119 @@ function formatKickoff(startTime: string) {
|
||||
<DesktopBanner3DCarousel :banners="banners" :fallback-to="bannerFallbackTo" />
|
||||
</section>
|
||||
|
||||
<section class="quick-bento" @mouseenter="stopFeaturedRotation" @mouseleave="startFeaturedRotation">
|
||||
<div class="bento-item bento-item--large bento-item--featured-carousel">
|
||||
<div v-if="hotMatches.length" class="bento-header-row fixed-header">
|
||||
<span class="featured-tag">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="14" height="14" class="tag-icon"><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>
|
||||
{{ t('home.hot_matches') }}
|
||||
</span>
|
||||
<span class="bento-badge">HOT</span>
|
||||
</div>
|
||||
|
||||
<div class="carousel-viewport">
|
||||
<div
|
||||
class="carousel-track"
|
||||
:style="{ transform: `translateX(-${activeFeaturedIndex * 100}%)` }"
|
||||
>
|
||||
<template v-if="hotMatches.length">
|
||||
<div
|
||||
v-for="match in hotMatches"
|
||||
:key="match.id"
|
||||
class="carousel-slide clickable"
|
||||
@click="goMatch(match.id)"
|
||||
>
|
||||
<div class="bento-content--vertical">
|
||||
<div class="bento-header-row placeholder-header" aria-hidden="true">
|
||||
<span class="featured-tag">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="14" height="14" class="tag-icon"><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>
|
||||
{{ t('home.hot_matches') }}
|
||||
</span>
|
||||
<span class="bento-badge">HOT</span>
|
||||
</div>
|
||||
|
||||
<div class="featured-vs-area">
|
||||
<div class="featured-team">
|
||||
<TeamEmblem
|
||||
size="md"
|
||||
:team-code="match.homeTeamCode"
|
||||
:team-name="match.homeTeamName"
|
||||
:logo-url="match.homeTeamLogoUrl"
|
||||
/>
|
||||
<span class="featured-team-name">{{ match.homeTeamName }}</span>
|
||||
</div>
|
||||
<span class="featured-vs">VS</span>
|
||||
<div class="featured-team">
|
||||
<TeamEmblem
|
||||
size="md"
|
||||
:team-code="match.awayTeamCode"
|
||||
:team-name="match.awayTeamName"
|
||||
:logo-url="match.awayTeamLogoUrl"
|
||||
/>
|
||||
<span class="featured-team-name">{{ match.awayTeamName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="featured-footer">
|
||||
<span class="featured-time">{{ formatKickoff(match.startTime) }}</span>
|
||||
<span class="featured-action-btn">{{ t('announcements.go_link') }} ›</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="carousel-slide clickable" @click="router.push('/bet')">
|
||||
<div class="bento-content--vertical">
|
||||
<div class="bento-header-row">
|
||||
<span class="bento-icon bento-icon--bet">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" width="24" height="24"><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 000 20 14.5 14.5 0 000-20M2 12h20"/></svg>
|
||||
</span>
|
||||
<span class="bento-badge">HOT</span>
|
||||
</div>
|
||||
<div class="bento-text">
|
||||
<span class="bento-title bento-title--lg">{{ t('nav.bet') }}</span>
|
||||
<span class="bento-desc">{{ t('home.bento_desc') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hotMatches.length > 1" class="carousel-dots">
|
||||
<button
|
||||
v-for="(_, i) in hotMatches"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="carousel-dot"
|
||||
:class="{ active: i === activeFeaturedIndex }"
|
||||
:aria-label="`${i + 1}`"
|
||||
@click="setFeaturedIndex(i)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="bento-item bento-item--small" @click="router.push('/wallet/recharge')">
|
||||
<span class="bento-icon bento-icon--recharge">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20M12 15h4"/></svg>
|
||||
</span>
|
||||
<span class="bento-title bento-title--sm">{{ t('recharge.title') }}</span>
|
||||
</button>
|
||||
|
||||
<button type="button" class="bento-item bento-item--small" @click="router.push('/bets')">
|
||||
<span class="bento-icon bento-icon--history">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="20" height="20">
|
||||
<path d="M6 4h12a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H6a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1Z" stroke-linejoin="round"/>
|
||||
<path d="M8 9h8M8 12.5h8M8 16h5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span class="bento-title bento-title--sm">{{ t('home.view_bet_history') }}</span>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<div class="content-row">
|
||||
<div class="matches-hub">
|
||||
<div class="hub-header">
|
||||
<div class="tabs-list" role="tablist">
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hub-tab"
|
||||
:class="{ active: activeTab === 'hot' }"
|
||||
:aria-selected="activeTab === 'hot'"
|
||||
@click="activeTab = 'hot'"
|
||||
>
|
||||
{{ t('home.hot_tab') || '热门赛事' }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="hub-tab"
|
||||
:class="{ active: activeTab === 'upcoming' }"
|
||||
:aria-selected="activeTab === 'upcoming'"
|
||||
@click="activeTab = 'upcoming'"
|
||||
>
|
||||
{{ t('home.upcoming_tab') || '即将开赛' }}
|
||||
</button>
|
||||
</div>
|
||||
<h2 class="section-title">{{ t('home.upcoming_tab') }}</h2>
|
||||
<button type="button" class="refresh-btn" :disabled="loading" @click="load(true)">
|
||||
<span v-if="loading" class="spin">↻</span>
|
||||
<span v-else>↻ {{ t('bet.refresh') || '刷新' }}</span>
|
||||
@@ -152,6 +265,282 @@ function formatKickoff(startTime: string) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.quick-bento {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(220px, 1fr);
|
||||
grid-template-rows: 76px 76px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bento-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
box-shadow: var(--shadow);
|
||||
cursor: pointer;
|
||||
transition: transform 0.15s, box-shadow 0.15s, border-color 0.15s;
|
||||
padding: 14px 16px;
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.bento-item:hover {
|
||||
border-color: var(--border-active);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.bento-item--large {
|
||||
grid-row: 1 / 3;
|
||||
height: 100%;
|
||||
background: linear-gradient(135deg, #F3F8FC 0%, #FFFFFF 100%);
|
||||
border-color: rgba(0, 61, 107, 0.15);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.carousel-viewport {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.carousel-track {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
transition: transform 0.4s cubic-bezier(0.25, 1, 0.5, 1);
|
||||
}
|
||||
|
||||
.carousel-slide {
|
||||
flex: 0 0 100%;
|
||||
min-width: 100%;
|
||||
height: 100%;
|
||||
padding: 14px 16px 12px;
|
||||
box-sizing: border-box;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.carousel-dots {
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.carousel-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
border: none;
|
||||
padding: 0;
|
||||
background: rgba(0, 61, 107, 0.15);
|
||||
cursor: pointer;
|
||||
transition: background 0.25s, width 0.25s;
|
||||
}
|
||||
|
||||
.carousel-dot.active {
|
||||
width: 18px;
|
||||
border-radius: 3px;
|
||||
background: var(--primary);
|
||||
}
|
||||
|
||||
.featured-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
background: rgba(0, 61, 107, 0.06);
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.tag-icon {
|
||||
color: var(--odds-accent, #F97316);
|
||||
}
|
||||
|
||||
.featured-vs-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-around;
|
||||
width: 100%;
|
||||
margin: 10px 0;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.featured-team {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.featured-team-name {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.featured-vs {
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
color: var(--odds-accent, #F97316);
|
||||
font-style: italic;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.featured-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
border-top: 1px dashed rgba(0, 61, 107, 0.12);
|
||||
padding-top: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.featured-time {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.featured-action-btn {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
background: rgba(0, 61, 107, 0.06);
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.carousel-slide:hover .featured-action-btn {
|
||||
background: rgba(0, 61, 107, 0.12);
|
||||
}
|
||||
|
||||
.bento-content--vertical {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.bento-header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fixed-header {
|
||||
position: absolute;
|
||||
top: 14px;
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
width: auto;
|
||||
z-index: 20;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.placeholder-header {
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bento-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
background: var(--odds-accent, #F97316);
|
||||
padding: 3px 7px;
|
||||
border-radius: 4px;
|
||||
letter-spacing: 0.05em;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.bento-item--small {
|
||||
flex-direction: row;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.bento-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.bento-icon--bet {
|
||||
background: rgba(0, 61, 107, 0.08);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.bento-icon--recharge {
|
||||
background: rgba(5, 150, 105, 0.08);
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.bento-icon--history {
|
||||
background: rgba(248, 151, 31, 0.08);
|
||||
color: var(--odds-accent, #E8870A);
|
||||
}
|
||||
|
||||
.bento-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.bento-title {
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.bento-title--sm {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bento-title--lg {
|
||||
font-size: 18px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.bento-desc {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.content-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 300px;
|
||||
@@ -168,7 +557,7 @@ function formatKickoff(startTime: string) {
|
||||
}
|
||||
|
||||
.matches-hub {
|
||||
background: rgba(20, 20, 20, 0.85);
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
@@ -183,28 +572,14 @@ function formatKickoff(startTime: string) {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.tabs-list {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hub-tab {
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
padding: 8px 4px;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.hub-tab:hover {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hub-tab.active {
|
||||
color: var(--primary-light);
|
||||
border-bottom-color: var(--primary);
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
color: var(--primary);
|
||||
padding-left: 10px;
|
||||
border-left: 3px solid var(--primary);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.refresh-btn {
|
||||
@@ -242,7 +617,7 @@ function formatKickoff(startTime: string) {
|
||||
}
|
||||
|
||||
.match-card {
|
||||
background: #141414;
|
||||
background: var(--bg-body);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 14px;
|
||||
@@ -290,7 +665,7 @@ function formatKickoff(startTime: string) {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.04);
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.team-side {
|
||||
@@ -304,7 +679,7 @@ function formatKickoff(startTime: string) {
|
||||
.team-name {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
color: #eee;
|
||||
color: var(--text);
|
||||
text-align: center;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
@@ -316,7 +691,7 @@ function formatKickoff(startTime: string) {
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: var(--text-muted);
|
||||
background: #222;
|
||||
background: var(--bg-body);
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user