Files
arcticnewone/app/components/home/HomeHero.vue
T
virtheart 1bc881994f refactor(home): 将首页组件拆分为独立模块并优化代码结构
将首页的加载动画、介绍动画和英雄区域拆分为独立的组件文件
移除index.vue中冗余的样式和逻辑,通过组件引用方式重构
优化代码组织结构,提高可维护性
2026-04-26 23:07:37 +08:00

229 lines
5.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { onMounted, onBeforeUnmount, ref } from 'vue'
import type Lenis from 'lenis'
import gsap from 'gsap'
const heroPinDistance = 1200
const heroGalleryColumns = [
{
side: 'left',
images: [
'https://picsum.photos/id/1018/900/1200',
'https://picsum.photos/id/1015/900/1200',
'https://picsum.photos/id/1024/900/1200',
'https://picsum.photos/id/1039/900/1200',
],
},
{
side: 'right',
images: [
'https://picsum.photos/id/1043/900/1200',
'https://picsum.photos/id/1050/900/1200',
'https://picsum.photos/id/1067/900/1200',
'https://picsum.photos/id/1074/900/1200',
],
},
] as const
const heroStageRef = ref<HTMLElement | null>(null)
const heroRef = ref<HTMLElement | null>(null)
const heroPanelRef = ref<HTMLElement | null>(null)
let cleanupHeroScroll: (() => void) | null = null
onMounted(() => {
const heroStageElement = heroStageRef.value
const heroPanelElement = heroPanelRef.value
if (!heroStageElement || !heroPanelElement) {
return
}
const { $lenis } = useNuxtApp() as ReturnType<typeof useNuxtApp> & { $lenis?: Lenis }
const pinEndScale = 0.65
const finalScale = 0.18
const getExitDistance = () => window.innerHeight
const setupHeroScroll = () => {
cleanupHeroScroll?.()
gsap.set(heroPanelElement, {
scale: 1,
force3D: true,
transformOrigin: 'center center',
})
let renderedScale = -1
const setHeroPanelScale = (scale: number) => {
if (Math.abs(renderedScale - scale) < 0.001) {
return
}
renderedScale = scale
gsap.set(heroPanelElement, { scale })
}
const updateHeroPanelScale = () => {
const exitDistance = getExitDistance()
const totalDistance = heroPinDistance + exitDistance
// Lenis 存在时只读 Lenis 的平滑滚动值,避免向上滚时 window.scrollY 和 Lenis 值互相打架。
const currentScroll = $lenis ? $lenis.scroll : window.scrollY
const currentDistance = gsap.utils.clamp(0, totalDistance, currentScroll - heroStageElement.offsetTop)
if (currentDistance <= heroPinDistance) {
setHeroPanelScale(gsap.utils.interpolate(1, pinEndScale, currentDistance / heroPinDistance))
return
}
setHeroPanelScale(
gsap.utils.interpolate(
pinEndScale,
finalScale,
(currentDistance - heroPinDistance) / exitDistance,
),
)
}
// CSS sticky 负责固定;ticker 每帧读取当前滚动距离,避免依赖 scroll/ScrollTrigger 事件链。
updateHeroPanelScale()
gsap.ticker.add(updateHeroPanelScale)
cleanupHeroScroll = () => {
gsap.ticker.remove(updateHeroPanelScale)
}
}
setupHeroScroll()
})
onBeforeUnmount(() => {
cleanupHeroScroll?.()
cleanupHeroScroll = null
})
defineExpose({
heroStageRef,
heroRef,
})
</script>
<template>
<section
ref="heroStageRef"
class="hero-stage"
:style="{ '--hero-pin-distance': `${heroPinDistance}px` }"
>
<section ref="heroRef" class="hero-section" v-once>
<div class="hero-backdrop" aria-hidden="true">
<div
v-for="column in heroGalleryColumns"
:key="column.side"
class="hero-gallery"
:class="`hero-gallery-${column.side}`"
>
<!-- 复制两份图片组配合 translateY(-50%) 做无缝循环 -->
<div
class="hero-gallery-track"
:class="{ 'hero-gallery-track-slow': column.side === 'right' }"
>
<div v-for="copy in 2" :key="`${column.side}-copy-${copy}`" class="hero-gallery-group">
<img
v-for="image in column.images"
:key="`${column.side}-${copy}-${image}`"
class="hero-gallery-image"
:src="image"
alt=""
decoding="async"
loading="eager"
/>
</div>
</div>
</div>
</div>
<div ref="heroPanelRef" class="hero-panel">
<p>Hero Section</p>
</div>
</section>
</section>
</template>
<style scoped>
.hero-stage {
position: relative;
height: calc(100svh + var(--hero-pin-distance));
background: #000;
}
.hero-section {
position: sticky;
top: 0;
height: 100svh;
overflow: hidden;
background: #111;
}
.hero-backdrop {
position: absolute;
inset: 0;
overflow: hidden;
background: #000;
}
.hero-gallery {
position: absolute;
top: -40px;
bottom: -40px;
width: 49vw;
overflow: hidden;
}
.hero-gallery-left {
left: 0;
}
.hero-gallery-right {
right: 0;
}
.hero-gallery-track {
will-change: transform;
animation: hero-gallery-scroll 28s linear infinite;
}
.hero-gallery-track-slow {
animation-duration: 42s;
}
.hero-gallery-group {
display: grid;
gap: 36px;
padding-bottom: 36px;
}
.hero-gallery-image {
display: block;
width: 100%;
height: clamp(260px, 42vh, 430px);
border-radius: 0;
object-fit: cover;
}
@keyframes hero-gallery-scroll {
to {
transform: translateY(-50%);
}
}
.hero-panel {
position: absolute;
inset: 0;
display: grid;
place-items: center;
background: #fff;
font-size: 48px;
will-change: transform;
}
</style>