Files
arcticnewone/app/components/home/HomeTechStack.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

251 lines
8.4 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-white min-h-screen flex items-center justify-center overflow-hidden py-20">
<div class="w-full max-w-[1400px] mx-auto px-6 md:px-10">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-16 lg:gap-20 items-center">
<!-- 左侧3D 球体 -->
<div class="relative h-[500px] md:h-[600px]">
<div
ref="sphereRef"
class="relative w-full h-full flex items-center justify-center"
@mousemove="handleMouseMove"
@mouseleave="handleMouseLeave"
>
<!-- 技术 logo 标签 -->
<div
v-for="(tech, index) in technologies"
:key="tech.id"
:ref="el => setTechRef(el, index)"
class="tech-tag absolute flex items-center gap-3 px-5 py-3 bg-white rounded-full shadow-lg border-2 cursor-pointer hover:scale-110 transition-transform duration-300"
:class="tech.borderColor"
:style="getTechStyle(index)"
>
<Icon :name="tech.icon" class="w-6 h-6 md:w-7 md:h-7" :class="tech.color" />
<span class="text-sm md:text-base font-semibold text-gray-800">{{ tech.name }}</span>
</div>
<!-- 中心装饰 -->
<div class="absolute inset-0 flex items-center justify-center pointer-events-none">
<div class="w-32 h-32 md:w-40 md:h-40 rounded-full bg-gradient-to-br from-blue-100 to-purple-100 blur-3xl opacity-50 animate-pulse"></div>
</div>
</div>
</div>
<!-- 右侧文本内容 -->
<div ref="contentRef" class="space-y-8">
<div>
<h2 class="text-4xl md:text-6xl font-bold tracking-tight text-black mb-6">
{{ t('home.techStack.title') }}
</h2>
<p class="text-lg md:text-xl text-gray-600 leading-relaxed mb-8">
{{ t('home.techStack.subtitle') }}
</p>
</div>
<!-- 技术分类 -->
<div class="space-y-6">
<div
v-for="category in categories"
:key="category.id"
class="tech-category p-6 rounded-2xl bg-gradient-to-br hover:shadow-lg transition-all duration-300"
:class="category.gradient"
>
<div class="flex items-center gap-3 mb-3">
<Icon :name="category.icon" class="w-6 h-6 text-white" />
<h3 class="text-xl font-bold text-white">
{{ t(`home.techStack.categories.${category.id}.title`) }}
</h3>
</div>
<p class="text-white/90 text-sm md:text-base">
{{ t(`home.techStack.categories.${category.id}.description`) }}
</p>
</div>
</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 sphereRef = ref<HTMLElement | null>(null)
const contentRef = ref<HTMLElement | null>(null)
const techElements = ref<(HTMLElement | null)[]>([])
const rotationX = ref(0)
const rotationY = ref(0)
const autoRotation = ref({ x: 0, y: 0 })
let ctx: gsap.Context | null = null
let autoRotateInterval: ReturnType<typeof setInterval> | null = null
const setTechRef = (el: any, index: number) => {
if (el) techElements.value[index] = el
}
const technologies = [
{ id: 'vue', name: 'Vue.js', icon: 'ph:file-vue', color: 'text-green-500', borderColor: 'border-green-400' },
{ id: 'react', name: 'React', icon: 'ph:atom', color: 'text-blue-500', borderColor: 'border-blue-400' },
{ id: 'nuxt', name: 'Nuxt', icon: 'ph:mountains', color: 'text-green-600', borderColor: 'border-green-500' },
{ id: 'tailwind', name: 'Tailwind', icon: 'ph:wind', color: 'text-cyan-500', borderColor: 'border-cyan-400' },
{ id: 'node', name: 'Node.js', icon: 'ph:hexagon', color: 'text-green-500', borderColor: 'border-green-400' },
{ id: 'typescript', name: 'TypeScript', icon: 'ph:code', color: 'text-blue-600', borderColor: 'border-blue-500' },
{ id: 'gsap', name: 'GSAP', icon: 'ph:magic-wand', color: 'text-purple-500', borderColor: 'border-purple-400' },
{ id: 'figma', name: 'Figma', icon: 'ph:figma-logo', color: 'text-pink-500', borderColor: 'border-pink-400' },
{ id: 'webgl', name: 'WebGL', icon: 'ph:cube', color: 'text-orange-500', borderColor: 'border-orange-400' }
]
const categories = [
{ id: 'frontend', icon: 'ph:monitor', gradient: 'from-blue-500 to-cyan-500' },
{ id: 'backend', icon: 'ph:database', gradient: 'from-purple-500 to-pink-500' },
{ id: 'design', icon: 'ph:palette', gradient: 'from-orange-500 to-red-500' }
]
// 计算 3D 球体位置
const getTechStyle = (index: number) => {
const total = technologies.length
const phi = Math.acos(-1 + (2 * index) / total)
const theta = Math.sqrt(total * Math.PI) * phi
// 球体半径
const radius = 200
// 3D 坐标转 2D
const x = radius * Math.cos(theta) * Math.sin(phi)
const y = radius * Math.sin(theta) * Math.sin(phi)
const z = radius * Math.cos(phi)
// 应用旋转
const rotX = (rotationX.value + autoRotation.value.x) * Math.PI / 180
const rotY = (rotationY.value + autoRotation.value.y) * Math.PI / 180
// 旋转 X 轴
const y1 = y * Math.cos(rotX) - z * Math.sin(rotX)
const z1 = y * Math.sin(rotX) + z * Math.cos(rotX)
// 旋转 Y 轴
const x2 = x * Math.cos(rotY) + z1 * Math.sin(rotY)
const z2 = -x * Math.sin(rotY) + z1 * Math.cos(rotY)
// 透视效果
const perspective = 600
const scale = perspective / (perspective + z2)
return {
left: '50%',
top: '50%',
transform: `translate(-50%, -50%) translate(${x2}px, ${y1}px) scale(${scale})`,
zIndex: Math.round(scale * 100),
opacity: scale < 0.6 ? 0.3 : 1
}
}
// 鼠标控制旋转
const handleMouseMove = (e: MouseEvent) => {
if (!sphereRef.value) return
const rect = sphereRef.value.getBoundingClientRect()
const centerX = rect.left + rect.width / 2
const centerY = rect.top + rect.height / 2
const deltaX = (e.clientX - centerX) / rect.width
const deltaY = (e.clientY - centerY) / rect.height
gsap.to(rotationX, { value: -deltaY * 30, duration: 0.5 })
gsap.to(rotationY, { value: deltaX * 30, duration: 0.5 })
updatePositions()
}
const handleMouseLeave = () => {
gsap.to(rotationX, { value: 0, duration: 0.8 })
gsap.to(rotationY, { value: 0, duration: 0.8 })
updatePositions()
}
// 自动旋转
const startAutoRotation = () => {
autoRotateInterval = setInterval(() => {
autoRotation.value.y += 1
if (autoRotation.value.y >= 360) autoRotation.value.y = 0
updatePositions()
}, 50)
}
// 更新所有标签位置
const updatePositions = () => {
techElements.value.forEach((el, index) => {
if (!el) return
const style = getTechStyle(index)
Object.assign(el.style, style)
})
}
onMounted(async () => {
await nextTick()
if (!rootRef.value || !contentRef.value) return
ctx = gsap.context(() => {
// 初始状态
gsap.set(contentRef.value, { autoAlpha: 0, x: 100 })
techElements.value.forEach((el) => {
if (!el) return
gsap.set(el, { autoAlpha: 0, scale: 0 })
})
// 统一时间线:入场 + pin 旋转
const tl = gsap.timeline()
// 第一屏:内容入场 + 技术标签散开
tl.to(contentRef.value, { autoAlpha: 1, x: 0, duration: 0.8, ease: 'power3.out' }, 0)
techElements.value.forEach((el, index) => {
if (!el) return
tl.to(el, {
autoAlpha: 1, scale: 1, duration: 0.8, ease: 'back.out(2)',
}, 0.3 + index * 0.05)
})
// 后续屏:球体旋转一圈
tl.to(autoRotation.value, {
y: 360, duration: 3, ease: 'none', onUpdate: updatePositions,
}, 1.5)
ScrollTrigger.create({
trigger: rootRef.value,
start: 'top top',
end: () => `+=${window.innerHeight * 3}`,
pin: true,
scrub: 1,
anticipatePin: 1,
animation: tl,
})
}, rootRef.value)
// 启动自动旋转
startAutoRotation()
})
onUnmounted(() => {
ctx?.revert()
if (autoRotateInterval) clearInterval(autoRotateInterval)
})
</script>
<style scoped>
.tech-tag {
will-change: transform, opacity;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.tech-category {
will-change: transform, box-shadow;
}
</style>