refactor: 客户评价改为无缝自动滚动轮播
✨ 重写为自动滚动: - 无缝循环:复制一组卡片,滚到末尾自动重置 - requestAnimationFrame 驱动,流畅 60fps - 鼠标悬停暂停,移开继续 - 移除导航点 - 移除模糊效果 - 移除拖拽交互 - 移除 3D 轮播效果 🎯 效果: - 卡片从右向左匀速滚动 - 悬停时暂停,方便阅读 - 简洁干净,无多余装饰 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
ref="trackRef"
|
||||
class="flex gap-6 md:gap-8"
|
||||
:style="{ transform: `translateX(${offset}px)` }"
|
||||
@mouseenter="pauseAutoplay"
|
||||
@mouseleave="resumeAutoplay"
|
||||
>
|
||||
<!-- 复制两份实现无缝循环 -->
|
||||
<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)"
|
||||
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)
|
||||
}
|
||||
|
||||
animFrameId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
const stopAutoplay = () => {
|
||||
if (autoplayInterval) {
|
||||
clearInterval(autoplayInterval)
|
||||
autoplayInterval = null
|
||||
}
|
||||
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({
|
||||
trigger: rootRef.value,
|
||||
start: 'top 75%',
|
||||
toggleActions: 'play none none none',
|
||||
animation: tl,
|
||||
})
|
||||
// 标题入场动画
|
||||
gsap.fromTo(headerRef.value,
|
||||
{ autoAlpha: 0, y: 50 },
|
||||
{
|
||||
scrollTrigger: {
|
||||
trigger: rootRef.value,
|
||||
start: 'top 75%',
|
||||
toggleActions: 'play none none none',
|
||||
},
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user