feat: 初始化Arctic New One项目基础架构
添加项目基础文件结构,包括: - 国际化多语言支持 - Tailwind CSS配置 - 自定义布局组件 - 平滑滚动和动画效果 - 自定义光标交互 - 错误页面处理
This commit is contained in:
+3
-4
@@ -1,6 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<NuxtLayout>
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtPage />
|
||||||
<NuxtWelcome />
|
</NuxtLayout>
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
--page-container--width: 1400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body {
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -0,0 +1,325 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import gsap from 'gsap'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全站自定义鼠标。
|
||||||
|
*
|
||||||
|
* 使用方式:
|
||||||
|
* - 默认状态是一个圆点,加上横向和纵向的十字线。
|
||||||
|
* - 给任意元素添加 `data-cursor-label`,鼠标移入后圆点会变成胶囊提示。
|
||||||
|
*
|
||||||
|
* 示例:
|
||||||
|
* <section data-cursor-label="Read more">
|
||||||
|
* ...
|
||||||
|
* </section>
|
||||||
|
*
|
||||||
|
* 说明:
|
||||||
|
* - 这个组件只需要在默认 layout 中挂载一次。
|
||||||
|
* - 只在精确指针设备上运行,所以触摸屏会继续使用系统默认鼠标。
|
||||||
|
* - 鼠标元素会 Teleport 到 body,避免被页面局部层级或布局包住。
|
||||||
|
*/
|
||||||
|
|
||||||
|
const dotRef = ref<HTMLElement | null>(null)
|
||||||
|
const horizontalLineRef = ref<HTMLElement | null>(null)
|
||||||
|
const verticalLineRef = ref<HTMLElement | null>(null)
|
||||||
|
const pillRef = ref<HTMLElement | null>(null)
|
||||||
|
const pillTextRef = ref<HTMLElement | null>(null)
|
||||||
|
|
||||||
|
let cleanupCursor: (() => void) | null = null
|
||||||
|
let cursorX = 0
|
||||||
|
let cursorY = 0
|
||||||
|
let pillWidth = 100
|
||||||
|
let isPillVisible = false
|
||||||
|
|
||||||
|
// 查找最近的 `data-cursor-label` 元素,用来判断是否需要显示胶囊态。
|
||||||
|
const getCursorTarget = (target: EventTarget | null) => {
|
||||||
|
if (!(target instanceof Element)) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return target.closest<HTMLElement>('[data-cursor-label]')
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const finePointer = window.matchMedia('(pointer: fine)')
|
||||||
|
|
||||||
|
// 触摸设备保留系统默认鼠标,不启用自定义鼠标。
|
||||||
|
if (!finePointer.matches) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cursorTargets = [
|
||||||
|
dotRef.value,
|
||||||
|
horizontalLineRef.value,
|
||||||
|
verticalLineRef.value,
|
||||||
|
]
|
||||||
|
const allTargets = [
|
||||||
|
...cursorTargets,
|
||||||
|
pillRef.value,
|
||||||
|
pillTextRef.value,
|
||||||
|
]
|
||||||
|
|
||||||
|
document.documentElement.classList.add('has-custom-cursor')
|
||||||
|
|
||||||
|
// 初始隐藏,避免页面刚打开、鼠标还没移动时出现闪烁。
|
||||||
|
gsap.set(cursorTargets, {
|
||||||
|
autoAlpha: 0,
|
||||||
|
})
|
||||||
|
gsap.set(pillRef.value, {
|
||||||
|
autoAlpha: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
})
|
||||||
|
gsap.set(pillTextRef.value, {
|
||||||
|
autoAlpha: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// quickTo 会复用 tween,让十字线跟随更轻量,不需要每一帧都创建新动画。
|
||||||
|
const moveLineX = gsap.quickTo(verticalLineRef.value, 'x', {
|
||||||
|
duration: 0.08,
|
||||||
|
ease: 'power3.out',
|
||||||
|
})
|
||||||
|
const moveLineY = gsap.quickTo(horizontalLineRef.value, 'y', {
|
||||||
|
duration: 0.08,
|
||||||
|
ease: 'power3.out',
|
||||||
|
})
|
||||||
|
|
||||||
|
const movePill = () => {
|
||||||
|
// 胶囊提示始终以真实鼠标位置为中心。
|
||||||
|
gsap.set(pillRef.value, {
|
||||||
|
x: cursorX - pillWidth / 2,
|
||||||
|
y: cursorY - 15,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showCursor = () => {
|
||||||
|
gsap.to([horizontalLineRef.value, verticalLineRef.value], {
|
||||||
|
autoAlpha: 1,
|
||||||
|
duration: 0.15,
|
||||||
|
overwrite: 'auto',
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isPillVisible) {
|
||||||
|
gsap.to(dotRef.value, {
|
||||||
|
autoAlpha: 1,
|
||||||
|
scale: 1,
|
||||||
|
duration: 0.15,
|
||||||
|
overwrite: 'auto',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hideCursor = () => {
|
||||||
|
isPillVisible = false
|
||||||
|
|
||||||
|
gsap.to(allTargets, {
|
||||||
|
autoAlpha: 0,
|
||||||
|
duration: 0.15,
|
||||||
|
overwrite: 'auto',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const showPill = (label: string) => {
|
||||||
|
if (!pillRef.value || !pillTextRef.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pillTextRef.value.textContent = label
|
||||||
|
// 短文案保持一个基础宽度,长文案则按内容自然变宽。
|
||||||
|
pillWidth = Math.max(100, label.length * 9 + 32)
|
||||||
|
isPillVisible = true
|
||||||
|
movePill()
|
||||||
|
|
||||||
|
gsap.to(dotRef.value, {
|
||||||
|
autoAlpha: 0,
|
||||||
|
scale: 0,
|
||||||
|
duration: 0.18,
|
||||||
|
ease: 'power2.out',
|
||||||
|
overwrite: 'auto',
|
||||||
|
})
|
||||||
|
gsap.to(pillRef.value, {
|
||||||
|
autoAlpha: 1,
|
||||||
|
width: pillWidth,
|
||||||
|
height: 30,
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'power3.out',
|
||||||
|
overwrite: 'auto',
|
||||||
|
})
|
||||||
|
gsap.to(pillTextRef.value, {
|
||||||
|
autoAlpha: 1,
|
||||||
|
delay: 0.08,
|
||||||
|
duration: 0.18,
|
||||||
|
overwrite: 'auto',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const hidePill = () => {
|
||||||
|
if (!isPillVisible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPillVisible = false
|
||||||
|
|
||||||
|
gsap.to(pillTextRef.value, {
|
||||||
|
autoAlpha: 0,
|
||||||
|
duration: 0.12,
|
||||||
|
overwrite: 'auto',
|
||||||
|
})
|
||||||
|
gsap.to(pillRef.value, {
|
||||||
|
autoAlpha: 0,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
duration: 0.2,
|
||||||
|
ease: 'power3.out',
|
||||||
|
overwrite: 'auto',
|
||||||
|
})
|
||||||
|
gsap.to(dotRef.value, {
|
||||||
|
autoAlpha: 1,
|
||||||
|
scale: 1,
|
||||||
|
delay: 0.08,
|
||||||
|
duration: 0.18,
|
||||||
|
ease: 'power2.out',
|
||||||
|
overwrite: 'auto',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const moveCursor = (event: MouseEvent) => {
|
||||||
|
cursorX = event.clientX
|
||||||
|
cursorY = event.clientY
|
||||||
|
|
||||||
|
showCursor()
|
||||||
|
gsap.set(dotRef.value, {
|
||||||
|
x: cursorX - 12,
|
||||||
|
y: cursorY - 12,
|
||||||
|
})
|
||||||
|
movePill()
|
||||||
|
moveLineX(cursorX)
|
||||||
|
moveLineY(cursorY)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseOver = (event: MouseEvent) => {
|
||||||
|
const target = getCursorTarget(event.target)
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
showPill(target.dataset.cursorLabel || 'Read more')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleMouseOut = (event: MouseEvent) => {
|
||||||
|
const target = getCursorTarget(event.target)
|
||||||
|
const nextTarget = getCursorTarget(event.relatedTarget)
|
||||||
|
|
||||||
|
// 在同一个标记区域的子元素之间移动时,不关闭胶囊提示。
|
||||||
|
if (!target || nextTarget === target) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hidePill()
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mousemove', moveCursor)
|
||||||
|
window.addEventListener('mouseenter', showCursor)
|
||||||
|
window.addEventListener('mouseleave', hideCursor)
|
||||||
|
document.addEventListener('mouseover', handleMouseOver)
|
||||||
|
document.addEventListener('mouseout', handleMouseOut)
|
||||||
|
|
||||||
|
cleanupCursor = () => {
|
||||||
|
window.removeEventListener('mousemove', moveCursor)
|
||||||
|
window.removeEventListener('mouseenter', showCursor)
|
||||||
|
window.removeEventListener('mouseleave', hideCursor)
|
||||||
|
document.removeEventListener('mouseover', handleMouseOver)
|
||||||
|
document.removeEventListener('mouseout', handleMouseOut)
|
||||||
|
document.documentElement.classList.remove('has-custom-cursor')
|
||||||
|
gsap.killTweensOf(allTargets)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cleanupCursor?.()
|
||||||
|
cleanupCursor = null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div aria-hidden="true">
|
||||||
|
<div ref="dotRef" class="custom-cursor-dot" />
|
||||||
|
<div ref="horizontalLineRef" class="custom-cursor-line custom-cursor-line-x" />
|
||||||
|
<div ref="verticalLineRef" class="custom-cursor-line custom-cursor-line-y" />
|
||||||
|
<div ref="pillRef" class="custom-cursor-pill">
|
||||||
|
<div ref="pillTextRef" class="custom-cursor-pill-text">Read more</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.custom-cursor-dot,
|
||||||
|
.custom-cursor-line {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
mix-blend-mode: difference;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-cursor-dot {
|
||||||
|
z-index: 2147483647;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-cursor-line {
|
||||||
|
z-index: 2147483646;
|
||||||
|
background: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-cursor-line-x {
|
||||||
|
width: 100vw;
|
||||||
|
height: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-cursor-line-y {
|
||||||
|
width: 0.5px;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-cursor-pill {
|
||||||
|
position: fixed;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
pointer-events: none;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 30px;
|
||||||
|
color: #fff;
|
||||||
|
background: rgb(0 0 0 / 35%);
|
||||||
|
-webkit-backdrop-filter: blur(10px);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-cursor-pill-text {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
overflow-wrap: normal;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (pointer: fine) {
|
||||||
|
:global(.has-custom-cursor),
|
||||||
|
:global(.has-custom-cursor *) {
|
||||||
|
cursor: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<footer>
|
||||||
|
<h1>Arctic New One Footer</h1>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import gsap from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
|
||||||
|
const topHeaderRef = ref<HTMLElement | null>(null)
|
||||||
|
const bottomHeaderRef = ref<HTMLElement | null>(null)
|
||||||
|
let topHeaderTween: gsap.core.Tween | null = null
|
||||||
|
let bottomHeaderScrollTrigger: ReturnType<typeof ScrollTrigger.create> | null = null
|
||||||
|
let isBottomHeaderVisible = false
|
||||||
|
|
||||||
|
const setBottomHeaderVisible = (visible: boolean) => {
|
||||||
|
if (!bottomHeaderRef.value || isBottomHeaderVisible === visible) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isBottomHeaderVisible = visible
|
||||||
|
|
||||||
|
gsap.to(bottomHeaderRef.value, {
|
||||||
|
yPercent: visible ? 0 : 140,
|
||||||
|
autoAlpha: visible ? 1 : 0,
|
||||||
|
duration: 0.35,
|
||||||
|
ease: 'power2.out',
|
||||||
|
overwrite: 'auto',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// 底部导航默认藏在视口下方,只有滚动到深处并向上滚时才出现。
|
||||||
|
gsap.set(bottomHeaderRef.value, {
|
||||||
|
yPercent: 140,
|
||||||
|
autoAlpha: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 顶部导航在页面刚开始滚动的前 200px 内逐渐上移并隐藏。
|
||||||
|
topHeaderTween = gsap.to(topHeaderRef.value, {
|
||||||
|
yPercent: -140,
|
||||||
|
autoAlpha: 0,
|
||||||
|
ease: 'none',
|
||||||
|
scrollTrigger: {
|
||||||
|
start: 0,
|
||||||
|
end: 200,
|
||||||
|
scrub: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
bottomHeaderScrollTrigger = ScrollTrigger.create({
|
||||||
|
start: 0,
|
||||||
|
end: 'max',
|
||||||
|
onUpdate: (self) => {
|
||||||
|
const scrollY = self.scroll()
|
||||||
|
|
||||||
|
// 深度超过 2500px 后向上滚,显示未来用于深层内容的底部导航。
|
||||||
|
if (scrollY > 2500 && self.direction === -1) {
|
||||||
|
setBottomHeaderVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 回到 2300px 以内,或继续向下滚时,底部导航收回。
|
||||||
|
if (scrollY <= 2300 || self.direction === 1) {
|
||||||
|
setBottomHeaderVisible(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
ScrollTrigger.refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
topHeaderTween?.scrollTrigger?.kill()
|
||||||
|
topHeaderTween?.kill()
|
||||||
|
bottomHeaderScrollTrigger?.kill()
|
||||||
|
topHeaderTween = null
|
||||||
|
bottomHeaderScrollTrigger = null
|
||||||
|
isBottomHeaderVisible = false
|
||||||
|
gsap.killTweensOf([topHeaderRef.value, bottomHeaderRef.value])
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
ref="topHeaderRef"
|
||||||
|
class="fixed left-0 right-0 top-0 z-50 mx-auto h-[70px] w-page-container max-w-[calc(100%-2rem)] bg-red-200 px-4 py-5"
|
||||||
|
>
|
||||||
|
<nav>Top Navigation</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<header
|
||||||
|
ref="bottomHeaderRef"
|
||||||
|
class="fixed bottom-0 left-0 right-0 z-50 mx-auto h-[70px] w-page-container max-w-[calc(100%-2rem)] bg-blue-200 px-4 py-5"
|
||||||
|
style="visibility: hidden; opacity: 0;"
|
||||||
|
>
|
||||||
|
<nav>Bottom Navigation</nav>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NuxtError } from '#app'
|
||||||
|
|
||||||
|
const props = defineProps<{ error: NuxtError }>()
|
||||||
|
|
||||||
|
const statusText = computed(() => props.error.statusText || 'Something went wrong.')
|
||||||
|
|
||||||
|
const handleError = () => clearError({ redirect: '/' })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex min-h-screen flex-col items-center justify-center gap-4 bg-slate-950 px-6 text-center text-slate-50">
|
||||||
|
<p class="text-sm font-medium uppercase tracking-[0.35em] text-cyan-300">
|
||||||
|
Error occurred in this page
|
||||||
|
</p>
|
||||||
|
<h1 class="text-6xl font-semibold">
|
||||||
|
{{ error.status }}
|
||||||
|
</h1>
|
||||||
|
<p class="max-w-xl text-slate-300">
|
||||||
|
{{ statusText }}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
class="rounded-md bg-cyan-300 px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-cyan-200"
|
||||||
|
type="button"
|
||||||
|
@click="handleError"
|
||||||
|
>
|
||||||
|
Go back home
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import ArcticNewOneCursor from '~/components/layouts/ArcticNewOneCursor.vue';
|
||||||
|
import ArcticNewOneFooter from '~/components/layouts/ArcticNewOneFooter.vue';
|
||||||
|
import ArcticNewOneHeader from '~/components/layouts/ArcticNewOneHeader.vue';
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<ArcticNewOneHeader />
|
||||||
|
<slot />
|
||||||
|
<ArcticNewOneFooter />
|
||||||
|
<ArcticNewOneCursor />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,212 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import gsap from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
// 背景图是装饰层,左右两列共用同一套模板,只通过 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 heroRef = ref<HTMLElement | null>(null)
|
||||||
|
const heroPanelRef = ref<HTMLElement | null>(null)
|
||||||
|
const nextSectionRef = ref<HTMLElement | null>(null)
|
||||||
|
let heroAnimationContext: gsap.Context | null = null
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (!heroRef.value || !nextSectionRef.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
heroAnimationContext = gsap.context(() => {
|
||||||
|
// pinDistance 控制 Hero 固定阶段的滚动长度;scrollScrub 控制滚动追随的柔和程度。
|
||||||
|
const pinDistance = 1200
|
||||||
|
const scrollScrub = 0.65
|
||||||
|
|
||||||
|
gsap.set(heroPanelRef.value, {
|
||||||
|
scale: 1,
|
||||||
|
transformOrigin: 'center center',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 第一段:Hero 停在屏幕中,前景白色面板先缩小,露出背后的图片列。
|
||||||
|
gsap.timeline({
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: heroRef.value,
|
||||||
|
start: 'top top',
|
||||||
|
end: `+=${pinDistance}`,
|
||||||
|
scrub: scrollScrub,
|
||||||
|
pin: true,
|
||||||
|
anticipatePin: 1,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.to(heroPanelRef.value, {
|
||||||
|
scale: 0.65,
|
||||||
|
ease: 'none',
|
||||||
|
})
|
||||||
|
|
||||||
|
// 第二段:取消 pin 后,下一个板块开始进入,白色面板继续缩小。
|
||||||
|
gsap.fromTo(heroPanelRef.value, {
|
||||||
|
scale: 0.65,
|
||||||
|
}, {
|
||||||
|
scale: 0.18,
|
||||||
|
ease: 'none',
|
||||||
|
immediateRender: false,
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: nextSectionRef.value,
|
||||||
|
start: 'top bottom',
|
||||||
|
end: 'top top',
|
||||||
|
scrub: scrollScrub,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}, heroRef.value)
|
||||||
|
|
||||||
|
ScrollTrigger.refresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
heroAnimationContext?.revert()
|
||||||
|
heroAnimationContext = null
|
||||||
|
})
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main id="top">
|
||||||
|
<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="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div ref="heroPanelRef" class="hero-panel">
|
||||||
|
<p>Hero Section</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section ref="nextSectionRef" 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-section {
|
||||||
|
position: relative;
|
||||||
|
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>
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import Lenis from 'lenis'
|
||||||
|
import 'lenis/dist/lenis.css'
|
||||||
|
import gsap from 'gsap'
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger)
|
||||||
|
|
||||||
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
|
// 用 Lenis 平滑浏览器原始 wheel 输入,避免鼠标滚轮一格跳太远。
|
||||||
|
const lenis = new Lenis({
|
||||||
|
lerp: 0.075,
|
||||||
|
wheelMultiplier: 0.7,
|
||||||
|
})
|
||||||
|
|
||||||
|
const updateLenis = (time: number) => {
|
||||||
|
lenis.raf(time * 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollTrigger 必须跟随 Lenis 的平滑滚动值更新,否则触发点会和真实视觉滚动不同步。
|
||||||
|
lenis.on('scroll', ScrollTrigger.update)
|
||||||
|
gsap.ticker.add(updateLenis)
|
||||||
|
gsap.ticker.lagSmoothing(0)
|
||||||
|
|
||||||
|
// Nuxt 卸载时清掉 ticker 和事件,避免热更新或页面销毁后重复驱动滚动。
|
||||||
|
nuxtApp.hook('app:beforeUnmount', () => {
|
||||||
|
lenis.off('scroll', ScrollTrigger.update)
|
||||||
|
gsap.ticker.remove(updateLenis)
|
||||||
|
lenis.destroy()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"home": {
|
||||||
|
"title": "Home",
|
||||||
|
"welcome": "Welcome to Arctic New One"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"home": {
|
||||||
|
"title": "首页",
|
||||||
|
"welcome": "欢迎来到 Arctic New One"
|
||||||
|
}
|
||||||
|
}
|
||||||
+28
-2
@@ -2,5 +2,31 @@
|
|||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
compatibilityDate: '2025-07-15',
|
compatibilityDate: '2025-07-15',
|
||||||
devtools: { enabled: true },
|
devtools: { enabled: true },
|
||||||
modules: ['@nuxtjs/i18n']
|
css: ['~/assets/css/tailwind.css'],
|
||||||
})
|
modules: [
|
||||||
|
'@nuxtjs/i18n',
|
||||||
|
'@nuxt/hints',
|
||||||
|
'@nuxt/icon',
|
||||||
|
'@nuxtjs/tailwindcss',
|
||||||
|
'@pinia/nuxt'
|
||||||
|
],
|
||||||
|
tailwindcss: {
|
||||||
|
exposeConfig: true,
|
||||||
|
viewer: true,
|
||||||
|
},
|
||||||
|
postcss: {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
i18n: {
|
||||||
|
locales: [
|
||||||
|
{ code: 'zh', name: '中文', language: 'zh-CN', file: 'zh.json' },
|
||||||
|
{ code: 'en', name: 'English', language: 'en-US', file: 'en.json' },
|
||||||
|
],
|
||||||
|
defaultLocale: 'zh',
|
||||||
|
strategy: 'no_prefix',
|
||||||
|
detectBrowserLanguage: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|||||||
@@ -10,9 +10,17 @@
|
|||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@nuxt/hints": "1.0.3",
|
||||||
|
"@nuxt/icon": "2.2.1",
|
||||||
"@nuxtjs/i18n": "10.3.0",
|
"@nuxtjs/i18n": "10.3.0",
|
||||||
|
"@pinia/nuxt": "0.11.3",
|
||||||
|
"gsap": "^3.15.0",
|
||||||
|
"lenis": "^1.3.23",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"vue": "^3.5.32",
|
"vue": "^3.5.32",
|
||||||
"vue-router": "^5.0.4"
|
"vue-router": "^5.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxtjs/tailwindcss": "6.14.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1670
-14
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,17 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: [
|
||||||
|
"./app/**/*.{js,vue,ts}"
|
||||||
|
],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
width: {
|
||||||
|
'page-container': 'var(--page-container--width)',
|
||||||
|
},
|
||||||
|
maxWidth: {
|
||||||
|
'page-container': 'var(--page-container--width)',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user