桌面端: - 新增 DesktopWalletPageHeader 统一钱包子导航,移除重复大标题 - 重构我的余额页布局(居中窄卡片 + 余额/统计/账户设置) - 新增 DesktopBetSlipRail 可折叠注单侧栏,优化 BetSlipPanel 交互 - 首页 upcoming 赛事卡片支持胜平负/让球/大小球快速下注 - 优化 DesktopShell 布局、3D 轮播、各账户/消息/充值页面样式 移动端: - 重构 MobileWalletView 钱包页 UI - 修复 MarketSelectionsPanel 下注区黑色背景问题 - 新增 PageBackButton 与 useSmartBack 智能返回逻辑 - 优化公告/消息/个人中心等详情页导航 API: - listUpcomingPublished 支持 includeMarkets,返回首页卡片所需核心玩法
474 lines
13 KiB
Vue
474 lines
13 KiB
Vue
<script setup lang="ts">
|
|
import { RouterView, RouterLink, useRoute } from 'vue-router';
|
|
import { useI18n } from 'vue-i18n';
|
|
import { useAuthStore } from '../stores/auth';
|
|
import { useBetSlipStore } from '../stores/betSlip';
|
|
import CashBalanceChip from '../components/CashBalanceChip.vue';
|
|
import UserAvatarMenu from '../components/UserAvatarMenu.vue';
|
|
import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
|
import { useAppLocale } from '../composables/useAppLocale';
|
|
import BottomNavIcon from '../components/BottomNavIcon.vue';
|
|
import BackToTopButton from '../components/BackToTopButton.vue';
|
|
import { computed, defineAsyncComponent, onMounted, onUnmounted, ref, watch } from 'vue';
|
|
import { useViewport } from '../composables/useViewport';
|
|
import DesktopShell from '../components/desktop/DesktopShell.vue';
|
|
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
|
|
|
|
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
|
|
import { usePlayerHome } from '../composables/usePlayerHome';
|
|
import { useInboxFeature } from '../composables/useInboxFeature';
|
|
import { usePlayerProfile } from '../composables/usePlayerProfile';
|
|
import { useDepositNotifications } from '../composables/useDepositNotifications';
|
|
import { usePlayerMessages } from '../composables/usePlayerMessages';
|
|
import { startPresencePing, stopPresencePing } from '../composables/usePresencePing';
|
|
|
|
const { isDesktop } = useViewport();
|
|
const { t, locale } = useI18n();
|
|
const auth = useAuthStore();
|
|
const { initFromUser } = useAppLocale();
|
|
const route = useRoute();
|
|
const slip = useBetSlipStore();
|
|
const { inboxEnabled, hubRoute, hubOpenLabelKey } = useInboxFeature();
|
|
|
|
const isDetailPage = computed(() => {
|
|
const p = route.path;
|
|
return (
|
|
p.startsWith('/match/') ||
|
|
p.startsWith('/bet/') ||
|
|
p.startsWith('/bets/') ||
|
|
p.startsWith('/wallet/') ||
|
|
p.startsWith('/messages') ||
|
|
p === '/profile/edit' ||
|
|
p === '/profile/cashbacks'
|
|
);
|
|
});
|
|
|
|
const showHeader = computed(() => !isDetailPage.value);
|
|
const showAnnouncement = computed(
|
|
() => !isDetailPage.value && !route.path.startsWith('/profile') && !route.path.startsWith('/announcements'),
|
|
);
|
|
|
|
const showBottomNav = computed(() => {
|
|
const p = route.path;
|
|
if (
|
|
p === '/' ||
|
|
p === '/bet' ||
|
|
p === '/announcements' ||
|
|
p.startsWith('/announcements/') ||
|
|
p.startsWith('/match/') ||
|
|
p === '/bets' ||
|
|
p === '/wallet' ||
|
|
p.startsWith('/wallet/')
|
|
) return true;
|
|
// 邮箱关闭时客服页保留底部导航,避免用户无法离开
|
|
if (!inboxEnabled.value && p === '/messages') return true;
|
|
return false;
|
|
});
|
|
const { announcements, announcementItems, load: loadPlayerHome } = usePlayerHome();
|
|
const { loadProfile, refreshProfile, bindProfileVisibilityRefresh } = usePlayerProfile();
|
|
const { startPolling, stopPolling } = useDepositNotifications();
|
|
const { unreadCount, refreshUnreadCount, resetMessagesState } = usePlayerMessages();
|
|
const mainRef = ref<HTMLElement | null>(null);
|
|
const tabScrollTops = new Map<string, number>();
|
|
const primaryAnnouncementId = computed(() => announcementItems.value[0]?.id ?? '');
|
|
const bottomNavHidden = ref(false);
|
|
let lastMainScrollTop = 0;
|
|
|
|
function onMainScroll() {
|
|
const el = mainRef.value;
|
|
if (!el || !showBottomNav.value) return;
|
|
|
|
const scrollTop = el.scrollTop;
|
|
const delta = scrollTop - lastMainScrollTop;
|
|
|
|
if (scrollTop <= 8) {
|
|
bottomNavHidden.value = false;
|
|
} else if (delta > 10) {
|
|
bottomNavHidden.value = true;
|
|
} else if (delta < -10) {
|
|
bottomNavHidden.value = false;
|
|
}
|
|
|
|
lastMainScrollTop = scrollTop;
|
|
}
|
|
|
|
watch(showBottomNav, (visible) => {
|
|
if (!visible) bottomNavHidden.value = false;
|
|
});
|
|
|
|
watch(
|
|
() => route.fullPath,
|
|
(_path, oldPath) => {
|
|
lastMainScrollTop = 0;
|
|
bottomNavHidden.value = false;
|
|
const el = mainRef.value;
|
|
if (!el) return;
|
|
if (oldPath) tabScrollTops.set(oldPath, el.scrollTop);
|
|
el.scrollTop = tabScrollTops.get(route.fullPath) ?? 0;
|
|
},
|
|
);
|
|
|
|
const balanceRefreshPaths = ['/wallet', '/bets'];
|
|
let mainScrollEl: HTMLElement | null = null;
|
|
|
|
watch(locale, (next, prev) => {
|
|
if (prev && next !== prev) void loadPlayerHome(true);
|
|
});
|
|
|
|
onMounted(() => {
|
|
if (auth.user?.locale) void initFromUser(auth.user.locale);
|
|
bindProfileVisibilityRefresh();
|
|
mainScrollEl = mainRef.value;
|
|
mainScrollEl?.addEventListener('scroll', onMainScroll, { passive: true });
|
|
});
|
|
|
|
onUnmounted(() => {
|
|
mainScrollEl?.removeEventListener('scroll', onMainScroll);
|
|
mainScrollEl = null;
|
|
});
|
|
|
|
watch(
|
|
() => auth.token,
|
|
(token) => {
|
|
// 首页数据(公告、热门赛事)对所有人公开,始终加载
|
|
void loadPlayerHome(true);
|
|
// 个人资料仅登录用户需要
|
|
if (token) {
|
|
void loadProfile(true);
|
|
startPolling();
|
|
startPresencePing();
|
|
if (inboxEnabled.value) void refreshUnreadCount();
|
|
} else {
|
|
stopPolling();
|
|
stopPresencePing();
|
|
resetMessagesState();
|
|
}
|
|
},
|
|
{ immediate: true },
|
|
);
|
|
|
|
watch(
|
|
() => route.path,
|
|
(path) => {
|
|
if (!auth.token) return;
|
|
if (inboxEnabled.value && path.startsWith('/messages')) {
|
|
void refreshUnreadCount();
|
|
}
|
|
if (balanceRefreshPaths.some((p) => path === p || path.startsWith(`${p}/`))) {
|
|
void refreshProfile();
|
|
}
|
|
},
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<DesktopShell v-if="isDesktop" />
|
|
<div v-else class="layout">
|
|
<header v-if="showHeader" class="header">
|
|
<svg class="logo" viewBox="0 0 130 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-label="TheBet365">
|
|
<text x="0" y="18" font-family="'Inter','SF Pro Display',system-ui,sans-serif" font-size="11" font-weight="800" fill="#6B7280" letter-spacing="0.14em">THE</text>
|
|
<text x="32" y="18" font-family="'Inter','SF Pro Display',system-ui,sans-serif" font-size="18" font-weight="900" fill="#003D6B" letter-spacing="0.01em">BET</text>
|
|
<text x="74" y="18" font-family="'Inter','SF Pro Display',system-ui,sans-serif" font-size="18" font-weight="900" fill="#F8971F" letter-spacing="0.01em">365</text>
|
|
</svg>
|
|
<div class="header-actions">
|
|
<RouterLink
|
|
:to="hubRoute"
|
|
class="support-btn"
|
|
:aria-label="inboxEnabled && unreadCount ? t('messages.unread_badge', { count: unreadCount }) : t(hubOpenLabelKey)"
|
|
>
|
|
<svg class="hub-icon" viewBox="0 0 24 24" aria-hidden="true">
|
|
<path
|
|
d="M12 3C7.03 3 3 6.58 3 11c0 2.02.9 3.86 2.38 5.24L4 21l4.2-1.02A10.8 10.8 0 0 0 12 19c4.97 0 9-3.58 9-8s-4.03-8-9-8Z"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
stroke-width="1.8"
|
|
stroke-linejoin="round"
|
|
/>
|
|
<circle cx="9" cy="11" r="1" fill="currentColor" />
|
|
<circle cx="12" cy="11" r="1" fill="currentColor" />
|
|
<circle cx="15" cy="11" r="1" fill="currentColor" />
|
|
</svg>
|
|
<span v-if="auth.user && inboxEnabled && unreadCount > 0" class="hub-badge">{{ unreadCount > 99 ? '99+' : unreadCount }}</span>
|
|
</RouterLink>
|
|
<div class="header-divider" />
|
|
<LocaleSwitcher />
|
|
<template v-if="auth.user">
|
|
<CashBalanceChip />
|
|
<UserAvatarMenu />
|
|
</template>
|
|
<button v-else type="button" class="login-btn" @click="auth.showLoginPrompt(route.fullPath)">
|
|
{{ t('auth.login') }}
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<div v-if="showAnnouncement" class="announce-strip">
|
|
<AnnouncementMarquee
|
|
:items="announcements"
|
|
:target-id="primaryAnnouncementId"
|
|
embedded
|
|
/>
|
|
</div>
|
|
|
|
<main ref="mainRef" :class="['main', { 'has-nav': showBottomNav, 'main--detail': isDetailPage }]">
|
|
<RouterView v-slot="{ Component, route: viewRoute }">
|
|
<KeepAlive v-if="viewRoute.meta.keepAlive" :max="10">
|
|
<component :is="Component" :key="viewRoute.path" />
|
|
</KeepAlive>
|
|
<component v-else :is="Component" :key="viewRoute.fullPath" />
|
|
</RouterView>
|
|
</main>
|
|
|
|
<nav
|
|
v-if="showBottomNav"
|
|
class="bottom-nav"
|
|
:class="{ 'bottom-nav--hidden': bottomNavHidden }"
|
|
aria-label="Main"
|
|
>
|
|
<RouterLink to="/" class="nav-item" :class="{ active: route.path === '/' }">
|
|
<BottomNavIcon name="home" />
|
|
<span class="nav-label">{{ t('nav.home') }}</span>
|
|
</RouterLink>
|
|
<RouterLink
|
|
to="/bet"
|
|
class="nav-item"
|
|
:class="{ active: route.path === '/bet' || route.path.startsWith('/match') }"
|
|
>
|
|
<BottomNavIcon name="bet" />
|
|
<span class="nav-label">{{ t('nav.bet') }}</span>
|
|
</RouterLink>
|
|
<RouterLink to="/bets" class="nav-item" :class="{ active: route.path === '/bets' }">
|
|
<BottomNavIcon name="history" />
|
|
<span class="nav-label">{{ t('nav.bet_history') }}</span>
|
|
</RouterLink>
|
|
<RouterLink
|
|
to="/wallet"
|
|
class="nav-item"
|
|
:class="{ active: route.path === '/wallet' || route.path.startsWith('/wallet/') }"
|
|
>
|
|
<BottomNavIcon name="wallet" />
|
|
<span class="nav-label">{{ t('nav.my_wallet') }}</span>
|
|
</RouterLink>
|
|
</nav>
|
|
|
|
<BackToTopButton
|
|
:scroll-el="mainRef"
|
|
:above-nav="showBottomNav && !bottomNavHidden"
|
|
/>
|
|
|
|
<BetSlipDrawer v-model="slip.drawerOpen" />
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.layout {
|
|
position: fixed;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
overflow: hidden;
|
|
background: var(--bg-body);
|
|
box-sizing: border-box;
|
|
padding-top: var(--safe-top);
|
|
}
|
|
.header {
|
|
flex-shrink: 0;
|
|
position: relative;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 8px 12px;
|
|
min-height: var(--player-header-h, 48px);
|
|
background: #FFFFFF;
|
|
border-bottom: 1px solid var(--border);
|
|
z-index: 120;
|
|
}
|
|
.logo {
|
|
height: 22px;
|
|
width: auto;
|
|
display: block;
|
|
flex-shrink: 0;
|
|
}
|
|
.header-actions {
|
|
--header-chip-h: 32px;
|
|
display: flex;
|
|
gap: 6px;
|
|
align-items: center;
|
|
min-width: 0;
|
|
}
|
|
.header-actions :deep(.locale-switch:not(.compact)),
|
|
.header-actions :deep(.cash-chip),
|
|
.header-actions :deep(.avatar-btn) {
|
|
height: var(--header-chip-h);
|
|
box-sizing: border-box;
|
|
}
|
|
.header-actions :deep(.avatar-btn) {
|
|
width: var(--header-chip-h);
|
|
}
|
|
|
|
.header-divider {
|
|
width: 1px;
|
|
height: 18px;
|
|
background: #E5E7EB;
|
|
flex-shrink: 0;
|
|
margin: 0 2px;
|
|
}
|
|
|
|
.support-btn {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 0;
|
|
width: var(--header-chip-h, 32px);
|
|
height: var(--header-chip-h, 32px);
|
|
padding: 0;
|
|
border-radius: 50%;
|
|
border: 1px solid var(--border);
|
|
background: #F5F7FA;
|
|
color: var(--primary);
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
cursor: pointer;
|
|
transition: background 0.15s, border-color 0.15s;
|
|
flex-shrink: 0;
|
|
position: relative;
|
|
text-decoration: none;
|
|
}
|
|
.support-btn:active {
|
|
background: var(--bg-hover);
|
|
border-color: var(--border-active);
|
|
}
|
|
|
|
.support-icon, .hub-icon {
|
|
width: 16px;
|
|
height: 16px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.hub-badge {
|
|
position: absolute;
|
|
top: -4px;
|
|
right: -4px;
|
|
min-width: 16px;
|
|
padding: 0 4px;
|
|
border-radius: 8px;
|
|
background: var(--primary);
|
|
color: #fff;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
line-height: 16px;
|
|
text-align: center;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.support-label {
|
|
display: none;
|
|
}
|
|
|
|
.login-btn {
|
|
padding: 6px 18px;
|
|
border-radius: var(--radius-sm);
|
|
border: 1px solid var(--primary);
|
|
background: var(--primary);
|
|
color: #FFFFFF;
|
|
font-size: 13px;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
transition: background 0.15s;
|
|
}
|
|
.login-btn:active {
|
|
background: var(--primary-dark);
|
|
}
|
|
|
|
.announce-strip {
|
|
flex-shrink: 0;
|
|
z-index: 105;
|
|
background: #FFFFFF;
|
|
position: relative;
|
|
border-bottom: 1px solid var(--border);
|
|
}
|
|
|
|
.main {
|
|
flex: 1;
|
|
min-height: 0;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
-webkit-overflow-scrolling: touch;
|
|
padding: 10px 14px 0;
|
|
}
|
|
.main.has-nav {
|
|
padding-bottom: calc(var(--player-bottom-nav-h) + var(--safe-bottom));
|
|
}
|
|
.main.main--detail {
|
|
padding-top: 0;
|
|
}
|
|
|
|
.bottom-nav {
|
|
position: fixed;
|
|
left: 0;
|
|
right: 0;
|
|
bottom: 0;
|
|
display: flex;
|
|
background: #FFFFFF;
|
|
box-sizing: border-box;
|
|
min-height: calc(var(--player-bottom-nav-h) + var(--safe-bottom));
|
|
padding-bottom: var(--safe-bottom);
|
|
border-top: 1px solid var(--border);
|
|
z-index: 100;
|
|
transition: transform 0.28s ease, opacity 0.28s ease;
|
|
will-change: transform;
|
|
}
|
|
|
|
.bottom-nav--hidden {
|
|
transform: translateY(100%);
|
|
opacity: 0;
|
|
pointer-events: none;
|
|
}
|
|
.nav-item {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 3px;
|
|
padding: 7px 2px 8px;
|
|
font-size: 10px;
|
|
color: var(--text-muted);
|
|
font-weight: 500;
|
|
letter-spacing: 0.01em;
|
|
position: relative;
|
|
transition: color 0.2s;
|
|
line-height: 1.2;
|
|
min-width: 0;
|
|
text-decoration: none;
|
|
}
|
|
|
|
.nav-label {
|
|
max-width: 100%;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
padding: 0 2px;
|
|
}
|
|
|
|
.nav-item.active {
|
|
color: var(--primary);
|
|
font-weight: 700;
|
|
}
|
|
|
|
.nav-item.active :deep(.nav-icon) {
|
|
color: var(--primary);
|
|
filter: drop-shadow(0 1px 2px rgba(0, 61, 107, 0.2));
|
|
}
|
|
|
|
.nav-item.active::after {
|
|
content: '';
|
|
position: absolute;
|
|
top: 0;
|
|
left: 18%;
|
|
right: 18%;
|
|
height: 2.5px;
|
|
background: var(--primary);
|
|
border-radius: 0 0 3px 3px;
|
|
}
|
|
</style> |