feat(player): 新增长页面一键回到顶部按钮

- 在 MainLayout 主滚动区挂载浮动回顶按钮,投注列表与赛事详情等长页通用

- 滚动超过阈值后显示,支持平滑回顶与底部导航避让

- 补充中/英/马来语文案 common.back_to_top

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-16 10:07:09 +08:00
parent 2a8b8415e7
commit 06d85953ba
6 changed files with 123 additions and 0 deletions

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import { useBackToTop } from '../composables/useBackToTop';
const props = defineProps<{
scrollEl: HTMLElement | null;
aboveNav?: boolean;
}>();
const { t } = useI18n();
const { visible, scrollToTop } = useBackToTop(toRef(props, 'scrollEl'));
</script>
<template>
<Transition name="back-top-fade">
<button
v-if="visible"
type="button"
class="back-top-btn"
:class="{ 'above-nav': aboveNav }"
:aria-label="t('common.back_to_top')"
@click="scrollToTop"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path
d="M12 5.5 6 11.5l1.4 1.4 3.6-3.6V18h2V9.3l3.6 3.6 1.4-1.4-6-6Z"
fill="currentColor"
/>
</svg>
</button>
</Transition>
</template>
<style scoped>
.back-top-btn {
position: fixed;
right: 16px;
bottom: calc(12px + env(safe-area-inset-bottom, 0px));
z-index: 120;
display: flex;
align-items: center;
justify-content: center;
width: 44px;
height: 44px;
border-radius: 50%;
border: 1px solid var(--border-gold-soft, rgba(200, 168, 78, 0.35));
background: rgba(17, 17, 17, 0.92);
color: var(--primary-light, #c8a84e);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45);
cursor: pointer;
transition: background 0.2s, transform 0.2s;
}
.back-top-btn.above-nav {
bottom: calc(68px + env(safe-area-inset-bottom, 0px));
}
.back-top-btn:active {
background: rgba(200, 168, 78, 0.16);
transform: scale(0.96);
}
.back-top-btn svg {
width: 22px;
height: 22px;
}
.back-top-fade-enter-active,
.back-top-fade-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.back-top-fade-enter-from,
.back-top-fade-leave-to {
opacity: 0;
transform: translateY(8px);
}
</style>

View File

@@ -0,0 +1,38 @@
import { onMounted, onUnmounted, ref, watch, type Ref } from 'vue';
export function useBackToTop(
scrollEl: Ref<HTMLElement | null>,
options?: { threshold?: number },
) {
const visible = ref(false);
const threshold = options?.threshold ?? 320;
function onScroll() {
const el = scrollEl.value;
visible.value = !!el && el.scrollTop > threshold;
}
function scrollToTop() {
scrollEl.value?.scrollTo({ top: 0, behavior: 'smooth' });
}
function bind(el: HTMLElement | null) {
el?.addEventListener('scroll', onScroll, { passive: true });
onScroll();
}
function unbind(el: HTMLElement | null) {
el?.removeEventListener('scroll', onScroll);
}
onMounted(() => bind(scrollEl.value));
onUnmounted(() => unbind(scrollEl.value));
watch(scrollEl, (el, prev) => {
unbind(prev);
bind(el);
});
return { visible, scrollToTop, onScroll };
}

View File

@@ -7,6 +7,7 @@ export default {
no_more: 'No more',
load_failed: 'Failed to load',
retry: 'Retry',
back_to_top: 'Back to top',
},
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
home: {

View File

@@ -7,6 +7,7 @@ export default {
no_more: 'Tiada lagi',
load_failed: 'Gagal dimuat',
retry: 'Cuba lagi',
back_to_top: 'Kembali ke atas',
},
nav: {
home: 'Laman Utama',

View File

@@ -7,6 +7,7 @@ export default {
no_more: '没有更多了',
load_failed: '加载失败',
retry: '重试',
back_to_top: '回到顶部',
},
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
home: {

View File

@@ -9,6 +9,7 @@ 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'));
@@ -178,6 +179,8 @@ watch(
</RouterLink>
</nav>
<BackToTopButton :scroll-el="mainRef" :above-nav="showBottomNav" />
<BetSlipDrawer v-model="slip.drawerOpen" />
<CustomerServiceModal v-model="customerServiceOpen" />
</div>