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
+211
View File
@@ -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>
+291
View File
@@ -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>
+102 -7
View File
@@ -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,6 +26,52 @@ 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
@@ -35,17 +84,30 @@ onMounted(() => {
}, {
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)
})
@@ -84,17 +146,36 @@ onUnmounted(() => {
<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">
<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')"
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"
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"
: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>
+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>
+386 -6
View File
@@ -1,13 +1,393 @@
<template>
<div class="page-container flex min-h-screen items-center justify-center pt-32">
<h1 class="text-4xl font-bold">Contact Page</h1>
<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">
// Contact 页面
useSeoMeta({
title: 'Contact',
description: '联系我们页面',
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>
+276 -6
View File
@@ -1,13 +1,283 @@
<template>
<div class="page-container flex min-h-screen items-center justify-center pt-32">
<h1 class="text-4xl font-bold">News Page</h1>
<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-10 md:mb-16"
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('news.title') }}</h1>
<p ref="descRef" class="text-base md:text-lg text-gray-600 max-w-2xl">{{ t('news.description') }}</p>
</div>
<div
ref="filterWrapRef"
class="flex justify-center gap-3 md:gap-4 mb-10 md:mb-14 flex-wrap"
style="opacity: 0; visibility: hidden;"
>
<button
v-for="category in categories"
:key="category"
@click="activeCategory = category"
class="px-5 md:px-6 py-2 md:py-2.5 rounded-full text-sm md:text-base font-semibold transition-all duration-300"
:class="activeCategory === category
? 'bg-black text-white shadow-lg'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
>
{{ t(`news.categories.${category}`) }}
</button>
</div>
<div
ref="articlesWrapRef"
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 md:gap-8"
style="opacity: 0; visibility: hidden;"
>
<article
v-for="article in filteredNewsData"
:key="article.id"
class="news-card group relative flex flex-col rounded-2xl md:rounded-3xl overflow-hidden bg-white border border-gray-100 hover:shadow-xl transition-all duration-500 cursor-pointer hover:-translate-y-2"
>
<div class="relative aspect-[16/10] overflow-hidden bg-gray-100">
<img
:src="article.image"
:alt="article.title"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div class="absolute top-4 left-4">
<span
class="px-3 py-1.5 text-xs md:text-sm font-semibold rounded-full shadow-sm"
:class="article.badge"
>
{{ article.category }}
</span>
</div>
</div>
<div class="flex flex-col flex-grow p-5 md:p-6">
<div class="flex items-center gap-3 text-xs md:text-sm text-gray-500 mb-3">
<span class="flex items-center gap-1.5">
<Icon name="ph:calendar-blank" class="w-4 h-4" />
{{ article.date }}
</span>
<span class="flex items-center gap-1.5">
<Icon name="ph:clock" class="w-4 h-4" />
{{ article.readTime }}
</span>
</div>
<h3 class="text-lg md:text-xl font-bold text-black mb-3 group-hover:text-gray-700 transition-colors duration-300 line-clamp-2">
{{ article.title }}
</h3>
<p class="text-sm md:text-base text-gray-600 mb-4 flex-grow line-clamp-3">
{{ article.excerpt }}
</p>
<div class="flex items-center justify-between pt-3 border-t border-gray-100">
<span class="text-sm font-semibold text-black group-hover:text-gray-700 transition-colors duration-300">
{{ t('news.readMore') }}
</span>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-gray-50 text-gray-400 group-hover:bg-black group-hover:text-white transition-all duration-300">
<Icon name="ph:arrow-right" class="w-4 h-4" />
</div>
</div>
</div>
</article>
</div>
</main>
</template>
<script setup lang="ts">
// News 页面
useSeoMeta({
title: 'News',
description: '新闻页面',
import { nextTick, onBeforeUnmount, onMounted, ref, computed } 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 filterWrapRef = ref<HTMLElement | null>(null)
const articlesWrapRef = ref<HTMLElement | null>(null)
const activeCategory = ref('all')
let articlesCtx: gsap.Context | null = null
const categories = ['all', 'insights', 'projects', 'announcements']
// 计算属性:根据选中的分类过滤文章
const filteredNewsData = computed(() => {
if (activeCategory.value === 'all') {
return newsData
}
return newsData.filter(item => item.category.toLowerCase() === activeCategory.value.toLowerCase())
})
useSeoMeta({
title: () => t('news.title'),
description: () => t('news.description'),
})
const playPageIntro = async () => {
await nextTick()
if (!headerWrapRef.value || !titleRef.value || !descRef.value || !filterWrapRef.value || !articlesWrapRef.value) return
articlesCtx?.revert()
articlesCtx = gsap.context(() => {
const filterButtons = gsap.utils.toArray<HTMLElement>('button', filterWrapRef.value!)
const newsCards = gsap.utils.toArray<HTMLElement>('.news-card', articlesWrapRef.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(filterWrapRef.value!, { autoAlpha: 1 }, '-=0.2')
tl.fromTo(
filterButtons,
{ autoAlpha: 0, y: 20 },
{
autoAlpha: 1,
y: 0,
duration: 0.7,
ease: 'power4.out',
stagger: { each: 0.08, from: 'start' },
clearProps: 'transform',
},
'-=0.3'
)
tl.set(articlesWrapRef.value!, { autoAlpha: 1 }, '-=0.05')
tl.fromTo(
newsCards,
{ autoAlpha: 0, y: 34, scale: 0.965 },
{
autoAlpha: 1,
y: 0,
scale: 1,
duration: 0.95,
ease: 'power4.out',
stagger: { each: 0.11, from: 'start' },
clearProps: 'transform',
},
'+=0.08'
)
tl.set([headerWrapRef.value!, filterWrapRef.value!, articlesWrapRef.value!], { clearProps: 'opacity,visibility' })
})
})
})
}, headerWrapRef.value)
}
onMounted(() => {
const handleLoadingDone = () => {
playPageIntro()
}
if (!appStore.isAppLoading) {
playPageIntro()
return
}
window.addEventListener('app-loading-done', handleLoadingDone, { once: true })
})
onBeforeUnmount(() => {
articlesCtx?.revert()
})
interface NewsItem {
id: string
title: string
excerpt: string
image: string
category: string
badge: string
date: string
readTime: string
url: string
}
const newsData: NewsItem[] = [
{
id: 'design-trends-2026',
title: 'Design Trends Shaping 2026',
excerpt: 'Explore the emerging design trends that are defining digital experiences this year, from minimalism to bold typography.',
image: 'https://picsum.photos/id/1/800/500',
category: 'Insights',
badge: 'bg-purple-100 text-purple-700',
date: 'May 28, 2026',
readTime: '5 min',
url: '#'
},
{
id: 'forma-digital-launch',
title: 'Forma Digital Platform Launch',
excerpt: 'We are excited to announce the successful launch of Forma Digital, a revolutionary e-commerce experience.',
image: 'https://picsum.photos/id/201/800/500',
category: 'Projects',
badge: 'bg-blue-100 text-blue-700',
date: 'May 15, 2026',
readTime: '3 min',
url: '#'
},
{
id: 'studio-expansion',
title: 'Studio Expansion Announcement',
excerpt: 'ArcticNewOne is growing! We are expanding our team and capabilities to serve you better.',
image: 'https://picsum.photos/id/164/800/500',
category: 'Announcements',
badge: 'bg-green-100 text-green-700',
date: 'May 10, 2026',
readTime: '2 min',
url: '#'
},
{
id: 'ux-research-methods',
title: 'Modern UX Research Methods',
excerpt: 'Dive into the latest user experience research methodologies that inform our design decisions.',
image: 'https://picsum.photos/id/180/800/500',
category: 'Insights',
badge: 'bg-purple-100 text-purple-700',
date: 'Apr 28, 2026',
readTime: '7 min',
url: '#'
},
{
id: 'bold-moves-case-study',
title: 'Bold Moves: A Case Study',
excerpt: 'Behind the scenes of our award-winning architectural visualization project featuring cutting-edge 3D technology.',
image: 'https://picsum.photos/id/119/800/500',
category: 'Projects',
badge: 'bg-blue-100 text-blue-700',
date: 'Apr 20, 2026',
readTime: '6 min',
url: '#'
},
{
id: 'new-tools-integration',
title: 'New Tools Added to Our Platform',
excerpt: 'Check out the latest additions to our toolbox, designed to streamline your workflow and boost productivity.',
image: 'https://picsum.photos/id/326/800/500',
category: 'Announcements',
badge: 'bg-green-100 text-green-700',
date: 'Apr 12, 2026',
readTime: '4 min',
url: '#'
}
]
</script>
<style scoped>
p {
word-break: break-word;
}
</style>
+200 -5
View File
@@ -1,13 +1,208 @@
<template>
<div class="page-container flex min-h-screen items-center justify-center pt-32">
<h1 class="text-4xl font-bold">Studio Page</h1>
<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-24"
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('studio.title') }}</h1>
<p ref="descRef" class="text-base md:text-lg text-gray-600 max-w-2xl">{{ t('studio.description') }}</p>
</div>
<div
ref="missionWrapRef"
class="mb-20 md:mb-28"
style="opacity: 0; visibility: hidden;"
>
<div class="studio-section bg-gradient-to-br from-black to-gray-800 rounded-2xl md:rounded-3xl p-8 md:p-12 text-white">
<h2 class="text-2xl md:text-4xl font-bold mb-4 md:mb-6">{{ t('studio.mission.title') }}</h2>
<p class="text-base md:text-xl leading-relaxed text-gray-100 max-w-3xl">{{ t('studio.mission.content') }}</p>
</div>
</div>
<div
ref="valuesWrapRef"
class="mb-20 md:mb-28"
style="opacity: 0; visibility: hidden;"
>
<h2 class="text-2xl md:text-4xl font-bold mb-8 md:mb-12 text-center text-black">{{ t('studio.values.title') }}</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8">
<div
v-for="value in values"
:key="value.key"
class="value-card group relative p-6 md:p-8 rounded-2xl bg-white border border-gray-100 hover:shadow-xl transition-all duration-500 hover:-translate-y-2"
>
<div
class="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-500 bg-gradient-to-br rounded-2xl"
:class="value.color"
></div>
<div class="relative z-10">
<div class="w-12 h-12 md:w-14 md:h-14 rounded-2xl flex items-center justify-center bg-gray-50 text-black group-hover:bg-white group-hover:shadow-sm transition-all duration-300 mb-5">
<Icon :name="value.icon" class="w-6 h-6 md:w-8 md:h-8" />
</div>
<h3 class="text-xl md:text-2xl font-bold mb-3 text-black">{{ t(`studio.values.${value.key}`) }}</h3>
<p class="text-sm md:text-base text-gray-600 leading-relaxed">{{ t(`studio.values.${value.key}Desc`) }}</p>
</div>
</div>
</div>
</div>
<div
ref="teamWrapRef"
class="text-center"
style="opacity: 0; visibility: hidden;"
>
<div class="studio-section bg-gradient-to-br from-gray-50 to-gray-100 rounded-2xl md:rounded-3xl p-8 md:p-12">
<h2 class="text-2xl md:text-4xl font-bold mb-4 md:mb-6 text-black">{{ t('studio.team.title') }}</h2>
<p class="text-base md:text-xl text-gray-700 max-w-3xl mx-auto">{{ t('studio.team.description') }}</p>
</div>
</div>
</main>
</template>
<script setup lang="ts">
// Studio 页面
import { nextTick, onBeforeUnmount, onMounted, ref } 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 missionWrapRef = ref<HTMLElement | null>(null)
const valuesWrapRef = ref<HTMLElement | null>(null)
const teamWrapRef = ref<HTMLElement | null>(null)
let sectionsCtx: gsap.Context | null = null
useSeoMeta({
title: 'Studio',
description: '工作室页面',
title: () => t('studio.title'),
description: () => t('studio.description'),
})
const playPageIntro = async () => {
await nextTick()
if (!headerWrapRef.value || !titleRef.value || !descRef.value || !missionWrapRef.value || !valuesWrapRef.value || !teamWrapRef.value) return
sectionsCtx?.revert()
sectionsCtx = gsap.context(() => {
const valueCards = gsap.utils.toArray<HTMLElement>('.value-card', valuesWrapRef.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(missionWrapRef.value!, { autoAlpha: 1 }, '-=0.2')
tl.fromTo(
'.studio-section',
{ autoAlpha: 0, y: 30, scale: 0.97 },
{
autoAlpha: 1,
y: 0,
scale: 1,
duration: 0.9,
ease: 'power4.out',
clearProps: 'transform',
},
missionWrapRef.value
)
tl.set(valuesWrapRef.value!, { autoAlpha: 1 }, '-=0.1')
tl.fromTo(
valueCards,
{ autoAlpha: 0, y: 34, scale: 0.965 },
{
autoAlpha: 1,
y: 0,
scale: 1,
duration: 0.95,
ease: 'power4.out',
stagger: { each: 0.12, from: 'start' },
clearProps: 'transform',
},
'+=0.1'
)
tl.set(teamWrapRef.value!, { autoAlpha: 1 }, '-=0.3')
tl.fromTo(
teamWrapRef.value!,
{ autoAlpha: 0, y: 30 },
{
autoAlpha: 1,
y: 0,
duration: 0.85,
ease: 'power4.out',
clearProps: 'transform',
},
'+=0.15'
)
tl.set([headerWrapRef.value!, missionWrapRef.value!, valuesWrapRef.value!, teamWrapRef.value!], { clearProps: 'opacity,visibility' })
})
})
})
}, headerWrapRef.value)
}
onMounted(() => {
const handleLoadingDone = () => {
playPageIntro()
}
if (!appStore.isAppLoading) {
playPageIntro()
return
}
window.addEventListener('app-loading-done', handleLoadingDone, { once: true })
})
onBeforeUnmount(() => {
sectionsCtx?.revert()
})
interface ValueItem {
key: string
icon: string
color: string
}
const values: ValueItem[] = [
{
key: 'creativity',
icon: 'ph:lightbulb-duotone',
color: 'from-yellow-50/50 to-orange-100/50'
},
{
key: 'quality',
icon: 'ph:medal-duotone',
color: 'from-blue-50/50 to-cyan-100/50'
},
{
key: 'collaboration',
icon: 'ph:users-three-duotone',
color: 'from-purple-50/50 to-pink-100/50'
},
{
key: 'integrity',
icon: 'ph:shield-check-duotone',
color: 'from-green-50/50 to-emerald-100/50'
}
]
</script>
<style scoped>
p {
word-break: break-word;
}
</style>
+245 -6
View File
@@ -1,13 +1,252 @@
<template>
<div class="page-container flex min-h-screen items-center justify-center pt-32">
<h1 class="text-4xl font-bold">Work Page</h1>
<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-10 md:mb-16"
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('work.title') }}</h1>
<p ref="descRef" class="text-base md:text-lg text-gray-600 max-w-2xl">{{ t('work.description') }}</p>
</div>
<div
ref="filterWrapRef"
class="flex justify-center gap-3 md:gap-4 mb-10 md:mb-14 flex-wrap"
style="opacity: 0; visibility: hidden;"
>
<button
v-for="category in categories"
:key="category"
@click="activeCategory = category"
class="px-5 md:px-6 py-2 md:py-2.5 rounded-full text-sm md:text-base font-semibold transition-all duration-300"
:class="activeCategory === category
? 'bg-black text-white shadow-lg'
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
>
{{ t(`work.categories.${category}`) }}
</button>
</div>
<div
ref="projectsWrapRef"
class="grid grid-cols-1 md:grid-cols-2 gap-6 md:gap-8"
style="opacity: 0; visibility: hidden;"
>
<a
v-for="project in filteredWorkData"
:key="project.id"
:href="project.url"
class="work-card group relative block rounded-2xl md:rounded-3xl overflow-hidden cursor-pointer hover:shadow-2xl transition-all duration-500"
>
<div class="relative aspect-[4/3] overflow-hidden bg-gray-100">
<img
:src="project.image"
:alt="project.title"
class="w-full h-full object-cover transition-transform duration-700 group-hover:scale-110"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-500"></div>
</div>
<div class="p-6 md:p-8 bg-white">
<div class="flex items-start justify-between mb-3">
<h3 class="text-xl md:text-2xl font-bold text-black group-hover:text-gray-700 transition-colors duration-300">{{ project.title }}</h3>
<div class="w-9 h-9 md:w-10 md:h-10 rounded-full flex items-center justify-center bg-gray-50 text-gray-400 group-hover:bg-black group-hover:text-white transition-all duration-300 flex-shrink-0 ml-3">
<Icon name="ph:arrow-up-right" class="w-4 h-4 md:w-5 md:h-5" />
</div>
</div>
<p class="text-sm md:text-base text-gray-600 mb-4">{{ project.description }}</p>
<div class="flex flex-wrap gap-2">
<span
v-for="tag in project.tags"
:key="tag"
class="px-3 py-1 text-xs md:text-sm bg-gray-50 text-gray-700 rounded-full"
>
{{ tag }}
</span>
</div>
</div>
</a>
</div>
</main>
</template>
<script setup lang="ts">
// Work 页面
useSeoMeta({
title: 'Work',
description: '作品页面',
import { nextTick, onBeforeUnmount, onMounted, ref, computed } 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 filterWrapRef = ref<HTMLElement | null>(null)
const projectsWrapRef = ref<HTMLElement | null>(null)
const activeCategory = ref('all')
let projectsCtx: gsap.Context | null = null
const categories = ['all', 'webDesign', 'branding', 'development']
// 计算属性:根据选中的分类过滤项目
const filteredWorkData = computed(() => {
if (activeCategory.value === 'all') {
return workData
}
return workData.filter(item => item.category === activeCategory.value)
})
useSeoMeta({
title: () => t('work.title'),
description: () => t('work.description'),
})
const playPageIntro = async () => {
await nextTick()
if (!headerWrapRef.value || !titleRef.value || !descRef.value || !filterWrapRef.value || !projectsWrapRef.value) return
projectsCtx?.revert()
projectsCtx = gsap.context(() => {
const filterButtons = gsap.utils.toArray<HTMLElement>('button', filterWrapRef.value!)
const projectCards = gsap.utils.toArray<HTMLElement>('.work-card', projectsWrapRef.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(filterWrapRef.value!, { autoAlpha: 1 }, '-=0.2')
tl.fromTo(
filterButtons,
{ autoAlpha: 0, y: 20 },
{
autoAlpha: 1,
y: 0,
duration: 0.7,
ease: 'power4.out',
stagger: { each: 0.08, from: 'start' },
clearProps: 'transform',
},
'-=0.3'
)
tl.set(projectsWrapRef.value!, { autoAlpha: 1 }, '-=0.05')
tl.fromTo(
projectCards,
{ autoAlpha: 0, y: 34, scale: 0.965 },
{
autoAlpha: 1,
y: 0,
scale: 1,
duration: 0.95,
ease: 'power4.out',
stagger: { each: 0.13, from: 'start' },
clearProps: 'transform',
},
'+=0.08'
)
tl.set([headerWrapRef.value!, filterWrapRef.value!, projectsWrapRef.value!], { clearProps: 'opacity,visibility' })
})
})
})
}, headerWrapRef.value)
}
onMounted(() => {
const handleLoadingDone = () => {
playPageIntro()
}
if (!appStore.isAppLoading) {
playPageIntro()
return
}
window.addEventListener('app-loading-done', handleLoadingDone, { once: true })
})
onBeforeUnmount(() => {
projectsCtx?.revert()
})
interface WorkItem {
id: string
title: string
description: string
image: string
url: string
tags: string[]
category: string
}
const workData: WorkItem[] = [
{
id: 'forma-digital',
title: 'Forma Digital',
description: 'A modern e-commerce platform with seamless user experience and dynamic product showcases.',
image: 'https://picsum.photos/id/201/800/600',
url: '#',
tags: ['Web Design', 'E-commerce', 'React'],
category: 'webDesign'
},
{
id: 'one-step',
title: 'One Step',
description: 'Brand identity and marketing campaign for a fitness app focused on sustainable habits.',
image: 'https://picsum.photos/id/180/800/600',
url: '#',
tags: ['Branding', 'Marketing', 'UI/UX'],
category: 'branding'
},
{
id: 'nero-vision',
title: 'Nero Vision',
description: 'Full-stack web application for creative professionals to manage their portfolios.',
image: 'https://picsum.photos/id/119/800/600',
url: '#',
tags: ['Development', 'Vue.js', 'Node.js'],
category: 'development'
},
{
id: 'bold-moves',
title: 'Bold Moves',
description: 'Interactive website showcasing innovative architectural designs with 3D visualizations.',
image: 'https://picsum.photos/id/164/800/600',
url: '#',
tags: ['Web Design', '3D', 'Three.js'],
category: 'webDesign'
},
{
id: 'echo-labs',
title: 'Echo Labs',
description: 'Corporate branding and visual identity for a tech startup in the AI space.',
image: 'https://picsum.photos/id/101/800/600',
url: '#',
tags: ['Branding', 'Visual Identity', 'Corporate'],
category: 'branding'
},
{
id: 'flux-api',
title: 'Flux API',
description: 'Scalable REST API with comprehensive documentation and developer tools.',
image: 'https://picsum.photos/id/326/800/600',
url: '#',
tags: ['Development', 'API', 'Python'],
category: 'development'
}
]
</script>
<style scoped>
p {
word-break: break-word;
}
</style>
+121
View File
@@ -0,0 +1,121 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export interface User {
id: string
name: string
email: string
avatar?: string
}
export const useUserStore = defineStore('user', () => {
const user = ref<User | null>(null)
const isAuthenticated = computed(() => user.value !== null)
// 登录
const login = async (account: string, password: string) => {
try {
// 模拟 API 调用 - 你需要替换为真实的登录 API
// const response = await $fetch('/api/auth/login', {
// method: 'POST',
// body: { account, password }
// })
// 模拟延迟
await new Promise(resolve => setTimeout(resolve, 1000))
// 判断账号类型并提取用户名
let username = account
if (account.includes('@')) {
// 邮箱:使用 @ 前面的部分
username = account.split('@')[0]
} else if (/^1[3-9]\d{9}$/.test(account)) {
// 手机号:使用后4位
username = 'User_' + account.slice(-4)
}
// 其他情况:直接使用账号作为用户名
// 模拟成功登录
user.value = {
id: '1',
name: username,
email: account.includes('@') ? account : `${account}@example.com`,
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(username)}&background=000&color=fff&size=128`
}
// 保存到 localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('user', JSON.stringify(user.value))
}
return { success: true }
} catch (error) {
console.error('Login error:', error)
return { success: false, error: 'Login failed' }
}
}
// 登出
const logout = () => {
user.value = null
if (typeof window !== 'undefined') {
localStorage.removeItem('user')
}
}
// 注册
const register = async (data: { username: string; email: string; phone?: string; password: string }) => {
try {
// 模拟 API 调用 - 你需要替换为真实的注册 API
// const response = await $fetch('/api/auth/register', {
// method: 'POST',
// body: data
// })
// 模拟延迟
await new Promise(resolve => setTimeout(resolve, 1000))
// 模拟成功注册并自动登录
user.value = {
id: Date.now().toString(),
name: data.username,
email: data.email,
avatar: `https://ui-avatars.com/api/?name=${encodeURIComponent(data.username)}&background=000&color=fff&size=128`
}
// 保存到 localStorage
if (typeof window !== 'undefined') {
localStorage.setItem('user', JSON.stringify(user.value))
}
return { success: true }
} catch (error) {
console.error('Register error:', error)
return { success: false, error: 'Registration failed' }
}
}
// 从 localStorage 恢复用户状态
const restoreUser = () => {
if (typeof window !== 'undefined') {
const savedUser = localStorage.getItem('user')
if (savedUser) {
try {
user.value = JSON.parse(savedUser)
} catch (error) {
console.error('Failed to restore user:', error)
localStorage.removeItem('user')
}
}
}
}
return {
user,
isAuthenticated,
login,
logout,
register,
restoreUser,
}
})
+101
View File
@@ -62,5 +62,106 @@
"copyright": "© 2026 ArcticNewOne Studio",
"address": "Binhe West Road, Qixingguan District, Bijie City, Guizhou Province",
"wechat": "WeChat"
},
"work": {
"title": "Our Work",
"description": "Explore our portfolio of creative projects and digital experiences that drive results.",
"categories": {
"all": "All",
"webDesign": "Web Design",
"branding": "Branding",
"development": "Development"
},
"cta": "View Project"
},
"studio": {
"title": "About Studio",
"description": "We are a creative studio focused on crafting meaningful digital experiences.",
"mission": {
"title": "Our Mission",
"content": "To create exceptional digital experiences that inspire, engage, and deliver measurable results for our clients."
},
"values": {
"title": "Core Values",
"creativity": "Creativity",
"creativityDesc": "Innovation and original thinking in every project.",
"quality": "Quality",
"qualityDesc": "Commitment to excellence in design and development.",
"collaboration": "Collaboration",
"collaborationDesc": "Partnership with clients to achieve shared goals.",
"integrity": "Integrity",
"integrityDesc": "Honest communication and transparent processes."
},
"team": {
"title": "Our Team",
"description": "A diverse group of talented designers, developers, and strategists."
}
},
"contact": {
"title": "Get In Touch",
"description": "Ready to start your next project? Let's talk about how we can help bring your vision to life.",
"form": {
"name": "Your Name",
"email": "Email Address",
"subject": "Subject",
"message": "Your Message",
"send": "Send Message",
"sending": "Sending...",
"sent": "Sent!",
"retry": "Try Again",
"successMessage": "Message sent successfully! We will get back to you soon.",
"errorMessage": "Failed to send message. Please try again or contact us directly."
},
"info": {
"email": "chenchun{'@'}virtheart.com",
"wechat": "WeChat",
"wechatId": "LucskyNew",
"address": "Binhe West Road, Qixingguan District\nBijie City, Guizhou Province",
"social": "Follow Us"
}
},
"news": {
"title": "News & Updates",
"description": "Stay informed with our latest insights, project updates, and industry trends.",
"readMore": "Read More",
"categories": {
"all": "All Posts",
"insights": "Insights",
"projects": "Projects",
"announcements": "Announcements"
}
},
"auth": {
"login": "Login",
"logout": "Logout",
"loginDescription": "Welcome back! Please login to your account.",
"account": "Account",
"accountPlaceholder": "Username / Email / Phone",
"password": "Password",
"passwordPlaceholder": "Enter your password",
"loginButton": "Login",
"loggingIn": "Logging in...",
"loginError": "Login failed. Please check your credentials.",
"demoHint": "Demo: Use any account and password to login",
"noAccount": "Don't have an account?",
"createAccount": "Sign Up",
"register": "Sign Up",
"registerDescription": "Create your account to get started.",
"username": "Username",
"usernamePlaceholder": "Enter your username",
"email": "Email",
"emailPlaceholder": "Enter your email address",
"phone": "Phone",
"phonePlaceholder": "Enter your phone (optional)",
"confirmPassword": "Confirm Password",
"confirmPasswordPlaceholder": "Enter your password again",
"registerButton": "Sign Up",
"registering": "Signing up...",
"registerError": "Registration failed. Please try again.",
"registerSuccess": "Registration successful! Logging you in...",
"passwordMismatch": "Passwords do not match",
"invalidPhone": "Invalid phone number format",
"alreadyHaveAccount": "Already have an account?",
"backToLogin": "Back to Login"
}
}
+101
View File
@@ -62,5 +62,106 @@
"copyright": "© 2026 北极新一工作室",
"address": "贵州省毕节市七星关区滨河西路",
"wechat": "微信"
},
"work": {
"title": "我们的作品",
"description": "探索我们的创意项目和数字体验作品集,见证成果驱动的设计。",
"categories": {
"all": "全部",
"webDesign": "网页设计",
"branding": "品牌设计",
"development": "程序开发"
},
"cta": "查看项目"
},
"studio": {
"title": "关于工作室",
"description": "我们是一家专注于打造有意义数字体验的创意工作室。",
"mission": {
"title": "我们的使命",
"content": "为客户创造卓越的数字体验,激发灵感、提升参与度,并实现可衡量的成果。"
},
"values": {
"title": "核心价值观",
"creativity": "创造力",
"creativityDesc": "在每个项目中注入创新和原创思维。",
"quality": "品质",
"qualityDesc": "对设计和开发精益求精的承诺。",
"collaboration": "协作",
"collaborationDesc": "与客户合作实现共同目标。",
"integrity": "诚信",
"integrityDesc": "诚实沟通和透明的工作流程。"
},
"team": {
"title": "我们的团队",
"description": "由才华横溢的设计师、开发人员和策略师组成的多元化团队。"
}
},
"contact": {
"title": "联系我们",
"description": "准备好启动您的下一个项目了吗?让我们聊聊如何帮助您实现愿景。",
"form": {
"name": "您的姓名",
"email": "电子邮箱",
"subject": "主题",
"message": "留言内容",
"send": "发送消息",
"sending": "发送中...",
"sent": "已发送!",
"retry": "重试",
"successMessage": "消息发送成功!我们会尽快回复您。",
"errorMessage": "发送失败,请重试或直接联系我们。"
},
"info": {
"email": "chenchun{'@'}virtheart.com",
"wechat": "微信",
"wechatId": "LucskyNew",
"address": "贵州省毕节市七星关区\n滨河西路",
"social": "关注我们"
}
},
"news": {
"title": "新闻动态",
"description": "获取我们的最新见解、项目更新和行业趋势。",
"readMore": "阅读更多",
"categories": {
"all": "全部文章",
"insights": "行业洞察",
"projects": "项目动态",
"announcements": "公告通知"
}
},
"auth": {
"login": "登录",
"logout": "退出登录",
"loginDescription": "欢迎回来!请登录您的账户。",
"account": "账号",
"accountPlaceholder": "用户名 / 邮箱 / 手机号",
"password": "密码",
"passwordPlaceholder": "请输入密码",
"loginButton": "登录",
"loggingIn": "登录中...",
"loginError": "登录失败,请检查您的凭据。",
"demoHint": "演示:使用任意账号和密码即可登录",
"noAccount": "还没有账号?",
"createAccount": "立即注册",
"register": "注册账号",
"registerDescription": "创建您的账户,开始使用我们的服务。",
"username": "用户名",
"usernamePlaceholder": "请输入用户名",
"email": "邮箱",
"emailPlaceholder": "请输入邮箱地址",
"phone": "手机号",
"phonePlaceholder": "请输入手机号(可选)",
"confirmPassword": "确认密码",
"confirmPasswordPlaceholder": "请再次输入密码",
"registerButton": "注册",
"registering": "注册中...",
"registerError": "注册失败,请重试。",
"registerSuccess": "注册成功!正在为您登录...",
"passwordMismatch": "两次输入的密码不一致",
"invalidPhone": "手机号格式不正确",
"alreadyHaveAccount": "已有账号?",
"backToLogin": "返回登录"
}
}