Files
陈春 f4684f694e feat: 完成所有页面功能和用户认证系统
 新增功能:
- Work 和 News 页面实现筛选功能
- Contact 页面联系表单提交功能(加载状态、成功/失败反馈)
- Newsletter 订阅功能(邮箱验证、状态反馈)
- 移动端汉堡菜单功能(全屏菜单、动画效果)
- 用户登录/注册系统(支持账号/邮箱/手机号三合一)
- 用户状态管理(Pinia store + localStorage 持久化)
- 登录和注册弹窗组件(表单验证、错误提示)
- 用户头像和下拉菜单(桌面端和移动端)

🎨 优化改进:
- Footer 显示问题修复(多重备用机制)
- 联系信息更新(邮箱改为 chenchun@virtheart.com,手机改为微信)
- 完整的国际化支持(中英文双语)
- 响应式设计优化

🔧 技术实现:
- GSAP 动画系统
- 表单验证和反馈
- 状态持久化
- 自动生成用户头像

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

586 lines
18 KiB
Vue
Executable File
Raw Permalink 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 { onBeforeUnmount, onMounted, ref, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useI18n } from 'vue-i18n'
import gsap from 'gsap'
import { ScrollTrigger } from 'gsap/ScrollTrigger'
import { useAppStore } from '~/stores/app'
import { useUserStore } from '~/stores/user'
import AppLogo from '~/components/common/AppLogo.vue'
import LoginModal from '~/components/common/LoginModal.vue'
import RegisterModal from '~/components/common/RegisterModal.vue'
gsap.registerPlugin(ScrollTrigger)
const { t } = useI18n()
const route = useRoute()
const appStore = useAppStore()
const userStore = useUserStore()
// 恢复用户状态
onMounted(() => {
userStore.restoreUser()
})
// 导航菜单数据配置
const navItems = computed<Array<{ name: string, path: string }>>(() => [
{ name: t('nav.home'), path: '/' },
{ name: t('nav.studio'), path: '/studio' },
{ name: t('nav.work'), path: '/work' },
{ name: t('nav.news'), path: '/news' },
{ name: t('nav.tools'), path: '/tools' },
{ name: t('nav.contact'), path: '/contact' }
])
const topHeaderRef = ref<HTMLElement | null>(null)
const bottomHeaderRef = ref<HTMLElement | null>(null)
const mobileMenuRef = ref<HTMLElement | null>(null)
const isMobileMenuOpen = ref(false)
const isLoginModalOpen = ref(false)
const isRegisterModalOpen = ref(false)
const isUserMenuOpen = ref(false)
let topHeaderTween: gsap.core.Tween | null = null
let bottomHeaderScrollTrigger: ReturnType<typeof ScrollTrigger.create> | null = null
let isBottomHeaderVisible = false
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
// 打开登录弹窗
const openLoginModal = () => {
isLoginModalOpen.value = true
isRegisterModalOpen.value = false
}
// 关闭登录弹窗
const closeLoginModal = () => {
isLoginModalOpen.value = false
}
// 打开注册弹窗
const openRegisterModal = () => {
isRegisterModalOpen.value = true
isLoginModalOpen.value = false
}
// 关闭注册弹窗
const closeRegisterModal = () => {
isRegisterModalOpen.value = false
}
// 从登录切换到注册
const switchToRegister = () => {
isLoginModalOpen.value = false
isRegisterModalOpen.value = true
}
// 从注册切换到登录
const switchToLogin = () => {
isRegisterModalOpen.value = false
isLoginModalOpen.value = true
}
// 切换用户菜单
const toggleUserMenu = () => {
isUserMenuOpen.value = !isUserMenuOpen.value
}
// 关闭用户菜单
const closeUserMenu = () => {
isUserMenuOpen.value = false
}
// 处理登出
const handleLogout = () => {
userStore.logout()
closeUserMenu()
}
const setBottomHeaderVisible = (visible: boolean) => {
if (!bottomHeaderRef.value || isBottomHeaderVisible === visible) {
return
}
isBottomHeaderVisible = visible
gsap.to(bottomHeaderRef.value, {
yPercent: visible ? 0 : 150,
autoAlpha: visible ? 1 : 0,
duration: 0.45,
ease: 'power2.out',
overwrite: 'auto',
force3D: true,
})
}
// 定义一个供外部或延迟调用的入场动画方法
const playHeaderIntro = () => {
if (!topHeaderRef.value) return
// 如果已经触发过,清理可能存在的保底定时器
if (fallbackTimer) {
clearTimeout(fallbackTimer)
fallbackTimer = null
}
gsap.fromTo(topHeaderRef.value,
{
y: '100vh',
opacity: 0 // 恢复初始透明度为0,让它在屏幕最底部时是隐形的
},
{
y: 0,
opacity: 1, // 伴随飞入的过程逐渐完全显示
duration: 1.4,
ease: 'power4.out',
delay: 0,
overwrite: 'auto',
clearProps: 'transform',
onComplete: () => {
// 动画完成时,强制把 style 的 opacity 移除,防止被内联样式锁死
if (topHeaderRef.value) {
topHeaderRef.value.style.opacity = ''
}
// 进场动画完毕后,再将滚动消失动画挂载到 ScrollTrigger
if (topHeaderTween) {
topHeaderTween.scrollTrigger?.kill()
ScrollTrigger.create({
animation: topHeaderTween,
start: 0,
end: 200,
scrub: true,
})
ScrollTrigger.refresh()
}
}
})
}
// 监听自定义事件,当首页的 Hero 动画完成或到达特定阶段时触发导航栏入场
const handleHeroReady = () => {
playHeaderIntro()
}
const cleanupHeroReadyHook = () => {
if (fallbackTimer) {
clearTimeout(fallbackTimer)
fallbackTimer = null
}
if (typeof window !== 'undefined') {
window.removeEventListener('hero-intro-done', handleHeroReady)
}
}
const hideTopHeader = () => {
if (!topHeaderRef.value) return
gsap.set(topHeaderRef.value, { y: '100vh', opacity: 0 })
}
const showTopHeaderInstant = () => {
if (!topHeaderRef.value) return
gsap.set(topHeaderRef.value, { y: 0, opacity: 1 })
topHeaderRef.value.style.opacity = ''
if (topHeaderTween) {
topHeaderTween.scrollTrigger?.kill()
ScrollTrigger.create({
animation: topHeaderTween,
start: 0,
end: 200,
scrub: true,
})
ScrollTrigger.refresh()
}
}
// 切换移动端菜单
const toggleMobileMenu = () => {
isMobileMenuOpen.value = !isMobileMenuOpen.value
if (!mobileMenuRef.value) return
if (isMobileMenuOpen.value) {
// 打开菜单
gsap.set(mobileMenuRef.value, { display: 'flex' })
gsap.fromTo(mobileMenuRef.value,
{ opacity: 0, backdropFilter: 'blur(0px)' },
{ opacity: 1, backdropFilter: 'blur(12px)', duration: 0.3, ease: 'power2.out' }
)
// 菜单项依次出现
const menuItems = gsap.utils.toArray<HTMLElement>('.mobile-menu-item', mobileMenuRef.value)
gsap.fromTo(menuItems,
{ y: 30, opacity: 0 },
{ y: 0, opacity: 1, duration: 0.4, stagger: 0.05, ease: 'power3.out', delay: 0.1 }
)
// 禁止背景滚动
document.body.style.overflow = 'hidden'
} else {
// 关闭菜单
gsap.to(mobileMenuRef.value,
{
opacity: 0,
backdropFilter: 'blur(0px)',
duration: 0.25,
ease: 'power2.in',
onComplete: () => {
if (mobileMenuRef.value) {
gsap.set(mobileMenuRef.value, { display: 'none' })
}
}
}
)
// 恢复滚动
document.body.style.overflow = ''
}
}
// 关闭移动端菜单
const closeMobileMenu = () => {
if (isMobileMenuOpen.value) {
toggleMobileMenu()
}
}
onMounted(() => {
// 顶部导航在页面刚开始滚动的前 200px 内逐渐上移并隐藏。
topHeaderTween = gsap.to(topHeaderRef.value, {
yPercent: -140,
autoAlpha: 0,
ease: 'none',
paused: true, // 初始不执行,等进场动画完毕再绑定到 ScrollTrigger
})
// 底部导航默认藏在视口下方,只有滚动到深处并向上滚时才出现。
gsap.set(bottomHeaderRef.value, {
xPercent: -50,
yPercent: 150,
autoAlpha: 0,
force3D: true
})
bottomHeaderScrollTrigger = ScrollTrigger.create({
start: 0,
end: 'max',
onUpdate: (self) => {
const scrollY = self.scroll()
// 深度超过 1000px 后向上滚,显示底部导航。
if (scrollY > 1000 && self.direction === -1) {
setBottomHeaderVisible(true)
return
}
// 回到 1000px 以内,或继续向下滚时,底部导航收回。
if (scrollY <= 1000 || self.direction === 1) {
setBottomHeaderVisible(false)
}
},
})
ScrollTrigger.refresh()
if (!appStore.isAppLoading && route.path !== '/') {
showTopHeaderInstant()
}
})
watch([() => appStore.isAppLoading, () => route.path], ([isLoading, path]) => {
if (isLoading || typeof window === 'undefined') return
cleanupHeroReadyHook()
if (path === '/') {
hideTopHeader()
window.addEventListener('hero-intro-done', handleHeroReady, { once: true })
fallbackTimer = setTimeout(() => {
window.removeEventListener('hero-intro-done', handleHeroReady)
playHeaderIntro()
}, 5000)
return
}
showTopHeaderInstant()
}, { immediate: true })
onBeforeUnmount(() => {
cleanupHeroReadyHook()
topHeaderTween?.scrollTrigger?.kill()
topHeaderTween?.kill()
bottomHeaderScrollTrigger?.kill()
topHeaderTween = null
bottomHeaderScrollTrigger = null
isBottomHeaderVisible = false
if (topHeaderRef.value && bottomHeaderRef.value) {
gsap.killTweensOf([topHeaderRef.value, bottomHeaderRef.value])
}
// 清理移动端菜单
if (isMobileMenuOpen.value) {
document.body.style.overflow = ''
}
})
// 监听路由变化,关闭移动端菜单
watch(() => route.path, () => {
closeMobileMenu()
closeUserMenu()
})
// 点击外部关闭用户菜单
if (typeof window !== 'undefined') {
const handleClickOutside = (event: MouseEvent) => {
const target = event.target as HTMLElement
if (isUserMenuOpen.value && !target.closest('.relative')) {
closeUserMenu()
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onBeforeUnmount(() => {
document.removeEventListener('click', handleClickOutside)
})
}
</script>
<template>
<header ref="topHeaderRef"
class="site-header-top fixed left-0 right-0 top-0 z-50 mx-auto flex h-[100px] w-page-container max-w-[calc(100%-2rem)] items-center justify-between px-4 py-5"
style="opacity: 0; transform: translateY(100vh);">
<!-- 左侧Logo 标题 -->
<AppLogo />
<!-- 中间导航链接 -->
<nav class="hidden md:flex items-center gap-10 lg:gap-16">
<NuxtLink v-for="item in navItems" :key="item.name" :to="item.path"
class="nav-link-item group flex items-center overflow-hidden font-semibold tracking-[-0.7px] text-black"
style="font-size: 1rem; justify-content: flex-start; position: relative;">
<div
class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[18px] pr-[18px]">
<span class="mr-2 h-2.5 w-2.5 rounded-full bg-black shrink-0"></span>
<span>{{ item.name }}</span>
</div>
</NuxtLink>
</nav>
<!-- 右侧登录按钮 / 用户信息桌面端 -->
<div class="hidden md:flex items-center gap-4">
<!-- 未登录显示登录按钮 -->
<button
v-if="!userStore.isAuthenticated"
@click="openLoginModal"
class="px-5 py-2.5 rounded-full bg-black text-white text-sm font-semibold hover:bg-gray-800 transition-all duration-300 shadow-md hover:shadow-lg"
>
{{ t('auth.login') }}
</button>
<!-- 已登录显示用户头像和菜单 -->
<div v-else class="relative">
<button
@click="toggleUserMenu"
class="flex items-center gap-3 px-4 py-2 rounded-full hover:bg-gray-100 transition-all duration-300"
>
<img
:src="userStore.user?.avatar"
:alt="userStore.user?.name"
class="w-9 h-9 rounded-full object-cover border-2 border-gray-200"
/>
<span class="text-sm font-semibold text-black">{{ userStore.user?.name }}</span>
<Icon
name="ph:caret-down"
class="w-4 h-4 text-gray-600 transition-transform duration-300"
:class="{ 'rotate-180': isUserMenuOpen }"
/>
</button>
<!-- 用户下拉菜单 -->
<Transition name="dropdown">
<div
v-if="isUserMenuOpen"
class="absolute top-full right-0 mt-2 w-48 bg-white rounded-xl shadow-xl border border-gray-100 py-2 z-50"
@click="closeUserMenu"
>
<div class="px-4 py-3 border-b border-gray-100">
<p class="text-sm font-semibold text-black">{{ userStore.user?.name }}</p>
<p class="text-xs text-gray-500 truncate">{{ userStore.user?.email }}</p>
</div>
<button
@click="handleLogout"
class="w-full px-4 py-2.5 text-left text-sm font-medium text-red-600 hover:bg-red-50 transition-colors duration-200 flex items-center gap-2"
>
<Icon name="ph:sign-out" class="w-4 h-4" />
{{ t('auth.logout') }}
</button>
</div>
</Transition>
</div>
</div>
<!-- 右侧菜单图标移动端 -->
<button
@click="toggleMobileMenu"
class="menu-icon group flex h-12 w-12 cursor-pointer flex-col items-end justify-center gap-[8px] md:hidden relative z-50"
:class="{ 'menu-open': isMobileMenuOpen }"
aria-label="Toggle menu"
>
<div
class="h-[2px] w-10 bg-black transition-all duration-300"
:class="isMobileMenuOpen ? 'rotate-45 translate-y-[5px]' : 'group-hover:translate-y-[2.5px]'"
></div>
<div
class="h-[2px] w-10 bg-black transition-all duration-300"
:class="isMobileMenuOpen ? '-rotate-45 -translate-y-[5px]' : 'group-hover:-translate-y-[2.5px]'"
></div>
</button>
</header>
<!-- 移动端全屏菜单 -->
<div
ref="mobileMenuRef"
class="mobile-menu fixed inset-0 z-40 hidden md:hidden flex-col items-center justify-center bg-white/95 backdrop-blur-md"
style="opacity: 0;"
@click.self="closeMobileMenu"
>
<nav class="flex flex-col items-center gap-8 px-8">
<NuxtLink
v-for="item in navItems"
:key="'mobile-' + item.name"
:to="item.path"
class="mobile-menu-item text-3xl font-bold tracking-tight text-black hover:text-gray-600 transition-colors duration-300"
>
{{ item.name }}
</NuxtLink>
<!-- 移动端登录/用户信息 -->
<div class="mobile-menu-item mt-8 pt-8 border-t border-gray-200 w-full flex flex-col items-center gap-4">
<!-- 未登录 -->
<button
v-if="!userStore.isAuthenticated"
@click="openLoginModal"
class="px-8 py-3 rounded-full bg-black text-white text-base font-semibold hover:bg-gray-800 transition-all duration-300"
>
{{ t('auth.login') }}
</button>
<!-- 已登录 -->
<div v-else class="flex flex-col items-center gap-4">
<div class="flex items-center gap-3">
<img
:src="userStore.user?.avatar"
:alt="userStore.user?.name"
class="w-12 h-12 rounded-full object-cover border-2 border-gray-200"
/>
<div class="text-left">
<p class="text-lg font-bold text-black">{{ userStore.user?.name }}</p>
<p class="text-sm text-gray-500">{{ userStore.user?.email }}</p>
</div>
</div>
<button
@click="handleLogout"
class="px-6 py-2.5 rounded-full border-2 border-red-600 text-red-600 text-sm font-semibold hover:bg-red-50 transition-all duration-300 flex items-center gap-2"
>
<Icon name="ph:sign-out" class="w-4 h-4" />
{{ t('auth.logout') }}
</button>
</div>
</div>
</nav>
</div>
<!-- 登录弹窗 -->
<LoginModal
:is-open="isLoginModalOpen"
@close="closeLoginModal"
@switch-to-register="switchToRegister"
/>
<!-- 注册弹窗 -->
<RegisterModal
:is-open="isRegisterModalOpen"
@close="closeRegisterModal"
@switch-to-login="switchToLogin"
/>
<header ref="bottomHeaderRef" class="site-header-bottom z-50 bg-white/80 backdrop-blur-md">
<!-- 左侧菜单 (前两个菜单项) -->
<nav class="flex items-center justify-evenly md:justify-end gap-3 md:gap-4 w-full">
<NuxtLink v-for="item in navItems.slice(0, 2)" :key="'bottom-' + item.name" :to="item.path"
class="group flex items-center font-semibold tracking-[-0.7px] text-black text-sm md:text-base whitespace-nowrap md:overflow-hidden"
style="justify-content: flex-start; position: relative;">
<div
class="flex items-center transition-transform duration-300 md:group-hover:translate-x-0 md:-translate-x-[18px] md:pr-[18px]">
<span class="hidden md:block mr-1 md:mr-2 h-1.5 w-1.5 md:h-2.5 md:w-2.5 rounded-full bg-black shrink-0"></span>
<span>{{ item.name }}</span>
</div>
</NuxtLink>
</nav>
<!-- 中间 Logo (组件内部自带跳转和动画) -->
<div class="flex items-center justify-center px-1 md:px-6">
<AppLogo compact click-action="top" />
</div>
<!-- 右侧菜单 (去除左侧两个和最后contact菜单后的所有菜单) -->
<nav class="flex items-center justify-evenly md:justify-start gap-3 md:gap-4 w-full">
<NuxtLink v-for="item in navItems.slice(2, navItems.length - 1)" :key="'bottom-' + item.name" :to="item.path"
class="group flex items-center font-semibold tracking-[-0.7px] text-black text-sm md:text-base whitespace-nowrap md:overflow-hidden"
style="justify-content: flex-start; position: relative;">
<div
class="flex items-center transition-transform duration-300 md:group-hover:translate-x-0 md:-translate-x-[18px] md:pr-[18px]">
<span class="hidden md:block mr-1 md:mr-2 h-1.5 w-1.5 md:h-2.5 md:w-2.5 rounded-full bg-black shrink-0"></span>
<span>{{ item.name }}</span>
</div>
</NuxtLink>
</nav>
</header>
</template>
<style scoped>
.site-header-bottom {
border: 1px solid #ffffff47;
border-radius: 12px;
grid-template-rows: auto;
/* 移动端恢复三列,靠 flex 让菜单均匀铺开 */
grid-template-columns: 1fr auto 1fr;
grid-auto-columns: 1fr;
place-items: center;
width: calc(100% - 1rem);
max-width: 570px;
padding: 10px 10px;
display: grid;
position: fixed;
left: 50%;
bottom: 15px;
overflow: hidden;
visibility: hidden;
opacity: 0;
box-shadow: 0 -1px #0000001f, 0 12px 30px #0000001a, inset 0 1px #ffffff96;
}
@media (min-width: 768px) {
.site-header-bottom {
grid-template-columns: auto minmax(50px, auto) auto;
gap: 0;
width: fit-content;
padding: 15px 20px;
}
}
/* 用户下拉菜单动画 */
.dropdown-enter-active,
.dropdown-leave-active {
transition: opacity 0.2s ease, transform 0.2s ease;
}
.dropdown-enter-from,
.dropdown-leave-to {
opacity: 0;
transform: translateY(-10px);
}
</style>