Files
arcticnewone/app/components/home/HomeHero.vue
T
virtheart be0bf01845 feat(i18n): 添加首页英雄区域的多语言支持
为首页英雄区域添加中英文翻译内容,包括服务列表、描述文本和按钮文字
同时优化AppLogo组件的动画安全检查和HomeHero组件的布局与动画效果
2026-04-27 17:51:00 +08:00

249 lines
10 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 { useNuxtApp } from '#app'
import { useI18n } from 'vue-i18n'
import gsap from 'gsap'
const { t } = useI18n()
// 定义背景画廊与主视图绑定的总滚动距离(像素),数值越大,用户向下滚动时 Hero 面板缩放的过程就越长
const heroPinDistance = 1200
const heroGalleryColumns = [
{ side: 'left', images: ['1018', '1015', '1024', '1039'].map(id => `https://picsum.photos/id/${id}/900/1200`) },
{ side: 'right', images: ['1043', '1050', '1067', '1074'].map(id => `https://picsum.photos/id/${id}/900/1200`) },
] as const
const heroStageRef = ref<HTMLElement | null>(null)
const heroRef = ref<HTMLElement | null>(null)
const heroPanelRef = ref<HTMLElement | null>(null)
const brandNameRef = ref<HTMLElement | null>(null)
const studioNameRef = ref<HTMLElement | null>(null)
let cleanupHeroScroll: (() => void) | null = null
// 暴露供外部(如 index.vue)使用的异步播放方法
const play = () => new Promise<void>((resolve) => {
if (!heroRef.value) return resolve()
let eventDispatched = false
// 获取所有需要执行动画的字符和 TM 标志 DOM 节点
const brandChars = gsap.utils.toArray('.hero-brand-char', brandNameRef.value)
const studioChars = gsap.utils.toArray('.hero-studio-char', studioNameRef.value)
const tm = brandNameRef.value?.querySelector('.hero-brand-tm')
// 初始隐藏底部文字:通过 autoAlpha (opacity + visibility) 控制,不仅下移而且完全不可见
gsap.set([...brandChars, ...studioChars], { yPercent: 110, autoAlpha: 0 })
if (tm) gsap.set(tm, { autoAlpha: 0 })
// 执行入场动画:将 Hero 整体容器从屏幕下方(yPercent: 100)滑入屏幕(yPercent: 0
gsap.to(heroRef.value, {
yPercent: 0,
duration: 1.2,
ease: 'power3.out',
onUpdate() {
// 当上滑动画进度超过 60% 时,触发全局事件,通知导航栏等其他组件可以开始它们自己的进场动画了
if (!eventDispatched && this.progress() > 0.6) {
eventDispatched = true
window.dispatchEvent(new CustomEvent('hero-intro-done'))
// 导航栏动画大约 1.4s,我们在导航栏入场即将结束时开始播放底部文字动画
gsap.timeline({ delay: 1.0 })
.to([...brandChars, ...studioChars], {
yPercent: 0,
autoAlpha: 1, // 恢复透明度和可见性
duration: 1.2,
stagger: 0.08,
ease: 'power3.out'
})
.to(tm, {
autoAlpha: 1,
duration: 0.6,
ease: 'power2.out'
}, '-=0.4')
}
},
onComplete: resolve,
})
})
// 设置页面向下滚动时的视差缩放逻辑(绑定到 Lenis 的平滑滚动数值上)
const setupHeroScroll = () => {
if (!heroStageRef.value || !heroPanelRef.value) return
const { $lenis } = useNuxtApp() as { $lenis?: { scroll: number } }
const stageTop = heroStageRef.value.offsetTop
let renderedScale = -1
// 初始化 Hero 面板的缩放状态和 3D 硬件加速
gsap.set(heroPanelRef.value, { scale: 1, force3D: true, transformOrigin: 'center center' })
// 核心的每帧更新逻辑
const updateHeroPanelScale = () => {
const exitDistance = window.innerHeight
const totalDistance = heroPinDistance + exitDistance
// 如果启用了 Lenis,则读取其平滑的插值滚动值,否则回退到浏览器原生的 window.scrollY
const currentScroll = $lenis ? $lenis.scroll : window.scrollY
// 计算当前元素在滚动容器内走过的有效距离
const currentDistance = gsap.utils.clamp(0, totalDistance, currentScroll - stageTop)
// 根据滚动距离,分两个阶段计算 Hero 面板的缩放比例 (scale):
// 阶段一:在设置的 1200px 距离内,面板从 1.0 缩小到 0.65
// 阶段二:超过 1200px 后,跟随页面继续向下滚出的过程,面板从 0.65 继续缩小到 0.18
const newScale = currentDistance <= heroPinDistance
? gsap.utils.interpolate(1, 0.65, currentDistance / heroPinDistance)
: gsap.utils.interpolate(0.65, 0.18, (currentDistance - heroPinDistance) / exitDistance)
// 只有当缩放值的变化超过肉眼/渲染的有效阈值(0.001)时,才真正触发 DOM 更新,节省性能
if (Math.abs(renderedScale - newScale) > 0.001) {
renderedScale = newScale
gsap.set(heroPanelRef.value, { scale: newScale })
}
}
// 立即执行一次计算初始状态
updateHeroPanelScale()
// 将更新函数绑定到 gsap 的全局 ticker(类似于 requestAnimationFrame),保证在每一帧平滑读取数据并更新
gsap.ticker.add(updateHeroPanelScale)
// 返回清理函数
cleanupHeroScroll = () => gsap.ticker.remove(updateHeroPanelScale)
}
onMounted(() => {
// 组件挂载时,先将整个 Hero 强行置于屏幕下方,等待外部调用 `play()` 才滑入
if (heroRef.value) gsap.set(heroRef.value, { yPercent: 100 })
// 初始化滚动监听逻辑
setupHeroScroll()
})
onBeforeUnmount(() => {
cleanupHeroScroll?.()
})
defineExpose({ play })
</script>
<template>
<section
ref="heroStageRef"
class="relative bg-black"
:style="{ height: `calc(100svh + ${heroPinDistance}px)` }"
>
<section ref="heroRef" class="sticky top-0 h-[100svh] overflow-hidden bg-[#111]" v-once>
<div class="absolute inset-0 overflow-hidden bg-black" aria-hidden="true">
<div
v-for="column in heroGalleryColumns"
:key="column.side"
class="absolute -bottom-10 -top-10 w-[49vw] overflow-hidden"
:class="column.side === 'left' ? 'left-0' : 'right-0'"
>
<!-- 复制两份图片组配合 translateY(-50%) 做无缝循环 -->
<div
class="hero-gallery-track will-change-transform"
:class="{ 'hero-gallery-track-slow': column.side === 'right' }"
>
<div v-for="copy in 2" :key="`${column.side}-copy-${copy}`" class="grid gap-9 pb-9">
<img
v-for="image in column.images"
:key="`${column.side}-${copy}-${image}`"
class="block w-full rounded-none object-cover h-[clamp(260px,42vh,430px)]"
:src="image"
alt=""
decoding="async"
loading="eager"
/>
</div>
</div>
</div>
</div>
<div ref="heroPanelRef" class="absolute inset-0 flex flex-col justify-between items-center bg-white text-black will-change-transform pt-28 pb-10 md:pt-36 md:pb-12 overflow-hidden">
<div class="flex flex-col justify-between w-page-container max-w-[calc(100%-2rem)] mx-auto px-4 h-full">
<!-- Top Section -->
<div class="flex flex-col md:flex-row justify-between w-full">
<!-- Top Left: Services List -->
<div class="flex flex-col gap-2 font-bold text-base md:text-[1rem] tracking-tight leading-[1.5]">
<p>{{ t('hero.services.webDesign') }}</p>
<p>{{ t('hero.services.socialMedia') }}</p>
<p>{{ t('hero.services.marketing') }}</p>
<p>{{ t('hero.services.development') }}</p>
<p>{{ t('hero.services.seo') }}</p>
</div>
<!-- Top Right: Description and Button -->
<div class="flex flex-col gap-8 max-w-[22rem] mt-10 md:mt-0 text-left">
<p class="font-bold text-[1rem] leading-[1.5] tracking-tight">
{{ t('hero.description') }}
</p>
<div>
<button class="bg-black text-white px-7 py-3.5 rounded-[2rem] font-semibold text-base transition-transform hover:scale-105 shadow-[0_15px_30px_-5px_rgba(0,0,0,0.3)]">
{{ t('hero.letsTalk') }}
</button>
</div>
</div>
</div>
<!-- Middle Section: 4 small dots -->
<div class="flex justify-between w-full items-start flex-1 mt-16 md:mt-24 mb-6 md:mb-0">
<div class="w-2.5 h-2.5 bg-black rounded-full"></div>
<div class="w-2.5 h-2.5 bg-black rounded-full"></div>
<div class="w-2.5 h-2.5 bg-black rounded-full"></div>
<div class="w-2.5 h-2.5 bg-black rounded-full"></div>
</div>
<!-- Bottom Section -->
<div class="flex flex-col md:flex-row justify-between items-end w-full">
<!-- Bottom Left: Brand Name -->
<h1 ref="brandNameRef" class="text-[11vw] md:text-[8vw] font-bold leading-[0.8] tracking-tighter m-0 -ml-1 whitespace-nowrap pt-4 pb-2">
<span class="relative inline-flex overflow-visible">
<span
v-for="(letter, index) in t('app.brand').split('')"
:key="`brand-${index}`"
class="relative inline-block overflow-hidden"
>
<span class="hero-brand-char inline-block will-change-transform opacity-0 invisible pb-4">{{ letter }}</span>
</span>
<span class="hero-brand-tm text-[0.45em] font-black align-super ml-[0.1em] opacity-0 invisible relative -top-[0.2em]"></span>
</span>
</h1>
<!-- Bottom Right: Studio -->
<div class="flex flex-col items-start md:items-end mt-4 md:mt-0">
<p class="text-xs md:text-sm font-semibold text-gray-500 mb-2 md:mb-6 mr-1">© {{ new Date().getFullYear() }} {{ t('app.brand') }} {{ t('nav.studio') }}</p>
<h2 ref="studioNameRef" class="text-[11vw] md:text-[8vw] font-bold leading-[0.8] tracking-tighter m-0 -mr-1 whitespace-nowrap pt-4 pb-2">
<span class="relative inline-flex overflow-hidden">
<span
v-for="(letter, index) in t('nav.studio').split('')"
:key="`studio-${index}`"
class="relative inline-block overflow-hidden"
>
<span class="hero-studio-char inline-block will-change-transform opacity-0 invisible pb-4">{{ letter }}</span>
</span>
</span>
</h2>
</div>
</div>
</div>
</div>
</section>
</section>
</template>
<style scoped>
.hero-gallery-track {
animation: hero-gallery-scroll 28s linear infinite;
}
.hero-gallery-track-slow {
animation-duration: 42s;
}
@keyframes hero-gallery-scroll {
to {
transform: translateY(-50%);
}
}
</style>