feat: 添加管理后台页面和功能,优化测试和性能配置
refactor: 重构页面导航和滚动逻辑,提升用户体验 test: 更新测试配置和用例,增加覆盖率和稳定性 perf: 优化性能指标和阈值,适应开发环境需求 ci: 添加Lighthouse CI工作流,集成性能测试 docs: 更新API文档和健康检查端点 fix: 修复登录页面和表单提交问题 style: 调整响应式布局和可访问性改进 chore: 更新依赖项和脚本配置
This commit is contained in:
@@ -1,19 +1,120 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useRef, useEffect, ChangeEvent } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { contentService } from '@/lib/api/services';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { Search, ArrowLeft, Building2, Calendar, TrendingUp, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { ArrowLeft, ArrowRight, Building2, Calendar, TrendingUp } from 'lucide-react';
|
||||
import { CASES } from '@/lib/constants';
|
||||
|
||||
const industries = ['全部', '金融', '制造', '零售', '医疗', '教育'];
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
interface CaseItem {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
industry: string;
|
||||
client: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default function CasesPage() {
|
||||
const [selectedIndustry, setSelectedIndustry] = useState('全部');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [cases, setCases] = useState<CaseItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const contentRef = useRef(null);
|
||||
const isContentInView = useInView(contentRef, { once: true, margin: '-100px' });
|
||||
|
||||
const fetchCases = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await contentService.getNews(['案例'], 100, 'desc');
|
||||
const caseItems: CaseItem[] = data.map(item => ({
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.excerpt,
|
||||
industry: item.category,
|
||||
client: '客户企业',
|
||||
slug: item.slug,
|
||||
}));
|
||||
setCases(caseItems);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch cases'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchCases();
|
||||
}, []);
|
||||
|
||||
const filteredCases = useMemo(() => {
|
||||
if (!cases || cases.length === 0) return [];
|
||||
|
||||
return cases.filter((caseItem) => {
|
||||
const matchesIndustry = selectedIndustry === '全部' || caseItem.industry === selectedIndustry;
|
||||
const matchesSearch =
|
||||
caseItem.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
caseItem.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesIndustry && matchesSearch;
|
||||
});
|
||||
}, [cases, selectedIndustry, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(filteredCases.length / ITEMS_PER_PAGE);
|
||||
const paginatedCases = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
return filteredCases.slice(startIndex, endIndex);
|
||||
}, [filteredCases, currentPage]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleIndustryChange = (industry: string) => {
|
||||
setSelectedIndustry(industry);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A] mx-auto mb-4"></div>
|
||||
<p className="text-[#5C5C5C]">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">加载案例失败</p>
|
||||
<Button onClick={() => window.location.reload()}>重新加载</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<PageHeader
|
||||
@@ -27,63 +128,155 @@ export default function CasesPage() {
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回首页
|
||||
</Link>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{CASES.map((caseItem, index) => (
|
||||
<motion.div
|
||||
key={caseItem.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link
|
||||
href={`/cases/${caseItem.id}`}
|
||||
className="group bg-white rounded-2xl border border-[#E5E5E5] overflow-hidden hover:shadow-xl transition-all duration-300 block"
|
||||
>
|
||||
<div className="relative h-48 bg-gradient-to-br from-[#F5F5F5] to-[#E5E5E5] overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Building2 className="w-24 h-24 text-[#C41E3A]/20 group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="absolute top-4 right-4">
|
||||
<Badge className="bg-white/90 text-[#1C1C1C] hover:bg-white">
|
||||
{caseItem.industry}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Building2 className="w-5 h-5 text-[#C41E3A]" />
|
||||
<span className="font-semibold text-[#1C1C1C]">{caseItem.client}</span>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-8 space-y-4"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center">
|
||||
<div className="flex items-center gap-2 text-[#1C1C1C]">
|
||||
<Filter className="w-5 h-5" />
|
||||
<span className="font-medium">行业筛选:</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{industries.map((industry) => (
|
||||
<Button
|
||||
key={industry}
|
||||
variant={selectedIndustry === industry ? 'default' : 'outline'}
|
||||
onClick={() => handleIndustryChange(industry)}
|
||||
className={
|
||||
selectedIndustry === industry
|
||||
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{industry}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="text-xl font-bold text-[#1C1C1C] mb-3 group-hover:text-[#C41E3A] transition-colors">
|
||||
{caseItem.title}
|
||||
</h3>
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[#5C5C5C] w-5 h-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索案例..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
3年合作
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
数字化转型
|
||||
</Badge>
|
||||
</div>
|
||||
{paginatedCases.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-xl text-[#5C5C5C]">没有找到相关案例</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{paginatedCases.map((caseItem, index) => (
|
||||
<motion.div
|
||||
key={caseItem.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link
|
||||
href={`/cases/${caseItem.slug}`}
|
||||
className="group bg-white rounded-2xl border border-[#E5E5E5] overflow-hidden hover:shadow-xl transition-all duration-300 block"
|
||||
>
|
||||
<div className="relative h-48 bg-gradient-to-br from-[#F5F5F5] to-[#E5E5E5] overflow-hidden">
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<Building2 className="w-24 h-24 text-[#C41E3A]/20 group-hover:scale-110 transition-transform duration-300" />
|
||||
</div>
|
||||
<div className="absolute top-4 right-4">
|
||||
<Badge className="bg-white/90 text-[#1C1C1C] hover:bg-white">
|
||||
{caseItem.industry}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-[#5C5C5C] text-sm line-clamp-2 mb-4">
|
||||
{caseItem.description}
|
||||
</p>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Building2 className="w-5 h-5 text-[#C41E3A]" />
|
||||
<span className="font-semibold text-[#1C1C1C]">{caseItem.client}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center text-[#C41E3A] font-medium group-hover:translate-x-2 transition-transform">
|
||||
查看详情
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<h3 className="text-xl font-bold text-[#1C1C1C] mb-3 group-hover:text-[#C41E3A] transition-colors">
|
||||
{caseItem.title}
|
||||
</h3>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<Calendar className="w-3 h-3" />
|
||||
3年合作
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="flex items-center gap-1">
|
||||
<TrendingUp className="w-3 h-3" />
|
||||
数字化转型
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-[#5C5C5C] text-sm line-clamp-2 mb-4">
|
||||
{caseItem.description}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center text-[#C41E3A] font-medium group-hover:translate-x-2 transition-transform">
|
||||
查看详情
|
||||
<ArrowLeft className="w-4 h-4 ml-2 rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={
|
||||
currentPage === page
|
||||
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mt-4 text-[#5C5C5C] text-sm">
|
||||
显示 {paginatedCases.length} 条,共 {filteredCases.length} 条案例
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -115,7 +308,7 @@ export default function CasesPage() {
|
||||
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
|
||||
>
|
||||
立即咨询
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
<ArrowLeft className="ml-2 w-4 h-4 rotate-180" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -1,28 +1,266 @@
|
||||
'use server';
|
||||
|
||||
import { Resend } from 'resend';
|
||||
import { z } from 'zod';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const companyEmail = process.env.COMPANY_EMAIL || 'contact@novalon.cn';
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
name: z.string().min(2, '姓名至少需要2个字符'),
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '请输入有效的手机号码'),
|
||||
email: z.string().email('请输入有效的邮箱地址'),
|
||||
subject: z.string().min(2, '主题至少需要2个字符'),
|
||||
message: z.string().min(10, '留言内容至少需要10个字符'),
|
||||
website: z.string().optional(),
|
||||
submitTime: z.string().optional(),
|
||||
mathHash: z.string().optional(),
|
||||
mathTimestamp: z.string().optional(),
|
||||
mathAnswer: z.string().optional(),
|
||||
});
|
||||
|
||||
export interface ContactFormState {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
error?: string;
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
export async function submitContactForm(
|
||||
_prevState: ContactFormState | null,
|
||||
formData: FormData
|
||||
): Promise<ContactFormState> {
|
||||
const name = formData.get('name') as string;
|
||||
const email = formData.get('email') as string;
|
||||
const subject = formData.get('subject') as string;
|
||||
const message = formData.get('message') as string;
|
||||
const rawData = {
|
||||
name: formData.get('name') as string,
|
||||
phone: formData.get('phone') as string,
|
||||
email: formData.get('email') as string,
|
||||
subject: formData.get('subject') as string,
|
||||
message: formData.get('message') as string,
|
||||
website: formData.get('website') as string,
|
||||
submitTime: formData.get('submitTime') as string,
|
||||
mathHash: formData.get('mathHash') as string,
|
||||
mathTimestamp: formData.get('mathTimestamp') as string,
|
||||
mathAnswer: formData.get('mathAnswer') as string,
|
||||
};
|
||||
|
||||
if (!name || !email || !subject || !message) {
|
||||
return { success: false, error: '请填写必填字段' };
|
||||
const validationResult = contactFormSchema.safeParse(rawData);
|
||||
|
||||
if (!validationResult.success) {
|
||||
const errors: Record<string, string> = {};
|
||||
validationResult.error.issues.forEach((issue) => {
|
||||
const field = issue.path[0] as string;
|
||||
errors[field] = issue.message;
|
||||
});
|
||||
return { success: false, error: '请检查表单字段', errors };
|
||||
}
|
||||
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (!emailRegex.test(email)) {
|
||||
return { success: false, error: '请输入有效的邮箱地址' };
|
||||
const data = validationResult.data;
|
||||
|
||||
if (data.website) {
|
||||
console.log('Honeypot field filled, rejecting request');
|
||||
return { success: true, message: '消息已发送' };
|
||||
}
|
||||
|
||||
return { success: true, message: '消息已发送' };
|
||||
if (data.submitTime) {
|
||||
const timeDiff = Date.now() - parseInt(data.submitTime);
|
||||
if (timeDiff < 2000) {
|
||||
console.log('Submission too fast:', timeDiff);
|
||||
return { success: false, error: '提交过快,请稍后再试' };
|
||||
}
|
||||
}
|
||||
|
||||
if (data.mathHash && data.mathTimestamp && data.mathAnswer !== undefined) {
|
||||
const expectedHash = btoa(`${data.mathAnswer}-${data.mathTimestamp}`);
|
||||
if (expectedHash !== data.mathHash) {
|
||||
console.log('Invalid math captcha');
|
||||
return { success: false, error: '验证码错误,请重新计算' };
|
||||
}
|
||||
}
|
||||
|
||||
const emailContent = `
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #1C1C1C;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
.container {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #ffffff;
|
||||
}
|
||||
.header {
|
||||
background: #C41E3A;
|
||||
color: white;
|
||||
padding: 40px 30px;
|
||||
text-align: center;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
.header h1 {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.header p {
|
||||
margin: 10px 0 0 0;
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.content {
|
||||
padding: 40px 30px;
|
||||
background: #ffffff;
|
||||
}
|
||||
.info-card {
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 25px;
|
||||
border: 1px solid #e5e5e5;
|
||||
}
|
||||
.info-row {
|
||||
display: flex;
|
||||
margin-bottom: 12px;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.info-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.info-label {
|
||||
font-weight: 600;
|
||||
color: #1C1C1C;
|
||||
min-width: 70px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.info-value {
|
||||
color: #5C5C5C;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
.message-box {
|
||||
background: #fff;
|
||||
padding: 20px;
|
||||
border-left: 4px solid #C41E3A;
|
||||
margin-top: 20px;
|
||||
border-radius: 0 8px 8px 0;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.05);
|
||||
}
|
||||
.message-label {
|
||||
font-weight: 600;
|
||||
color: #C41E3A;
|
||||
font-size: 14px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.message-content {
|
||||
color: #1C1C1C;
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 30px;
|
||||
color: #8C8C8C;
|
||||
font-size: 12px;
|
||||
border-top: 1px solid #e5e5e5;
|
||||
}
|
||||
.footer a {
|
||||
color: #C41E3A;
|
||||
text-decoration: none;
|
||||
}
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: #e5e5e5;
|
||||
margin: 25px 0;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
background: #C41E3A;
|
||||
color: white;
|
||||
padding: 4px 12px;
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>📬 新的客户咨询</h1>
|
||||
<p>来自 睿新致远官方网站</p>
|
||||
</div>
|
||||
<div class="content">
|
||||
<span class="badge">新消息</span>
|
||||
|
||||
<div class="info-card">
|
||||
<div class="info-row">
|
||||
<div class="info-label">姓名</div>
|
||||
<div class="info-value">${data.name}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">邮箱</div>
|
||||
<div class="info-value"><a href="mailto:${data.email}" style="color: #C41E3A; text-decoration: none;">${data.email}</a></div>
|
||||
</div>
|
||||
${data.phone ? `
|
||||
<div class="info-row">
|
||||
<div class="info-label">电话</div>
|
||||
<div class="info-value">${data.phone}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<div class="info-row">
|
||||
<div class="info-label">主题</div>
|
||||
<div class="info-value">${data.subject}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="message-box">
|
||||
<div class="message-label">咨询内容</div>
|
||||
<div class="message-content">${data.message}</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<div style="text-align: center; color: #8C8C8C; font-size: 13px;">
|
||||
<p>💡 提示:点击邮箱地址可直接回复客户</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<p style="margin-bottom: 10px;">本邮件由 睿新致远 官网联系表单自动发送,请勿直接回复此邮件</p>
|
||||
<p style="margin-bottom: 10px;">如需回复客户,请点击上方邮箱地址或直接回复客户的原始邮件</p>
|
||||
<p style="margin-bottom: 15px;">提交时间:${new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })}</p>
|
||||
<p style="margin-top: 15px; border-top: 1px solid #e5e5e5; padding-top: 15px;">© ${new Date().getFullYear()} 四川睿新致远科技有限公司. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
try {
|
||||
const { data: emailData, error } = await resend.emails.send({
|
||||
from: '睿新致远官网 <onboarding@resend.dev>',
|
||||
to: [companyEmail],
|
||||
subject: `📧 ${data.subject} - ${data.name}`,
|
||||
html: emailContent,
|
||||
replyTo: data.email,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
console.error('Resend API error:', error);
|
||||
return { success: false, error: '邮件发送失败,请稍后重试' };
|
||||
}
|
||||
|
||||
console.log('Email sent successfully:', emailData);
|
||||
return { success: true, message: '消息已发送' };
|
||||
} catch (error) {
|
||||
console.error('Contact form submission error:', error);
|
||||
return { success: false, error: '提交失败,请重试' };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,7 +93,23 @@ jest.mock('@/lib/constants', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('resend', () => ({
|
||||
Resend: jest.fn().mockImplementation(() => ({
|
||||
emails: {
|
||||
send: jest.fn().mockResolvedValue({
|
||||
data: { id: 'test-email-id' },
|
||||
error: null,
|
||||
}),
|
||||
},
|
||||
})),
|
||||
}));
|
||||
|
||||
jest.mock('./actions', () => ({
|
||||
submitContactForm: jest.fn(),
|
||||
}));
|
||||
|
||||
import ContactPage from './page';
|
||||
import { submitContactForm } from './actions';
|
||||
|
||||
describe('ContactPage', () => {
|
||||
beforeEach(() => {
|
||||
@@ -197,9 +213,10 @@ describe('ContactPage', () => {
|
||||
|
||||
describe('Form Submission', () => {
|
||||
it('should submit form successfully', async () => {
|
||||
(global.fetch as jest.Mock).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({ success: true }),
|
||||
const mockSubmitContactForm = submitContactForm as jest.Mock;
|
||||
mockSubmitContactForm.mockResolvedValueOnce({
|
||||
success: true,
|
||||
message: '消息已发送',
|
||||
});
|
||||
|
||||
render(<ContactPage />);
|
||||
@@ -221,7 +238,7 @@ describe('ContactPage', () => {
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/contact', expect.any(Object));
|
||||
expect(mockSubmitContactForm).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect, useRef, useActionState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -10,6 +10,7 @@ import { sanitizeInput } from '@/lib/sanitize';
|
||||
import { generateCSRFToken, setCSRFTokenToStorage } from '@/lib/csrf';
|
||||
import { Mail, Phone, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2 } from 'lucide-react';
|
||||
import { COMPANY_INFO } from '@/lib/constants';
|
||||
import { submitContactForm, ContactFormState } from './actions';
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
name: z.string().min(2, '姓名至少需要2个字符'),
|
||||
@@ -31,8 +32,6 @@ interface FormErrors {
|
||||
|
||||
export default function ContactPage() {
|
||||
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');
|
||||
@@ -47,6 +46,14 @@ export default function ContactPage() {
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
submitContactForm,
|
||||
null as ContactFormState | null
|
||||
);
|
||||
|
||||
const isSubmitted = state?.success === true;
|
||||
const isSubmitting = isPending;
|
||||
|
||||
useEffect(() => {
|
||||
setIsVisible(true);
|
||||
|
||||
@@ -55,6 +62,28 @@ export default function ContactPage() {
|
||||
setCSRFTokenToStorage(token);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state) {
|
||||
if (state.success) {
|
||||
setToastMessage(state.message || '表单提交成功!我们会尽快与您联系。');
|
||||
setToastType('success');
|
||||
setShowToast(true);
|
||||
|
||||
const newToken = generateCSRFToken();
|
||||
setCsrfToken(newToken);
|
||||
setCSRFTokenToStorage(newToken);
|
||||
} else if (state.error) {
|
||||
setToastMessage(state.error);
|
||||
setToastType('error');
|
||||
setShowToast(true);
|
||||
|
||||
if (state.errors) {
|
||||
setErrors(state.errors);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
const validateField = (field: keyof ContactFormData, value: string) => {
|
||||
try {
|
||||
contactFormSchema.shape[field].parse(value);
|
||||
@@ -81,7 +110,7 @@ export default function ContactPage() {
|
||||
validateField(field, value);
|
||||
};
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
if (!csrfToken) {
|
||||
@@ -103,41 +132,10 @@ export default function ContactPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
csrfToken: csrfToken,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.message || '提交失败');
|
||||
}
|
||||
|
||||
const newToken = generateCSRFToken();
|
||||
setCsrfToken(newToken);
|
||||
setCSRFTokenToStorage(newToken);
|
||||
|
||||
setIsSubmitting(false);
|
||||
setIsSubmitted(true);
|
||||
setToastMessage('表单提交成功!我们会尽快与您联系。');
|
||||
setToastType('success');
|
||||
setShowToast(true);
|
||||
} catch (error) {
|
||||
setIsSubmitting(false);
|
||||
setToastMessage(error instanceof Error ? error.message : '提交失败,请稍后重试。');
|
||||
setToastType('error');
|
||||
setShowToast(true);
|
||||
}
|
||||
const form = e.currentTarget;
|
||||
const formDataObj = new FormData(form);
|
||||
formDataObj.set('submitTime', Date.now().toString());
|
||||
formAction(formDataObj);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -275,6 +273,7 @@ export default function ContactPage() {
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 flex-1 flex flex-col">
|
||||
<input type="hidden" name="_csrf" value={csrfToken} />
|
||||
<input type="text" name="website" style={{ display: 'none' }} tabIndex={-1} autoComplete="off" />
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
name="name"
|
||||
|
||||
@@ -1,17 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { ErrorBoundary } from '@/components/ui/error-boundary';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Footer } from '@/components/layout/footer';
|
||||
import { Breadcrumb } from '@/components/layout/breadcrumb';
|
||||
|
||||
const breadcrumbMap: Record<string, { label: string; href: string }> = {
|
||||
'/about': { label: '关于我们', href: '/about' },
|
||||
'/cases': { label: '成功案例', href: '/cases' },
|
||||
'/services': { label: '核心业务', href: '/services' },
|
||||
'/products': { label: '产品服务', href: '/products' },
|
||||
'/solutions': { label: '解决方案', href: '/solutions' },
|
||||
'/news': { label: '新闻动态', href: '/news' },
|
||||
'/contact': { label: '联系我们', href: '/contact' },
|
||||
};
|
||||
|
||||
export default function MarketingLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const breadcrumbItem = breadcrumbMap[pathname];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
<Header />
|
||||
<ErrorBoundary>
|
||||
<main className="flex-1">{children}</main>
|
||||
<main className="flex-1">
|
||||
{breadcrumbItem && (
|
||||
<div className="container-wide">
|
||||
<Breadcrumb items={[breadcrumbItem]} />
|
||||
</div>
|
||||
)}
|
||||
{children}
|
||||
</main>
|
||||
</ErrorBoundary>
|
||||
<Footer />
|
||||
</div>
|
||||
|
||||
@@ -2,33 +2,82 @@
|
||||
|
||||
import { useState, useMemo, useRef, ChangeEvent } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { NEWS, NewsItem } from '@/lib/constants';
|
||||
import { useNews } from '@/hooks/use-news';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { Search, Calendar, ArrowRight, ArrowLeft, Filter } from 'lucide-react';
|
||||
import { Search, Calendar, ArrowLeft, Filter, ChevronLeft, ChevronRight } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const categories = ['全部', '公司新闻', '产品发布', '合作动态', '行业资讯'];
|
||||
const ITEMS_PER_PAGE = 9;
|
||||
|
||||
export default function NewsListPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState('全部');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const contentRef = useRef(null);
|
||||
const isContentInView = useInView(contentRef, { once: true, margin: '-100px' });
|
||||
const { news, loading, error } = useNews();
|
||||
|
||||
const filteredNews = useMemo(() => {
|
||||
return NEWS.filter((news) => {
|
||||
const matchesCategory = selectedCategory === '全部' || news.category === selectedCategory;
|
||||
if (!news || news.length === 0) return [];
|
||||
|
||||
return news.filter((newsItem) => {
|
||||
const matchesCategory = selectedCategory === '全部' || newsItem.category === selectedCategory;
|
||||
const matchesSearch =
|
||||
news.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
news.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
newsItem.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
newsItem.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
}, [selectedCategory, searchQuery]);
|
||||
}, [news, selectedCategory, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(filteredNews.length / ITEMS_PER_PAGE);
|
||||
const paginatedNews = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
return filteredNews.slice(startIndex, endIndex);
|
||||
}, [filteredNews, currentPage]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategory(category);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A] mx-auto mb-4"></div>
|
||||
<p className="text-[#5C5C5C]">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">加载新闻失败</p>
|
||||
<Button onClick={() => window.location.reload()}>重新加载</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
@@ -42,6 +91,7 @@ export default function NewsListPage() {
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回首页
|
||||
</Link>
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
@@ -58,7 +108,7 @@ export default function NewsListPage() {
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||
onClick={() => setSelectedCategory(category)}
|
||||
onClick={() => handleCategoryChange(category)}
|
||||
className={
|
||||
selectedCategory === category
|
||||
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
|
||||
@@ -77,48 +127,59 @@ export default function NewsListPage() {
|
||||
type="text"
|
||||
placeholder="搜索新闻..."
|
||||
value={searchQuery}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchQuery(e.target.value)}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{filteredNews.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-xl text-[#5C5C5C]">没有找到相关新闻</p>
|
||||
</div>
|
||||
) : (
|
||||
{paginatedNews.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-xl text-[#5C5C5C]">没有找到相关新闻</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredNews.map((news: NewsItem, index: number) => (
|
||||
{paginatedNews.map((newsItem, index) => (
|
||||
<motion.div
|
||||
key={news.id}
|
||||
key={newsItem.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
|
||||
>
|
||||
<Link href={`/news/${news.id}`}>
|
||||
<Link href={`/news/${newsItem.slug}`}>
|
||||
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A]">
|
||||
<CardContent className="p-0">
|
||||
<div className="aspect-video bg-linear-to-br from-[#C41E3A]/10 to-[#1C1C1C]/10 flex items-center justify-center mb-4">
|
||||
<span className="text-4xl">📰</span>
|
||||
</div>
|
||||
{newsItem.image ? (
|
||||
<div className="aspect-video bg-gray-100 overflow-hidden">
|
||||
<img
|
||||
src={newsItem.image}
|
||||
alt={newsItem.title}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="aspect-video bg-gradient-to-br from-[#C41E3A]/10 to-[#1C1C1C]/10 flex items-center justify-center mb-4">
|
||||
<span className="text-4xl">📰</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="p-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Badge variant="secondary">{news.category}</Badge>
|
||||
<Badge variant="secondary">{newsItem.category}</Badge>
|
||||
<div className="flex items-center gap-1 text-sm text-[#5C5C5C]">
|
||||
<Calendar className="w-4 h-4" />
|
||||
{news.date}
|
||||
{newsItem.date}
|
||||
</div>
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-3 line-clamp-2">
|
||||
{news.title}
|
||||
{newsItem.title}
|
||||
</h3>
|
||||
<p className="text-[#5C5C5C] text-sm line-clamp-3 mb-4">
|
||||
{news.excerpt}
|
||||
{newsItem.excerpt}
|
||||
</p>
|
||||
<div className="flex items-center text-[#C41E3A] text-sm font-medium group">
|
||||
阅读更多
|
||||
<ArrowRight className="ml-1 w-4 h-4 group-hover:translate-x-1 transition-transform" />
|
||||
<ArrowRight className="ml-1 w-4 h-4" />
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
@@ -127,7 +188,50 @@ export default function NewsListPage() {
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={
|
||||
currentPage === page
|
||||
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mt-4 text-[#5C5C5C] text-sm">
|
||||
显示 {paginatedNews.length} 条,共 {filteredNews.length} 条新闻
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -43,26 +43,13 @@ jest.mock('next/link', () => {
|
||||
);
|
||||
});
|
||||
|
||||
jest.mock('next/dynamic', () => {
|
||||
const React = require('react');
|
||||
return {
|
||||
__esModule: true,
|
||||
default: (importFn: any, options: any) => {
|
||||
const componentName = importFn.toString().match(/\/(\w+-section)/)?.[1] || 'dynamic-component';
|
||||
const idMap: Record<string, string> = {
|
||||
'services-section': 'services',
|
||||
'products-section': 'products',
|
||||
'cases-section': 'cases',
|
||||
'about-section': 'about',
|
||||
'news-section': 'news',
|
||||
};
|
||||
const id = idMap[componentName] || componentName;
|
||||
return React.forwardRef((props: any, ref: any) => (
|
||||
<section id={id} data-testid={componentName} {...props} />
|
||||
));
|
||||
},
|
||||
};
|
||||
});
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: jest.fn().mockReturnValue({
|
||||
from: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/components/sections/hero-section', () => ({
|
||||
HeroSection: () => (
|
||||
@@ -72,11 +59,93 @@ jest.mock('@/components/sections/hero-section', () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/sections/services-section', () => ({
|
||||
ServicesSection: () => (
|
||||
<section id="services" aria-labelledby="services-heading">
|
||||
<h2 id="services-heading">我们的服务</h2>
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/sections/products-section', () => ({
|
||||
ProductsSection: () => (
|
||||
<section id="products" aria-labelledby="products-heading">
|
||||
<h2 id="products-heading">我们的产品</h2>
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/sections/cases-section', () => ({
|
||||
CasesSection: () => (
|
||||
<section id="cases" aria-labelledby="cases-heading">
|
||||
<h2 id="cases-heading">成功案例</h2>
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/sections/about-section', () => ({
|
||||
AboutSection: () => (
|
||||
<section id="about" aria-labelledby="about-heading">
|
||||
<h2 id="about-heading">关于我们</h2>
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/sections/news-section', () => ({
|
||||
NewsSection: () => (
|
||||
<section id="news" aria-labelledby="news-heading">
|
||||
<h2 id="news-heading">最新资讯</h2>
|
||||
</section>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/loading-skeleton', () => ({
|
||||
SectionSkeleton: () => <div data-testid="section-skeleton">Loading...</div>,
|
||||
}));
|
||||
|
||||
import HomePage from './page';
|
||||
jest.mock('next/dynamic', () => ({
|
||||
__esModule: true,
|
||||
default: (importFn: any) => {
|
||||
const mockComponents: Record<string, any> = {
|
||||
'@/components/sections/services-section': () => (
|
||||
<section id="services" aria-labelledby="services-heading">
|
||||
<h2 id="services-heading">我们的服务</h2>
|
||||
</section>
|
||||
),
|
||||
'@/components/sections/products-section': () => (
|
||||
<section id="products" aria-labelledby="products-heading">
|
||||
<h2 id="products-heading">我们的产品</h2>
|
||||
</section>
|
||||
),
|
||||
'@/components/sections/cases-section': () => (
|
||||
<section id="cases" aria-labelledby="cases-heading">
|
||||
<h2 id="cases-heading">成功案例</h2>
|
||||
</section>
|
||||
),
|
||||
'@/components/sections/about-section': () => (
|
||||
<section id="about" aria-labelledby="about-heading">
|
||||
<h2 id="about-heading">关于我们</h2>
|
||||
</section>
|
||||
),
|
||||
'@/components/sections/news-section': () => (
|
||||
<section id="news" aria-labelledby="news-heading">
|
||||
<h2 id="news-heading">最新资讯</h2>
|
||||
</section>
|
||||
),
|
||||
};
|
||||
|
||||
const importString = importFn.toString();
|
||||
for (const [key, component] of Object.entries(mockComponents)) {
|
||||
if (importString.includes(key.replace('@/components/sections/', ''))) {
|
||||
return component;
|
||||
}
|
||||
}
|
||||
|
||||
return () => <div>Mocked Dynamic Component</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
import { HomeContent } from './home-content';
|
||||
|
||||
describe('HomePage', () => {
|
||||
beforeEach(() => {
|
||||
@@ -85,43 +154,43 @@ describe('HomePage', () => {
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render home page', () => {
|
||||
render(<HomePage />);
|
||||
render(<HomeContent config={{}} />);
|
||||
const main = screen.getByRole('main');
|
||||
expect(main).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render hero section', () => {
|
||||
render(<HomePage />);
|
||||
render(<HomeContent config={{}} />);
|
||||
const heroSection = document.querySelector('#home');
|
||||
expect(heroSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render services section', () => {
|
||||
render(<HomePage />);
|
||||
render(<HomeContent config={{}} />);
|
||||
const servicesSection = document.querySelector('#services');
|
||||
expect(servicesSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render products section', () => {
|
||||
render(<HomePage />);
|
||||
render(<HomeContent config={{}} />);
|
||||
const productsSection = document.querySelector('#products');
|
||||
expect(productsSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render cases section', () => {
|
||||
render(<HomePage />);
|
||||
render(<HomeContent config={{}} />);
|
||||
const casesSection = document.querySelector('#cases');
|
||||
expect(casesSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render about section', () => {
|
||||
render(<HomePage />);
|
||||
render(<HomeContent config={{}} />);
|
||||
const aboutSection = document.querySelector('#about');
|
||||
expect(aboutSection).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render news section', () => {
|
||||
render(<HomePage />);
|
||||
render(<HomeContent config={{}} />);
|
||||
const newsSection = document.querySelector('#news');
|
||||
expect(newsSection).toBeInTheDocument();
|
||||
});
|
||||
@@ -129,13 +198,13 @@ describe('HomePage', () => {
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have main landmark', () => {
|
||||
render(<HomePage />);
|
||||
render(<HomeContent config={{}} />);
|
||||
const main = screen.getByRole('main');
|
||||
expect(main).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should have proper heading hierarchy', () => {
|
||||
render(<HomePage />);
|
||||
render(<HomeContent config={{}} />);
|
||||
const h1 = screen.getByRole('heading', { level: 1 });
|
||||
expect(h1).toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -1,19 +1,83 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useRef, ChangeEvent } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useProducts } from '@/hooks/use-products';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { Search, ArrowLeft, Check, TrendingUp, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useRef } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { ArrowRight, ArrowLeft, Check, TrendingUp } from 'lucide-react';
|
||||
import { PRODUCTS } from '@/lib/constants';
|
||||
|
||||
const categories = ['全部', '软件产品', '云服务', '数据分析', '信息安全'];
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
export default function ProductsPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState('全部');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const contentRef = useRef(null);
|
||||
const isContentInView = useInView(contentRef, { once: true, margin: '-100px' });
|
||||
const { products, loading, error } = useProducts();
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (!products || products.length === 0) return [];
|
||||
|
||||
return products.filter((product) => {
|
||||
const matchesCategory = selectedCategory === '全部' || product.category === selectedCategory;
|
||||
const matchesSearch =
|
||||
product.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
product.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
}, [products, selectedCategory, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(filteredProducts.length / ITEMS_PER_PAGE);
|
||||
const paginatedProducts = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
return filteredProducts.slice(startIndex, endIndex);
|
||||
}, [filteredProducts, currentPage]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategory(category);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A] mx-auto mb-4"></div>
|
||||
<p className="text-[#5C5C5C]">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">加载产品失败</p>
|
||||
<Button onClick={() => window.location.reload()}>重新加载</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
@@ -28,67 +92,159 @@ export default function ProductsPage() {
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回首页
|
||||
</Link>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{PRODUCTS.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link href={`/products/${product.id}`}>
|
||||
<Card className="h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
|
||||
<CardHeader>
|
||||
<Badge variant="secondary" className="w-fit mb-3">
|
||||
{product.category}
|
||||
</Badge>
|
||||
<CardTitle className="group-hover:text-[#C41E3A] transition-colors">{product.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col">
|
||||
<CardDescription className="text-base leading-relaxed mb-4 flex-1">
|
||||
{product.description}
|
||||
</CardDescription>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-[#1C1C1C] mb-2">核心功能</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{product.features.slice(0, 4).map((feature, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center text-xs px-2 py-1 bg-[#FAFAFA] text-[#3D3D3D] rounded border border-[#E5E5E5]"
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1 text-[#C41E3A]" />
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-[#1C1C1C] mb-2 flex items-center">
|
||||
<TrendingUp className="w-4 h-4 mr-1 text-[#C41E3A]" />
|
||||
核心价值
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{product.benefits.map((benefit, idx) => (
|
||||
<li key={idx} className="text-xs text-[#5C5C5C] flex items-start">
|
||||
<span className="text-[#C41E3A] mr-1.5">•</span>
|
||||
{benefit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-8 space-y-4"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center">
|
||||
<div className="flex items-center gap-2 text-[#1C1C1C]">
|
||||
<Filter className="w-5 h-5" />
|
||||
<span className="font-medium">分类筛选:</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||
onClick={() => handleCategoryChange(category)}
|
||||
className={
|
||||
selectedCategory === category
|
||||
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{category}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="w-full mt-auto group-hover:bg-[#C41E3A] group-hover:text-white group-hover:border-[#C41E3A] transition-colors">
|
||||
了解详情
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[#5C5C5C] w-5 h-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索产品..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{paginatedProducts.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-xl text-[#5C5C5C]">没有找到相关产品</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
|
||||
{paginatedProducts.map((product, index) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link href={`/products/${product.slug}`}>
|
||||
<Card className="h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
|
||||
<CardHeader>
|
||||
<Badge variant="secondary" className="w-fit mb-3">
|
||||
{product.category}
|
||||
</Badge>
|
||||
<CardTitle className="group-hover:text-[#C41E3A] transition-colors">{product.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col">
|
||||
<CardDescription className="text-base leading-relaxed mb-4 flex-1">
|
||||
{product.description}
|
||||
</CardDescription>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-[#1C1C1C] mb-2">核心功能</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{product.features.slice(0, 4).map((feature, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center text-xs px-2 py-1 bg-[#FAFAFA] text-[#3D3D3D] rounded border border-[#E5E5E5]"
|
||||
>
|
||||
<Check className="w-3 h-3 mr-1 text-[#C41E3A]" />
|
||||
{feature}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<p className="text-sm font-medium text-[#1C1C1C] mb-2 flex items-center">
|
||||
<TrendingUp className="w-4 h-4 mr-1 text-[#C41E3A]" />
|
||||
核心价值
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{product.benefits.map((benefit, idx) => (
|
||||
<li key={idx} className="text-xs text-[#5C5C5C] flex items-start">
|
||||
<span className="text-[#C41E3A] mr-1.5">•</span>
|
||||
{benefit}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Button variant="outline" className="w-full mt-auto group-hover:bg-[#C41E3A] group-hover:text-white group-hover:border-[#C41E3A] transition-colors">
|
||||
了解详情
|
||||
<ArrowLeft className="ml-2 w-4 h-4 rotate-180" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={
|
||||
currentPage === page
|
||||
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mt-4 text-[#5C5C5C] text-sm">
|
||||
显示 {paginatedProducts.length} 条,共 {filteredProducts.length} 条产品
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -112,7 +268,7 @@ export default function ProductsPage() {
|
||||
>
|
||||
<Link href="/contact">
|
||||
联系我们
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
<ArrowLeft className="ml-2 w-4 h-4 rotate-180" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
import { useState, useMemo, useRef, ChangeEvent } from 'react';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import { useServices } from '@/hooks/use-services';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
import { ServiceCardSkeleton } from '@/components/ui/loading-skeleton';
|
||||
import { ArrowRight, ArrowLeft, Code, Cloud, BarChart3, Shield } from 'lucide-react';
|
||||
import { SERVICES } from '@/lib/constants';
|
||||
import { Search, ArrowLeft, Code, Cloud, BarChart3, Shield, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Code,
|
||||
@@ -18,15 +18,72 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Shield,
|
||||
};
|
||||
|
||||
const categories = ['全部', '软件开发', '云服务', '数据分析', '信息安全'];
|
||||
const ITEMS_PER_PAGE = 6;
|
||||
|
||||
export default function ServicesPage() {
|
||||
const [selectedCategory, setSelectedCategory] = useState('全部');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const contentRef = useRef(null);
|
||||
const isContentInView = useInView(contentRef, { once: true, margin: '-100px' });
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const { services, loading, error } = useServices();
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setIsLoading(false), 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
const filteredServices = useMemo(() => {
|
||||
if (!services || services.length === 0) return [];
|
||||
|
||||
return services.filter((service) => {
|
||||
const matchesCategory = selectedCategory === '全部' || service.title.includes(selectedCategory);
|
||||
const matchesSearch =
|
||||
service.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
service.description.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
return matchesCategory && matchesSearch;
|
||||
});
|
||||
}, [services, selectedCategory, searchQuery]);
|
||||
|
||||
const totalPages = Math.ceil(filteredServices.length / ITEMS_PER_PAGE);
|
||||
const paginatedServices = useMemo(() => {
|
||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
const endIndex = startIndex + ITEMS_PER_PAGE;
|
||||
return filteredServices.slice(startIndex, endIndex);
|
||||
}, [filteredServices, currentPage]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setCurrentPage(page);
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setSelectedCategory(category);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
const handleSearchChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(e.target.value);
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-[#C41E3A] mx-auto mb-4"></div>
|
||||
<p className="text-[#5C5C5C]">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-red-600 mb-4">加载服务失败</p>
|
||||
<Button onClick={() => window.location.reload()}>重新加载</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
@@ -41,61 +98,145 @@ export default function ServicesPage() {
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回首页
|
||||
</Link>
|
||||
{isLoading ? (
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{Array.from({ length: 4 }).map((_, index) => (
|
||||
<ServiceCardSkeleton key={index} />
|
||||
))}
|
||||
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.6 }}
|
||||
className="mb-8 space-y-4"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row gap-4 items-start md:items-center">
|
||||
<div className="flex items-center gap-2 text-[#1C1C1C]">
|
||||
<Filter className="w-5 h-5" />
|
||||
<span className="font-medium">分类筛选:</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{categories.map((category) => (
|
||||
<Button
|
||||
key={category}
|
||||
variant={selectedCategory === category ? 'default' : 'outline'}
|
||||
onClick={() => handleCategoryChange(category)}
|
||||
className={
|
||||
selectedCategory === category
|
||||
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{category}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-[#5C5C5C] w-5 h-5" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="搜索服务..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearchChange}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{paginatedServices.length === 0 ? (
|
||||
<div className="text-center py-20">
|
||||
<p className="text-xl text-[#5C5C5C]">没有找到相关服务</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{SERVICES.map((service, index) => {
|
||||
const Icon = iconMap[service.icon];
|
||||
return (
|
||||
<motion.div
|
||||
key={service.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity:1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link
|
||||
href={`/services/${service.id}`}
|
||||
className="group bg-white rounded-2xl border border-[#E5E5E5] overflow-hidden hover:shadow-xl transition-all duration-300 block h-full"
|
||||
<>
|
||||
<div className="grid md:grid-cols-2 gap-8">
|
||||
{paginatedServices.map((service, index) => {
|
||||
const Icon = iconMap[service.icon];
|
||||
return (
|
||||
<motion.div
|
||||
key={service.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<div className="p-8">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-[#F5F5F5] flex items-center justify-center group-hover:bg-[#C41E3A] transition-all duration-300">
|
||||
{Icon && <Icon className="w-7 h-7 text-[#1C1C1C] group-hover:text-white transition-colors" />}
|
||||
<Link
|
||||
href={`/services/${service.slug}`}
|
||||
className="group bg-white rounded-2xl border border-[#E5E5E5] overflow-hidden hover:shadow-xl transition-all duration-300 block h-full"
|
||||
>
|
||||
<div className="p-8">
|
||||
<div className="flex items-start gap-4 mb-4">
|
||||
<div className="w-14 h-14 rounded-xl bg-[#F5F5F5] flex items-center justify-center group-hover:bg-[#C41E3A] transition-all duration-300">
|
||||
{Icon && <Icon className="w-7 h-7 text-[#1C1C1C] group-hover:text-white transition-colors" />}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-2 group-hover:text-[#C41E3A] transition-colors">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-[#5C5C5C] text-sm leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-xl font-semibold text-[#1C1C1C] mb-2 group-hover:text-[#C41E3A] transition-colors">
|
||||
{service.title}
|
||||
</h3>
|
||||
<p className="text-[#5C5C5C] text-sm leading-relaxed">
|
||||
{service.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t border-[#E5E5E5]">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{service.features.slice(0, 3).map((feature, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
{feature.split(':')[0]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center text-[#C41E3A] font-medium group-hover:translate-x-2 transition-transform">
|
||||
了解详情
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
<div className="mt-6 pt-4 border-t border-[#E5E5E5]">
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{service.features.slice(0, 3).map((feature, idx) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
{feature.split(':')[0]}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center text-[#C41E3A] font-medium group-hover:translate-x-2 transition-transform">
|
||||
了解详情
|
||||
<ArrowLeft className="w-4 h-4 ml-2 rotate-180" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex justify-center items-center gap-2 mt-8">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage - 1)}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? 'default' : 'outline'}
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(page)}
|
||||
className={
|
||||
currentPage === page
|
||||
? 'bg-[#C41E3A] hover:bg-[#A01830] text-white'
|
||||
: ''
|
||||
}
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handlePageChange(currentPage + 1)}
|
||||
disabled={currentPage === totalPages}
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-center mt-4 text-[#5C5C5C] text-sm">
|
||||
显示 {paginatedServices.length} 条,共 {filteredServices.length} 条服务
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,7 +261,7 @@ export default function ServicesPage() {
|
||||
>
|
||||
<Link href="/contact">
|
||||
立即咨询
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
<ArrowLeft className="ml-2 w-4 h-4 rotate-180" />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user