diff --git a/app/pages/index.vue b/app/pages/index.vue index 6963de9..c2be7a1 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -4,13 +4,22 @@ import { useI18n } from 'vue-i18n' import type Lenis from 'lenis' import gsap from 'gsap' import { ScrollTrigger } from 'gsap/ScrollTrigger' +import { useUserStore } from '~/stores/user' gsap.registerPlugin(ScrollTrigger) const { t } = useI18n() const introTitle = 'ArcticNewOne' const introLetters = introTitle.split('') +const heroPinDistance = 1200 const isIntroActive = ref(true) +const userStore = useUserStore() +const demoUserName = computed(() => userStore.displayName) +const demoUserRole = computed(() => userStore.role) + +if (!userStore.isLoggedIn) { + userStore.loadDemoUser() +} // 让首屏入场的锁滚动在 SSR/首帧就生效,避免等 onMounted 才隐藏滚动条。 useHead({ @@ -42,15 +51,16 @@ const heroGalleryColumns = [ ] as const const pageRef = ref(null) const introRef = ref(null) +const heroStageRef = ref(null) const heroRef = ref(null) const heroPanelRef = ref(null) -const nextSectionRef = ref(null) let heroAnimationContext: gsap.Context | null = null let removeLoadListener: (() => void) | null = null let restorePageScroll: (() => void) | null = null +let cleanupHeroScroll: (() => void) | null = null onMounted(() => { - if (!pageRef.value || !introRef.value || !heroRef.value || !nextSectionRef.value) { + if (!pageRef.value || !introRef.value || !heroStageRef.value || !heroRef.value || !heroPanelRef.value) { return } @@ -58,46 +68,58 @@ onMounted(() => { const topHeader = document.querySelector('.site-header-top') heroAnimationContext = gsap.context(() => { - // pinDistance 控制 Hero 固定阶段的滚动长度;scrollScrub 控制滚动追随的柔和程度。 - const pinDistance = 1200 - const scrollScrub = 0.65 + const pinEndScale = 0.65 + const finalScale = 0.18 + const getExitDistance = () => window.innerHeight const setupHeroScroll = () => { + cleanupHeroScroll?.() + gsap.set(heroPanelRef.value, { scale: 1, + force3D: true, transformOrigin: 'center center', }) - // 第一段:Hero 停在屏幕中,前景白色面板先缩小,露出背后的图片列。 - gsap.timeline({ - scrollTrigger: { - trigger: heroRef.value, - start: 'top top', - end: `+=${pinDistance}`, - scrub: scrollScrub, - pin: true, - anticipatePin: 1, - }, - }) - .to(heroPanelRef.value, { - scale: 0.65, - ease: 'none', - }) + const setHeroPanelScale = (scale: number) => { + if (!heroPanelRef.value) { + return + } - // 第二段:取消 pin 后,下一个板块开始进入,白色面板继续缩小。 - gsap.fromTo(heroPanelRef.value, { - scale: 0.65, - }, { - scale: 0.18, - ease: 'none', - immediateRender: false, - scrollTrigger: { - trigger: nextSectionRef.value, - start: 'top bottom', - end: 'top top', - scrub: scrollScrub, - }, - }) + heroPanelRef.value.style.transform = `scale(${scale})` + } + + const updateHeroPanelScale = () => { + if (!heroStageRef.value) { + return + } + + const exitDistance = getExitDistance() + const totalDistance = heroPinDistance + exitDistance + const currentScroll = Math.max(window.scrollY, $lenis?.scroll ?? 0) + const currentDistance = gsap.utils.clamp(0, totalDistance, currentScroll - heroStageRef.value.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) + } } const playIntro = () => { @@ -107,13 +129,11 @@ onMounted(() => { const introTimeline = gsap.timeline({ defaults: { ease: 'power3.out' }, onComplete: () => { - gsap.set(heroRef.value, { clearProps: 'transform' }) gsap.set(introRef.value, { display: 'none' }) - setupHeroScroll() - ScrollTrigger.refresh() - isIntroActive.value = false + gsap.set(heroRef.value, { clearProps: 'transform' }) restorePageScroll?.() restorePageScroll = null + ScrollTrigger.refresh() }, }) @@ -123,13 +143,12 @@ onMounted(() => { duration: 0.75, stagger: 0.055, }) + .addLabel('introOut', '+=0.35') .to('.hero-intro-title', { - y: -28, autoAlpha: 0, - duration: 0.45, - ease: 'power2.in', - }, '+=0.35') - .addLabel('introOut') + duration: 1.05, + ease: 'power4.inOut', + }, 'introOut') .to(introRef.value, { yPercent: -100, duration: 1.05, @@ -154,21 +173,30 @@ onMounted(() => { const previousHtmlOverflow = document.documentElement.style.overflow const previousBodyOverflow = document.body.style.overflow - $lenis?.scrollTo(0, { immediate: true }) - $lenis?.stop() - document.documentElement.style.overflow = 'hidden' - document.body.style.overflow = 'hidden' - restorePageScroll = () => { + const releasePageScroll = () => { + isIntroActive.value = false + document.documentElement.classList.remove('is-home-intro-active') document.documentElement.style.overflow = previousHtmlOverflow document.body.style.overflow = previousBodyOverflow $lenis?.start() } - gsap.set(heroRef.value, { yPercent: 100 }) + document.documentElement.classList.add('is-home-intro-active') + document.documentElement.style.overflow = 'hidden' + document.body.style.overflow = 'hidden' + $lenis?.scrollTo(0, { immediate: true }) + $lenis?.stop() + restorePageScroll = releasePageScroll + + // 每次播放前都重置首屏状态,避免热更新或页面返回时残留上一次的 inline style。 + gsap.set(introRef.value, { display: 'grid', yPercent: 0, autoAlpha: 1 }) + gsap.set('.hero-intro-title', { y: 0, autoAlpha: 1 }) gsap.set('.hero-intro-char', { yPercent: 110 }) + gsap.set(heroRef.value, { yPercent: 100 }) if (topHeader) { gsap.set(topHeader, { yPercent: -140, autoAlpha: 0 }) } + setupHeroScroll() if (document.readyState === 'complete') { requestAnimationFrame(playIntro) @@ -182,6 +210,8 @@ onMounted(() => { onBeforeUnmount(() => { isIntroActive.value = false + cleanupHeroScroll?.() + cleanupHeroScroll = null removeLoadListener?.() removeLoadListener = null restorePageScroll?.() @@ -193,7 +223,12 @@ onBeforeUnmount(() => {