f4684f694e
✨ 新增功能: - Work 和 News 页面实现筛选功能 - Contact 页面联系表单提交功能(加载状态、成功/失败反馈) - Newsletter 订阅功能(邮箱验证、状态反馈) - 移动端汉堡菜单功能(全屏菜单、动画效果) - 用户登录/注册系统(支持账号/邮箱/手机号三合一) - 用户状态管理(Pinia store + localStorage 持久化) - 登录和注册弹窗组件(表单验证、错误提示) - 用户头像和下拉菜单(桌面端和移动端) 🎨 优化改进: - Footer 显示问题修复(多重备用机制) - 联系信息更新(邮箱改为 chenchun@virtheart.com,手机改为微信) - 完整的国际化支持(中英文双语) - 响应式设计优化 🔧 技术实现: - GSAP 动画系统 - 表单验证和反馈 - 状态持久化 - 自动生成用户头像 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
309 lines
12 KiB
Vue
Executable File
309 lines
12 KiB
Vue
Executable File
<script setup lang="ts">
|
|
import { useI18n } from 'vue-i18n'
|
|
import { onMounted, ref, onUnmounted } from 'vue'
|
|
import { gsap } from 'gsap'
|
|
import { ScrollTrigger } from 'gsap/ScrollTrigger'
|
|
import FooterLogo from '~/components/common/FooterLogo.vue'
|
|
import AnimatedButton from '~/components/common/AnimatedButton.vue'
|
|
|
|
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 = [
|
|
{ name: 'nav.home', url: '/' },
|
|
{ name: 'nav.studio', url: '/studio' },
|
|
{ name: 'nav.work', url: '/work' },
|
|
{ name: 'nav.news', url: '/news' },
|
|
]
|
|
|
|
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 90%',
|
|
toggleActions: 'play none none reset',
|
|
},
|
|
y: 0,
|
|
autoAlpha: 1,
|
|
duration: 0.8,
|
|
stagger: 0.085,
|
|
ease: 'power3.out',
|
|
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)
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
ctx?.revert()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<footer ref="footerRef" class="bg-black text-white px-6 py-16 md:px-12 md:py-24 overflow-hidden">
|
|
<div class="max-w-[1440px] mx-auto">
|
|
<!-- Top Section -->
|
|
<div class="flex flex-col md:flex-row justify-between items-start md:items-end gap-8 mb-16">
|
|
<h2 class="animated-item text-3xl md:text-4xl lg:text-[44px] font-normal tracking-normal !leading-[1.4] max-w-none whitespace-pre-line">
|
|
{{ t('footer.title') }}
|
|
</h2>
|
|
<div class="animated-item inline-flex items-center shrink-0">
|
|
<AnimatedButton
|
|
:text="t('footer.collaborate')"
|
|
to="/contact"
|
|
theme="light"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Divider -->
|
|
<hr class="animated-item border-white/10 w-full mb-16" />
|
|
|
|
<!-- Bottom Section -->
|
|
<div class="grid grid-cols-1 lg:grid-cols-2">
|
|
|
|
<!-- Left Column -->
|
|
<div class="flex flex-col justify-between lg:border-r border-white/10 lg:pr-16 pb-16 lg:pb-0 border-b lg:border-b-0 mb-16 lg:mb-0">
|
|
<div>
|
|
<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="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"
|
|
: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'"
|
|
>
|
|
<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>
|
|
|
|
<div class="text-xs text-white/40 flex items-center gap-2 animated-item">
|
|
<span class="w-1.5 h-1.5 bg-white rounded-full inline-block shrink-0"></span>
|
|
{{ t('footer.newsletterNote') }}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mt-24 lg:mt-0 flex items-center gap-2 animated-item">
|
|
<div class="text-[16px] font-bold text-white/30 font-sans">
|
|
{{ t('footer.copyright') }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Right Column -->
|
|
<div class="flex flex-col gap-10 lg:pl-16">
|
|
|
|
<!-- Links Grid (Top Half) -->
|
|
<div>
|
|
<ul class="space-y-3">
|
|
<li v-for="link in pagesLinks" :key="link.name" class="animated-item">
|
|
<NuxtLink :to="link.url" class="group flex items-center overflow-hidden text-[16px] font-bold text-white/80 hover:text-white transition-colors" style="justify-content: flex-start;">
|
|
<div class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[21px] pr-[21px]">
|
|
<span class="mr-[10px] h-[11px] w-[11px] rounded-full bg-white shrink-0"></span>
|
|
<span>{{ t(link.name) }}</span>
|
|
</div>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<div>
|
|
<ul class="space-y-3">
|
|
<li v-for="link in toolLinks" :key="link.name" class="animated-item">
|
|
<NuxtLink :to="link.url" class="group flex items-center overflow-hidden text-[16px] font-bold text-white/80 hover:text-white transition-colors" style="justify-content: flex-start;">
|
|
<div class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[21px] pr-[21px]">
|
|
<span class="mr-[10px] h-[11px] w-[11px] rounded-full bg-white shrink-0"></span>
|
|
<span>{{ t(link.name) }}</span>
|
|
</div>
|
|
</NuxtLink>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Contact / Location / Social Grid (Bottom Half) -->
|
|
<!-- Contacts Row -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-8">
|
|
<ul class="space-y-3">
|
|
<li class="animated-item">
|
|
<a href="mailto:chenchun@virtheart.com" class="group flex items-center overflow-hidden text-[16px] font-bold text-white/80 hover:text-white transition-colors" style="justify-content: flex-start;">
|
|
<div class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[21px] pr-[21px]">
|
|
<span class="mr-[10px] h-[11px] w-[11px] rounded-full bg-white shrink-0"></span>
|
|
<span>chenchun@virtheart.com</span>
|
|
</div>
|
|
</a>
|
|
</li>
|
|
<li class="animated-item">
|
|
<span class="group flex items-center overflow-hidden text-[16px] font-bold text-white/80 transition-colors cursor-default" style="justify-content: flex-start;">
|
|
<div class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[21px] pr-[21px]">
|
|
<span class="mr-[10px] h-[11px] w-[11px] rounded-full bg-white shrink-0"></span>
|
|
<span>{{ t('footer.wechat') }}: LucskyNew</span>
|
|
</div>
|
|
</span>
|
|
</li>
|
|
</ul>
|
|
|
|
<ul class="space-y-3">
|
|
<li class="animated-item">
|
|
<a href="mailto:info@virtheart.com" class="group flex items-center overflow-hidden text-[16px] font-bold text-white/80 hover:text-white transition-colors" style="justify-content: flex-start;">
|
|
<div class="flex items-center transition-transform duration-300 group-hover:translate-x-0 -translate-x-[21px] pr-[21px]">
|
|
<span class="mr-[10px] h-[11px] w-[11px] rounded-full bg-white shrink-0"></span>
|
|
<span>info@virtheart.com</span>
|
|
</div>
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
|
|
<!-- Address & Social Row -->
|
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-8 items-start">
|
|
<div class="group flex items-start overflow-hidden text-[16px] font-bold text-white/80 hover:text-white transition-colors animated-item cursor-default" style="justify-content: flex-start;">
|
|
<div class="flex items-start transition-transform duration-300 group-hover:translate-x-0 -translate-x-[21px] pr-[21px]">
|
|
<span class="mr-[10px] mt-[6px] h-[11px] w-[11px] rounded-full bg-white shrink-0"></span>
|
|
<span class="leading-relaxed whitespace-pre-line">{{ t('footer.address') }}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="flex gap-3 animated-item">
|
|
<a href="#" class="w-10 h-10 rounded-full bg-[#1f1f1f] hover:bg-[#2f2f2f] flex items-center justify-center transition-colors text-white">
|
|
<Icon name="ph:instagram-logo" class="w-4 h-4" />
|
|
</a>
|
|
<a href="#" class="w-10 h-10 rounded-full bg-[#1f1f1f] hover:bg-[#2f2f2f] flex items-center justify-center transition-colors text-white">
|
|
<Icon name="ph:x-logo" class="w-4 h-4" />
|
|
</a>
|
|
<a href="#" class="w-10 h-10 rounded-full bg-[#1f1f1f] hover:bg-[#2f2f2f] flex items-center justify-center transition-colors text-white">
|
|
<Icon name="ph:diamond-fill" class="w-4 h-4" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</footer>
|
|
</template>
|
|
|
|
<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>
|