Files
arcticnewone/app/components/home/HomeAchievements.vue
T
陈春 02323fd426 fix: 修复所有首页板块 pin 滚动不连贯问题
🐛 问题根源:
每个板块有两套独立的 ScrollTrigger 在同一个元素上:
- 入场动画用独立的 scrollTrigger
- pin 效果用 ScrollTrigger.create
两者互相冲突,导致空白间隙和动画跳变

 修复方案:
将所有板块的入场动画和 pin 滚动合并为一个统一时间线(timeline),
确保动画从 pin 开始到结束完全连续,无空白区

🔧 修复的 6 个板块:
1. HomeTestimonials - 标题入场 + 卡片逐张切换(淡入淡出)
2. HomeServices - 标题入场 + 卡片飞入归位 + 逐张聚焦
3. HomeAchievements - 逐个成就项展示(前一个淡出,后一个淡入)
4. HomeTechStack - 内容入场 + 标签散开 + 球体旋转
5. HomeProcess - 标题入场 + 线条绘制 + 步骤逐个出现 + 横向滚动
6. HomeUpdates - 标题入场 + 卡片分批出现

🎯 效果:
- pin 期间动画完全连续,无空白间隙
- 入场和 pin 共享同一个 timeline
- scrub: 0.5 让滚动更平滑
- 每个板块到下一个板块无缝过渡

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

259 lines
6.6 KiB
Vue

<template>
<section ref="rootRef" class="relative bg-black text-white min-h-screen flex items-center justify-center overflow-hidden">
<!-- 背景粒子 -->
<canvas ref="canvasRef" class="absolute inset-0 w-full h-full opacity-30"></canvas>
<div class="relative z-10 w-full max-w-[1200px] mx-auto px-6 md:px-10">
<!-- 成就网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-16 md:gap-20">
<div
v-for="(achievement, index) in achievements"
:key="achievement.id"
:ref="el => setAchievementRef(el, index)"
class="achievement-item flex flex-col md:flex-row items-start md:items-center gap-8"
>
<!-- 数字 -->
<div class="flex-shrink-0">
<div class="relative">
<div
:ref="el => setNumberRef(el, index)"
class="text-6xl md:text-8xl font-bold tracking-tight"
:class="achievement.color"
>
0
</div>
<div
class="absolute -top-2 -right-2 w-20 h-20 rounded-full blur-2xl opacity-50"
:class="achievement.bgColor"
></div>
</div>
</div>
<!-- 描述 -->
<div class="flex-1">
<h3 class="text-2xl md:text-3xl font-bold mb-3">
{{ t(`home.achievements.items.${achievement.id}.title`) }}
</h3>
<p class="text-gray-400 text-base md:text-lg leading-relaxed">
{{ t(`home.achievements.items.${achievement.id}.description`) }}
</p>
</div>
</div>
</div>
</div>
</section>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, nextTick } 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 canvasRef = ref<HTMLCanvasElement | null>(null)
const achievementElements = ref<(HTMLElement | null)[]>([])
const numberElements = ref<(HTMLElement | null)[]>([])
let ctx: gsap.Context | null = null
let animationId: number | null = null
const setAchievementRef = (el: any, index: number) => {
if (el) achievementElements.value[index] = el
}
const setNumberRef = (el: any, index: number) => {
if (el) numberElements.value[index] = el
}
const achievements = [
{
id: 'projects',
number: 150,
suffix: '+',
color: 'text-blue-400',
bgColor: 'bg-blue-500'
},
{
id: 'clients',
number: 85,
suffix: '+',
color: 'text-purple-400',
bgColor: 'bg-purple-500'
},
{
id: 'experience',
number: 12,
suffix: '+',
color: 'text-green-400',
bgColor: 'bg-green-500'
},
{
id: 'satisfaction',
number: 98,
suffix: '%',
color: 'text-orange-400',
bgColor: 'bg-orange-500'
}
]
// 粒子动画
const initParticles = () => {
const canvas = canvasRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const resize = () => {
canvas.width = canvas.offsetWidth
canvas.height = canvas.offsetHeight
}
resize()
window.addEventListener('resize', resize)
const particles: Array<{
x: number
y: number
vx: number
vy: number
size: number
opacity: number
}> = []
// 创建粒子
for (let i = 0; i < 80; i++) {
particles.push({
x: Math.random() * canvas.width,
y: Math.random() * canvas.height,
vx: (Math.random() - 0.5) * 0.5,
vy: (Math.random() - 0.5) * 0.5,
size: Math.random() * 2 + 1,
opacity: Math.random() * 0.5 + 0.3
})
}
const animate = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height)
particles.forEach(p => {
// 更新位置
p.x += p.vx
p.y += p.vy
// 边界检测
if (p.x < 0 || p.x > canvas.width) p.vx *= -1
if (p.y < 0 || p.y > canvas.height) p.vy *= -1
// 绘制粒子
ctx.beginPath()
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
ctx.fillStyle = `rgba(255, 255, 255, ${p.opacity})`
ctx.fill()
})
// 绘制连线
particles.forEach((p1, i) => {
particles.slice(i + 1).forEach(p2 => {
const dx = p1.x - p2.x
const dy = p1.y - p2.y
const distance = Math.sqrt(dx * dx + dy * dy)
if (distance < 150) {
ctx.beginPath()
ctx.moveTo(p1.x, p1.y)
ctx.lineTo(p2.x, p2.y)
ctx.strokeStyle = `rgba(255, 255, 255, ${0.1 * (1 - distance / 150)})`
ctx.lineWidth = 0.5
ctx.stroke()
}
})
})
animationId = requestAnimationFrame(animate)
}
animate()
return () => {
window.removeEventListener('resize', resize)
if (animationId) cancelAnimationFrame(animationId)
}
}
// 数字计数动画
const animateNumber = (element: HTMLElement, target: number, suffix: string) => {
const obj = { value: 0 }
gsap.to(obj, {
value: target,
duration: 2.5,
ease: 'power2.out',
onUpdate: () => {
element.textContent = Math.floor(obj.value) + suffix
}
})
}
onMounted(async () => {
await nextTick()
if (!rootRef.value) return
// 初始化粒子
const cleanupParticles = initParticles()
ctx = gsap.context(() => {
// 初始状态:全部隐藏
const totalItems = achievements.length
achievementElements.value.forEach((item, index) => {
if (!item) return
gsap.set(item, { autoAlpha: 0, x: index % 2 === 0 ? -100 : 100 })
})
// 统一时间线:逐个展示
const tl = gsap.timeline()
for (let i = 0; i < totalItems; i++) {
// 每个成就项入场,前一个淡出
if (i > 0) {
tl.to(achievementElements.value[i - 1], {
autoAlpha: 0, x: i % 2 === 0 ? 100 : -100,
duration: 0.5, ease: 'power2.in',
}, i)
}
tl.to(achievementElements.value[i], {
autoAlpha: 1, x: 0, duration: 0.8, ease: 'power3.out',
onStart: () => {
const numberEl = numberElements.value[i]
if (numberEl) animateNumber(numberEl, achievements[i].number, achievements[i].suffix)
}
}, i + 0.2)
}
ScrollTrigger.create({
trigger: rootRef.value,
start: 'top top',
end: () => `+=${(totalItems + 1) * window.innerHeight}`,
pin: true,
scrub: 0.5,
anticipatePin: 1,
animation: tl,
})
}, rootRef.value)
onUnmounted(() => {
ctx?.revert()
if (cleanupParticles) cleanupParticles()
})
})
</script>
<style scoped>
.achievement-item {
will-change: transform, opacity;
}
</style>