From c1ab766c4919488c98062a0b97a60bb956e99a65 Mon Sep 17 00:00:00 2001 From: chenchun Date: Sun, 26 Apr 2026 21:55:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E9=A1=B5=E9=9D=A2?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=8A=A8=E7=94=BB=E5=B9=B6=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E9=A6=96=E5=B1=8F=E5=85=A5=E5=9C=BA=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在页面加载时添加加载进度条动画 - 重构首屏入场动画逻辑,分离加载层和品牌展示层 - 优化滚动控制逻辑,避免 class 和 inline style 冲突 - 添加动画状态管理变量,防止重复触发 --- app/layouts/default.vue | 4 +- app/pages/index.vue | 255 ++++++++++++++++++++++++++++++++-------- 2 files changed, 211 insertions(+), 48 deletions(-) diff --git a/app/layouts/default.vue b/app/layouts/default.vue index 9927025..1a080ad 100644 --- a/app/layouts/default.vue +++ b/app/layouts/default.vue @@ -10,6 +10,8 @@ import ArcticNewOneHeader from '~/components/layouts/ArcticNewOneHeader.vue'; - + + + diff --git a/app/pages/index.vue b/app/pages/index.vue index c2be7a1..5fe7d2b 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -50,7 +50,11 @@ const heroGalleryColumns = [ }, ] as const const pageRef = ref(null) +const loaderRef = ref(null) +const loaderBarRef = ref(null) const introRef = ref(null) +const introTitleRef = ref(null) +const introCharRefs = ref([]) const heroStageRef = ref(null) const heroRef = ref(null) const heroPanelRef = ref(null) @@ -58,9 +62,42 @@ let heroAnimationContext: gsap.Context | null = null let removeLoadListener: (() => void) | null = null let restorePageScroll: (() => void) | null = null let cleanupHeroScroll: (() => void) | null = null +let hasIntroStarted = false +let loaderTween: gsap.core.Tween | null = null +let introTimeline: gsap.core.Timeline | null = null +let introStartTimer: ReturnType | null = null + +const setHomeIntroActive = (active: boolean) => { + isIntroActive.value = active + document.documentElement.classList.toggle('is-home-intro-active', active) +} onMounted(() => { - if (!pageRef.value || !introRef.value || !heroStageRef.value || !heroRef.value || !heroPanelRef.value) { + const pageElement = pageRef.value + const loaderElement = loaderRef.value + const loaderBarElement = loaderBarRef.value + const introElement = introRef.value + const introTitleElement = introTitleRef.value + const heroStageElement = heroStageRef.value + const heroElement = heroRef.value + const heroPanelElement = heroPanelRef.value + + if ( + !pageElement + || !loaderElement + || !loaderBarElement + || !introElement + || !introTitleElement + || !heroStageElement + || !heroElement + || !heroPanelElement + ) { + return + } + + const introCharElements = gsap.utils.toArray('.hero-intro-char', introElement) + + if (introCharElements.length === 0) { return } @@ -75,29 +112,29 @@ onMounted(() => { const setupHeroScroll = () => { cleanupHeroScroll?.() - gsap.set(heroPanelRef.value, { + gsap.set(heroPanelElement, { scale: 1, force3D: true, transformOrigin: 'center center', }) + let renderedScale = -1 + const setHeroPanelScale = (scale: number) => { - if (!heroPanelRef.value) { + if (Math.abs(renderedScale - scale) < 0.001) { return } - heroPanelRef.value.style.transform = `scale(${scale})` + renderedScale = scale + gsap.set(heroPanelElement, { 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) + // 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)) @@ -123,38 +160,85 @@ onMounted(() => { } const playIntro = () => { + if (hasIntroStarted) { + return + } + + hasIntroStarted = true removeLoadListener?.() removeLoadListener = null + if (introStartTimer) { + window.clearTimeout(introStartTimer) + introStartTimer = null + } + loaderTween?.kill() + loaderTween = null + introTimeline?.kill() - const introTimeline = gsap.timeline({ + introTimeline = gsap.timeline({ defaults: { ease: 'power3.out' }, onComplete: () => { - gsap.set(introRef.value, { display: 'none' }) - gsap.set(heroRef.value, { clearProps: 'transform' }) + gsap.set(introElement, { display: 'none' }) + gsap.set(heroElement, { clearProps: 'transform' }) restorePageScroll?.() restorePageScroll = null ScrollTrigger.refresh() + introTimeline = null }, }) introTimeline - .to('.hero-intro-char', { + .to(loaderBarElement, { + scaleX: 1, + duration: 0.25, + ease: 'power2.out', + }) + .to(loaderElement, { + yPercent: -100, + duration: 0.75, + ease: 'power4.inOut', + }) + .set(loaderElement, { + display: 'none', + }) + .set(introElement, { + display: 'grid', yPercent: 0, + autoAlpha: 1, + }) + .set(introTitleElement, { + y: 0, + autoAlpha: 1, + }) + .set('.hero-intro-tm', { + autoAlpha: 0, + }) + .fromTo(gsap.utils.toArray('.hero-intro-char', introElement), { + yPercent: 110, + y: 0, + }, { + yPercent: 0, + y: 0, duration: 0.75, stagger: 0.055, }) + .to('.hero-intro-tm', { + autoAlpha: 1, + duration: 0.4, + ease: 'power2.out', + }) .addLabel('introOut', '+=0.35') - .to('.hero-intro-title', { + .to(introTitleElement, { autoAlpha: 0, duration: 1.05, ease: 'power4.inOut', }, 'introOut') - .to(introRef.value, { + .to(introElement, { yPercent: -100, duration: 1.05, ease: 'power4.inOut', }, 'introOut') - .to(heroRef.value, { + .to(heroElement, { yPercent: 0, duration: 1.05, ease: 'power4.inOut', @@ -169,47 +253,70 @@ onMounted(() => { } } - // 首屏入场期间锁住滚动,等 window load 后再开始字母动画,避免资源未就绪时露出 Hero。 - const previousHtmlOverflow = document.documentElement.style.overflow - const previousBodyOverflow = document.body.style.overflow - const releasePageScroll = () => { - isIntroActive.value = false - document.documentElement.classList.remove('is-home-intro-active') - document.documentElement.style.overflow = previousHtmlOverflow - document.body.style.overflow = previousBodyOverflow + setHomeIntroActive(false) $lenis?.start() } - document.documentElement.classList.add('is-home-intro-active') - document.documentElement.style.overflow = 'hidden' - document.body.style.overflow = 'hidden' + // 首屏入场期间只通过 html class 锁滚动,避免 class 和 inline overflow 两套状态互相覆盖。 + setHomeIntroActive(true) $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 }) + // Loading 层先接管首屏;真正的品牌入场等 window.load 完成后再开始。 + gsap.set(loaderElement, { display: 'grid', yPercent: 0, autoAlpha: 1 }) + gsap.set(loaderBarElement, { scaleX: 0.18, transformOrigin: 'left center' }) + gsap.set(introElement, { display: 'grid', yPercent: 0, autoAlpha: 0 }) + gsap.set(introTitleElement, { y: 0, autoAlpha: 1 }) + // 强制清除 CSS 中的 translateY 像素值,避免被 GSAP 解析为固定 y 像素导致一直不可见 + gsap.set(introCharElements, { y: 0, yPercent: 110 }) + gsap.set('.hero-intro-tm', { autoAlpha: 0 }) + gsap.set(heroElement, { yPercent: 100 }) if (topHeader) { gsap.set(topHeader, { yPercent: -140, autoAlpha: 0 }) } setupHeroScroll() + loaderTween = gsap.to(loaderBarElement, { + scaleX: 0.82, + duration: 1.2, + ease: 'power1.inOut', + repeat: -1, + yoyo: true, + }) + + const loaderStartedAt = window.performance.now() + const minLoaderDuration = 1200 + const queueIntro = () => { + const remainingTime = Math.max(0, minLoaderDuration - (window.performance.now() - loaderStartedAt)) + + introStartTimer = window.setTimeout(() => { + window.requestAnimationFrame(playIntro) + }, remainingTime) + } + if (document.readyState === 'complete') { - requestAnimationFrame(playIntro) + queueIntro() return } - window.addEventListener('load', playIntro, { once: true }) - removeLoadListener = () => window.removeEventListener('load', playIntro) - }, pageRef.value) + window.addEventListener('load', queueIntro, { once: true }) + removeLoadListener = () => window.removeEventListener('load', queueIntro) + }, pageElement) }) onBeforeUnmount(() => { - isIntroActive.value = false + setHomeIntroActive(false) + hasIntroStarted = false + if (introStartTimer) { + window.clearTimeout(introStartTimer) + introStartTimer = null + } + loaderTween?.kill() + loaderTween = null + introTimeline?.kill() + introTimeline = null cleanupHeroScroll?.() cleanupHeroScroll = null removeLoadListener?.() @@ -229,15 +336,26 @@ onBeforeUnmount(() => { :data-demo-user="demoUserName" :data-demo-user-role="demoUserRole" > + + @@ -289,6 +407,45 @@ onBeforeUnmount(() => {