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:
陈春
2026-06-03 20:03:36 +08:00
parent 02323fd426
commit e94f7d92e9
6 changed files with 80 additions and 66 deletions
+25 -19
View File
@@ -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>