Files
thebet365/apps/player/src/layouts/MainLayout.vue

414 lines
12 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 AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
import BottomNavIcon from '../components/BottomNavIcon.vue';
import BackToTopButton from '../components/BackToTopButton.vue';
import { computed, defineAsyncComponent, onMounted, ref, watch } from '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 { 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 === '/profile'
) return true;
// 邮箱关闭时客服页保留底部导航,避免用户无法离开
if (!inboxEnabled.value && p === '/messages') return true;
return false;
});
const { announcements, announcementItems, load: loadPlayerHome } = usePlayerHome();
const primaryAnnouncementId = computed(() => announcementItems.value[0]?.id ?? '');
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>();
watch(locale, (next, prev) => {
if (prev && next !== prev) void loadPlayerHome(true);
});
watch(
() => route.fullPath,
(_path, oldPath) => {
const el = mainRef.value;
if (!el) return;
if (oldPath) tabScrollTops.set(oldPath, el.scrollTop);
el.scrollTop = tabScrollTops.get(route.fullPath) ?? 0;
},
);
const balanceRefreshPaths = ['/profile', '/wallet', '/bets'];
onMounted(() => {
if (auth.user?.locale) void initFromUser(auth.user.locale);
bindProfileVisibilityRefresh();
});
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>
<div 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="#94A3B8" 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="#FFB980" 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="#FFDDD2" letter-spacing="0.01em">365</text>
</svg>
<div class="header-actions">
<RouterLink
:to="hubRoute"
class="hub-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" 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' }">
<BottomNavIcon name="wallet" />
<span class="nav-label">{{ t('nav.wallet') }}</span>
</RouterLink>
<RouterLink to="/profile" class="nav-item" :class="{ active: route.path.startsWith('/profile') }">
<BottomNavIcon name="profile" />
<span class="nav-label">{{ t('nav.profile') }}</span>
</RouterLink>
</nav>
<BackToTopButton :scroll-el="mainRef" :above-nav="showBottomNav" />
<BetSlipDrawer v-model="slip.drawerOpen" />
</div>
</template>
<style scoped>
.layout {
display: flex;
flex-direction: column;
height: 100%;
min-height: 100dvh;
overflow: hidden;
background: var(--bg-body);
}
.header {
flex-shrink: 0;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 12px;
height: 48px;
background: var(--bg-glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-bottom: 1px solid var(--border);
z-index: 110;
}
.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: var(--border);
flex-shrink: 0;
margin: 0 2px;
}
.hub-btn {
position: relative;
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: var(--bg-card-elevated);
color: var(--primary-light);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
flex-shrink: 0;
text-decoration: none;
}
.hub-btn:active {
background: var(--bg-hover);
border-color: var(--border-active);
}
.hub-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.hub-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: var(--danger, #EF4444);
color: #fff;
font-size: 10px;
font-weight: 800;
line-height: 16px;
text-align: center;
box-shadow: 0 0 0 2px var(--bg-card-elevated);
}
.login-btn {
padding: 6px 18px;
border-radius: var(--radius-sm);
border: none;
background: var(--gradient-primary);
color: #FFFFFF;
font-size: 13px;
font-weight: 700;
cursor: pointer;
transition: opacity 0.15s, transform 0.15s;
box-shadow: var(--shadow-glow);
}
.login-btn:active {
opacity: 0.9;
transform: scale(0.98);
}
.announce-strip {
flex-shrink: 0;
z-index: 105;
background: var(--bg-card);
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: 16px;
}
.main.main--detail {
padding-top: 0;
}
.bottom-nav {
display: flex;
background: var(--bg-glass);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border-top: 1px solid var(--border);
z-index: 100;
padding-bottom: env(safe-area-inset-bottom, 0);
}
.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-light);
font-weight: 700;
text-shadow: 0 0 8px rgba(255, 185, 128, 0.4);
}
.nav-item.active :deep(.nav-icon) {
color: var(--primary-light);
filter: drop-shadow(0 0 6px rgba(255, 185, 128, 0.5));
}
.nav-item.active::after {
content: '';
position: absolute;
top: 0;
left: 18%;
right: 18%;
height: 2.5px;
background: var(--gradient-primary);
border-radius: 0 0 3px 3px;
}
</style>