refactor: 客户评价改为无缝自动滚动轮播

 重写为自动滚动:
- 无缝循环:复制一组卡片,滚到末尾自动重置
- requestAnimationFrame 驱动,流畅 60fps
- 鼠标悬停暂停,移开继续
- 移除导航点
- 移除模糊效果
- 移除拖拽交互
- 移除 3D 轮播效果

🎯 效果:
- 卡片从右向左匀速滚动
- 悬停时暂停,方便阅读
- 简洁干净,无多余装饰

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
陈春
2026-06-03 20:24:08 +08:00
parent a6c85c6634
commit 2ed3c5092d
+75 -187
View File
@@ -11,15 +11,20 @@
</p> </p>
</div> </div>
<!-- 轮播容器 --> <!-- 自动滚动轮播 -->
<div class="relative h-[500px] md:h-[600px] select-none" @mousedown.prevent="startDrag" @mousemove="onDrag" @mouseup="endDrag" @mouseleave="endDrag" @touchstart="startDrag" @touchmove="onDrag" @touchend="endDrag"> <div class="relative overflow-hidden">
<div ref="carouselRef" class="flex items-center h-full cursor-grab active:cursor-grabbing"> <div
ref="trackRef"
class="flex gap-6 md:gap-8"
:style="{ transform: `translateX(${offset}px)` }"
@mouseenter="pauseAutoplay"
@mouseleave="resumeAutoplay"
>
<!-- 复制两份实现无缝循环 -->
<div <div
v-for="(testimonial, index) in testimonials" v-for="(item, index) in allTestimonials"
:key="testimonial.id" :key="`card-${index}`"
:ref="el => setCardRef(el, index)" class="testimonial-card flex-shrink-0 w-[320px] md:w-[400px] bg-white rounded-3xl p-8 md:p-10 shadow-lg hover:shadow-xl transition-shadow duration-300"
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"> <div class="flex gap-1 mb-6">
@@ -28,43 +33,32 @@
<!-- 评价内容 --> <!-- 评价内容 -->
<p class="text-gray-700 text-base md:text-lg leading-relaxed mb-8 italic"> <p class="text-gray-700 text-base md:text-lg leading-relaxed mb-8 italic">
"{{ t(`home.testimonials.items.${testimonial.id}.content`) }}" "{{ t(`home.testimonials.items.${item.id}.content`) }}"
</p> </p>
<!-- 客户信息 --> <!-- 客户信息 -->
<div class="flex items-center gap-4"> <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-14 h-14 md:w-16 md:h-16 rounded-full overflow-hidden bg-gradient-to-br" :class="item.avatarGradient">
<div class="w-full h-full flex items-center justify-center text-white text-xl md:text-2xl font-bold"> <div class="w-full h-full flex items-center justify-center text-white text-xl md:text-2xl font-bold">
{{ testimonial.initial }} {{ item.initial }}
</div> </div>
</div> </div>
<div> <div>
<h4 class="text-lg md:text-xl font-bold text-black"> <h4 class="text-lg md:text-xl font-bold text-black">
{{ t(`home.testimonials.items.${testimonial.id}.name`) }} {{ t(`home.testimonials.items.${item.id}.name`) }}
</h4> </h4>
<p class="text-sm md:text-base text-gray-500"> <p class="text-sm md:text-base text-gray-500">
{{ t(`home.testimonials.items.${testimonial.id}.role`) }} {{ t(`home.testimonials.items.${item.id}.role`) }}
</p> </p>
</div> </div>
</div> </div>
<!-- 公司 logo装饰 --> <!-- 装饰引号 -->
<div class="absolute top-8 right-8 w-10 h-10 opacity-20"> <div class="absolute top-8 right-8 w-10 h-10 opacity-10">
<Icon name="ph:quotes-duotone" class="w-full h-full text-black" /> <Icon name="ph:quotes-duotone" class="w-full h-full text-black" />
</div> </div>
</div> </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>
</div> </div>
</section> </section>
@@ -82,142 +76,51 @@ const { t } = useI18n()
const rootRef = ref<HTMLElement | null>(null) const rootRef = ref<HTMLElement | null>(null)
const headerRef = ref<HTMLElement | null>(null) const headerRef = ref<HTMLElement | null>(null)
const carouselRef = ref<HTMLElement | null>(null) const trackRef = 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 ctx: gsap.Context | null = null
let autoplayInterval: ReturnType<typeof setInterval> | null = null let animFrameId: number | null = null
const setCardRef = (el: any, index: number) => {
if (el) cardElements.value[index] = el
}
const testimonials = [ const testimonials = [
{ { id: 'client1', initial: 'A', avatarGradient: 'from-blue-500 to-cyan-500' },
id: 'client1', { id: 'client2', initial: 'B', avatarGradient: 'from-purple-500 to-pink-500' },
initial: 'A', { id: 'client3', initial: 'C', avatarGradient: 'from-orange-500 to-red-500' },
avatarGradient: 'from-blue-500 to-cyan-500' { id: 'client4', initial: 'D', avatarGradient: 'from-green-500 to-emerald-500' },
}, { id: 'client5', initial: 'E', avatarGradient: 'from-indigo-500 to-purple-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 allTestimonials = computed(() => [...testimonials, ...testimonials])
const diff = index - currentIndex.value
const distance = 450 // 卡片间距
const offset = diff * distance
// 中心卡片 // 自动滚动状态
if (diff === 0) { const offset = ref(0)
return { const speed = 0.5 // 每帧移动像素
transform: `translateX(calc(50vw - 50%)) translateZ(0) scale(1)`, let isPaused = false
opacity: 1, const cardWidth = 400 + 32 // 卡片宽度 + gap
zIndex: 10,
filter: 'blur(0px)'
}
}
// 左右卡片
const scale = 1 - Math.abs(diff) * 0.12
const opacity = Math.max(0.5, 1 - Math.abs(diff) * 0.25)
return {
transform: `translateX(calc(50vw - 50% + ${offset}px)) scale(${scale})`,
opacity: opacity,
zIndex: 10 - Math.abs(diff),
}
}
// 拖拽功能(鼠标 + 触摸)
const startDrag = (e: MouseEvent | TouchEvent) => {
isDragging.value = true
startX.value = 'touches' in e ? e.touches[0].clientX : e.clientX
if (carouselRef.value) {
carouselRef.value.style.cursor = 'grabbing'
}
}
const onDrag = (e: MouseEvent | TouchEvent) => {
if (!isDragging.value) return
const clientX = 'touches' in e ? e.touches[0].clientX : e.clientX
currentX.value = clientX - startX.value
}
const endDrag = () => {
if (!isDragging.value) return
isDragging.value = false
if (carouselRef.value) {
carouselRef.value.style.cursor = 'grab'
}
// 降低阈值到 50px,更灵敏
if (Math.abs(currentX.value) > 50) {
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 = () => { const startAutoplay = () => {
autoplayInterval = setInterval(() => { const totalWidth = testimonials.length * cardWidth
const nextIndex = (currentIndex.value + 1) % testimonials.length
goToSlide(nextIndex) const animate = () => {
}, 5000) if (!isPaused) {
offset.value -= speed
// 当第一组卡片完全滚出时,重置位置实现无缝
if (Math.abs(offset.value) >= totalWidth) {
offset.value += totalWidth
}
}
animFrameId = requestAnimationFrame(animate)
}
animFrameId = requestAnimationFrame(animate)
} }
const stopAutoplay = () => { const pauseAutoplay = () => {
if (autoplayInterval) { isPaused = true
clearInterval(autoplayInterval) }
autoplayInterval = null
} const resumeAutoplay = () => {
isPaused = false
} }
onMounted(async () => { onMounted(async () => {
@@ -225,51 +128,36 @@ onMounted(async () => {
if (!rootRef.value || !headerRef.value) return if (!rootRef.value || !headerRef.value) return
ctx = gsap.context(() => { ctx = gsap.context(() => {
// 初始状态:标题隐藏,卡片居中缩小 // 标题入场动画
gsap.set(headerRef.value, { autoAlpha: 0, y: 50 }) gsap.fromTo(headerRef.value,
cardElements.value.forEach((card, index) => { { autoAlpha: 0, y: 50 },
if (!card) return {
gsap.set(card, { autoAlpha: 0, scale: 0.5 }) scrollTrigger: {
}) trigger: rootRef.value,
start: 'top 75%',
// 入场动画(无 pin):标题 + 卡片从中心扩散 toggleActions: 'play none none none',
const totalCards = testimonials.length },
const tl = gsap.timeline() autoAlpha: 1,
y: 0,
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, 0) duration: 0.8,
cardElements.value.forEach((card, index) => { ease: 'power3.out',
if (!card) return }
tl.to(card, { autoAlpha: 1, scale: 1, duration: 0.6, ease: 'back.out(1.5)' }, 0.3 + index * 0.08) )
})
// 初始化到第一张卡片
tl.call(() => { currentIndex.value = 0 }, [], 0.3)
ScrollTrigger.create({
trigger: rootRef.value,
start: 'top 75%',
toggleActions: 'play none none none',
animation: tl,
})
}, rootRef.value) }, rootRef.value)
// 启动自动播放,使用滚动控制 // 启动自动滚动
// startAutoplay() startAutoplay()
}) })
onUnmounted(() => { onUnmounted(() => {
ctx?.revert() ctx?.revert()
stopAutoplay() if (animFrameId) cancelAnimationFrame(animFrameId)
}) })
</script> </script>
<style scoped> <style scoped>
.testimonial-card { .testimonial-card {
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease; position: relative;
will-change: transform, opacity; will-change: transform;
cursor: grab;
}
.testimonial-card:active {
cursor: grabbing;
} }
</style> </style>