Files
thebet365/apps/player/src/components/desktop/DesktopBanner3DCarousel.vue

469 lines
10 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 { computed, onMounted, onUnmounted, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useI18n } from 'vue-i18n';
import type { BannerItem } from '../BannerCarousel.vue';
import defaultBannerImg from '../../assets/images/banner.webp';
const props = defineProps<{
banners: BannerItem[];
fallbackTo?: string;
}>();
const { t } = useI18n();
const router = useRouter();
const active = ref(0);
const timer = ref<ReturnType<typeof setInterval> | null>(null);
const FALLBACK_IMG = '/uploads/banners/welcome.svg';
const slideCount = computed(() => props.banners.length);
const canLoop = computed(() => slideCount.value > 1);
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 normalizeOffset(index: number) {
const len = slideCount.value;
if (!len) return 0;
let offset = index - active.value;
if (offset > len / 2) offset -= len;
if (offset < -len / 2) offset += len;
return offset;
}
/** Coverflow略重叠中间卡片置顶 */
function slideLayout(index: number) {
const offset = normalizeOffset(index);
const abs = Math.abs(offset);
if (abs > 1) {
return { hidden: true, offset, active: false };
}
const slotX = 50 + offset * 26;
const scale = offset === 0 ? 1 : 0.9;
const rotateY = offset === 0 ? 0 : offset < 0 ? 12 : -12;
const zIndex = offset === 0 ? 10 : 5;
return {
hidden: false,
offset,
active: offset === 0,
slotX,
scale,
rotateY,
zIndex,
};
}
function slideStyle(index: number) {
const layout = slideLayout(index);
if (layout.hidden) {
return {
left: '50%',
transform: 'translate(-50%, -50%) scale(0.7)',
opacity: '0',
zIndex: '0',
pointerEvents: 'none' as const,
};
}
return {
left: `${layout.slotX}%`,
transform: `translate(-50%, -50%) scale(${layout.scale}) rotateY(${layout.rotateY}deg)`,
opacity: '1',
zIndex: String(layout.zIndex),
pointerEvents: 'auto' as const,
};
}
function goTo(index: number) {
if (!slideCount.value) return;
active.value = (index + slideCount.value) % slideCount.value;
}
function next() {
goTo(active.value + 1);
}
function prev() {
goTo(active.value - 1);
}
function onBannerClick(banner: BannerItem, index: number) {
if (normalizeOffset(index) !== 0) {
goTo(index);
return;
}
if (banner.id) {
void router.push(`/announcements/${banner.id}`);
return;
}
if (props.fallbackTo) {
void router.push(props.fallbackTo);
}
}
function startAutoPlay() {
stopAutoPlay();
if (!canLoop.value) return;
timer.value = setInterval(next, 5000);
}
function stopAutoPlay() {
if (timer.value) {
clearInterval(timer.value);
timer.value = null;
}
}
watch(
() => props.banners.length,
() => {
active.value = 0;
startAutoPlay();
},
);
onMounted(startAutoPlay);
onUnmounted(stopAutoPlay);
</script>
<template>
<section
v-if="banners.length"
class="banner-coverflow"
@mouseenter="stopAutoPlay"
@mouseleave="startAutoPlay"
>
<div class="coverflow-stage">
<button
v-for="(banner, i) in banners"
:key="banner.id ?? i"
type="button"
class="coverflow-slide"
:class="{ 'is-active': slideLayout(i).active, 'is-side': Math.abs(slideLayout(i).offset) === 1 }"
:style="slideStyle(i)"
:aria-label="title(banner)"
@click="onBannerClick(banner, i)"
>
<div class="slide-card">
<img
v-if="imageUrl(banner)"
:src="imageUrl(banner)"
:alt="''"
class="slide-img"
:loading="i === 0 ? 'eager' : 'lazy'"
@error="onImgError"
/>
<div v-else class="slide-fallback" aria-hidden="true">
<svg class="fallback-icon" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
</div>
<div v-if="slideLayout(i).active" class="click-hint" aria-hidden="true">
<span class="click-ring" />
<svg class="click-icon" viewBox="0 0 24 24" aria-hidden="true">
<path
fill="currentColor"
d="M9 11.24V7.5a2.5 2.5 0 0 1 5 0v3.74c1.21-.81 2-2.18 2-3.74C16 5.01 13.99 3 11.5 3S7 5.01 7 7.5c0 1.56.79 2.93 2 3.74zm9.84 4.63-4.54-2.26a1.5 1.5 0 0 0-.54-.11H13v-6c0-.83-.67-1.5-1.5-1.5S10 6.67 10 7.5v10.74l-3.43-.72a1 1 0 0 0-.24-.03c-.31 0-.59.13-.79.33l-.79.8 4.94 4.94c.27.27.65.44 1.06.44h6.79c.75 0 1.33-.55 1.44-1.28l.75-5.27a1.5 1.5 0 0 0-.91-1.38z"
/>
</svg>
</div>
</div>
</button>
<button
v-if="canLoop"
type="button"
class="nav-btn prev"
:aria-label="t('home.banner_prev')"
@click.stop="prev"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M15.41 7.41 14 6l-6 6 6 6 1.41-1.41L10.83 12z"/></svg>
</button>
<button
v-if="canLoop"
type="button"
class="nav-btn next"
:aria-label="t('home.banner_next')"
@click.stop="next"
>
<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z"/></svg>
</button>
</div>
<div v-if="canLoop" class="coverflow-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>
</section>
</template>
<style scoped>
.banner-coverflow {
width: 100%;
padding: 4px 0 0;
}
.coverflow-stage {
position: relative;
height: clamp(260px, 28vw, 360px);
perspective: 1000px;
}
.coverflow-slide {
position: absolute;
top: 50%;
width: 42%;
max-width: 560px;
height: 90%;
padding: 0;
border: none;
background: transparent;
cursor: pointer;
transition:
left 0.55s cubic-bezier(0.25, 0.8, 0.25, 1),
transform 0.55s cubic-bezier(0.25, 0.8, 0.25, 1),
opacity 0.4s ease;
transform-style: preserve-3d;
will-change: left, transform;
}
.slide-card {
position: relative;
width: 100%;
height: 100%;
border-radius: 14px;
overflow: hidden;
background: linear-gradient(180deg, #141414 0%, #0a0a0a 100%);
box-shadow: 0 10px 32px rgba(0, 0, 0, 0.45);
transition: box-shadow 0.35s ease, filter 0.35s ease, transform 0.25s ease;
display: flex;
align-items: center;
justify-content: center;
}
.coverflow-slide.is-side .slide-card {
filter: brightness(0.72) saturate(0.9);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35);
}
.coverflow-slide.is-side:hover .slide-card {
filter: brightness(0.88) saturate(1);
}
.coverflow-slide.is-active .slide-card {
filter: none;
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.5),
0 0 28px rgba(0, 102, 204, 0.22),
0 0 56px rgba(0, 102, 204, 0.1);
animation: banner-breathe 3s ease-in-out infinite;
}
.coverflow-slide.is-active:hover .slide-card {
animation: none;
transform: scale(1.018);
}
@keyframes banner-breathe {
0%,
100% {
transform: scale(1);
box-shadow:
0 16px 40px rgba(0, 0, 0, 0.5),
0 0 22px rgba(0, 102, 204, 0.16),
0 0 48px rgba(0, 102, 204, 0.08);
}
50% {
transform: scale(1.008);
box-shadow:
0 18px 44px rgba(0, 0, 0, 0.52),
0 0 32px rgba(0, 102, 204, 0.26),
0 0 60px rgba(0, 102, 204, 0.12);
}
}
.slide-img {
width: 100%;
height: 100%;
object-fit: contain;
object-position: center;
display: block;
}
.slide-fallback {
width: 100%;
height: 100%;
min-height: 120px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, rgba(0, 102, 204, 0.12), #1a1a1a);
}
.fallback-icon {
width: 40px;
height: 40px;
color: rgba(0, 102, 204, 0.35);
}
.click-hint {
position: absolute;
right: 10px;
bottom: 10px;
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
pointer-events: none;
z-index: 2;
background: rgba(0, 0, 0, 0.35);
border-radius: 50%;
}
.click-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 1px solid rgba(0, 102, 204, 0.45);
animation: click-ring-pulse 2.8s ease-out infinite;
}
.click-icon {
position: relative;
width: 15px;
height: 15px;
color: rgba(0, 102, 204, 0.88);
animation: click-icon-bob 2.8s ease-in-out infinite;
}
@keyframes click-ring-pulse {
0% {
transform: scale(0.9);
opacity: 0.55;
}
70% {
transform: scale(1.35);
opacity: 0;
}
100% {
transform: scale(1.35);
opacity: 0;
}
}
@keyframes click-icon-bob {
0%,
100% {
transform: translateY(1px) scale(1);
opacity: 0.65;
}
50% {
transform: translateY(-1px) scale(1.06);
opacity: 1;
}
}
.coverflow-slide.is-active:hover .click-hint {
opacity: 0;
transition: opacity 0.2s ease;
}
@media (prefers-reduced-motion: reduce) {
.coverflow-slide.is-active .slide-card,
.click-ring,
.click-icon {
animation: none;
}
}
.nav-btn {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 36px;
height: 36px;
border-radius: 50%;
border: 1px solid rgba(0, 102, 204, 0.4);
background: rgba(10, 10, 10, 0.75);
color: var(--primary-light);
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
transition: background 0.2s, border-color 0.2s;
}
.nav-btn svg {
width: 20px;
height: 20px;
}
.nav-btn:hover {
background: rgba(0, 102, 204, 0.2);
border-color: var(--primary);
}
.nav-btn.prev {
left: 4px;
}
.nav-btn.next {
right: 4px;
}
.coverflow-dots {
display: flex;
justify-content: center;
gap: 8px;
margin-top: 14px;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.25);
border: none;
padding: 0;
cursor: pointer;
transition: all 0.25s ease;
}
.dot.active {
width: 22px;
border-radius: 4px;
background: var(--primary);
}
.dot:hover:not(.active) {
background: rgba(255, 255, 255, 0.45);
}
</style>