feat: 添加管理后台页面和功能,优化测试和性能配置

refactor: 重构页面导航和滚动逻辑,提升用户体验

test: 更新测试配置和用例,增加覆盖率和稳定性

perf: 优化性能指标和阈值,适应开发环境需求

ci: 添加Lighthouse CI工作流,集成性能测试

docs: 更新API文档和健康检查端点

fix: 修复登录页面和表单提交问题

style: 调整响应式布局和可访问性改进

chore: 更新依赖项和脚本配置
This commit is contained in:
张翔
2026-03-24 10:11:30 +08:00
parent 08978d38c8
commit f5dec95a83
85 changed files with 12331 additions and 1408 deletions
+1 -1
View File
@@ -94,7 +94,7 @@ describe('/api/admin/config', () => {
expect(response.status).toBe(200);
expect(data.configs).toBeDefined();
expect(data.flat).toBeDefined();
expect(Array.isArray(data.configs)).toBe(true);
});
});
+160
View File
@@ -7,6 +7,83 @@ import { forbidden, badRequest, success, handleApiError, validationError } from
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();
@@ -69,6 +146,89 @@ export async function GET(request: NextRequest) {
}
}
/**
* @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();
+20
View File
@@ -1,6 +1,26 @@
import { POST } from './route';
import { NextRequest } from 'next/server';
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 {
+44 -183
View File
@@ -1,54 +1,52 @@
import { NextRequest, NextResponse } from 'next/server';
import { NextRequest } from 'next/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';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { name, email, phone, subject, message, website, submitTime, mathHash, mathTimestamp, mathAnswer } = body;
if (!name || !email || !subject || !message) {
return NextResponse.json(
{ success: false, error: '请填写必填字段' },
{ status: 400 }
);
const requiredFields = ['name', 'email', 'subject', 'message'];
for (const field of requiredFields) {
if (!body[field]) {
return Response.json(
{ success: false, error: '请填写必填字段' },
{ status: 400 }
);
}
}
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(email)) {
return NextResponse.json(
const emailValidation = z.string().email().safeParse(body.email);
if (!emailValidation.success) {
return Response.json(
{ success: false, error: '请输入有效的邮箱地址' },
{ status: 400 }
);
}
if (website) {
console.log('Honeypot field filled, rejecting request');
return NextResponse.json(
{ success: true, message: '消息已发送' },
{ status: 200 }
);
if (body.website) {
return Response.json({ success: true, message: '消息已发送' });
}
if (submitTime) {
const timeDiff = Date.now() - parseInt(submitTime);
if (body.submitTime) {
const timeDiff = Date.now() - parseInt(body.submitTime);
if (timeDiff < 2000) {
console.log('Submission too fast:', timeDiff);
return NextResponse.json(
return Response.json(
{ success: false, error: '提交过快,请稍后再试' },
{ status: 400 }
);
}
}
if (mathHash && mathTimestamp && mathAnswer !== undefined) {
const expectedHash = btoa(`${mathAnswer}-${mathTimestamp}`);
if (expectedHash !== mathHash) {
console.log('Invalid math captcha');
return NextResponse.json(
if (body.mathHash && body.mathTimestamp && body.mathAnswer !== undefined) {
const expectedHash = btoa(`${body.mathAnswer}-${body.mathTimestamp}`);
if (expectedHash !== body.mathHash) {
return Response.json(
{ success: false, error: '验证码错误,请重新计算' },
{ status: 400 }
);
@@ -59,189 +57,52 @@ export async function POST(request: NextRequest) {
<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;
}
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>
<p>来自 睿新致远官方网站</p>
<h1>新的客户咨询</h1>
</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">${name}</div>
</div>
<div class="info-row">
<div class="info-label">邮箱</div>
<div class="info-value"><a href="mailto:${email}" style="color: #C41E3A; text-decoration: none;">${email}</a></div>
</div>
${phone ? `
<div class="info-row">
<div class="info-label">电话</div>
<div class="info-value">${phone}</div>
</div>
` : ''}
<div class="info-row">
<div class="info-label">主题</div>
<div class="info-value">${subject}</div>
</div>
</div>
<div class="message-box">
<div class="message-label">咨询内容</div>
<div class="message-content">${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 class="info-row"><span class="info-label">姓名:</span> ${body.name}</div>
<div class="info-row"><span class="info-label">邮箱:</span> ${body.email}</div>
${body.phone ? `<div class="info-row"><span class="info-label">电话:</span> ${body.phone}</div>` : ''}
<div class="info-row"><span class="info-label">主题:</span> ${body.subject}</div>
<div class="info-row"><span class="info-label">留言:</span> ${body.message}</div>
</div>
</div>
</body>
</html>
`;
const { data, error } = await resend.emails.send({
const result = await resend.emails.send({
from: '睿新致远官网 <onboarding@resend.dev>',
to: [companyEmail],
subject: `📧 ${subject} - ${name}`,
subject: `${body.subject} - ${body.name}`,
html: emailContent,
replyTo: email,
replyTo: body.email,
});
if (error) {
console.error('Resend API error:', error);
return NextResponse.json(
if (result.error) {
console.error('Resend API error:', result.error);
return Response.json(
{ success: false, error: '邮件发送失败,请稍后重试' },
{ status: 500 }
);
}
console.log('Email sent successfully:', data);
return NextResponse.json({ success: true, message: '消息已发送' });
return Response.json({ success: true, message: '消息已发送' });
} catch (error) {
console.error('Contact form submission error:', error);
return NextResponse.json(
return Response.json(
{ success: false, error: '提交失败,请重试' },
{ status: 500 }
);
+188
View File
@@ -0,0 +1,188 @@
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);
}
+68
View File
@@ -1,6 +1,74 @@
import { NextResponse } from 'next/server';
import { monitor } from '@/lib/monitoring';
/**
* @openapi
* /api/health:
* get:
* tags:
* - Health
* summary: 健康检查
* description: 检查应用程序的健康状态,包括数据库连接、内存使用等
* operationId: getHealth
* responses:
* 200:
* description: 服务健康
* content:
* application/json:
* schema:
* type: object
* properties:
* status:
* type: string
* example: ok
* timestamp:
* type: string
* format: date-time
* uptime:
* type: number
* description: 服务运行时间(秒)
* version:
* type: string
* description: 应用版本
* environment:
* type: string
* 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();
+25
View File
@@ -0,0 +1,25 @@
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
@@ -0,0 +1,132 @@
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),
};
}