Files
thebet365/apps/player/src/components/BannerCarousel.vue

254 lines
5.4 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
import defaultBannerImg from '../assets/images/banner.webp';
export interface BannerItem {
id?: string;
linkType?: string | null;
linkTarget?: string | null;
translation?: { title?: string; body?: string; imageUrl?: string };
}
const props = defineProps<{
banners: BannerItem[];
/** 未配置跳转链接时的默认路由 */
fallbackTo?: string;
}>();
const router = useRouter();
const active = ref(0);
const timer = ref<ReturnType<typeof setInterval> | null>(null);
const touchStartX = ref(0);
const touchDeltaX = ref(0);
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
function imageUrl(banner: BannerItem) {
return banner.translation?.imageUrl || defaultBannerImg || FALLBACK_IMG;
}
function onImgError(e: Event) {
const img = e.target as HTMLImageElement;
if (img.dataset.fallbackApplied) return;
img.dataset.fallbackApplied = '1';
img.src = defaultBannerImg || FALLBACK_IMG;
}
function title(banner: BannerItem) {
return banner.translation?.title || t('home.banner_fallback');
}
function goTo(index: number) {
if (!props.banners.length) return;
active.value = (index + props.banners.length) % props.banners.length;
}
function next() {
goTo(active.value + 1);
}
function prev() {
goTo(active.value - 1);
}
function onBannerClick(banner: BannerItem) {
if (banner.id) {
void router.push(`/announcements/${banner.id}`);
return;
}
if (props.fallbackTo) {
void router.push(props.fallbackTo);
}
}
function startAutoPlay() {
stopAutoPlay();
if (props.banners.length <= 1) return;
timer.value = setInterval(next, 4500);
}
function stopAutoPlay() {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
}
function onTouchStart(e: TouchEvent) {
touchStartX.value = e.touches[0].clientX;
touchDeltaX.value = 0;
stopAutoPlay();
}
function onTouchMove(e: TouchEvent) {
touchDeltaX.value = e.touches[0].clientX - touchStartX.value;
}
function onTouchEnd() {
if (touchDeltaX.value > 50) prev();
else if (touchDeltaX.value < -50) next();
touchDeltaX.value = 0;
startAutoPlay();
}
watch(
() => props.banners.length,
() => {
active.value = 0;
startAutoPlay();
},
);
onMounted(startAutoPlay);
onUnmounted(stopAutoPlay);
</script>
<template>
<div
v-if="banners.length"
class="carousel"
@mouseenter="stopAutoPlay"
@mouseleave="startAutoPlay"
@touchstart.passive="onTouchStart"
@touchmove.passive="onTouchMove"
@touchend="onTouchEnd"
>
<div class="media">
<div class="viewport">
<div class="track" :style="{ transform: `translateX(-${active * 100}%)` }">
<div
v-for="(banner, i) in banners"
:key="banner.id ?? i"
class="slide"
@click="onBannerClick(banner)"
>
<img
v-if="imageUrl(banner)"
:src="imageUrl(banner)"
:alt="title(banner)"
class="slide-img"
:loading="i === 0 ? 'eager' : 'lazy'"
@error="onImgError"
/>
<div v-else class="slide-fallback">{{ title(banner) }}</div>
</div>
</div>
</div>
<button v-if="banners.length > 1" class="nav prev" type="button" :aria-label="t('home.banner_prev')" @click.stop="prev"></button>
<button v-if="banners.length > 1" class="nav next" type="button" :aria-label="t('home.banner_next')" @click.stop="next"></button>
<div v-if="banners.length > 1" class="dots">
<button
v-for="(_, i) in banners"
:key="i"
type="button"
class="dot"
:class="{ active: i === active }"
:aria-label="t('home.banner_slide', { n: i + 1 })"
@click.stop="goTo(i)"
/>
</div>
</div>
</div>
</template>
<style scoped>
.carousel {
position: relative;
margin: 0 -16px 14px;
overflow: hidden;
}
.media {
position: relative;
overflow: hidden;
}
.viewport {
overflow: hidden;
width: 100%;
}
.track {
display: flex;
transition: transform 0.45s cubic-bezier(0.4, 0, 0.2, 1);
}
.slide {
flex: 0 0 100%;
min-width: 100%;
cursor: pointer;
}
.slide-img {
width: 100%;
height: 180px;
object-fit: cover;
display: block;
}
.slide-fallback {
height: 180px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(212, 175, 55, 0.25), var(--secondary));
font-size: 22px;
font-weight: 800;
color: var(--primary-light);
}
.media .nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.55);
color: var(--primary-light);
border: 1px solid var(--border);
font-size: 22px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 2;
}
.nav.prev { left: 8px; }
.nav.next { right: 8px; }
.media .dots {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
display: flex;
justify-content: center;
gap: 8px;
z-index: 2;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.35);
border: 2px solid transparent;
padding: 0;
}
.dot.active {
width: 22px;
border-radius: 6px;
background: var(--gradient-gold);
border-color: var(--primary-light);
box-shadow: none;
}
</style>