fix: 修复所有板块 pin 滚动空白间隙 + 服务卡片适配屏幕
🐛 问题1: 滚动到下一个内容时有空白区 根因: 时间线动画之间有间隙(动画 duration < 步骤间距) 修复: 所有板块改用 cursor 模式,每个步骤精确衔接,无空白 🐛 问题2: 服务卡片太大,一屏显示不全 根因: 卡片高度固定 h-[380px],不适配小屏幕 修复: 改用 clamp(260px, 38vh, 400px) 自适应屏幕高度 🔧 修复的 6 个板块: 1. HomeTestimonials - 淡入淡出切换无间隙 2. HomeServices - 卡片聚焦无间隙 + 自适应高度 3. HomeAchievements - 数字展示无间隙 4. HomeTechStack - 球体旋转无间隙 5. HomeProcess - 步骤展示 + 横向滚动无间隙 6. HomeUpdates - 卡片分批出现无间隙 📐 cursor 模式: 每一步动画 duration 正好等于步骤间距, 动画结束 = 下一步开始,完美衔接 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -213,16 +213,17 @@ onMounted(async () => {
|
||||
gsap.set(item, { autoAlpha: 0, x: index % 2 === 0 ? -100 : 100 })
|
||||
})
|
||||
|
||||
// 统一时间线:逐个展示
|
||||
// 统一时间线:逐个展示,无空白
|
||||
const tl = gsap.timeline()
|
||||
let cursor = 0
|
||||
|
||||
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)
|
||||
duration: 0.6, ease: 'power2.in',
|
||||
}, cursor)
|
||||
}
|
||||
tl.to(achievementElements.value[i], {
|
||||
autoAlpha: 1, x: 0, duration: 0.8, ease: 'power3.out',
|
||||
@@ -230,13 +231,14 @@ onMounted(async () => {
|
||||
const numberEl = numberElements.value[i]
|
||||
if (numberEl) animateNumber(numberEl, achievements[i].number, achievements[i].suffix)
|
||||
}
|
||||
}, i + 0.2)
|
||||
}, cursor + 0.1)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: rootRef.value,
|
||||
start: 'top top',
|
||||
end: () => `+=${(totalItems + 1) * window.innerHeight}`,
|
||||
end: () => `+=${cursor * window.innerHeight}`,
|
||||
pin: true,
|
||||
scrub: 0.5,
|
||||
anticipatePin: 1,
|
||||
|
||||
@@ -223,45 +223,44 @@ onMounted(async () => {
|
||||
gsap.set(step, { autoAlpha: 0, y: 60, scale: 0.85 })
|
||||
})
|
||||
|
||||
// 统一时间线
|
||||
// 统一时间线:无空白
|
||||
const tl = gsap.timeline()
|
||||
let cursor = 0
|
||||
|
||||
// 第一屏:标题入场
|
||||
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, 0)
|
||||
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, cursor)
|
||||
cursor += 1
|
||||
|
||||
// 连接线绘制
|
||||
// 第二屏:连接线绘制 + 所有步骤卡片出现
|
||||
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)
|
||||
tl.to(lineRef.value, { autoAlpha: 1, duration: 0.3 }, cursor)
|
||||
tl.to(linePathRef.value, { strokeDashoffset: 0, duration: 1.5, ease: 'power2.inOut' }, cursor)
|
||||
}
|
||||
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)',
|
||||
}, cursor + index * 0.1)
|
||||
})
|
||||
cursor += 1
|
||||
|
||||
// 横向滚动:步骤卡片逐个出现
|
||||
// 第三屏起:横向滚动
|
||||
if (scrollContainerRef.value) {
|
||||
const container = scrollContainerRef.value.querySelector('.flex')
|
||||
if (container) {
|
||||
const scrollWidth = container.scrollWidth - window.innerWidth
|
||||
|
||||
// 逐个展示步骤
|
||||
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)
|
||||
tl.to(container, { x: -scrollWidth, duration: 2, ease: 'none' }, cursor)
|
||||
cursor += 2
|
||||
}
|
||||
}
|
||||
|
||||
const totalSteps = stepElements.value.length || 6
|
||||
ScrollTrigger.create({
|
||||
trigger: rootRef.value,
|
||||
start: 'top top',
|
||||
end: () => `+=${(totalSteps + 2) * window.innerHeight}`,
|
||||
end: () => `+=${cursor * window.innerHeight}`,
|
||||
pin: true,
|
||||
scrub: 0.5,
|
||||
anticipatePin: 1,
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
v-for="(service, index) in services"
|
||||
:key="service.id"
|
||||
:ref="el => setCardRef(el, index)"
|
||||
class="service-card group relative h-[380px] cursor-pointer"
|
||||
class="service-card group relative cursor-pointer"
|
||||
:style="{ transformStyle: 'preserve-3d' }"
|
||||
@mouseenter="() => handleCardHover(index, true)"
|
||||
@mouseleave="() => handleCardHover(index, false)"
|
||||
@@ -218,50 +218,48 @@ onMounted(async () => {
|
||||
gsap.set(card, { autoAlpha: 0, x: p.x, y: p.y, scale: 0.8, rotateZ: p.rotate })
|
||||
})
|
||||
|
||||
// 统一时间线
|
||||
// 统一时间线:所有动画填满时间轴,无空白
|
||||
const totalCards = services.length
|
||||
const tl = gsap.timeline()
|
||||
let cursor = 0 // 时间游标
|
||||
|
||||
// 第一屏:标题入场 + 所有卡片飞入归位
|
||||
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, 0)
|
||||
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, cursor)
|
||||
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)
|
||||
duration: 0.8, ease: 'back.out(1.2)',
|
||||
}, cursor + 0.1 + index * 0.08)
|
||||
})
|
||||
cursor += 1
|
||||
|
||||
// 后续屏:逐张聚焦
|
||||
// 后续屏:逐张聚焦,每步 duration=1 填满整步
|
||||
for (let focus = 0; focus < totalCards; focus++) {
|
||||
const pos = 1.5 + focus
|
||||
cardElements.value.forEach((card, index) => {
|
||||
if (!card) return
|
||||
tl.to(card, {
|
||||
scale: index === focus ? 1.05 : 0.85,
|
||||
autoAlpha: index === focus ? 1 : 0.4,
|
||||
duration: 0.6, ease: 'power2.out',
|
||||
}, pos)
|
||||
if (index === focus) {
|
||||
tl.call(() => handleCardHover(focus, true), [], pos)
|
||||
} else {
|
||||
tl.call(() => handleCardHover(index, false), [], pos)
|
||||
}
|
||||
duration: 0.8, ease: 'power2.out',
|
||||
}, cursor)
|
||||
tl.call(() => handleCardHover(index, index === focus), [], cursor + 0.2)
|
||||
})
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
// 最后:恢复正常
|
||||
const resetPos = 1.5 + totalCards
|
||||
// 最后一屏:恢复正常
|
||||
cardElements.value.forEach((card, index) => {
|
||||
if (!card) return
|
||||
tl.to(card, { scale: 1, autoAlpha: 1, duration: 0.5, ease: 'power2.out' }, resetPos)
|
||||
tl.call(() => handleCardHover(index, false), [], resetPos)
|
||||
tl.to(card, { scale: 1, autoAlpha: 1, duration: 0.6, ease: 'power2.out' }, cursor)
|
||||
tl.call(() => handleCardHover(index, false), [], cursor)
|
||||
})
|
||||
cursor += 1
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: rootRef.value,
|
||||
start: 'top top',
|
||||
end: () => `+=${(totalCards + 3) * window.innerHeight}`,
|
||||
end: () => `+=${cursor * window.innerHeight}`,
|
||||
pin: true,
|
||||
scrub: 0.5,
|
||||
anticipatePin: 1,
|
||||
@@ -278,6 +276,7 @@ onUnmounted(() => {
|
||||
<style scoped>
|
||||
.service-card {
|
||||
perspective: 1000px;
|
||||
height: clamp(260px, 38vh, 400px);
|
||||
}
|
||||
|
||||
.card-face {
|
||||
@@ -288,4 +287,11 @@ onUnmounted(() => {
|
||||
.card-back {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
/* 移动端单列时卡片更高 */
|
||||
@media (max-width: 767px) {
|
||||
.service-card {
|
||||
height: clamp(300px, 50vh, 420px);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -200,27 +200,30 @@ onMounted(async () => {
|
||||
gsap.set(el, { autoAlpha: 0, scale: 0 })
|
||||
})
|
||||
|
||||
// 统一时间线:入场 + pin 旋转
|
||||
// 统一时间线:入场 + pin 旋转,无空白
|
||||
const tl = gsap.timeline()
|
||||
let cursor = 0
|
||||
|
||||
// 第一屏:内容入场 + 技术标签散开
|
||||
tl.to(contentRef.value, { autoAlpha: 1, x: 0, duration: 0.8, ease: 'power3.out' }, 0)
|
||||
tl.to(contentRef.value, { autoAlpha: 1, x: 0, duration: 0.8, ease: 'power3.out' }, cursor)
|
||||
techElements.value.forEach((el, index) => {
|
||||
if (!el) return
|
||||
tl.to(el, {
|
||||
autoAlpha: 1, scale: 1, duration: 0.8, ease: 'back.out(2)',
|
||||
}, 0.3 + index * 0.05)
|
||||
}, cursor + 0.1 + index * 0.05)
|
||||
})
|
||||
cursor += 1
|
||||
|
||||
// 后续屏:球体旋转一圈
|
||||
tl.to(autoRotation.value, {
|
||||
y: 360, duration: 3, ease: 'none', onUpdate: updatePositions,
|
||||
}, 1.5)
|
||||
y: 360, duration: 2, ease: 'none', onUpdate: updatePositions,
|
||||
}, cursor)
|
||||
cursor += 2
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: rootRef.value,
|
||||
start: 'top top',
|
||||
end: () => `+=${window.innerHeight * 3}`,
|
||||
end: () => `+=${cursor * window.innerHeight}`,
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
anticipatePin: 1,
|
||||
|
||||
@@ -233,33 +233,35 @@ onMounted(async () => {
|
||||
gsap.set(card, { autoAlpha: 0, scale: 0.5 })
|
||||
})
|
||||
|
||||
// 统一时间线:入场 + pin 滚动切换
|
||||
// 统一时间线:入场 + pin 滚动切换,无空白
|
||||
const totalCards = testimonials.length
|
||||
const tl = gsap.timeline()
|
||||
let cursor = 0
|
||||
|
||||
// 第一屏:标题入场 + 第一张卡片出现
|
||||
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)
|
||||
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, cursor)
|
||||
tl.to(cardElements.value[0], { autoAlpha: 1, scale: 1, duration: 0.8, ease: 'back.out(1.5)' }, cursor + 0.2)
|
||||
tl.call(() => { currentIndex.value = 0 }, [], cursor + 0.2)
|
||||
cursor += 1
|
||||
|
||||
// 后续屏:滚动切换卡片,每屏切一张
|
||||
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)
|
||||
// 新卡片放大淡入
|
||||
}, cursor)
|
||||
// 新卡片放大淡入(紧接淡出开始,略有重叠)
|
||||
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)
|
||||
}, cursor + 0.2)
|
||||
tl.call(() => { currentIndex.value = i }, [], cursor + 0.2)
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: rootRef.value,
|
||||
start: 'top top',
|
||||
end: () => `+=${totalCards * window.innerHeight}`,
|
||||
end: () => `+=${cursor * window.innerHeight}`,
|
||||
pin: true,
|
||||
scrub: 0.5,
|
||||
anticipatePin: 1,
|
||||
|
||||
@@ -171,11 +171,13 @@ onMounted(async () => {
|
||||
gsap.set(card, { autoAlpha: 0, y: 60, scale: 0.9 })
|
||||
})
|
||||
|
||||
// 统一时间线
|
||||
// 统一时间线:无空白
|
||||
const tl = gsap.timeline()
|
||||
let cursor = 0
|
||||
|
||||
// 第一屏:标题入场
|
||||
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, 0)
|
||||
tl.to(headerRef.value, { autoAlpha: 1, y: 0, duration: 0.8, ease: 'power3.out' }, cursor)
|
||||
cursor += 1
|
||||
|
||||
// 分批展示卡片:每批 2 个,逐批出现
|
||||
const totalCards = updates.length
|
||||
@@ -183,7 +185,6 @@ onMounted(async () => {
|
||||
const batches = Math.ceil(totalCards / batchSize)
|
||||
|
||||
for (let batch = 0; batch < batches; batch++) {
|
||||
const pos = 0.8 + batch
|
||||
const start = batch * batchSize
|
||||
const end = Math.min(start + batchSize, totalCards)
|
||||
|
||||
@@ -191,14 +192,15 @@ onMounted(async () => {
|
||||
tl.to(cardElements.value[i], {
|
||||
autoAlpha: 1, y: 0, scale: 1,
|
||||
duration: 0.7, ease: 'power3.out',
|
||||
}, pos + (i - start) * 0.12)
|
||||
}, cursor + (i - start) * 0.12)
|
||||
}
|
||||
cursor += 1
|
||||
}
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: rootRef.value,
|
||||
start: 'top top',
|
||||
end: () => `+=${(batches + 1) * window.innerHeight}`,
|
||||
end: () => `+=${cursor * window.innerHeight}`,
|
||||
pin: true,
|
||||
scrub: 0.5,
|
||||
anticipatePin: 1,
|
||||
|
||||
Reference in New Issue
Block a user