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>
This commit is contained in:
@@ -0,0 +1,250 @@
|
||||
<template>
|
||||
<section ref="rootRef" class="relative bg-black text-white py-32 md:py-40 overflow-hidden">
|
||||
<!-- 背景粒子 -->
|
||||
<canvas ref="canvasRef" class="absolute inset-0 w-full h-full opacity-30"></canvas>
|
||||
|
||||
<div class="relative z-10 w-page-container 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"
|
||||
style="opacity: 0;"
|
||||
>
|
||||
<!-- 数字 -->
|
||||
<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(() => {
|
||||
achievementElements.value.forEach((item, index) => {
|
||||
if (!item) return
|
||||
|
||||
// 成就项入场动画
|
||||
gsap.fromTo(item,
|
||||
{ autoAlpha: 0, x: index % 2 === 0 ? -100 : 100 },
|
||||
{
|
||||
scrollTrigger: {
|
||||
trigger: item,
|
||||
start: 'top 80%',
|
||||
toggleActions: 'play none none reverse',
|
||||
onEnter: () => {
|
||||
// 触发数字计数
|
||||
const numberEl = numberElements.value[index]
|
||||
if (numberEl) {
|
||||
animateNumber(numberEl, achievements[index].number, achievements[index].suffix)
|
||||
}
|
||||
}
|
||||
},
|
||||
autoAlpha: 1,
|
||||
x: 0,
|
||||
duration: 1,
|
||||
delay: index * 0.2,
|
||||
ease: 'power3.out'
|
||||
}
|
||||
)
|
||||
})
|
||||
}, rootRef.value)
|
||||
|
||||
onUnmounted(() => {
|
||||
ctx?.revert()
|
||||
if (cleanupParticles) cleanupParticles()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.achievement-item {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<section ref="rootRef" class="relative min-h-screen flex items-center justify-center overflow-hidden">
|
||||
<!-- 粒子背景 -->
|
||||
<canvas ref="canvasRef" class="absolute inset-0 w-full h-full"></canvas>
|
||||
|
||||
<!-- 渐变背景 -->
|
||||
<div
|
||||
ref="gradientRef"
|
||||
class="absolute inset-0 opacity-80"
|
||||
style="background: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%);"
|
||||
></div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative z-10 text-center px-6 md:px-10 max-w-5xl mx-auto">
|
||||
<!-- 主标题 - 分裂后合并动画 -->
|
||||
<h2 ref="titleRef" class="mb-8 md:mb-12 overflow-hidden">
|
||||
<div class="title-line flex justify-center gap-3 md:gap-4 mb-4 md:mb-6">
|
||||
<span
|
||||
v-for="(char, i) in line1Chars"
|
||||
:key="`l1-${i}`"
|
||||
:ref="el => setCharRef(el, 'line1', i)"
|
||||
class="inline-block text-5xl md:text-7xl lg:text-8xl font-bold text-white"
|
||||
style="opacity: 0; transform: translateY(100px) rotate(10deg);"
|
||||
v-html="char === ' ' ? ' ' : char"
|
||||
></span>
|
||||
</div>
|
||||
<div class="title-line flex justify-center gap-3 md:gap-4">
|
||||
<span
|
||||
v-for="(char, i) in line2Chars"
|
||||
:key="`l2-${i}`"
|
||||
:ref="el => setCharRef(el, 'line2', i)"
|
||||
class="inline-block text-5xl md:text-7xl lg:text-8xl font-bold text-white"
|
||||
style="opacity: 0; transform: translateY(100px) rotate(-10deg);"
|
||||
v-html="char === ' ' ? ' ' : char"
|
||||
></span>
|
||||
</div>
|
||||
</h2>
|
||||
|
||||
<!-- 副标题 -->
|
||||
<p ref="subtitleRef" class="text-xl md:text-3xl text-white/90 mb-12 md:mb-16 leading-relaxed max-w-3xl mx-auto" style="opacity: 0;">
|
||||
{{ t('home.cta.subtitle') }}
|
||||
</p>
|
||||
|
||||
<!-- 按钮组 -->
|
||||
<div ref="buttonsRef" class="flex flex-col sm:flex-row gap-6 justify-center items-center" style="opacity: 0;">
|
||||
<NuxtLink
|
||||
to="/contact"
|
||||
class="cta-button group relative px-10 py-5 rounded-full bg-white text-black text-lg font-bold overflow-hidden shadow-2xl hover:shadow-white/50 transition-all duration-300"
|
||||
>
|
||||
<span class="relative z-10 flex items-center gap-3">
|
||||
{{ t('home.cta.primaryButton') }}
|
||||
<Icon name="ph:arrow-right" class="w-6 h-6 transition-transform duration-300 group-hover:translate-x-2" />
|
||||
</span>
|
||||
<div class="absolute inset-0 bg-gradient-to-r from-blue-500 to-purple-500 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink
|
||||
to="/work"
|
||||
class="cta-button-secondary group px-10 py-5 rounded-full border-2 border-white text-white text-lg font-bold hover:bg-white hover:text-black transition-all duration-300"
|
||||
>
|
||||
<span class="flex items-center gap-3">
|
||||
{{ t('home.cta.secondaryButton') }}
|
||||
<Icon name="ph:eye" class="w-6 h-6" />
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 滚动提示 -->
|
||||
<div ref="scrollHintRef" class="absolute bottom-12 left-1/2 -translate-x-1/2" style="opacity: 0;">
|
||||
<div class="flex flex-col items-center gap-2 animate-bounce">
|
||||
<span class="text-white/70 text-sm">{{ t('home.cta.scrollDown') }}</span>
|
||||
<Icon name="ph:caret-down" class="w-6 h-6 text-white/70" />
|
||||
</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'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const rootRef = ref<HTMLElement | null>(null)
|
||||
const canvasRef = ref<HTMLCanvasElement | null>(null)
|
||||
const gradientRef = ref<HTMLElement | null>(null)
|
||||
const titleRef = ref<HTMLElement | null>(null)
|
||||
const subtitleRef = ref<HTMLElement | null>(null)
|
||||
const buttonsRef = ref<HTMLElement | null>(null)
|
||||
const scrollHintRef = ref<HTMLElement | null>(null)
|
||||
|
||||
const line1Chars = computed(() => t('home.cta.titleLine1').split(''))
|
||||
const line2Chars = computed(() => t('home.cta.titleLine2').split(''))
|
||||
|
||||
const line1CharRefs = ref<(HTMLElement | null)[]>([])
|
||||
const line2CharRefs = ref<(HTMLElement | null)[]>([])
|
||||
|
||||
let animationId: number | null = null
|
||||
let gradientHue = 0
|
||||
|
||||
const setCharRef = (el: any, line: 'line1' | 'line2', index: number) => {
|
||||
if (line === 'line1') {
|
||||
line1CharRefs.value[index] = el
|
||||
} else {
|
||||
line2CharRefs.value[index] = el
|
||||
}
|
||||
}
|
||||
|
||||
// 星空粒子动画
|
||||
const initParticles = () => {
|
||||
const canvas = canvasRef.value
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const resize = () => {
|
||||
canvas.width = window.innerWidth
|
||||
canvas.height = window.innerHeight
|
||||
}
|
||||
resize()
|
||||
window.addEventListener('resize', resize)
|
||||
|
||||
const particles: Array<{
|
||||
x: number
|
||||
y: number
|
||||
vx: number
|
||||
vy: number
|
||||
size: number
|
||||
opacity: number
|
||||
twinkle: number
|
||||
}> = []
|
||||
|
||||
// 创建星星
|
||||
for (let i = 0; i < 150; i++) {
|
||||
particles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
vx: (Math.random() - 0.5) * 0.3,
|
||||
vy: (Math.random() - 0.5) * 0.3,
|
||||
size: Math.random() * 2.5 + 0.5,
|
||||
opacity: Math.random(),
|
||||
twinkle: Math.random() * Math.PI * 2
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
if (p.x > canvas.width) p.x = 0
|
||||
if (p.y < 0) p.y = canvas.height
|
||||
if (p.y > canvas.height) p.y = 0
|
||||
|
||||
// 闪烁效果
|
||||
p.twinkle += 0.02
|
||||
const twinkleOpacity = Math.sin(p.twinkle) * 0.5 + 0.5
|
||||
|
||||
// 绘制星星
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2)
|
||||
ctx.fillStyle = `rgba(255, 255, 255, ${p.opacity * twinkleOpacity})`
|
||||
ctx.fill()
|
||||
|
||||
// 绘制光晕
|
||||
const gradient = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, p.size * 3)
|
||||
gradient.addColorStop(0, `rgba(255, 255, 255, ${0.3 * twinkleOpacity})`)
|
||||
gradient.addColorStop(1, 'rgba(255, 255, 255, 0)')
|
||||
ctx.fillStyle = gradient
|
||||
ctx.beginPath()
|
||||
ctx.arc(p.x, p.y, p.size * 3, 0, Math.PI * 2)
|
||||
ctx.fill()
|
||||
})
|
||||
|
||||
animationId = requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animate()
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', resize)
|
||||
if (animationId) cancelAnimationFrame(animationId)
|
||||
}
|
||||
}
|
||||
|
||||
// 渐变色循环变化
|
||||
const animateGradient = () => {
|
||||
const animate = () => {
|
||||
gradientHue = (gradientHue + 0.2) % 360
|
||||
|
||||
if (gradientRef.value) {
|
||||
const h1 = gradientHue
|
||||
const h2 = (gradientHue + 60) % 360
|
||||
const h3 = (gradientHue + 120) % 360
|
||||
|
||||
gradientRef.value.style.background = `linear-gradient(135deg,
|
||||
hsl(${h1}, 70%, 60%) 0%,
|
||||
hsl(${h2}, 70%, 60%) 50%,
|
||||
hsl(${h3}, 70%, 75%) 100%
|
||||
)`
|
||||
}
|
||||
|
||||
requestAnimationFrame(animate)
|
||||
}
|
||||
|
||||
animate()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
|
||||
// 初始化粒子和渐变
|
||||
const cleanupParticles = initParticles()
|
||||
animateGradient()
|
||||
|
||||
// 文字分裂后合并动画
|
||||
const timeline = gsap.timeline()
|
||||
|
||||
// Line 1 字符动画
|
||||
line1CharRefs.value.forEach((char, i) => {
|
||||
if (!char) return
|
||||
timeline.to(char, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
duration: 0.8,
|
||||
ease: 'back.out(1.5)'
|
||||
}, i * 0.03)
|
||||
})
|
||||
|
||||
// Line 2 字符动画
|
||||
line2CharRefs.value.forEach((char, i) => {
|
||||
if (!char) return
|
||||
timeline.to(char, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
rotation: 0,
|
||||
duration: 0.8,
|
||||
ease: 'back.out(1.5)'
|
||||
}, 0.5 + i * 0.03)
|
||||
})
|
||||
|
||||
// 副标题
|
||||
timeline.to(subtitleRef.value, {
|
||||
opacity: 1,
|
||||
duration: 1,
|
||||
ease: 'power2.out'
|
||||
}, '-=0.3')
|
||||
|
||||
// 按钮组
|
||||
timeline.to(buttonsRef.value, {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
duration: 0.8,
|
||||
ease: 'power3.out'
|
||||
}, '-=0.5')
|
||||
|
||||
// 滚动提示
|
||||
timeline.to(scrollHintRef.value, {
|
||||
opacity: 1,
|
||||
duration: 0.6,
|
||||
ease: 'power2.out'
|
||||
}, '-=0.3')
|
||||
|
||||
onUnmounted(() => {
|
||||
if (cleanupParticles) cleanupParticles()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cta-button {
|
||||
will-change: transform, box-shadow;
|
||||
}
|
||||
|
||||
.cta-button:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.cta-button-secondary:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,324 @@
|
||||
<template>
|
||||
<section ref="rootRef" class="relative bg-gradient-to-b from-white to-gray-50 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 md:mb-28" style="opacity: 0;">
|
||||
<h2 class="text-4xl md:text-6xl font-bold tracking-tight text-black mb-6">
|
||||
{{ t('home.process.title') }}
|
||||
</h2>
|
||||
<p class="text-lg md:text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{{ t('home.process.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 水平滚动容器 -->
|
||||
<div ref="scrollContainerRef" class="relative">
|
||||
<!-- 连接线 -->
|
||||
<svg
|
||||
ref="lineRef"
|
||||
class="absolute top-1/2 left-0 w-full h-1 -translate-y-1/2 pointer-events-none z-0"
|
||||
style="opacity: 0;"
|
||||
>
|
||||
<path
|
||||
ref="linePathRef"
|
||||
d=""
|
||||
stroke="url(#lineGradient)"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
stroke-dasharray="10 10"
|
||||
class="line-path"
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
|
||||
<stop offset="0%" style="stop-color:#3B82F6;stop-opacity:1" />
|
||||
<stop offset="50%" style="stop-color:#8B5CF6;stop-opacity:1" />
|
||||
<stop offset="100%" style="stop-color:#EC4899;stop-opacity:1" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<!-- 步骤卡片 -->
|
||||
<div class="flex gap-8 md:gap-12 pb-10 overflow-x-auto scrollbar-hide">
|
||||
<div
|
||||
v-for="(step, index) in steps"
|
||||
:key="step.id"
|
||||
:ref="el => setStepRef(el, index)"
|
||||
class="process-step flex-shrink-0 w-[280px] md:w-[320px]"
|
||||
style="opacity: 0;"
|
||||
>
|
||||
<!-- 步骤编号节点 -->
|
||||
<div class="relative flex justify-center mb-8">
|
||||
<div
|
||||
class="w-20 h-20 md:w-24 md:h-24 rounded-full flex items-center justify-center text-white text-2xl md:text-3xl font-bold shadow-xl relative z-10"
|
||||
:class="step.gradient"
|
||||
>
|
||||
{{ index + 1 }}
|
||||
<!-- 脉冲动画 -->
|
||||
<div class="absolute inset-0 rounded-full animate-ping opacity-20" :class="step.bgColor"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 卡片内容 -->
|
||||
<div class="bg-white rounded-2xl p-6 md:p-8 shadow-lg hover:shadow-2xl transition-all duration-300 hover:-translate-y-2">
|
||||
<div class="w-14 h-14 md:w-16 md:h-16 rounded-xl mb-5 flex items-center justify-center" :class="step.iconBg">
|
||||
<Icon :name="step.icon" class="w-7 h-7 md:w-8 md:h-8" :class="step.iconColor" />
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl md:text-2xl font-bold text-black mb-3">
|
||||
{{ t(`home.process.steps.${step.id}.title`) }}
|
||||
</h3>
|
||||
|
||||
<p class="text-gray-600 text-sm md:text-base leading-relaxed mb-5">
|
||||
{{ t(`home.process.steps.${step.id}.description`) }}
|
||||
</p>
|
||||
|
||||
<!-- 关键点 -->
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(point, i) in step.points"
|
||||
:key="i"
|
||||
class="flex items-center text-sm text-gray-500"
|
||||
>
|
||||
<Icon name="ph:check-circle-fill" :class="step.iconColor" class="w-4 h-4 mr-2 flex-shrink-0" />
|
||||
<span>{{ t(`home.process.steps.${step.id}.points.${i}`) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 滚动提示 -->
|
||||
<div class="flex justify-center mt-8">
|
||||
<div class="flex items-center gap-2 text-gray-400 text-sm animate-bounce">
|
||||
<Icon name="ph:arrow-right" class="w-5 h-5" />
|
||||
<span>{{ t('home.process.scrollHint') }}</span>
|
||||
</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 headerRef = ref<HTMLElement | null>(null)
|
||||
const scrollContainerRef = ref<HTMLElement | null>(null)
|
||||
const lineRef = ref<SVGElement | null>(null)
|
||||
const linePathRef = ref<SVGPathElement | null>(null)
|
||||
const stepElements = ref<(HTMLElement | null)[]>([])
|
||||
let ctx: gsap.Context | null = null
|
||||
|
||||
const setStepRef = (el: any, index: number) => {
|
||||
if (el) stepElements.value[index] = el
|
||||
}
|
||||
|
||||
const steps = [
|
||||
{
|
||||
id: 'discovery',
|
||||
icon: 'ph:magnifying-glass-duotone',
|
||||
gradient: 'bg-gradient-to-br from-blue-500 to-cyan-500',
|
||||
bgColor: 'bg-blue-500',
|
||||
iconBg: 'bg-blue-50',
|
||||
iconColor: 'text-blue-600',
|
||||
points: [0, 1, 2]
|
||||
},
|
||||
{
|
||||
id: 'strategy',
|
||||
icon: 'ph:strategy-duotone',
|
||||
gradient: 'bg-gradient-to-br from-purple-500 to-pink-500',
|
||||
bgColor: 'bg-purple-500',
|
||||
iconBg: 'bg-purple-50',
|
||||
iconColor: 'text-purple-600',
|
||||
points: [0, 1, 2]
|
||||
},
|
||||
{
|
||||
id: 'design',
|
||||
icon: 'ph:paint-brush-duotone',
|
||||
gradient: 'bg-gradient-to-br from-pink-500 to-rose-500',
|
||||
bgColor: 'bg-pink-500',
|
||||
iconBg: 'bg-pink-50',
|
||||
iconColor: 'text-pink-600',
|
||||
points: [0, 1, 2]
|
||||
},
|
||||
{
|
||||
id: 'development',
|
||||
icon: 'ph:code-duotone',
|
||||
gradient: 'bg-gradient-to-br from-orange-500 to-amber-500',
|
||||
bgColor: 'bg-orange-500',
|
||||
iconBg: 'bg-orange-50',
|
||||
iconColor: 'text-orange-600',
|
||||
points: [0, 1, 2]
|
||||
},
|
||||
{
|
||||
id: 'testing',
|
||||
icon: 'ph:test-tube-duotone',
|
||||
gradient: 'bg-gradient-to-br from-green-500 to-emerald-500',
|
||||
bgColor: 'bg-green-500',
|
||||
iconBg: 'bg-green-50',
|
||||
iconColor: 'text-green-600',
|
||||
points: [0, 1, 2]
|
||||
},
|
||||
{
|
||||
id: 'launch',
|
||||
icon: 'ph:rocket-launch-duotone',
|
||||
gradient: 'bg-gradient-to-br from-indigo-500 to-blue-500',
|
||||
bgColor: 'bg-indigo-500',
|
||||
iconBg: 'bg-indigo-50',
|
||||
iconColor: 'text-indigo-600',
|
||||
points: [0, 1, 2]
|
||||
}
|
||||
]
|
||||
|
||||
// 绘制连接线
|
||||
const drawLine = () => {
|
||||
if (!scrollContainerRef.value || !linePathRef.value) return
|
||||
|
||||
const container = scrollContainerRef.value
|
||||
const nodes = stepElements.value.filter(el => el !== null)
|
||||
|
||||
if (nodes.length < 2) return
|
||||
|
||||
let pathData = ''
|
||||
nodes.forEach((node, index) => {
|
||||
if (!node) return
|
||||
|
||||
const rect = node.getBoundingClientRect()
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const x = rect.left - containerRect.left + rect.width / 2
|
||||
const y = 60 // 固定高度
|
||||
|
||||
if (index === 0) {
|
||||
pathData = `M ${x} ${y}`
|
||||
} else {
|
||||
pathData += ` L ${x} ${y}`
|
||||
}
|
||||
})
|
||||
|
||||
linePathRef.value.setAttribute('d', pathData)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
if (!rootRef.value || !headerRef.value) return
|
||||
|
||||
// 绘制连接线
|
||||
setTimeout(() => {
|
||||
drawLine()
|
||||
}, 100)
|
||||
|
||||
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'
|
||||
}
|
||||
)
|
||||
|
||||
// 连接线绘制动画
|
||||
if (lineRef.value && linePathRef.value) {
|
||||
const pathLength = linePathRef.value.getTotalLength()
|
||||
|
||||
gsap.set(linePathRef.value, {
|
||||
strokeDasharray: pathLength,
|
||||
strokeDashoffset: pathLength
|
||||
})
|
||||
|
||||
gsap.fromTo(lineRef.value,
|
||||
{ autoAlpha: 0 },
|
||||
{
|
||||
scrollTrigger: {
|
||||
trigger: scrollContainerRef.value,
|
||||
start: 'top 75%',
|
||||
toggleActions: 'play none none reverse'
|
||||
},
|
||||
autoAlpha: 1,
|
||||
duration: 0.5
|
||||
}
|
||||
)
|
||||
|
||||
gsap.to(linePathRef.value,
|
||||
{
|
||||
scrollTrigger: {
|
||||
trigger: scrollContainerRef.value,
|
||||
start: 'top 75%',
|
||||
toggleActions: 'play none none reverse'
|
||||
},
|
||||
strokeDashoffset: 0,
|
||||
duration: 2,
|
||||
ease: 'power2.inOut'
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// 步骤节点弹跳入场
|
||||
stepElements.value.forEach((step, index) => {
|
||||
if (!step) return
|
||||
|
||||
gsap.fromTo(step,
|
||||
{ autoAlpha: 0, y: 100, scale: 0.8 },
|
||||
{
|
||||
scrollTrigger: {
|
||||
trigger: step,
|
||||
start: 'top 90%',
|
||||
toggleActions: 'play none none reverse'
|
||||
},
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
duration: 0.8,
|
||||
delay: index * 0.15,
|
||||
ease: 'back.out(1.5)'
|
||||
}
|
||||
)
|
||||
})
|
||||
}, rootRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ctx?.revert()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.process-step {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
.line-path {
|
||||
stroke-dasharray: 10 10;
|
||||
animation: dash 20s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes dash {
|
||||
to {
|
||||
stroke-dashoffset: -200;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,282 @@
|
||||
<template>
|
||||
<section ref="rootRef" class="relative bg-white py-32 md:py-40 overflow-hidden">
|
||||
<div class="w-page-container max-w-[1200px] mx-auto px-6 md:px-10">
|
||||
<!-- 标题区 -->
|
||||
<div ref="headerRef" class="text-center mb-20 md:mb-28" style="opacity: 0;">
|
||||
<h2 class="text-4xl md:text-6xl font-bold tracking-tight text-black mb-6">
|
||||
{{ t('home.services.title') }}
|
||||
</h2>
|
||||
<p class="text-lg md:text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{{ t('home.services.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 服务卡片网格 -->
|
||||
<div
|
||||
ref="cardsRef"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 md:gap-10"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseleave="handleMouseLeave"
|
||||
>
|
||||
<div
|
||||
v-for="(service, index) in services"
|
||||
:key="service.id"
|
||||
:ref="el => setCardRef(el, index)"
|
||||
class="service-card group relative h-[380px] md:h-[420px] cursor-pointer"
|
||||
:style="{ transformStyle: 'preserve-3d' }"
|
||||
@mouseenter="() => handleCardHover(index, true)"
|
||||
@mouseleave="() => handleCardHover(index, false)"
|
||||
>
|
||||
<!-- 前面 -->
|
||||
<div
|
||||
class="card-face card-front absolute inset-0 rounded-3xl p-8 md:p-10 flex flex-col justify-between overflow-hidden"
|
||||
:class="service.gradient"
|
||||
>
|
||||
<div class="relative z-10">
|
||||
<div class="w-16 h-16 md:w-20 md:h-20 rounded-2xl bg-white/90 backdrop-blur-sm flex items-center justify-center mb-6 shadow-lg">
|
||||
<Icon :name="service.icon" class="w-8 h-8 md:w-10 md:h-10 text-black" />
|
||||
</div>
|
||||
<h3 class="text-2xl md:text-3xl font-bold text-white mb-3">
|
||||
{{ t(`home.services.items.${service.id}.title`) }}
|
||||
</h3>
|
||||
<p class="text-white/80 text-sm md:text-base">
|
||||
{{ t(`home.services.items.${service.id}.brief`) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 装饰图案 -->
|
||||
<div class="absolute bottom-0 right-0 w-48 h-48 opacity-10">
|
||||
<Icon :name="service.icon" class="w-full h-full text-white" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背面 -->
|
||||
<div
|
||||
class="card-face card-back absolute inset-0 rounded-3xl p-8 md:p-10 bg-white border-2 flex flex-col justify-between shadow-2xl"
|
||||
:class="service.borderColor"
|
||||
>
|
||||
<div>
|
||||
<Icon :name="service.icon" class="w-12 h-12 md:w-14 md:h-14 text-black mb-4" />
|
||||
<h3 class="text-xl md:text-2xl font-bold text-black mb-4">
|
||||
{{ t(`home.services.items.${service.id}.title`) }}
|
||||
</h3>
|
||||
<p class="text-gray-700 text-sm md:text-base leading-relaxed mb-6">
|
||||
{{ t(`home.services.items.${service.id}.description`) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul class="space-y-2">
|
||||
<li
|
||||
v-for="(feature, i) in service.features"
|
||||
:key="i"
|
||||
class="flex items-center text-sm md:text-base text-gray-600"
|
||||
>
|
||||
<Icon name="ph:check-circle-fill" :class="service.iconColor" class="w-5 h-5 mr-2 flex-shrink-0" />
|
||||
<span>{{ t(`home.services.items.${service.id}.features.${i}`) }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</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 headerRef = ref<HTMLElement | null>(null)
|
||||
const cardsRef = ref<HTMLElement | null>(null)
|
||||
const cardElements = ref<(HTMLElement | null)[]>([])
|
||||
let ctx: gsap.Context | null = null
|
||||
|
||||
const setCardRef = (el: any, index: number) => {
|
||||
if (el) cardElements.value[index] = el
|
||||
}
|
||||
|
||||
const services = [
|
||||
{
|
||||
id: 'webDesign',
|
||||
icon: 'ph:palette-duotone',
|
||||
gradient: 'bg-gradient-to-br from-purple-500 via-pink-500 to-red-500',
|
||||
borderColor: 'border-purple-500',
|
||||
iconColor: 'text-purple-500',
|
||||
features: [0, 1, 2]
|
||||
},
|
||||
{
|
||||
id: 'development',
|
||||
icon: 'ph:code-duotone',
|
||||
gradient: 'bg-gradient-to-br from-blue-500 via-cyan-500 to-teal-500',
|
||||
borderColor: 'border-blue-500',
|
||||
iconColor: 'text-blue-500',
|
||||
features: [0, 1, 2]
|
||||
},
|
||||
{
|
||||
id: 'branding',
|
||||
icon: 'ph:sparkle-duotone',
|
||||
gradient: 'bg-gradient-to-br from-amber-500 via-orange-500 to-red-500',
|
||||
borderColor: 'border-amber-500',
|
||||
iconColor: 'text-amber-500',
|
||||
features: [0, 1, 2]
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
icon: 'ph:megaphone-duotone',
|
||||
gradient: 'bg-gradient-to-br from-green-500 via-emerald-500 to-teal-500',
|
||||
borderColor: 'border-green-500',
|
||||
iconColor: 'text-green-500',
|
||||
features: [0, 1, 2]
|
||||
},
|
||||
{
|
||||
id: 'seo',
|
||||
icon: 'ph:magnifying-glass-duotone',
|
||||
gradient: 'bg-gradient-to-br from-indigo-500 via-purple-500 to-pink-500',
|
||||
borderColor: 'border-indigo-500',
|
||||
iconColor: 'text-indigo-500',
|
||||
features: [0, 1, 2]
|
||||
},
|
||||
{
|
||||
id: 'consulting',
|
||||
icon: 'ph:lightbulb-duotone',
|
||||
gradient: 'bg-gradient-to-br from-rose-500 via-pink-500 to-fuchsia-500',
|
||||
borderColor: 'border-rose-500',
|
||||
iconColor: 'text-rose-500',
|
||||
features: [0, 1, 2]
|
||||
}
|
||||
]
|
||||
|
||||
// 视差效果
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!cardsRef.value) return
|
||||
|
||||
const rect = cardsRef.value.getBoundingClientRect()
|
||||
const x = (e.clientX - rect.left) / rect.width - 0.5
|
||||
const y = (e.clientY - rect.top) / rect.height - 0.5
|
||||
|
||||
cardElements.value.forEach((card, index) => {
|
||||
if (!card) return
|
||||
const offset = (index % 3) + 1
|
||||
gsap.to(card, {
|
||||
x: x * 20 * offset,
|
||||
y: y * 20 * offset,
|
||||
duration: 0.6,
|
||||
ease: 'power2.out'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
cardElements.value.forEach(card => {
|
||||
if (!card) return
|
||||
gsap.to(card, {
|
||||
x: 0,
|
||||
y: 0,
|
||||
duration: 0.6,
|
||||
ease: 'power2.out'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// 卡片翻转
|
||||
const handleCardHover = (index: number, isHover: boolean) => {
|
||||
const card = cardElements.value[index]
|
||||
if (!card) return
|
||||
|
||||
gsap.to(card, {
|
||||
rotateY: isHover ? 180 : 0,
|
||||
duration: 0.6,
|
||||
ease: 'power2.inOut'
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
const positions = [
|
||||
{ x: -200, y: -200 }, // 左上
|
||||
{ x: 0, y: -200 }, // 上
|
||||
{ x: 200, y: -200 }, // 右上
|
||||
{ x: -200, y: 200 }, // 左下
|
||||
{ x: 0, y: 200 }, // 下
|
||||
{ x: 200, y: 200 } // 右下
|
||||
]
|
||||
|
||||
const startPos = positions[index]
|
||||
|
||||
gsap.fromTo(card,
|
||||
{
|
||||
autoAlpha: 0,
|
||||
x: startPos.x,
|
||||
y: startPos.y,
|
||||
scale: 0.8,
|
||||
rotateZ: index % 2 === 0 ? -15 : 15
|
||||
},
|
||||
{
|
||||
scrollTrigger: {
|
||||
trigger: card,
|
||||
start: 'top 85%',
|
||||
toggleActions: 'play none none reverse'
|
||||
},
|
||||
autoAlpha: 1,
|
||||
x: 0,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
rotateZ: 0,
|
||||
duration: 1.2,
|
||||
delay: index * 0.1,
|
||||
ease: 'back.out(1.2)'
|
||||
}
|
||||
)
|
||||
})
|
||||
}, rootRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ctx?.revert()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.service-card {
|
||||
perspective: 1000px;
|
||||
}
|
||||
|
||||
.card-face {
|
||||
backface-visibility: hidden;
|
||||
-webkit-backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.card-back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,253 @@
|
||||
<template>
|
||||
<section ref="rootRef" class="relative bg-white py-32 md:py-40 overflow-hidden">
|
||||
<div class="w-page-container 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" style="opacity: 0;">
|
||||
<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)'
|
||||
}
|
||||
)
|
||||
})
|
||||
}, 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>
|
||||
@@ -0,0 +1,287 @@
|
||||
<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>
|
||||
@@ -0,0 +1,225 @@
|
||||
<template>
|
||||
<section ref="rootRef" class="relative bg-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 md:mb-28" style="opacity: 0;">
|
||||
<h2 class="text-4xl md:text-6xl font-bold tracking-tight text-black mb-6">
|
||||
{{ t('home.updates.title') }}
|
||||
</h2>
|
||||
<p class="text-lg md:text-xl text-gray-600 max-w-2xl mx-auto">
|
||||
{{ t('home.updates.subtitle') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Bento 网格 -->
|
||||
<div class="grid grid-cols-12 gap-4 md:gap-6 auto-rows-[200px]">
|
||||
<div
|
||||
v-for="(item, index) in updates"
|
||||
:key="item.id"
|
||||
:ref="el => setCardRef(el, index)"
|
||||
class="update-card group relative overflow-hidden rounded-3xl cursor-pointer"
|
||||
:class="item.gridClass"
|
||||
:style="{ opacity: 0 }"
|
||||
@mouseenter="() => handleCardHover(index, true)"
|
||||
@mouseleave="() => handleCardHover(index, false)"
|
||||
>
|
||||
<!-- 背景渐变 -->
|
||||
<div class="absolute inset-0 transition-transform duration-500 group-hover:scale-110" :class="item.gradient"></div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="relative z-10 h-full p-6 md:p-8 flex flex-col justify-between text-white">
|
||||
<div>
|
||||
<div class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/20 backdrop-blur-sm text-xs md:text-sm font-semibold mb-4">
|
||||
<Icon :name="item.categoryIcon" class="w-4 h-4" />
|
||||
{{ t(`home.updates.categories.${item.category}`) }}
|
||||
</div>
|
||||
|
||||
<h3 class="text-xl md:text-3xl font-bold mb-3 line-clamp-2">
|
||||
{{ t(`home.updates.items.${item.id}.title`) }}
|
||||
</h3>
|
||||
|
||||
<p v-if="item.showDescription" class="text-white/90 text-sm md:text-base line-clamp-3">
|
||||
{{ t(`home.updates.items.${item.id}.description`) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-xs md:text-sm text-white/80">{{ item.date }}</span>
|
||||
<Icon name="ph:arrow-up-right" class="w-5 h-5 md:w-6 md:h-6 transition-transform duration-300 group-hover:translate-x-1 group-hover:-translate-y-1" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 悬浮效果 -->
|
||||
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition-colors duration-300"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 查看更多 -->
|
||||
<div class="flex justify-center mt-12 md:mt-16">
|
||||
<NuxtLink
|
||||
to="/news"
|
||||
class="inline-flex items-center gap-3 px-8 py-4 rounded-full bg-black text-white font-semibold hover:shadow-2xl transition-all duration-300 hover:-translate-y-1"
|
||||
>
|
||||
{{ t('home.updates.viewAll') }}
|
||||
<Icon name="ph:arrow-right" class="w-5 h-5" />
|
||||
</NuxtLink>
|
||||
</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 headerRef = ref<HTMLElement | null>(null)
|
||||
const cardElements = ref<(HTMLElement | null)[]>([])
|
||||
let ctx: gsap.Context | null = null
|
||||
|
||||
const setCardRef = (el: any, index: number) => {
|
||||
if (el) cardElements.value[index] = el
|
||||
}
|
||||
|
||||
const updates = [
|
||||
{
|
||||
id: 'project1',
|
||||
category: 'project',
|
||||
categoryIcon: 'ph:briefcase',
|
||||
gradient: 'bg-gradient-to-br from-blue-500 via-purple-500 to-pink-500',
|
||||
gridClass: 'col-span-12 md:col-span-6 md:row-span-2',
|
||||
showDescription: true,
|
||||
date: '2026-06-01'
|
||||
},
|
||||
{
|
||||
id: 'blog1',
|
||||
category: 'blog',
|
||||
categoryIcon: 'ph:article',
|
||||
gradient: 'bg-gradient-to-br from-orange-500 to-red-500',
|
||||
gridClass: 'col-span-12 md:col-span-3 md:row-span-1',
|
||||
showDescription: false,
|
||||
date: '2026-05-28'
|
||||
},
|
||||
{
|
||||
id: 'news1',
|
||||
category: 'news',
|
||||
categoryIcon: 'ph:newspaper',
|
||||
gradient: 'bg-gradient-to-br from-green-500 to-emerald-500',
|
||||
gridClass: 'col-span-12 md:col-span-3 md:row-span-1',
|
||||
showDescription: false,
|
||||
date: '2026-05-25'
|
||||
},
|
||||
{
|
||||
id: 'project2',
|
||||
category: 'project',
|
||||
categoryIcon: 'ph:briefcase',
|
||||
gradient: 'bg-gradient-to-br from-indigo-500 to-blue-500',
|
||||
gridClass: 'col-span-12 md:col-span-3 md:row-span-2',
|
||||
showDescription: true,
|
||||
date: '2026-05-20'
|
||||
},
|
||||
{
|
||||
id: 'blog2',
|
||||
category: 'blog',
|
||||
categoryIcon: 'ph:article',
|
||||
gradient: 'bg-gradient-to-br from-pink-500 to-rose-500',
|
||||
gridClass: 'col-span-12 md:col-span-6 md:row-span-1',
|
||||
showDescription: false,
|
||||
date: '2026-05-15'
|
||||
},
|
||||
{
|
||||
id: 'news2',
|
||||
category: 'news',
|
||||
categoryIcon: 'ph:newspaper',
|
||||
gradient: 'bg-gradient-to-br from-amber-500 to-orange-500',
|
||||
gridClass: 'col-span-12 md:col-span-3 md:row-span-1',
|
||||
showDescription: false,
|
||||
date: '2026-05-10'
|
||||
}
|
||||
]
|
||||
|
||||
// 卡片悬浮效果
|
||||
const handleCardHover = (index: number, isHover: boolean) => {
|
||||
const card = cardElements.value[index]
|
||||
if (!card) return
|
||||
|
||||
gsap.to(card, {
|
||||
y: isHover ? -8 : 0,
|
||||
boxShadow: isHover
|
||||
? '0 25px 50px -12px rgba(0, 0, 0, 0.25)'
|
||||
: '0 10px 15px -3px rgba(0, 0, 0, 0.1)',
|
||||
duration: 0.3,
|
||||
ease: 'power2.out'
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 根据列位置计算延迟
|
||||
const columnDelay = (index % 3) * 0.1
|
||||
const rowDelay = Math.floor(index / 3) * 0.15
|
||||
|
||||
gsap.fromTo(card,
|
||||
{
|
||||
autoAlpha: 0,
|
||||
y: 80,
|
||||
scale: 0.9
|
||||
},
|
||||
{
|
||||
scrollTrigger: {
|
||||
trigger: card,
|
||||
start: 'top 85%',
|
||||
toggleActions: 'play none none reverse'
|
||||
},
|
||||
autoAlpha: 1,
|
||||
y: 0,
|
||||
scale: 1,
|
||||
duration: 0.8,
|
||||
delay: columnDelay + rowDelay,
|
||||
ease: 'power3.out'
|
||||
}
|
||||
)
|
||||
})
|
||||
}, rootRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
ctx?.revert()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.update-card {
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1);
|
||||
will-change: transform, box-shadow, opacity;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user