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>
This commit is contained in:
陈春
2026-06-03 19:53:46 +08:00
parent bb0020b406
commit 02323fd426
6 changed files with 161 additions and 307 deletions
+18 -20
View File
@@ -206,43 +206,41 @@ onMounted(async () => {
const cleanupParticles = initParticles() const cleanupParticles = initParticles()
ctx = gsap.context(() => { ctx = gsap.context(() => {
// Pin 效果:固定板块,逐个成就项展示 // 初始状态:全部隐藏
const totalItems = achievements.length const totalItems = achievements.length
const timeline = gsap.timeline()
// 初始隐藏所有
achievementElements.value.forEach((item, index) => { achievementElements.value.forEach((item, index) => {
if (!item) return if (!item) return
gsap.set(item, { autoAlpha: 0, x: index % 2 === 0 ? -100 : 100 }) gsap.set(item, { autoAlpha: 0, x: index % 2 === 0 ? -100 : 100 })
}) })
// 逐个展示每个成就项 // 统一时间线:逐个展示
for (let i = 0; i < totalItems; i++) { const tl = gsap.timeline()
timeline.addLabel(`item-${i}`)
timeline.to(achievementElements.value[i], { for (let i = 0; i < totalItems; i++) {
autoAlpha: 1, // 每个成就项入场,前一个淡出
x: 0, if (i > 0) {
duration: 0.8, tl.to(achievementElements.value[i - 1], {
ease: 'power3.out', 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: () => { onStart: () => {
// 触发数字计数
const numberEl = numberElements.value[i] const numberEl = numberElements.value[i]
if (numberEl) { if (numberEl) animateNumber(numberEl, achievements[i].number, achievements[i].suffix)
animateNumber(numberEl, achievements[i].number, achievements[i].suffix)
}
} }
}, `item-${i}`) }, i + 0.2)
} }
ScrollTrigger.create({ ScrollTrigger.create({
trigger: rootRef.value, trigger: rootRef.value,
start: 'top top', start: 'top top',
end: () => `+=${totalItems * window.innerHeight}`, end: () => `+=${(totalItems + 1) * window.innerHeight}`,
pin: true, pin: true,
scrub: true, scrub: 0.5,
anticipatePin: 1, anticipatePin: 1,
animation: timeline, animation: tl,
}) })
}, rootRef.value) }, rootRef.value)
+40 -83
View File
@@ -216,100 +216,57 @@ onMounted(async () => {
}, 100) }, 100)
ctx = gsap.context(() => { ctx = gsap.context(() => {
// 标题动画 // 初始状态
gsap.fromTo(headerRef.value, gsap.set(headerRef.value, { autoAlpha: 0, y: 50 })
{ autoAlpha: 0, y: 50 }, stepElements.value.forEach((step) => {
{
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 if (!step) return
gsap.set(step, { autoAlpha: 0, y: 60, scale: 0.85 })
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)'
}
)
}) })
// Pin 滚动效果:固定板块,横向滚动浏览所有步骤 // 统一时间线
const tl = gsap.timeline()
// 第一屏:标题入场
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, 0)
// 连接线绘制
if (lineRef.value && linePathRef.value) {
const pathLength = linePathRef.value.getTotalLength()
gsap.set(linePathRef.value, { strokeDasharray: pathLength, strokeDashoffset: pathLength })
tl.to(lineRef.value, { autoAlpha: 1, duration: 0.3 }, 0.5)
tl.to(linePathRef.value, { strokeDashoffset: 0, duration: 2, ease: 'power2.inOut' }, 0.5)
}
// 横向滚动:步骤卡片逐个出现
if (scrollContainerRef.value) { if (scrollContainerRef.value) {
const container = scrollContainerRef.value.querySelector('.flex') const container = scrollContainerRef.value.querySelector('.flex')
if (container) { if (container) {
const scrollWidth = container.scrollWidth - window.innerWidth const scrollWidth = container.scrollWidth - window.innerWidth
gsap.to(container, { // 逐个展示步骤
x: -scrollWidth, stepElements.value.forEach((step, index) => {
ease: 'none', if (!step) return
scrollTrigger: { tl.to(step, {
trigger: rootRef.value, autoAlpha: 1, y: 0, scale: 1,
start: 'top top', duration: 0.6, ease: 'back.out(1.5)',
end: () => `+=${stepElements.value.length * window.innerHeight}`, }, 1 + index * 0.3)
pin: true,
scrub: 1,
anticipatePin: 1,
}
}) })
// 横向滚动
tl.to(container, { x: -scrollWidth, duration: 3, ease: 'none' }, 1)
} }
} }
const totalSteps = stepElements.value.length || 6
ScrollTrigger.create({
trigger: rootRef.value,
start: 'top top',
end: () => `+=${(totalSteps + 2) * window.innerHeight}`,
pin: true,
scrub: 0.5,
anticipatePin: 1,
animation: tl,
})
}, rootRef.value) }, rootRef.value)
}) })
+40 -76
View File
@@ -202,106 +202,70 @@ onMounted(async () => {
if (!rootRef.value || !headerRef.value) return if (!rootRef.value || !headerRef.value) return
ctx = gsap.context(() => { ctx = gsap.context(() => {
// 标题动画 // 初始状态
gsap.fromTo(headerRef.value, gsap.set(headerRef.value, { autoAlpha: 0, y: 50 })
{ autoAlpha: 0, y: 50 }, const positions = [
{ { x: -200, y: -200, rotate: -15 },
scrollTrigger: { { x: 0, y: -200, rotate: 10 },
trigger: headerRef.value, { x: 200, y: -200, rotate: -15 },
start: 'top 80%', { x: -200, y: 200, rotate: 15 },
toggleActions: 'play none none reverse' { x: 0, y: 200, rotate: -10 },
}, { x: 200, y: 200, rotate: 15 }
autoAlpha: 1, ]
y: 0,
duration: 1,
ease: 'power3.out'
}
)
// Pin 效果:固定板块,逐张卡片聚焦展示
const totalCards = services.length
const timeline = gsap.timeline()
// 初始:所有卡片从四周飞入并归位
cardElements.value.forEach((card, index) => { cardElements.value.forEach((card, index) => {
if (!card) return if (!card) return
const p = positions[index]
const positions = [ gsap.set(card, { autoAlpha: 0, x: p.x, y: p.y, scale: 0.8, rotateZ: p.rotate })
{ x: -200, y: -200, rotate: -15 },
{ x: 0, y: -200, rotate: 10 },
{ x: 200, y: -200, rotate: -15 },
{ x: -200, y: 200, rotate: 15 },
{ x: 0, y: 200, rotate: -10 },
{ x: 200, y: 200, rotate: 15 }
]
const startPos = positions[index]
gsap.set(card, {
autoAlpha: 0,
x: startPos.x,
y: startPos.y,
scale: 0.8,
rotateZ: startPos.rotate,
})
// 第一屏:所有卡片飞入归位
timeline.to(card, {
autoAlpha: 1,
x: 0,
y: 0,
scale: 1,
rotateZ: 0,
duration: 1,
ease: 'back.out(1.2)',
}, index * 0.1)
}) })
// 后续屏:每次聚焦一张卡片(放大),其他缩小 // 统一时间线
for (let focus = 0; focus < totalCards; focus++) { const totalCards = services.length
timeline.addLabel(`focus-${focus}`, focus + 1) const tl = gsap.timeline()
// 第一屏:标题入场 + 所有卡片飞入归位
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, 0)
cardElements.value.forEach((card, index) => {
if (!card) return
tl.to(card, {
autoAlpha: 1, x: 0, y: 0, scale: 1, rotateZ: 0,
duration: 1, ease: 'back.out(1.2)',
}, 0.2 + index * 0.1)
})
// 后续屏:逐张聚焦
for (let focus = 0; focus < totalCards; focus++) {
const pos = 1.5 + focus
cardElements.value.forEach((card, index) => { cardElements.value.forEach((card, index) => {
if (!card) return if (!card) return
tl.to(card, {
timeline.to(card, {
scale: index === focus ? 1.05 : 0.85, scale: index === focus ? 1.05 : 0.85,
autoAlpha: index === focus ? 1 : 0.4, autoAlpha: index === focus ? 1 : 0.4,
zIndex: index === focus ? 10 : 1, duration: 0.6, ease: 'power2.out',
duration: 0.6, }, pos)
ease: 'power2.out',
}, `focus-${focus}`)
// 聚焦的卡片翻转展示背面
if (index === focus) { if (index === focus) {
timeline.call(() => handleCardHover(focus, true), [], `focus-${focus}`) tl.call(() => handleCardHover(focus, true), [], pos)
} else { } else {
timeline.call(() => handleCardHover(index, false), [], `focus-${focus}`) tl.call(() => handleCardHover(index, false), [], pos)
} }
}) })
} }
// 最后:恢复所有卡片正常状态 // 最后:恢复正常
timeline.addLabel('reset') const resetPos = 1.5 + totalCards
cardElements.value.forEach((card, index) => { cardElements.value.forEach((card, index) => {
if (!card) return if (!card) return
timeline.to(card, { tl.to(card, { scale: 1, autoAlpha: 1, duration: 0.5, ease: 'power2.out' }, resetPos)
scale: 1, tl.call(() => handleCardHover(index, false), [], resetPos)
autoAlpha: 1,
zIndex: 1,
duration: 0.5,
ease: 'power2.out',
}, 'reset')
timeline.call(() => handleCardHover(index, false), [], 'reset')
}) })
ScrollTrigger.create({ ScrollTrigger.create({
trigger: rootRef.value, trigger: rootRef.value,
start: 'top top', start: 'top top',
end: () => `+=${(totalCards + 2) * window.innerHeight}`, end: () => `+=${(totalCards + 3) * window.innerHeight}`,
pin: true, pin: true,
scrub: true, scrub: 0.5,
anticipatePin: 1, anticipatePin: 1,
animation: timeline, animation: tl,
}) })
}, rootRef.value) }, rootRef.value)
}) })
+20 -43
View File
@@ -193,61 +193,38 @@ onMounted(async () => {
if (!rootRef.value || !contentRef.value) return if (!rootRef.value || !contentRef.value) return
ctx = gsap.context(() => { ctx = gsap.context(() => {
// 右侧内容动画 // 初始状态
gsap.fromTo(contentRef.value, gsap.set(contentRef.value, { autoAlpha: 0, x: 100 })
{ autoAlpha: 0, x: 100 }, techElements.value.forEach((el) => {
{ if (!el) return
scrollTrigger: { gsap.set(el, { autoAlpha: 0, scale: 0 })
trigger: contentRef.value, })
start: 'top 80%',
toggleActions: 'play none none reverse'
},
autoAlpha: 1,
x: 0,
duration: 1,
ease: 'power3.out'
}
)
// 技术标签从中心爆炸散开 // 统一时间线:入场 + 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) => { techElements.value.forEach((el, index) => {
if (!el) return if (!el) return
tl.to(el, {
gsap.fromTo(el, autoAlpha: 1, scale: 1, duration: 0.8, ease: 'back.out(2)',
{ autoAlpha: 0, scale: 0 }, }, 0.3 + index * 0.05)
{
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() tl.to(autoRotation.value, {
y: 360, duration: 3, ease: 'none', onUpdate: updatePositions,
rotationTimeline.to(autoRotation.value, { }, 1.5)
y: 360,
duration: 3,
ease: 'none',
onUpdate: updatePositions
})
ScrollTrigger.create({ ScrollTrigger.create({
trigger: rootRef.value, trigger: rootRef.value,
start: 'top top', start: 'top top',
end: () => `+=${window.innerHeight * 2}`, end: () => `+=${window.innerHeight * 3}`,
pin: true, pin: true,
scrub: 1, scrub: 1,
anticipatePin: 1, anticipatePin: 1,
animation: rotationTimeline, animation: tl,
}) })
}, rootRef.value) }, rootRef.value)
+24 -48
View File
@@ -226,58 +226,34 @@ onMounted(async () => {
if (!rootRef.value || !headerRef.value) return if (!rootRef.value || !headerRef.value) return
ctx = gsap.context(() => { ctx = gsap.context(() => {
// 标题动画 // 初始状态:标题隐藏,卡片居中缩小
gsap.fromTo(headerRef.value, gsap.set(headerRef.value, { autoAlpha: 0, y: 50 })
{ 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) => { cardElements.value.forEach((card, index) => {
if (!card) return if (!card) return
gsap.set(card, { autoAlpha: 0, scale: 0.5 })
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()
}
}
}
)
}) })
// Pin 滚动效果:固定板块,滚动切换卡片 // 统一时间线:入场 + pin 滚动切换
const totalCards = testimonials.length const totalCards = testimonials.length
const timeline = gsap.timeline() const tl = gsap.timeline()
// 为每张卡片创建切换动画(每屏切换一张) // 第一屏:标题入场 + 第一张卡片出现
for (let i = 0; i < totalCards - 1; i++) { tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, 0)
timeline.to({}, { duration: 1 }, i) tl.to(cardElements.value[0], { autoAlpha: 1, scale: 1, duration: 0.8, ease: 'back.out(1.5)' }, 0.3)
timeline.call(() => { tl.call(() => { currentIndex.value = 0 }, [], 0.3)
goToSlide(i + 1)
}, [], i + 0.5) // 后续屏:滚动切换卡片,每屏切一张
for (let i = 1; i < totalCards; i++) {
const pos = i
// 当前卡片缩小淡出
tl.to(cardElements.value[i - 1], {
autoAlpha: 0, scale: 0.5, duration: 0.5, ease: 'power2.in'
}, pos)
// 新卡片放大淡入
tl.to(cardElements.value[i], {
autoAlpha: 1, scale: 1, duration: 0.6, ease: 'back.out(1.5)'
}, pos + 0.3)
tl.call(() => { currentIndex.value = i }, [], pos + 0.3)
} }
ScrollTrigger.create({ ScrollTrigger.create({
@@ -285,9 +261,9 @@ onMounted(async () => {
start: 'top top', start: 'top top',
end: () => `+=${totalCards * window.innerHeight}`, end: () => `+=${totalCards * window.innerHeight}`,
pin: true, pin: true,
scrub: true, scrub: 0.5,
anticipatePin: 1, anticipatePin: 1,
animation: timeline, animation: tl,
}) })
}, rootRef.value) }, rootRef.value)
+19 -37
View File
@@ -164,63 +164,45 @@ onMounted(async () => {
if (!rootRef.value || !headerRef.value) return if (!rootRef.value || !headerRef.value) return
ctx = gsap.context(() => { ctx = gsap.context(() => {
// 标题动画 // 初始状态
gsap.fromTo(headerRef.value, gsap.set(headerRef.value, { autoAlpha: 0, y: 50 })
{ 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'
}
)
// Pin 效果:固定板块,逐行展示卡片
const totalCards = updates.length
const timeline = gsap.timeline()
// 初始隐藏所有卡片
cardElements.value.forEach((card) => { cardElements.value.forEach((card) => {
if (!card) return if (!card) return
gsap.set(card, { autoAlpha: 0, y: 60, scale: 0.9 }) gsap.set(card, { autoAlpha: 0, y: 60, scale: 0.9 })
}) })
// 分批展示:每批展示一行(按顺序,每行 2-3 个) // 统一时间线
const batchSize = 3 const tl = gsap.timeline()
// 第一屏:标题入场
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, 0)
// 分批展示卡片:每批 2 个,逐批出现
const totalCards = updates.length
const batchSize = 2
const batches = Math.ceil(totalCards / batchSize) const batches = Math.ceil(totalCards / batchSize)
for (let batch = 0; batch < batches; batch++) { for (let batch = 0; batch < batches; batch++) {
timeline.addLabel(`batch-${batch}`) const pos = 0.8 + batch
const start = batch * batchSize const start = batch * batchSize
const end = Math.min(start + batchSize, totalCards) const end = Math.min(start + batchSize, totalCards)
for (let i = start; i < end; i++) { for (let i = start; i < end; i++) {
if (!cardElements.value[i]) continue tl.to(cardElements.value[i], {
autoAlpha: 1, y: 0, scale: 1,
timeline.to(cardElements.value[i], { duration: 0.7, ease: 'power3.out',
autoAlpha: 1, }, pos + (i - start) * 0.12)
y: 0,
scale: 1,
duration: 0.8,
ease: 'power3.out',
}, `batch-${batch}+=${(i - start) * 0.15}`)
} }
} }
ScrollTrigger.create({ ScrollTrigger.create({
trigger: rootRef.value, trigger: rootRef.value,
start: 'top top', start: 'top top',
end: () => `+=${batches * window.innerHeight}`, end: () => `+=${(batches + 1) * window.innerHeight}`,
pin: true, pin: true,
scrub: true, scrub: 0.5,
anticipatePin: 1, anticipatePin: 1,
animation: timeline, animation: tl,
}) })
}, rootRef.value) }, rootRef.value)
}) })