feat(player+api): 桌面端钱包/注单栏重构,首页快速下注与移动端体验优化

桌面端:
- 新增 DesktopWalletPageHeader 统一钱包子导航,移除重复大标题
- 重构我的余额页布局(居中窄卡片 + 余额/统计/账户设置)
- 新增 DesktopBetSlipRail 可折叠注单侧栏,优化 BetSlipPanel 交互
- 首页 upcoming 赛事卡片支持胜平负/让球/大小球快速下注
- 优化 DesktopShell 布局、3D 轮播、各账户/消息/充值页面样式

移动端:
- 重构 MobileWalletView 钱包页 UI
- 修复 MarketSelectionsPanel 下注区黑色背景问题
- 新增 PageBackButton 与 useSmartBack 智能返回逻辑
- 优化公告/消息/个人中心等详情页导航

API:
- listUpcomingPublished 支持 includeMarkets,返回首页卡片所需核心玩法
This commit is contained in:
2026-06-30 16:11:27 +08:00
parent ef5b9416cc
commit 7f563326d8
73 changed files with 2866 additions and 1552 deletions

View File

@@ -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;