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
+71 -183
View File
@@ -11,15 +11,20 @@
</p>
</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 ref="carouselRef" class="flex items-center h-full cursor-grab active:cursor-grabbing">
<!-- 自动滚动轮播 -->
<div class="relative overflow-hidden">
<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)"
ref="trackRef"
class="flex gap-6 md:gap-8"
:style="{ transform: `translateX(${offset}px)` }"
@mouseenter="pauseAutoplay"
@mouseleave="resumeAutoplay"
>
<!-- 复制两份实现无缝循环 -->
<div
v-for="(item, index) in allTestimonials"
:key="`card-${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"
>
<!-- 评分 -->
<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">
"{{ t(`home.testimonials.items.${testimonial.id}.content`) }}"
"{{ t(`home.testimonials.items.${item.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-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">
{{ testimonial.initial }}
{{ item.initial }}
</div>
</div>
<div>
<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>
<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>
</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" />
</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>
@@ -82,142 +76,51 @@ 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)
const trackRef = ref<HTMLElement | null>(null)
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
}
let animFrameId: number | null = null
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'
}
{ 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
// 复制一份用于无缝循环
const allTestimonials = computed(() => [...testimonials, ...testimonials])
// 中心卡片
if (diff === 0) {
return {
transform: `translateX(calc(50vw - 50%)) translateZ(0) scale(1)`,
opacity: 1,
zIndex: 10,
filter: 'blur(0px)'
}
}
// 自动滚动状态
const offset = ref(0)
const speed = 0.5 // 每帧移动像素
let isPaused = false
const cardWidth = 400 + 32 // 卡片宽度 + gap
// 左右卡片
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 = () => {
autoplayInterval = setInterval(() => {
const nextIndex = (currentIndex.value + 1) % testimonials.length
goToSlide(nextIndex)
}, 5000)
const totalWidth = testimonials.length * cardWidth
const animate = () => {
if (!isPaused) {
offset.value -= speed
// 当第一组卡片完全滚出时,重置位置实现无缝
if (Math.abs(offset.value) >= totalWidth) {
offset.value += totalWidth
}
}
animFrameId = requestAnimationFrame(animate)
}
const stopAutoplay = () => {
if (autoplayInterval) {
clearInterval(autoplayInterval)
autoplayInterval = null
animFrameId = requestAnimationFrame(animate)
}
const pauseAutoplay = () => {
isPaused = true
}
const resumeAutoplay = () => {
isPaused = false
}
onMounted(async () => {
@@ -225,51 +128,36 @@ onMounted(async () => {
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)
cardElements.value.forEach((card, index) => {
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({
// 标题入场动画
gsap.fromTo(headerRef.value,
{ autoAlpha: 0, y: 50 },
{
scrollTrigger: {
trigger: rootRef.value,
start: 'top 75%',
toggleActions: 'play none none none',
animation: tl,
})
},
autoAlpha: 1,
y: 0,
duration: 0.8,
ease: 'power3.out',
}
)
}, rootRef.value)
// 启动自动播放,使用滚动控制
// startAutoplay()
// 启动自动滚动
startAutoplay()
})
onUnmounted(() => {
ctx?.revert()
stopAutoplay()
if (animFrameId) cancelAnimationFrame(animFrameId)
})
</script>
<style scoped>
.testimonial-card {
transition: transform 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94), opacity 0.5s ease;
will-change: transform, opacity;
cursor: grab;
}
.testimonial-card:active {
cursor: grabbing;
position: relative;
will-change: transform;
}
</style>