Files
arcticnewone/app/components/home/HomeTechStack.vue
T
陈春 bb0020b406 feat: 为首页新增板块添加 ScrollTrigger pin 滚动效果
 新增 pin 效果(板块固定,内容随滚动切换):

1. HomeTestimonials - 客户评价
   - 固定板块,滚动自动切换卡片
   - 每屏切换一张评价卡片

2. HomeTechStack - 技术栈
   - 固定板块,3D 球体自动旋转一圈
   - 滚动控制旋转进度

3. HomeProcess - 创意流程
   - 固定板块,横向滚动浏览所有步骤
   - 6 个步骤依次展示

4. HomeServices - 服务能力
   - 固定板块,逐张卡片聚焦展示
   - 聚焦卡片放大,其他缩小变暗
   - 所有卡片飞入归位后逐个聚焦

5. HomeAchievements - 数字成就
   - 固定板块,逐个成就项展示
   - 每个数字滚动计数动画触发

6. HomeUpdates - 最新动态
   - 固定板块,逐批展示卡片
   - 分批出现,每批 3 个卡片

🎯 效果:
- 滚动到板块时自动固定(pin)
- 内部元素随滚动依次展示
- 所有内部元素展示完毕后才继续下一个板块
- 每个板块独特的 pin 动画效果

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

274 lines
8.8 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.fromTo(contentRef.value,
{ autoAlpha: 0, x: 100 },
{
scrollTrigger: {
trigger: contentRef.value,
start: 'top 80%',
toggleActions: 'play none none reverse'
},
autoAlpha: 1,
x: 0,
duration: 1,
ease: 'power3.out'
}
)
// 技术标签从中心爆炸散开
techElements.value.forEach((el, index) => {
if (!el) return
gsap.fromTo(el,
{ autoAlpha: 0, scale: 0 },
{
scrollTrigger: {
trigger: sphereRef.value,
start: 'top 75%',
toggleActions: 'play none none reverse'
},
autoAlpha: 1,
scale: 1,
duration: 1,
delay: index * 0.05,
ease: 'back.out(2)'
}
)
})
// Pin 滚动效果:固定板块,球体旋转一圈展示所有技术
const rotationTimeline = gsap.timeline()
rotationTimeline.to(autoRotation.value, {
y: 360,
duration: 3,
ease: 'none',
onUpdate: updatePositions
})
ScrollTrigger.create({
trigger: rootRef.value,
start: 'top top',
end: () => `+=${window.innerHeight * 2}`,
pin: true,
scrub: 1,
anticipatePin: 1,
animation: rotationTimeline,
})
}, 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>