From 02323fd4265f1d50007491123ab65f6a6b53a6b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E6=98=A5?= Date: Wed, 3 Jun 2026 19:53:46 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=89=80=E6=9C=89?= =?UTF-8?q?=E9=A6=96=E9=A1=B5=E6=9D=BF=E5=9D=97=20pin=20=E6=BB=9A=E5=8A=A8?= =?UTF-8?q?=E4=B8=8D=E8=BF=9E=E8=B4=AF=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🐛 问题根源: 每个板块有两套独立的 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) --- app/components/home/HomeAchievements.vue | 38 ++++--- app/components/home/HomeProcess.vue | 123 ++++++++--------------- app/components/home/HomeServices.vue | 116 ++++++++------------- app/components/home/HomeTechStack.vue | 63 ++++-------- app/components/home/HomeTestimonials.vue | 72 +++++-------- app/components/home/HomeUpdates.vue | 56 ++++------- 6 files changed, 161 insertions(+), 307 deletions(-) diff --git a/app/components/home/HomeAchievements.vue b/app/components/home/HomeAchievements.vue index 10e4212..90425f9 100644 --- a/app/components/home/HomeAchievements.vue +++ b/app/components/home/HomeAchievements.vue @@ -206,43 +206,41 @@ onMounted(async () => { const cleanupParticles = initParticles() ctx = gsap.context(() => { - // Pin 效果:固定板块,逐个成就项展示 + // 初始状态:全部隐藏 const totalItems = achievements.length - const timeline = gsap.timeline() - - // 初始隐藏所有 achievementElements.value.forEach((item, index) => { if (!item) return gsap.set(item, { autoAlpha: 0, x: index % 2 === 0 ? -100 : 100 }) }) - // 逐个展示每个成就项 - for (let i = 0; i < totalItems; i++) { - timeline.addLabel(`item-${i}`) + // 统一时间线:逐个展示 + const tl = gsap.timeline() - timeline.to(achievementElements.value[i], { - autoAlpha: 1, - x: 0, - duration: 0.8, - ease: 'power3.out', + 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.5, ease: 'power2.in', + }, i) + } + 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) - } + if (numberEl) animateNumber(numberEl, achievements[i].number, achievements[i].suffix) } - }, `item-${i}`) + }, i + 0.2) } ScrollTrigger.create({ trigger: rootRef.value, start: 'top top', - end: () => `+=${totalItems * window.innerHeight}`, + end: () => `+=${(totalItems + 1) * window.innerHeight}`, pin: true, - scrub: true, + scrub: 0.5, anticipatePin: 1, - animation: timeline, + animation: tl, }) }, rootRef.value) diff --git a/app/components/home/HomeProcess.vue b/app/components/home/HomeProcess.vue index 5396de7..4ac7426 100644 --- a/app/components/home/HomeProcess.vue +++ b/app/components/home/HomeProcess.vue @@ -216,100 +216,57 @@ onMounted(async () => { }, 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) => { + // 初始状态 + gsap.set(headerRef.value, { autoAlpha: 0, y: 50 }) + stepElements.value.forEach((step) => { 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)' - } - ) + gsap.set(step, { autoAlpha: 0, y: 60, scale: 0.85 }) }) - // 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) { const container = scrollContainerRef.value.querySelector('.flex') if (container) { const scrollWidth = container.scrollWidth - window.innerWidth - gsap.to(container, { - x: -scrollWidth, - ease: 'none', - scrollTrigger: { - trigger: rootRef.value, - start: 'top top', - end: () => `+=${stepElements.value.length * window.innerHeight}`, - pin: true, - scrub: 1, - anticipatePin: 1, - } + // 逐个展示步骤 + stepElements.value.forEach((step, index) => { + if (!step) return + tl.to(step, { + autoAlpha: 1, y: 0, scale: 1, + duration: 0.6, ease: 'back.out(1.5)', + }, 1 + index * 0.3) }) + + // 横向滚动 + 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) }) diff --git a/app/components/home/HomeServices.vue b/app/components/home/HomeServices.vue index 5a76fdf..a9eaca8 100644 --- a/app/components/home/HomeServices.vue +++ b/app/components/home/HomeServices.vue @@ -202,106 +202,70 @@ onMounted(async () => { 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' - } - ) - - // Pin 效果:固定板块,逐张卡片聚焦展示 - const totalCards = services.length - const timeline = gsap.timeline() - - // 初始:所有卡片从四周飞入并归位 + // 初始状态 + gsap.set(headerRef.value, { autoAlpha: 0, y: 50 }) + const positions = [ + { 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 } + ] cardElements.value.forEach((card, index) => { if (!card) return - - const positions = [ - { 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) + const p = positions[index] + gsap.set(card, { autoAlpha: 0, x: p.x, y: p.y, scale: 0.8, rotateZ: p.rotate }) }) - // 后续屏:每次聚焦一张卡片(放大),其他缩小 - for (let focus = 0; focus < totalCards; focus++) { - timeline.addLabel(`focus-${focus}`, focus + 1) + // 统一时间线 + const totalCards = services.length + 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) => { if (!card) return - - timeline.to(card, { + tl.to(card, { scale: index === focus ? 1.05 : 0.85, autoAlpha: index === focus ? 1 : 0.4, - zIndex: index === focus ? 10 : 1, - duration: 0.6, - ease: 'power2.out', - }, `focus-${focus}`) - - // 聚焦的卡片翻转展示背面 + duration: 0.6, ease: 'power2.out', + }, pos) if (index === focus) { - timeline.call(() => handleCardHover(focus, true), [], `focus-${focus}`) + tl.call(() => handleCardHover(focus, true), [], pos) } 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) => { if (!card) return - timeline.to(card, { - scale: 1, - autoAlpha: 1, - zIndex: 1, - duration: 0.5, - ease: 'power2.out', - }, 'reset') - timeline.call(() => handleCardHover(index, false), [], 'reset') + tl.to(card, { scale: 1, autoAlpha: 1, duration: 0.5, ease: 'power2.out' }, resetPos) + tl.call(() => handleCardHover(index, false), [], resetPos) }) ScrollTrigger.create({ trigger: rootRef.value, start: 'top top', - end: () => `+=${(totalCards + 2) * window.innerHeight}`, + end: () => `+=${(totalCards + 3) * window.innerHeight}`, pin: true, - scrub: true, + scrub: 0.5, anticipatePin: 1, - animation: timeline, + animation: tl, }) }, rootRef.value) }) diff --git a/app/components/home/HomeTechStack.vue b/app/components/home/HomeTechStack.vue index ad0e2a9..8204664 100644 --- a/app/components/home/HomeTechStack.vue +++ b/app/components/home/HomeTechStack.vue @@ -193,61 +193,38 @@ onMounted(async () => { 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' - } - ) + // 初始状态 + 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 - - 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)' - } - ) + tl.to(el, { + autoAlpha: 1, scale: 1, duration: 0.8, ease: 'back.out(2)', + }, 0.3 + index * 0.05) }) - // Pin 滚动效果:固定板块,球体旋转一圈展示所有技术 - const rotationTimeline = gsap.timeline() - - rotationTimeline.to(autoRotation.value, { - y: 360, - duration: 3, - ease: 'none', - onUpdate: updatePositions - }) + // 后续屏:球体旋转一圈 + 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 * 2}`, + end: () => `+=${window.innerHeight * 3}`, pin: true, scrub: 1, anticipatePin: 1, - animation: rotationTimeline, + animation: tl, }) }, rootRef.value) diff --git a/app/components/home/HomeTestimonials.vue b/app/components/home/HomeTestimonials.vue index 693e97f..04a0cc7 100644 --- a/app/components/home/HomeTestimonials.vue +++ b/app/components/home/HomeTestimonials.vue @@ -226,58 +226,34 @@ onMounted(async () => { 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' - } - ) - - // 卡片从中心扩散入场 + // 初始状态:标题隐藏,卡片居中缩小 + gsap.set(headerRef.value, { autoAlpha: 0, y: 50 }) 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() - } - } - } - ) + gsap.set(card, { autoAlpha: 0, scale: 0.5 }) }) - // Pin 滚动效果:固定板块,滚动切换卡片 + // 统一时间线:入场 + pin 滚动切换 const totalCards = testimonials.length - const timeline = gsap.timeline() + const tl = gsap.timeline() - // 为每张卡片创建切换动画(每屏切换一张) - for (let i = 0; i < totalCards - 1; i++) { - timeline.to({}, { duration: 1 }, i) - timeline.call(() => { - goToSlide(i + 1) - }, [], i + 0.5) + // 第一屏:标题入场 + 第一张卡片出现 + tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, 0) + tl.to(cardElements.value[0], { autoAlpha: 1, scale: 1, duration: 0.8, ease: 'back.out(1.5)' }, 0.3) + tl.call(() => { currentIndex.value = 0 }, [], 0.3) + + // 后续屏:滚动切换卡片,每屏切一张 + 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({ @@ -285,9 +261,9 @@ onMounted(async () => { start: 'top top', end: () => `+=${totalCards * window.innerHeight}`, pin: true, - scrub: true, + scrub: 0.5, anticipatePin: 1, - animation: timeline, + animation: tl, }) }, rootRef.value) diff --git a/app/components/home/HomeUpdates.vue b/app/components/home/HomeUpdates.vue index d5a6fa7..490b8a9 100644 --- a/app/components/home/HomeUpdates.vue +++ b/app/components/home/HomeUpdates.vue @@ -164,63 +164,45 @@ onMounted(async () => { 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' - } - ) - - // Pin 效果:固定板块,逐行展示卡片 - const totalCards = updates.length - const timeline = gsap.timeline() - - // 初始隐藏所有卡片 + // 初始状态 + gsap.set(headerRef.value, { autoAlpha: 0, y: 50 }) cardElements.value.forEach((card) => { if (!card) return 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) for (let batch = 0; batch < batches; batch++) { - timeline.addLabel(`batch-${batch}`) - + const pos = 0.8 + batch const start = batch * batchSize const end = Math.min(start + batchSize, totalCards) for (let i = start; i < end; i++) { - if (!cardElements.value[i]) continue - - timeline.to(cardElements.value[i], { - autoAlpha: 1, - y: 0, - scale: 1, - duration: 0.8, - ease: 'power3.out', - }, `batch-${batch}+=${(i - start) * 0.15}`) + tl.to(cardElements.value[i], { + autoAlpha: 1, y: 0, scale: 1, + duration: 0.7, ease: 'power3.out', + }, pos + (i - start) * 0.12) } } ScrollTrigger.create({ trigger: rootRef.value, start: 'top top', - end: () => `+=${batches * window.innerHeight}`, + end: () => `+=${(batches + 1) * window.innerHeight}`, pin: true, - scrub: true, + scrub: 0.5, anticipatePin: 1, - animation: timeline, + animation: tl, }) }, rootRef.value) })