02323fd426
🐛 问题根源: 每个板块有两套独立的 ScrollTrigger 在同一个元素上: - 入场动画用独立的 scrollTrigger - pin 效果用 ScrollTrigger.create 两者互相冲突,导致空白间隙和动画跳变 ✅ 修复方案: 将所有板块的入场动画和 pin 滚动合并为一个统一时间线(timeline), 确保动画从 pin 开始到结束完全连续,无空白区 🔧 修复的 6 个板块: 1. HomeTestimonials - 标题入场 + 卡片逐张切换(淡入淡出) 2. HomeServices - 标题入场 + 卡片飞入归位 + 逐张聚焦 3. HomeAchievements - 逐个成就项展示(前一个淡出,后一个淡入) 4. HomeTechStack - 内容入场 + 标签散开 + 球体旋转 5. HomeProcess - 标题入场 + 线条绘制 + 步骤逐个出现 + 横向滚动 6. HomeUpdates - 标题入场 + 卡片分批出现 🎯 效果: - pin 期间动画完全连续,无空白间隙 - 入场和 pin 共享同一个 timeline - scrub: 0.5 让滚动更平滑 - 每个板块到下一个板块无缝过渡 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
286 lines
8.3 KiB
Vue
286 lines
8.3 KiB
Vue
<template>
|
||
<section ref="rootRef" class="relative bg-gradient-to-b from-gray-50 to-white min-h-screen flex flex-col items-center justify-center overflow-hidden py-20">
|
||
<div class="w-full max-w-[1400px] mx-auto px-6 md:px-10">
|
||
<!-- 标题 -->
|
||
<div ref="headerRef" class="text-center mb-16 md:mb-20">
|
||
<h2 class="text-5xl md:text-7xl font-bold tracking-tight text-black mb-6">
|
||
{{ t('home.testimonials.title') }}
|
||
</h2>
|
||
<p class="text-lg md:text-xl text-gray-600 max-w-2xl mx-auto">
|
||
{{ t('home.testimonials.subtitle') }}
|
||
</p>
|
||
</div>
|
||
|
||
<!-- 轮播容器 -->
|
||
<div class="relative h-[500px] md:h-[600px]" @mousedown="startDrag" @mousemove="onDrag" @mouseup="endDrag" @mouseleave="endDrag">
|
||
<div ref="carouselRef" class="flex items-center h-full cursor-grab active:cursor-grabbing">
|
||
<div
|
||
v-for="(testimonial, index) in testimonials"
|
||
:key="testimonial.id"
|
||
:ref="el => setCardRef(el, index)"
|
||
class="testimonial-card absolute flex-shrink-0 w-[320px] md:w-[400px] bg-white rounded-3xl p-8 md:p-10 shadow-xl"
|
||
:style="getCardStyle(index)"
|
||
>
|
||
<!-- 评分 -->
|
||
<div class="flex gap-1 mb-6">
|
||
<Icon v-for="i in 5" :key="i" name="ph:star-fill" class="w-5 h-5 text-yellow-400" />
|
||
</div>
|
||
|
||
<!-- 评价内容 -->
|
||
<p class="text-gray-700 text-base md:text-lg leading-relaxed mb-8 italic">
|
||
"{{ t(`home.testimonials.items.${testimonial.id}.content`) }}"
|
||
</p>
|
||
|
||
<!-- 客户信息 -->
|
||
<div class="flex items-center gap-4">
|
||
<div class="w-14 h-14 md:w-16 md:h-16 rounded-full overflow-hidden bg-gradient-to-br" :class="testimonial.avatarGradient">
|
||
<div class="w-full h-full flex items-center justify-center text-white text-xl md:text-2xl font-bold">
|
||
{{ testimonial.initial }}
|
||
</div>
|
||
</div>
|
||
<div>
|
||
<h4 class="text-lg md:text-xl font-bold text-black">
|
||
{{ t(`home.testimonials.items.${testimonial.id}.name`) }}
|
||
</h4>
|
||
<p class="text-sm md:text-base text-gray-500">
|
||
{{ t(`home.testimonials.items.${testimonial.id}.role`) }}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 公司 logo(装饰) -->
|
||
<div class="absolute top-8 right-8 w-10 h-10 opacity-20">
|
||
<Icon name="ph:quotes-duotone" class="w-full h-full text-black" />
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 导航点 -->
|
||
<div class="absolute bottom-0 left-1/2 -translate-x-1/2 flex gap-3">
|
||
<button
|
||
v-for="(_, index) in testimonials"
|
||
:key="index"
|
||
@click="goToSlide(index)"
|
||
class="w-3 h-3 rounded-full transition-all duration-300"
|
||
:class="currentIndex === index ? 'bg-black w-8' : 'bg-gray-300 hover:bg-gray-400'"
|
||
></button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, onUnmounted, nextTick, computed } from 'vue'
|
||
import { useI18n } from 'vue-i18n'
|
||
import gsap from 'gsap'
|
||
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||
|
||
gsap.registerPlugin(ScrollTrigger)
|
||
|
||
const { t } = useI18n()
|
||
|
||
const rootRef = ref<HTMLElement | null>(null)
|
||
const headerRef = ref<HTMLElement | null>(null)
|
||
const carouselRef = ref<HTMLElement | null>(null)
|
||
const cardElements = ref<(HTMLElement | null)[]>([])
|
||
const currentIndex = ref(2) // 从中间开始
|
||
const isDragging = ref(false)
|
||
const startX = ref(0)
|
||
const currentX = ref(0)
|
||
let ctx: gsap.Context | null = null
|
||
let autoplayInterval: ReturnType<typeof setInterval> | null = null
|
||
|
||
const setCardRef = (el: any, index: number) => {
|
||
if (el) cardElements.value[index] = el
|
||
}
|
||
|
||
const testimonials = [
|
||
{
|
||
id: 'client1',
|
||
initial: 'A',
|
||
avatarGradient: 'from-blue-500 to-cyan-500'
|
||
},
|
||
{
|
||
id: 'client2',
|
||
initial: 'B',
|
||
avatarGradient: 'from-purple-500 to-pink-500'
|
||
},
|
||
{
|
||
id: 'client3',
|
||
initial: 'C',
|
||
avatarGradient: 'from-orange-500 to-red-500'
|
||
},
|
||
{
|
||
id: 'client4',
|
||
initial: 'D',
|
||
avatarGradient: 'from-green-500 to-emerald-500'
|
||
},
|
||
{
|
||
id: 'client5',
|
||
initial: 'E',
|
||
avatarGradient: 'from-indigo-500 to-purple-500'
|
||
}
|
||
]
|
||
|
||
// 计算卡片样式(3D 轮播效果)
|
||
const getCardStyle = (index: number) => {
|
||
const diff = index - currentIndex.value
|
||
const distance = 450 // 卡片间距
|
||
const offset = diff * distance
|
||
|
||
// 中心卡片
|
||
if (diff === 0) {
|
||
return {
|
||
transform: `translateX(calc(50vw - 50%)) translateZ(0) scale(1)`,
|
||
opacity: 1,
|
||
zIndex: 10,
|
||
filter: 'blur(0px)'
|
||
}
|
||
}
|
||
|
||
// 左右卡片
|
||
const scale = 1 - Math.abs(diff) * 0.1
|
||
const opacity = Math.max(0.3, 1 - Math.abs(diff) * 0.3)
|
||
const blur = Math.abs(diff) * 3
|
||
|
||
return {
|
||
transform: `translateX(calc(50vw - 50% + ${offset}px)) translateZ(-${Math.abs(diff) * 100}px) scale(${scale})`,
|
||
opacity: opacity,
|
||
zIndex: 10 - Math.abs(diff),
|
||
filter: `blur(${blur}px)`
|
||
}
|
||
}
|
||
|
||
// 拖拽功能
|
||
const startDrag = (e: MouseEvent) => {
|
||
isDragging.value = true
|
||
startX.value = e.clientX
|
||
if (carouselRef.value) {
|
||
carouselRef.value.style.cursor = 'grabbing'
|
||
}
|
||
}
|
||
|
||
const onDrag = (e: MouseEvent) => {
|
||
if (!isDragging.value) return
|
||
currentX.value = e.clientX - startX.value
|
||
}
|
||
|
||
const endDrag = () => {
|
||
if (!isDragging.value) return
|
||
isDragging.value = false
|
||
|
||
if (carouselRef.value) {
|
||
carouselRef.value.style.cursor = 'grab'
|
||
}
|
||
|
||
// 根据拖拽距离决定是否切换
|
||
if (Math.abs(currentX.value) > 100) {
|
||
if (currentX.value > 0) {
|
||
goToSlide(Math.max(0, currentIndex.value - 1))
|
||
} else {
|
||
goToSlide(Math.min(testimonials.length - 1, currentIndex.value + 1))
|
||
}
|
||
}
|
||
|
||
currentX.value = 0
|
||
startX.value = 0
|
||
}
|
||
|
||
// 切换到指定幻灯片
|
||
const goToSlide = (index: number) => {
|
||
currentIndex.value = index
|
||
updateCards()
|
||
}
|
||
|
||
// 更新卡片位置
|
||
const updateCards = () => {
|
||
cardElements.value.forEach((card, index) => {
|
||
if (!card) return
|
||
const style = getCardStyle(index)
|
||
gsap.to(card, {
|
||
...style,
|
||
duration: 0.8,
|
||
ease: 'power2.out'
|
||
})
|
||
})
|
||
}
|
||
|
||
// 自动播放
|
||
const startAutoplay = () => {
|
||
autoplayInterval = setInterval(() => {
|
||
const nextIndex = (currentIndex.value + 1) % testimonials.length
|
||
goToSlide(nextIndex)
|
||
}, 5000)
|
||
}
|
||
|
||
const stopAutoplay = () => {
|
||
if (autoplayInterval) {
|
||
clearInterval(autoplayInterval)
|
||
autoplayInterval = null
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await nextTick()
|
||
if (!rootRef.value || !headerRef.value) return
|
||
|
||
ctx = gsap.context(() => {
|
||
// 初始状态:标题隐藏,卡片居中缩小
|
||
gsap.set(headerRef.value, { autoAlpha: 0, y: 50 })
|
||
cardElements.value.forEach((card, index) => {
|
||
if (!card) return
|
||
gsap.set(card, { autoAlpha: 0, scale: 0.5 })
|
||
})
|
||
|
||
// 统一时间线:入场 + pin 滚动切换
|
||
const totalCards = testimonials.length
|
||
const tl = gsap.timeline()
|
||
|
||
// 第一屏:标题入场 + 第一张卡片出现
|
||
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, 0)
|
||
tl.to(cardElements.value[0], { autoAlpha: 1, scale: 1, duration: 0.8, ease: 'back.out(1.5)' }, 0.3)
|
||
tl.call(() => { currentIndex.value = 0 }, [], 0.3)
|
||
|
||
// 后续屏:滚动切换卡片,每屏切一张
|
||
for (let i = 1; i < totalCards; i++) {
|
||
const pos = i
|
||
// 当前卡片缩小淡出
|
||
tl.to(cardElements.value[i - 1], {
|
||
autoAlpha: 0, scale: 0.5, duration: 0.5, ease: 'power2.in'
|
||
}, pos)
|
||
// 新卡片放大淡入
|
||
tl.to(cardElements.value[i], {
|
||
autoAlpha: 1, scale: 1, duration: 0.6, ease: 'back.out(1.5)'
|
||
}, pos + 0.3)
|
||
tl.call(() => { currentIndex.value = i }, [], pos + 0.3)
|
||
}
|
||
|
||
ScrollTrigger.create({
|
||
trigger: rootRef.value,
|
||
start: 'top top',
|
||
end: () => `+=${totalCards * window.innerHeight}`,
|
||
pin: true,
|
||
scrub: 0.5,
|
||
anticipatePin: 1,
|
||
animation: tl,
|
||
})
|
||
}, rootRef.value)
|
||
|
||
// 不启动自动播放,使用滚动控制
|
||
// startAutoplay()
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
ctx?.revert()
|
||
stopAutoplay()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.testimonial-card {
|
||
transition: transform 0.8s ease, opacity 0.8s ease, filter 0.8s ease;
|
||
will-change: transform, opacity, filter;
|
||
}
|
||
</style>
|