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

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