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>
|
</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
|
<div
|
||||||
v-for="(testimonial, index) in testimonials"
|
ref="trackRef"
|
||||||
:key="testimonial.id"
|
class="flex gap-6 md:gap-8"
|
||||||
:ref="el => setCardRef(el, index)"
|
:style="{ transform: `translateX(${offset}px)` }"
|
||||||
class="testimonial-card absolute flex-shrink-0 w-[320px] md:w-[400px] bg-white rounded-3xl p-8 md:p-10 shadow-xl"
|
@mouseenter="pauseAutoplay"
|
||||||
:style="getCardStyle(index)"
|
@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">
|
<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)
|
||||||
}
|
}
|
||||||
|
|
||||||
const stopAutoplay = () => {
|
animFrameId = requestAnimationFrame(animate)
|
||||||
if (autoplayInterval) {
|
|
||||||
clearInterval(autoplayInterval)
|
|
||||||
autoplayInterval = null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const pauseAutoplay = () => {
|
||||||
|
isPaused = true
|
||||||
|
}
|
||||||
|
|
||||||
|
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: {
|
||||||
})
|
|
||||||
|
|
||||||
// 入场动画(无 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,
|
trigger: rootRef.value,
|
||||||
start: 'top 75%',
|
start: 'top 75%',
|
||||||
toggleActions: 'play none none none',
|
toggleActions: 'play none none none',
|
||||||
animation: tl,
|
},
|
||||||
})
|
autoAlpha: 1,
|
||||||
|
y: 0,
|
||||||
|
duration: 0.8,
|
||||||
|
ease: 'power3.out',
|
||||||
|
}
|
||||||
|
)
|
||||||
}, 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>
|
||||||
|
|||||||
Reference in New Issue
Block a user