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>
This commit is contained in:
陈春
2026-06-03 19:03:57 +08:00
parent be0b644e4c
commit f4684f694e
11 changed files with 2335 additions and 45 deletions
+290 -4
View File
@@ -5,13 +5,22 @@ 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 }>>(() => [
@@ -25,11 +34,66 @@ const navItems = computed<Array<{ name: string, path: string }>>(() => [
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
@@ -128,6 +192,57 @@ const showTopHeaderInstant = () => {
}
}
// 切换移动端菜单
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, {
@@ -202,7 +317,35 @@ onBeforeUnmount(() => {
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>
@@ -225,13 +368,144 @@ onBeforeUnmount(() => {
</NuxtLink>
</nav>
<!-- 右侧菜单图标 (使用 div 绘制) -->
<div class="menu-icon group flex h-12 w-12 cursor-pointer flex-col items-end justify-center gap-[8px]">
<div class="h-[2px] w-10 bg-black transition-all duration-300 group-hover:translate-y-[2.5px]"></div>
<div class="h-[2px] w-10 bg-black transition-all duration-300 group-hover:-translate-y-[2.5px]"></div>
<!-- 右侧登录按钮 / 用户信息桌面端 -->
<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">
@@ -296,4 +570,16 @@ onBeforeUnmount(() => {
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>