feat(player): 新增长页面一键回到顶部按钮
- 在 MainLayout 主滚动区挂载浮动回顶按钮,投注列表与赛事详情等长页通用 - 滚动超过阈值后显示,支持平滑回顶与底部导航避让 - 补充中/英/马来语文案 common.back_to_top Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
79
apps/player/src/components/BackToTopButton.vue
Normal file
79
apps/player/src/components/BackToTopButton.vue
Normal 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>
|
||||||
38
apps/player/src/composables/useBackToTop.ts
Normal file
38
apps/player/src/composables/useBackToTop.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ export default {
|
|||||||
no_more: 'No more',
|
no_more: 'No more',
|
||||||
load_failed: 'Failed to load',
|
load_failed: 'Failed to load',
|
||||||
retry: 'Retry',
|
retry: 'Retry',
|
||||||
|
back_to_top: 'Back to top',
|
||||||
},
|
},
|
||||||
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
|
nav: { home: 'Home', bet: 'Bet', bet_history: 'History', wallet: 'Wallet', profile: 'Profile' },
|
||||||
home: {
|
home: {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default {
|
|||||||
no_more: 'Tiada lagi',
|
no_more: 'Tiada lagi',
|
||||||
load_failed: 'Gagal dimuat',
|
load_failed: 'Gagal dimuat',
|
||||||
retry: 'Cuba lagi',
|
retry: 'Cuba lagi',
|
||||||
|
back_to_top: 'Kembali ke atas',
|
||||||
},
|
},
|
||||||
nav: {
|
nav: {
|
||||||
home: 'Laman Utama',
|
home: 'Laman Utama',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export default {
|
|||||||
no_more: '没有更多了',
|
no_more: '没有更多了',
|
||||||
load_failed: '加载失败',
|
load_failed: '加载失败',
|
||||||
retry: '重试',
|
retry: '重试',
|
||||||
|
back_to_top: '回到顶部',
|
||||||
},
|
},
|
||||||
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
|
nav: { home: '主页', bet: '投注', bet_history: '历史投注', wallet: '账单', profile: '我的' },
|
||||||
home: {
|
home: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import LocaleSwitcher from '../components/LocaleSwitcher.vue';
|
|||||||
import { useAppLocale } from '../composables/useAppLocale';
|
import { useAppLocale } from '../composables/useAppLocale';
|
||||||
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
|
import AnnouncementMarquee from '../components/AnnouncementMarquee.vue';
|
||||||
import BottomNavIcon from '../components/BottomNavIcon.vue';
|
import BottomNavIcon from '../components/BottomNavIcon.vue';
|
||||||
|
import BackToTopButton from '../components/BackToTopButton.vue';
|
||||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
import { computed, defineAsyncComponent, onMounted, ref, watch } from 'vue';
|
||||||
|
|
||||||
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
|
const BetSlipDrawer = defineAsyncComponent(() => import('../components/BetSlipDrawer.vue'));
|
||||||
@@ -178,6 +179,8 @@ watch(
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<BackToTopButton :scroll-el="mainRef" :above-nav="showBottomNav" />
|
||||||
|
|
||||||
<BetSlipDrawer v-model="slip.drawerOpen" />
|
<BetSlipDrawer v-model="slip.drawerOpen" />
|
||||||
<CustomerServiceModal v-model="customerServiceOpen" />
|
<CustomerServiceModal v-model="customerServiceOpen" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user