e94f7d92e9
🐛 问题1: 滚动到下一个内容时有空白区 根因: 时间线动画之间有间隙(动画 duration < 步骤间距) 修复: 所有板块改用 cursor 模式,每个步骤精确衔接,无空白 🐛 问题2: 服务卡片太大,一屏显示不全 根因: 卡片高度固定 h-[380px],不适配小屏幕 修复: 改用 clamp(260px, 38vh, 400px) 自适应屏幕高度 🔧 修复的 6 个板块: 1. HomeTestimonials - 淡入淡出切换无间隙 2. HomeServices - 卡片聚焦无间隙 + 自适应高度 3. HomeAchievements - 数字展示无间隙 4. HomeTechStack - 球体旋转无间隙 5. HomeProcess - 步骤展示 + 横向滚动无间隙 6. HomeUpdates - 卡片分批出现无间隙 📐 cursor 模式: 每一步动画 duration 正好等于步骤间距, 动画结束 = 下一步开始,完美衔接 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
261 lines
6.6 KiB
Vue
261 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()
|
|
let cursor = 0
|
|
|
|
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.6, ease: 'power2.in',
|
|
}, cursor)
|
|
}
|
|
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)
|
|
}
|
|
}, cursor + 0.1)
|
|
cursor += 1
|
|
}
|
|
|
|
ScrollTrigger.create({
|
|
trigger: rootRef.value,
|
|
start: 'top top',
|
|
end: () => `+=${cursor * 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>
|