feat: 初始化Arctic New One项目基础架构

添加项目基础文件结构,包括:
- 国际化多语言支持
- Tailwind CSS配置
- 自定义布局组件
- 平滑滚动和动画效果
- 自定义光标交互
- 错误页面处理
This commit is contained in:
2026-04-26 19:03:48 +08:00
parent 03ce24e689
commit fc52afda99
16 changed files with 2467 additions and 20 deletions
+3 -4
View File
@@ -1,6 +1,5 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtWelcome />
</div>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
+14
View File
@@ -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>
+30
View File
@@ -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>
+15
View File
@@ -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>
+212
View File
@@ -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>
+30
View File
@@ -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()
})
})
+6
View File
@@ -0,0 +1,6 @@
{
"home": {
"title": "Home",
"welcome": "Welcome to Arctic New One"
}
}
+6
View File
@@ -0,0 +1,6 @@
{
"home": {
"title": "首页",
"welcome": "欢迎来到 Arctic New One"
}
}
+28 -2
View File
@@ -2,5 +2,31 @@
export default defineNuxtConfig({
compatibilityDate: '2025-07-15',
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,
},
})
+8
View File
@@ -10,9 +10,17 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@nuxt/hints": "1.0.3",
"@nuxt/icon": "2.2.1",
"@nuxtjs/i18n": "10.3.0",
"@pinia/nuxt": "0.11.3",
"gsap": "^3.15.0",
"lenis": "^1.3.23",
"nuxt": "^4.4.2",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@nuxtjs/tailwindcss": "6.14.0"
}
}
+1670 -14
View File
File diff suppressed because it is too large Load Diff
+17
View File
@@ -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: [],
}