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:
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm px-4"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div
|
||||
ref="modalRef"
|
||||
class="relative w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors z-10"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon name="ph:x" class="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
|
||||
<!-- 登录表单 -->
|
||||
<div class="p-8">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-black mb-2">{{ t('auth.login') }}</h2>
|
||||
<p class="text-gray-600 mb-8">{{ t('auth.loginDescription') }}</p>
|
||||
|
||||
<form @submit.prevent="handleLogin" class="space-y-5">
|
||||
<div>
|
||||
<label for="account" class="block text-sm font-semibold text-gray-800 mb-2">
|
||||
{{ t('auth.account') }}
|
||||
</label>
|
||||
<input
|
||||
id="account"
|
||||
v-model="account"
|
||||
type="text"
|
||||
required
|
||||
:disabled="isLoggingIn"
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:placeholder="t('auth.accountPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="password" class="block text-sm font-semibold text-gray-800 mb-2">
|
||||
{{ t('auth.password') }}
|
||||
</label>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
:disabled="isLoggingIn"
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 错误消息 -->
|
||||
<div v-if="errorMessage" class="p-3 rounded-lg bg-red-50 border border-red-200 text-red-800 text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- 登录按钮 -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isLoggingIn || !account || !password"
|
||||
class="w-full py-3 rounded-xl font-semibold text-white transition-all duration-300 flex items-center justify-center gap-2"
|
||||
:class="isLoggingIn || !account || !password
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-black hover:bg-gray-800 shadow-lg hover:shadow-xl'"
|
||||
>
|
||||
<Icon v-if="isLoggingIn" name="ph:circle-notch" class="w-5 h-5 animate-spin" />
|
||||
<span>{{ isLoggingIn ? t('auth.loggingIn') : t('auth.loginButton') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 提示信息 -->
|
||||
<p class="mt-6 text-center text-sm text-gray-500">
|
||||
{{ t('auth.demoHint') }}
|
||||
</p>
|
||||
|
||||
<!-- 注册账号链接 -->
|
||||
<p class="mt-4 text-center text-sm text-gray-600">
|
||||
{{ t('auth.noAccount') }}
|
||||
<button @click="switchToRegister" class="text-black font-semibold hover:underline">
|
||||
{{ t('auth.createAccount') }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '~/stores/user'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
switchToRegister: []
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const account = ref('')
|
||||
const password = ref('')
|
||||
const isLoggingIn = ref(false)
|
||||
const errorMessage = ref('')
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
if (!isLoggingIn.value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到注册
|
||||
const switchToRegister = () => {
|
||||
emit('switchToRegister')
|
||||
}
|
||||
|
||||
// 处理登录
|
||||
const handleLogin = async () => {
|
||||
if (isLoggingIn.value) return
|
||||
|
||||
isLoggingIn.value = true
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const result = await userStore.login(account.value, password.value)
|
||||
|
||||
if (result.success) {
|
||||
// 登录成功,关闭模态框
|
||||
account.value = ''
|
||||
password.value = ''
|
||||
closeModal()
|
||||
} else {
|
||||
errorMessage.value = t('auth.loginError')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login error:', error)
|
||||
errorMessage.value = t('auth.loginError')
|
||||
} finally {
|
||||
isLoggingIn.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听打开状态,重置表单
|
||||
watch(() => props.isOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
account.value = ''
|
||||
password.value = ''
|
||||
errorMessage.value = ''
|
||||
// 禁止背景滚动
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
// 恢复滚动
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
// ESC 键关闭
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.isOpen) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
// 添加键盘事件监听
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .relative,
|
||||
.modal-leave-active .relative {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from .relative {
|
||||
transform: scale(0.95) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-leave-to .relative {
|
||||
transform: scale(0.95) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,291 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm px-4"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div
|
||||
ref="modalRef"
|
||||
class="relative w-full max-w-md bg-white rounded-2xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 关闭按钮 -->
|
||||
<button
|
||||
@click="closeModal"
|
||||
class="absolute top-4 right-4 w-8 h-8 flex items-center justify-center rounded-full hover:bg-gray-100 transition-colors z-10"
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon name="ph:x" class="w-5 h-5 text-gray-600" />
|
||||
</button>
|
||||
|
||||
<!-- 注册表单 -->
|
||||
<div class="p-8">
|
||||
<h2 class="text-2xl md:text-3xl font-bold text-black mb-2">{{ t('auth.register') }}</h2>
|
||||
<p class="text-gray-600 mb-8">{{ t('auth.registerDescription') }}</p>
|
||||
|
||||
<form @submit.prevent="handleRegister" class="space-y-5">
|
||||
<div>
|
||||
<label for="reg-username" class="block text-sm font-semibold text-gray-800 mb-2">
|
||||
{{ t('auth.username') }}
|
||||
</label>
|
||||
<input
|
||||
id="reg-username"
|
||||
v-model="username"
|
||||
type="text"
|
||||
required
|
||||
:disabled="isRegistering"
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:placeholder="t('auth.usernamePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="reg-email" class="block text-sm font-semibold text-gray-800 mb-2">
|
||||
{{ t('auth.email') }}
|
||||
</label>
|
||||
<input
|
||||
id="reg-email"
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
:disabled="isRegistering"
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:placeholder="t('auth.emailPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="reg-phone" class="block text-sm font-semibold text-gray-800 mb-2">
|
||||
{{ t('auth.phone') }}
|
||||
</label>
|
||||
<input
|
||||
id="reg-phone"
|
||||
v-model="phone"
|
||||
type="tel"
|
||||
:disabled="isRegistering"
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:placeholder="t('auth.phonePlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="reg-password" class="block text-sm font-semibold text-gray-800 mb-2">
|
||||
{{ t('auth.password') }}
|
||||
</label>
|
||||
<input
|
||||
id="reg-password"
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
:disabled="isRegistering"
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:placeholder="t('auth.passwordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="reg-confirm-password" class="block text-sm font-semibold text-gray-800 mb-2">
|
||||
{{ t('auth.confirmPassword') }}
|
||||
</label>
|
||||
<input
|
||||
id="reg-confirm-password"
|
||||
v-model="confirmPassword"
|
||||
type="password"
|
||||
required
|
||||
:disabled="isRegistering"
|
||||
class="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:placeholder="t('auth.confirmPasswordPlaceholder')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 错误消息 -->
|
||||
<div v-if="errorMessage" class="p-3 rounded-lg bg-red-50 border border-red-200 text-red-800 text-sm">
|
||||
{{ errorMessage }}
|
||||
</div>
|
||||
|
||||
<!-- 成功消息 -->
|
||||
<div v-if="successMessage" class="p-3 rounded-lg bg-green-50 border border-green-200 text-green-800 text-sm">
|
||||
{{ successMessage }}
|
||||
</div>
|
||||
|
||||
<!-- 注册按钮 -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isRegistering || !username || !email || !password || !confirmPassword"
|
||||
class="w-full py-3 rounded-xl font-semibold text-white transition-all duration-300 flex items-center justify-center gap-2"
|
||||
:class="isRegistering || !username || !email || !password || !confirmPassword
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-black hover:bg-gray-800 shadow-lg hover:shadow-xl'"
|
||||
>
|
||||
<Icon v-if="isRegistering" name="ph:circle-notch" class="w-5 h-5 animate-spin" />
|
||||
<span>{{ isRegistering ? t('auth.registering') : t('auth.registerButton') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- 返回登录链接 -->
|
||||
<p class="mt-6 text-center text-sm text-gray-600">
|
||||
{{ t('auth.alreadyHaveAccount') }}
|
||||
<button @click="switchToLogin" class="text-black font-semibold hover:underline">
|
||||
{{ t('auth.backToLogin') }}
|
||||
</button>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useUserStore } from '~/stores/user'
|
||||
|
||||
const { t } = useI18n()
|
||||
const userStore = useUserStore()
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
switchToLogin: []
|
||||
}>()
|
||||
|
||||
const modalRef = ref<HTMLElement | null>(null)
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const phone = ref('')
|
||||
const password = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const isRegistering = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const successMessage = ref('')
|
||||
|
||||
// 关闭模态框
|
||||
const closeModal = () => {
|
||||
if (!isRegistering.value) {
|
||||
emit('close')
|
||||
}
|
||||
}
|
||||
|
||||
// 切换到登录
|
||||
const switchToLogin = () => {
|
||||
emit('switchToLogin')
|
||||
}
|
||||
|
||||
// 处理注册
|
||||
const handleRegister = async () => {
|
||||
if (isRegistering.value) return
|
||||
|
||||
// 验证密码匹配
|
||||
if (password.value !== confirmPassword.value) {
|
||||
errorMessage.value = t('auth.passwordMismatch')
|
||||
return
|
||||
}
|
||||
|
||||
// 验证手机号格式(如果填写了)
|
||||
if (phone.value && !/^1[3-9]\d{9}$/.test(phone.value)) {
|
||||
errorMessage.value = t('auth.invalidPhone')
|
||||
return
|
||||
}
|
||||
|
||||
isRegistering.value = true
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
|
||||
try {
|
||||
const result = await userStore.register({
|
||||
username: username.value,
|
||||
email: email.value,
|
||||
phone: phone.value,
|
||||
password: password.value
|
||||
})
|
||||
|
||||
if (result.success) {
|
||||
// 注册成功
|
||||
successMessage.value = t('auth.registerSuccess')
|
||||
|
||||
// 清空表单
|
||||
username.value = ''
|
||||
email.value = ''
|
||||
phone.value = ''
|
||||
password.value = ''
|
||||
confirmPassword.value = ''
|
||||
|
||||
// 2秒后关闭并自动登录
|
||||
setTimeout(() => {
|
||||
closeModal()
|
||||
}, 2000)
|
||||
} else {
|
||||
errorMessage.value = result.error || t('auth.registerError')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Register error:', error)
|
||||
errorMessage.value = t('auth.registerError')
|
||||
} finally {
|
||||
isRegistering.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 监听打开状态,重置表单
|
||||
watch(() => props.isOpen, (isOpen) => {
|
||||
if (isOpen) {
|
||||
username.value = ''
|
||||
email.value = ''
|
||||
phone.value = ''
|
||||
password.value = ''
|
||||
confirmPassword.value = ''
|
||||
errorMessage.value = ''
|
||||
successMessage.value = ''
|
||||
// 禁止背景滚动
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
// 恢复滚动
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
// ESC 键关闭
|
||||
const handleKeydown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && props.isOpen) {
|
||||
closeModal()
|
||||
}
|
||||
}
|
||||
|
||||
// 添加键盘事件监听
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('keydown', handleKeydown)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .relative,
|
||||
.modal-leave-active .relative {
|
||||
transition: transform 0.3s ease, opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from .relative {
|
||||
transform: scale(0.95) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-leave-to .relative {
|
||||
transform: scale(0.95) translateY(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,9 @@ gsap.registerPlugin(ScrollTrigger)
|
||||
|
||||
const { t } = useI18n()
|
||||
const footerRef = ref<HTMLElement | null>(null)
|
||||
const newsletterEmail = ref('')
|
||||
const isSubscribing = ref(false)
|
||||
const subscribeStatus = ref<'idle' | 'success' | 'error'>('idle')
|
||||
let ctx: gsap.Context
|
||||
|
||||
const pagesLinks = [
|
||||
@@ -23,29 +26,88 @@ const toolLinks = [
|
||||
{ name: 'nav.tools', url: '/tools' },
|
||||
]
|
||||
|
||||
const handleNewsletterSubmit = async () => {
|
||||
if (isSubscribing.value || !newsletterEmail.value) return
|
||||
|
||||
// 简单的邮箱验证
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
if (!emailRegex.test(newsletterEmail.value)) {
|
||||
subscribeStatus.value = 'error'
|
||||
setTimeout(() => {
|
||||
subscribeStatus.value = 'idle'
|
||||
}, 3000)
|
||||
return
|
||||
}
|
||||
|
||||
isSubscribing.value = true
|
||||
subscribeStatus.value = 'idle'
|
||||
|
||||
try {
|
||||
// 模拟 API 调用 - 你需要替换为真实的 Newsletter 服务
|
||||
// 可选方案:
|
||||
// 1. Mailchimp: await $fetch('/api/newsletter/subscribe', { method: 'POST', body: { email: newsletterEmail.value } })
|
||||
// 2. ConvertKit, SendGrid, 或其他邮件服务
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)) // 模拟网络延迟
|
||||
|
||||
console.log('Newsletter subscription:', newsletterEmail.value)
|
||||
|
||||
subscribeStatus.value = 'success'
|
||||
newsletterEmail.value = ''
|
||||
|
||||
// 3秒后重置状态
|
||||
setTimeout(() => {
|
||||
subscribeStatus.value = 'idle'
|
||||
}, 3000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('Newsletter subscription error:', error)
|
||||
subscribeStatus.value = 'error'
|
||||
|
||||
setTimeout(() => {
|
||||
subscribeStatus.value = 'idle'
|
||||
}, 3000)
|
||||
} finally {
|
||||
isSubscribing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!footerRef.value) return
|
||||
|
||||
ctx = gsap.context(() => {
|
||||
const items = gsap.utils.toArray<HTMLElement>('.animated-item', footerRef.value)
|
||||
|
||||
|
||||
gsap.fromTo(items, {
|
||||
y: 24,
|
||||
autoAlpha: 0,
|
||||
}, {
|
||||
scrollTrigger: {
|
||||
trigger: footerRef.value,
|
||||
start: 'top 80%',
|
||||
toggleActions: 'play none none reverse',
|
||||
start: 'top 90%',
|
||||
toggleActions: 'play none none reset',
|
||||
},
|
||||
y: 0,
|
||||
autoAlpha: 1,
|
||||
duration: 0.8,
|
||||
stagger: 0.085,
|
||||
ease: 'power3.out',
|
||||
immediateRender: false,
|
||||
immediateRender: true,
|
||||
overwrite: 'auto',
|
||||
})
|
||||
|
||||
// 备用机制:如果 2 秒后内容仍然隐藏,强制显示
|
||||
gsap.delayedCall(2, () => {
|
||||
const firstItem = items[0] as HTMLElement
|
||||
if (firstItem && gsap.getProperty(firstItem, 'autoAlpha') < 0.1) {
|
||||
gsap.set(items, { autoAlpha: 1, y: 0, clearProps: 'transform' })
|
||||
}
|
||||
})
|
||||
|
||||
// 刷新 ScrollTrigger 确保正确计算位置
|
||||
gsap.delayedCall(0.1, () => {
|
||||
ScrollTrigger.refresh()
|
||||
})
|
||||
}, footerRef.value)
|
||||
})
|
||||
|
||||
@@ -83,18 +145,37 @@ onUnmounted(() => {
|
||||
<FooterLogo class="mb-16 animated-item justify-start" />
|
||||
|
||||
<h3 class="text-2xl font-medium mb-6 animated-item">{{ t('footer.newsletterTitle') }}</h3>
|
||||
|
||||
<form @submit.prevent class="relative w-full max-w-sm mb-4 animated-item">
|
||||
<input
|
||||
type="email"
|
||||
:placeholder="t('footer.emailPlaceholder')"
|
||||
class="w-full h-[64px] bg-[#1f1f1f] border border-white/10 rounded-full pl-6 pr-[120px] text-sm text-white placeholder-white/40 focus:outline-none focus:bg-[#2f2f2f] transition-colors"
|
||||
|
||||
<form @submit.prevent="handleNewsletterSubmit" class="relative w-full max-w-sm mb-4 animated-item">
|
||||
<input
|
||||
v-model="newsletterEmail"
|
||||
type="email"
|
||||
:placeholder="t('footer.emailPlaceholder')"
|
||||
required
|
||||
:disabled="isSubscribing"
|
||||
class="w-full h-[64px] bg-[#1f1f1f] border rounded-full pl-6 pr-[120px] text-sm text-white placeholder-white/40 focus:outline-none focus:bg-[#2f2f2f] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:class="subscribeStatus === 'success'
|
||||
? 'border-green-500'
|
||||
: subscribeStatus === 'error'
|
||||
? 'border-red-500'
|
||||
: 'border-white/10'"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="absolute right-2 top-2 bottom-2 bg-black text-white text-sm px-6 rounded-full font-medium hover:opacity-75 transition-opacity"
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="isSubscribing || !newsletterEmail"
|
||||
class="absolute right-2 top-2 bottom-2 text-white text-sm px-6 rounded-full font-medium transition-all"
|
||||
:class="isSubscribing || !newsletterEmail
|
||||
? 'bg-gray-600 cursor-not-allowed'
|
||||
: subscribeStatus === 'success'
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: subscribeStatus === 'error'
|
||||
? 'bg-red-600 hover:bg-red-700'
|
||||
: 'bg-black hover:opacity-75'"
|
||||
>
|
||||
{{ t('footer.subscribe') }}
|
||||
<span v-if="isSubscribing">...</span>
|
||||
<span v-else-if="subscribeStatus === 'success'">✓</span>
|
||||
<span v-else-if="subscribeStatus === 'error'">✗</span>
|
||||
<span v-else>{{ t('footer.subscribe') }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -205,9 +286,23 @@ onUnmounted(() => {
|
||||
|
||||
<style scoped>
|
||||
.animated-item {
|
||||
/* 初始状态:隐藏并向下偏移 */
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transform: translate3d(0, 24px, 0);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* 备用方案:如果 JS 加载失败或动画未触发,3秒后自动显示 */
|
||||
@keyframes footer-fallback {
|
||||
to {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.animated-item {
|
||||
animation: footer-fallback 0.6s ease-out 3s forwards;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user