Files
arcticnewone/app/components/home/HomeTestimonials.vue
T
陈春 cd87644d41 feat: 扩展首页内容,新增7个高级动画板块
 新增首页板块:
- HomeServices: 服务能力展示(3D卡片翻转 + 视差移动)
- HomeAchievements: 数字成就展示(数字滚动 + 粒子背景)
- HomeTestimonials: 客户评价轮播(3D轮播 + 拖拽交互)
- HomeTechStack: 技术栈展示(3D球体 + 鼠标控制旋转)
- HomeProcess: 创意流程时间线(横向滚动 + SVG线条动画)
- HomeUpdates: 最新动态网格(Bento布局 + 悬浮卡片)
- HomeCTA: 全屏CTA区域(文字分裂动画 + 星空粒子)

🎨 设计亮点:
- 每个板块独特的动画效果(翻转、滚动、拖拽、旋转、绘制)
- 避免传统布局,采用创意设计
- 丰富的交互效果(hover、拖拽、视差)
- Canvas粒子动画和SVG路径动画
- 响应式设计,完美适配移动端

🌍 国际化:
- 完整的中英文内容
- 涵盖所有新板块的文案

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:14:53 +08:00

288 lines
7.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<section ref="rootRef" class="relative bg-gradient-to-b from-gray-50 to-white py-32 md:py-40 overflow-hidden">
<div class="w-page-container max-w-[1400px] mx-auto px-6 md:px-10">
<!-- 标题 -->
<div ref="headerRef" class="text-center mb-20" style="opacity: 0;">
<h2 class="text-4xl md:text-6xl 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 h-[500px] md:h-[600px]" @mousedown="startDrag" @mousemove="onDrag" @mouseup="endDrag" @mouseleave="endDrag">
<div ref="carouselRef" class="flex items-center h-full cursor-grab active:cursor-grabbing">
<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)"
>
<!-- 评分 -->
<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.${testimonial.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-full h-full flex items-center justify-center text-white text-xl md:text-2xl font-bold">
{{ testimonial.initial }}
</div>
</div>
<div>
<h4 class="text-lg md:text-xl font-bold text-black">
{{ t(`home.testimonials.items.${testimonial.id}.name`) }}
</h4>
<p class="text-sm md:text-base text-gray-500">
{{ t(`home.testimonials.items.${testimonial.id}.role`) }}
</p>
</div>
</div>
<!-- 公司 logo装饰 -->
<div class="absolute top-8 right-8 w-10 h-10 opacity-20">
<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>
</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 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)
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
}
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'
}
]
// 计算卡片样式(3D 轮播效果)
const getCardStyle = (index: number) => {
const diff = index - currentIndex.value
const distance = 450 // 卡片间距
const offset = diff * distance
// 中心卡片
if (diff === 0) {
return {
transform: `translateX(calc(50vw - 50%)) translateZ(0) scale(1)`,
opacity: 1,
zIndex: 10,
filter: 'blur(0px)'
}
}
// 左右卡片
const scale = 1 - Math.abs(diff) * 0.1
const opacity = Math.max(0.3, 1 - Math.abs(diff) * 0.3)
const blur = Math.abs(diff) * 3
return {
transform: `translateX(calc(50vw - 50% + ${offset}px)) translateZ(-${Math.abs(diff) * 100}px) scale(${scale})`,
opacity: opacity,
zIndex: 10 - Math.abs(diff),
filter: `blur(${blur}px)`
}
}
// 拖拽功能
const startDrag = (e: MouseEvent) => {
isDragging.value = true
startX.value = e.clientX
if (carouselRef.value) {
carouselRef.value.style.cursor = 'grabbing'
}
}
const onDrag = (e: MouseEvent) => {
if (!isDragging.value) return
currentX.value = e.clientX - startX.value
}
const endDrag = () => {
if (!isDragging.value) return
isDragging.value = false
if (carouselRef.value) {
carouselRef.value.style.cursor = 'grab'
}
// 根据拖拽距离决定是否切换
if (Math.abs(currentX.value) > 100) {
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 stopAutoplay = () => {
if (autoplayInterval) {
clearInterval(autoplayInterval)
autoplayInterval = null
}
}
onMounted(async () => {
await nextTick()
if (!rootRef.value || !headerRef.value) return
ctx = gsap.context(() => {
// 标题动画
gsap.fromTo(headerRef.value,
{ autoAlpha: 0, y: 50 },
{
scrollTrigger: {
trigger: headerRef.value,
start: 'top 80%',
toggleActions: 'play none none reverse'
},
autoAlpha: 1,
y: 0,
duration: 1,
ease: 'power3.out'
}
)
// 卡片从中心扩散入场
cardElements.value.forEach((card, index) => {
if (!card) return
gsap.fromTo(card,
{ autoAlpha: 0, scale: 0.5 },
{
scrollTrigger: {
trigger: rootRef.value,
start: 'top 70%',
toggleActions: 'play none none reverse'
},
autoAlpha: 1,
scale: 1,
duration: 0.8,
delay: index * 0.1,
ease: 'back.out(1.5)',
onComplete: () => {
if (index === cardElements.value.length - 1) {
updateCards()
}
}
}
)
})
}, rootRef.value)
// 启动自动播放
startAutoplay()
})
onUnmounted(() => {
ctx?.revert()
stopAutoplay()
})
</script>
<style scoped>
.testimonial-card {
transition: transform 0.8s ease, opacity 0.8s ease, filter 0.8s ease;
will-change: transform, opacity, filter;
}
</style>