refactor: 完成静态网站转换,移除所有 CMS 和动态功能

- 删除数据库相关代码 (src/db/)
- 删除 API 路由 (src/app/api/)
- 删除认证相关代码 (src/lib/auth/, src/providers/)
- 删除监控和安全中间件 (src/lib/security/, src/lib/monitoring/)
- 删除 hooks (use-news, use-products, use-services)
- 更新组件为静态数据源
- 添加 nginx 静态配置和部署脚本
- 添加 static-link 组件
This commit is contained in:
张翔
2026-04-21 07:53:56 +08:00
parent cd1d6aa28a
commit 6403489954
197 changed files with 654 additions and 24762 deletions
+4 -4
View File
@@ -1,7 +1,7 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BackButton } from '@/components/ui/back-button';
@@ -99,7 +99,7 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
</h2>
</div>
<div className="prose prose-sm max-w-none">
<div className="prose prose-base max-w-none [&_h3]:text-xl [&_h3]:font-semibold [&_h3]:text-[#1C1C1C] [&_h3]:mt-8 [&_h3]:mb-4 [&_p]:text-[#5C5C5C] [&_p]:leading-[1.8] [&_p]:mb-4 [&_p]:text-base">
<div dangerouslySetInnerHTML={{ __html: caseItem.content }} />
</div>
</section>
@@ -237,9 +237,9 @@ export function CaseDetailClient({ caseItem }: CaseDetailClientProps) {
className="w-full bg-white text-[#C41E3A] hover:bg-white/90"
asChild
>
<Link href="/contact">
<StaticLink href="/contact">
</Link>
</StaticLink>
</Button>
</div>
</div>
+15 -20
View File
@@ -1,30 +1,17 @@
import { Metadata } from 'next';
import { notFound } from 'next/navigation';
import { contentService } from '@/lib/api/services';
import { CASES } from '@/lib/constants';
import { CaseDetailClient } from './client';
interface CaseItem {
id: string;
title: string;
excerpt: string;
content: string;
category: string;
slug: string;
date: string;
image?: string;
}
export async function generateStaticParams() {
const cases = await contentService.getCases(100);
return cases.map((caseItem) => ({
return CASES.map((caseItem) => ({
id: caseItem.id,
}));
}
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }): Promise<Metadata> {
const { id } = await params;
const cases = await contentService.getCases(100);
const caseItem = cases.find((c) => c.id === id);
const caseItem = CASES.find((c) => c.id === id);
if (!caseItem) {
return {
@@ -34,18 +21,26 @@ export async function generateMetadata({ params }: { params: Promise<{ id: strin
return {
title: `${caseItem.title} - 睿新致远`,
description: caseItem.excerpt,
description: caseItem.description,
};
}
export default async function CaseDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const cases = await contentService.getCases(100);
const caseItem = cases.find((c) => c.id === id);
const caseItem = CASES.find((c) => c.id === id);
if (!caseItem) {
notFound();
}
return <CaseDetailClient caseItem={caseItem as CaseItem} />;
return <CaseDetailClient caseItem={{
id: caseItem.id,
title: caseItem.title,
excerpt: caseItem.description,
content: caseItem.content || '',
category: caseItem.industry,
slug: caseItem.id,
date: '2026-01-15',
image: caseItem.image,
}} />;
}
-265
View File
@@ -1,265 +0,0 @@
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
import { render, screen, waitFor } from '@testing-library/react';
import '@testing-library/jest-dom';
jest.mock('framer-motion', () => ({
motion: {
div: ({ children, className, ...props }: any) => (
<div className={className} {...props}>
{children}
</div>
),
section: ({ children, className, ...props }: any) => (
<section className={className} {...props}>
{children}
</section>
),
},
AnimatePresence: ({ children }: any) => <>{children}</>,
useInView: () => [null, true],
}));
jest.mock('next/link', () => {
return ({ children, href, ...props }: any) => (
<a href={href} {...props}>
{children}
</a>
);
});
jest.mock('lucide-react', () => ({
ArrowRight: () => <span data-testid="arrow-right-icon" />,
ArrowLeft: () => <span data-testid="arrow-left-icon" />,
Building2: () => <span data-testid="building-icon" />,
Calendar: () => <span data-testid="calendar-icon" />,
TrendingUp: () => <span data-testid="trending-up-icon" />,
ChevronLeft: () => <span data-testid="chevron-left-icon" />,
ChevronRight: () => <span data-testid="chevron-right-icon" />,
Filter: () => <span data-testid="filter-icon" />,
Search: () => <span data-testid="search-icon" />,
}));
jest.mock('@/components/ui/button', () => ({
Button: ({ children, className, variant, size, disabled, onClick, ...props }: any) => (
<button
className={className}
data-variant={variant}
data-size={size}
disabled={disabled}
onClick={onClick}
{...props}
>
{children}
</button>
),
}));
jest.mock('@/components/ui/badge', () => ({
Badge: ({ children, className, variant, ...props }: any) => (
<span className={className} data-variant={variant} {...props}>
{children}
</span>
),
}));
jest.mock('@/components/ui/input', () => ({
Input: ({ className, ...props }: any) => (
<input className={className} {...props} />
),
}));
jest.mock('@/components/ui/page-header', () => ({
PageHeader: ({ title, description }: any) => (
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
),
}));
const mockCases = [
{
id: 'case-1',
title: '数字化转型案例',
excerpt: '帮助客户实现数字化转型',
content: '详细的数字化转型案例内容',
category: '制造业',
slug: 'digital-transformation',
date: '2024-01-15',
},
{
id: 'case-2',
title: 'ERP系统实施案例',
excerpt: 'ERP系统成功实施',
content: '详细的ERP系统实施案例内容',
category: '零售业',
slug: 'erp-implementation',
date: '2024-01-10',
},
{
id: 'case-3',
title: '智能制造升级',
excerpt: '智能制造系统升级',
content: '详细的智能制造升级案例内容',
category: '制造业',
slug: 'smart-manufacturing',
date: '2024-01-05',
},
];
jest.mock('@/lib/api/services', () => ({
contentService: {
getNews: jest.fn(),
},
}));
import CasesPage from './page';
import { contentService } from '@/lib/api/services';
describe('CasesPage', () => {
beforeEach(() => {
jest.clearAllMocks();
(contentService.getNews as jest.Mock).mockResolvedValue(mockCases);
});
describe('Rendering', () => {
it('should render loading state initially', () => {
render(<CasesPage />);
expect(screen.getByText('加载中...')).toBeInTheDocument();
});
it('should render cases page after loading', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const pageContainer = document.querySelector('.min-h-screen');
expect(pageContainer).toBeInTheDocument();
});
it('should render page header', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const title = screen.getByText(/与谁同行/i);
expect(title).toBeInTheDocument();
});
it('should render back to home link', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const backLink = screen.getByText(/返回首页/i);
expect(backLink).toBeInTheDocument();
});
it('should render case cards', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const caseTitles = screen.getAllByRole('heading', { level: 3 });
expect(caseTitles.length).toBeGreaterThan(0);
});
it('should render case categories', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
expect(screen.getByText('全部')).toBeInTheDocument();
expect(screen.getByText('金融')).toBeInTheDocument();
expect(screen.getByText('制造')).toBeInTheDocument();
});
it('should render CTA section', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const cta = screen.getByText(/准备开始您的数字化转型之旅/i);
expect(cta).toBeInTheDocument();
});
});
describe('Navigation', () => {
it('should have case detail links', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const links = screen.getAllByRole('link');
const caseLinks = links.filter(link => link.getAttribute('href')?.startsWith('/cases/'));
expect(caseLinks.length).toBeGreaterThan(0);
});
it('should have contact links', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const links = screen.getAllByRole('link');
const contactLinks = links.filter(link => link.getAttribute('href') === '/contact');
expect(contactLinks.length).toBeGreaterThan(0);
});
});
describe('Accessibility', () => {
it('should have proper heading hierarchy', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const h1 = screen.getByRole('heading', { level: 1 });
expect(h1).toBeInTheDocument();
});
});
describe('Filtering', () => {
it('should render filter buttons', async () => {
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const filterButtons = screen.getAllByRole('button');
expect(filterButtons.length).toBeGreaterThan(0);
});
});
describe('Error Handling', () => {
it('should display error message when API fails', async () => {
(contentService.getNews as jest.Mock).mockRejectedValue(new Error('API Error'));
render(<CasesPage />);
await waitFor(() => {
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
});
const errorMessage = screen.getByText(/加载案例失败/i);
expect(errorMessage).toBeInTheDocument();
});
});
});
+15 -81
View File
@@ -1,74 +1,35 @@
'use client';
import { useState, useMemo, useRef, useEffect, ChangeEvent } from 'react';
import { useState, useMemo, useRef, ChangeEvent } from 'react';
import { useInView } from 'framer-motion';
import { contentService } from '@/lib/api/services';
import { CASES } from '@/lib/constants';
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 { StaticLink } from '@/components/ui/static-link';
import { motion } from 'framer-motion';
const industries = ['全部', '金融', '制造', '零售', '医疗', '教育'];
const industries = ['全部', ...Array.from(new Set(CASES.map((c) => c.industry)))];
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) => {
return CASES.filter((caseItem) => {
const matchesIndustry = selectedIndustry === '全部' || caseItem.industry === selectedIndustry;
const matchesSearch =
const matchesSearch =
caseItem.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
caseItem.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesIndustry && matchesSearch;
});
}, [cases, selectedIndustry, searchQuery]);
}, [selectedIndustry, searchQuery]);
const totalPages = Math.ceil(filteredCases.length / ITEMS_PER_PAGE);
const paginatedCases = useMemo(() => {
@@ -92,28 +53,6 @@ export default function CasesPage() {
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" />
<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
@@ -123,12 +62,7 @@ export default function CasesPage() {
<div className="container-wide relative z-10 py-16" ref={contentRef}>
<div className="max-w-6xl mx-auto">
<Link href="/" className="inline-flex items-center text-[#5C5C5C] hover:text-[#C41E3A] transition-colors mb-8">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
<motion.div
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
@@ -183,8 +117,8 @@ export default function CasesPage() {
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Link
href={`/cases/${caseItem.slug}`}
<StaticLink
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">
@@ -228,7 +162,7 @@ export default function CasesPage() {
<ArrowLeft className="w-4 h-4 ml-2 rotate-180" />
</div>
</div>
</Link>
</StaticLink>
</motion.div>
))}
</div>
@@ -293,15 +227,15 @@ export default function CasesPage() {
</p>
<div className="flex justify-center gap-4">
<Link href="/contact">
<StaticLink href="/contact">
<Button
size="lg"
variant="outline"
>
</Button>
</Link>
<Link href="/contact">
</StaticLink>
<StaticLink href="/contact">
<Button
size="lg"
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
@@ -309,7 +243,7 @@ export default function CasesPage() {
<ArrowLeft className="ml-2 w-4 h-4 rotate-180" />
</Button>
</Link>
</StaticLink>
</div>
</div>
</motion.div>
-266
View File
@@ -1,266 +0,0 @@
'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 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,
};
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 data = validationResult.data;
if (data.website) {
console.log('Honeypot field filled, rejecting request');
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: '提交失败,请重试' };
}
}
+33 -57
View File
@@ -1,16 +1,13 @@
'use client';
import { useState, useEffect, useRef, useActionState } from 'react';
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 } from '@/lib/csrf';
import { Mail, 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个字符'),
@@ -35,7 +32,8 @@ export default function ContactPage() {
const [showToast, setShowToast] = useState(false);
const [toastMessage, setToastMessage] = useState('');
const [toastType, setToastType] = useState<'success' | 'error'>('success');
const [csrfToken, setCsrfToken] = useState<string>('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [isSubmitted, setIsSubmitted] = useState(false);
const [formData, setFormData] = useState<ContactFormData>({
name: '',
phone: '',
@@ -46,47 +44,12 @@ 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(() => {
requestAnimationFrame(() => {
setIsVisible(true);
const token = generateCSRFToken();
setCsrfToken(token);
setCSRFTokenToStorage(token);
});
}, []);
useEffect(() => {
if (state) {
requestAnimationFrame(() => {
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);
@@ -102,10 +65,9 @@ export default function ContactPage() {
};
const handleChange = (field: keyof ContactFormData, value: string) => {
const sanitizedValue = sanitizeInput(value);
setFormData((prev) => ({ ...prev, [field]: sanitizedValue }));
setFormData((prev) => ({ ...prev, [field]: value }));
if (errors[field]) {
validateField(field, sanitizedValue);
validateField(field, value);
}
};
@@ -113,16 +75,9 @@ export default function ContactPage() {
validateField(field, value);
};
function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!csrfToken) {
setToastMessage('安全验证失败,请刷新页面重试。');
setToastType('error');
setShowToast(true);
return;
}
const result = contactFormSchema.safeParse(formData);
if (!result.success) {
@@ -135,14 +90,36 @@ export default function ContactPage() {
return;
}
const form = e.currentTarget;
const formDataObj = new FormData(form);
formDataObj.set('submitTime', Date.now().toString());
formAction(formDataObj);
setIsSubmitting(true);
try {
const response = await fetch('https://formspree.io/f/' + process.env.NEXT_PUBLIC_FORMSPREE_ID, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify(formData),
});
if (response.ok) {
setIsSubmitted(true);
setToastMessage('表单提交成功!我们会尽快与您联系。');
setToastType('success');
setShowToast(true);
setFormData({ name: '', phone: '', email: '', subject: '', message: '' });
} else {
setToastMessage('提交失败,请稍后重试或直接发送邮件联系我们。');
setToastType('error');
setShowToast(true);
}
} catch {
setToastMessage('网络错误,请稍后重试。');
setToastType('error');
setShowToast(true);
} finally {
setIsSubmitting(false);
}
}
return (
<main className="min-h-screen bg-white pt-16">
<main className="min-h-screen bg-white">
{showToast && (
<Toast
message={toastMessage}
@@ -265,7 +242,6 @@ export default function ContactPage() {
</div>
) : (
<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
+4 -18
View File
@@ -46,17 +46,7 @@ const NewsSection = dynamic(
}
);
interface SiteConfig {
feature_services?: { enabled: boolean; items: string[] };
feature_products?: { enabled: boolean; showPricing: boolean; featuredProducts: string[] };
feature_news?: { enabled: boolean; displayCount: number; categories: string[]; sortOrder: 'asc' | 'desc' };
}
interface HomeContentProps {
config: SiteConfig;
}
function HomeContent({ config }: HomeContentProps) {
function HomeContent() {
const searchParams = useSearchParams();
useEffect(() => {
@@ -74,18 +64,14 @@ function HomeContent({ config }: HomeContentProps) {
return undefined;
}, [searchParams]);
const showServices = config.feature_services?.enabled !== false;
const showProducts = config.feature_products?.enabled !== false;
const showNews = config.feature_news?.enabled !== false;
return (
<main className="min-h-screen bg-white dark:bg-(--color-bg-primary)">
<HeroSection />
{showServices && <ServicesSection config={config.feature_services} />}
{showProducts && <ProductsSection config={config.feature_products} />}
<ServicesSection />
<ProductsSection />
<CasesSection />
<AboutSection />
{showNews && <NewsSection config={config.feature_news} />}
<NewsSection />
</main>
);
}
+1 -1
View File
@@ -28,7 +28,7 @@ export default function MarketingLayout({
<div className="min-h-screen flex flex-col">
<Header />
<ErrorBoundary>
<main className="flex-1">
<main className="flex-1 pt-16">
{breadcrumbItem && (
<div className="container-wide">
<Breadcrumb items={[breadcrumbItem]} />
@@ -1,10 +1,10 @@
'use client';
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BackButton } from '@/components/ui/back-button';
import { Calendar } from 'lucide-react';
import { Calendar, ArrowLeft } from 'lucide-react';
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
@@ -72,7 +72,7 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
</h2>
<div className="grid md:grid-cols-3 gap-6">
{relatedNews.map((related) => (
<Link key={related.id} href={`/news/${related.id}`}>
<StaticLink key={related.id} href={`/news/${related.id}`}>
<div className="group cursor-pointer">
<div className="aspect-video bg-linear-to-br from-[#C41E3A]/10 to-[#1C1C1C]/10 rounded-lg mb-4 flex items-center justify-center group-hover:shadow-lg transition-shadow">
<span className="text-4xl">📰</span>
@@ -87,23 +87,24 @@ export function NewsDetailClient({ news }: NewsDetailClientProps) {
{related.excerpt}
</p>
</div>
</Link>
</StaticLink>
))}
</div>
</div>
)}
<div className="mt-16 flex justify-center gap-4">
<Link href="/news">
<StaticLink href="/news">
<Button variant="outline" size="lg">
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
</Link>
<Link href="/contact">
</StaticLink>
<StaticLink href="/contact">
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white">
</Button>
</Link>
</StaticLink>
</div>
</motion.div>
</div>
+9 -40
View File
@@ -2,14 +2,14 @@
import { useState, useMemo, useRef, ChangeEvent } from 'react';
import { useInView } from 'framer-motion';
import { useNews } from '@/hooks/use-news';
import { NEWS } from '@/lib/constants';
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, ArrowLeft, Filter, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import Link from 'next/link';
import { Search, Calendar, Filter, ChevronLeft, ChevronRight, ArrowRight } from 'lucide-react';
import { StaticLink } from '@/components/ui/static-link';
import { motion } from 'framer-motion';
const categories = ['全部', '公司新闻', '产品发布', '合作动态', '行业资讯'];
@@ -21,19 +21,15 @@ export default function NewsListPage() {
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(() => {
if (!news || news.length === 0) {return [];}
return news.filter((newsItem) => {
return NEWS.filter((newsItem) => {
const matchesCategory = selectedCategory === '全部' || newsItem.category === selectedCategory;
const matchesSearch =
const matchesSearch =
newsItem.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
newsItem.excerpt.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch;
});
}, [news, selectedCategory, searchQuery]);
}, [selectedCategory, searchQuery]);
const totalPages = Math.ceil(filteredNews.length / ITEMS_PER_PAGE);
const paginatedNews = useMemo(() => {
@@ -57,28 +53,6 @@ export default function NewsListPage() {
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" />
<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
@@ -87,12 +61,7 @@ export default function NewsListPage() {
/>
<div className="container-wide relative z-10 py-12" ref={contentRef}>
<Link href="/" className="inline-flex items-center text-[#5C5C5C] hover:text-[#C41E3A] transition-colors mb-8">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
<motion.div
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
@@ -147,7 +116,7 @@ export default function NewsListPage() {
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: 0.2 + index * 0.1 }}
>
<Link href={`/news/${newsItem.id}`}>
<StaticLink href={`/news/${newsItem.id}`}>
<Card className="h-full hover:shadow-lg transition-shadow cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A]">
<CardContent className="p-0">
{newsItem.image ? (
@@ -184,7 +153,7 @@ export default function NewsListPage() {
</div>
</CardContent>
</Card>
</Link>
</StaticLink>
</motion.div>
))}
</div>
+2 -28
View File
@@ -1,37 +1,11 @@
import { Suspense } from 'react';
import { db } from '@/db';
import { siteConfig } from '@/db/schema';
import { HomeContent } from './home-content';
import { SectionSkeleton } from '@/components/ui/loading-skeleton';
interface SiteConfig {
feature_services?: { enabled: boolean; items: string[] };
feature_products?: { enabled: boolean; showPricing: boolean; featuredProducts: string[] };
feature_news?: { enabled: boolean; displayCount: number; categories: string[]; sortOrder: 'asc' | 'desc' };
}
async function getSiteConfig(): Promise<SiteConfig> {
try {
const allConfigs = await db.select().from(siteConfig);
const configMap = allConfigs.reduce((acc, config) => {
acc[config.key] = config.value;
return acc;
}, {} as Record<string, any>);
return configMap as SiteConfig;
} catch (error) {
console.error('获取配置失败:', error);
return {};
}
}
export default async function HomePage() {
const config = await getSiteConfig();
export default function HomePage() {
return (
<Suspense fallback={<SectionSkeleton />}>
<HomeContent config={config} />
<HomeContent />
</Suspense>
);
}
+5 -5
View File
@@ -1,5 +1,5 @@
import { notFound } from 'next/navigation';
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import { PRODUCTS } from '@/lib/constants';
import { Button } from '@/components/ui/button';
import { BackButton } from '@/components/ui/back-button';
@@ -211,15 +211,15 @@ export default async function ProductDetailPage({ params }: { params: Promise<{
<div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]">
<Button variant="outline" size="lg" asChild>
<Link href="/contact">
<StaticLink href="/contact">
</Link>
</StaticLink>
</Button>
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white" asChild>
<Link href="/contact">
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
</Link>
</StaticLink>
</Button>
</div>
</div>
+10 -41
View File
@@ -2,14 +2,14 @@
import { useState, useMemo, useRef, ChangeEvent } from 'react';
import { useInView } from 'framer-motion';
import { useProducts } from '@/hooks/use-products';
import { PRODUCTS } from '@/lib/constants';
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 { StaticLink } from '@/components/ui/static-link';
import { motion } from 'framer-motion';
const categories = ['全部', '软件产品', '云服务', '数据分析', '信息安全'];
@@ -21,19 +21,15 @@ export default function ProductsPage() {
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) => {
return PRODUCTS.filter((product) => {
const matchesCategory = selectedCategory === '全部' || product.category === selectedCategory;
const matchesSearch =
const matchesSearch =
product.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
product.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch;
});
}, [products, selectedCategory, searchQuery]);
}, [selectedCategory, searchQuery]);
const totalPages = Math.ceil(filteredProducts.length / ITEMS_PER_PAGE);
const paginatedProducts = useMemo(() => {
@@ -57,28 +53,6 @@ export default function ProductsPage() {
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
@@ -88,12 +62,7 @@ export default function ProductsPage() {
<div className="container-wide relative z-10 py-16" ref={contentRef}>
<div className="max-w-6xl mx-auto">
<Link href="/" className="inline-flex items-center text-[#5C5C5C] hover:text-[#C41E3A] transition-colors mb-8">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
<motion.div
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
@@ -148,7 +117,7 @@ export default function ProductsPage() {
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Link href={`/products/${product.slug}`}>
<StaticLink 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">
@@ -197,7 +166,7 @@ export default function ProductsPage() {
</Button>
</CardContent>
</Card>
</Link>
</StaticLink>
</motion.div>
))}
</div>
@@ -266,10 +235,10 @@ export default function ProductsPage() {
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
asChild
>
<Link href="/contact">
<StaticLink href="/contact">
<ArrowLeft className="ml-2 w-4 h-4 rotate-180" />
</Link>
</StaticLink>
</Button>
</div>
</motion.div>
+34 -34
View File
@@ -1,7 +1,7 @@
'use client';
import { useRef } from 'react';
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { BackButton } from '@/components/ui/back-button';
@@ -26,19 +26,19 @@ const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 20l4-16m4 4l4 4-4 4M6 16l-4-4 4-4" />
</svg>
),
Cloud: () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
</svg>
),
BarChart3: () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
),
Shield: () => (
Lightbulb: () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
),
Puzzle: () => (
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.7 6.3a1 1 0 000 1.4l1.6 1.6a1 1 0 001.4 0l3.77-3.77a6 6 0 01-7.94 7.94l-6.91 6.91a2.12 2.12 0 01-3-3l6.91-6.91a6 6 0 017.94-7.94l-3.76 3.76z" />
</svg>
),
};
@@ -50,23 +50,23 @@ const challenges = {
{ title: '项目延期', description: '开发进度难以把控,上线时间一拖再拖' },
{ title: '维护成本高', description: '系统上线后问题不断,运维压力巨大' },
],
cloud: [
{ title: '资源浪费', description: '服务器资源利用率低,成本居高不下' },
{ title: '扩展困难', description: '业务增长时系统无法快速扩容' },
{ title: '迁移风险', description: '担心数据丢失、业务中断' },
{ title: '安全顾虑', description: '不确定云端数据是否安全' },
],
data: [
{ title: '数据孤岛', description: '各系统数据分散,无法整合分析' },
{ title: '决策盲区', description: '缺乏数据支撑,决策凭感觉' },
{ title: '报表滞后', description: '手工制作报表,时效性差' },
{ title: '价值难挖', description: '数据很多,但不知道怎么用' },
],
security: [
{ title: '安全漏洞', description: '系统存在未知漏洞,随时可能被攻击' },
{ title: '合规压力', description: '监管要求越来越严,不知如何应对' },
{ title: '内部威胁', description: '员工操作不规范,数据泄露风险' },
{ title: '应急能力弱', description: '安全事件发生后不知所措' },
consulting: [
{ title: '方向不明', description: '数字化转型不知道从哪里入手' },
{ title: '技术债务', description: '历史系统包袱重,新技术难以引入' },
{ title: '人才短缺', description: '缺乏专业的技术规划和架构人才' },
{ title: '投入浪费', description: 'IT投入不少,但看不到明显效果' },
],
solutions: [
{ title: '行业壁垒', description: '不了解行业最佳实践,走弯路' },
{ title: '方案碎片化', description: '各系统各自为政,无法协同' },
{ title: '实施风险', description: '大型项目实施失败率高' },
{ title: '效果难量化', description: '投入产出比不清晰,难以评估' },
],
};
@@ -76,20 +76,20 @@ const outcomes = {
{ value: '50%', label: '返工率降低' },
{ value: '100%', label: '按时交付率' },
],
cloud: [
{ value: '40%', label: '成本降低' },
{ value: '99.9%', label: '可用性保障' },
{ value: '10x', label: '弹性扩展能力' },
],
data: [
{ value: '70%', label: '决策效率提升' },
{ value: '实时', label: '数据更新' },
{ value: '100+', label: '可视化报表' },
],
security: [
{ value: '99%', label: '漏洞修复率' },
{ value: '100%', label: '合规达标' },
{ value: '24/7', label: '安全监控' },
consulting: [
{ value: '60%', label: '方向明确度' },
{ value: '40%', label: '试错成本降低' },
{ value: '3x', label: '转型速度提升' },
],
solutions: [
{ value: '50%', label: '实施周期缩短' },
{ value: '30%', label: '成本降低' },
{ value: '95%', label: '客户满意度' },
],
};
@@ -238,7 +238,7 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
</div>
<div className="grid md:grid-cols-2 gap-4">
{relatedCases.map((caseItem) => (
<Link
<StaticLink
key={caseItem.id}
href={`/cases/${caseItem.id}`}
className="group p-4 bg-white rounded-lg border border-[#E5E5E5] hover:border-[#C41E3A] transition-colors"
@@ -252,23 +252,23 @@ export function ServiceDetailClient({ service }: ServiceDetailClientProps) {
<p className="text-sm text-[#5C5C5C] mt-2 line-clamp-2">
{caseItem.description}
</p>
</Link>
</StaticLink>
))}
</div>
</section>
<div className="flex justify-center gap-4 pt-8 border-t border-[#E5E5E5]">
<Link href="/services">
<StaticLink href="/services">
<Button variant="outline" size="lg">
</Button>
</Link>
<Link href="/contact">
</StaticLink>
<StaticLink href="/contact">
<Button size="lg" className="bg-[#C41E3A] hover:bg-[#A01830] text-white">
<ArrowRight className="ml-2 w-4 h-4" />
</Button>
</Link>
</StaticLink>
</div>
</div>
</div>
+15 -46
View File
@@ -2,23 +2,23 @@
import { useState, useMemo, useRef, ChangeEvent } from 'react';
import { useInView } from 'framer-motion';
import { useServices } from '@/hooks/use-services';
import { SERVICES } from '@/lib/constants';
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 { Search, ArrowLeft, Code, Cloud, BarChart3, Shield, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
import Link from 'next/link';
import { Search, ArrowLeft, Code, BarChart3, Lightbulb, Puzzle, ChevronLeft, ChevronRight, Filter } from 'lucide-react';
import { StaticLink } from '@/components/ui/static-link';
import { motion } from 'framer-motion';
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
Code,
Cloud,
BarChart3,
Shield,
Lightbulb,
Puzzle,
};
const categories = ['全部', '软件开发', '云服务', '数据分析', '信息安全'];
const categories = ['全部', ...SERVICES.map((s) => s.title)];
const ITEMS_PER_PAGE = 6;
export default function ServicesPage() {
@@ -27,19 +27,15 @@ export default function ServicesPage() {
const [currentPage, setCurrentPage] = useState(1);
const contentRef = useRef(null);
const isContentInView = useInView(contentRef, { once: true, margin: '-100px' });
const { services, loading, error } = useServices();
const filteredServices = useMemo(() => {
if (!services || services.length === 0) return [];
return services.filter((service) => {
return SERVICES.filter((service) => {
const matchesCategory = selectedCategory === '全部' || service.title.includes(selectedCategory);
const matchesSearch =
const matchesSearch =
service.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
service.description.toLowerCase().includes(searchQuery.toLowerCase());
return matchesCategory && matchesSearch;
});
}, [services, selectedCategory, searchQuery]);
}, [selectedCategory, searchQuery]);
const totalPages = Math.ceil(filteredServices.length / ITEMS_PER_PAGE);
const paginatedServices = useMemo(() => {
@@ -63,28 +59,6 @@ export default function ServicesPage() {
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
@@ -94,12 +68,7 @@ export default function ServicesPage() {
<div className="container-wide relative z-10 py-16" ref={contentRef}>
<div className="max-w-6xl mx-auto">
<Link href="/" className="inline-flex items-center text-[#5C5C5C] hover:text-[#C41E3A] transition-colors mb-8">
<ArrowLeft className="w-4 h-4 mr-2" />
</Link>
<motion.div
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.6 }}
@@ -156,8 +125,8 @@ export default function ServicesPage() {
animate={isContentInView ? { opacity: 1, y: 0 } : {}}
transition={{ duration: 0.5, delay: index * 0.1 }}
>
<Link
href={`/services/${service.slug}`}
<StaticLink
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="p-8">
@@ -189,7 +158,7 @@ export default function ServicesPage() {
</div>
</div>
</div>
</Link>
</StaticLink>
</motion.div>
);
})}
@@ -259,10 +228,10 @@ export default function ServicesPage() {
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
asChild
>
<Link href="/contact">
<StaticLink href="/contact">
<ArrowLeft className="ml-2 w-4 h-4 rotate-180" />
</Link>
</StaticLink>
</Button>
</div>
</motion.div>
+5 -5
View File
@@ -3,7 +3,7 @@
import { motion } from 'framer-motion';
import { useInView } from 'framer-motion';
import { useRef } from 'react';
import Link from 'next/link';
import { StaticLink } from '@/components/ui/static-link';
import { Button } from '@/components/ui/button';
import { PageHeader } from '@/components/ui/page-header';
import { ArrowRight, Lightbulb, Cpu, Users, CheckCircle2 } from 'lucide-react';
@@ -114,7 +114,7 @@ export default function SolutionsPage() {
<div className="space-y-6 mb-8">
<p className="text-lg text-[#1C1C1C] leading-relaxed">
"最火""最对"
&ldquo;&rdquo;&ldquo;&rdquo;
</p>
<p className="text-lg text-[#1C1C1C] leading-relaxed">
沿
@@ -255,17 +255,17 @@ export default function SolutionsPage() {
variant="outline"
asChild
>
<Link href="/contact"></Link>
<StaticLink href="/contact"></StaticLink>
</Button>
<Button
size="lg"
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
asChild
>
<Link href="/contact">
<StaticLink href="/contact">
<ArrowRight className="ml-2 w-4 h-4" />
</Link>
</StaticLink>
</Button>
</div>
</div>