Files
arcticnewone/app/pages/index.vue
T
virtheart a22739aa7d feat: 添加 Pinia 状态管理并重构首页动画逻辑
- 添加 Pinia 作为状态管理工具,创建用户 store 管理登录状态
- 重构首页动画逻辑,使用 CSS sticky 替代 ScrollTrigger 实现更流畅的滚动效果
- 优化首屏加载和动画播放流程,修复热更新时的样式残留问题
- 添加演示用户数据并展示在页面属性中
2026-04-26 20:39:14 +08:00

422 lines
11 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 { computed, onBeforeUnmount, onMounted, ref } from 'vue'
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({
htmlAttrs: {
class: computed(() => (isIntroActive.value ? 'is-home-intro-active' : undefined)),
},
})
// 背景图是装饰层,左右两列共用同一套模板,只通过 side 区分位置和速度。
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 pageRef = ref<HTMLElement | null>(null)
const introRef = ref<HTMLElement | null>(null)
const heroStageRef = ref<HTMLElement | null>(null)
const heroRef = ref<HTMLElement | null>(null)
const heroPanelRef = ref<HTMLElement | null>(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 || !heroStageRef.value || !heroRef.value || !heroPanelRef.value) {
return
}
const { $lenis } = useNuxtApp() as ReturnType<typeof useNuxtApp> & { $lenis?: Lenis }
const topHeader = document.querySelector<HTMLElement>('.site-header-top')
heroAnimationContext = gsap.context(() => {
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',
})
const setHeroPanelScale = (scale: number) => {
if (!heroPanelRef.value) {
return
}
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 = () => {
removeLoadListener?.()
removeLoadListener = null
const introTimeline = gsap.timeline({
defaults: { ease: 'power3.out' },
onComplete: () => {
gsap.set(introRef.value, { display: 'none' })
gsap.set(heroRef.value, { clearProps: 'transform' })
restorePageScroll?.()
restorePageScroll = null
ScrollTrigger.refresh()
},
})
introTimeline
.to('.hero-intro-char', {
yPercent: 0,
duration: 0.75,
stagger: 0.055,
})
.addLabel('introOut', '+=0.35')
.to('.hero-intro-title', {
autoAlpha: 0,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
.to(introRef.value, {
yPercent: -100,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
.to(heroRef.value, {
yPercent: 0,
duration: 1.05,
ease: 'power4.inOut',
}, 'introOut')
if (topHeader) {
introTimeline.to(topHeader, {
yPercent: 0,
autoAlpha: 1,
duration: 0.55,
ease: 'power3.out',
}, 'introOut+=0.45')
}
}
// 首屏入场期间锁住滚动,等 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
$lenis?.start()
}
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)
return
}
window.addEventListener('load', playIntro, { once: true })
removeLoadListener = () => window.removeEventListener('load', playIntro)
}, pageRef.value)
})
onBeforeUnmount(() => {
isIntroActive.value = false
cleanupHeroScroll?.()
cleanupHeroScroll = null
removeLoadListener?.()
removeLoadListener = null
restorePageScroll?.()
restorePageScroll = null
heroAnimationContext?.revert()
heroAnimationContext = null
})
</script>
<template>
<main
ref="pageRef"
id="top"
:data-demo-user="demoUserName"
:data-demo-user-role="demoUserRole"
>
<div ref="introRef" class="hero-intro" aria-hidden="true">
<h1 class="hero-intro-title" :aria-label="introTitle">
<span
v-for="(letter, index) in introLetters"
:key="`${letter}-${index}`"
class="hero-intro-char-wrap"
>
<span class="hero-intro-char">{{ letter }}</span>
<span v-if="index === introLetters.length - 1" class="hero-intro-tm">TM</span>
</span>
</h1>
</div>
<section
ref="heroStageRef"
class="hero-stage"
:style="{ '--hero-pin-distance': `${heroPinDistance}px` }"
>
<section ref="heroRef" class="hero-section">
<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>
<section class="next-section">
<h2>Next Section</h2>
</section>
<section id="work" class="work-section" data-cursor-label="Read more">
<p>{{ t('home.welcome') }}</p>
</section>
</main>
</template>
<style scoped>
.hero-intro {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
overflow: hidden;
color: #fff;
background: #000;
will-change: transform;
}
.hero-intro-title {
display: flex;
margin: 0;
overflow: hidden;
font-size: clamp(36px, 6.2vw, 104px);
font-weight: 700;
line-height: 0.9;
letter-spacing: -0.07em;
white-space: nowrap;
}
.hero-intro-char-wrap,
.hero-intro-char {
display: inline-block;
}
.hero-intro-char-wrap {
position: relative;
overflow: hidden;
}
.hero-intro-char {
will-change: transform;
}
.hero-intro-tm {
position: absolute;
left: calc(100% + 0.12em);
top: 0.08em;
font-size: 0.13em;
font-weight: 700;
letter-spacing: -0.02em;
line-height: 1;
}
.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;
}
.next-section,
.work-section {
min-height: 100svh;
display: grid;
place-items: center;
}
</style>