254 lines
5.4 KiB
Vue
254 lines
5.4 KiB
Vue
<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>
|