05f2e56ff3
- 滚动速度从 0.5px/帧 提升到 1.5px/帧 - 使用 mask-image 渐变边缘替代 overflow-hidden 卡片阴影不再被裁剪,边缘自然淡出 - 添加 py-4 上下留白,阴影完整显示 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
5.3 KiB
Vue
164 lines
5.3 KiB
Vue
<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 -mx-6 md:-mx-10 px-6 md:px-10 overflow-hidden" style="mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%); -webkit-mask-image: linear-gradient(to right, transparent 0%, black 5%, black 95%, transparent 100%);">
|
|
<div
|
|
ref="trackRef"
|
|
class="flex gap-6 md:gap-8 py-4"
|
|
: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">
|
|
<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.${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="item.avatarGradient">
|
|
<div class="w-full h-full flex items-center justify-center text-white text-xl md:text-2xl font-bold">
|
|
{{ item.initial }}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<h4 class="text-lg md:text-xl font-bold text-black">
|
|
{{ t(`home.testimonials.items.${item.id}.name`) }}
|
|
</h4>
|
|
<p class="text-sm md:text-base text-gray-500">
|
|
{{ t(`home.testimonials.items.${item.id}.role`) }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- 装饰引号 -->
|
|
<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>
|
|
</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 trackRef = ref<HTMLElement | null>(null)
|
|
let ctx: gsap.Context | null = null
|
|
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' },
|
|
]
|
|
|
|
// 复制一份用于无缝循环
|
|
const allTestimonials = computed(() => [...testimonials, ...testimonials])
|
|
|
|
// 自动滚动状态
|
|
const offset = ref(0)
|
|
const speed = 1.5 // 每帧移动像素
|
|
let isPaused = false
|
|
const cardWidth = 400 + 32 // 卡片宽度 + gap
|
|
|
|
const startAutoplay = () => {
|
|
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 pauseAutoplay = () => {
|
|
isPaused = true
|
|
}
|
|
|
|
const resumeAutoplay = () => {
|
|
isPaused = false
|
|
}
|
|
|
|
onMounted(async () => {
|
|
await nextTick()
|
|
if (!rootRef.value || !headerRef.value) return
|
|
|
|
ctx = gsap.context(() => {
|
|
// 标题入场动画
|
|
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()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
ctx?.revert()
|
|
if (animFrameId) cancelAnimationFrame(animFrameId)
|
|
})
|
|
</script>
|
|
|
|
<style scoped>
|
|
.testimonial-card {
|
|
position: relative;
|
|
will-change: transform;
|
|
}
|
|
</style>
|