Files
arcticnewone/app/components/home/HomePartnersWork.vue
T
陈春 0c5ad55621 fix: 修复首屏加载慢和菜单延迟问题
🐛 核心问题:
- HomePartnersWork 的 ScrollTrigger 在 onMounted 时立即初始化
- 复杂的卡片堆叠动画计算阻塞了首屏 Hero 动画
- 导致菜单延迟出现,用户体验不佳

 解决方案:
- 监听 'hero-intro-done' 事件,等待 Hero 动画完成后才初始化
- 添加 3 秒保底延迟,确保不会无限等待
- 将 ScrollTrigger 初始化封装为独立函数
- 不影响原有的卡片堆叠滚动效果

🎯 效果:
- 首屏 Hero 动画流畅,不再被阻塞
- 菜单在 Hero 动画 60% 时立即出现
- HomePartnersWork 在 Hero 完成后异步初始化
- 用户体验大幅提升

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 19:31:44 +08:00

269 lines
10 KiB
Vue
Executable File

<script setup lang="ts">
import { onMounted, onUnmounted, ref, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
gsap.registerPlugin(ScrollTrigger)
const { t } = useI18n()
const rootRef = ref<HTMLElement | null>(null)
const workSectionRef = ref<HTMLElement | null>(null)
const workPinRef = ref<HTMLElement | null>(null)
const cardRefs = ref<HTMLElement[]>([])
const partners = [
{
id: 'p1',
svg: '<svg viewBox="0 0 64 64" width="34" height="34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M20 44V20h12c7 0 12 4 12 10s-5 10-12 10h-6v4H20Z" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><path d="M34 40l10 12" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>',
},
{
id: 'p2',
svg: '<svg viewBox="0 0 64 64" width="34" height="34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14 32c0-9.94 8.06-18 18-18s18 8.06 18 18-8.06 18-18 18-18-8.06-18-18Z" stroke="currentColor" stroke-width="3"/><path d="M24 32c0-4.42 3.58-8 8-8s8 3.58 8 8-3.58 8-8 8-8-3.58-8-8Z" stroke="currentColor" stroke-width="3"/></svg>',
},
{
id: 'p3',
svg: '<svg viewBox="0 0 64 64" width="34" height="34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M32 14v10M32 40v10M14 32h10M40 32h10" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><path d="M32 22c5.52 0 10 4.48 10 10s-4.48 10-10 10-10-4.48-10-10 4.48-10 10-10Z" stroke="currentColor" stroke-width="3"/></svg>',
},
{
id: 'p4',
svg: '<svg viewBox="0 0 64 64" width="34" height="34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M14 44c10-2 12-14 18-18s10-1 18-6" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><path d="M16 34c8-2 10 8 18 6s10-18 18-18" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>',
},
{
id: 'p5',
svg: '<svg viewBox="0 0 64 64" width="34" height="34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 38c0-9 6-18 14-18s14 9 14 18" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><path d="M22 42h20" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>',
},
{
id: 'p6',
svg: '<svg viewBox="0 0 64 64" width="34" height="34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 20v24M30 20v24M42 20v24" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><path d="M14 20h36M14 44h36" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>',
},
{
id: 'p7',
svg: '<svg viewBox="0 0 64 64" width="34" height="34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M18 44V20h10c9 0 18 7 18 12s-9 12-18 12H18Z" stroke="currentColor" stroke-width="3" stroke-linejoin="round"/></svg>',
},
{
id: 'p8',
svg: '<svg viewBox="0 0 64 64" width="34" height="34" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M16 34c8-14 18-14 32-4" stroke="currentColor" stroke-width="3" stroke-linecap="round"/><path d="M18 44c10-10 20-10 30-2" stroke="currentColor" stroke-width="3" stroke-linecap="round"/></svg>',
},
]
const toImageUrl = (prompt: string, imageSize: string) => {
const encoded = encodeURIComponent(prompt)
return `https://coresg-normal.trae.ai/api/ide/v1/text_to_image?prompt=${encoded}&image_size=${imageSize}`
}
const workItems = [
{
titleKey: 'home.work.items.forma',
year: '26',
image: toImageUrl(
'high-end editorial product still life, minimal composition, matte ceramics and brushed aluminum on soft neutral background, studio lighting, shallow depth of field, premium, clean, realistic, 35mm photo',
'landscape_16_9'
),
},
{
titleKey: 'home.work.items.oneStep',
year: '24',
image: toImageUrl(
'architectural close-up, modern brutalist concrete facade with sharp shadows, minimal, cinematic lighting, high contrast, ultra realistic, 35mm photography, no people',
'landscape_16_9'
),
},
{
titleKey: 'home.work.items.neroVision',
year: '25',
image: toImageUrl(
'minimal graphic poster photographed in studio, black ink typography on off-white textured paper, premium editorial style, soft shadow, ultra realistic, no people',
'landscape_16_9'
),
},
{
titleKey: 'home.work.items.boldMoves',
year: '24',
image: toImageUrl(
'moody nighttime city street still life, neon reflection on wet asphalt, abstract bokeh, premium cinematic photography, no people, high detail',
'landscape_16_9'
),
},
] as const
let ctx: gsap.Context | null = null
const refresh = () => {
ScrollTrigger.refresh()
}
onMounted(async () => {
if (!rootRef.value || !workSectionRef.value || !workPinRef.value) return
await nextTick()
// 延迟初始化 ScrollTrigger,避免阻塞首屏 Hero 动画
// 等待 Hero 入场动画完成(约 3-4 秒)
const initScrollAnimation = () => {
ctx = gsap.context(() => {
const cards = cardRefs.value
const count = cards.length
if (count < 2) return
const enterOffset = 160
const scaleTo = 0.92
const depthOffsetY = -18
for (let i = 0; i < count; i += 1) {
const card = cards[i]
gsap.set(card, {
position: 'absolute',
inset: 0,
borderRadius: 22,
overflow: 'hidden',
transformOrigin: 'center center',
force3D: true,
zIndex: i + 1,
boxShadow: '0 18px 40px rgba(0,0,0,0.12)',
y: i === 0 ? 0 : window.innerHeight + enterOffset,
scale: 1,
})
}
const tl = gsap.timeline()
for (let i = 1; i < count; i += 1) {
const prev = cards[i - 1]
const next = cards[i]
const start = i - 1
tl.to(
next,
{ y: 0, duration: 1, ease: 'power2.out' },
start
)
tl.to(
prev,
{ scale: scaleTo, y: depthOffsetY, duration: 0.6, ease: 'power2.out' },
start + 0.35
)
tl.set(prev, { scale: scaleTo, y: depthOffsetY }, start + 1)
}
ScrollTrigger.create({
trigger: workSectionRef.value,
start: 'top top',
end: () => `+=${(count - 1) * window.innerHeight}`,
pin: workPinRef.value,
scrub: true,
anticipatePin: 1,
invalidateOnRefresh: true,
animation: tl,
})
ScrollTrigger.refresh()
}, rootRef.value)
}
// 监听 hero 动画完成事件
if (typeof window !== 'undefined') {
window.addEventListener('hero-intro-done', initScrollAnimation, { once: true })
// 保底:3秒后必定初始化
setTimeout(() => {
window.removeEventListener('hero-intro-done', initScrollAnimation)
if (!ctx) {
initScrollAnimation()
}
}, 3000)
}
})
onUnmounted(() => {
ctx?.revert()
ctx = null
})
</script>
<template>
<section ref="rootRef" class="bg-[#F5F5F5]">
<div class="w-page-container max-w-[calc(100%-2rem)] mx-auto px-6 md:px-10 lg:px-14 pt-24 pb-20">
<div class="grid grid-cols-[1fr_auto_1fr] items-center">
<p class="justify-self-start text-xs font-semibold tracking-tight text-black">( {{ t('home.partners.label') }} )</p>
<div class="flex items-center justify-self-center gap-8 text-xs font-semibold tracking-tight text-black">
<span class="h-1 w-1 rounded-full bg-black" />
<span>{{ t('home.partners.years') }}</span>
</div>
<span class="justify-self-end h-1 w-1 rounded-full bg-black" />
</div>
<div class="mt-12 grid grid-cols-2 sm:grid-cols-4 gap-5">
<div
v-for="item in partners"
:key="item.id"
class="h-[152px] rounded-[20px] bg-white flex items-center justify-center text-black border-0 shadow-none outline-none ring-0"
v-html="item.svg"
/>
</div>
</div>
<section id="work" ref="workSectionRef" class="bg-white">
<div class="w-page-container max-w-[calc(100%-2rem)] mx-auto px-4 pb-28">
<div
ref="workPinRef"
class="flex flex-col lg:flex-row gap-10 lg:gap-16 h-[100svh] items-stretch"
>
<div class="lg:w-[340px] flex flex-col gap-8 pt-8 md:pt-10">
<p class="text-xs font-semibold tracking-tight text-black/70">( {{ t('home.work.label') }} )</p>
<div>
<h2 class="text-5xl md:text-6xl font-bold tracking-tighter leading-[0.92]">
{{ t('home.work.title') }}
<span class="text-black/40 text-base md:text-lg align-middle ml-2 font-semibold tracking-tight">
({{ workItems.length }})
</span>
</h2>
</div>
<NuxtLink
to="/work"
class="inline-flex w-fit items-center gap-2 rounded-full bg-black px-6 py-3 text-white text-sm font-semibold shadow-[0_15px_30px_-5px_rgba(0,0,0,0.25)]"
>
{{ t('home.work.viewAll') }}
</NuxtLink>
</div>
<div class="relative flex-1 h-full flex items-center">
<div class="relative w-full h-[78svh] md:h-[82svh] lg:h-[88svh] p-[clamp(14px,2.4vw,28px)]">
<div class="relative h-full w-full">
<article
v-for="(item, idx) in workItems"
:key="`${item.titleKey}-${idx}`"
ref="cardRefs"
class="will-change-transform relative"
>
<img
class="h-full w-full object-cover"
:src="item.image"
:alt="t(item.titleKey)"
loading="lazy"
decoding="async"
@load="refresh"
/>
<div class="absolute left-6 top-6 flex items-baseline gap-3">
<p class="text-white text-sm md:text-base font-semibold tracking-tight drop-shadow-[0_10px_18px_rgba(0,0,0,0.4)]">
{{ t(item.titleKey) }}
</p>
<p class="text-white/80 text-xs md:text-sm font-semibold tracking-tight drop-shadow-[0_10px_18px_rgba(0,0,0,0.4)]">
{{ item.year }} ©
</p>
</div>
</article>
</div>
</div>
</div>
</div>
</div>
</section>
</section>
</template>