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:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}} />;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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: '提交失败,请重试' };
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
我们不追逐"最火"的技术,只选择"最对"的技术。
|
||||
我们不追逐“最火”的技术,只选择“最对”的技术。
|
||||
</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>
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import ContentEditPage from './page';
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: jest.fn(),
|
||||
back: jest.fn(),
|
||||
}),
|
||||
useParams: () => ({
|
||||
id: 'new',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: { children: React.ReactNode; href: string }) => {
|
||||
return <a href={href}>{children}</a>;
|
||||
};
|
||||
});
|
||||
|
||||
jest.mock('next/dynamic', () => () => {
|
||||
return function MockEditor() {
|
||||
return <div data-testid="rich-text-editor">Editor</div>;
|
||||
};
|
||||
});
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
describe('ContentEditPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
type: 'news',
|
||||
title: 'Test Content',
|
||||
slug: 'test-content',
|
||||
excerpt: 'Test excerpt',
|
||||
content: '<p>Test content</p>',
|
||||
coverImage: '',
|
||||
category: '',
|
||||
tags: [],
|
||||
status: 'draft',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render content edit page', () => {
|
||||
render(<ContentEditPage />);
|
||||
const container = document.body;
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render form', () => {
|
||||
render(<ContentEditPage />);
|
||||
const container = document.body;
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render back button', () => {
|
||||
render(<ContentEditPage />);
|
||||
const container = document.body;
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Functionality', () => {
|
||||
it('should initialize with default values for new content', () => {
|
||||
render(<ContentEditPage />);
|
||||
const container = document.body;
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have form labels', () => {
|
||||
render(<ContentEditPage />);
|
||||
const container = document.body;
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,396 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter, useParams } from 'next/navigation';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Save,
|
||||
Loader2,
|
||||
Eye,
|
||||
Upload
|
||||
} from 'lucide-react';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
const RichTextEditor = dynamic(
|
||||
() => import('@/components/admin/RichTextEditor'),
|
||||
{
|
||||
ssr: false,
|
||||
loading: () => (
|
||||
<div className="h-64 border border-gray-300 rounded-lg flex items-center justify-center bg-gray-50">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
);
|
||||
|
||||
const typeOptions = [
|
||||
{ value: 'news', label: '新闻' },
|
||||
{ value: 'product', label: '产品' },
|
||||
{ value: 'service', label: '服务' },
|
||||
{ value: 'case', label: '案例' },
|
||||
];
|
||||
|
||||
const statusOptions = [
|
||||
{ value: 'draft', label: '草稿' },
|
||||
{ value: 'published', label: '发布' },
|
||||
{ value: 'archived', label: '归档' },
|
||||
];
|
||||
|
||||
export default function ContentEditPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const isNew = params.id === 'new';
|
||||
const contentId = isNew ? null : (params.id as string);
|
||||
|
||||
const [loading, setLoading] = useState(!isNew);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
type: 'news',
|
||||
title: '',
|
||||
slug: '',
|
||||
excerpt: '',
|
||||
content: '',
|
||||
coverImage: '',
|
||||
category: '',
|
||||
tags: [] as string[],
|
||||
status: 'draft',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew && contentId) {
|
||||
fetchContent();
|
||||
}
|
||||
}, [isNew, contentId]);
|
||||
|
||||
const fetchContent = async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/content/${contentId}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setFormData({
|
||||
type: data.type,
|
||||
title: data.title,
|
||||
slug: data.slug,
|
||||
excerpt: data.excerpt || '',
|
||||
content: data.content || '',
|
||||
coverImage: data.coverImage || '',
|
||||
category: data.category || '',
|
||||
tags: data.tags || [],
|
||||
status: data.status,
|
||||
});
|
||||
} else {
|
||||
router.push('/admin/content');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取内容失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const generateSlug = (title: string) => {
|
||||
return title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
};
|
||||
|
||||
const handleTitleChange = (title: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
title,
|
||||
slug: prev.slug || generateSlug(title),
|
||||
}));
|
||||
};
|
||||
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append('file', file);
|
||||
uploadFormData.append('type', 'image');
|
||||
|
||||
const res = await fetch('/api/admin/upload', {
|
||||
method: 'POST',
|
||||
body: uploadFormData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setFormData(prev => ({ ...prev, coverImage: data.file.url }));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传失败:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const validate = () => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
if (!formData.title.trim()) {
|
||||
newErrors.title = '请输入标题';
|
||||
}
|
||||
if (!formData.slug.trim()) {
|
||||
newErrors.slug = '请输入 Slug';
|
||||
}
|
||||
if (!formData.type) {
|
||||
newErrors.type = '请选择类型';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSave = async (publish: boolean = false) => {
|
||||
if (!validate()) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const url = isNew
|
||||
? '/api/admin/content'
|
||||
: `/api/admin/content/${contentId}`;
|
||||
|
||||
const body = {
|
||||
...formData,
|
||||
status: publish ? 'published' : formData.status,
|
||||
contentBody: formData.content,
|
||||
};
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: isNew ? 'POST' : 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
if (isNew) {
|
||||
router.push(`/admin/content/${data.id}`);
|
||||
}
|
||||
alert('保存成功');
|
||||
} else {
|
||||
alert(data.error || '保存失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存失败:', error);
|
||||
alert('保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#C41E3A]" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Link
|
||||
href="/admin/content"
|
||||
className="p-2 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
</Link>
|
||||
<h1 className="text-2xl font-bold text-gray-900">
|
||||
{isNew ? '新建内容' : '编辑内容'}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => handleSave(false)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
{saving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />}
|
||||
保存草稿
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleSave(true)}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#a01830] disabled:opacity-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Eye className="h-4 w-4" />
|
||||
发布
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
标题 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleTitleChange(e.target.value)}
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none ${
|
||||
errors.title ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="请输入标题"
|
||||
/>
|
||||
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Slug <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.slug}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, slug: e.target.value }))}
|
||||
className={`w-full px-4 py-2 border rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none ${
|
||||
errors.slug ? 'border-red-500' : 'border-gray-300'
|
||||
}`}
|
||||
placeholder="url-slug"
|
||||
/>
|
||||
{errors.slug && <p className="text-red-500 text-sm mt-1">{errors.slug}</p>}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
摘要
|
||||
</label>
|
||||
<textarea
|
||||
value={formData.excerpt}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, excerpt: e.target.value }))}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none resize-none"
|
||||
placeholder="请输入摘要(可选)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
内容
|
||||
</label>
|
||||
<RichTextEditor
|
||||
content={formData.content}
|
||||
onChange={(content: string) => setFormData(prev => ({ ...prev, content }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">基本信息</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
类型 <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<select
|
||||
value={formData.type}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
>
|
||||
{typeOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
状态
|
||||
</label>
|
||||
<select
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, status: e.target.value }))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
>
|
||||
{statusOptions.map(option => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
分类
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.category}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
|
||||
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
placeholder="分类名称"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<h3 className="font-medium text-gray-900 mb-4">封面图片</h3>
|
||||
|
||||
{formData.coverImage ? (
|
||||
<div className="relative">
|
||||
<img
|
||||
src={formData.coverImage}
|
||||
alt="封面"
|
||||
className="w-full h-40 object-cover rounded-lg"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setFormData(prev => ({ ...prev, coverImage: '' }))}
|
||||
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow hover:bg-gray-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="block">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
disabled={uploading}
|
||||
/>
|
||||
<div className="w-full h-40 border-2 border-dashed border-gray-300 rounded-lg flex flex-col items-center justify-center cursor-pointer hover:border-[#C41E3A] hover:bg-red-50 transition-colors">
|
||||
{uploading ? (
|
||||
<Loader2 className="h-6 w-6 animate-spin text-gray-400" />
|
||||
) : (
|
||||
<>
|
||||
<Upload className="h-6 w-6 text-gray-400 mb-2" />
|
||||
<span className="text-sm text-gray-500">点击上传</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import ContentListPage from './page';
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useSearchParams: () => ({
|
||||
get: jest.fn(() => null),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: { children: React.ReactNode; href: string }) => {
|
||||
return <a href={href}>{children}</a>;
|
||||
};
|
||||
});
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
describe('ContentListPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
items: [
|
||||
{
|
||||
id: 'test-content',
|
||||
type: 'news',
|
||||
title: 'Test Content',
|
||||
slug: 'test-content',
|
||||
excerpt: 'Test excerpt',
|
||||
status: 'published',
|
||||
category: 'test',
|
||||
createdAt: '2024-01-01',
|
||||
publishedAt: '2024-01-01',
|
||||
},
|
||||
],
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 1,
|
||||
totalPages: 1,
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render content list page', () => {
|
||||
render(<ContentListPage />);
|
||||
const container = screen.getByText(/内容管理/i).closest('div');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render page title', () => {
|
||||
render(<ContentListPage />);
|
||||
const title = screen.getByRole('heading', { level: 1 });
|
||||
expect(title).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render search input', () => {
|
||||
render(<ContentListPage />);
|
||||
const searchInput = screen.getByPlaceholderText(/搜索/i);
|
||||
expect(searchInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render add content button', () => {
|
||||
render(<ContentListPage />);
|
||||
const buttons = screen.getAllByRole('button');
|
||||
expect(buttons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Functionality', () => {
|
||||
it('should fetch content on mount', async () => {
|
||||
render(<ContentListPage />);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', () => {
|
||||
render(<ContentListPage />);
|
||||
|
||||
const h1 = screen.getByRole('heading', { level: 1 });
|
||||
expect(h1).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,324 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Edit,
|
||||
Trash2,
|
||||
FileText,
|
||||
Loader2
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ContentItem {
|
||||
id: string;
|
||||
type: 'news' | 'product' | 'service' | 'case';
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt: string | null;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
category: string | null;
|
||||
createdAt: string;
|
||||
publishedAt: string | null;
|
||||
}
|
||||
|
||||
interface Pagination {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
news: '新闻',
|
||||
product: '产品',
|
||||
service: '服务',
|
||||
case: '案例',
|
||||
};
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
draft: '草稿',
|
||||
published: '已发布',
|
||||
archived: '已归档',
|
||||
};
|
||||
|
||||
const statusColors: Record<string, string> = {
|
||||
draft: 'bg-yellow-100 text-yellow-800',
|
||||
published: 'bg-green-100 text-green-800',
|
||||
archived: 'bg-gray-100 text-gray-800',
|
||||
};
|
||||
|
||||
export default function ContentListPage() {
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [items, setItems] = useState<ContentItem[]>([]);
|
||||
const [pagination, setPagination] = useState<Pagination>({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState(searchParams.get('search') || '');
|
||||
const [typeFilter, setTypeFilter] = useState(searchParams.get('type') || '');
|
||||
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||
const [deleteId, setDeleteId] = useState<string | null>(null);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
const fetchContent = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
params.set('page', pagination.page.toString());
|
||||
params.set('limit', pagination.limit.toString());
|
||||
if (search) params.set('search', search);
|
||||
if (typeFilter) params.set('type', typeFilter);
|
||||
if (statusFilter) params.set('status', statusFilter);
|
||||
|
||||
const res = await fetch(`/api/admin/content?${params}`);
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
setItems(data.items);
|
||||
setPagination(data.pagination);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取内容列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [pagination.page, pagination.limit, search, typeFilter, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchContent();
|
||||
}, [fetchContent]);
|
||||
|
||||
const handleSearch = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setPagination(prev => ({ ...prev, page: 1 }));
|
||||
fetchContent();
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteId) return;
|
||||
|
||||
setDeleting(true);
|
||||
try {
|
||||
const res = await fetch(`/api/admin/content/${deleteId}`, {
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setItems(items.filter(item => item.id !== deleteId));
|
||||
setDeleteId(null);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除失败:', error);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatDate = (dateStr: string) => {
|
||||
return new Date(dateStr).toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<h1 className="text-2xl font-bold text-gray-900">内容管理</h1>
|
||||
<Link
|
||||
href="/admin/content/new"
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#a01830] transition-colors"
|
||||
>
|
||||
<Plus className="h-5 w-5" />
|
||||
新建内容
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<form onSubmit={handleSearch} className="flex flex-col sm:flex-row gap-4 mb-6">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder="搜索标题..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => {
|
||||
setTypeFilter(e.target.value);
|
||||
setPagination(prev => ({ ...prev, page: 1 }));
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
>
|
||||
<option value="">全部类型</option>
|
||||
{Object.entries(typeLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => {
|
||||
setStatusFilter(e.target.value);
|
||||
setPagination(prev => ({ ...prev, page: 1 }));
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
{Object.entries(statusLabels).map(([value, label]) => (
|
||||
<option key={value} value={value}>{label}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
className="px-6 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200 transition-colors"
|
||||
>
|
||||
搜索
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-[#C41E3A]" />
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<p className="text-gray-500">暂无内容</p>
|
||||
<Link
|
||||
href="/admin/content/new"
|
||||
className="inline-block mt-4 text-[#C41E3A] hover:underline"
|
||||
>
|
||||
创建第一个内容
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200">
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600">标题</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600">类型</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600">状态</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600">分类</th>
|
||||
<th className="text-left py-3 px-4 text-sm font-medium text-gray-600">创建时间</th>
|
||||
<th className="text-right py-3 px-4 text-sm font-medium text-gray-600">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-gray-100 hover:bg-gray-50">
|
||||
<td className="py-4 px-4">
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">{item.title}</p>
|
||||
<p className="text-sm text-gray-500">{item.slug}</p>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<span className="px-2 py-1 text-xs font-medium bg-blue-100 text-blue-800 rounded">
|
||||
{typeLabels[item.type]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<span className={`px-2 py-1 text-xs font-medium rounded ${statusColors[item.status]}`}>
|
||||
{statusLabels[item.status]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-4 px-4 text-gray-600">
|
||||
{item.category || '-'}
|
||||
</td>
|
||||
<td className="py-4 px-4 text-gray-600">
|
||||
{formatDate(item.createdAt)}
|
||||
</td>
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Link
|
||||
href={`/admin/content/${item.id}`}
|
||||
className="p-2 text-gray-400 hover:text-[#C41E3A] hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="h-5 w-5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setDeleteId(item.id)}
|
||||
className="p-2 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{pagination.totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-6 pt-6 border-t border-gray-200">
|
||||
<p className="text-sm text-gray-600">
|
||||
共 {pagination.total} 条记录
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setPagination(prev => ({ ...prev, page: prev.page - 1 }))}
|
||||
disabled={pagination.page === 1}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
上一页
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setPagination(prev => ({ ...prev, page: prev.page + 1 }))}
|
||||
disabled={pagination.page === pagination.totalPages}
|
||||
className="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
下一页
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{deleteId && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div className="bg-white rounded-xl p-6 max-w-md w-full mx-4">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">确认删除</h3>
|
||||
<p className="text-gray-600 mb-6">确定要删除此内容吗?此操作不可撤销。</p>
|
||||
<div className="flex gap-3 justify-end">
|
||||
<button
|
||||
onClick={() => setDeleteId(null)}
|
||||
className="px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleting}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{deleting ? '删除中...' : '确认删除'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useSession, signOut } from 'next-auth/react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useRouter } from 'next/navigation';
|
||||
import {
|
||||
FileText,
|
||||
Settings,
|
||||
Users,
|
||||
LayoutDashboard,
|
||||
LogOut,
|
||||
Menu,
|
||||
X,
|
||||
Activity
|
||||
} from 'lucide-react';
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const navigation = [
|
||||
{ name: '仪表盘', href: '/admin', icon: LayoutDashboard },
|
||||
{ name: '内容管理', href: '/admin/content', icon: FileText },
|
||||
{ name: '配置中心', href: '/admin/settings', icon: Settings },
|
||||
{ name: '用户管理', href: '/admin/users', icon: Users },
|
||||
{ name: '审计日志', href: '/admin/logs', icon: Activity },
|
||||
];
|
||||
|
||||
export default function AdminLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const { data: session, status } = useSession();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
const isLoginPage = pathname === '/admin/login';
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (mounted && status === 'unauthenticated' && !isLoginPage) {
|
||||
router.push('/admin/login');
|
||||
}
|
||||
}, [mounted, status, isLoginPage, router]);
|
||||
|
||||
if (!mounted) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isLoginPage) {
|
||||
return <div className="min-h-screen bg-gradient-to-br from-gray-50 to-gray-100">{children}</div>;
|
||||
}
|
||||
|
||||
if (status === 'loading') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'unauthenticated') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
<div className="lg:hidden fixed top-0 left-0 right-0 z-40 bg-white border-b border-gray-200 px-4 py-3 flex items-center justify-between">
|
||||
<Link href="/admin" className="text-xl font-bold text-[#C41E3A]">
|
||||
睿新致遠 后台管理
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 rounded-md text-gray-600 hover:bg-gray-100"
|
||||
>
|
||||
{sidebarOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="lg:hidden fixed inset-0 z-30 bg-black/50"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<aside className={`
|
||||
fixed top-0 left-0 z-30 h-full w-64 bg-white border-r border-gray-200 transform transition-transform duration-300 ease-in-out
|
||||
lg:translate-x-0
|
||||
${sidebarOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}>
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="h-16 flex items-center px-6 border-b border-gray-200">
|
||||
<Link href="/admin" className="text-xl font-bold text-[#C41E3A]">
|
||||
睿新致遠 后台管理
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 px-4 py-6 space-y-1 overflow-y-auto">
|
||||
{navigation.map((item) => {
|
||||
const isActive = pathname === item.href ||
|
||||
(item.href !== '/admin' && pathname.startsWith(item.href));
|
||||
return (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`
|
||||
flex items-center gap-3 px-4 py-3 rounded-lg text-sm font-medium transition-colors
|
||||
${isActive
|
||||
? 'bg-[#C41E3A] text-white'
|
||||
: 'text-gray-700 hover:bg-gray-100'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<item.icon className="h-5 w-5" />
|
||||
{item.name}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-3 px-4 py-3">
|
||||
<div className="w-10 h-10 rounded-full bg-gray-200 flex items-center justify-center text-gray-600 font-medium">
|
||||
{session?.user?.name?.[0] || 'U'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{session?.user?.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 truncate">
|
||||
{session?.user?.isAdmin ? '管理员' : '用户'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => signOut({ callbackUrl: '/admin/login' })}
|
||||
className="p-2 text-gray-400 hover:text-gray-600 hover:bg-gray-100 rounded-lg"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="lg:ml-64 min-h-screen">
|
||||
<div className="p-6 lg:p-8 pt-20 lg:pt-8">
|
||||
{children}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import LoginPage from './page';
|
||||
|
||||
jest.mock('next-auth/react', () => ({
|
||||
signIn: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('next/navigation', () => ({
|
||||
useRouter: () => ({
|
||||
push: jest.fn(),
|
||||
}),
|
||||
useSearchParams: () => ({
|
||||
get: jest.fn(() => null),
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: { children: React.ReactNode; href: string }) => {
|
||||
return <a href={href}>{children}</a>;
|
||||
};
|
||||
});
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render login page', () => {
|
||||
render(<LoginPage />);
|
||||
const container = screen.getByText('管理后台登录').closest('div');
|
||||
expect(container).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render email input', () => {
|
||||
render(<LoginPage />);
|
||||
const emailInput = screen.getByLabelText(/邮箱地址/i);
|
||||
expect(emailInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render password input', () => {
|
||||
render(<LoginPage />);
|
||||
const passwordInput = screen.getByLabelText(/密码/i);
|
||||
expect(passwordInput).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render login button', () => {
|
||||
render(<LoginPage />);
|
||||
const loginButton = screen.getByRole('button', { name: /登录/i });
|
||||
expect(loginButton).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Functionality', () => {
|
||||
it('should update email value on change', () => {
|
||||
render(<LoginPage />);
|
||||
const emailInput = screen.getByLabelText(/邮箱地址/i) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(emailInput, { target: { value: 'test@example.com' } });
|
||||
|
||||
expect(emailInput.value).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should update password value on change', () => {
|
||||
render(<LoginPage />);
|
||||
const passwordInput = screen.getByLabelText(/密码/i) as HTMLInputElement;
|
||||
|
||||
fireEvent.change(passwordInput, { target: { value: 'password123' } });
|
||||
|
||||
expect(passwordInput.value).toBe('password123');
|
||||
});
|
||||
|
||||
it('should toggle password visibility', () => {
|
||||
render(<LoginPage />);
|
||||
const passwordInput = screen.getByLabelText(/密码/i) as HTMLInputElement;
|
||||
|
||||
expect(passwordInput.type).toBe('password');
|
||||
|
||||
const toggleButtons = screen.getAllByRole('button');
|
||||
const toggleButton = toggleButtons.find(btn =>
|
||||
btn.querySelector('svg') && btn !== screen.getByRole('button', { name: /登录/i })
|
||||
);
|
||||
|
||||
if (toggleButton) {
|
||||
fireEvent.click(toggleButton);
|
||||
expect(passwordInput.type).toBe('text');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have form labels', () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
expect(screen.getByLabelText(/邮箱地址/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/密码/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,123 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { signIn } from 'next-auth/react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Eye, EyeOff, Mail, Lock, AlertCircle } from 'lucide-react';
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
|
||||
const [email, setEmail] = useState('admin@novalon.cn');
|
||||
const [password, setPassword] = useState('admin123456');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const result = await signIn('credentials', {
|
||||
email,
|
||||
password,
|
||||
redirect: false,
|
||||
});
|
||||
|
||||
if (result?.error) {
|
||||
setError('邮箱或密码错误');
|
||||
} else {
|
||||
router.push('/admin');
|
||||
}
|
||||
} catch (err) {
|
||||
setError('登录失败,请稍后重试');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 px-4">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-gray-200 p-8">
|
||||
<div className="text-center mb-8">
|
||||
<h1 className="text-3xl font-bold text-[#C41E3A]">睿新致遠</h1>
|
||||
<p className="text-gray-600 mt-2">管理后台登录</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg flex items-center gap-3 text-red-700">
|
||||
<AlertCircle className="h-5 w-5 flex-shrink-0" />
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div>
|
||||
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
邮箱地址
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
placeholder="请输入邮箱"
|
||||
className="w-full pl-10 pr-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-gray-400" />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
placeholder="请输入密码"
|
||||
className="w-full pl-10 pr-12 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent outline-none transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="w-full py-3 px-4 bg-[#C41E3A] text-white font-medium rounded-lg hover:bg-[#a01830] focus:ring-2 focus:ring-offset-2 focus:ring-[#C41E3A] disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{loading ? '登录中...' : '登录'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 text-center">
|
||||
<a href="/" className="text-sm text-gray-600 hover:text-[#C41E3A] transition-colors">
|
||||
← 返回首页
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-center text-xs text-gray-500 mt-6">
|
||||
© {new Date().getFullYear()} 四川睿新致远科技有限公司 版权所有
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import AdminDashboard from './page';
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: jest.fn().mockResolvedValue({
|
||||
user: { name: '测试用户' },
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: jest.fn().mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
orderBy: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
orderBy: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: { children: React.ReactNode; href: string }) => {
|
||||
return <a href={href}>{children}</a>;
|
||||
};
|
||||
});
|
||||
|
||||
describe('AdminDashboard', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render dashboard', async () => {
|
||||
const dashboard = await AdminDashboard();
|
||||
render(dashboard);
|
||||
|
||||
const heading = screen.getByRole('heading', { level: 1 });
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading).toHaveTextContent('仪表盘');
|
||||
});
|
||||
|
||||
it('should render welcome message', async () => {
|
||||
const dashboard = await AdminDashboard();
|
||||
render(dashboard);
|
||||
|
||||
const welcome = screen.getByText(/欢迎回来/i);
|
||||
expect(welcome).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render stat cards', async () => {
|
||||
const dashboard = await AdminDashboard();
|
||||
render(dashboard);
|
||||
|
||||
const totalContent = screen.getByText('总内容数');
|
||||
const published = screen.getByText('已发布');
|
||||
const draft = screen.getByText('草稿');
|
||||
const users = screen.getByText('用户数');
|
||||
|
||||
expect(totalContent).toBeInTheDocument();
|
||||
expect(published).toBeInTheDocument();
|
||||
expect(draft).toBeInTheDocument();
|
||||
expect(users).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render recent content section', async () => {
|
||||
const dashboard = await AdminDashboard();
|
||||
render(dashboard);
|
||||
|
||||
const recentContent = screen.getByText('最近内容');
|
||||
expect(recentContent).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render quick actions section', async () => {
|
||||
const dashboard = await AdminDashboard();
|
||||
render(dashboard);
|
||||
|
||||
const quickActions = screen.getByText('快捷操作');
|
||||
expect(quickActions).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should have content management link', async () => {
|
||||
const dashboard = await AdminDashboard();
|
||||
render(dashboard);
|
||||
|
||||
const contentLink = screen.getByRole('link', { name: /总内容数/i });
|
||||
expect(contentLink).toBeInTheDocument();
|
||||
expect(contentLink).toHaveAttribute('href', '/admin/content');
|
||||
});
|
||||
|
||||
it('should have users link', async () => {
|
||||
const dashboard = await AdminDashboard();
|
||||
render(dashboard);
|
||||
|
||||
const usersLink = screen.getByRole('link', { name: /用户数/i });
|
||||
expect(usersLink).toBeInTheDocument();
|
||||
expect(usersLink).toHaveAttribute('href', '/admin/users');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,161 +0,0 @@
|
||||
import { auth } from '@/lib/auth';
|
||||
import { db } from '@/db';
|
||||
import { content, users } from '@/db/schema';
|
||||
import { desc, eq, sql } from 'drizzle-orm';
|
||||
import Link from 'next/link';
|
||||
import { FileText, Settings, Users, TrendingUp } from 'lucide-react';
|
||||
|
||||
async function getStats() {
|
||||
const [
|
||||
contentCount,
|
||||
publishedCount,
|
||||
draftCount,
|
||||
userCount,
|
||||
recentContent,
|
||||
] = await Promise.all([
|
||||
db.select({ count: sql<number>`count(*)` }).from(content),
|
||||
db.select({ count: sql<number>`count(*)` }).from(content).where(eq(content.status, 'published')),
|
||||
db.select({ count: sql<number>`count(*)` }).from(content).where(eq(content.status, 'draft')),
|
||||
db.select({ count: sql<number>`count(*)` }).from(users),
|
||||
db.select().from(content).orderBy(desc(content.createdAt)).limit(5),
|
||||
]);
|
||||
|
||||
return {
|
||||
contentCount: contentCount[0]?.count || 0,
|
||||
publishedCount: publishedCount[0]?.count || 0,
|
||||
draftCount: draftCount[0]?.count || 0,
|
||||
userCount: userCount[0]?.count || 0,
|
||||
recentContent,
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AdminDashboard() {
|
||||
const session = await auth();
|
||||
const stats = await getStats();
|
||||
|
||||
const statCards = [
|
||||
{
|
||||
name: '总内容数',
|
||||
value: stats.contentCount,
|
||||
icon: FileText,
|
||||
color: 'bg-blue-500',
|
||||
href: '/admin/content'
|
||||
},
|
||||
{
|
||||
name: '已发布',
|
||||
value: stats.publishedCount,
|
||||
icon: TrendingUp,
|
||||
color: 'bg-green-500',
|
||||
href: '/admin/content?status=published'
|
||||
},
|
||||
{
|
||||
name: '草稿',
|
||||
value: stats.draftCount,
|
||||
icon: FileText,
|
||||
color: 'bg-yellow-500',
|
||||
href: '/admin/content?status=draft'
|
||||
},
|
||||
{
|
||||
name: '用户数',
|
||||
value: stats.userCount,
|
||||
icon: Users,
|
||||
color: 'bg-purple-500',
|
||||
href: '/admin/users'
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">仪表盘</h1>
|
||||
<p className="text-gray-600 mt-1">欢迎回来,{session?.user?.name}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{statCards.map((stat) => (
|
||||
<Link
|
||||
key={stat.name}
|
||||
href={stat.href}
|
||||
className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">{stat.name}</p>
|
||||
<p className="text-3xl font-bold text-gray-900 mt-2">{stat.value}</p>
|
||||
</div>
|
||||
<div className={`${stat.color} p-3 rounded-lg`}>
|
||||
<stat.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">最近内容</h2>
|
||||
<Link
|
||||
href="/admin/content"
|
||||
className="text-sm text-[#C41E3A] hover:underline"
|
||||
>
|
||||
查看全部
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{stats.recentContent.length === 0 ? (
|
||||
<p className="text-gray-500 text-center py-8">暂无内容</p>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{stats.recentContent.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="flex items-center justify-between py-3 border-b border-gray-100 last:border-0"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 truncate">
|
||||
{item.title}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{item.type} · {item.status === 'published' ? '已发布' : '草稿'}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={`/admin/content/${item.id}`}
|
||||
className="text-sm text-[#C41E3A] hover:underline ml-4"
|
||||
>
|
||||
编辑
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-gray-900">快捷操作</h2>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Link
|
||||
href="/admin/content/new"
|
||||
className="flex flex-col items-center justify-center p-6 rounded-lg border-2 border-dashed border-gray-300 hover:border-[#C41E3A] hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<FileText className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<span className="text-sm font-medium text-gray-600">新建内容</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/admin/config"
|
||||
className="flex flex-col items-center justify-center p-6 rounded-lg border-2 border-dashed border-gray-300 hover:border-[#C41E3A] hover:bg-red-50 transition-colors"
|
||||
>
|
||||
<Settings className="h-8 w-8 text-gray-400 mb-2" />
|
||||
<span className="text-sm font-medium text-gray-600">配置中心</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, it, expect, jest, beforeAll, afterEach } from '@jest/globals';
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import SecurityDashboard from './page';
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
Shield: () => <span data-testid="shield-icon" />,
|
||||
AlertTriangle: () => <span data-testid="alert-icon" />,
|
||||
Activity: () => <span data-testid="activity-icon" />,
|
||||
Lock: () => <span data-testid="lock-icon" />,
|
||||
RefreshCw: () => <span data-testid="refresh-cw-icon" />,
|
||||
TrendingUp: () => <span data-testid="trending-up-icon" />,
|
||||
TrendingDown: () => <span data-testid="trending-down-icon" />,
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, disabled, ...props }: any) => (
|
||||
<button disabled={disabled} {...props}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/card', () => ({
|
||||
Card: ({ children }: any) => <div data-testid="card">{children}</div>,
|
||||
CardHeader: ({ children }: any) => <div data-testid="card-header">{children}</div>,
|
||||
CardTitle: ({ children }: any) => <h3 data-testid="card-title">{children}</h3>,
|
||||
CardContent: ({ children }: any) => <div data-testid="card-content">{children}</div>,
|
||||
}));
|
||||
|
||||
global.fetch = jest.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve({
|
||||
success: true,
|
||||
logs: [
|
||||
{
|
||||
id: '1',
|
||||
timestamp: Date.now(),
|
||||
type: 'captcha',
|
||||
severity: 'high',
|
||||
message: '验证码验证失败',
|
||||
ip: '192.168.1.1',
|
||||
},
|
||||
],
|
||||
stats: {
|
||||
totalRequests: 100,
|
||||
blockedRequests: 5,
|
||||
captchaAttempts: 10,
|
||||
rateLimitHits: 3,
|
||||
maliciousContentDetected: 2,
|
||||
successRate: 95,
|
||||
},
|
||||
}),
|
||||
} as Response)
|
||||
);
|
||||
|
||||
describe('SecurityDashboard', () => {
|
||||
beforeAll(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render security dashboard', () => {
|
||||
render(<SecurityDashboard />);
|
||||
expect(screen.getByText('安全监控仪表板')).toBeInTheDocument();
|
||||
expect(screen.getByText('实时监控网站安全状态和威胁检测')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render all stat cards', () => {
|
||||
render(<SecurityDashboard />);
|
||||
expect(screen.getByText('总请求数')).toBeInTheDocument();
|
||||
expect(screen.getByText('已拦截请求')).toBeInTheDocument();
|
||||
expect(screen.getByText('验证码尝试')).toBeInTheDocument();
|
||||
expect(screen.getByText('频率限制命中')).toBeInTheDocument();
|
||||
expect(screen.getByText('恶意内容检测')).toBeInTheDocument();
|
||||
expect(screen.getByText('成功率')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display stats values', async () => {
|
||||
render(<SecurityDashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('100')).toBeInTheDocument();
|
||||
expect(screen.getByText('5')).toBeInTheDocument();
|
||||
expect(screen.getByText('10')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('95%')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Security Logs', () => {
|
||||
it('should render security logs section', async () => {
|
||||
render(<SecurityDashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('安全日志')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display log entries', async () => {
|
||||
render(<SecurityDashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('验证码验证失败')).toBeInTheDocument();
|
||||
expect(screen.getByText('IP: 192.168.1.1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have filter buttons', () => {
|
||||
render(<SecurityDashboard />);
|
||||
expect(screen.getByText('全部')).toBeInTheDocument();
|
||||
expect(screen.getByText('高危')).toBeInTheDocument();
|
||||
expect(screen.getByText('中危')).toBeInTheDocument();
|
||||
expect(screen.getByText('低危')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Refresh Functionality', () => {
|
||||
it('should have refresh button', async () => {
|
||||
render(<SecurityDashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('refresh-cw-icon')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call fetch when refresh is clicked', async () => {
|
||||
render(<SecurityDashboard />);
|
||||
|
||||
await waitFor(() => {
|
||||
const refreshButton = screen.getAllByRole('button')[0];
|
||||
expect(refreshButton).not.toBeDisabled();
|
||||
|
||||
refreshButton.click();
|
||||
|
||||
expect(global.fetch).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,271 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Shield, AlertTriangle, Activity, Lock, RefreshCw, TrendingUp, TrendingDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
|
||||
interface SecurityLog {
|
||||
id: string;
|
||||
timestamp: number;
|
||||
type: 'captcha' | 'rate_limit' | 'sanitization' | 'malicious_content';
|
||||
severity: 'low' | 'medium' | 'high';
|
||||
message: string;
|
||||
ip?: string;
|
||||
email?: string;
|
||||
}
|
||||
|
||||
interface SecurityStats {
|
||||
totalRequests: number;
|
||||
blockedRequests: number;
|
||||
captchaAttempts: number;
|
||||
rateLimitHits: number;
|
||||
maliciousContentDetected: number;
|
||||
successRate: number;
|
||||
}
|
||||
|
||||
export default function SecurityDashboard() {
|
||||
const [logs, setLogs] = useState<SecurityLog[]>([]);
|
||||
const [stats, setStats] = useState<SecurityStats>({
|
||||
totalRequests: 0,
|
||||
blockedRequests: 0,
|
||||
captchaAttempts: 0,
|
||||
rateLimitHits: 0,
|
||||
maliciousContentDetected: 0,
|
||||
successRate: 100,
|
||||
});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState<'all' | 'high' | 'medium' | 'low'>('all');
|
||||
|
||||
useEffect(() => {
|
||||
fetchSecurityData();
|
||||
}, []);
|
||||
|
||||
const fetchSecurityData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const response = await fetch('/api/admin/security');
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
setLogs(data.logs || []);
|
||||
setStats(data.stats || {
|
||||
totalRequests: 0,
|
||||
blockedRequests: 0,
|
||||
captchaAttempts: 0,
|
||||
rateLimitHits: 0,
|
||||
maliciousContentDetected: 0,
|
||||
successRate: 100,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch security data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'high':
|
||||
return 'text-red-600 bg-red-50';
|
||||
case 'medium':
|
||||
return 'text-yellow-600 bg-yellow-50';
|
||||
case 'low':
|
||||
return 'text-blue-600 bg-blue-50';
|
||||
default:
|
||||
return 'text-gray-600 bg-gray-50';
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'captcha':
|
||||
return <Lock className="w-4 h-4" />;
|
||||
case 'rate_limit':
|
||||
return <Activity className="w-4 h-4" />;
|
||||
case 'sanitization':
|
||||
return <Shield className="w-4 h-4" />;
|
||||
case 'malicious_content':
|
||||
return <AlertTriangle className="w-4 h-4" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredLogs = filter === 'all'
|
||||
? logs
|
||||
: logs.filter(log => log.severity === filter);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 p-6">
|
||||
<div className="max-w-7xl mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gray-900">安全监控仪表板</h1>
|
||||
<p className="text-gray-600 mt-1">实时监控网站安全状态和威胁检测</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={fetchSecurityData}
|
||||
disabled={loading}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
<RefreshCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">总请求数</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-gray-900">{stats.totalRequests}</div>
|
||||
<div className="flex items-center text-sm text-green-600 mt-2">
|
||||
<TrendingUp className="w-4 h-4 mr-1" />
|
||||
<span>实时统计</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">已拦截请求</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-red-600">{stats.blockedRequests}</div>
|
||||
<div className="flex items-center text-sm text-gray-600 mt-2">
|
||||
<span>安全防护生效</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">验证码尝试</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-blue-600">{stats.captchaAttempts}</div>
|
||||
<div className="flex items-center text-sm text-gray-600 mt-2">
|
||||
<span>人机验证</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">频率限制命中</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-yellow-600">{stats.rateLimitHits}</div>
|
||||
<div className="flex items-center text-sm text-gray-600 mt-2">
|
||||
<span>防刷机制</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">恶意内容检测</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-purple-600">{stats.maliciousContentDetected}</div>
|
||||
<div className="flex items-center text-sm text-gray-600 mt-2">
|
||||
<span>内容过滤</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium text-gray-600">成功率</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-3xl font-bold text-green-600">{stats.successRate}%</div>
|
||||
<div className="flex items-center text-sm text-gray-600 mt-2">
|
||||
<TrendingDown className="w-4 h-4 mr-1" />
|
||||
<span>正常请求比例</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>安全日志</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant={filter === 'all' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('all')}
|
||||
>
|
||||
全部
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'high' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('high')}
|
||||
>
|
||||
高危
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'medium' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('medium')}
|
||||
>
|
||||
中危
|
||||
</Button>
|
||||
<Button
|
||||
variant={filter === 'low' ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => setFilter('low')}
|
||||
>
|
||||
低危
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
) : filteredLogs.length === 0 ? (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
<Shield className="w-12 h-12 mx-auto mb-4 text-gray-300" />
|
||||
<p>暂无安全日志</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredLogs.map((log) => (
|
||||
<div
|
||||
key={log.id}
|
||||
className="flex items-start gap-3 p-4 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className={`flex-shrink-0 p-2 rounded-full ${getSeverityColor(log.severity)}`}>
|
||||
{getTypeIcon(log.type)}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-medium text-gray-900">{log.message}</span>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${getSeverityColor(log.severity)}`}>
|
||||
{log.severity === 'high' ? '高危' : log.severity === 'medium' ? '中危' : '低危'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 text-sm text-gray-600">
|
||||
<span>{new Date(log.timestamp).toLocaleString('zh-CN')}</span>
|
||||
{log.ip && <span>IP: {log.ip}</span>}
|
||||
{log.email && <span>邮箱: {log.email}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import SettingsPage from './page';
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
describe('SettingsPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
configs: [
|
||||
{
|
||||
id: 'test-config',
|
||||
key: 'test.key',
|
||||
value: { enabled: true },
|
||||
category: 'feature',
|
||||
description: 'Test config',
|
||||
updatedAt: '2024-01-01',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render settings page', () => {
|
||||
render(<SettingsPage />);
|
||||
const container = document.body;
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render page content', () => {
|
||||
render(<SettingsPage />);
|
||||
const content = document.querySelector('main') || document.body.firstChild;
|
||||
expect(content).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Functionality', () => {
|
||||
it('should fetch configs on mount', async () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/admin/config');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have accessible content', () => {
|
||||
render(<SettingsPage />);
|
||||
|
||||
const content = document.body;
|
||||
expect(content).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,278 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Save,
|
||||
RefreshCw,
|
||||
Loader2,
|
||||
ChevronDown,
|
||||
ChevronUp
|
||||
} from 'lucide-react';
|
||||
|
||||
interface ConfigItem {
|
||||
id: string;
|
||||
key: string;
|
||||
value: Record<string, any>;
|
||||
category: 'feature' | 'style' | 'seo' | 'general';
|
||||
description: string | null;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const categoryLabels = {
|
||||
feature: '功能配置',
|
||||
style: '样式配置',
|
||||
seo: 'SEO 配置',
|
||||
general: '常规配置'
|
||||
};
|
||||
|
||||
const categoryColors = {
|
||||
feature: 'bg-blue-100 text-blue-800',
|
||||
style: 'bg-purple-100 text-purple-800',
|
||||
seo: 'bg-green-100 text-green-800',
|
||||
general: 'bg-gray-100 text-gray-800'
|
||||
};
|
||||
|
||||
export default function SettingsPage() {
|
||||
const [configs, setConfigs] = useState<ConfigItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState<string | null>(null);
|
||||
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(new Set(['feature', 'seo']));
|
||||
const [editedValues, setEditedValues] = useState<Record<string, Record<string, any>>>({});
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs();
|
||||
}, []);
|
||||
|
||||
const fetchConfigs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/config');
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setConfigs(data.configs || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async (configId: string) => {
|
||||
const editedValue = editedValues[configId];
|
||||
if (!editedValue) return;
|
||||
|
||||
try {
|
||||
setSaving(configId);
|
||||
const res = await fetch('/api/admin/config', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
id: configId,
|
||||
value: editedValue
|
||||
})
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setEditedValues(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[configId];
|
||||
return updated;
|
||||
});
|
||||
await fetchConfigs();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error);
|
||||
} finally {
|
||||
setSaving(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleCategory = (category: string) => {
|
||||
setExpandedCategories(prev => {
|
||||
const updated = new Set(prev);
|
||||
if (updated.has(category)) {
|
||||
updated.delete(category);
|
||||
} else {
|
||||
updated.add(category);
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
const handleValueChange = (configId: string, field: string, value: any) => {
|
||||
setEditedValues(prev => ({
|
||||
...prev,
|
||||
[configId]: {
|
||||
...prev[configId],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
const getConfigValue = (config: ConfigItem, field: string) => {
|
||||
if (editedValues[config.id]?.[field] !== undefined) {
|
||||
return editedValues[config.id]![field];
|
||||
}
|
||||
return config.value[field];
|
||||
};
|
||||
|
||||
const hasChanges = (configId: string) => {
|
||||
return editedValues[configId] && Object.keys(editedValues[configId]).length > 0;
|
||||
};
|
||||
|
||||
const groupedConfigs = configs.reduce((acc, config) => {
|
||||
if (!acc[config.category]) {
|
||||
acc[config.category] = [];
|
||||
}
|
||||
acc[config.category]!.push(config);
|
||||
return acc;
|
||||
}, {} as Record<string, ConfigItem[]>);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">配置中心</h1>
|
||||
<p className="text-gray-600 mt-1">管理网站功能和样式配置</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchConfigs}
|
||||
className="flex items-center gap-2 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
刷新
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{Object.entries(groupedConfigs).map(([category, categoryConfigs]) => (
|
||||
<div key={category} className="bg-white rounded-lg border overflow-hidden">
|
||||
<button
|
||||
onClick={() => toggleCategory(category)}
|
||||
className="w-full flex items-center justify-between p-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className={`px-3 py-1 rounded-full text-sm font-medium ${categoryColors[category as keyof typeof categoryColors]}`}>
|
||||
{categoryLabels[category as keyof typeof categoryLabels]}
|
||||
</span>
|
||||
<span className="text-gray-600 text-sm">
|
||||
{categoryConfigs.length} 项配置
|
||||
</span>
|
||||
</div>
|
||||
{expandedCategories.has(category) ? (
|
||||
<ChevronUp className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<ChevronDown className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expandedCategories.has(category) && (
|
||||
<div className="border-t divide-y">
|
||||
{categoryConfigs.map(config => (
|
||||
<div key={config.id} className="p-4">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{config.key}</h3>
|
||||
{config.description && (
|
||||
<p className="text-sm text-gray-600 mt-1">{config.description}</p>
|
||||
)}
|
||||
</div>
|
||||
{hasChanges(config.id) && (
|
||||
<button
|
||||
onClick={() => handleSave(config.id)}
|
||||
disabled={saving === config.id}
|
||||
className="flex items-center gap-2 px-3 py-1.5 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] transition-colors disabled:opacity-50"
|
||||
>
|
||||
{saving === config.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4" />
|
||||
)}
|
||||
保存
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{Object.entries(config.value).map(([field, value]) => {
|
||||
const currentValue = getConfigValue(config, field);
|
||||
|
||||
return (
|
||||
<div key={field} className="flex items-start gap-4">
|
||||
<label className="w-32 text-sm font-medium text-gray-700 pt-2">
|
||||
{field}
|
||||
</label>
|
||||
<div className="flex-1">
|
||||
{typeof value === 'boolean' ? (
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={currentValue}
|
||||
onChange={(e) => handleValueChange(config.id, field, e.target.checked)}
|
||||
className="w-4 h-4 text-[#C41E3A] border-gray-300 rounded focus:ring-[#C41E3A]"
|
||||
/>
|
||||
<span className="text-sm text-gray-600">
|
||||
{currentValue ? '已启用' : '已禁用'}
|
||||
</span>
|
||||
</label>
|
||||
) : typeof value === 'string' ? (
|
||||
<input
|
||||
type="text"
|
||||
value={currentValue}
|
||||
onChange={(e) => handleValueChange(config.id, field, e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent"
|
||||
/>
|
||||
) : typeof value === 'number' ? (
|
||||
<input
|
||||
type="number"
|
||||
value={currentValue}
|
||||
onChange={(e) => handleValueChange(config.id, field, Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent"
|
||||
/>
|
||||
) : Array.isArray(value) ? (
|
||||
<textarea
|
||||
value={Array.isArray(currentValue) ? currentValue.join('\n') : currentValue}
|
||||
onChange={(e) => handleValueChange(config.id, field, e.target.value.split('\n').filter(Boolean))}
|
||||
rows={3}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent font-mono text-sm"
|
||||
placeholder="每行一个值"
|
||||
/>
|
||||
) : (
|
||||
<textarea
|
||||
value={JSON.stringify(currentValue, null, 2)}
|
||||
onChange={(e) => {
|
||||
try {
|
||||
const parsed = JSON.parse(e.target.value);
|
||||
handleValueChange(config.id, field, parsed);
|
||||
} catch (err) {
|
||||
// Invalid JSON, ignore
|
||||
}
|
||||
}}
|
||||
rows={5}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent font-mono text-sm"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import UsersPage from './page';
|
||||
|
||||
global.fetch = jest.fn();
|
||||
|
||||
describe('UsersPage', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(global.fetch as jest.Mock).mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
users: [
|
||||
{
|
||||
id: 'test-user',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'admin',
|
||||
createdAt: '2024-01-01',
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render users page', () => {
|
||||
render(<UsersPage />);
|
||||
const container = document.body;
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render page content', () => {
|
||||
render(<UsersPage />);
|
||||
const content = document.querySelector('main') || document.body.firstChild;
|
||||
expect(content).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render add user button', () => {
|
||||
render(<UsersPage />);
|
||||
const container = document.body;
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Functionality', () => {
|
||||
it('should fetch users on mount', async () => {
|
||||
render(<UsersPage />);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith('/api/admin/users');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have proper heading hierarchy', () => {
|
||||
render(<UsersPage />);
|
||||
|
||||
const container = document.body;
|
||||
expect(container).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,422 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Users as UsersIcon,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Loader2,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
role: 'admin' | 'editor' | 'viewer';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
const roleLabels = {
|
||||
admin: '管理员',
|
||||
editor: '编辑',
|
||||
viewer: '查看者'
|
||||
};
|
||||
|
||||
const roleColors = {
|
||||
admin: 'bg-red-100 text-red-800',
|
||||
editor: 'bg-blue-100 text-blue-800',
|
||||
viewer: 'bg-gray-100 text-gray-800'
|
||||
};
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<User[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [deletingUserId, setDeletingUserId] = useState<string | null>(null);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
password: '',
|
||||
role: 'viewer' as 'admin' | 'editor' | 'viewer'
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await fetch('/api/admin/users');
|
||||
const data = await res.json();
|
||||
if (res.ok) {
|
||||
setUsers(data.users || []);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData.email || !formData.name || !formData.password || !formData.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
const res = await fetch('/api/admin/users', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ email: '', name: '', password: '', role: 'viewer' });
|
||||
await fetchUsers();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || '创建失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建用户失败:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (userId: string) => {
|
||||
if (deletingUserId) {
|
||||
console.log('删除操作正在进行中,请勿重复点击');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('确定要删除此用户吗?此操作不可恢复。')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeletingUserId(userId);
|
||||
const res = await fetch(`/api/admin/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
await fetchUsers();
|
||||
} else {
|
||||
const data = await res.json();
|
||||
alert(data.error || '删除失败');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('删除用户失败:', error);
|
||||
alert('删除失败,请稍后重试');
|
||||
} finally {
|
||||
setDeletingUserId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredUsers = users.filter(user =>
|
||||
user.email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
user.name.toLowerCase().includes(searchTerm.toLowerCase())
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-gray-400" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">用户管理</h1>
|
||||
<p className="text-gray-600 mt-1">管理系统用户和权限</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] transition-colors"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加用户
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border">
|
||||
<div className="p-4 border-b">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索用户..."
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A] focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
用户信息
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
角色
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
创建时间
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
操作
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{filteredUsers.map(user => (
|
||||
<tr key={user.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="flex items-center">
|
||||
<div className="w-10 h-10 bg-gray-200 rounded-full flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-gray-600" />
|
||||
</div>
|
||||
<div className="ml-4">
|
||||
<div className="text-sm font-medium text-gray-900">{user.name}</div>
|
||||
<div className="text-sm text-gray-500">{user.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-3 py-1 rounded-full text-xs font-medium ${roleColors[user.role]}`}>
|
||||
{roleLabels[user.role]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||||
{new Date(user.createdAt).toLocaleDateString('zh-CN')}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedUser(user);
|
||||
setFormData({
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
password: '',
|
||||
role: user.role
|
||||
});
|
||||
setShowEditModal(true);
|
||||
}}
|
||||
className="text-[#C41E3A] hover:text-[#A01830] mr-4"
|
||||
>
|
||||
<Edit className="h-4 w-4 inline" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete(user.id);
|
||||
}}
|
||||
disabled={deletingUserId === user.id}
|
||||
className="text-red-600 hover:text-red-800 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{deletingUserId === user.id ? (
|
||||
<Loader2 className="h-4 w-4 inline animate-spin" />
|
||||
) : (
|
||||
<Trash2 className="h-4 w-4 inline" />
|
||||
)}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{filteredUsers.length === 0 && (
|
||||
<div className="text-center py-12 text-gray-500">
|
||||
暂无用户数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">添加用户</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">姓名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">密码</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">角色</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
>
|
||||
<option value="viewer">查看者</option>
|
||||
<option value="editor">编辑</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowCreateModal(false);
|
||||
setFormData({ email: '', name: '', password: '', role: 'viewer' });
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] disabled:opacity-50"
|
||||
>
|
||||
{saving ? '创建中...' : '创建'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{showEditModal && selectedUser && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md">
|
||||
<h2 className="text-xl font-bold mb-4">编辑用户</h2>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">邮箱</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">姓名</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">新密码(留空则不修改)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">角色</label>
|
||||
<select
|
||||
value={formData.role}
|
||||
onChange={(e) => setFormData({ ...formData, role: e.target.value as any })}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-[#C41E3A]"
|
||||
>
|
||||
<option value="viewer">查看者</option>
|
||||
<option value="editor">编辑</option>
|
||||
<option value="admin">管理员</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3 mt-6">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowEditModal(false);
|
||||
setSelectedUser(null);
|
||||
setFormData({ email: '', name: '', password: '', role: 'viewer' });
|
||||
}}
|
||||
className="px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const updateData: any = {
|
||||
email: formData.email,
|
||||
name: formData.name,
|
||||
role: formData.role
|
||||
};
|
||||
if (formData.password) {
|
||||
updateData.password = formData.password;
|
||||
}
|
||||
|
||||
const res = await fetch(`/api/admin/users/${selectedUser.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(updateData)
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
setShowEditModal(false);
|
||||
setSelectedUser(null);
|
||||
setFormData({ email: '', name: '', password: '', role: 'viewer' });
|
||||
await fetchUsers();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('更新用户失败:', error);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 bg-[#C41E3A] text-white rounded-lg hover:bg-[#A01830] disabled:opacity-50"
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import SwaggerUI from 'swagger-ui-react';
|
||||
import 'swagger-ui-react/swagger-ui.css';
|
||||
|
||||
export default function ApiDocsPage() {
|
||||
const [spec, setSpec] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/docs')
|
||||
.then((res) => {
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to load API documentation');
|
||||
}
|
||||
return res.json();
|
||||
})
|
||||
.then((data) => {
|
||||
setSpec(data);
|
||||
setLoading(false);
|
||||
})
|
||||
.catch((err) => {
|
||||
setError(err.message);
|
||||
setLoading(false);
|
||||
});
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen 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]">加载API文档中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="text-red-500 mb-4">
|
||||
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-red-500 mb-4">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-[#C41E3A] text-white rounded-md hover:bg-[#A01830] transition-colors"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-white">
|
||||
<div className="swagger-ui-wrapper">
|
||||
{spec && <SwaggerUI spec={spec} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,179 +0,0 @@
|
||||
import { GET, POST, PUT } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
getAdminUserId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => {
|
||||
const mockSelect = jest.fn().mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([]),
|
||||
orderBy: jest.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const mockUpdate = jest.fn().mockReturnValue({
|
||||
set: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{
|
||||
id: 'test-id',
|
||||
key: 'test_key',
|
||||
value: 'test_value',
|
||||
category: 'general',
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
db: {
|
||||
select: mockSelect,
|
||||
update: mockUpdate,
|
||||
insert: jest.fn().mockReturnValue({
|
||||
values: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{
|
||||
id: 'test-id',
|
||||
key: 'test_key',
|
||||
value: 'test_value',
|
||||
category: 'general',
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('/api/admin/config', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return configs if authenticated and has permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.configs).toBeDefined();
|
||||
expect(Array.isArray(data.configs)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'test', value: {} }),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 if missing required fields', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ key: 'test' }),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('缺少必要字段');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ configs: [] }),
|
||||
});
|
||||
const response = await PUT(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ configs: [] }),
|
||||
});
|
||||
const response = await PUT(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 if configs is not an array', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/config', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ configs: 'not-array' }),
|
||||
});
|
||||
const response = await PUT(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('无效的数据格式');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,209 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { siteConfig } from '@/db/schema';
|
||||
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
|
||||
import { forbidden, success, notFound, validationError, badRequest, handleApiError } from '@/lib/api-response';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const category = searchParams.get('category');
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (key) {
|
||||
const config = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (config.length === 0) {
|
||||
return notFound('配置不存在');
|
||||
}
|
||||
|
||||
return success(config[0]);
|
||||
}
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (category) {
|
||||
conditions.push(eq(siteConfig.category, category as 'feature' | 'style' | 'seo' | 'general'));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const configs = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(whereClause)
|
||||
.orderBy(siteConfig.key);
|
||||
|
||||
return success({
|
||||
configs: configs,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { key, value, category, description } = body;
|
||||
|
||||
if (!key || !value || !category) {
|
||||
return validationError('缺少必要字段', { required: ['key', 'value', 'category'] });
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
const now = new Date();
|
||||
|
||||
if (existing.length > 0) {
|
||||
const updated = await db
|
||||
.update(siteConfig)
|
||||
.set({
|
||||
value,
|
||||
description: description || existing[0]!.description,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(siteConfig.key, key))
|
||||
.returning();
|
||||
|
||||
return success(updated[0], 200);
|
||||
}
|
||||
|
||||
const newConfig = await db
|
||||
.insert(siteConfig)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
key,
|
||||
value,
|
||||
category,
|
||||
description: description || null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
return success(newConfig[0], 201);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { configs } = body as { configs: Array<{ key: string; value: unknown; description?: string }> };
|
||||
|
||||
if (!Array.isArray(configs)) {
|
||||
return badRequest('无效的数据格式');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const results = [];
|
||||
|
||||
for (const config of configs) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, config.key))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length > 0) {
|
||||
const updated = await db
|
||||
.update(siteConfig)
|
||||
.set({
|
||||
value: config.value,
|
||||
description: config.description || existing[0]!.description,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.where(eq(siteConfig.key, config.key))
|
||||
.returning();
|
||||
results.push(updated[0]);
|
||||
} else {
|
||||
const created = await db
|
||||
.insert(siteConfig)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
key: config.key,
|
||||
value: config.value,
|
||||
category: 'general',
|
||||
description: config.description || null,
|
||||
updatedAt: now,
|
||||
updatedBy: userId,
|
||||
})
|
||||
.returning();
|
||||
results.push(created[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return success(results);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const key = searchParams.get('key');
|
||||
|
||||
if (!key) {
|
||||
return badRequest('缺少 key 参数');
|
||||
}
|
||||
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, key))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
return notFound('配置不存在');
|
||||
}
|
||||
|
||||
await db
|
||||
.delete(siteConfig)
|
||||
.where(eq(siteConfig.key, key));
|
||||
|
||||
return success({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
getAdminUserId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/audit', () => ({
|
||||
createAuditLog: jest.fn().mockResolvedValue({}),
|
||||
}));
|
||||
|
||||
const { db } = require('@/db');
|
||||
const { auth } = require('@/lib/auth');
|
||||
const { hasPermission } = require('@/lib/auth/permissions');
|
||||
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('GET /api/admin/content/[id]', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 404 if content not found', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
db.limit.mockResolvedValue([]);
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(data.error).toBe('内容不存在');
|
||||
});
|
||||
|
||||
it('should return content if found', async () => {
|
||||
const mockContent = {
|
||||
id: '123',
|
||||
title: 'Test Content',
|
||||
status: 'published',
|
||||
};
|
||||
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
db.limit.mockResolvedValue([mockContent]);
|
||||
db.orderBy.mockResolvedValue([]);
|
||||
|
||||
const { GET } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123');
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await GET(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.title).toBe('Test Content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT /api/admin/content/[id]', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { PUT } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ title: 'Updated' }),
|
||||
});
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await PUT(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { PUT } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ title: 'Updated' }),
|
||||
});
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await PUT(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/admin/content/[id]', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await DELETE(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const { DELETE } = require('./route');
|
||||
const request = new NextRequest('http://localhost/api/admin/content/123', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const params = Promise.resolve({ id: '123' });
|
||||
|
||||
const response = await DELETE(request, { params });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
});
|
||||
});
|
||||
@@ -1,185 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { content, contentVersions } from '@/db/schema';
|
||||
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { forbidden, notFound, success, handleApiError } from '@/lib/api-response';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const item = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (item.length === 0) {
|
||||
return notFound('内容不存在');
|
||||
}
|
||||
|
||||
const versions = await db
|
||||
.select()
|
||||
.from(contentVersions)
|
||||
.where(eq(contentVersions.contentId, id))
|
||||
.orderBy(contentVersions.version);
|
||||
|
||||
return success({
|
||||
...item[0],
|
||||
versions,
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
|
||||
const existingContent = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (existingContent.length === 0) {
|
||||
return notFound('内容不存在');
|
||||
}
|
||||
|
||||
const current = existingContent[0]!;
|
||||
const now = new Date();
|
||||
|
||||
const maxVersion = await db
|
||||
.select({ max: contentVersions.version })
|
||||
.from(contentVersions)
|
||||
.where(eq(contentVersions.contentId, id));
|
||||
|
||||
const nextVersion = (maxVersion[0]?.max || 0) + 1;
|
||||
|
||||
await db.insert(contentVersions).values({
|
||||
id: nanoid(),
|
||||
contentId: id,
|
||||
version: nextVersion,
|
||||
title: current.title,
|
||||
content: current.content,
|
||||
changes: {
|
||||
from: {
|
||||
title: current.title,
|
||||
content: current.content,
|
||||
excerpt: current.excerpt,
|
||||
status: current.status,
|
||||
},
|
||||
to: body,
|
||||
},
|
||||
changedBy: userId,
|
||||
changedAt: now,
|
||||
});
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
if (body.title) updateData.title = body.title;
|
||||
if (body.slug) updateData.slug = body.slug;
|
||||
if (body.excerpt !== undefined) updateData.excerpt = body.excerpt;
|
||||
if (body.contentBody !== undefined) updateData.content = body.contentBody;
|
||||
if (body.coverImage !== undefined) updateData.coverImage = body.coverImage;
|
||||
if (body.category !== undefined) updateData.category = body.category;
|
||||
if (body.tags !== undefined) updateData.tags = body.tags;
|
||||
if (body.metadata !== undefined) updateData.metadata = body.metadata;
|
||||
|
||||
if (body.status) {
|
||||
updateData.status = body.status;
|
||||
if (body.status === 'published' && current.status !== 'published') {
|
||||
updateData.publishedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(content)
|
||||
.set(updateData)
|
||||
.where(eq(content.id, id))
|
||||
.returning();
|
||||
|
||||
await createAuditLog({
|
||||
userId,
|
||||
action: 'update',
|
||||
resourceType: 'content',
|
||||
resourceId: id,
|
||||
details: {
|
||||
changes: updateData,
|
||||
},
|
||||
});
|
||||
|
||||
return success(updated[0]);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const existingContent = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (existingContent.length === 0) {
|
||||
return notFound('内容不存在');
|
||||
}
|
||||
|
||||
await db.delete(contentVersions).where(eq(contentVersions.contentId, id));
|
||||
await db.delete(content).where(eq(content.id, id));
|
||||
|
||||
await createAuditLog({
|
||||
userId,
|
||||
action: 'delete',
|
||||
resourceType: 'content',
|
||||
resourceId: id,
|
||||
details: {
|
||||
title: existingContent[0]!.title,
|
||||
},
|
||||
});
|
||||
|
||||
return success({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,143 +0,0 @@
|
||||
import { describe, it, expect, jest, beforeAll, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
const mockAuth = jest.fn();
|
||||
const mockHasPermission = jest.fn();
|
||||
const mockCheckIsAdmin = jest.fn();
|
||||
const mockGetAdminUserId = jest.fn();
|
||||
const mockDbSelect = jest.fn();
|
||||
const mockDbInsert = jest.fn();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: mockAuth,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: mockCheckIsAdmin,
|
||||
getAdminUserId: mockGetAdminUserId,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
orderBy: () => ({
|
||||
limit: () => ({
|
||||
offset: mockDbSelect,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({
|
||||
returning: mockDbInsert,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('drizzle-orm', () => ({
|
||||
eq: jest.fn(),
|
||||
desc: jest.fn(),
|
||||
and: jest.fn(),
|
||||
like: jest.fn(),
|
||||
sql: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: () => 'test-id-123',
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/audit', () => ({
|
||||
createAuditLog: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/db/schema', () => ({
|
||||
content: {},
|
||||
}));
|
||||
|
||||
import { GET, POST } from './route';
|
||||
|
||||
describe('/api/admin/content', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 when user lacks permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return content list when authorized', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockDbSelect.mockResolvedValueOnce([]);
|
||||
mockDbSelect.mockResolvedValueOnce([{ count: 0 }]);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.items).toEqual([]);
|
||||
expect(data.pagination).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: 'news', title: 'Test', slug: 'test' }),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 when missing required fields', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
mockDbSelect.mockResolvedValueOnce([]);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/content', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: 'news' }),
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('缺少必要字段');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,296 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { content } from '@/db/schema';
|
||||
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { forbidden, badRequest, success, handleApiError, validationError } from '@/lib/api-response';
|
||||
import { eq, desc, and, like, sql } from 'drizzle-orm';
|
||||
import { nanoid } from 'nanoid';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/admin/content:
|
||||
* get:
|
||||
* tags:
|
||||
* - Admin
|
||||
* - Content
|
||||
* summary: 获取内容列表
|
||||
* description: 管理员获取内容列表,支持分页、筛选和搜索
|
||||
* operationId: getAdminContent
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* parameters:
|
||||
* - name: type
|
||||
* in: query
|
||||
* description: 内容类型
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [news, product, service, case]
|
||||
* - name: status
|
||||
* in: query
|
||||
* description: 内容状态
|
||||
* schema:
|
||||
* type: string
|
||||
* enum: [draft, published, archived]
|
||||
* - name: search
|
||||
* in: query
|
||||
* description: 搜索关键词
|
||||
* schema:
|
||||
* type: string
|
||||
* - name: page
|
||||
* in: query
|
||||
* description: 页码
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 1
|
||||
* - name: limit
|
||||
* in: query
|
||||
* description: 每页数量
|
||||
* schema:
|
||||
* type: integer
|
||||
* default: 20
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 成功获取内容列表
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* type: object
|
||||
* properties:
|
||||
* items:
|
||||
* type: array
|
||||
* items:
|
||||
* $ref: '#/components/schemas/Content'
|
||||
* pagination:
|
||||
* type: object
|
||||
* properties:
|
||||
* page:
|
||||
* type: integer
|
||||
* limit:
|
||||
* type: integer
|
||||
* total:
|
||||
* type: integer
|
||||
* totalPages:
|
||||
* type: integer
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type');
|
||||
const status = searchParams.get('status');
|
||||
const search = searchParams.get('search');
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '20');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(content.type, type as 'news' | 'product' | 'service' | 'case'));
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(content.status, status as 'draft' | 'published' | 'archived'));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(like(content.title, `%${search}%`));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const [items, countResult] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(content.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(content)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const total = countResult[0]?.count || 0;
|
||||
|
||||
return success({
|
||||
items,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/admin/content:
|
||||
* post:
|
||||
* tags:
|
||||
* - Admin
|
||||
* - Content
|
||||
* summary: 创建新内容
|
||||
* description: 管理员创建新的内容(新闻、产品、服务、案例)
|
||||
* operationId: createContent
|
||||
* security:
|
||||
* - bearerAuth: []
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required:
|
||||
* - type
|
||||
* - title
|
||||
* - slug
|
||||
* properties:
|
||||
* type:
|
||||
* type: string
|
||||
* enum: [news, product, service, case]
|
||||
* description: 内容类型
|
||||
* title:
|
||||
* type: string
|
||||
* description: 标题
|
||||
* slug:
|
||||
* type: string
|
||||
* description: URL别名
|
||||
* excerpt:
|
||||
* type: string
|
||||
* description: 摘要
|
||||
* contentBody:
|
||||
* type: string
|
||||
* description: 内容正文
|
||||
* coverImage:
|
||||
* type: string
|
||||
* description: 封面图片URL
|
||||
* category:
|
||||
* type: string
|
||||
* description: 分类
|
||||
* tags:
|
||||
* type: array
|
||||
* items:
|
||||
* type: string
|
||||
* description: 标签列表
|
||||
* status:
|
||||
* type: string
|
||||
* enum: [draft, published, archived]
|
||||
* default: draft
|
||||
* description: 状态
|
||||
* metadata:
|
||||
* type: object
|
||||
* description: 元数据
|
||||
* responses:
|
||||
* 201:
|
||||
* description: 内容创建成功
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* success:
|
||||
* type: boolean
|
||||
* data:
|
||||
* $ref: '#/components/schemas/Content'
|
||||
* 400:
|
||||
* description: 请求参数错误
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
* 403:
|
||||
* description: 权限不足
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { type, title, slug, excerpt, contentBody, coverImage, category, tags, status: contentStatus, metadata } = body;
|
||||
|
||||
if (!type || !title || !slug) {
|
||||
return validationError('缺少必要字段', { required: ['type', 'title', 'slug'] });
|
||||
}
|
||||
|
||||
const existingContent = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.slug, slug))
|
||||
.limit(1);
|
||||
|
||||
if (existingContent.length > 0) {
|
||||
return badRequest('Slug 已存在');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const newContent = await db
|
||||
.insert(content)
|
||||
.values({
|
||||
id: nanoid(),
|
||||
type,
|
||||
title,
|
||||
slug,
|
||||
excerpt: excerpt || null,
|
||||
content: contentBody || '',
|
||||
coverImage: coverImage || null,
|
||||
category: category || null,
|
||||
tags: tags || [],
|
||||
status: contentStatus || 'draft',
|
||||
publishedAt: contentStatus === 'published' ? now : null,
|
||||
authorId: userId,
|
||||
metadata: metadata || null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await createAuditLog({
|
||||
userId,
|
||||
action: 'create',
|
||||
resourceType: 'content',
|
||||
resourceId: newContent[0]!.id,
|
||||
details: {
|
||||
type,
|
||||
title,
|
||||
status: contentStatus || 'draft',
|
||||
},
|
||||
});
|
||||
|
||||
return success(newContent[0], 201);
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { SecurityLogger } from '@/lib/security/logger';
|
||||
|
||||
const securityLogger = new SecurityLogger();
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const logs = securityLogger.getRecentLogs(100);
|
||||
const stats = securityLogger.getStats();
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
logs,
|
||||
stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching security data:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch security data'
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { POST, DELETE } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
getAdminUserId: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/audit', () => ({
|
||||
createAuditLog: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/upload', () => ({
|
||||
uploadFile: jest.fn().mockResolvedValue({
|
||||
id: 'test-id',
|
||||
name: 'test.jpg',
|
||||
type: 'image',
|
||||
size: 1024,
|
||||
url: 'https://example.com/test.jpg',
|
||||
}),
|
||||
deleteFile: jest.fn(),
|
||||
}));
|
||||
|
||||
const { checkIsAdmin: mockCheckIsAdmin, getAdminUserId: mockGetAdminUserId } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('/api/admin/upload', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('POST', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', new File(['test'], 'test.jpg', { type: 'image/jpeg' }));
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload', {
|
||||
method: 'POST',
|
||||
});
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 400 if no file', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockGetAdminUserId.mockResolvedValueOnce('1');
|
||||
|
||||
const request = {
|
||||
formData: jest.fn().mockResolvedValue(new FormData()),
|
||||
} as any;
|
||||
const response = await POST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe('未找到文件');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
mockGetAdminUserId.mockResolvedValueOnce(null);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/upload?url=test.jpg', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const response = await DELETE(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,84 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { checkIsAdmin, getAdminUserId } from '@/lib/auth/check-permission';
|
||||
import { createAuditLog } from '@/lib/audit';
|
||||
import { uploadFile, deleteFile } from '@/lib/upload';
|
||||
import { forbidden, badRequest, notFound, success, handleApiError } from '@/lib/api-response';
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file') as File | null;
|
||||
const type = (formData.get('type') as 'image' | 'document') || 'image';
|
||||
|
||||
if (!file) {
|
||||
return badRequest('未找到文件');
|
||||
}
|
||||
|
||||
const result = await uploadFile(file, {
|
||||
type,
|
||||
userId,
|
||||
});
|
||||
|
||||
await createAuditLog({
|
||||
userId,
|
||||
action: 'upload',
|
||||
resourceType: 'file',
|
||||
resourceId: result.id,
|
||||
details: {
|
||||
fileName: result.name,
|
||||
fileType: result.type,
|
||||
fileSize: result.size,
|
||||
url: result.url,
|
||||
},
|
||||
});
|
||||
|
||||
return success({
|
||||
success: true,
|
||||
file: result,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('文件上传失败:', error);
|
||||
|
||||
if (error instanceof Error) {
|
||||
return badRequest(error.message);
|
||||
}
|
||||
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
const userId = await getAdminUserId();
|
||||
|
||||
if (!isAdmin || !userId) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { searchParams } = new URL(request.url);
|
||||
const fileUrl = searchParams.get('url');
|
||||
|
||||
if (!fileUrl) {
|
||||
return badRequest('缺少文件 URL');
|
||||
}
|
||||
|
||||
const result = await deleteFile(fileUrl);
|
||||
|
||||
if (!result) {
|
||||
return notFound('文件不存在或删除失败');
|
||||
}
|
||||
|
||||
return success({ success: true });
|
||||
} catch (error) {
|
||||
console.error('文件删除失败:', error);
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import { GET, PUT, DELETE } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: jest.fn().mockReturnValue({
|
||||
from: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
limit: jest.fn().mockResolvedValue([{
|
||||
id: 'test-user-id',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
isAdmin: true,
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
update: jest.fn().mockReturnValue({
|
||||
set: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockReturnValue({
|
||||
returning: jest.fn().mockResolvedValue([{
|
||||
id: 'test-user-id',
|
||||
email: 'updated@example.com',
|
||||
name: 'Updated User',
|
||||
}]),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
delete: jest.fn().mockReturnValue({
|
||||
where: jest.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
const { checkIsAdmin: mockCheckIsAdmin } = require('@/lib/auth/check-permission');
|
||||
|
||||
describe('/api/admin/users/[id]', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id');
|
||||
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 if no permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id');
|
||||
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return user if authenticated and has permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id');
|
||||
const response = await GET(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.user).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('PUT', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ name: 'Updated User' }),
|
||||
});
|
||||
const response = await PUT(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE', () => {
|
||||
it('should return 401 if not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users/test-id', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
const response = await DELETE(request, { params: Promise.resolve({ id: 'test-id' }) });
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,121 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { users } from '@/db/schema';
|
||||
import { checkIsAdmin } from '@/lib/auth/check-permission';
|
||||
import { forbidden, notFound, success, handleApiError } from '@/lib/api-response';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const user = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
isAdmin: users.isAdmin,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (user.length === 0) {
|
||||
return notFound('用户不存在');
|
||||
}
|
||||
|
||||
return success({ user: user[0] });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
const body = await request.json();
|
||||
const { email, name, password } = body;
|
||||
|
||||
const existingUser = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.id, id))
|
||||
.limit(1);
|
||||
|
||||
if (existingUser.length === 0) {
|
||||
return notFound('用户不存在');
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (email) updateData.email = email;
|
||||
if (name) updateData.name = name;
|
||||
if (password) {
|
||||
updateData.passwordHash = await bcrypt.hash(password, 10);
|
||||
}
|
||||
|
||||
const updated = await db
|
||||
.update(users)
|
||||
.set(updateData)
|
||||
.where(eq(users.id, id))
|
||||
.returning();
|
||||
|
||||
return success({
|
||||
user: {
|
||||
id: updated[0]!.id,
|
||||
email: updated[0]!.email,
|
||||
name: updated[0]!.name,
|
||||
isAdmin: updated[0]!.isAdmin,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
|
||||
await db
|
||||
.delete(users)
|
||||
.where(eq(users.id, id));
|
||||
|
||||
return success({ success: true });
|
||||
} catch (error) {
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { describe, it, expect, jest, beforeAll, beforeEach } from '@jest/globals';
|
||||
import { NextRequest } from 'next/server';
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
const mockAuth = jest.fn();
|
||||
const mockHasPermission = jest.fn();
|
||||
const mockCheckIsAdmin = jest.fn();
|
||||
const mockDbSelect = jest.fn();
|
||||
const mockDbInsert = jest.fn();
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
auth: mockAuth,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/check-permission', () => ({
|
||||
checkIsAdmin: mockCheckIsAdmin,
|
||||
}));
|
||||
|
||||
jest.mock('@/lib/auth/permissions', () => ({
|
||||
hasPermission: mockHasPermission,
|
||||
}));
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
select: () => ({
|
||||
from: () => ({
|
||||
where: () => ({
|
||||
limit: mockDbSelect,
|
||||
}),
|
||||
orderBy: () => mockDbSelect(),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: () => ({
|
||||
returning: mockDbInsert,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('drizzle-orm', () => ({
|
||||
eq: jest.fn(),
|
||||
desc: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: () => 'test-id-123',
|
||||
}));
|
||||
|
||||
jest.mock('bcryptjs', () => ({
|
||||
hash: jest.fn().mockResolvedValue('hashed-password'),
|
||||
}));
|
||||
|
||||
jest.mock('@/db/schema', () => ({
|
||||
users: {},
|
||||
}));
|
||||
|
||||
import { GET } from './route';
|
||||
|
||||
describe('/api/admin/users', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET', () => {
|
||||
it('should return 401 when not authenticated', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return 403 when user lacks permission', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: false, userId: '1' });
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.error).toBe('无权限执行此操作');
|
||||
});
|
||||
|
||||
it('should return users list when authorized', async () => {
|
||||
mockCheckIsAdmin.mockResolvedValueOnce({ isAdmin: true, userId: '1' });
|
||||
mockDbSelect.mockResolvedValueOnce([
|
||||
{ id: '1', email: 'admin@example.com', name: 'Admin', isAdmin: true },
|
||||
]);
|
||||
|
||||
const request = new NextRequest('http://localhost/api/admin/users');
|
||||
const response = await GET(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.users).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { users } from '@/db/schema';
|
||||
import { checkIsAdmin } from '@/lib/auth/check-permission';
|
||||
import { forbidden, success, handleApiError } from '@/lib/api-response';
|
||||
import { desc } from 'drizzle-orm';
|
||||
|
||||
export async function GET(_request: NextRequest) {
|
||||
try {
|
||||
const { isAdmin } = await checkIsAdmin();
|
||||
|
||||
if (!isAdmin) {
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
const allUsers = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
email: users.email,
|
||||
name: users.name,
|
||||
isAdmin: users.isAdmin,
|
||||
createdAt: users.createdAt,
|
||||
updatedAt: users.updatedAt,
|
||||
})
|
||||
.from(users)
|
||||
.orderBy(desc(users.createdAt));
|
||||
|
||||
return success({ users: allUsers });
|
||||
} catch (error) {
|
||||
console.error('获取用户列表失败:', error);
|
||||
return handleApiError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
import { GET, POST } from './route';
|
||||
|
||||
jest.mock('@/lib/auth', () => ({
|
||||
handlers: {
|
||||
GET: jest.fn(() => new Response('GET response')),
|
||||
POST: jest.fn(() => new Response('POST response')),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('/api/auth/[...nextauth]', () => {
|
||||
describe('GET handler', () => {
|
||||
it('should export GET handler', () => {
|
||||
expect(typeof GET).toBe('function');
|
||||
});
|
||||
|
||||
it('should call auth GET handler', async () => {
|
||||
const response = await GET(new Request('http://localhost/api/auth/signin'));
|
||||
expect(response).toBeDefined();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST handler', () => {
|
||||
it('should export POST handler', () => {
|
||||
expect(typeof POST).toBe('function');
|
||||
});
|
||||
|
||||
it('should call auth POST handler', async () => {
|
||||
const response = await POST(
|
||||
new Request('http://localhost/api/auth/signin', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ email: 'test@example.com', password: 'password' }),
|
||||
})
|
||||
);
|
||||
expect(response).toBeDefined();
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
import { handlers } from '@/lib/auth';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { siteConfig } from '@/db/schema';
|
||||
|
||||
export async function GET() {
|
||||
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 NextResponse.json({
|
||||
success: true,
|
||||
data: configMap
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,316 +0,0 @@
|
||||
import { POST, setSecurityMiddleware } from './route';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { generateCaptcha } from '@/lib/security/captcha';
|
||||
import { SecurityMiddleware } from '@/lib/security/middleware';
|
||||
|
||||
if (!global.Response) {
|
||||
global.Response = class Response {
|
||||
status: number;
|
||||
private _body: string;
|
||||
constructor(body: string, init?: { status?: number }) {
|
||||
this._body = body;
|
||||
this.status = init?.status || 200;
|
||||
}
|
||||
async json() {
|
||||
return JSON.parse(this._body);
|
||||
}
|
||||
} as any;
|
||||
}
|
||||
|
||||
if (!(global.Response as any).json) {
|
||||
(global.Response as any).json = function(data: any, init?: { status?: number }) {
|
||||
return new Response(JSON.stringify(data), init);
|
||||
};
|
||||
}
|
||||
|
||||
jest.mock('resend', () => {
|
||||
const mockSend = jest.fn();
|
||||
return {
|
||||
__esModule: true,
|
||||
default: jest.fn().mockImplementation(() => ({
|
||||
emails: {
|
||||
send: mockSend,
|
||||
},
|
||||
})),
|
||||
Resend: jest.fn().mockImplementation(() => ({
|
||||
emails: {
|
||||
send: mockSend,
|
||||
},
|
||||
})),
|
||||
};
|
||||
});
|
||||
|
||||
describe('/api/contact', () => {
|
||||
let mockRequest: NextRequest;
|
||||
let mockSend: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const { default: Resend } = require('resend');
|
||||
const resendInstance = new Resend();
|
||||
mockSend = resendInstance.emails.send;
|
||||
mockSend.mockClear();
|
||||
|
||||
const securityMiddleware = new SecurityMiddleware();
|
||||
setSecurityMiddleware(securityMiddleware);
|
||||
});
|
||||
|
||||
const createMockRequest = (body: any, ip: string = '192.168.1.1'): NextRequest => {
|
||||
const headers = new Headers();
|
||||
headers.set('x-forwarded-for', ip);
|
||||
headers.set('user-agent', 'test-agent');
|
||||
|
||||
return {
|
||||
json: async () => body,
|
||||
headers,
|
||||
} as unknown as NextRequest;
|
||||
};
|
||||
|
||||
it('should handle POST request with valid data', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
data: { id: 'test-id' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.1.2');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(data.message).toBe('消息已发送');
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should validate required fields', async () => {
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
}, '192.168.1.5');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('请填写必填字段');
|
||||
});
|
||||
|
||||
it('should validate email format', async () => {
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'invalid-email',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
}, '192.168.1.6');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('请输入有效的邮箱地址');
|
||||
});
|
||||
|
||||
it('should reject honeypot field', async () => {
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
website: 'spam-bot',
|
||||
}, '192.168.1.7');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(mockSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should reject invalid captcha', async () => {
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: 999,
|
||||
}, '192.168.1.3');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('Invalid captcha');
|
||||
});
|
||||
|
||||
it('should handle Resend API error', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
data: null,
|
||||
error: { message: 'API Error' },
|
||||
});
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.1.4');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('邮件发送失败,请稍后重试');
|
||||
});
|
||||
|
||||
it('should handle JSON parsing error', async () => {
|
||||
mockRequest = {
|
||||
json: async () => {
|
||||
throw new Error('Invalid JSON');
|
||||
},
|
||||
} as unknown as NextRequest;
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toBe('提交失败,请重试');
|
||||
});
|
||||
|
||||
it('should accept valid submission with phone', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
data: { id: 'test-id' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
phone: '13800138000',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.1.10');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should accept valid submission with math captcha', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
data: { id: 'test-id' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.1.11');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.success).toBe(true);
|
||||
expect(mockSend).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should block malicious content', async () => {
|
||||
const captcha = generateCaptcha('simple');
|
||||
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'You won a free bitcoin lottery! Act now to claim your prize.',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.1.12');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('malicious');
|
||||
});
|
||||
|
||||
it('should block rate limit exceeded', async () => {
|
||||
mockSend.mockResolvedValue({
|
||||
data: { id: 'test-id' },
|
||||
error: null,
|
||||
});
|
||||
|
||||
for (let i = 0; i < 11; i++) {
|
||||
const captcha = generateCaptcha('simple');
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: `test${i}@example.com`,
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.2.1');
|
||||
|
||||
await POST(mockRequest);
|
||||
}
|
||||
|
||||
const captcha = generateCaptcha('simple');
|
||||
mockRequest = createMockRequest({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
subject: 'Test Subject',
|
||||
message: 'Test Message',
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}, '192.168.2.1');
|
||||
|
||||
const response = await POST(mockRequest);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(403);
|
||||
expect(data.success).toBe(false);
|
||||
expect(data.error).toContain('Rate limit exceeded');
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { Resend } from 'resend';
|
||||
import { z } from 'zod';
|
||||
import { SecurityMiddleware } from '@/lib/security/middleware';
|
||||
|
||||
const resend = new Resend(process.env.RESEND_API_KEY);
|
||||
const companyEmail = process.env.COMPANY_EMAIL || 'contact@novalon.cn';
|
||||
|
||||
let securityMiddleware = new SecurityMiddleware();
|
||||
|
||||
export function setSecurityMiddleware(middleware: SecurityMiddleware) {
|
||||
securityMiddleware = middleware;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
const requiredFields = ['name', 'email', 'subject', 'message'];
|
||||
for (const field of requiredFields) {
|
||||
if (!body[field]) {
|
||||
return Response.json(
|
||||
{ success: false, error: '请填写必填字段' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const emailValidation = z.string().email().safeParse(body.email);
|
||||
if (!emailValidation.success) {
|
||||
return Response.json(
|
||||
{ success: false, error: '请输入有效的邮箱地址' },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
if (body.website) {
|
||||
return Response.json({ success: true, message: '消息已发送' });
|
||||
}
|
||||
|
||||
const clientIP = request.headers.get('x-forwarded-for')?.split(',')[0] ||
|
||||
request.headers.get('x-real-ip') ||
|
||||
'unknown';
|
||||
|
||||
const securityCheck = await securityMiddleware.checkRequest({
|
||||
ip: clientIP,
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
subject: body.subject,
|
||||
message: body.message,
|
||||
captchaHash: body.mathHash || '',
|
||||
captchaAnswer: parseInt(body.mathAnswer) || 0,
|
||||
captchaTimestamp: parseInt(body.mathTimestamp) || 0,
|
||||
userAgent: request.headers.get('user-agent') || undefined,
|
||||
});
|
||||
|
||||
if (!securityCheck.allowed) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: securityCheck.errors[0] || '安全验证失败',
|
||||
warnings: securityCheck.warnings
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
const sanitizedData = securityMiddleware.sanitizeRequest({
|
||||
ip: clientIP,
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
subject: body.subject,
|
||||
message: body.message,
|
||||
captchaHash: body.mathHash || '',
|
||||
captchaAnswer: parseInt(body.mathAnswer) || 0,
|
||||
captchaTimestamp: parseInt(body.mathTimestamp) || 0,
|
||||
});
|
||||
|
||||
const emailContent = `
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: sans-serif; line-height: 1.6; color: #1C1C1C; }
|
||||
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
|
||||
.header { background: #C41E3A; color: white; padding: 20px; text-align: center; }
|
||||
.content { padding: 20px; }
|
||||
.info-row { margin-bottom: 10px; }
|
||||
.info-label { font-weight: bold; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>新的客户咨询</h1>
|
||||
</div>
|
||||
<div class="content">
|
||||
<div class="info-row"><span class="info-label">姓名:</span> ${sanitizedData.name}</div>
|
||||
<div class="info-row"><span class="info-label">邮箱:</span> ${sanitizedData.email}</div>
|
||||
${sanitizedData.phone ? `<div class="info-row"><span class="info-label">电话:</span> ${sanitizedData.phone}</div>` : ''}
|
||||
<div class="info-row"><span class="info-label">主题:</span> ${sanitizedData.subject}</div>
|
||||
<div class="info-row"><span class="info-label">留言:</span> ${sanitizedData.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const result = await resend.emails.send({
|
||||
from: '睿新致远官网 <onboarding@resend.dev>',
|
||||
to: [companyEmail],
|
||||
subject: `${sanitizedData.subject} - ${sanitizedData.name}`,
|
||||
html: emailContent,
|
||||
replyTo: sanitizedData.email,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
console.error('Resend API error:', result.error);
|
||||
return Response.json(
|
||||
{ success: false, error: '邮件发送失败,请稍后重试' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
return Response.json({ success: true, message: '消息已发送' });
|
||||
} catch (error) {
|
||||
console.error('Contact form submission error:', error);
|
||||
return Response.json(
|
||||
{ success: false, error: '提交失败,请重试' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { content } from '@/db/schema';
|
||||
import { eq, desc, and, like } from 'drizzle-orm';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const type = searchParams.get('type');
|
||||
const status = searchParams.get('status') || 'published';
|
||||
const search = searchParams.get('search');
|
||||
const page = parseInt(searchParams.get('page') || '1');
|
||||
const limit = parseInt(searchParams.get('limit') || '100');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const conditions = [];
|
||||
|
||||
if (type) {
|
||||
conditions.push(eq(content.type, type as 'news' | 'product' | 'service' | 'case'));
|
||||
}
|
||||
|
||||
if (status) {
|
||||
conditions.push(eq(content.status, status as 'draft' | 'published' | 'archived'));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
conditions.push(like(content.title, `%${search}%`));
|
||||
}
|
||||
|
||||
const whereClause = conditions.length > 0 ? and(...conditions) : undefined;
|
||||
|
||||
const items = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(whereClause)
|
||||
.orderBy(desc(content.publishedAt), desc(content.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
data: items,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch content:', error);
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch content',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,188 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import swaggerJsdoc from 'swagger-jsdoc';
|
||||
|
||||
const options = {
|
||||
definition: {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
title: '睿新致远 API',
|
||||
version: '1.0.0',
|
||||
description: `
|
||||
## 睿新致远官方网站API文档
|
||||
|
||||
### API版本
|
||||
|
||||
当前支持以下版本:
|
||||
|
||||
- **v1** (Current): 当前推荐版本,包含所有核心功能
|
||||
- **legacy** (Deprecated): 旧版本API,已重定向到v1
|
||||
|
||||
### 版本使用
|
||||
|
||||
所有新开发应使用v1版本API:
|
||||
|
||||
\`\`\`
|
||||
GET /api/v1/health
|
||||
GET /api/v1/admin/content
|
||||
\`\`\`
|
||||
|
||||
旧版本API路径会自动重定向到v1版本:
|
||||
|
||||
\`\`\`
|
||||
GET /api/health → GET /api/v1/health
|
||||
GET /api/admin/content → GET /api/v1/admin/content
|
||||
\`\`\`
|
||||
|
||||
### 认证
|
||||
|
||||
需要认证的API使用Bearer Token:
|
||||
|
||||
\`\`\`
|
||||
Authorization: Bearer <your-access-token>
|
||||
\`\`\`
|
||||
`,
|
||||
contact: {
|
||||
name: '睿新致远',
|
||||
email: 'contact@novalon.cn',
|
||||
url: 'https://novalon.cn',
|
||||
},
|
||||
},
|
||||
servers: [
|
||||
{
|
||||
url: '/api/v1',
|
||||
description: 'API v1 (Current)',
|
||||
},
|
||||
{
|
||||
url: '/api',
|
||||
description: 'Legacy API (Redirects to v1)',
|
||||
},
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http',
|
||||
scheme: 'bearer',
|
||||
bearerFormat: 'JWT',
|
||||
},
|
||||
},
|
||||
schemas: {
|
||||
Error: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
success: {
|
||||
type: 'boolean',
|
||||
example: false,
|
||||
},
|
||||
error: {
|
||||
type: 'string',
|
||||
example: '错误信息',
|
||||
},
|
||||
},
|
||||
},
|
||||
Content: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
enum: ['news', 'case', 'product', 'service'],
|
||||
example: 'news',
|
||||
},
|
||||
title: {
|
||||
type: 'string',
|
||||
example: '文章标题',
|
||||
},
|
||||
content: {
|
||||
type: 'string',
|
||||
example: '文章内容',
|
||||
},
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['draft', 'published', 'archived'],
|
||||
example: 'published',
|
||||
},
|
||||
createdAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
updatedAt: {
|
||||
type: 'string',
|
||||
format: 'date-time',
|
||||
},
|
||||
},
|
||||
},
|
||||
User: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: {
|
||||
type: 'integer',
|
||||
example: 1,
|
||||
},
|
||||
name: {
|
||||
type: 'string',
|
||||
example: '用户名',
|
||||
},
|
||||
email: {
|
||||
type: 'string',
|
||||
format: 'email',
|
||||
example: 'user@example.com',
|
||||
},
|
||||
role: {
|
||||
type: 'string',
|
||||
enum: ['admin', 'editor', 'viewer'],
|
||||
example: 'admin',
|
||||
},
|
||||
},
|
||||
},
|
||||
Config: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
example: 'site_name',
|
||||
},
|
||||
value: {
|
||||
type: 'string',
|
||||
example: '睿新致远',
|
||||
},
|
||||
description: {
|
||||
type: 'string',
|
||||
example: '网站名称',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
{
|
||||
name: 'Content',
|
||||
description: '内容管理相关接口',
|
||||
},
|
||||
{
|
||||
name: 'Admin',
|
||||
description: '管理员相关接口',
|
||||
},
|
||||
{
|
||||
name: 'Config',
|
||||
description: '配置相关接口',
|
||||
},
|
||||
{
|
||||
name: 'Health',
|
||||
description: '健康检查接口',
|
||||
},
|
||||
],
|
||||
},
|
||||
apis: [
|
||||
'./src/app/api/v1/**/route.ts',
|
||||
'./src/app/api/**/route.ts',
|
||||
'./src/app/(marketing)/contact/actions.ts',
|
||||
],
|
||||
};
|
||||
|
||||
export async function GET() {
|
||||
const specs = swaggerJsdoc(options);
|
||||
return NextResponse.json(specs);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { GET } from './route';
|
||||
|
||||
describe('/api/health', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return health status with all required fields', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect([200, 503]).toContain(response.status);
|
||||
expect(['healthy', 'unhealthy']).toContain(data.status);
|
||||
expect(data.timestamp).toBeDefined();
|
||||
expect(data.uptime).toBeDefined();
|
||||
expect(data.version).toBeDefined();
|
||||
expect(data.environment).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include database check', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.checks).toBeDefined();
|
||||
expect(data.checks.database).toBeDefined();
|
||||
expect(data.checks.database.status).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include memory check', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.checks.memory).toBeDefined();
|
||||
expect(data.checks.memory.status).toBeDefined();
|
||||
expect(data.checks.memory.used).toBeDefined();
|
||||
expect(data.checks.memory.total).toBeDefined();
|
||||
expect(data.checks.memory.percentage).toBeDefined();
|
||||
});
|
||||
|
||||
it('should include CPU check', async () => {
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(data.checks.cpu).toBeDefined();
|
||||
expect(data.checks.cpu.status).toBeDefined();
|
||||
expect(data.checks.cpu.load).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return 503 when a check is unhealthy', async () => {
|
||||
const originalMemoryUsage = process.memoryUsage;
|
||||
process.memoryUsage = jest.fn(() => ({
|
||||
heapUsed: 1000000000,
|
||||
heapTotal: 1000000000,
|
||||
external: 0,
|
||||
arrayBuffers: 0,
|
||||
rss: 0,
|
||||
}));
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(data.checks.memory.status).toBe('unhealthy');
|
||||
|
||||
process.memoryUsage = originalMemoryUsage;
|
||||
});
|
||||
|
||||
it('should handle errors gracefully', async () => {
|
||||
const originalUptime = process.uptime;
|
||||
process.uptime = jest.fn(() => {
|
||||
throw new Error('Process error');
|
||||
});
|
||||
|
||||
const response = await GET();
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(data.status).toBe('unhealthy');
|
||||
expect(data.error).toBeDefined();
|
||||
|
||||
process.uptime = originalUptime;
|
||||
});
|
||||
});
|
||||
@@ -1,101 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const healthStatus = {
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: process.env.NODE_ENV || 'development',
|
||||
version: process.env.npm_package_version || '1.0.0',
|
||||
checks: {
|
||||
database: await checkDatabase(),
|
||||
memory: checkMemory(),
|
||||
cpu: checkCPU(),
|
||||
},
|
||||
};
|
||||
|
||||
const allChecksHealthy = Object.values(healthStatus.checks).every(
|
||||
(check) => check.status === 'healthy'
|
||||
);
|
||||
|
||||
return NextResponse.json(healthStatus, {
|
||||
status: allChecksHealthy ? 200 : 503,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Health check failed:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'unhealthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDatabase(): Promise<{ status: string; latency?: number; error?: string }> {
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 简单的数据库连接检查
|
||||
// 如果有数据库连接,可以添加实际的检查逻辑
|
||||
// const db = await getDatabaseConnection();
|
||||
// await db.execute('SELECT 1');
|
||||
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
return {
|
||||
status: 'healthy',
|
||||
latency,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
error: error instanceof Error ? error.message : 'Database check failed',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkMemory(): { status: string; used?: number; total?: number; percentage?: number } {
|
||||
try {
|
||||
const memUsage = process.memoryUsage();
|
||||
const usedMB = Math.round(memUsage.heapUsed / 1024 / 1024);
|
||||
const totalMB = Math.round(memUsage.heapTotal / 1024 / 1024);
|
||||
const percentage = Math.round((usedMB / totalMB) * 100);
|
||||
|
||||
// 如果内存使用超过90%,标记为不健康
|
||||
const status = percentage > 90 ? 'unhealthy' : 'healthy';
|
||||
|
||||
return {
|
||||
status,
|
||||
used: usedMB,
|
||||
total: totalMB,
|
||||
percentage,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkCPU(): { status: string; load?: number } {
|
||||
try {
|
||||
const cpus = process.cpuUsage();
|
||||
const load = (cpus.user + cpus.system) / 1000000; // 转换为秒
|
||||
|
||||
// 简单的CPU负载检查
|
||||
const status = load < 100 ? 'healthy' : 'unhealthy';
|
||||
|
||||
return {
|
||||
status,
|
||||
load: Math.round(load * 100) / 100,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'unhealthy',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { db } from '@/db';
|
||||
import { siteConfig } from '@/db/schema';
|
||||
|
||||
export async function GET() {
|
||||
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 NextResponse.json({
|
||||
success: true,
|
||||
data: configMap
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('获取配置失败:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: '获取配置失败' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { monitor } from '@/lib/monitoring';
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /api/v1/health:
|
||||
* get:
|
||||
* tags:
|
||||
* - Health
|
||||
* summary: 健康检查 (v1)
|
||||
* description: 检查应用程序的健康状态,包括数据库连接、内存使用等
|
||||
* operationId: getHealthV1
|
||||
* responses:
|
||||
* 200:
|
||||
* description: 服务健康
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* example: ok
|
||||
* version:
|
||||
* type: string
|
||||
* description: API版本
|
||||
* example: v1
|
||||
* timestamp:
|
||||
* type: string
|
||||
* format: date-time
|
||||
* uptime:
|
||||
* type: number
|
||||
* description: 服务运行时间(秒)
|
||||
* memory:
|
||||
* type: object
|
||||
* properties:
|
||||
* heapUsed:
|
||||
* type: integer
|
||||
* description: 已使用堆内存(MB)
|
||||
* heapTotal:
|
||||
* type: integer
|
||||
* description: 总堆内存(MB)
|
||||
* rss:
|
||||
* type: integer
|
||||
* description: 常驻内存集大小(MB)
|
||||
* checks:
|
||||
* type: object
|
||||
* properties:
|
||||
* database:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* latency:
|
||||
* type: integer
|
||||
* memory:
|
||||
* type: object
|
||||
* properties:
|
||||
* status:
|
||||
* type: string
|
||||
* usage:
|
||||
* type: integer
|
||||
* 503:
|
||||
* description: 服务不可用
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* $ref: '#/components/schemas/Error'
|
||||
*/
|
||||
export async function GET() {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const health = {
|
||||
status: 'ok',
|
||||
version: 'v1',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
memory: {
|
||||
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024),
|
||||
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024),
|
||||
rss: Math.round(process.memoryUsage().rss / 1024 / 1024),
|
||||
},
|
||||
checks: {
|
||||
database: await checkDatabase(),
|
||||
memory: checkMemory(),
|
||||
},
|
||||
};
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
monitor.recordMetric('response_time', responseTime);
|
||||
|
||||
return NextResponse.json(health, { status: 200 });
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
status: 'error',
|
||||
version: 'v1',
|
||||
timestamp: new Date().toISOString(),
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDatabase(): Promise<{ status: string; latency?: number }> {
|
||||
try {
|
||||
const start = Date.now();
|
||||
|
||||
return {
|
||||
status: 'ok',
|
||||
latency: Date.now() - start,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function checkMemory(): { status: string; usage: number } {
|
||||
const memUsage = process.memoryUsage();
|
||||
const heapUsedMB = memUsage.heapUsed / 1024 / 1024;
|
||||
const heapTotalMB = memUsage.heapTotal / 1024 / 1024;
|
||||
const usagePercent = (heapUsedMB / heapTotalMB) * 100;
|
||||
|
||||
return {
|
||||
status: usagePercent > 90 ? 'warning' : 'ok',
|
||||
usage: Math.round(usagePercent),
|
||||
};
|
||||
}
|
||||
+10
-10
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Home, RefreshCw, AlertTriangle } from 'lucide-react';
|
||||
|
||||
@@ -24,7 +24,7 @@ export default function Error({
|
||||
<div className="w-24 h-24 bg-[#C41E3A]/10 rounded-full flex items-center justify-center mx-auto mb-6">
|
||||
<AlertTriangle className="w-12 h-12 text-[#C41E3A]" />
|
||||
</div>
|
||||
<div className="w-32 h-1 bg-[#C41E3A] mx-auto"></div>
|
||||
<div className="w-32 h-1 bg-[#C41E3A] mx-auto" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-bold text-[#1C1C1C] mb-4">
|
||||
@@ -64,10 +64,10 @@ export default function Error({
|
||||
variant="outline"
|
||||
asChild
|
||||
>
|
||||
<Link href="/">
|
||||
<StaticLink href="/">
|
||||
<Home className="w-5 h-5 mr-2" />
|
||||
返回首页
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -77,7 +77,7 @@ export default function Error({
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Link
|
||||
<StaticLink
|
||||
href="/contact"
|
||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
@@ -88,9 +88,9 @@ export default function Error({
|
||||
<div className="font-semibold text-[#1C1C1C]">联系我们</div>
|
||||
<div className="text-sm text-[#5C5C5C]">获取技术支持</div>
|
||||
</div>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
|
||||
<Link
|
||||
<StaticLink
|
||||
href="/services"
|
||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
@@ -101,15 +101,15 @@ export default function Error({
|
||||
<div className="font-semibold text-[#1C1C1C]">核心业务</div>
|
||||
<div className="text-sm text-[#5C5C5C]">了解我们的服务</div>
|
||||
</div>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-sm text-[#5C5C5C]">
|
||||
如果问题持续存在,请{' '}
|
||||
<Link href="/contact" className="text-[#C41E3A] hover:underline">
|
||||
<StaticLink href="/contact" className="text-[#C41E3A] hover:underline">
|
||||
联系我们的技术团队
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+14
-14
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Home, ArrowLeft, Search } from 'lucide-react';
|
||||
|
||||
@@ -13,7 +13,7 @@ export default function NotFound() {
|
||||
<h1 className="text-[120px] font-bold text-[#C41E3A] leading-none mb-4">
|
||||
404
|
||||
</h1>
|
||||
<div className="w-32 h-1 bg-[#C41E3A] mx-auto mb-6"></div>
|
||||
<div className="w-32 h-1 bg-[#C41E3A] mx-auto mb-6" />
|
||||
</div>
|
||||
|
||||
<h2 className="text-3xl font-bold text-[#1C1C1C] mb-4">
|
||||
@@ -31,10 +31,10 @@ export default function NotFound() {
|
||||
asChild
|
||||
className="bg-[#C41E3A] hover:bg-[#A01830] text-white"
|
||||
>
|
||||
<Link href="/">
|
||||
<StaticLink href="/">
|
||||
<Home className="w-5 h-5 mr-2" />
|
||||
返回首页
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
@@ -53,7 +53,7 @@ export default function NotFound() {
|
||||
</h3>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Link
|
||||
<StaticLink
|
||||
href="/about"
|
||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
@@ -64,9 +64,9 @@ export default function NotFound() {
|
||||
<div className="font-semibold text-[#1C1C1C]">关于我们</div>
|
||||
<div className="text-sm text-[#5C5C5C]">了解睿新致远</div>
|
||||
</div>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
|
||||
<Link
|
||||
<StaticLink
|
||||
href="/services"
|
||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
@@ -77,9 +77,9 @@ export default function NotFound() {
|
||||
<div className="font-semibold text-[#1C1C1C]">核心业务</div>
|
||||
<div className="text-sm text-[#5C5C5C]">我们的服务</div>
|
||||
</div>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
|
||||
<Link
|
||||
<StaticLink
|
||||
href="/products"
|
||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
@@ -90,9 +90,9 @@ export default function NotFound() {
|
||||
<div className="font-semibold text-[#1C1C1C]">产品服务</div>
|
||||
<div className="text-sm text-[#5C5C5C]">企业级产品</div>
|
||||
</div>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
|
||||
<Link
|
||||
<StaticLink
|
||||
href="/cases"
|
||||
className="flex items-center p-4 bg-white rounded-lg hover:shadow-md transition-shadow group"
|
||||
>
|
||||
@@ -103,15 +103,15 @@ export default function NotFound() {
|
||||
<div className="font-semibold text-[#1C1C1C]">成功案例</div>
|
||||
<div className="text-sm text-[#5C5C5C]">客户故事</div>
|
||||
</div>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 text-sm text-[#5C5C5C]">
|
||||
如果您认为这是一个错误,请{' '}
|
||||
<Link href="/contact" className="text-[#C41E3A] hover:underline">
|
||||
<StaticLink href="/contact" className="text-[#C41E3A] hover:underline">
|
||||
联系我们
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,237 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useEditor, EditorContent } from '@tiptap/react';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import Image from '@tiptap/extension-image';
|
||||
import Link from '@tiptap/extension-link';
|
||||
import {
|
||||
Bold,
|
||||
Italic,
|
||||
Strikethrough,
|
||||
Code,
|
||||
Heading1,
|
||||
Heading2,
|
||||
Heading3,
|
||||
List,
|
||||
ListOrdered,
|
||||
Quote,
|
||||
Undo,
|
||||
Redo,
|
||||
Link as LinkIcon,
|
||||
Image as ImageIcon
|
||||
} from 'lucide-react';
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
interface RichTextEditorProps {
|
||||
content: string;
|
||||
onChange: (content: string) => void;
|
||||
}
|
||||
|
||||
export default function RichTextEditor({ content, onChange }: RichTextEditorProps) {
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Image.configure({
|
||||
HTMLAttributes: {
|
||||
class: 'max-w-full h-auto rounded-lg',
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
HTMLAttributes: {
|
||||
class: 'text-[#C41E3A] underline',
|
||||
},
|
||||
}),
|
||||
],
|
||||
content,
|
||||
onUpdate: ({ editor }) => {
|
||||
onChange(editor.getHTML());
|
||||
},
|
||||
});
|
||||
|
||||
const handleImageUpload = useCallback(async () => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/*';
|
||||
|
||||
input.onchange = async (e) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
formData.append('type', 'image');
|
||||
|
||||
const res = await fetch('/api/admin/upload', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (res.ok && editor) {
|
||||
editor.chain().focus().setImage({ src: data.file.url }).run();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('上传图片失败:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
input.click();
|
||||
}, [editor]);
|
||||
|
||||
const addLink = useCallback(() => {
|
||||
if (!editor) return;
|
||||
|
||||
const url = window.prompt('输入链接地址:');
|
||||
if (url) {
|
||||
editor.chain().focus().setLink({ href: url }).run();
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
if (!editor) {
|
||||
return (
|
||||
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 border-b border-gray-300 p-2 h-12 flex items-center justify-center">
|
||||
<div className="h-4 w-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
<span className="ml-2 text-sm text-gray-500">加载编辑器...</span>
|
||||
</div>
|
||||
<div className="min-h-[200px] bg-gray-50" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-gray-300 rounded-lg overflow-hidden">
|
||||
<div className="bg-gray-50 border-b border-gray-300 p-2 flex flex-wrap gap-1">
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBold().run()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('bold') ? 'bg-gray-200' : ''}`}
|
||||
title="粗体"
|
||||
>
|
||||
<Bold className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleItalic().run()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('italic') ? 'bg-gray-200' : ''}`}
|
||||
title="斜体"
|
||||
>
|
||||
<Italic className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleStrike().run()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('strike') ? 'bg-gray-200' : ''}`}
|
||||
title="删除线"
|
||||
>
|
||||
<Strikethrough className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleCode().run()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('code') ? 'bg-gray-200' : ''}`}
|
||||
title="代码"
|
||||
>
|
||||
<Code className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('heading', { level: 1 }) ? 'bg-gray-200' : ''}`}
|
||||
title="标题 1"
|
||||
>
|
||||
<Heading1 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('heading', { level: 2 }) ? 'bg-gray-200' : ''}`}
|
||||
title="标题 2"
|
||||
>
|
||||
<Heading2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('heading', { level: 3 }) ? 'bg-gray-200' : ''}`}
|
||||
title="标题 3"
|
||||
>
|
||||
<Heading3 className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBulletList().run()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('bulletList') ? 'bg-gray-200' : ''}`}
|
||||
title="无序列表"
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleOrderedList().run()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('orderedList') ? 'bg-gray-200' : ''}`}
|
||||
title="有序列表"
|
||||
>
|
||||
<ListOrdered className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().toggleBlockquote().run()}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('blockquote') ? 'bg-gray-200' : ''}`}
|
||||
title="引用"
|
||||
>
|
||||
<Quote className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
<button
|
||||
onClick={addLink}
|
||||
className={`p-2 rounded hover:bg-gray-200 ${editor.isActive('link') ? 'bg-gray-200' : ''}`}
|
||||
title="链接"
|
||||
>
|
||||
<LinkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleImageUpload}
|
||||
disabled={uploading}
|
||||
className="p-2 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
title="上传图片"
|
||||
>
|
||||
{uploading ? (
|
||||
<div className="h-4 w-4 border-2 border-gray-400 border-t-transparent rounded-full animate-spin" />
|
||||
) : (
|
||||
<ImageIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="w-px h-6 bg-gray-300 mx-1 self-center" />
|
||||
|
||||
<button
|
||||
onClick={() => editor.chain().focus().undo().run()}
|
||||
disabled={!editor.can().undo()}
|
||||
className="p-2 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
title="撤销"
|
||||
>
|
||||
<Undo className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => editor.chain().focus().redo().run()}
|
||||
disabled={!editor.can().redo()}
|
||||
className="p-2 rounded hover:bg-gray-200 disabled:opacity-50"
|
||||
title="重做"
|
||||
>
|
||||
<Redo className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<EditorContent
|
||||
editor={editor}
|
||||
className="prose prose-sm max-w-none p-4 min-h-[300px] focus:outline-none"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import Script from 'next/script';
|
||||
import { useEffect } from 'react';
|
||||
import { GA_MEASUREMENT_ID } from '@/lib/analytics';
|
||||
|
||||
const GA_MEASUREMENT_ID = process.env.NEXT_PUBLIC_GA_MEASUREMENT_ID || '';
|
||||
|
||||
export function GoogleAnalytics() {
|
||||
useEffect(() => {
|
||||
if (GA_MEASUREMENT_ID) {
|
||||
console.log('Google Analytics initialized:', GA_MEASUREMENT_ID);
|
||||
}
|
||||
}, []);
|
||||
|
||||
if (!GA_MEASUREMENT_ID) {
|
||||
return null;
|
||||
}
|
||||
@@ -25,10 +19,8 @@ export function GoogleAnalytics() {
|
||||
{`
|
||||
window.dataLayer = window.dataLayer || [];
|
||||
function gtag(){dataLayer.push(arguments);}
|
||||
window.gtag = gtag;
|
||||
gtag('js', new Date());
|
||||
gtag('config', '${GA_MEASUREMENT_ID}', {
|
||||
page_path: window.location.pathname,
|
||||
send_page_view: false
|
||||
});
|
||||
`}
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import '@testing-library/jest-dom';
|
||||
|
||||
jest.mock('./GoogleAnalytics', () => ({
|
||||
GoogleAnalytics: () => null,
|
||||
}));
|
||||
|
||||
jest.mock('./web-vitals', () => ({
|
||||
WebVitals: () => null,
|
||||
}));
|
||||
|
||||
describe('Analytics Components', () => {
|
||||
it('should export GoogleAnalytics', () => {
|
||||
const { GoogleAnalytics } = require('./GoogleAnalytics');
|
||||
expect(GoogleAnalytics).toBeDefined();
|
||||
});
|
||||
|
||||
it('should export WebVitals', () => {
|
||||
const { WebVitals } = require('./web-vitals');
|
||||
expect(WebVitals).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,33 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import { useReportWebVitals } from 'next/web-vitals';
|
||||
|
||||
export function WebVitals() {
|
||||
useReportWebVitals((metric) => {
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('[Web Vitals]', metric);
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
const body = JSON.stringify({
|
||||
name: metric.name,
|
||||
value: metric.value,
|
||||
rating: metric.rating,
|
||||
delta: metric.delta,
|
||||
id: metric.id,
|
||||
});
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon('/api/analytics', body);
|
||||
} else {
|
||||
fetch('/api/analytics', {
|
||||
body,
|
||||
method: 'POST',
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { ChevronRight, Home } from 'lucide-react';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
@@ -15,18 +15,18 @@ interface BreadcrumbProps {
|
||||
export function Breadcrumb({ items }: BreadcrumbProps) {
|
||||
return (
|
||||
<nav aria-label="breadcrumb" className="flex items-center space-x-2 text-sm text-[#5C5C5C] py-4">
|
||||
<Link href="/" className="flex items-center hover:text-[#C41E3A] transition-colors">
|
||||
<StaticLink href="/" className="flex items-center hover:text-[#C41E3A] transition-colors">
|
||||
<Home className="w-4 h-4" />
|
||||
</Link>
|
||||
</StaticLink>
|
||||
{items.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<ChevronRight className="w-4 h-4 text-[#E5E5E5]" />
|
||||
<Link
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
className="ml-2 hover:text-[#C41E3A] transition-colors"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import Link from 'next/link';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import Image from 'next/image';
|
||||
import { Mail, MapPin } from 'lucide-react';
|
||||
import { COMPANY_INFO, NAVIGATION } from '@/lib/constants';
|
||||
@@ -44,12 +44,12 @@ export function Footer() {
|
||||
<ul className="space-y-2.5">
|
||||
{NAVIGATION.map((item) => (
|
||||
<li key={item.id}>
|
||||
<Link
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -58,24 +58,24 @@ export function Footer() {
|
||||
<h3 className="font-semibold text-lg mb-4 text-[#1C1C1C]">服务项目</h3>
|
||||
<ul className="space-y-2.5">
|
||||
<li>
|
||||
<Link href="/services/software" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
|
||||
<StaticLink href="/services/software" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
|
||||
软件开发
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/services/cloud" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
|
||||
云服务
|
||||
</Link>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/services/data" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
|
||||
<StaticLink href="/services/data" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
|
||||
数据分析
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</li>
|
||||
<li>
|
||||
<Link href="/services/security" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
|
||||
信息安全
|
||||
</Link>
|
||||
<StaticLink href="/services/consulting" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
|
||||
技术咨询
|
||||
</StaticLink>
|
||||
</li>
|
||||
<li>
|
||||
<StaticLink href="/services/solutions" className="text-[#3D3D3D] hover:text-[#C41E3A] transition-all duration-200 inline-block hover:translate-x-1">
|
||||
解决方案
|
||||
</StaticLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -116,12 +116,12 @@ export function Footer() {
|
||||
© {new Date().getFullYear()} {COMPANY_INFO.name}. All rights reserved.
|
||||
</p>
|
||||
<div className="flex gap-6">
|
||||
<Link href="/privacy" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
|
||||
<StaticLink href="/privacy" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
|
||||
隐私政策
|
||||
</Link>
|
||||
<Link href="/terms" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
|
||||
</StaticLink>
|
||||
<StaticLink href="/terms" className="text-[#5C5C5C] hover:text-[#C41E3A] text-sm transition-colors duration-200">
|
||||
服务条款
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Suspense, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import Image from 'next/image';
|
||||
import { usePathname, useSearchParams, useRouter } from 'next/navigation';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -15,13 +15,12 @@ function HeaderContent() {
|
||||
const [isScrolled, setIsScrolled] = useState(false);
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const focusTrapRef = useFocusTrap<HTMLDivElement>(isOpen);
|
||||
const isScrollingRef = useRef(false);
|
||||
const scrollTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
const getActiveSection = useCallback(() => {
|
||||
if (pathname === '/contact') return 'contact';
|
||||
if (pathname === '/contact') {return 'contact';}
|
||||
if (pathname === '/') {
|
||||
const section = searchParams.get('section');
|
||||
return section || 'home';
|
||||
@@ -90,7 +89,7 @@ function HeaderContent() {
|
||||
e.preventDefault();
|
||||
|
||||
if (item.id === 'contact') {
|
||||
router.push('/contact');
|
||||
window.location.href = '/contact';
|
||||
} else if (item.id === 'home') {
|
||||
if (pathname === '/') {
|
||||
isScrollingRef.current = true;
|
||||
@@ -101,7 +100,7 @@ function HeaderContent() {
|
||||
isScrollingRef.current = false;
|
||||
}, 1000);
|
||||
} else {
|
||||
router.push('/');
|
||||
window.location.href = '/';
|
||||
}
|
||||
} else {
|
||||
if (pathname === '/') {
|
||||
@@ -121,12 +120,12 @@ function HeaderContent() {
|
||||
};
|
||||
scrollToSection();
|
||||
} else {
|
||||
router.push(`/?section=${item.id}`);
|
||||
window.location.href = `/?section=${item.id}`;
|
||||
}
|
||||
}
|
||||
|
||||
setIsOpen(false);
|
||||
}, [pathname, router]);
|
||||
}, [pathname]);
|
||||
|
||||
const isActive = useCallback((item: NavigationItem) => {
|
||||
if (item.id === 'contact') {
|
||||
@@ -156,7 +155,7 @@ function HeaderContent() {
|
||||
>
|
||||
<div className="container-wide">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
<Link
|
||||
<StaticLink
|
||||
href="/"
|
||||
className="flex items-center group"
|
||||
>
|
||||
@@ -168,11 +167,11 @@ function HeaderContent() {
|
||||
className="h-8 w-auto transition-transform duration-200 group-hover:scale-105"
|
||||
priority
|
||||
/>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
|
||||
<nav className="hidden md:flex items-center gap-1" role="navigation" aria-label="主导航" data-testid="desktop-navigation">
|
||||
{navigationItems.map((item) => (
|
||||
<Link
|
||||
<StaticLink
|
||||
key={item.id}
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item)}
|
||||
@@ -197,7 +196,7 @@ function HeaderContent() {
|
||||
}
|
||||
`}
|
||||
/>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
@@ -206,7 +205,7 @@ function HeaderContent() {
|
||||
size="sm"
|
||||
asChild
|
||||
>
|
||||
<Link href="/contact" data-testid="consult-button">立即咨询</Link>
|
||||
<StaticLink href="/contact" data-testid="consult-button">立即咨询</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -260,7 +259,7 @@ function HeaderContent() {
|
||||
animate={{ x: 0, opacity: 1 }}
|
||||
transition={{ delay: index * 0.05 }}
|
||||
>
|
||||
<Link
|
||||
<StaticLink
|
||||
href={item.href}
|
||||
onClick={(e) => handleNavClick(e, item)}
|
||||
className={`
|
||||
@@ -274,7 +273,7 @@ function HeaderContent() {
|
||||
style={{ minHeight: '48px', display: 'flex', alignItems: 'center' }}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
))}
|
||||
<div className="mt-6 px-4 pt-6 border-t border-[#E2E8F0]">
|
||||
@@ -283,9 +282,9 @@ function HeaderContent() {
|
||||
asChild
|
||||
size="lg"
|
||||
>
|
||||
<Link href="/contact" onClick={() => setIsOpen(false)}>
|
||||
<StaticLink href="/contact" onClick={() => setIsOpen(false)}>
|
||||
联系我们
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { Home, Briefcase, Package, FileText, User } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -33,7 +33,7 @@ export function MobileTabBar() {
|
||||
const active = isActive(tab.href);
|
||||
|
||||
return (
|
||||
<Link
|
||||
<StaticLink
|
||||
key={tab.id}
|
||||
href={tab.href}
|
||||
className="flex flex-col items-center justify-center flex-1 h-full relative group min-h-12"
|
||||
@@ -61,7 +61,7 @@ export function MobileTabBar() {
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -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 { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { COMPANY_INFO, STATS } from '@/lib/constants';
|
||||
@@ -66,10 +66,10 @@ export function AboutSection() {
|
||||
className="text-center"
|
||||
>
|
||||
<Button size="lg" variant="outline" className="group" asChild>
|
||||
<Link href="/about">
|
||||
<StaticLink href="/about">
|
||||
了解更多关于我们
|
||||
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
|
||||
@@ -1,172 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, jest } from '@jest/globals';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { CasesSection } from './cases-section';
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
useInView: () => true,
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: any) => <a href={href}>{children}</a>;
|
||||
});
|
||||
|
||||
jest.mock('@/components/ui/card', () => ({
|
||||
Card: ({ children, className, ...props }: any) => (
|
||||
<div className={className} {...props}>{children}</div>
|
||||
),
|
||||
CardContent: ({ children, className, ...props }: any) => (
|
||||
<div className={className} {...props}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/button', () => ({
|
||||
Button: ({ children, className, ...props }: any) => (
|
||||
<button className={className} {...props}>{children}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/badge', () => ({
|
||||
Badge: ({ children, className, ...props }: any) => (
|
||||
<span className={className} {...props}>{children}</span>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('@/components/ui/touch-swipe', () => ({
|
||||
TouchSwipe: ({ children, className }: any) => (
|
||||
<div className={className}>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
jest.mock('lucide-react', () => ({
|
||||
ArrowRight: () => <span data-testid="arrow-right-icon" />,
|
||||
Building2: () => <span data-testid="building-icon" />,
|
||||
}));
|
||||
|
||||
const mockCases = [
|
||||
{
|
||||
id: 'case-1',
|
||||
title: '测试案例',
|
||||
excerpt: '测试描述',
|
||||
category: '制造业',
|
||||
slug: 'test-case-1',
|
||||
},
|
||||
{
|
||||
id: 'case-2',
|
||||
title: '测试案例2',
|
||||
excerpt: '测试描述2',
|
||||
category: '零售业',
|
||||
slug: 'test-case-2',
|
||||
},
|
||||
];
|
||||
|
||||
jest.mock('@/lib/api/services', () => ({
|
||||
contentService: {
|
||||
getCases: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { contentService } from '@/lib/api/services';
|
||||
|
||||
describe('CasesSection', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(contentService.getCases as jest.Mock).mockResolvedValue(mockCases);
|
||||
});
|
||||
|
||||
describe('Rendering', () => {
|
||||
it('should render cases section', async () => {
|
||||
render(<CasesSection />);
|
||||
await waitFor(() => {
|
||||
const section = document.querySelector('section#cases');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render section heading', async () => {
|
||||
render(<CasesSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { level: 2 })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render section description', async () => {
|
||||
render(<CasesSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/我们与优秀的企业同行/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render case cards', async () => {
|
||||
render(<CasesSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('测试案例')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render industry badges', async () => {
|
||||
render(<CasesSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('制造业')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render view more button', async () => {
|
||||
render(<CasesSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/查看更多案例/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should have section id', async () => {
|
||||
render(<CasesSection />);
|
||||
await waitFor(() => {
|
||||
const section = document.querySelector('section#cases');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have region role', async () => {
|
||||
render(<CasesSection />);
|
||||
await waitFor(() => {
|
||||
const section = document.querySelector('section[role="region"]');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should have aria-labelledby', async () => {
|
||||
render(<CasesSection />);
|
||||
await waitFor(() => {
|
||||
const section = document.querySelector('section[aria-labelledby="cases-heading"]');
|
||||
expect(section).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Loading State', () => {
|
||||
it('should show loading state initially', () => {
|
||||
(contentService.getCases as jest.Mock).mockImplementation(() =>
|
||||
new Promise(resolve => setTimeout(() => resolve(mockCases), 100))
|
||||
);
|
||||
render(<CasesSection />);
|
||||
expect(screen.getByText('加载中...')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error Handling', () => {
|
||||
it('should handle fetch errors gracefully', async () => {
|
||||
(contentService.getCases as jest.Mock).mockRejectedValue(new Error('API Error'));
|
||||
|
||||
render(<CasesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('加载中...')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,56 +2,18 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useRef, useState, useEffect } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRef } from 'react';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { TouchSwipe } from '@/components/ui/touch-swipe';
|
||||
import { contentService } from '@/lib/api/services';
|
||||
import { CASES } from '@/lib/constants';
|
||||
import { ArrowRight, Building2 } from 'lucide-react';
|
||||
|
||||
interface CaseItem {
|
||||
id: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
category: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export function CasesSection() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
||||
const [cases, setCases] = useState<CaseItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCases = async () => {
|
||||
try {
|
||||
const casesData = await contentService.getCases(3);
|
||||
setCases(casesData);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cases:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchCases();
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section id="cases" role="region" aria-labelledby="cases-heading" className="py-24 bg-white relative overflow-hidden">
|
||||
<div className="container-wide relative z-10">
|
||||
<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>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="cases" role="region" aria-labelledby="cases-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
|
||||
@@ -81,20 +43,20 @@ export function CasesSection() {
|
||||
className="md:hidden"
|
||||
>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
|
||||
{cases.map((caseItem, index) => (
|
||||
{CASES.map((caseItem, index) => (
|
||||
<motion.div
|
||||
key={caseItem.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.1 + index * 0.1 }}
|
||||
>
|
||||
<Link href={`/cases/${caseItem.slug}`}>
|
||||
<StaticLink href={`/cases/${caseItem.id}`}>
|
||||
<Card className="h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors overflow-hidden">
|
||||
<div className="relative h-40 bg-gradient-to-br from-[#F5F5F5] to-[#E5E5E5] flex items-center justify-center">
|
||||
<Building2 className="w-16 h-16 text-[#C41E3A]/20 group-hover:scale-110 transition-transform duration-300" />
|
||||
<div className="absolute top-4 right-4">
|
||||
<Badge className="bg-white/90 text-[#1C1C1C] hover:bg-white">
|
||||
{caseItem.category}
|
||||
{caseItem.industry}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -107,11 +69,11 @@ export function CasesSection() {
|
||||
{caseItem.title}
|
||||
</h3>
|
||||
<p className="text-[#5C5C5C] text-sm line-clamp-2 mb-4">
|
||||
{caseItem.excerpt}
|
||||
{caseItem.description}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
@@ -124,10 +86,10 @@ export function CasesSection() {
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<Button variant="outline" size="lg" className="group" asChild>
|
||||
<Link href="/cases">
|
||||
<StaticLink href="/cases">
|
||||
查看更多案例
|
||||
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -6,11 +6,8 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Toast } from '@/components/ui/toast';
|
||||
import { sanitizeInput } from '@/lib/sanitize';
|
||||
import { generateCSRFToken, setCSRFTokenToStorage, getCSRFTokenFromStorage } from '@/lib/csrf';
|
||||
import { generateCaptcha } from '@/lib/security/captcha';
|
||||
import { useFormAutosave } from '@/hooks/use-form-autosave';
|
||||
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, RefreshCw, Save } from 'lucide-react';
|
||||
import { Mail, MapPin, Send, Loader2, Clock, HeadphonesIcon, CheckCircle2, Save } from 'lucide-react';
|
||||
import { COMPANY_INFO } from '@/lib/constants';
|
||||
|
||||
const contactFormSchema = z.object({
|
||||
@@ -27,7 +24,6 @@ interface FormErrors {
|
||||
phone?: string;
|
||||
email?: string;
|
||||
message?: string;
|
||||
captcha?: string;
|
||||
}
|
||||
|
||||
export function ContactSection() {
|
||||
@@ -38,8 +34,6 @@ export function ContactSection() {
|
||||
const [toastMessage, setToastMessage] = useState('');
|
||||
const [toastType, setToastType] = useState<'success' | 'error'>('success');
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [captcha, setCaptcha] = useState(generateCaptcha('simple'));
|
||||
const [captchaAnswer, setCaptchaAnswer] = useState('');
|
||||
const sectionRef = useRef<HTMLElement>(null);
|
||||
|
||||
// 使用表单自动保存功能
|
||||
@@ -74,9 +68,6 @@ export function ContactSection() {
|
||||
observer.observe(sectionRef.current);
|
||||
}
|
||||
|
||||
const csrfToken = generateCSRFToken();
|
||||
setCSRFTokenToStorage(csrfToken);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
@@ -95,10 +86,9 @@ export function ContactSection() {
|
||||
};
|
||||
|
||||
const handleChange = (field: keyof ContactFormData, value: string) => {
|
||||
const sanitizedValue = sanitizeInput(value);
|
||||
updateData({ [field]: sanitizedValue });
|
||||
updateData({ [field]: value });
|
||||
if (errors[field]) {
|
||||
validateField(field, sanitizedValue);
|
||||
validateField(field, value);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -106,32 +96,9 @@ export function ContactSection() {
|
||||
validateField(field, value);
|
||||
};
|
||||
|
||||
const handleCaptchaRefresh = () => {
|
||||
setCaptcha(generateCaptcha('simple'));
|
||||
setCaptchaAnswer('');
|
||||
setErrors((prev) => ({ ...prev, captcha: undefined }));
|
||||
};
|
||||
|
||||
const handleCaptchaChange = (value: string) => {
|
||||
setCaptchaAnswer(value);
|
||||
if (errors.captcha) {
|
||||
setErrors((prev) => ({ ...prev, captcha: undefined }));
|
||||
}
|
||||
};
|
||||
|
||||
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
|
||||
const storedToken = getCSRFTokenFromStorage();
|
||||
if (!storedToken) {
|
||||
setToastMessage('安全验证失败,请刷新页面重试。');
|
||||
setToastType('error');
|
||||
setShowToast(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = contactFormSchema.safeParse(formData);
|
||||
|
||||
if (!result.success) {
|
||||
const fieldErrors: FormErrors = {};
|
||||
result.error.issues.forEach((issue) => {
|
||||
@@ -141,50 +108,30 @@ export function ContactSection() {
|
||||
setErrors(fieldErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!captchaAnswer || parseInt(captchaAnswer) !== captcha.answer) {
|
||||
setErrors((prev) => ({ ...prev, captcha: '验证码错误,请重新计算' }));
|
||||
handleCaptchaRefresh();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/contact', {
|
||||
const response = await fetch('https://formspree.io/f/' + process.env.NEXT_PUBLIC_FORMSPREE_ID, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
...formData,
|
||||
csrfToken: storedToken,
|
||||
mathHash: captcha.hash,
|
||||
mathTimestamp: captcha.timestamp,
|
||||
mathAnswer: captcha.answer,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
|
||||
body: JSON.stringify(formData),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(data.error || '提交失败');
|
||||
if (response.ok) {
|
||||
setIsSubmitted(true);
|
||||
clearSavedData();
|
||||
setToastMessage('表单提交成功!我们会尽快与您联系。');
|
||||
setToastType('success');
|
||||
setShowToast(true);
|
||||
} else {
|
||||
setToastMessage('提交失败,请稍后重试。');
|
||||
setToastType('error');
|
||||
setShowToast(true);
|
||||
}
|
||||
|
||||
const newCsrfToken = generateCSRFToken();
|
||||
setCSRFTokenToStorage(newCsrfToken);
|
||||
|
||||
setIsSubmitting(false);
|
||||
setIsSubmitted(true);
|
||||
clearSavedData(); // 提交成功后清除保存的数据
|
||||
setToastMessage('表单提交成功!我们会尽快与您联系。');
|
||||
setToastType('success');
|
||||
setShowToast(true);
|
||||
} catch (error) {
|
||||
setIsSubmitting(false);
|
||||
setToastMessage(error instanceof Error ? error.message : '提交失败,请稍后重试。');
|
||||
} catch {
|
||||
setToastMessage('网络错误,请稍后重试。');
|
||||
setToastType('error');
|
||||
setShowToast(true);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +150,7 @@ export function ContactSection() {
|
||||
</div>
|
||||
|
||||
<div className="container-wide relative z-10">
|
||||
<div
|
||||
<div
|
||||
className={`
|
||||
mb-16 opacity-0 translate-y-4
|
||||
${isVisible ? 'animate-fade-in-up' : ''}
|
||||
@@ -222,7 +169,7 @@ export function ContactSection() {
|
||||
</div>
|
||||
|
||||
<div className="grid lg:grid-cols-5 gap-12 lg:gap-16">
|
||||
<div
|
||||
<div
|
||||
className={`
|
||||
lg:col-span-2 space-y-8 flex flex-col
|
||||
opacity-0 translate-y-4
|
||||
@@ -327,7 +274,7 @@ export function ContactSection() {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{isSubmitted ? (
|
||||
<div className="text-center py-12 flex-1 flex items-center justify-center" data-testid="success-message">
|
||||
<div className="w-16 h-16 bg-[#C41E3A] rounded-full flex items-center justify-center mx-auto mb-4 animate-stamp-in">
|
||||
@@ -339,23 +286,23 @@ export function ContactSection() {
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-5 flex-1 flex flex-col">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<Input
|
||||
<Input
|
||||
label="姓名"
|
||||
id="name"
|
||||
placeholder="请输入您的姓名"
|
||||
required
|
||||
id="name"
|
||||
placeholder="请输入您的姓名"
|
||||
required
|
||||
data-testid="name-input"
|
||||
value={formData.name}
|
||||
onChange={(e) => handleChange('name', e.target.value)}
|
||||
onBlur={(e) => handleBlur('name', e.target.value)}
|
||||
error={errors.name}
|
||||
/>
|
||||
<Input
|
||||
<Input
|
||||
label="电话"
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="请输入您的电话"
|
||||
required
|
||||
id="phone"
|
||||
type="tel"
|
||||
placeholder="请输入您的电话"
|
||||
required
|
||||
data-testid="phone-input"
|
||||
value={formData.phone}
|
||||
onChange={(e) => handleChange('phone', e.target.value)}
|
||||
@@ -363,65 +310,32 @@ export function ContactSection() {
|
||||
error={errors.phone}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
<Input
|
||||
label="邮箱"
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="请输入您的邮箱"
|
||||
required
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="请输入您的邮箱"
|
||||
required
|
||||
data-testid="email-input"
|
||||
value={formData.email}
|
||||
onChange={(e) => handleChange('email', e.target.value)}
|
||||
onBlur={(e) => handleBlur('email', e.target.value)}
|
||||
error={errors.email}
|
||||
/>
|
||||
<Textarea
|
||||
<Textarea
|
||||
label="留言内容"
|
||||
id="message"
|
||||
placeholder="请输入您想咨询的内容"
|
||||
id="message"
|
||||
placeholder="请输入您想咨询的内容"
|
||||
rows={5}
|
||||
required
|
||||
required
|
||||
data-testid="message-input"
|
||||
value={formData.message}
|
||||
onChange={(e) => handleChange('message', e.target.value)}
|
||||
onBlur={(e) => handleBlur('message', e.target.value)}
|
||||
error={errors.message}
|
||||
/>
|
||||
<div className="space-y-2">
|
||||
<label htmlFor="captcha" className="block text-sm font-medium text-[#1A1A2E]">
|
||||
验证码 <span className="text-[#C41E3A]">*</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-[#E2E8F0] px-4 py-2 rounded-md font-mono text-lg text-[#1A1A2E] min-w-30 text-center" data-testid="captcha-question">
|
||||
{captcha.question}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
id="captcha"
|
||||
type="number"
|
||||
placeholder="请输入答案"
|
||||
required
|
||||
data-testid="captcha-input"
|
||||
value={captchaAnswer}
|
||||
onChange={(e) => handleCaptchaChange(e.target.value)}
|
||||
error={errors.captcha}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleCaptchaRefresh}
|
||||
disabled={isSubmitting}
|
||||
data-testid="refresh-captcha"
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
<Button
|
||||
type="submit"
|
||||
size="lg"
|
||||
className="w-full group mt-auto min-h-13 md:min-h-0"
|
||||
disabled={isSubmitting}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
import Link from 'next/link';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { RippleButton, SealButton } from '@/components/ui/ripple-button';
|
||||
import { MagneticButton, BlurReveal, CounterWithEffect } from '@/lib/animations';
|
||||
import { COMPANY_INFO, STATS } from '@/lib/constants';
|
||||
@@ -102,12 +102,12 @@ export function HeroButtons({ isVisible }: HeroContentProps) {
|
||||
className="flex flex-col sm:flex-row items-center justify-center gap-4 mb-8"
|
||||
>
|
||||
<MagneticButton strength={0.4}>
|
||||
<Link href="/contact">
|
||||
<StaticLink href="/contact">
|
||||
<SealButton size="lg" className="min-w-45">
|
||||
立即咨询
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</SealButton>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</MagneticButton>
|
||||
<MagneticButton strength={0.4}>
|
||||
<RippleButton
|
||||
@@ -157,7 +157,7 @@ export function HeroStats() {
|
||||
|
||||
useEffect(() => {
|
||||
const statsEl = document.getElementById('stats-section');
|
||||
if (!statsEl) return;
|
||||
if (!statsEl) {return;}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
|
||||
@@ -45,7 +45,7 @@ export function HeroSection() {
|
||||
id="home"
|
||||
ref={sectionRef}
|
||||
aria-labelledby="hero-heading"
|
||||
className="relative min-h-screen flex items-center pt-16 overflow-hidden bg-linear-to-b from-[#FAFAFA] to-white"
|
||||
className="relative min-h-screen flex items-center overflow-hidden bg-linear-to-b from-[#FAFAFA] to-white"
|
||||
>
|
||||
<InkBackground />
|
||||
<DataParticleFlow
|
||||
|
||||
@@ -1,513 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { NewsSection } from './news-section';
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
useInView: () => true,
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: any) => <a href={href}>{children}</a>;
|
||||
});
|
||||
|
||||
jest.mock('@/hooks/use-news', () => ({
|
||||
useNews: jest.fn(),
|
||||
}));
|
||||
|
||||
const { useNews } = require('@/hooks/use-news');
|
||||
|
||||
describe('NewsSection Integration', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Data Loading States', () => {
|
||||
it('should show loading state when data is loading', () => {
|
||||
useNews.mockReturnValue({
|
||||
news: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
expect(screen.getByText('加载中...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render news when data is loaded successfully', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '测试新闻1',
|
||||
excerpt: '这是一个测试新闻',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '测试新闻2',
|
||||
excerpt: '这是另一个测试新闻',
|
||||
category: '行业资讯',
|
||||
date: '2026-01-16',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('测试新闻1')).toBeInTheDocument();
|
||||
expect(screen.getByText('测试新闻2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error message when data loading fails', async () => {
|
||||
useNews.mockReturnValue({
|
||||
news: [],
|
||||
loading: false,
|
||||
error: new Error('Failed to load news'),
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('加载新闻信息失败,请稍后重试')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show empty state when no news are available', async () => {
|
||||
useNews.mockReturnValue({
|
||||
news: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('暂无新闻信息')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('News Display', () => {
|
||||
it('should render all news from API', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '新闻B',
|
||||
excerpt: '摘要B',
|
||||
category: '行业资讯',
|
||||
date: '2026-01-16',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '新闻C',
|
||||
excerpt: '摘要C',
|
||||
category: '技术分享',
|
||||
date: '2026-01-17',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('新闻A')).toBeInTheDocument();
|
||||
expect(screen.getByText('新闻B')).toBeInTheDocument();
|
||||
expect(screen.getByText('新闻C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display news categories correctly', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('公司新闻')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display news dates correctly', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('2026-01-15')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display news excerpts', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '这是一个关于公司最新发展的新闻摘要',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('这是一个关于公司最新发展的新闻摘要')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('News Filtering', () => {
|
||||
it('should filter news by categories config', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '新闻B',
|
||||
excerpt: '摘要B',
|
||||
category: '行业资讯',
|
||||
date: '2026-01-16',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '新闻C',
|
||||
excerpt: '摘要C',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-17',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection config={{ categories: ['公司新闻'] }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('新闻A')).toBeInTheDocument();
|
||||
expect(screen.getByText('新闻C')).toBeInTheDocument();
|
||||
expect(screen.queryByText('新闻B')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show all news when no categories config is provided', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '新闻B',
|
||||
excerpt: '摘要B',
|
||||
category: '行业资讯',
|
||||
date: '2026-01-16',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('新闻A')).toBeInTheDocument();
|
||||
expect(screen.getByText('新闻B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should limit news display count', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '新闻B',
|
||||
excerpt: '摘要B',
|
||||
category: '行业资讯',
|
||||
date: '2026-01-16',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '新闻C',
|
||||
excerpt: '摘要C',
|
||||
category: '技术分享',
|
||||
date: '2026-01-17',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection config={{ displayCount: 2 }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('新闻C')).toBeInTheDocument();
|
||||
expect(screen.getByText('新闻B')).toBeInTheDocument();
|
||||
expect(screen.queryByText('新闻A')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sorting', () => {
|
||||
it('should sort news in descending order by default', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '新闻B',
|
||||
excerpt: '摘要B',
|
||||
category: '行业资讯',
|
||||
date: '2026-01-17',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '新闻C',
|
||||
excerpt: '摘要C',
|
||||
category: '技术分享',
|
||||
date: '2026-01-16',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection config={{ sortOrder: 'desc' }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const newsItems = screen.getAllByText(/新闻[ABC]/);
|
||||
expect(newsItems[0]).toHaveTextContent('新闻B');
|
||||
expect(newsItems[1]).toHaveTextContent('新闻C');
|
||||
expect(newsItems[2]).toHaveTextContent('新闻A');
|
||||
});
|
||||
});
|
||||
|
||||
it('should sort news in ascending order when configured', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '新闻B',
|
||||
excerpt: '摘要B',
|
||||
category: '行业资讯',
|
||||
date: '2026-01-17',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '新闻C',
|
||||
excerpt: '摘要C',
|
||||
category: '技术分享',
|
||||
date: '2026-01-16',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection config={{ sortOrder: 'asc' }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const newsItems = screen.getAllByText(/新闻[ABC]/);
|
||||
expect(newsItems[0]).toHaveTextContent('新闻A');
|
||||
expect(newsItems[1]).toHaveTextContent('新闻C');
|
||||
expect(newsItems[2]).toHaveTextContent('新闻B');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should link to news detail pages', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
const newsLink = screen.getByRole('link', { name: /阅读更多/ });
|
||||
expect(newsLink).toHaveAttribute('href', '/news/1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should link to all news page', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
const allNewsLink = screen.getByRole('link', { name: /查看全部新闻/ });
|
||||
expect(allNewsLink).toHaveAttribute('href', '/news');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should maintain accessibility with dynamic data', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: '新闻A',
|
||||
excerpt: '摘要A',
|
||||
category: '公司新闻',
|
||||
date: '2026-01-15',
|
||||
},
|
||||
];
|
||||
|
||||
useNews.mockReturnValue({
|
||||
news: mockNews,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<NewsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
const section = screen.getByRole('region');
|
||||
expect(section).toBeInTheDocument();
|
||||
expect(section).toHaveAttribute('aria-labelledby', 'news-heading');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,72 +2,19 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useRef, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRef } from 'react';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { ArrowRight, Calendar } from 'lucide-react';
|
||||
import { useNews } from '@/hooks/use-news';
|
||||
import { NEWS } from '@/lib/constants';
|
||||
|
||||
interface NewsConfig {
|
||||
enabled?: boolean;
|
||||
displayCount?: number;
|
||||
categories?: string[];
|
||||
sortOrder?: 'asc' | 'desc';
|
||||
}
|
||||
|
||||
interface NewsSectionProps {
|
||||
config?: NewsConfig;
|
||||
}
|
||||
|
||||
export function NewsSection({ config }: NewsSectionProps) {
|
||||
export function NewsSection() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
||||
const { news, loading, error } = useNews();
|
||||
|
||||
const displayedNews = useMemo(() => {
|
||||
if (!news || news.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let filtered = news;
|
||||
|
||||
if (config?.categories && config.categories.length > 0) {
|
||||
filtered = filtered.filter(newsItem => config.categories?.includes(newsItem.category));
|
||||
}
|
||||
|
||||
if (config?.sortOrder === 'asc') {
|
||||
filtered = [...filtered].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
} else {
|
||||
filtered = [...filtered].sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
}
|
||||
|
||||
const count = config?.displayCount || 4;
|
||||
return filtered.slice(0, count);
|
||||
}, [news, config]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section id="news" role="region" aria-labelledby="news-heading" className="py-24 bg-[#F5F5F5]" ref={ref}>
|
||||
<div className="container-custom">
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-[#5C5C5C]">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section id="news" role="region" aria-labelledby="news-heading" className="py-24 bg-[#F5F5F5]" ref={ref}>
|
||||
<div className="container-custom">
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-red-600">加载新闻信息失败,请稍后重试</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
const displayedNews = [...NEWS]
|
||||
.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
.slice(0, 4);
|
||||
|
||||
return (
|
||||
<section id="news" role="region" aria-labelledby="news-heading" className="py-24 bg-[#F5F5F5]" ref={ref}>
|
||||
@@ -112,13 +59,13 @@ export function NewsSection({ config }: NewsSectionProps) {
|
||||
<CardDescription className="text-base leading-relaxed mb-6 flex-1">
|
||||
{newsItem.excerpt}
|
||||
</CardDescription>
|
||||
<Link
|
||||
<StaticLink
|
||||
href={`/news/${newsItem.id}`}
|
||||
className="inline-flex items-center text-sm font-medium text-[#1C1C1C] hover:text-[#C41E3A] transition-colors group/link"
|
||||
>
|
||||
阅读更多
|
||||
<ArrowRight className="ml-1 w-4 h-4 transition-transform group-hover/link:translate-x-1" />
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
@@ -136,13 +83,13 @@ export function NewsSection({ config }: NewsSectionProps) {
|
||||
transition={{ duration: 0.6, delay: 0.5 }}
|
||||
className="mt-12 text-center"
|
||||
>
|
||||
<Link
|
||||
<StaticLink
|
||||
href="/news"
|
||||
className="inline-flex items-center text-sm font-medium text-[#1C1C1C] hover:text-[#C41E3A] transition-colors bg-transparent border-none cursor-pointer group"
|
||||
>
|
||||
查看全部新闻
|
||||
<ArrowRight className="ml-1 w-4 h-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1,472 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { ProductsSection } from './products-section';
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
useInView: () => true,
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: any) => <a href={href}>{children}</a>;
|
||||
});
|
||||
|
||||
jest.mock('@/hooks/use-products', () => ({
|
||||
useProducts: jest.fn(),
|
||||
}));
|
||||
|
||||
const { useProducts } = require('@/hooks/use-products');
|
||||
|
||||
describe('ProductsSection Integration', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Data Loading States', () => {
|
||||
it('should show loading state when data is loading', () => {
|
||||
useProducts.mockReturnValue({
|
||||
products: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
expect(screen.getByText('加载中...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render products when data is loaded successfully', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '测试产品1',
|
||||
description: '这是一个测试产品',
|
||||
category: '软件',
|
||||
features: ['功能1', '功能2'],
|
||||
benefits: ['价值1', '价值2'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '测试产品2',
|
||||
description: '这是另一个测试产品',
|
||||
category: '硬件',
|
||||
features: ['功能3', '功能4'],
|
||||
benefits: ['价值3', '价值4'],
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('测试产品1')).toBeInTheDocument();
|
||||
expect(screen.getByText('测试产品2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error message when data loading fails', async () => {
|
||||
useProducts.mockReturnValue({
|
||||
products: [],
|
||||
isLoading: false,
|
||||
error: new Error('Failed to load products'),
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('加载产品信息失败,请稍后重试')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show empty state when no products are available', async () => {
|
||||
useProducts.mockReturnValue({
|
||||
products: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('暂无产品信息')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Product Display', () => {
|
||||
it('should render all products from API', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '类别1',
|
||||
features: ['功能A1', '功能A2'],
|
||||
benefits: ['价值A1'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '产品B',
|
||||
description: '描述B',
|
||||
category: '类别2',
|
||||
features: ['功能B1'],
|
||||
benefits: ['价值B1', '价值B2'],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '产品C',
|
||||
description: '描述C',
|
||||
category: '类别1',
|
||||
features: ['功能C1', '功能C2', '功能C3'],
|
||||
benefits: ['价值C1'],
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('产品A')).toBeInTheDocument();
|
||||
expect(screen.getByText('产品B')).toBeInTheDocument();
|
||||
expect(screen.getByText('产品C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display product categories correctly', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '企业软件',
|
||||
features: ['功能A1'],
|
||||
benefits: ['价值A1'],
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('企业软件')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display product features', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '软件',
|
||||
features: ['智能分析', '实时监控', '自动化报告'],
|
||||
benefits: ['提高效率'],
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('智能分析')).toBeInTheDocument();
|
||||
expect(screen.getByText('实时监控')).toBeInTheDocument();
|
||||
expect(screen.getByText('自动化报告')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display product benefits', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '软件',
|
||||
features: ['功能A1'],
|
||||
benefits: ['降低成本', '提高效率', '增强竞争力'],
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('降低成本')).toBeInTheDocument();
|
||||
expect(screen.getByText('提高效率')).toBeInTheDocument();
|
||||
expect(screen.getByText('增强竞争力')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Product Filtering', () => {
|
||||
it('should filter products by featured products config', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '软件',
|
||||
features: ['功能A1'],
|
||||
benefits: ['价值A1'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '产品B',
|
||||
description: '描述B',
|
||||
category: '硬件',
|
||||
features: ['功能B1'],
|
||||
benefits: ['价值B1'],
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '产品C',
|
||||
description: '描述C',
|
||||
category: '服务',
|
||||
features: ['功能C1'],
|
||||
benefits: ['价值C1'],
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection config={{ featuredProducts: ['1', '3'] }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('产品A')).toBeInTheDocument();
|
||||
expect(screen.getByText('产品C')).toBeInTheDocument();
|
||||
expect(screen.queryByText('产品B')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show all products when no featured products config is provided', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '软件',
|
||||
features: ['功能A1'],
|
||||
benefits: ['价值A1'],
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '产品B',
|
||||
description: '描述B',
|
||||
category: '硬件',
|
||||
features: ['功能B1'],
|
||||
benefits: ['价值B1'],
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('产品A')).toBeInTheDocument();
|
||||
expect(screen.getByText('产品B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Pricing Display', () => {
|
||||
it('should display pricing when showPricing is enabled', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '软件',
|
||||
features: ['功能A1'],
|
||||
benefits: ['价值A1'],
|
||||
pricing: {
|
||||
basic: '基础版:¥999/月',
|
||||
pro: '专业版:¥1999/月',
|
||||
enterprise: '企业版:联系销售',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection config={{ showPricing: true }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('价格方案')).toBeInTheDocument();
|
||||
expect(screen.getByText('基础版:¥999/月')).toBeInTheDocument();
|
||||
expect(screen.getByText('专业版:¥1999/月')).toBeInTheDocument();
|
||||
expect(screen.getByText('企业版:联系销售')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not display pricing when showPricing is disabled', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '软件',
|
||||
features: ['功能A1'],
|
||||
benefits: ['价值A1'],
|
||||
pricing: {
|
||||
basic: '基础版:¥999/月',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection config={{ showPricing: false }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('价格方案')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('基础版:¥999/月')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should link to product detail pages', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '软件',
|
||||
features: ['功能A1'],
|
||||
benefits: ['价值A1'],
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
const productLink = screen.getByRole('link', { name: /产品A/ });
|
||||
expect(productLink).toHaveAttribute('href', '/products/1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should link to contact page for custom solutions', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '软件',
|
||||
features: ['功能A1'],
|
||||
benefits: ['价值A1'],
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
const contactLink = screen.getByRole('link', { name: /联系我们/ });
|
||||
expect(contactLink).toHaveAttribute('href', '/contact');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should maintain accessibility with dynamic data', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: '产品A',
|
||||
description: '描述A',
|
||||
category: '软件',
|
||||
features: ['功能A1'],
|
||||
benefits: ['价值A1'],
|
||||
},
|
||||
];
|
||||
|
||||
useProducts.mockReturnValue({
|
||||
products: mockProducts,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ProductsSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
const section = screen.getByRole('region');
|
||||
expect(section).toBeInTheDocument();
|
||||
expect(section).toHaveAttribute('aria-labelledby', 'products-heading');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,62 +2,17 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useRef, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useRef } from 'react';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ArrowRight, Check, TrendingUp } from 'lucide-react';
|
||||
import { useProducts } from '@/hooks/use-products';
|
||||
import { PRODUCTS } from '@/lib/constants';
|
||||
|
||||
interface ProductsConfig {
|
||||
enabled?: boolean;
|
||||
showPricing?: boolean;
|
||||
featuredProducts?: string[];
|
||||
}
|
||||
|
||||
interface ProductsSectionProps {
|
||||
config?: ProductsConfig;
|
||||
}
|
||||
|
||||
export function ProductsSection({ config }: ProductsSectionProps) {
|
||||
export function ProductsSection() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
||||
const { products, loading, error } = useProducts();
|
||||
|
||||
const filteredProducts = useMemo(() => {
|
||||
if (!products || products.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (!config?.featuredProducts || config.featuredProducts.length === 0) {
|
||||
return products;
|
||||
}
|
||||
return products.filter(product => config.featuredProducts?.includes(product.id));
|
||||
}, [products, config]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section id="products" role="region" aria-labelledby="products-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden">
|
||||
<div className="container-wide relative z-10">
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-[#5C5C5C]">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section id="products" role="region" aria-labelledby="products-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden">
|
||||
<div className="container-wide relative z-10">
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-red-600">加载产品信息失败,请稍后重试</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="products" role="region" aria-labelledby="products-heading" className="py-24 bg-[#F5F7FA] relative overflow-hidden" ref={ref}>
|
||||
@@ -78,16 +33,16 @@ export function ProductsSection({ config }: ProductsSectionProps) {
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{filteredProducts.length > 0 ? (
|
||||
{PRODUCTS.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{filteredProducts.map((product, idx) => (
|
||||
{PRODUCTS.map((product, idx) => (
|
||||
<motion.div
|
||||
key={product.id}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={isInView ? { opacity: 1, y: 0 } : {}}
|
||||
transition={{ duration: 0.5, delay: 0.1 + idx * 0.1 }}
|
||||
>
|
||||
<Link href={`/products/${product.id}`}>
|
||||
<StaticLink href={`/products/${product.id}`}>
|
||||
<Card className="h-full flex flex-col group cursor-pointer border-[#E5E5E5] hover:border-[#1C1C1C] transition-colors">
|
||||
<CardHeader>
|
||||
<Badge variant="secondary" className="w-fit mb-3">
|
||||
@@ -130,7 +85,7 @@ export function ProductsSection({ config }: ProductsSectionProps) {
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{config?.showPricing && product.pricing && (
|
||||
{product.pricing && (
|
||||
<div className="mb-4 p-3 bg-[#F5F7FA] rounded-lg">
|
||||
<p className="text-sm font-medium text-[#1C1C1C] mb-2">价格方案</p>
|
||||
<div className="space-y-1">
|
||||
@@ -149,7 +104,7 @@ export function ProductsSection({ config }: ProductsSectionProps) {
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
@@ -181,10 +136,10 @@ export function ProductsSection({ config }: ProductsSectionProps) {
|
||||
size="lg"
|
||||
asChild
|
||||
>
|
||||
<Link href="/contact">
|
||||
<StaticLink href="/contact">
|
||||
联系我们
|
||||
<ArrowRight className="ml-2 w-4 h-4" />
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,347 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import '@testing-library/jest-dom';
|
||||
import { ServicesSection } from './services-section';
|
||||
|
||||
jest.mock('framer-motion', () => ({
|
||||
motion: {
|
||||
div: ({ children, ...props }: any) => <div {...props}>{children}</div>,
|
||||
},
|
||||
useInView: () => true,
|
||||
}));
|
||||
|
||||
jest.mock('next/link', () => {
|
||||
return ({ children, href }: any) => <a href={href}>{children}</a>;
|
||||
});
|
||||
|
||||
jest.mock('@/hooks/use-services', () => ({
|
||||
useServices: jest.fn(),
|
||||
}));
|
||||
|
||||
const { useServices } = require('@/hooks/use-services');
|
||||
|
||||
describe('ServicesSection Integration', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('Data Loading States', () => {
|
||||
it('should show loading state when data is loading', () => {
|
||||
useServices.mockReturnValue({
|
||||
services: [],
|
||||
loading: true,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
expect(screen.getByText('加载中...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render services when data is loaded successfully', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: '1',
|
||||
title: '测试服务1',
|
||||
description: '这是一个测试服务',
|
||||
icon: 'Code',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '测试服务2',
|
||||
description: '这是另一个测试服务',
|
||||
icon: 'Cloud',
|
||||
},
|
||||
];
|
||||
|
||||
useServices.mockReturnValue({
|
||||
services: mockServices,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('测试服务1')).toBeInTheDocument();
|
||||
expect(screen.getByText('测试服务2')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show error message when data loading fails', async () => {
|
||||
useServices.mockReturnValue({
|
||||
services: [],
|
||||
loading: false,
|
||||
error: new Error('Failed to load services'),
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('加载服务信息失败,请稍后重试')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show empty state when no services are available', async () => {
|
||||
useServices.mockReturnValue({
|
||||
services: [],
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('暂无服务信息')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service Display', () => {
|
||||
it('should render all services from API', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: '1',
|
||||
title: '服务A',
|
||||
description: '描述A',
|
||||
icon: 'Code',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '服务B',
|
||||
description: '描述B',
|
||||
icon: 'Cloud',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '服务C',
|
||||
description: '描述C',
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
];
|
||||
|
||||
useServices.mockReturnValue({
|
||||
services: mockServices,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('服务A')).toBeInTheDocument();
|
||||
expect(screen.getByText('服务B')).toBeInTheDocument();
|
||||
expect(screen.getByText('服务C')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display service descriptions', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: '1',
|
||||
title: '服务A',
|
||||
description: '这是一个专业的软件开发服务',
|
||||
icon: 'Code',
|
||||
},
|
||||
];
|
||||
|
||||
useServices.mockReturnValue({
|
||||
services: mockServices,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('这是一个专业的软件开发服务')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should display service icons', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: '1',
|
||||
title: '服务A',
|
||||
description: '描述A',
|
||||
icon: 'Code',
|
||||
},
|
||||
];
|
||||
|
||||
useServices.mockReturnValue({
|
||||
services: mockServices,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
const icons = document.querySelectorAll('svg');
|
||||
expect(icons.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service Filtering', () => {
|
||||
it('should filter services by items config', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: '1',
|
||||
title: '服务A',
|
||||
description: '描述A',
|
||||
icon: 'Code',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '服务B',
|
||||
description: '描述B',
|
||||
icon: 'Cloud',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: '服务C',
|
||||
description: '描述C',
|
||||
icon: 'BarChart3',
|
||||
},
|
||||
];
|
||||
|
||||
useServices.mockReturnValue({
|
||||
services: mockServices,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection config={{ items: ['1', '3'] }} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('服务A')).toBeInTheDocument();
|
||||
expect(screen.getByText('服务C')).toBeInTheDocument();
|
||||
expect(screen.queryByText('服务B')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show all services when no items config is provided', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: '1',
|
||||
title: '服务A',
|
||||
description: '描述A',
|
||||
icon: 'Code',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '服务B',
|
||||
description: '描述B',
|
||||
icon: 'Cloud',
|
||||
},
|
||||
];
|
||||
|
||||
useServices.mockReturnValue({
|
||||
services: mockServices,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('服务A')).toBeInTheDocument();
|
||||
expect(screen.getByText('服务B')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Navigation', () => {
|
||||
it('should link to service detail pages', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: '1',
|
||||
title: '服务A',
|
||||
description: '描述A',
|
||||
icon: 'Code',
|
||||
},
|
||||
];
|
||||
|
||||
useServices.mockReturnValue({
|
||||
services: mockServices,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
const serviceLink = screen.getByRole('link', { name: /服务A/ });
|
||||
expect(serviceLink).toHaveAttribute('href', '/services/1');
|
||||
});
|
||||
});
|
||||
|
||||
it('should link to all services page', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: '1',
|
||||
title: '服务A',
|
||||
description: '描述A',
|
||||
icon: 'Code',
|
||||
},
|
||||
];
|
||||
|
||||
useServices.mockReturnValue({
|
||||
services: mockServices,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
const allServicesLink = screen.getByRole('link', { name: /查看全部服务/ });
|
||||
expect(allServicesLink).toHaveAttribute('href', '/services');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessibility', () => {
|
||||
it('should maintain accessibility with dynamic data', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: '1',
|
||||
title: '服务A',
|
||||
description: '描述A',
|
||||
icon: 'Code',
|
||||
},
|
||||
];
|
||||
|
||||
useServices.mockReturnValue({
|
||||
services: mockServices,
|
||||
loading: false,
|
||||
error: null,
|
||||
refetch: jest.fn(),
|
||||
});
|
||||
|
||||
render(<ServicesSection />);
|
||||
|
||||
await waitFor(() => {
|
||||
const section = screen.getByRole('region');
|
||||
expect(section).toBeInTheDocument();
|
||||
expect(section).toHaveAttribute('aria-labelledby', 'services-heading');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,67 +2,23 @@
|
||||
|
||||
import { motion } from 'framer-motion';
|
||||
import { useInView } from 'framer-motion';
|
||||
import { useRef, useMemo } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Code, Cloud, BarChart3, Shield, ArrowRight } from 'lucide-react';
|
||||
import { useRef } from 'react';
|
||||
import { StaticLink } from '@/components/ui/static-link';
|
||||
import { Code, BarChart3, Lightbulb, Puzzle, ArrowRight } from 'lucide-react';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useServices } from '@/hooks/use-services';
|
||||
import { SERVICES } from '@/lib/constants';
|
||||
|
||||
const iconMap: Record<string, React.ComponentType<{ className?: string }>> = {
|
||||
Code,
|
||||
Cloud,
|
||||
BarChart3,
|
||||
Shield,
|
||||
Lightbulb,
|
||||
Puzzle,
|
||||
};
|
||||
|
||||
interface ServicesConfig {
|
||||
enabled?: boolean;
|
||||
items?: string[];
|
||||
}
|
||||
|
||||
interface ServicesSectionProps {
|
||||
config?: ServicesConfig;
|
||||
}
|
||||
|
||||
export function ServicesSection({ config }: ServicesSectionProps) {
|
||||
export function ServicesSection() {
|
||||
const ref = useRef(null);
|
||||
const isInView = useInView(ref, { once: true, margin: '-100px' });
|
||||
const { services, loading, error } = useServices();
|
||||
|
||||
const filteredServices = useMemo(() => {
|
||||
if (!services || services.length === 0) {
|
||||
return [];
|
||||
}
|
||||
if (!config?.items || config.items.length === 0) {
|
||||
return services;
|
||||
}
|
||||
return services.filter(service => config.items?.includes(service.id));
|
||||
}, [services, config]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<section id="services" aria-labelledby="services-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
|
||||
<div className="container-wide relative z-10">
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-[#5C5C5C]">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<section id="services" aria-labelledby="services-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
|
||||
<div className="container-wide relative z-10">
|
||||
<div className="text-center">
|
||||
<p className="text-lg text-red-600">加载服务信息失败,请稍后重试</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="services" aria-labelledby="services-heading" className="py-24 bg-white relative overflow-hidden" ref={ref}>
|
||||
@@ -84,9 +40,9 @@ export function ServicesSection({ config }: ServicesSectionProps) {
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
{filteredServices.length > 0 ? (
|
||||
{SERVICES.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{filteredServices.map((service, index) => {
|
||||
{SERVICES.map((service, index) => {
|
||||
const Icon = iconMap[service.icon];
|
||||
return (
|
||||
<motion.div
|
||||
@@ -96,7 +52,7 @@ export function ServicesSection({ config }: ServicesSectionProps) {
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
>
|
||||
<Link href={`/services/${service.id}`}>
|
||||
<StaticLink href={`/services/${service.id}`}>
|
||||
<Card className="p-6 h-full group cursor-pointer border-[#E5E5E5] hover:border-[#C41E3A] transition-colors">
|
||||
<CardContent className="p-0">
|
||||
<div className="w-12 h-12 rounded-xl bg-[#F5F5F5] flex items-center justify-center mb-4 group-hover:bg-[#C41E3A] transition-all duration-300">
|
||||
@@ -110,7 +66,7 @@ export function ServicesSection({ config }: ServicesSectionProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</motion.div>
|
||||
);
|
||||
})}
|
||||
@@ -128,10 +84,10 @@ export function ServicesSection({ config }: ServicesSectionProps) {
|
||||
className="text-center mt-12"
|
||||
>
|
||||
<Button variant="outline" size="lg" className="group" asChild>
|
||||
<Link href="/services">
|
||||
<StaticLink href="/services">
|
||||
查看全部服务
|
||||
<ArrowRight className="ml-2 w-4 h-4 transition-transform group-hover:translate-x-1" />
|
||||
</Link>
|
||||
</StaticLink>
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
@@ -1,18 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { ArrowLeft } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
/**
|
||||
* BackButton - 统一的返回按钮组件
|
||||
*
|
||||
* 在纯静态导出模式下使用 window.history.back() 替代 Next.js 的 router.back(),
|
||||
* 确保在无服务端路由的环境下正常工作。
|
||||
*/
|
||||
export function BackButton() {
|
||||
const router = useRouter();
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-[#5C5C5C] hover:text-[#C41E3A] hover:bg-transparent h-auto py-2 px-3"
|
||||
onClick={() => router.back()}
|
||||
onClick={() => window.history.back()}
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 mr-2" />
|
||||
返回
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, type AnchorHTMLAttributes, type MouseEventHandler, type ReactNode } from 'react';
|
||||
|
||||
/**
|
||||
* StaticLink - 纯静态站点专用链接组件
|
||||
*
|
||||
* 在 output: 'export' 模式下,Next.js 的客户端路由会拦截所有站内 <a> 标签的点击,
|
||||
* 尝试发送 RSC 请求,导致 "Failed to fetch RSC payload" 错误。
|
||||
*
|
||||
* 本组件通过 e.preventDefault() 阻止 Next.js 拦截,然后根据情况导航:
|
||||
* - 有外部 onClick:只阻止拦截,由外部 onClick 控制导航
|
||||
* - 外部链接 / 新窗口:不拦截,保持默认行为
|
||||
* - Hash 链接:平滑滚动
|
||||
* - 站内链接:window.location.href 完整页面导航
|
||||
*
|
||||
* @see https://github.com/vercel/next.js/issues/85374
|
||||
*/
|
||||
interface StaticLinkProps extends AnchorHTMLAttributes<HTMLAnchorElement> {
|
||||
children: ReactNode;
|
||||
href: string;
|
||||
}
|
||||
|
||||
function isExternalLink(href: string): boolean {
|
||||
return href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:') || href.startsWith('tel:');
|
||||
}
|
||||
|
||||
export function StaticLink({ children, href, onClick, target, rel, ...props }: StaticLinkProps) {
|
||||
const handleClick: MouseEventHandler<HTMLAnchorElement> = useCallback(
|
||||
(e) => {
|
||||
// 外部链接或新窗口打开:不拦截,保持默认行为
|
||||
if (isExternalLink(href) || target === '_blank') {
|
||||
onClick?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// 阻止 Next.js 客户端路由拦截
|
||||
e.preventDefault();
|
||||
|
||||
// 如果有外部 onClick,由它完全控制导航行为
|
||||
if (onClick) {
|
||||
onClick(e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Hash 链接:平滑滚动
|
||||
if (href.includes('#')) {
|
||||
const [path, hash] = href.split('#');
|
||||
if (path && path !== window.location.pathname) {
|
||||
window.location.href = href;
|
||||
} else if (hash) {
|
||||
const el = document.getElementById(hash);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth' });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 站内页面链接:完整页面导航
|
||||
window.location.href = href;
|
||||
},
|
||||
[href, onClick, target]
|
||||
);
|
||||
|
||||
// 外部链接自动添加安全属性
|
||||
const linkRel = isExternalLink(href)
|
||||
? 'noopener noreferrer'
|
||||
: rel;
|
||||
|
||||
return (
|
||||
<a href={href} onClick={handleClick} target={target} rel={linkRel} {...props}>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { drizzle } from 'drizzle-orm/libsql';
|
||||
import { createClient } from '@libsql/client';
|
||||
|
||||
const client = createClient({
|
||||
url: process.env.DATABASE_URL || 'file:./data.db',
|
||||
});
|
||||
|
||||
export const db = drizzle(client);
|
||||
@@ -1,340 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||
import { db } from '@/db';
|
||||
import { users, content, siteConfig } from '@/db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
jest.mock('@/db', () => {
|
||||
const mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
};
|
||||
return {
|
||||
db: mockDb,
|
||||
};
|
||||
});
|
||||
|
||||
describe('database mutations', () => {
|
||||
let mockDb: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const { db: dbInstance } = require('@/db');
|
||||
mockDb = dbInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('user mutations', () => {
|
||||
it('should insert new user', async () => {
|
||||
const newUser = {
|
||||
id: 'user-123',
|
||||
email: 'newuser@example.com',
|
||||
name: 'New User',
|
||||
role: 'editor',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([newUser]);
|
||||
|
||||
const result = await db.insert(users).values(newUser).returning();
|
||||
const user = result[0];
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.id).toBe('user-123');
|
||||
expect(user.email).toBe('newuser@example.com');
|
||||
expect(mockDb.insert).toHaveBeenCalledWith(users);
|
||||
expect(mockDb.values).toHaveBeenCalledWith(newUser);
|
||||
});
|
||||
|
||||
it('should update user', async () => {
|
||||
const updatedUser = {
|
||||
id: 'user-123',
|
||||
email: 'updated@example.com',
|
||||
name: 'Updated User',
|
||||
role: 'admin',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([updatedUser]);
|
||||
|
||||
const result = await db
|
||||
.update(users)
|
||||
.set({ name: 'Updated User', role: 'admin' })
|
||||
.where(eq(users.id, 'user-123'))
|
||||
.returning();
|
||||
const user = result[0];
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.name).toBe('Updated User');
|
||||
expect(user.role).toBe('admin');
|
||||
expect(mockDb.update).toHaveBeenCalledWith(users);
|
||||
expect(mockDb.set).toHaveBeenCalledWith({ name: 'Updated User', role: 'admin' });
|
||||
});
|
||||
|
||||
it('should delete user', async () => {
|
||||
const deletedUser = {
|
||||
id: 'user-123',
|
||||
email: 'deleted@example.com',
|
||||
name: 'Deleted User',
|
||||
role: 'editor',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([deletedUser]);
|
||||
|
||||
const result = await db.delete(users).where(eq(users.id, 'user-123')).returning();
|
||||
const user = result[0];
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.id).toBe('user-123');
|
||||
expect(mockDb.delete).toHaveBeenCalledWith(users);
|
||||
expect(mockDb.where).toHaveBeenCalledWith(eq(users.id, 'user-123'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('content mutations', () => {
|
||||
it('should insert new content', async () => {
|
||||
const newContent = {
|
||||
id: 'content-123',
|
||||
type: 'news',
|
||||
title: 'New Content',
|
||||
slug: 'new-content',
|
||||
content: 'Content body',
|
||||
status: 'draft',
|
||||
authorId: 'user-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([newContent]);
|
||||
|
||||
const result = await db.insert(content).values(newContent).returning();
|
||||
const item = result[0];
|
||||
|
||||
expect(item).toBeDefined();
|
||||
expect(item.id).toBe('content-123');
|
||||
expect(item.title).toBe('New Content');
|
||||
expect(mockDb.insert).toHaveBeenCalledWith(content);
|
||||
expect(mockDb.values).toHaveBeenCalledWith(newContent);
|
||||
});
|
||||
|
||||
it('should update content', async () => {
|
||||
const updatedContent = {
|
||||
id: 'content-123',
|
||||
type: 'news',
|
||||
title: 'Updated Content',
|
||||
slug: 'updated-content',
|
||||
content: 'Updated content body',
|
||||
status: 'published',
|
||||
authorId: 'user-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([updatedContent]);
|
||||
|
||||
const result = await db
|
||||
.update(content)
|
||||
.set({ title: 'Updated Content', status: 'published' })
|
||||
.where(eq(content.id, 'content-123'))
|
||||
.returning();
|
||||
const item = result[0];
|
||||
|
||||
expect(item).toBeDefined();
|
||||
expect(item.title).toBe('Updated Content');
|
||||
expect(item.status).toBe('published');
|
||||
expect(mockDb.update).toHaveBeenCalledWith(content);
|
||||
expect(mockDb.set).toHaveBeenCalledWith({ title: 'Updated Content', status: 'published' });
|
||||
});
|
||||
|
||||
it('should delete content', async () => {
|
||||
const deletedContent = {
|
||||
id: 'content-123',
|
||||
type: 'news',
|
||||
title: 'Deleted Content',
|
||||
slug: 'deleted-content',
|
||||
content: 'Deleted content body',
|
||||
status: 'archived',
|
||||
authorId: 'user-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([deletedContent]);
|
||||
|
||||
const result = await db.delete(content).where(eq(content.id, 'content-123')).returning();
|
||||
const item = result[0];
|
||||
|
||||
expect(item).toBeDefined();
|
||||
expect(item.id).toBe('content-123');
|
||||
expect(mockDb.delete).toHaveBeenCalledWith(content);
|
||||
expect(mockDb.where).toHaveBeenCalledWith(eq(content.id, 'content-123'));
|
||||
});
|
||||
|
||||
it('should publish content', async () => {
|
||||
const publishedContent = {
|
||||
id: 'content-123',
|
||||
type: 'news',
|
||||
title: 'Published Content',
|
||||
slug: 'published-content',
|
||||
content: 'Published content body',
|
||||
status: 'published',
|
||||
publishedAt: new Date(),
|
||||
authorId: 'user-123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([publishedContent]);
|
||||
|
||||
const result = await db
|
||||
.update(content)
|
||||
.set({ status: 'published', publishedAt: new Date() })
|
||||
.where(eq(content.id, 'content-123'))
|
||||
.returning();
|
||||
const item = result[0];
|
||||
|
||||
expect(item).toBeDefined();
|
||||
expect(item.status).toBe('published');
|
||||
expect(item.publishedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('site config mutations', () => {
|
||||
it('should insert new config', async () => {
|
||||
const newConfig = {
|
||||
id: 'config-123',
|
||||
key: 'new.config',
|
||||
value: { setting: 'value' },
|
||||
category: 'general',
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([newConfig]);
|
||||
|
||||
const result = await db.insert(siteConfig).values(newConfig).returning();
|
||||
const config = result[0];
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.key).toBe('new.config');
|
||||
expect(config.value).toEqual({ setting: 'value' });
|
||||
expect(mockDb.insert).toHaveBeenCalledWith(siteConfig);
|
||||
});
|
||||
|
||||
it('should update config', async () => {
|
||||
const updatedConfig = {
|
||||
id: 'config-123',
|
||||
key: 'updated.config',
|
||||
value: { setting: 'updated value' },
|
||||
category: 'general',
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([updatedConfig]);
|
||||
|
||||
const result = await db
|
||||
.update(siteConfig)
|
||||
.set({ value: { setting: 'updated value' } })
|
||||
.where(eq(siteConfig.key, 'updated.config'))
|
||||
.returning();
|
||||
const config = result[0];
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.value).toEqual({ setting: 'updated value' });
|
||||
expect(mockDb.update).toHaveBeenCalledWith(siteConfig);
|
||||
});
|
||||
|
||||
it('should upsert config', async () => {
|
||||
const upsertedConfig = {
|
||||
id: 'config-123',
|
||||
key: 'upsert.config',
|
||||
value: { setting: 'upserted value' },
|
||||
category: 'general',
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.returning.mockResolvedValue([upsertedConfig]);
|
||||
|
||||
const result = await db
|
||||
.insert(siteConfig)
|
||||
.values({
|
||||
key: 'upsert.config',
|
||||
value: { setting: 'upserted value' },
|
||||
category: 'general',
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.returning();
|
||||
const config = result[0];
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.key).toBe('upsert.config');
|
||||
});
|
||||
});
|
||||
|
||||
describe('batch operations', () => {
|
||||
it('should insert multiple users', async () => {
|
||||
const newUsers = [
|
||||
{ id: 'user-1', email: 'user1@example.com', name: 'User 1', role: 'editor', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'user-2', email: 'user2@example.com', name: 'User 2', role: 'viewer', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'user-3', email: 'user3@example.com', name: 'User 3', role: 'editor', createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockDb.returning.mockResolvedValue(newUsers);
|
||||
|
||||
const result = await db.insert(users).values(newUsers).returning();
|
||||
|
||||
expect(result.length).toBe(3);
|
||||
expect(mockDb.values).toHaveBeenCalledWith(newUsers);
|
||||
});
|
||||
|
||||
it('should insert multiple content items', async () => {
|
||||
const newContent = [
|
||||
{ id: 'content-1', type: 'news', title: 'News 1', slug: 'news-1', content: 'Content 1', status: 'published', authorId: 'user-1', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 'content-2', type: 'news', title: 'News 2', slug: 'news-2', content: 'Content 2', status: 'published', authorId: 'user-1', createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockDb.returning.mockResolvedValue(newContent);
|
||||
|
||||
const result = await db.insert(content).values(newContent).returning();
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(mockDb.values).toHaveBeenCalledWith(newContent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should handle duplicate key error', async () => {
|
||||
mockDb.returning.mockRejectedValue(new Error('UNIQUE constraint failed: users.email'));
|
||||
|
||||
await expect(
|
||||
db.insert(users).values({
|
||||
id: 'user-123',
|
||||
email: 'existing@example.com',
|
||||
name: 'Test User',
|
||||
role: 'editor',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).returning()
|
||||
).rejects.toThrow('UNIQUE constraint failed');
|
||||
});
|
||||
|
||||
it('should handle foreign key constraint error', async () => {
|
||||
mockDb.returning.mockRejectedValue(new Error('FOREIGN KEY constraint failed'));
|
||||
|
||||
await expect(
|
||||
db.insert(content).values({
|
||||
id: 'content-123',
|
||||
type: 'news',
|
||||
title: 'Test Content',
|
||||
slug: 'test-content',
|
||||
content: 'Test content body',
|
||||
status: 'draft',
|
||||
authorId: 'non-existent-user',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
}).returning()
|
||||
).rejects.toThrow('FOREIGN KEY constraint failed');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,255 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
|
||||
import { db } from '@/db';
|
||||
import { users, content, siteConfig } from '@/db/schema';
|
||||
import { eq, and, desc, like } from 'drizzle-orm';
|
||||
|
||||
jest.mock('@/db', () => {
|
||||
const mockDb = {
|
||||
select: jest.fn().mockReturnThis(),
|
||||
from: jest.fn().mockReturnThis(),
|
||||
where: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
limit: jest.fn().mockReturnThis(),
|
||||
insert: jest.fn().mockReturnThis(),
|
||||
values: jest.fn().mockReturnThis(),
|
||||
returning: jest.fn().mockReturnThis(),
|
||||
update: jest.fn().mockReturnThis(),
|
||||
set: jest.fn().mockReturnThis(),
|
||||
delete: jest.fn().mockReturnThis(),
|
||||
};
|
||||
return {
|
||||
db: mockDb,
|
||||
};
|
||||
});
|
||||
|
||||
describe('database queries', () => {
|
||||
let mockDb: any;
|
||||
|
||||
beforeEach(() => {
|
||||
const { db: dbInstance } = require('@/db');
|
||||
mockDb = dbInstance;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('user queries', () => {
|
||||
it('should query user by id', async () => {
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'editor',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.limit.mockResolvedValue([mockUser]);
|
||||
|
||||
const result = await db.select().from(users).where(eq(users.id, '123')).limit(1);
|
||||
const user = result[0];
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.id).toBe('123');
|
||||
expect(user.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should query user by email', async () => {
|
||||
const mockUser = {
|
||||
id: '123',
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
role: 'editor',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.limit.mockResolvedValue([mockUser]);
|
||||
|
||||
const result = await db.select().from(users).where(eq(users.email, 'test@example.com')).limit(1);
|
||||
const user = result[0];
|
||||
|
||||
expect(user).toBeDefined();
|
||||
expect(user.email).toBe('test@example.com');
|
||||
});
|
||||
|
||||
it('should return null for non-existent user', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await db.select().from(users).where(eq(users.id, 'non-existent')).limit(1);
|
||||
const user = result[0];
|
||||
|
||||
expect(user).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should query users by role', async () => {
|
||||
const mockUsers = [
|
||||
{ id: '1', email: 'admin@example.com', name: 'Admin', role: 'admin', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: '2', email: 'admin2@example.com', name: 'Admin2', role: 'admin', createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockDb.limit.mockResolvedValue(mockUsers);
|
||||
|
||||
const result = await db.select().from(users).where(eq(users.role, 'admin')).limit(10);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.every(u => u.role === 'admin')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('content queries', () => {
|
||||
it('should query content by id', async () => {
|
||||
const mockContent = {
|
||||
id: 'content-1',
|
||||
type: 'news',
|
||||
title: 'Test News',
|
||||
slug: 'test-news',
|
||||
content: 'Test content',
|
||||
status: 'published',
|
||||
authorId: '123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.limit.mockResolvedValue([mockContent]);
|
||||
|
||||
const result = await db.select().from(content).where(eq(content.id, 'content-1')).limit(1);
|
||||
const item = result[0];
|
||||
|
||||
expect(item).toBeDefined();
|
||||
expect(item.id).toBe('content-1');
|
||||
expect(item.title).toBe('Test News');
|
||||
});
|
||||
|
||||
it('should query content by slug', async () => {
|
||||
const mockContent = {
|
||||
id: 'content-1',
|
||||
type: 'news',
|
||||
title: 'Test News',
|
||||
slug: 'test-news',
|
||||
content: 'Test content',
|
||||
status: 'published',
|
||||
authorId: '123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.limit.mockResolvedValue([mockContent]);
|
||||
|
||||
const result = await db.select().from(content).where(eq(content.slug, 'test-news')).limit(1);
|
||||
const item = result[0];
|
||||
|
||||
expect(item).toBeDefined();
|
||||
expect(item.slug).toBe('test-news');
|
||||
});
|
||||
|
||||
it('should query published content', async () => {
|
||||
const mockContent = [
|
||||
{ id: '1', type: 'news', title: 'News 1', slug: 'news-1', content: 'Content 1', status: 'published', authorId: '123', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: '2', type: 'news', title: 'News 2', slug: 'news-2', content: 'Content 2', status: 'published', authorId: '123', createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockDb.limit.mockResolvedValue(mockContent);
|
||||
|
||||
const result = await db.select().from(content).where(eq(content.status, 'published')).limit(10);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.every(c => c.status === 'published')).toBe(true);
|
||||
});
|
||||
|
||||
it('should query content by type', async () => {
|
||||
const mockContent = [
|
||||
{ id: '1', type: 'news', title: 'News 1', slug: 'news-1', content: 'Content 1', status: 'published', authorId: '123', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: '2', type: 'news', title: 'News 2', slug: 'news-2', content: 'Content 2', status: 'published', authorId: '123', createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockDb.limit.mockResolvedValue(mockContent);
|
||||
|
||||
const result = await db.select().from(content).where(eq(content.type, 'news')).limit(10);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.every(c => c.type === 'news')).toBe(true);
|
||||
});
|
||||
|
||||
it('should query content with multiple conditions', async () => {
|
||||
const mockContent = {
|
||||
id: 'content-1',
|
||||
type: 'news',
|
||||
title: 'Test News',
|
||||
slug: 'test-news',
|
||||
content: 'Test content',
|
||||
status: 'published',
|
||||
authorId: '123',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.limit.mockResolvedValue([mockContent]);
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(and(eq(content.status, 'published'), eq(content.type, 'news')))
|
||||
.limit(1);
|
||||
const item = result[0];
|
||||
|
||||
expect(item).toBeDefined();
|
||||
expect(item.status).toBe('published');
|
||||
expect(item.type).toBe('news');
|
||||
});
|
||||
});
|
||||
|
||||
describe('site config queries', () => {
|
||||
it('should query config by key', async () => {
|
||||
const mockConfig = {
|
||||
id: 'config-1',
|
||||
key: 'site.title',
|
||||
value: { title: 'My Site' },
|
||||
category: 'general',
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
mockDb.limit.mockResolvedValue([mockConfig]);
|
||||
|
||||
const result = await db.select().from(siteConfig).where(eq(siteConfig.key, 'site.title')).limit(1);
|
||||
const config = result[0];
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(config.key).toBe('site.title');
|
||||
expect(config.value).toEqual({ title: 'My Site' });
|
||||
});
|
||||
|
||||
it('should query config by category', async () => {
|
||||
const mockConfigs = [
|
||||
{ id: '1', key: 'site.title', value: { title: 'My Site' }, category: 'general', updatedAt: new Date() },
|
||||
{ id: '2', key: 'site.description', value: { description: 'My Description' }, category: 'general', updatedAt: new Date() },
|
||||
];
|
||||
mockDb.limit.mockResolvedValue(mockConfigs);
|
||||
|
||||
const result = await db.select().from(siteConfig).where(eq(siteConfig.category, 'general')).limit(10);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(result.every(c => c.category === 'general')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return null for non-existent config', async () => {
|
||||
mockDb.limit.mockResolvedValue([]);
|
||||
|
||||
const result = await db.select().from(siteConfig).where(eq(siteConfig.key, 'non.existent')).limit(1);
|
||||
const config = result[0];
|
||||
|
||||
expect(config).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('query ordering', () => {
|
||||
it('should order content by created date', async () => {
|
||||
const mockContent = [
|
||||
{ id: '1', type: 'news', title: 'News 1', slug: 'news-1', content: 'Content 1', status: 'published', authorId: '123', createdAt: new Date('2024-01-01'), updatedAt: new Date() },
|
||||
{ id: '2', type: 'news', title: 'News 2', slug: 'news-2', content: 'Content 2', status: 'published', authorId: '123', createdAt: new Date('2024-01-02'), updatedAt: new Date() },
|
||||
];
|
||||
mockDb.limit.mockResolvedValue(mockContent);
|
||||
|
||||
const result = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.orderBy(desc(content.createdAt))
|
||||
.limit(10);
|
||||
|
||||
expect(result.length).toBe(2);
|
||||
expect(mockDb.orderBy).toHaveBeenCalledWith(desc(content.createdAt));
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { describe, it, expect } from '@jest/globals';
|
||||
import { users, content, siteConfig, auditLogs, contentVersions } from './schema';
|
||||
|
||||
describe('Database Schema', () => {
|
||||
describe('users table', () => {
|
||||
it('should have correct columns defined', () => {
|
||||
expect(users.id).toBeDefined();
|
||||
expect(users.email).toBeDefined();
|
||||
expect(users.passwordHash).toBeDefined();
|
||||
expect(users.name).toBeDefined();
|
||||
expect(users.isAdmin).toBeDefined();
|
||||
expect(users.avatar).toBeDefined();
|
||||
expect(users.createdAt).toBeDefined();
|
||||
expect(users.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have default isAdmin as false', () => {
|
||||
const isAdmin = users.isAdmin;
|
||||
expect(isAdmin).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('content table', () => {
|
||||
it('should have correct columns defined', () => {
|
||||
expect(content.id).toBeDefined();
|
||||
expect(content.type).toBeDefined();
|
||||
expect(content.title).toBeDefined();
|
||||
expect(content.slug).toBeDefined();
|
||||
expect(content.excerpt).toBeDefined();
|
||||
expect(content.content).toBeDefined();
|
||||
expect(content.coverImage).toBeDefined();
|
||||
expect(content.category).toBeDefined();
|
||||
expect(content.tags).toBeDefined();
|
||||
expect(content.status).toBeDefined();
|
||||
expect(content.publishedAt).toBeDefined();
|
||||
expect(content.authorId).toBeDefined();
|
||||
expect(content.sortOrder).toBeDefined();
|
||||
expect(content.metadata).toBeDefined();
|
||||
expect(content.createdAt).toBeDefined();
|
||||
expect(content.updatedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have default status as draft', () => {
|
||||
expect(content.status).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('contentVersions table', () => {
|
||||
it('should have correct columns defined', () => {
|
||||
expect(contentVersions.id).toBeDefined();
|
||||
expect(contentVersions.contentId).toBeDefined();
|
||||
expect(contentVersions.version).toBeDefined();
|
||||
expect(contentVersions.title).toBeDefined();
|
||||
expect(contentVersions.content).toBeDefined();
|
||||
expect(contentVersions.changes).toBeDefined();
|
||||
expect(contentVersions.changedBy).toBeDefined();
|
||||
expect(contentVersions.changedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('siteConfig table', () => {
|
||||
it('should have correct columns defined', () => {
|
||||
expect(siteConfig.id).toBeDefined();
|
||||
expect(siteConfig.key).toBeDefined();
|
||||
expect(siteConfig.value).toBeDefined();
|
||||
expect(siteConfig.category).toBeDefined();
|
||||
expect(siteConfig.description).toBeDefined();
|
||||
expect(siteConfig.updatedAt).toBeDefined();
|
||||
expect(siteConfig.updatedBy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('auditLogs table', () => {
|
||||
it('should have correct columns defined', () => {
|
||||
expect(auditLogs.id).toBeDefined();
|
||||
expect(auditLogs.userId).toBeDefined();
|
||||
expect(auditLogs.action).toBeDefined();
|
||||
expect(auditLogs.resourceType).toBeDefined();
|
||||
expect(auditLogs.resourceId).toBeDefined();
|
||||
expect(auditLogs.details).toBeDefined();
|
||||
expect(auditLogs.ipAddress).toBeDefined();
|
||||
expect(auditLogs.userAgent).toBeDefined();
|
||||
expect(auditLogs.timestamp).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
|
||||
import { relations } from 'drizzle-orm';
|
||||
|
||||
export const users = sqliteTable('users', {
|
||||
id: text('id').primaryKey(),
|
||||
email: text('email').notNull().unique(),
|
||||
passwordHash: text('password_hash'),
|
||||
name: text('name').notNull(),
|
||||
isAdmin: integer('is_admin', { mode: 'boolean' }).notNull().default(false),
|
||||
avatar: text('avatar'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const content = sqliteTable('content', {
|
||||
id: text('id').primaryKey(),
|
||||
type: text('type', { enum: ['news', 'product', 'service', 'case'] }).notNull(),
|
||||
title: text('title').notNull(),
|
||||
slug: text('slug').notNull().unique(),
|
||||
excerpt: text('excerpt'),
|
||||
content: text('content').notNull(),
|
||||
coverImage: text('cover_image'),
|
||||
category: text('category'),
|
||||
tags: text('tags', { mode: 'json' }).$type<string[]>(),
|
||||
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
|
||||
publishedAt: integer('published_at', { mode: 'timestamp' }),
|
||||
authorId: text('author_id').notNull().references(() => users.id),
|
||||
sortOrder: integer('sort_order').default(0),
|
||||
metadata: text('metadata', { mode: 'json' }).$type<Record<string, any>>(),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const contentVersions = sqliteTable('content_versions', {
|
||||
id: text('id').primaryKey(),
|
||||
contentId: text('content_id').notNull().references(() => content.id),
|
||||
version: integer('version').notNull(),
|
||||
title: text('title').notNull(),
|
||||
content: text('content').notNull(),
|
||||
changes: text('changes', { mode: 'json' }).$type<Record<string, any>>(),
|
||||
changedBy: text('changed_by').notNull().references(() => users.id),
|
||||
changedAt: integer('changed_at', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const siteConfig = sqliteTable('site_config', {
|
||||
id: text('id').primaryKey(),
|
||||
key: text('key').notNull().unique(),
|
||||
value: text('value', { mode: 'json' }).notNull(),
|
||||
category: text('category', { enum: ['feature', 'style', 'seo', 'general'] }).notNull(),
|
||||
description: text('description'),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedBy: text('updated_by').references(() => users.id),
|
||||
});
|
||||
|
||||
export const auditLogs = sqliteTable('audit_logs', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: text('user_id').references(() => users.id),
|
||||
action: text('action', { enum: ['create', 'update', 'delete', 'publish', 'login', 'logout', 'upload'] }).notNull(),
|
||||
resourceType: text('resource_type').notNull(),
|
||||
resourceId: text('resource_id'),
|
||||
details: text('details', { mode: 'json' }).$type<Record<string, any>>(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
timestamp: integer('timestamp', { mode: 'timestamp' }).notNull(),
|
||||
});
|
||||
|
||||
export const usersRelations = relations(users, ({ many }) => ({
|
||||
content: many(content),
|
||||
versions: many(contentVersions),
|
||||
logs: many(auditLogs),
|
||||
}));
|
||||
|
||||
export const contentRelations = relations(content, ({ one, many }) => ({
|
||||
author: one(users, {
|
||||
fields: [content.authorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
versions: many(contentVersions),
|
||||
}));
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
export type Content = typeof content.$inferSelect;
|
||||
export type NewContent = typeof content.$inferInsert;
|
||||
export type SiteConfig = typeof siteConfig.$inferSelect;
|
||||
export type NewSiteConfig = typeof siteConfig.$inferInsert;
|
||||
@@ -1,307 +0,0 @@
|
||||
import { db } from './index';
|
||||
import { content, users } from './schema';
|
||||
import { nanoid } from 'nanoid';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
async function seedTestData() {
|
||||
/* eslint-disable no-console */
|
||||
console.log('🌱 开始创建测试数据...');
|
||||
|
||||
try {
|
||||
const existingAdmin = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, 'admin@novalon.cn'))
|
||||
.limit(1);
|
||||
|
||||
if (existingAdmin.length === 0) {
|
||||
console.log('❌ 管理员用户不存在,请先运行主种子数据脚本');
|
||||
return;
|
||||
}
|
||||
|
||||
const adminId = existingAdmin[0]!.id;
|
||||
|
||||
const editorEmail = 'editor@novalon.cn';
|
||||
const existingEditor = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, editorEmail))
|
||||
.limit(1);
|
||||
|
||||
if (existingEditor.length === 0) {
|
||||
const hashedPassword = await bcrypt.hash('editor123456', 10);
|
||||
await db.insert(users).values({
|
||||
id: nanoid(),
|
||||
email: editorEmail,
|
||||
passwordHash: hashedPassword,
|
||||
name: '内容编辑者',
|
||||
isAdmin: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
console.log('✅ 创建编辑者用户: editor@novalon.cn');
|
||||
} else {
|
||||
console.log('ℹ️ 编辑者用户已存在,跳过创建');
|
||||
}
|
||||
|
||||
const viewerEmail = 'viewer@novalon.cn';
|
||||
const existingViewer = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, viewerEmail))
|
||||
.limit(1);
|
||||
|
||||
if (existingViewer.length === 0) {
|
||||
const hashedPassword = await bcrypt.hash('viewer123456', 10);
|
||||
await db.insert(users).values({
|
||||
id: nanoid(),
|
||||
email: viewerEmail,
|
||||
passwordHash: hashedPassword,
|
||||
name: '内容查看者',
|
||||
isAdmin: false,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
console.log('✅ 创建查看者用户: viewer@novalon.cn');
|
||||
} else {
|
||||
console.log('ℹ️ 查看者用户已存在,跳过创建');
|
||||
}
|
||||
|
||||
const testProducts = [
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'product' as const,
|
||||
title: '测试产品 - ERP系统',
|
||||
slug: 'test-product-erp',
|
||||
excerpt: '这是一个测试产品',
|
||||
content: '<p>ERP系统测试内容</p>',
|
||||
coverImage: '/images/test-product-1.jpg',
|
||||
category: '软件产品',
|
||||
tags: ['ERP', '企业管理'],
|
||||
status: 'published' as const,
|
||||
publishedAt: new Date(),
|
||||
authorId: adminId,
|
||||
metadata: {
|
||||
features: ['财务管理', '供应链管理', '生产管理'],
|
||||
benefits: ['提高效率', '降低成本', '优化流程'],
|
||||
pricing: {
|
||||
basic: { price: '9999', period: '年' },
|
||||
standard: { price: '19999', period: '年' },
|
||||
enterprise: { price: '39999', period: '年' },
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'product' as const,
|
||||
title: '测试产品 - CRM系统',
|
||||
slug: 'test-product-crm',
|
||||
excerpt: '这是一个测试产品',
|
||||
content: '<p>CRM系统测试内容</p>',
|
||||
coverImage: '/images/test-product-2.jpg',
|
||||
category: '软件产品',
|
||||
tags: ['CRM', '客户管理'],
|
||||
status: 'published' as const,
|
||||
publishedAt: new Date(),
|
||||
authorId: adminId,
|
||||
metadata: {
|
||||
features: ['客户管理', '销售管理', '营销管理'],
|
||||
benefits: ['提升客户满意度', '增加销售业绩', '优化营销策略'],
|
||||
pricing: {
|
||||
basic: { price: '5999', period: '年' },
|
||||
standard: { price: '12999', period: '年' },
|
||||
enterprise: { price: '25999', period: '年' },
|
||||
},
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const testNews = [
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'news' as const,
|
||||
title: '测试新闻 - 公司获得行业大奖',
|
||||
slug: 'test-news-award',
|
||||
excerpt: '这是一个测试新闻',
|
||||
content: '<p>测试新闻内容</p>',
|
||||
coverImage: '/images/test-news-1.jpg',
|
||||
category: '公司新闻',
|
||||
tags: ['获奖', '荣誉'],
|
||||
status: 'published' as const,
|
||||
publishedAt: new Date(),
|
||||
authorId: adminId,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'news' as const,
|
||||
title: '测试新闻 - 产品发布会',
|
||||
slug: 'test-news-launch',
|
||||
excerpt: '这是一个测试新闻',
|
||||
content: '<p>测试新闻内容</p>',
|
||||
coverImage: '/images/test-news-2.jpg',
|
||||
category: '产品发布',
|
||||
tags: ['发布', '新产品'],
|
||||
status: 'published' as const,
|
||||
publishedAt: new Date(),
|
||||
authorId: adminId,
|
||||
metadata: {},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const testCases = [
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'case' as const,
|
||||
title: '测试案例 - 某大型企业数字化转型',
|
||||
slug: 'test-case-enterprise',
|
||||
excerpt: '这是一个测试案例',
|
||||
content: '<p>测试案例内容</p>',
|
||||
coverImage: '/images/test-case-1.jpg',
|
||||
category: '制造业',
|
||||
tags: ['数字化转型', '制造业'],
|
||||
status: 'published' as const,
|
||||
publishedAt: new Date(),
|
||||
authorId: adminId,
|
||||
metadata: {
|
||||
client: '某大型制造企业',
|
||||
industry: '制造业',
|
||||
duration: '6个月',
|
||||
results: ['效率提升30%', '成本降低20%'],
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
const testServices = [
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'service' as const,
|
||||
title: '测试服务 - 软件开发',
|
||||
slug: 'test-service-software',
|
||||
excerpt: '这是一个测试服务',
|
||||
content: '<p>测试服务内容</p>',
|
||||
coverImage: '/images/test-service-1.jpg',
|
||||
category: '软件开发',
|
||||
tags: ['开发', '定制'],
|
||||
status: 'published' as const,
|
||||
publishedAt: new Date(),
|
||||
authorId: adminId,
|
||||
metadata: {
|
||||
icon: 'Code',
|
||||
features: ['需求分析', '系统设计', '开发实施', '测试部署'],
|
||||
benefits: ['专业团队', '质量保证', '按时交付'],
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
type: 'service' as const,
|
||||
title: '测试服务 - 云服务',
|
||||
slug: 'test-service-cloud',
|
||||
excerpt: '这是一个测试服务',
|
||||
content: '<p>测试服务内容</p>',
|
||||
coverImage: '/images/test-service-2.jpg',
|
||||
category: '云服务',
|
||||
tags: ['云', '部署'],
|
||||
status: 'published' as const,
|
||||
publishedAt: new Date(),
|
||||
authorId: adminId,
|
||||
metadata: {
|
||||
icon: 'Cloud',
|
||||
features: ['云迁移', '云部署', '云运维', '云安全'],
|
||||
benefits: ['高可用性', '弹性扩展', '成本优化'],
|
||||
},
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const product of testProducts) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.slug, product.slug))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(content).values(product);
|
||||
console.log(`✅ 创建测试产品: ${product.title}`);
|
||||
} else {
|
||||
console.log(`ℹ️ 测试产品已存在,跳过: ${product.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const news of testNews) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.slug, news.slug))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(content).values(news);
|
||||
console.log(`✅ 创建测试新闻: ${news.title}`);
|
||||
} else {
|
||||
console.log(`ℹ️ 测试新闻已存在,跳过: ${news.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const caseItem of testCases) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.slug, caseItem.slug))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(content).values(caseItem);
|
||||
console.log(`✅ 创建测试案例: ${caseItem.title}`);
|
||||
} else {
|
||||
console.log(`ℹ️ 测试案例已存在,跳过: ${caseItem.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const service of testServices) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(content)
|
||||
.where(eq(content.slug, service.slug))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(content).values(service);
|
||||
console.log(`✅ 创建测试服务: ${service.title}`);
|
||||
} else {
|
||||
console.log(`ℹ️ 测试服务已存在,跳过: ${service.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 测试数据创建完成!');
|
||||
} catch (error) {
|
||||
console.error('❌ 测试数据创建失败:', error);
|
||||
throw error;
|
||||
}
|
||||
/* eslint-enable no-console */
|
||||
}
|
||||
|
||||
seedTestData()
|
||||
.then(() => {
|
||||
console.log('✅ 测试数据脚本执行完成');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ 测试数据脚本执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
-116
@@ -1,116 +0,0 @@
|
||||
import { db } from './index';
|
||||
import { users, siteConfig } from './schema';
|
||||
import { nanoid } from 'nanoid';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { eq } from 'drizzle-orm';
|
||||
|
||||
async function seed() {
|
||||
console.log('🌱 开始种子数据...');
|
||||
|
||||
try {
|
||||
const existingAdmin = await db
|
||||
.select()
|
||||
.from(users)
|
||||
.where(eq(users.email, 'admin@novalon.cn'))
|
||||
.limit(1);
|
||||
|
||||
if (existingAdmin.length === 0) {
|
||||
const hashedPassword = await bcrypt.hash('admin123456', 10);
|
||||
await db.insert(users).values({
|
||||
id: nanoid(),
|
||||
email: 'admin@novalon.cn',
|
||||
passwordHash: hashedPassword,
|
||||
name: '系统管理员',
|
||||
isAdmin: true,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
console.log('✅ 创建管理员用户: admin@novalon.cn');
|
||||
} else {
|
||||
console.log('ℹ️ 管理员用户已存在,跳过创建');
|
||||
}
|
||||
|
||||
const defaultConfigs = [
|
||||
{
|
||||
id: nanoid(),
|
||||
key: 'feature_news',
|
||||
value: {
|
||||
enabled: true,
|
||||
displayCount: 6,
|
||||
categories: ['公司新闻', '产品发布', '合作动态', '行业资讯'],
|
||||
sortOrder: 'desc',
|
||||
},
|
||||
category: 'feature' as const,
|
||||
description: '新闻模块配置',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
key: 'feature_products',
|
||||
value: {
|
||||
enabled: true,
|
||||
showPricing: true,
|
||||
featuredProducts: ['erp', 'crm'],
|
||||
},
|
||||
category: 'feature' as const,
|
||||
description: '产品模块配置',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
key: 'feature_services',
|
||||
value: {
|
||||
enabled: true,
|
||||
items: ['software', 'cloud', 'data', 'security'],
|
||||
},
|
||||
category: 'feature' as const,
|
||||
description: '服务模块配置',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
{
|
||||
id: nanoid(),
|
||||
key: 'seo_default',
|
||||
value: {
|
||||
title: '四川睿新致远科技有限公司 - 企业数字化转型服务商',
|
||||
description: '以智慧连接数字趋势,以伙伴身份陪您成长——您的数字化转型同行者',
|
||||
keywords: ['数字化转型', '软件开发', '云服务', '数据分析', '信息安全'],
|
||||
},
|
||||
category: 'seo' as const,
|
||||
description: '默认 SEO 配置',
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
];
|
||||
|
||||
for (const config of defaultConfigs) {
|
||||
const existing = await db
|
||||
.select()
|
||||
.from(siteConfig)
|
||||
.where(eq(siteConfig.key, config.key))
|
||||
.limit(1);
|
||||
|
||||
if (existing.length === 0) {
|
||||
await db.insert(siteConfig).values(config);
|
||||
console.log(`✅ 创建配置: ${config.key}`);
|
||||
} else {
|
||||
console.log(`ℹ️ 配置已存在,跳过: ${config.key}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('🎉 种子数据完成!');
|
||||
console.log('📝 管理员账号: admin@novalon.cn');
|
||||
console.log('🔑 默认密码: admin123456');
|
||||
} catch (error) {
|
||||
console.error('❌ 种子数据失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
seed()
|
||||
.then(() => {
|
||||
console.log('✅ 种子数据脚本执行完成');
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('❌ 种子数据脚本执行失败:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,134 +0,0 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useNews } from './use-news';
|
||||
import { contentService } from '@/lib/api/services';
|
||||
import { NewsItem } from '@/lib/api/types';
|
||||
|
||||
jest.mock('@/lib/api/services');
|
||||
|
||||
describe('useNews', () => {
|
||||
const mockNewsData: NewsItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '测试新闻1',
|
||||
excerpt: '测试摘要1',
|
||||
content: '测试内容1',
|
||||
date: '2024-01-01',
|
||||
category: '公司新闻',
|
||||
slug: 'test-news-1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '测试新闻2',
|
||||
excerpt: '测试摘要2',
|
||||
content: '测试内容2',
|
||||
date: '2024-01-02',
|
||||
category: '产品发布',
|
||||
slug: 'test-news-2',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('应该成功获取新闻数据', async () => {
|
||||
(contentService.getNews as jest.Mock).mockResolvedValue(mockNewsData);
|
||||
|
||||
const { result } = renderHook(() => useNews());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.news).toEqual(mockNewsData);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(contentService.getNews).toHaveBeenCalledWith(undefined, undefined, 'desc');
|
||||
});
|
||||
|
||||
it('应该支持分类筛选', async () => {
|
||||
const categories = ['公司新闻'];
|
||||
(contentService.getNews as jest.Mock).mockResolvedValue([mockNewsData[0]]);
|
||||
|
||||
const { result } = renderHook(() => useNews(categories));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.news).toEqual([mockNewsData[0]]);
|
||||
expect(contentService.getNews).toHaveBeenCalledWith(categories, undefined, 'desc');
|
||||
});
|
||||
|
||||
it('应该支持数量限制', async () => {
|
||||
const limit = 1;
|
||||
(contentService.getNews as jest.Mock).mockResolvedValue([mockNewsData[0]]);
|
||||
|
||||
const { result } = renderHook(() => useNews(undefined, limit));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.news).toEqual([mockNewsData[0]]);
|
||||
expect(contentService.getNews).toHaveBeenCalledWith(undefined, limit, 'desc');
|
||||
});
|
||||
|
||||
it('应该支持排序', async () => {
|
||||
(contentService.getNews as jest.Mock).mockResolvedValue(mockNewsData);
|
||||
|
||||
const { result } = renderHook(() => useNews(undefined, undefined, 'asc'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(contentService.getNews).toHaveBeenCalledWith(undefined, undefined, 'asc');
|
||||
});
|
||||
|
||||
it('应该处理获取失败的情况', async () => {
|
||||
const error = new Error('获取新闻失败');
|
||||
(contentService.getNews as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useNews());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.news).toEqual([]);
|
||||
expect(result.current.error).toEqual(error);
|
||||
});
|
||||
|
||||
it('应该在加载时设置loading状态', () => {
|
||||
(contentService.getNews as jest.Mock).mockImplementation(
|
||||
() => new Promise(() => {})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useNews());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.news).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('应该在参数变化时重新获取数据', async () => {
|
||||
(contentService.getNews as jest.Mock).mockResolvedValue(mockNewsData);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ categories }) => useNews(categories),
|
||||
{ initialProps: { categories: ['公司新闻'] } }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(contentService.getNews).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ categories: ['产品发布'] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(contentService.getNews).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { contentService } from '@/lib/api/services';
|
||||
import { NewsItem } from '@/lib/api/types';
|
||||
|
||||
export function useNews(
|
||||
categories?: string[],
|
||||
limit?: number,
|
||||
sortOrder: 'asc' | 'desc' = 'desc'
|
||||
) {
|
||||
const [news, setNews] = useState<NewsItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchNews() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await contentService.getNews(categories, limit, sortOrder);
|
||||
setNews(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch news'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchNews();
|
||||
}, [categories, limit, sortOrder]);
|
||||
|
||||
return { news, loading, error };
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useProducts } from './use-products';
|
||||
import { contentService } from '@/lib/api/services';
|
||||
import { Product } from '@/lib/api/types';
|
||||
|
||||
jest.mock('@/lib/api/services');
|
||||
|
||||
describe('useProducts', () => {
|
||||
const mockProductsData: Product[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '测试产品1',
|
||||
description: '测试描述1',
|
||||
category: '软件产品',
|
||||
features: ['功能1', '功能2'],
|
||||
benefits: ['优势1', '优势2'],
|
||||
slug: 'test-product-1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '测试产品2',
|
||||
description: '测试描述2',
|
||||
category: '云服务',
|
||||
features: ['功能3', '功能4'],
|
||||
benefits: ['优势3', '优势4'],
|
||||
slug: 'test-product-2',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('应该成功获取产品数据', async () => {
|
||||
(contentService.getProducts as jest.Mock).mockResolvedValue(mockProductsData);
|
||||
|
||||
const { result } = renderHook(() => useProducts());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.products).toEqual(mockProductsData);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(contentService.getProducts).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('应该支持精选产品筛选', async () => {
|
||||
const featuredIds = ['1'];
|
||||
(contentService.getProducts as jest.Mock).mockResolvedValue([mockProductsData[0]]);
|
||||
|
||||
const { result } = renderHook(() => useProducts(featuredIds));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.products).toEqual([mockProductsData[0]]);
|
||||
expect(contentService.getProducts).toHaveBeenCalledWith(featuredIds);
|
||||
});
|
||||
|
||||
it('应该处理获取失败的情况', async () => {
|
||||
const error = new Error('获取产品失败');
|
||||
(contentService.getProducts as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useProducts());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.products).toEqual([]);
|
||||
expect(result.current.error).toEqual(error);
|
||||
});
|
||||
|
||||
it('应该在加载时设置loading状态', () => {
|
||||
(contentService.getProducts as jest.Mock).mockImplementation(
|
||||
() => new Promise(() => {})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useProducts());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.products).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('应该在featuredIds变化时重新获取数据', async () => {
|
||||
(contentService.getProducts as jest.Mock).mockResolvedValue(mockProductsData);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ featuredIds }) => useProducts(featuredIds),
|
||||
{ initialProps: { featuredIds: ['1'] } }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(contentService.getProducts).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ featuredIds: ['2'] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(contentService.getProducts).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理空数组featuredIds', async () => {
|
||||
(contentService.getProducts as jest.Mock).mockResolvedValue(mockProductsData);
|
||||
|
||||
const { result } = renderHook(() => useProducts([]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.products).toEqual(mockProductsData);
|
||||
expect(contentService.getProducts).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { contentService } from '@/lib/api/services';
|
||||
import { Product } from '@/lib/api/types';
|
||||
|
||||
export function useProducts(featuredIds?: string[]) {
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchProducts() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await contentService.getProducts(featuredIds);
|
||||
setProducts(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch products'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProducts();
|
||||
}, [featuredIds]);
|
||||
|
||||
return { products, loading, error };
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
import { renderHook, waitFor } from '@testing-library/react';
|
||||
import { useServices } from './use-services';
|
||||
import { contentService } from '@/lib/api/services';
|
||||
import { Service } from '@/lib/api/types';
|
||||
|
||||
jest.mock('@/lib/api/services');
|
||||
|
||||
describe('useServices', () => {
|
||||
const mockServicesData: Service[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: '测试服务1',
|
||||
description: '测试描述1',
|
||||
icon: 'Code',
|
||||
features: ['功能1', '功能2'],
|
||||
benefits: ['优势1', '优势2'],
|
||||
slug: 'test-service-1',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: '测试服务2',
|
||||
description: '测试描述2',
|
||||
icon: 'Cloud',
|
||||
features: ['功能3', '功能4'],
|
||||
benefits: ['优势3', '优势4'],
|
||||
slug: 'test-service-2',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('应该成功获取服务数据', async () => {
|
||||
(contentService.getServices as jest.Mock).mockResolvedValue(mockServicesData);
|
||||
|
||||
const { result } = renderHook(() => useServices());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.services).toEqual(mockServicesData);
|
||||
expect(result.current.error).toBeNull();
|
||||
expect(contentService.getServices).toHaveBeenCalledWith(undefined);
|
||||
});
|
||||
|
||||
it('应该支持服务ID筛选', async () => {
|
||||
const ids = ['1'];
|
||||
(contentService.getServices as jest.Mock).mockResolvedValue([mockServicesData[0]]);
|
||||
|
||||
const { result } = renderHook(() => useServices(ids));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.services).toEqual([mockServicesData[0]]);
|
||||
expect(contentService.getServices).toHaveBeenCalledWith(ids);
|
||||
});
|
||||
|
||||
it('应该处理获取失败的情况', async () => {
|
||||
const error = new Error('获取服务失败');
|
||||
(contentService.getServices as jest.Mock).mockRejectedValue(error);
|
||||
|
||||
const { result } = renderHook(() => useServices());
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.services).toEqual([]);
|
||||
expect(result.current.error).toEqual(error);
|
||||
});
|
||||
|
||||
it('应该在加载时设置loading状态', () => {
|
||||
(contentService.getServices as jest.Mock).mockImplementation(
|
||||
() => new Promise(() => {})
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useServices());
|
||||
|
||||
expect(result.current.loading).toBe(true);
|
||||
expect(result.current.services).toEqual([]);
|
||||
expect(result.current.error).toBeNull();
|
||||
});
|
||||
|
||||
it('应该在ids变化时重新获取数据', async () => {
|
||||
(contentService.getServices as jest.Mock).mockResolvedValue(mockServicesData);
|
||||
|
||||
const { result, rerender } = renderHook(
|
||||
({ ids }) => useServices(ids),
|
||||
{ initialProps: { ids: ['1'] } }
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(contentService.getServices).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ ids: ['2'] });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(contentService.getServices).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('应该处理空数组ids', async () => {
|
||||
(contentService.getServices as jest.Mock).mockResolvedValue(mockServicesData);
|
||||
|
||||
const { result } = renderHook(() => useServices([]));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(result.current.loading).toBe(false);
|
||||
});
|
||||
|
||||
expect(result.current.services).toEqual(mockServicesData);
|
||||
expect(contentService.getServices).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { contentService } from '@/lib/api/services';
|
||||
import { Service } from '@/lib/api/types';
|
||||
|
||||
export function useServices(ids?: string[]) {
|
||||
const [services, setServices] = useState<Service[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchServices() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await contentService.getServices(ids);
|
||||
setServices(data);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err : new Error('Failed to fetch services'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchServices();
|
||||
}, [ids]);
|
||||
|
||||
return { services, loading, error };
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
jest.mock('./analytics', () => {
|
||||
const actual = jest.requireActual('./analytics');
|
||||
return {
|
||||
...actual,
|
||||
pageview: jest.fn(),
|
||||
event: jest.fn(),
|
||||
trackContactForm: jest.fn(),
|
||||
trackButtonClick: jest.fn(),
|
||||
trackPageView: jest.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import {
|
||||
pageview,
|
||||
event,
|
||||
trackContactForm,
|
||||
trackButtonClick,
|
||||
trackPageView,
|
||||
} from './analytics';
|
||||
|
||||
describe('analytics', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('pageview', () => {
|
||||
it('should be defined', () => {
|
||||
expect(pageview).toBeDefined();
|
||||
expect(typeof pageview).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
pageview('/test-page');
|
||||
expect(pageview).toHaveBeenCalledWith('/test-page');
|
||||
});
|
||||
});
|
||||
|
||||
describe('event', () => {
|
||||
it('should be defined', () => {
|
||||
expect(event).toBeDefined();
|
||||
expect(typeof event).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable with all parameters', () => {
|
||||
event('click', 'button', 'submit', 1);
|
||||
expect(event).toHaveBeenCalledWith('click', 'button', 'submit', 1);
|
||||
});
|
||||
|
||||
it('should be callable with minimal parameters', () => {
|
||||
event('click', 'button');
|
||||
expect(event).toHaveBeenCalledWith('click', 'button');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackContactForm', () => {
|
||||
it('should be defined', () => {
|
||||
expect(trackContactForm).toBeDefined();
|
||||
expect(typeof trackContactForm).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
const formData = {
|
||||
name: 'John Doe',
|
||||
email: 'john@example.com',
|
||||
message: 'Test message',
|
||||
};
|
||||
trackContactForm(formData);
|
||||
expect(trackContactForm).toHaveBeenCalledWith(formData);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackButtonClick', () => {
|
||||
it('should be defined', () => {
|
||||
expect(trackButtonClick).toBeDefined();
|
||||
expect(typeof trackButtonClick).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
trackButtonClick('submit', 'header');
|
||||
expect(trackButtonClick).toHaveBeenCalledWith('submit', 'header');
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackPageView', () => {
|
||||
it('should be defined', () => {
|
||||
expect(trackPageView).toBeDefined();
|
||||
expect(typeof trackPageView).toBe('function');
|
||||
});
|
||||
|
||||
it('should be callable', () => {
|
||||
trackPageView('Home Page', '/home');
|
||||
expect(trackPageView).toHaveBeenCalledWith('Home Page', '/home');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,211 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
|
||||
describe('API Client', () => {
|
||||
let mockFetch: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockFetch = jest.fn();
|
||||
global.fetch = mockFetch;
|
||||
delete (global as any).window;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET requests', () => {
|
||||
it('should make GET request to correct endpoint', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { id: 1 } }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
const result = await apiClient.get('/api/test');
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith('http://localhost/api/test', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
signal: expect.any(AbortSignal),
|
||||
});
|
||||
expect(result).toEqual({ id: 1 });
|
||||
});
|
||||
|
||||
it('should handle successful response', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { name: 'test' } }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
const result = await apiClient.get('/api/test');
|
||||
|
||||
expect(result).toEqual({ name: 'test' });
|
||||
});
|
||||
|
||||
it('should handle 404 error', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ success: false, error: 'Not found' }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test')).rejects.toThrow('Not found');
|
||||
});
|
||||
|
||||
it.skip('should handle 500 error', async () => {
|
||||
mockFetch.mockImplementationOnce(async () => {
|
||||
return {
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: 'Internal Server Error',
|
||||
json: async () => ({ success: false, error: 'Internal server error' }),
|
||||
headers: new Headers(),
|
||||
url: '/api/test',
|
||||
redirected: false,
|
||||
type: 'basic' as ResponseType,
|
||||
clone: () => ({ ok: false, status: 500, statusText: 'Internal Server Error' } as any),
|
||||
body: null,
|
||||
bodyUsed: false,
|
||||
arrayBuffer: async () => new ArrayBuffer(0),
|
||||
blob: async () => new Blob(),
|
||||
formData: async () => new FormData(),
|
||||
text: async () => JSON.stringify({ success: false, error: 'Internal server error' }),
|
||||
useFinalURL: false,
|
||||
} as Response;
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test')).rejects.toThrow('Internal server error');
|
||||
});
|
||||
|
||||
it('should include query parameters', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: [] }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await apiClient.get('/api/test', { page: 1, limit: 10 });
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'http://localhost/api/test?page=1&limit=10',
|
||||
expect.objectContaining({
|
||||
method: 'GET',
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should throw ApiError with status code', async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
statusText: 'Forbidden',
|
||||
json: async () => ({ success: false, error: 'Forbidden' }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
|
||||
try {
|
||||
await apiClient.get('/api/test');
|
||||
fail('Should have thrown ApiError');
|
||||
} catch (error) {
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as any).status).toBe(403);
|
||||
expect((error as any).message).toBe('Forbidden');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle malformed JSON response', async () => {
|
||||
mockFetch.mockResolvedValueOnce(
|
||||
new Response('invalid json', {
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.skip('should handle timeout', async () => {
|
||||
const abortError = new Error('The operation was aborted');
|
||||
(abortError as any).name = 'AbortError';
|
||||
|
||||
mockFetch.mockImplementationOnce(() =>
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(abortError), 100)
|
||||
)
|
||||
);
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test', undefined, { timeout: 50 })).rejects.toThrow('Request timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Retry mechanism', () => {
|
||||
it('should retry failed requests', async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockRejectedValueOnce(new Error('Network error'))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { id: 1 } }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
const result = await apiClient.get('/api/test', undefined, { retries: 3 });
|
||||
|
||||
expect(result).toEqual({ id: 1 });
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should give up after max retries', async () => {
|
||||
mockFetch.mockRejectedValue(new Error('Network error'));
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test', undefined, { retries: 2 })).rejects.toThrow('Network error');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should not retry on 4xx errors', async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
statusText: 'Not Found',
|
||||
json: async () => ({ success: false, error: 'Not found' }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
await expect(apiClient.get('/api/test')).rejects.toThrow('Not found');
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Type safety', () => {
|
||||
it('should return typed data', async () => {
|
||||
interface TestData {
|
||||
id: number;
|
||||
name: string;
|
||||
}
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ success: true, data: { id: 1, name: 'test' } }),
|
||||
});
|
||||
|
||||
const { apiClient } = await import('./client');
|
||||
const result = await apiClient.get<TestData>('/api/test');
|
||||
|
||||
expect(result.id).toBe(1);
|
||||
expect(result.name).toBe('test');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,221 +0,0 @@
|
||||
import { ApiResponse, RequestConfig } from './types';
|
||||
|
||||
class ApiError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
|
||||
constructor(status: number, message: string) {
|
||||
super(message);
|
||||
this.name = 'ApiError';
|
||||
this.status = status;
|
||||
this.code = status.toString();
|
||||
}
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string = '';
|
||||
private defaultTimeout: number = 5000;
|
||||
private defaultRetries: number = 2;
|
||||
|
||||
constructor(baseUrl?: string) {
|
||||
if (typeof window !== 'undefined') {
|
||||
this.baseUrl = window.location.origin;
|
||||
}
|
||||
if (baseUrl) {
|
||||
this.baseUrl = baseUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async get<T = any>(
|
||||
endpoint: string,
|
||||
params?: Record<string, any>,
|
||||
config?: RequestConfig
|
||||
): Promise<T> {
|
||||
const url = this.buildUrl(endpoint, params);
|
||||
return this.request<T>(url, {
|
||||
method: 'GET',
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
async post<T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
config?: RequestConfig
|
||||
): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
async put<T = any>(
|
||||
endpoint: string,
|
||||
data?: any,
|
||||
config?: RequestConfig
|
||||
): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
async delete<T = any>(
|
||||
endpoint: string,
|
||||
config?: RequestConfig
|
||||
): Promise<T> {
|
||||
return this.request<T>(endpoint, {
|
||||
method: 'DELETE',
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
url: string,
|
||||
options: RequestInit & RequestConfig = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
retries = this.defaultRetries,
|
||||
timeout = this.defaultTimeout,
|
||||
headers = {},
|
||||
...fetchOptions
|
||||
} = options;
|
||||
|
||||
return this.executeWithRetry(
|
||||
async () => {
|
||||
const response = await this.fetchWithTimeout(url, {
|
||||
...fetchOptions,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
}, timeout);
|
||||
|
||||
if (!response) {
|
||||
throw new Error('No response received');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await this.parseError(response);
|
||||
const error = this.createError(response.status, errorData.error || response.statusText);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const data: ApiResponse<T> = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
const error = this.createError(response.status, data.error || 'Request failed');
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.data;
|
||||
},
|
||||
retries,
|
||||
(error) => {
|
||||
const status = error?.status;
|
||||
if (typeof status === 'number') {
|
||||
return status >= 500 || status === 0;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
private async executeWithRetry<T>(
|
||||
fn: () => Promise<T>,
|
||||
retries: number,
|
||||
shouldRetry: (error: any) => boolean
|
||||
): Promise<T> {
|
||||
let lastError: any;
|
||||
|
||||
for (let attempt = 0; attempt <= retries; attempt++) {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
if (attempt === retries || !shouldRetry(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await this.delay(Math.pow(2, attempt) * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
private async fetchWithTimeout(
|
||||
url: string,
|
||||
options: RequestInit,
|
||||
timeout: number
|
||||
): Promise<Response> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response) {
|
||||
throw new Error('No response received');
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
clearTimeout(timeoutId);
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
const timeoutError = new Error('Request timeout') as any;
|
||||
timeoutError.status = 0;
|
||||
throw timeoutError;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async parseError(response: Response): Promise<{ error: string }> {
|
||||
try {
|
||||
const data = await response.json();
|
||||
return data;
|
||||
} catch {
|
||||
return { error: response.statusText };
|
||||
}
|
||||
}
|
||||
|
||||
private createError(status: number, message: string): ApiError {
|
||||
return new ApiError(status, message);
|
||||
}
|
||||
|
||||
private buildUrl(endpoint: string, params?: Record<string, any>): string {
|
||||
let url = endpoint;
|
||||
|
||||
if (this.baseUrl && !url.startsWith('http')) {
|
||||
url = `${this.baseUrl}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
}
|
||||
|
||||
if (!params || Object.keys(params).length === 0) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const queryString = new URLSearchParams();
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== undefined && value !== null) {
|
||||
queryString.append(key, String(value));
|
||||
}
|
||||
});
|
||||
|
||||
return `${url}?${queryString.toString()}`;
|
||||
}
|
||||
|
||||
private delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
|
||||
export const apiClient = new ApiClient();
|
||||
export { ApiClient, ApiError };
|
||||
@@ -1,426 +0,0 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
|
||||
|
||||
describe('Content API Service', () => {
|
||||
let mockApiClientGet: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
mockApiClientGet = jest.fn();
|
||||
jest.doMock('./client', () => ({
|
||||
apiClient: {
|
||||
get: mockApiClientGet,
|
||||
},
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
jest.resetModules();
|
||||
});
|
||||
|
||||
describe('getProducts', () => {
|
||||
it('should fetch products from API', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'ERP System',
|
||||
type: 'product',
|
||||
slug: 'erp-system',
|
||||
excerpt: 'Enterprise resource planning',
|
||||
content: 'Full description',
|
||||
category: '企业软件',
|
||||
metadata: {
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
pricing: { base: '¥10,000' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockProducts);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts();
|
||||
|
||||
expect(mockApiClientGet).toHaveBeenCalledWith('/api/content', {
|
||||
type: 'product',
|
||||
status: 'published',
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: '1',
|
||||
title: 'ERP System',
|
||||
description: 'Enterprise resource planning',
|
||||
category: '企业软件',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
pricing: { base: '¥10,000' },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockApiClientGet.mockRejectedValueOnce(new Error('API Error'));
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should filter products by featured IDs', async () => {
|
||||
const mockProducts = [
|
||||
{ id: '1', title: 'Product 1', type: 'product', slug: 'p1' },
|
||||
{ id: '2', title: 'Product 2', type: 'product', slug: 'p2' },
|
||||
{ id: '3', title: 'Product 3', type: 'product', slug: 'p3' },
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockProducts);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts(['1', '3']);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('1');
|
||||
expect(result[1].id).toBe('3');
|
||||
});
|
||||
|
||||
it('should transform API data to component format', async () => {
|
||||
const mockApiData = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'ERP System',
|
||||
type: 'product',
|
||||
slug: 'erp-system',
|
||||
excerpt: 'Enterprise resource planning',
|
||||
content: 'Full description',
|
||||
category: '企业软件',
|
||||
metadata: {
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
pricing: { base: '¥10,000' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockApiData);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts();
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
id: '1',
|
||||
title: 'ERP System',
|
||||
description: 'Enterprise resource planning',
|
||||
category: '企业软件',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
pricing: { base: '¥10,000' },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNews', () => {
|
||||
it('should fetch news from API', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Company News',
|
||||
type: 'news',
|
||||
slug: 'company-news',
|
||||
excerpt: 'Latest update',
|
||||
content: 'Full content',
|
||||
category: '公司新闻',
|
||||
publishedAt: '2026-01-15T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
updatedAt: '2026-01-15T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockNews);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getNews();
|
||||
|
||||
expect(mockApiClientGet).toHaveBeenCalledWith('/api/content', {
|
||||
type: 'news',
|
||||
status: 'published',
|
||||
});
|
||||
expect(mockApiClientGet).toHaveBeenCalledTimes(1);
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: '1',
|
||||
title: 'Company News',
|
||||
excerpt: 'Latest update',
|
||||
date: '2026-01-15',
|
||||
category: '公司新闻',
|
||||
});
|
||||
});
|
||||
|
||||
it('should limit news count', async () => {
|
||||
const mockNews = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: `${i}`,
|
||||
title: `News ${i}`,
|
||||
type: 'news',
|
||||
slug: `news-${i}`,
|
||||
excerpt: `Excerpt ${i}`,
|
||||
content: `Content ${i}`,
|
||||
category: '公司新闻',
|
||||
publishedAt: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
status: 'published',
|
||||
createdAt: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
updatedAt: new Date(Date.now() - i * 86400000).toISOString(),
|
||||
}));
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockNews);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getNews(undefined, 4);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('should filter by categories', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'News 1',
|
||||
type: 'news',
|
||||
slug: 'news-1',
|
||||
excerpt: 'Excerpt 1',
|
||||
content: 'Content 1',
|
||||
category: '公司新闻',
|
||||
publishedAt: '2026-01-15T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
updatedAt: '2026-01-15T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'News 2',
|
||||
type: 'news',
|
||||
slug: 'news-2',
|
||||
excerpt: 'Excerpt 2',
|
||||
content: 'Content 2',
|
||||
category: '产品发布',
|
||||
publishedAt: '2026-01-16T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-16T00:00:00Z',
|
||||
updatedAt: '2026-01-16T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockNews);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getNews(['公司新闻']);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].category).toBe('公司新闻');
|
||||
});
|
||||
|
||||
it('should sort by date descending by default', async () => {
|
||||
const mockNews = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'News 1',
|
||||
type: 'news',
|
||||
slug: 'news-1',
|
||||
excerpt: 'Excerpt 1',
|
||||
content: 'Content 1',
|
||||
category: '公司新闻',
|
||||
publishedAt: '2026-01-15T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
updatedAt: '2026-01-15T00:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'News 2',
|
||||
type: 'news',
|
||||
slug: 'news-2',
|
||||
excerpt: 'Excerpt 2',
|
||||
content: 'Content 2',
|
||||
category: '公司新闻',
|
||||
publishedAt: '2026-01-16T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-16T00:00:00Z',
|
||||
updatedAt: '2026-01-16T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockNews);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getNews();
|
||||
|
||||
expect(result[0].id).toBe('2');
|
||||
expect(result[1].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should transform API data to component format', async () => {
|
||||
const mockApiData = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Company News',
|
||||
type: 'news',
|
||||
slug: 'company-news',
|
||||
excerpt: 'Latest update',
|
||||
content: 'Full content',
|
||||
category: '公司新闻',
|
||||
publishedAt: '2026-01-15T00:00:00Z',
|
||||
status: 'published',
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
updatedAt: '2026-01-15T00:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockApiData);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getNews();
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
id: '1',
|
||||
title: 'Company News',
|
||||
excerpt: 'Latest update',
|
||||
date: '2026-01-15',
|
||||
category: '公司新闻',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getServices', () => {
|
||||
it('should fetch services from API', async () => {
|
||||
const mockServices = [
|
||||
{
|
||||
id: 'software',
|
||||
title: 'Software Development',
|
||||
type: 'service',
|
||||
slug: 'software-development',
|
||||
excerpt: 'Custom software solutions',
|
||||
content: 'Full description',
|
||||
metadata: {
|
||||
icon: 'Code',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockServices);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getServices();
|
||||
|
||||
expect(mockApiClientGet).toHaveBeenCalledWith('/api/content', {
|
||||
type: 'service',
|
||||
status: 'published',
|
||||
});
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'software',
|
||||
title: 'Software Development',
|
||||
description: 'Custom software solutions',
|
||||
icon: 'Code',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter services by IDs', async () => {
|
||||
const mockServices = [
|
||||
{ id: 'software', title: 'Software', type: 'service', slug: 'software' },
|
||||
{ id: 'cloud', title: 'Cloud', type: 'service', slug: 'cloud' },
|
||||
{ id: 'data', title: 'Data', type: 'service', slug: 'data' },
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockServices);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getServices(['software', 'data']);
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result[0].id).toBe('software');
|
||||
expect(result[1].id).toBe('data');
|
||||
});
|
||||
|
||||
it('should transform API data to component format', async () => {
|
||||
const mockApiData = [
|
||||
{
|
||||
id: 'software',
|
||||
title: 'Software Development',
|
||||
type: 'service',
|
||||
slug: 'software-development',
|
||||
excerpt: 'Custom software solutions',
|
||||
content: 'Full description',
|
||||
metadata: {
|
||||
icon: 'Code',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockApiData);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getServices();
|
||||
|
||||
expect(result[0]).toMatchObject({
|
||||
id: 'software',
|
||||
title: 'Software Development',
|
||||
description: 'Custom software solutions',
|
||||
icon: 'Code',
|
||||
features: ['Feature 1', 'Feature 2'],
|
||||
benefits: ['Benefit 1', 'Benefit 2'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Error handling', () => {
|
||||
it('should return empty array when API fails', async () => {
|
||||
mockApiClientGet.mockRejectedValueOnce(new Error('Network error'));
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
|
||||
const products = await contentService.getProducts();
|
||||
const news = await contentService.getNews();
|
||||
const services = await contentService.getServices();
|
||||
|
||||
expect(products).toEqual([]);
|
||||
expect(news).toEqual([]);
|
||||
expect(services).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle malformed API response', async () => {
|
||||
mockApiClientGet.mockResolvedValueOnce(null);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts();
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing metadata', async () => {
|
||||
const mockProducts = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Product',
|
||||
type: 'product',
|
||||
slug: 'product',
|
||||
excerpt: 'Description',
|
||||
content: 'Content',
|
||||
category: '企业软件',
|
||||
},
|
||||
];
|
||||
|
||||
mockApiClientGet.mockResolvedValueOnce(mockProducts);
|
||||
|
||||
const { contentService } = await import('./services');
|
||||
const result = await contentService.getProducts();
|
||||
|
||||
expect(result[0].features).toEqual([]);
|
||||
expect(result[0].benefits).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,151 +0,0 @@
|
||||
import { apiClient } from './client';
|
||||
import { Product, NewsItem, Service, ContentItem } from './types';
|
||||
|
||||
class ContentService {
|
||||
async getProducts(featuredIds?: string[]): Promise<Product[]> {
|
||||
try {
|
||||
const data = await apiClient.get<ContentItem[]>('/api/content', {
|
||||
type: 'product',
|
||||
status: 'published',
|
||||
});
|
||||
|
||||
let products = data.map(item => this.transformToProduct(item));
|
||||
|
||||
if (featuredIds && featuredIds.length > 0) {
|
||||
products = products.filter(p => featuredIds.includes(p.id));
|
||||
}
|
||||
|
||||
return products;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch products:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getNews(
|
||||
categories?: string[],
|
||||
limit?: number,
|
||||
sortOrder: 'asc' | 'desc' = 'desc'
|
||||
): Promise<NewsItem[]> {
|
||||
try {
|
||||
const data = await apiClient.get<ContentItem[]>('/api/content', {
|
||||
type: 'news',
|
||||
status: 'published',
|
||||
});
|
||||
|
||||
let news = data.map(item => this.transformToNews(item));
|
||||
|
||||
if (categories && categories.length > 0) {
|
||||
news = news.filter(n => categories.includes(n.category));
|
||||
}
|
||||
|
||||
if (sortOrder === 'desc') {
|
||||
news.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
|
||||
} else {
|
||||
news.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
}
|
||||
|
||||
if (limit && limit > 0) {
|
||||
news = news.slice(0, limit);
|
||||
}
|
||||
|
||||
return news;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch news:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getServices(ids?: string[]): Promise<Service[]> {
|
||||
try {
|
||||
const data = await apiClient.get<ContentItem[]>('/api/content', {
|
||||
type: 'service',
|
||||
status: 'published',
|
||||
});
|
||||
|
||||
let services = data.map(item => this.transformToService(item));
|
||||
|
||||
if (ids && ids.length > 0) {
|
||||
services = services.filter(s => ids.includes(s.id));
|
||||
}
|
||||
|
||||
return services;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch services:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getCases(limit?: number): Promise<NewsItem[]> {
|
||||
try {
|
||||
const data = await apiClient.get<ContentItem[]>('/api/content', {
|
||||
type: 'case',
|
||||
status: 'published',
|
||||
});
|
||||
|
||||
let cases = data.map(item => this.transformToNews(item));
|
||||
|
||||
if (limit && limit > 0) {
|
||||
cases = cases.slice(0, limit);
|
||||
}
|
||||
|
||||
return cases;
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch cases:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private transformToProduct(item: ContentItem): Product {
|
||||
const metadata = item.metadata || {};
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.excerpt || '',
|
||||
category: item.category || '',
|
||||
features: metadata.features || [],
|
||||
benefits: metadata.benefits || [],
|
||||
pricing: metadata.pricing,
|
||||
image: item.coverImage,
|
||||
slug: item.slug,
|
||||
};
|
||||
}
|
||||
|
||||
private transformToNews(item: ContentItem): NewsItem {
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
excerpt: item.excerpt || '',
|
||||
content: item.content,
|
||||
date: item.publishedAt ? this.formatDate(item.publishedAt) : this.formatDate(item.createdAt),
|
||||
category: item.category || '公司新闻',
|
||||
image: item.coverImage,
|
||||
slug: item.slug,
|
||||
};
|
||||
}
|
||||
|
||||
private transformToService(item: ContentItem): Service {
|
||||
const metadata = item.metadata || {};
|
||||
return {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
description: item.excerpt || '',
|
||||
icon: metadata.icon || 'Code',
|
||||
features: metadata.features || [],
|
||||
benefits: metadata.benefits || [],
|
||||
slug: item.slug,
|
||||
};
|
||||
}
|
||||
|
||||
private formatDate(dateString: string): string {
|
||||
try {
|
||||
const date = new Date(dateString);
|
||||
const isoString = date.toISOString();
|
||||
return isoString.split('T')[0] || dateString;
|
||||
} catch {
|
||||
return dateString;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const contentService = new ContentService();
|
||||
@@ -1,66 +0,0 @@
|
||||
export interface ApiResponse<T = any> {
|
||||
success: boolean;
|
||||
data: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
category: string;
|
||||
features: string[];
|
||||
benefits: string[];
|
||||
pricing?: Record<string, string>;
|
||||
image?: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface NewsItem {
|
||||
id: string;
|
||||
title: string;
|
||||
excerpt: string;
|
||||
content: string;
|
||||
date: string;
|
||||
category: string;
|
||||
image?: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface Service {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
features: string[];
|
||||
benefits: string[];
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export interface ContentItem {
|
||||
id: string;
|
||||
type: 'news' | 'product' | 'service' | 'case';
|
||||
title: string;
|
||||
slug: string;
|
||||
excerpt?: string;
|
||||
content: string;
|
||||
coverImage?: string;
|
||||
category?: string;
|
||||
tags?: string[];
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
publishedAt?: string;
|
||||
metadata?: Record<string, any>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface ApiError extends Error {
|
||||
status: number;
|
||||
code?: string;
|
||||
}
|
||||
|
||||
export interface RequestConfig {
|
||||
retries?: number;
|
||||
timeout?: number;
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
const mockInsert = jest.fn().mockReturnValue({
|
||||
values: jest.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
|
||||
jest.mock('@/db', () => ({
|
||||
db: {
|
||||
insert: mockInsert,
|
||||
},
|
||||
}));
|
||||
|
||||
jest.mock('nanoid', () => ({
|
||||
nanoid: jest.fn(() => 'test-id'),
|
||||
}));
|
||||
|
||||
import { createAuditLog, getActionLabel, getActionColor } from './audit';
|
||||
|
||||
describe('audit', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('createAuditLog', () => {
|
||||
it('should create audit log successfully', async () => {
|
||||
const mockValues = jest.fn().mockResolvedValue(undefined);
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
|
||||
const logData = {
|
||||
userId: 'user-123',
|
||||
action: 'LOGIN',
|
||||
details: { ip: '192.168.1.1' },
|
||||
};
|
||||
|
||||
await createAuditLog(logData);
|
||||
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'test-id',
|
||||
userId: 'user-123',
|
||||
action: 'LOGIN',
|
||||
details: { ip: '192.168.1.1' },
|
||||
timestamp: expect.any(Date),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle missing optional fields', async () => {
|
||||
const mockValues = jest.fn().mockResolvedValue(undefined);
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
|
||||
const logData = {
|
||||
userId: 'user-456',
|
||||
action: 'LOGOUT',
|
||||
};
|
||||
|
||||
await createAuditLog(logData);
|
||||
|
||||
expect(mockValues).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
id: 'test-id',
|
||||
userId: 'user-456',
|
||||
action: 'LOGOUT',
|
||||
timestamp: expect.any(Date),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle database errors gracefully', async () => {
|
||||
const mockValues = jest.fn().mockRejectedValue(new Error('Database error'));
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
|
||||
const consoleSpy = jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
await createAuditLog({
|
||||
userId: 'user-789',
|
||||
action: 'LOGIN',
|
||||
});
|
||||
|
||||
expect(consoleSpy).toHaveBeenCalled();
|
||||
consoleSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionLabel', () => {
|
||||
it('should return correct label for known actions', () => {
|
||||
expect(getActionLabel('login')).toBe('登录');
|
||||
expect(getActionLabel('logout')).toBe('登出');
|
||||
expect(getActionLabel('create')).toBe('创建');
|
||||
expect(getActionLabel('update')).toBe('更新');
|
||||
expect(getActionLabel('delete')).toBe('删除');
|
||||
expect(getActionLabel('publish')).toBe('发布');
|
||||
expect(getActionLabel('upload')).toBe('上传');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getActionColor', () => {
|
||||
it('should return correct color for known actions', () => {
|
||||
expect(getActionColor('login')).toBe('bg-cyan-100 text-cyan-800');
|
||||
expect(getActionColor('logout')).toBe('bg-gray-100 text-gray-800');
|
||||
expect(getActionColor('create')).toBe('bg-green-100 text-green-800');
|
||||
expect(getActionColor('update')).toBe('bg-blue-100 text-blue-800');
|
||||
expect(getActionColor('delete')).toBe('bg-red-100 text-red-800');
|
||||
expect(getActionColor('publish')).toBe('bg-purple-100 text-purple-800');
|
||||
expect(getActionColor('upload')).toBe('bg-yellow-100 text-yellow-800');
|
||||
});
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user