Files
arcticnewone/app/components/home/HomeTestimonials.vue
T
陈春 e94f7d92e9 fix: 修复所有板块 pin 滚动空白间隙 + 服务卡片适配屏幕
🐛 问题1: 滚动到下一个内容时有空白区
根因: 时间线动画之间有间隙(动画 duration < 步骤间距)
修复: 所有板块改用 cursor 模式,每个步骤精确衔接,无空白

🐛 问题2: 服务卡片太大,一屏显示不全
根因: 卡片高度固定 h-[380px],不适配小屏幕
修复: 改用 clamp(260px, 38vh, 400px) 自适应屏幕高度

🔧 修复的 6 个板块:
1. HomeTestimonials - 淡入淡出切换无间隙
2. HomeServices - 卡片聚焦无间隙 + 自适应高度
3. HomeAchievements - 数字展示无间隙
4. HomeTechStack - 球体旋转无间隙
5. HomeProcess - 步骤展示 + 横向滚动无间隙
6. HomeUpdates - 卡片分批出现无间隙

📐 cursor 模式:
每一步动画 duration 正好等于步骤间距,
动画结束 = 下一步开始,完美衔接

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 20:03:36 +08:00

288 lines
8.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.
<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()
let cursor = 0
// 第一屏:标题入场 + 第一张卡片出现
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, cursor)
tl.to(cardElements.value[0], { autoAlpha: 1, scale: 1, duration: 0.8, ease: 'back.out(1.5)' }, cursor + 0.2)
tl.call(() => { currentIndex.value = 0 }, [], cursor + 0.2)
cursor += 1
// 后续屏:滚动切换卡片,每屏切一张
for (let i = 1; i < totalCards; i++) {
// 当前卡片缩小淡出
tl.to(cardElements.value[i - 1], {
autoAlpha: 0, scale: 0.5, duration: 0.5, ease: 'power2.in'
}, cursor)
// 新卡片放大淡入(紧接淡出开始,略有重叠)
tl.to(cardElements.value[i], {
autoAlpha: 1, scale: 1, duration: 0.6, ease: 'back.out(1.5)'
}, cursor + 0.2)
tl.call(() => { currentIndex.value = i }, [], cursor + 0.2)
cursor += 1
}
ScrollTrigger.create({
trigger: rootRef.value,
start: 'top top',
end: () => `+=${cursor * 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>