26aa13b5a4
ci/woodpecker/push/woodpecker Pipeline is running
优化内容: - Lint、Type Check、Security Scan并行执行 - Unit Tests使用depends_on等待所有检查完成 - 添加npm缓存配置 - 修复shared-mocks.tsx的ESLint错误 预期效果: - 串行时间: 30s + 40s + 20s = 90s - 并行时间: max(30s, 40s, 20s) = 40s - 节省时间: 50s (55.6%改善)
453 lines
17 KiB
TypeScript
453 lines
17 KiB
TypeScript
'use client';
|
||
|
||
import { useState, useEffect, useRef } from 'react';
|
||
import { z } from 'zod';
|
||
import { Button } from '@/components/ui/button';
|
||
import { Input } from '@/components/ui/input';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import { Toast } from '@/components/ui/toast';
|
||
import { sanitizeInput } from '@/lib/sanitize';
|
||
import { generateCSRFToken, setCSRFTokenToStorage, getCSRFTokenFromStorage } from '@/lib/csrf';
|
||
import { generateCaptcha } from '@/lib/security/captcha';
|
||
import { useFormAutosave } from '@/hooks/use-form-autosave';
|
||
import { trackContactForm } from '@/lib/analytics';
|
||
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, RefreshCw, Save } from 'lucide-react';
|
||
import { COMPANY_INFO } from '@/lib/constants';
|
||
|
||
const contactFormSchema = z.object({
|
||
name: z.string().min(2, '姓名至少需要2个字符'),
|
||
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号码'),
|
||
email: z.string().email('请输入有效的邮箱地址'),
|
||
message: z.string().min(10, '留言内容至少需要10个字符'),
|
||
});
|
||
|
||
type ContactFormData = z.infer<typeof contactFormSchema>;
|
||
|
||
interface FormErrors {
|
||
name?: string;
|
||
phone?: string;
|
||
email?: string;
|
||
message?: string;
|
||
captcha?: string;
|
||
}
|
||
|
||
export function ContactSection() {
|
||
const [isVisible, setIsVisible] = useState(false);
|
||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||
const [isSubmitted, setIsSubmitted] = useState(false);
|
||
const [showToast, setShowToast] = useState(false);
|
||
const [toastMessage, setToastMessage] = useState('');
|
||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||
const [errors, setErrors] = useState<FormErrors>({});
|
||
const [captcha, setCaptcha] = useState(generateCaptcha('simple'));
|
||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||
const sectionRef = useRef<HTMLElement>(null);
|
||
|
||
// 使用表单自动保存功能
|
||
const {
|
||
data: formData,
|
||
updateData,
|
||
lastSaved,
|
||
isRestored,
|
||
clearSavedData,
|
||
} = useFormAutosave<ContactFormData>({
|
||
key: 'contact_form',
|
||
initialData: {
|
||
name: '',
|
||
phone: '',
|
||
email: '',
|
||
message: '',
|
||
},
|
||
debounceMs: 1000,
|
||
});
|
||
|
||
useEffect(() => {
|
||
const observer = new IntersectionObserver(
|
||
([entry]) => {
|
||
if (entry?.isIntersecting) {
|
||
setIsVisible(true);
|
||
}
|
||
},
|
||
{ threshold: 0.1 }
|
||
);
|
||
|
||
if (sectionRef.current) {
|
||
observer.observe(sectionRef.current);
|
||
}
|
||
|
||
const csrfToken = generateCSRFToken();
|
||
setCSRFTokenToStorage(csrfToken);
|
||
|
||
return () => observer.disconnect();
|
||
}, []);
|
||
|
||
const validateField = (field: keyof ContactFormData, value: string) => {
|
||
try {
|
||
contactFormSchema.shape[field].parse(value);
|
||
setErrors((prev) => ({ ...prev, [field]: undefined }));
|
||
} catch (error) {
|
||
if (error instanceof z.ZodError) {
|
||
const fieldError = error.issues[0];
|
||
if (fieldError) {
|
||
setErrors((prev) => ({ ...prev, [field]: fieldError.message }));
|
||
}
|
||
}
|
||
}
|
||
};
|
||
|
||
const handleChange = (field: keyof ContactFormData, value: string) => {
|
||
const sanitizedValue = sanitizeInput(value);
|
||
updateData({ [field]: sanitizedValue });
|
||
if (errors[field]) {
|
||
validateField(field, sanitizedValue);
|
||
}
|
||
};
|
||
|
||
const handleBlur = (field: keyof ContactFormData, value: string) => {
|
||
validateField(field, value);
|
||
};
|
||
|
||
const handleCaptchaRefresh = () => {
|
||
setCaptcha(generateCaptcha('simple'));
|
||
setCaptchaAnswer('');
|
||
setErrors((prev) => ({ ...prev, captcha: undefined }));
|
||
};
|
||
|
||
const handleCaptchaChange = (value: string) => {
|
||
setCaptchaAnswer(value);
|
||
if (errors.captcha) {
|
||
setErrors((prev) => ({ ...prev, captcha: undefined }));
|
||
}
|
||
};
|
||
|
||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||
e.preventDefault();
|
||
|
||
const storedToken = getCSRFTokenFromStorage();
|
||
if (!storedToken) {
|
||
setToastMessage('安全验证失败,请刷新页面重试。');
|
||
setToastType('error');
|
||
setShowToast(true);
|
||
return;
|
||
}
|
||
|
||
const result = contactFormSchema.safeParse(formData);
|
||
|
||
if (!result.success) {
|
||
const fieldErrors: FormErrors = {};
|
||
result.error.issues.forEach((issue) => {
|
||
const field = issue.path[0] as keyof ContactFormData;
|
||
fieldErrors[field] = issue.message;
|
||
});
|
||
setErrors(fieldErrors);
|
||
return;
|
||
}
|
||
|
||
if (!captchaAnswer || parseInt(captchaAnswer) !== captcha.answer) {
|
||
setErrors((prev) => ({ ...prev, captcha: '验证码错误,请重新计算' }));
|
||
handleCaptchaRefresh();
|
||
return;
|
||
}
|
||
|
||
setIsSubmitting(true);
|
||
|
||
try {
|
||
const response = await fetch('/api/contact', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
body: JSON.stringify({
|
||
...formData,
|
||
csrfToken: storedToken,
|
||
mathHash: captcha.hash,
|
||
mathTimestamp: captcha.timestamp,
|
||
mathAnswer: captcha.answer,
|
||
}),
|
||
});
|
||
|
||
const data = await response.json();
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data.error || '提交失败');
|
||
}
|
||
|
||
const newCsrfToken = generateCSRFToken();
|
||
setCSRFTokenToStorage(newCsrfToken);
|
||
|
||
setIsSubmitting(false);
|
||
setIsSubmitted(true);
|
||
clearSavedData();
|
||
trackContactForm(formData);
|
||
setToastMessage('表单提交成功!我们会尽快与您联系。');
|
||
setToastType('success');
|
||
setShowToast(true);
|
||
} catch (error) {
|
||
setIsSubmitting(false);
|
||
setToastMessage(error instanceof Error ? error.message : '提交失败,请稍后重试。');
|
||
setToastType('error');
|
||
setShowToast(true);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<section id="contact" role="region" aria-labelledby="contact-heading" className="section-padding relative bg-white overflow-hidden" ref={sectionRef}>
|
||
{showToast && (
|
||
<Toast
|
||
message={toastMessage}
|
||
type={toastType}
|
||
onClose={() => setShowToast(false)}
|
||
data-testid="toast-notification"
|
||
/>
|
||
)}
|
||
<div className="absolute inset-0 pointer-events-none">
|
||
<div className="absolute inset-0 bg-gradient-radial from-[rgba(79,70,229,0.03)] via-transparent to-transparent" />
|
||
</div>
|
||
|
||
<div className="container-wide relative z-10">
|
||
<div
|
||
className={`
|
||
mb-16 opacity-0 translate-y-4
|
||
${isVisible ? 'animate-fade-in-up' : ''}
|
||
`}
|
||
>
|
||
<div className="flex items-center gap-3 mb-4">
|
||
<div className="w-8 h-px bg-linear-to-r from-[#1C1C1C] to-[#C41E3A]" />
|
||
<span className="text-sm text-[#5C5C5C] tracking-wide">联系我们</span>
|
||
</div>
|
||
<h2 id="contact-heading" className="text-4xl md:text-5xl font-bold text-[#1C1C1C] mb-4">
|
||
开启 <span className="text-[#C41E3A]">合作</span>
|
||
</h2>
|
||
<p className="mt-4 text-[#5C5C5C] max-w-2xl">
|
||
无论您有任何问题或合作意向,我们都很乐意与您交流
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid lg:grid-cols-5 gap-12 lg:gap-16">
|
||
<div
|
||
className={`
|
||
lg:col-span-2 space-y-8 flex flex-col
|
||
opacity-0 translate-y-4
|
||
${isVisible ? 'animate-fade-in-up stagger-1' : ''}
|
||
`}
|
||
>
|
||
<div>
|
||
<h3 className="text-lg font-semibold text-[#1C1C1C] mb-6">联系方式</h3>
|
||
<div className="space-y-4">
|
||
<div className="flex items-start gap-4 group">
|
||
<div className="w-10 h-10 bg-[#C41E3A] rounded-md flex items-center justify-center shrink-0 transition-transform duration-200 group-hover:scale-105">
|
||
<Mail className="w-5 h-5 text-white" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-[#5C5C5C] mb-1">邮箱</p>
|
||
<a href={`mailto:${COMPANY_INFO.email}`} className="text-[#1C1C1C] hover:text-[#C41E3A] transition-colors duration-200">
|
||
{COMPANY_INFO.email}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-4 group">
|
||
<div className="w-10 h-10 bg-[#C41E3A] rounded-md flex items-center justify-center shrink-0 transition-transform duration-200 group-hover:scale-105">
|
||
<MapPin className="w-5 h-5 text-white" />
|
||
</div>
|
||
<div>
|
||
<p className="text-sm text-[#5C5C5C] mb-1">地址</p>
|
||
<p className="text-[#1C1C1C]">{COMPANY_INFO.address}</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-[#FFFBF5] p-5 rounded-lg border border-[#E5E5E5]" aria-label="工作时间" data-testid="work-hours-card">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<Clock className="w-4 h-4 text-[#C41E3A]" />
|
||
<h4 className="text-sm font-medium text-[#1C1C1C]">工作时间</h4>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="flex justify-between text-sm" data-testid="work-hours-row">
|
||
<span className="text-[#5C5C5C]">周一至周五</span>
|
||
<span className="text-[#C41E3A]">9:00 - 18:00</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-[#FFFBF5] p-5 rounded-lg border border-[#E5E5E5]">
|
||
<div className="flex items-center gap-2 mb-3">
|
||
<HeadphonesIcon className="w-4 h-4 text-[#C41E3A]" />
|
||
<h4 className="text-sm font-medium text-[#1C1C1C]">我们的承诺</h4>
|
||
</div>
|
||
<div className="space-y-3">
|
||
<div className="flex items-start gap-2">
|
||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
|
||
<p className="text-sm text-[#5C5C5C]">工作日 2 小时内快速响应您的咨询</p>
|
||
</div>
|
||
<div className="flex items-start gap-2">
|
||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
|
||
<p className="text-sm text-[#5C5C5C]">提供免费的业务咨询和方案评估服务</p>
|
||
</div>
|
||
<div className="flex items-start gap-2">
|
||
<div className="w-1.5 h-1.5 bg-[#C41E3A] rounded-full mt-2 shrink-0" />
|
||
<p className="text-sm text-[#5C5C5C]">根据您的需求量身定制最优解决方案</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className={`
|
||
lg:col-span-3 flex flex-col
|
||
opacity-0 translate-y-4
|
||
${isVisible ? 'animate-fade-in-up stagger-2' : ''}
|
||
`}
|
||
>
|
||
<div className="bg-[#F5F7FA] p-6 sm:p-8 rounded-lg border border-[#E2E8F0] flex-1 flex flex-col">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<h3 className="text-lg font-semibold text-[#1A1A2E]">发送消息</h3>
|
||
{/* 自动保存状态指示器 */}
|
||
<div className="flex items-center gap-2 text-sm text-[#595959]">
|
||
{lastSaved && (
|
||
<>
|
||
<Save className="w-4 h-4" />
|
||
<span>已保存 {lastSaved.toLocaleTimeString()}</span>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 数据恢复提示 */}
|
||
{isRestored && (
|
||
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg flex items-center justify-between">
|
||
<span className="text-sm text-blue-700">
|
||
已恢复您上次未提交的表单内容
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={clearSavedData}
|
||
className="text-sm text-blue-600 hover:text-blue-800 underline"
|
||
>
|
||
清除
|
||
</button>
|
||
</div>
|
||
)}
|
||
|
||
{isSubmitted ? (
|
||
<div className="text-center py-12 flex-1 flex items-center justify-center" data-testid="success-message">
|
||
<div className="w-16 h-16 bg-[#C41E3A] rounded-full flex items-center justify-center mx-auto mb-4 animate-stamp-in">
|
||
<CheckCircle2 className="w-8 h-8 text-white" />
|
||
</div>
|
||
<h4 className="text-xl font-semibold text-[#1A1A2E] mb-2">消息已发送</h4>
|
||
<p className="text-[#718096]">感谢您的留言,我们会尽快与您联系!</p>
|
||
</div>
|
||
) : (
|
||
<form onSubmit={handleSubmit} className="space-y-5 flex-1 flex flex-col">
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<Input
|
||
label="姓名"
|
||
id="name"
|
||
placeholder="请输入您的姓名"
|
||
required
|
||
data-testid="name-input"
|
||
value={formData.name}
|
||
onChange={(e) => handleChange('name', e.target.value)}
|
||
onBlur={(e) => handleBlur('name', e.target.value)}
|
||
error={errors.name}
|
||
/>
|
||
<Input
|
||
label="电话"
|
||
id="phone"
|
||
type="tel"
|
||
placeholder="请输入您的电话"
|
||
required
|
||
data-testid="phone-input"
|
||
value={formData.phone}
|
||
onChange={(e) => handleChange('phone', e.target.value)}
|
||
onBlur={(e) => handleBlur('phone', e.target.value)}
|
||
error={errors.phone}
|
||
/>
|
||
</div>
|
||
<Input
|
||
label="邮箱"
|
||
id="email"
|
||
type="email"
|
||
placeholder="请输入您的邮箱"
|
||
required
|
||
data-testid="email-input"
|
||
value={formData.email}
|
||
onChange={(e) => handleChange('email', e.target.value)}
|
||
onBlur={(e) => handleBlur('email', e.target.value)}
|
||
error={errors.email}
|
||
/>
|
||
<Textarea
|
||
label="留言内容"
|
||
id="message"
|
||
placeholder="请输入您想咨询的内容"
|
||
rows={5}
|
||
required
|
||
data-testid="message-input"
|
||
value={formData.message}
|
||
onChange={(e) => handleChange('message', e.target.value)}
|
||
onBlur={(e) => handleBlur('message', e.target.value)}
|
||
error={errors.message}
|
||
/>
|
||
<div className="space-y-2">
|
||
<label htmlFor="captcha" className="block text-sm font-medium text-[#1A1A2E]">
|
||
验证码 <span className="text-[#C41E3A]">*</span>
|
||
</label>
|
||
<div className="flex items-center gap-3">
|
||
<div className="bg-[#E2E8F0] px-4 py-2 rounded-md font-mono text-lg text-[#1A1A2E] min-w-30 text-center" data-testid="captcha-question">
|
||
{captcha.question}
|
||
</div>
|
||
<div className="flex-1">
|
||
<Input
|
||
id="captcha"
|
||
type="number"
|
||
placeholder="请输入答案"
|
||
required
|
||
data-testid="captcha-input"
|
||
value={captchaAnswer}
|
||
onChange={(e) => handleCaptchaChange(e.target.value)}
|
||
error={errors.captcha}
|
||
/>
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
size="icon"
|
||
onClick={handleCaptchaRefresh}
|
||
disabled={isSubmitting}
|
||
data-testid="refresh-captcha"
|
||
className="shrink-0"
|
||
>
|
||
<RefreshCw className="w-4 h-4" />
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
<Button
|
||
type="submit"
|
||
size="lg"
|
||
className="w-full group mt-auto min-h-13 md:min-h-0"
|
||
disabled={isSubmitting}
|
||
data-testid="submit-button"
|
||
>
|
||
{isSubmitting ? (
|
||
<>
|
||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||
发送中...
|
||
</>
|
||
) : (
|
||
<>
|
||
<Send className="mr-2 h-4 w-4 transition-transform group-hover:translate-x-0.5" />
|
||
发送消息
|
||
</>
|
||
)}
|
||
</Button>
|
||
</form>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
);
|
||
}
|