Files
arcticnewone/app/components/home/HomeHero.vue
T
陈春 c9cf644ac5 chore: 添加构建推送脚本并更新项目配置
- 添加 build-and-push.sh 脚本用于自动化构建和推送 Docker 镜像
- 更新 .dockerignore 排除 pnpm-workspace.yaml
- 更新项目依赖和配置文件
2026-06-03 17:19:14 +08:00

352 lines
16 KiB
Vue
Executable File
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'
import AnimatedButton from '~/components/common/AnimatedButton.vue'
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)
const talkBtnRef = ref<HTMLElement | null>(null)
let cleanupHeroScroll: (() => void) | null = null
let talkBtnTl: gsap.core.Timeline | null = null
const initTalkButton = () => {
if (!talkBtnRef.value) return
const hoverBg = talkBtnRef.value.querySelector<HTMLElement>('.btn-bg-hover')
const origChars = gsap.utils.toArray<HTMLElement>('.btn-char-orig', talkBtnRef.value)
const cloneChars = gsap.utils.toArray<HTMLElement>('.btn-char-clone', talkBtnRef.value)
if (!hoverBg || !origChars.length || !cloneChars.length) return
gsap.set(hoverBg, { yPercent: 110 })
gsap.set(origChars, { yPercent: 0 })
gsap.set(cloneChars, { yPercent: 110 })
}
const onTalkHover = () => {
if (!talkBtnRef.value) return
const hoverBg = talkBtnRef.value.querySelector<HTMLElement>('.btn-bg-hover')
const origChars = gsap.utils.toArray<HTMLElement>('.btn-char-orig', talkBtnRef.value)
const cloneChars = gsap.utils.toArray<HTMLElement>('.btn-char-clone', talkBtnRef.value)
if (!hoverBg || !origChars.length || !cloneChars.length) return
talkBtnTl?.kill()
talkBtnTl = gsap.timeline({ defaults: { ease: 'power3.out' } })
talkBtnTl
.to(hoverBg, { yPercent: 0, duration: 0.42 }, 0)
.to(origChars, { yPercent: -115, duration: 0.42, stagger: 0.018 }, 0)
.to(cloneChars, { yPercent: 0, duration: 0.42, stagger: 0.018 }, 0)
}
const onTalkLeave = () => {
if (!talkBtnRef.value) return
const hoverBg = talkBtnRef.value.querySelector<HTMLElement>('.btn-bg-hover')
const origChars = gsap.utils.toArray<HTMLElement>('.btn-char-orig', talkBtnRef.value)
const cloneChars = gsap.utils.toArray<HTMLElement>('.btn-char-clone', talkBtnRef.value)
if (!hoverBg || !origChars.length || !cloneChars.length) return
talkBtnTl?.kill()
talkBtnTl = gsap.timeline({ defaults: { ease: 'power3.out' } })
talkBtnTl
.to(hoverBg, { yPercent: 110, duration: 0.42 }, 0)
.to(origChars, { yPercent: 0, duration: 0.42, stagger: 0.018 }, 0)
.to(cloneChars, { yPercent: 110, duration: 0.42, stagger: 0.018 }, 0)
}
// 暴露供外部(如 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')
// 获取所有需要统一从下浮现的其他文本节点(服务列表、描述、版权等)
const fadeUpItems = gsap.utils.toArray('.hero-fade-up', heroPanelRef.value)
// 初始隐藏底部大字文字
gsap.set([...brandChars, ...studioChars], { yPercent: 110, autoAlpha: 0 })
if (tm) gsap.set(tm, { autoAlpha: 0 })
// 初始隐藏需要向上浮现的小字文本节点
gsap.set(fadeUpItems, { y: 20, 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')
.to(fadeUpItems, {
y: 0,
autoAlpha: 1,
duration: 0.8,
ease: 'power3.out',
stagger: 0.04 // 稍微错开一点点时间,或者设为 0 完全同时
}, '-=0.6') // 让它们在大字动画快结束时出现
}
},
onComplete: resolve,
})
})
// 设置页面向下滚动时的视差缩放逻辑(绑定到 Lenis 的平滑滚动数值上)
onMounted(() => {
// 组件挂载时,先将整个 Hero 强行置于屏幕下方,等待外部调用 `play()` 才滑入
if (heroRef.value) gsap.set(heroRef.value, { yPercent: 100 })
// 初始化滚动监听逻辑
setupHeroScroll()
initTalkButton()
})
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)
}
// 设置页面向下滚动时的视差缩放逻辑(绑定到 Lenis 的平滑滚动数值上)
onBeforeUnmount(() => {
cleanupHeroScroll?.()
talkBtnTl?.kill()
})
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="lazy"
/>
</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-24 pb-8 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-sm md:text-[1rem] tracking-tight leading-[1.5]">
<p class="hero-fade-up will-change-transform">{{ t('hero.services.webDesign') }}</p>
<p class="hero-fade-up will-change-transform">{{ t('hero.services.socialMedia') }}</p>
<p class="hero-fade-up will-change-transform">{{ t('hero.services.marketing') }}</p>
<p class="hero-fade-up will-change-transform">{{ t('hero.services.development') }}</p>
<p class="hero-fade-up will-change-transform">{{ t('hero.services.seo') }}</p>
</div>
<!-- Top Right: Description and Button -->
<div class="flex flex-col gap-6 md:gap-8 max-w-[22rem] mt-8 md:mt-0 text-left">
<p class="hero-fade-up will-change-transform font-bold text-sm md:text-[1rem] leading-[1.5] tracking-tight">
{{ t('hero.description') }}
</p>
<div class="hero-fade-up will-change-transform">
<NuxtLink to="/contact">
<button
ref="talkBtnRef"
@mouseenter="onTalkHover"
@mouseleave="onTalkLeave"
class="relative overflow-hidden bg-black text-white px-6 py-3 md:px-7 md:py-3.5 rounded-[2rem] font-semibold text-sm md:text-[1rem] shadow-[0_15px_30px_-5px_rgba(0,0,0,0.3)] flex items-center justify-center group"
>
<!-- 默认黑色背景层防止文字动画时漏底 -->
<div class="absolute inset-0 bg-black rounded-[2rem]"></div>
<!-- Hover 时的灰色背景色块层 -->
<div
class="btn-bg-hover absolute inset-0 bg-[#333333] rounded-[2rem] will-change-transform"
></div>
<!-- 文本层 -->
<span class="relative inline-flex overflow-hidden z-10">
<span class="inline-flex">
<span
v-for="(char, index) in t('hero.letsTalk').split('')"
:key="`orig-${index}`"
class="btn-char-orig block will-change-transform"
v-html="char === ' ' ? '&nbsp;' : char"
></span>
</span>
<span class="absolute inset-0 inline-flex" aria-hidden="true">
<span
v-for="(char, index) in t('hero.letsTalk').split('')"
:key="`clone-${index}`"
class="btn-char-clone block will-change-transform"
v-html="char === ' ' ? '&nbsp;' : char"
></span>
</span>
</span>
</button>
</NuxtLink>
</div>
</div>
</div>
<!-- 中间 Section: 4 small dots -->
<div class="flex justify-between w-full items-start flex-1 mt-8 md:mt-24 mb-4 md:mb-0">
<div class="hero-fade-up will-change-transform w-1.5 h-1.5 md:w-2.5 md:h-2.5 bg-black rounded-full"></div>
<div class="hero-fade-up will-change-transform w-1.5 h-1.5 md:w-2.5 md:h-2.5 bg-black rounded-full"></div>
<div class="hero-fade-up will-change-transform w-1.5 h-1.5 md:w-2.5 md:h-2.5 bg-black rounded-full"></div>
<div class="hero-fade-up will-change-transform w-1.5 h-1.5 md:w-2.5 md:h-2.5 bg-black rounded-full"></div>
</div>
<!-- Bottom Section -->
<div class="flex flex-col md:flex-row justify-between items-start md:items-end w-full">
<!-- Bottom Left: Brand Name -->
<h1 ref="brandNameRef" class="text-[13vw] md:text-[8vw] font-bold leading-[0.8] tracking-tighter m-0 -ml-1 whitespace-nowrap pt-2 md:pt-4 pb-1 md: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-2 md: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 self-start md:self-auto">
<p class="hero-fade-up will-change-transform text-[10px] md:text-sm font-semibold text-gray-500 mb-1 md:mb-6 mr-1">© {{ new Date().getFullYear() }} {{ t('app.brand') }} {{ t('nav.studio') }}</p>
<h2 ref="studioNameRef" class="text-[13vw] md:text-[8vw] font-bold leading-[0.8] tracking-tighter m-0 -ml-1 md:-ml-0 md:-mr-1 whitespace-nowrap pt-2 md:pt-4 pb-1 md: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-2 md: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>