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

394 lines
14 KiB
Vue
Executable File

<template>
<main class="page-container min-h-screen pt-32 pb-20 px-4 md:px-8 mx-auto max-w-[1200px]">
<div
ref="headerWrapRef"
class="flex flex-col items-center justify-center text-center mb-16 md:mb-20"
style="opacity: 0; visibility: hidden;"
>
<h1 ref="titleRef" class="text-3xl md:text-5xl font-bold mb-4 md:mb-6 tracking-tight text-black">{{ t('contact.title') }}</h1>
<p ref="descRef" class="text-base md:text-lg text-gray-600 max-w-2xl">{{ t('contact.description') }}</p>
</div>
<div class="grid grid-cols-1 lg:grid-cols-5 gap-10 md:gap-12 lg:gap-16">
<!-- Contact Form -->
<div
ref="formWrapRef"
class="lg:col-span-3"
style="opacity: 0; visibility: hidden;"
>
<form @submit.prevent="handleSubmit" class="space-y-6 md:space-y-7">
<div class="form-field">
<label for="name" class="block text-sm md:text-base font-semibold text-gray-800 mb-2">
{{ t('contact.form.name') }}
</label>
<input
id="name"
v-model="form.name"
type="text"
required
class="w-full px-4 md:px-5 py-3 md:py-4 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 text-base"
:placeholder="t('contact.form.name')"
/>
</div>
<div class="form-field">
<label for="email" class="block text-sm md:text-base font-semibold text-gray-800 mb-2">
{{ t('contact.form.email') }}
</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="w-full px-4 md:px-5 py-3 md:py-4 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 text-base"
:placeholder="t('contact.form.email')"
/>
</div>
<div class="form-field">
<label for="subject" class="block text-sm md:text-base font-semibold text-gray-800 mb-2">
{{ t('contact.form.subject') }}
</label>
<input
id="subject"
v-model="form.subject"
type="text"
required
class="w-full px-4 md:px-5 py-3 md:py-4 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 text-base"
:placeholder="t('contact.form.subject')"
/>
</div>
<div class="form-field">
<label for="message" class="block text-sm md:text-base font-semibold text-gray-800 mb-2">
{{ t('contact.form.message') }}
</label>
<textarea
id="message"
v-model="form.message"
rows="6"
required
class="w-full px-4 md:px-5 py-3 md:py-4 rounded-xl border border-gray-200 focus:border-black focus:ring-2 focus:ring-black/5 outline-none transition-all duration-300 resize-none text-base"
:placeholder="t('contact.form.message')"
></textarea>
</div>
<button
ref="submitBtnRef"
type="submit"
:disabled="isSubmitting"
class="w-full relative overflow-hidden px-6 py-4 md:py-5 rounded-xl font-semibold text-base md:text-lg shadow-lg transition-all duration-300 flex items-center justify-center gap-3 group"
:class="isSubmitting
? 'bg-gray-400 text-gray-200 cursor-not-allowed'
: submitStatus === 'success'
? 'bg-green-600 text-white hover:bg-green-700'
: submitStatus === 'error'
? 'bg-red-600 text-white hover:bg-red-700'
: 'bg-black text-white hover:shadow-2xl'"
>
<span v-if="isSubmitting">{{ t('contact.form.sending') || 'Sending...' }}</span>
<span v-else-if="submitStatus === 'success'">{{ t('contact.form.sent') || 'Sent!' }}</span>
<span v-else-if="submitStatus === 'error'">{{ t('contact.form.retry') || 'Try Again' }}</span>
<span v-else>{{ t('contact.form.send') }}</span>
<Icon
v-if="!isSubmitting && submitStatus === 'idle'"
name="ph:paper-plane-tilt"
class="w-5 h-5 group-hover:translate-x-1 transition-transform duration-300"
/>
<Icon
v-if="isSubmitting"
name="ph:circle-notch"
class="w-5 h-5 animate-spin"
/>
<Icon
v-if="submitStatus === 'success'"
name="ph:check-circle"
class="w-5 h-5"
/>
<Icon
v-if="submitStatus === 'error'"
name="ph:warning-circle"
class="w-5 h-5"
/>
</button>
<!-- 状态消息 -->
<div
v-if="statusMessage"
class="mt-4 p-4 rounded-lg text-sm"
:class="submitStatus === 'success'
? 'bg-green-50 text-green-800 border border-green-200'
: 'bg-red-50 text-red-800 border border-red-200'"
>
{{ statusMessage }}
</div>
</form>
</div>
<!-- Contact Info -->
<div
ref="infoWrapRef"
class="lg:col-span-2 space-y-8 md:space-y-10"
style="opacity: 0; visibility: hidden;"
>
<div class="contact-info-card p-6 md:p-8 rounded-2xl bg-gradient-to-br from-gray-50 to-gray-100 border border-gray-200">
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 rounded-xl flex items-center justify-center bg-black text-white flex-shrink-0">
<Icon name="ph:envelope-simple" class="w-6 h-6" />
</div>
<div>
<h3 class="text-base md:text-lg font-bold text-gray-900 mb-2">Email</h3>
<a href="mailto:chenchun@virtheart.com" class="text-sm md:text-base text-gray-600 hover:text-black transition-colors duration-300">
{{ t('contact.info.email') }}
</a>
</div>
</div>
</div>
<div class="contact-info-card p-6 md:p-8 rounded-2xl bg-gradient-to-br from-gray-50 to-gray-100 border border-gray-200">
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 rounded-xl flex items-center justify-center bg-black text-white flex-shrink-0">
<Icon name="ph:wechat-logo" class="w-6 h-6" />
</div>
<div>
<h3 class="text-base md:text-lg font-bold text-gray-900 mb-2">{{ t('contact.info.wechat') }}</h3>
<p class="text-sm md:text-base text-gray-600">
{{ t('contact.info.wechatId') }}
</p>
</div>
</div>
</div>
<div class="contact-info-card p-6 md:p-8 rounded-2xl bg-gradient-to-br from-gray-50 to-gray-100 border border-gray-200">
<div class="flex items-start gap-4 mb-6">
<div class="w-12 h-12 rounded-xl flex items-center justify-center bg-black text-white flex-shrink-0">
<Icon name="ph:map-pin" class="w-6 h-6" />
</div>
<div>
<h3 class="text-base md:text-lg font-bold text-gray-900 mb-2">Address</h3>
<p class="text-sm md:text-base text-gray-600 whitespace-pre-line">{{ t('contact.info.address') }}</p>
</div>
</div>
</div>
<div class="contact-info-card p-6 md:p-8 rounded-2xl bg-gradient-to-br from-black to-gray-800 text-white border border-gray-800">
<h3 class="text-base md:text-lg font-bold mb-4">{{ t('contact.info.social') }}</h3>
<div class="flex gap-3">
<a
v-for="social in socials"
:key="social.name"
:href="social.url"
target="_blank"
rel="noopener noreferrer"
class="w-11 h-11 rounded-full flex items-center justify-center bg-white/10 hover:bg-white hover:text-black transition-all duration-300"
>
<Icon :name="social.icon" class="w-5 h-5" />
</a>
</div>
</div>
</div>
</div>
</main>
</template>
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, reactive } from 'vue'
import { useI18n } from 'vue-i18n'
import { useSeoMeta } from '#imports'
import gsap from 'gsap'
import { useAppStore } from '~/stores/app'
const { t } = useI18n()
const appStore = useAppStore()
const headerWrapRef = ref<HTMLElement | null>(null)
const titleRef = ref<HTMLElement | null>(null)
const descRef = ref<HTMLElement | null>(null)
const formWrapRef = ref<HTMLElement | null>(null)
const infoWrapRef = ref<HTMLElement | null>(null)
const submitBtnRef = ref<HTMLElement | null>(null)
let contactCtx: gsap.Context | null = null
const form = reactive({
name: '',
email: '',
subject: '',
message: ''
})
const isSubmitting = ref(false)
const submitStatus = ref<'idle' | 'success' | 'error'>('idle')
const statusMessage = ref('')
useSeoMeta({
title: () => t('contact.title'),
description: () => t('contact.description'),
})
const handleSubmit = async () => {
if (isSubmitting.value) return
isSubmitting.value = true
submitStatus.value = 'idle'
statusMessage.value = ''
try {
// 模拟 API 调用 - 你需要替换为真实的邮件服务
// 可选方案:
// 1. 使用 Nuxt server API: await $fetch('/api/contact', { method: 'POST', body: form })
// 2. 使用 Formspree: await fetch('https://formspree.io/f/YOUR_ID', { ... })
// 3. 使用 EmailJS 等第三方服务
await new Promise(resolve => setTimeout(resolve, 1500)) // 模拟网络延迟
// 模拟成功
console.log('Form submitted:', form)
submitStatus.value = 'success'
statusMessage.value = t('contact.form.successMessage') || 'Message sent successfully! We will get back to you soon.'
// 清空表单
Object.keys(form).forEach(key => {
form[key as keyof typeof form] = ''
})
// 3秒后重置状态
setTimeout(() => {
submitStatus.value = 'idle'
statusMessage.value = ''
}, 3000)
} catch (error) {
console.error('Form submission error:', error)
submitStatus.value = 'error'
statusMessage.value = t('contact.form.errorMessage') || 'Failed to send message. Please try again or contact us directly.'
} finally {
isSubmitting.value = false
}
}
const playPageIntro = async () => {
await nextTick()
if (!headerWrapRef.value || !titleRef.value || !descRef.value || !formWrapRef.value || !infoWrapRef.value) return
contactCtx?.revert()
contactCtx = gsap.context(() => {
const formFields = gsap.utils.toArray<HTMLElement>('.form-field', formWrapRef.value!)
const infoCards = gsap.utils.toArray<HTMLElement>('.contact-info-card', infoWrapRef.value!)
gsap.delayedCall(0, () => {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const tl = gsap.timeline({ defaults: { ease: 'power3.out' } })
tl.set(headerWrapRef.value!, { autoAlpha: 1 })
tl.fromTo(titleRef.value!, { autoAlpha: 0, y: 20 }, { autoAlpha: 1, y: 0, duration: 0.85, ease: 'power4.out', clearProps: 'transform' })
tl.fromTo(descRef.value!, { autoAlpha: 0, y: 16 }, { autoAlpha: 1, y: 0, duration: 0.7, ease: 'power4.out', clearProps: 'transform' }, '-=0.45')
tl.set(formWrapRef.value!, { autoAlpha: 1 }, '-=0.2')
tl.fromTo(
formFields,
{ autoAlpha: 0, x: -20 },
{
autoAlpha: 1,
x: 0,
duration: 0.8,
ease: 'power4.out',
stagger: { each: 0.09, from: 'start' },
clearProps: 'transform',
},
'-=0.3'
)
if (submitBtnRef.value) {
tl.fromTo(
submitBtnRef.value,
{ autoAlpha: 0, y: 20 },
{
autoAlpha: 1,
y: 0,
duration: 0.75,
ease: 'power4.out',
clearProps: 'transform',
},
'-=0.4'
)
}
tl.set(infoWrapRef.value!, { autoAlpha: 1 }, '-=0.6')
tl.fromTo(
infoCards,
{ autoAlpha: 0, x: 20 },
{
autoAlpha: 1,
x: 0,
duration: 0.8,
ease: 'power4.out',
stagger: { each: 0.1, from: 'start' },
clearProps: 'transform',
},
'-=0.7'
)
tl.set([headerWrapRef.value!, formWrapRef.value!, infoWrapRef.value!], { clearProps: 'opacity,visibility' })
})
})
})
}, headerWrapRef.value)
}
onMounted(() => {
const handleLoadingDone = () => {
playPageIntro()
}
if (!appStore.isAppLoading) {
playPageIntro()
return
}
window.addEventListener('app-loading-done', handleLoadingDone, { once: true })
})
onBeforeUnmount(() => {
contactCtx?.revert()
})
interface SocialLink {
name: string
icon: string
url: string
}
const socials: SocialLink[] = [
{
name: 'Twitter',
icon: 'ph:x-logo',
url: 'https://twitter.com'
},
{
name: 'Instagram',
icon: 'ph:instagram-logo',
url: 'https://instagram.com'
},
{
name: 'LinkedIn',
icon: 'ph:linkedin-logo',
url: 'https://linkedin.com'
},
{
name: 'GitHub',
icon: 'ph:github-logo',
url: 'https://github.com'
}
]
</script>
<style scoped>
p {
word-break: break-word;
}
</style>