feat(cms): 添加 CMS 内容管理系统与 Admin 管理后台

- 新增 Prisma + SQLite 数据库模型 (Category, Content, Media, User 等)
- 新增 Admin 管理后台 (认证、内容管理、媒体管理)
- 新增 CMS API 路由 (CRUD, 草稿/发布, 重新验证)
- 新增 CMS 内容版本的历史归档页面
- 新增 components/cms 内容渲染组件
- 新增 components/admin 管理后台 UI 组件
- 更新 Contact API 路由
This commit is contained in:
张翔
2026-07-07 06:53:58 +08:00
parent 829d83522c
commit b5245f9aa2
87 changed files with 25659 additions and 0 deletions
+213
View File
@@ -0,0 +1,213 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth';
import {
success,
unauthorized,
notFound,
validationError,
internalError,
} from '@/lib/api-response';
function parseItem(item: { data: string; [key: string]: unknown }) {
return { ...item, data: JSON.parse(item.data as string) };
}
// GET /api/admin/items - 获取内容列表
export async function GET(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const { searchParams } = new URL(request.url);
const modelCode = searchParams.get('modelCode');
const status = searchParams.get('status');
const search = searchParams.get('search');
const page = parseInt(searchParams.get('page') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '10');
const where: Record<string, unknown> = {};
if (modelCode) where.modelCode = modelCode;
if (status) where.status = status;
if (search) {
where.OR = [
{ title: { contains: search } },
{ slug: { contains: search } },
];
}
const [items, total] = await Promise.all([
prisma.contentItem.findMany({
where,
orderBy: { sortOrder: 'asc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.contentItem.count({ where }),
]);
return success({
items: items.map(parseItem),
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
});
} catch (error) {
console.error('Get items error:', error);
return internalError();
}
}
// POST /api/admin/items - 创建内容
export async function POST(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const body = await request.json();
const { modelId, modelCode, title, slug, data, status, sortOrder } = body;
if (!modelId || !modelCode || !title) {
return validationError('缺少必填字段:modelId, modelCode, title');
}
// 检查 slug 唯一性
if (slug) {
const existing = await prisma.contentItem.findFirst({
where: { modelCode, slug },
});
if (existing) {
return validationError(`slug "${slug}" 已存在`);
}
}
const item = await prisma.contentItem.create({
data: {
modelId,
modelCode,
title,
slug: slug || '',
status: status || 'draft',
data: JSON.stringify(data || {}),
sortOrder: sortOrder || 0,
createdBy: payload.username,
updatedBy: payload.username,
publishedAt: status === 'published' ? new Date() : null,
},
});
// 记录操作日志
await prisma.auditLog.create({
data: {
module: 'item',
targetId: item.id,
action: 'create',
operator: payload.username,
afterData: JSON.stringify(item),
},
});
return success(parseItem(item), 201);
} catch (error) {
console.error('Create item error:', error);
return internalError();
}
}
// PUT /api/admin/items/[id] - 更新内容
export async function PUT(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return validationError('缺少 ID');
const existing = await prisma.contentItem.findUnique({ where: { id } });
if (!existing) return notFound('内容不存在');
const body = await request.json();
const { title, slug, data, status, sortOrder } = body;
// 检查 slug 唯一性
if (slug && slug !== existing.slug) {
const dup = await prisma.contentItem.findFirst({
where: { modelCode: existing.modelCode, slug, id: { not: id } },
});
if (dup) {
return validationError(`slug "${slug}" 已存在`);
}
}
const updateData: Record<string, unknown> = {
updatedBy: payload.username,
updatedAt: new Date(),
};
if (title !== undefined) updateData.title = title;
if (slug !== undefined) updateData.slug = slug;
if (data !== undefined) updateData.data = JSON.stringify(data);
if (status !== undefined) {
updateData.status = status;
if (status === 'published' && existing.status !== 'published') {
updateData.publishedAt = new Date();
}
}
if (sortOrder !== undefined) updateData.sortOrder = sortOrder;
const item = await prisma.contentItem.update({
where: { id },
data: updateData,
});
// 记录操作日志
await prisma.auditLog.create({
data: {
module: 'item',
targetId: id,
action: 'update',
operator: payload.username,
beforeData: JSON.stringify(existing),
afterData: JSON.stringify(item),
},
});
return success(parseItem(item));
} catch (error) {
console.error('Update item error:', error);
return internalError();
}
}
// DELETE /api/admin/items/[id] - 删除内容
export async function DELETE(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return validationError('缺少 ID');
const existing = await prisma.contentItem.findUnique({ where: { id } });
if (!existing) return notFound('内容不存在');
await prisma.contentItem.delete({ where: { id } });
await prisma.auditLog.create({
data: {
module: 'item',
targetId: id,
action: 'delete',
operator: payload.username,
beforeData: JSON.stringify(existing),
},
});
return success({ message: '删除成功' });
} catch (error) {
console.error('Delete item error:', error);
return internalError();
}
}
+156
View File
@@ -0,0 +1,156 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth';
import {
success,
unauthorized,
notFound,
validationError,
internalError,
} from '@/lib/api-response';
// GET /api/admin/media - 获取媒体列表
export async function GET(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1');
const pageSize = parseInt(searchParams.get('pageSize') || '20');
const mimeType = searchParams.get('mimeType');
const where: Record<string, unknown> = {};
if (mimeType) {
where.mimeType = { startsWith: mimeType };
}
const [items, total] = await Promise.all([
prisma.mediaAsset.findMany({
where,
orderBy: { createdAt: 'desc' },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.mediaAsset.count({ where }),
]);
return success({
items,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
});
} catch (error) {
console.error('Get media error:', error);
return internalError();
}
}
// POST /api/admin/media - 上传媒体文件
export async function POST(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const formData = await request.formData();
const file = formData.get('file') as File | null;
if (!file) {
return validationError('请选择文件');
}
// 文件大小限制 10MB
if (file.size > 10 * 1024 * 1024) {
return validationError('文件大小不能超过 10MB');
}
const bytes = await file.arrayBuffer();
const buffer = Buffer.from(bytes);
// 生成唯一文件名
const ext = file.name.split('.').pop() || '';
const fileName = `${Date.now()}-${Math.random().toString(36).slice(2)}.${ext}`;
const uploadDir = 'public/uploads';
const filePath = `${uploadDir}/${fileName}`;
// 写入文件
const fs = await import('fs/promises');
const path = await import('path');
const dirPath = path.join(process.cwd(), uploadDir);
await fs.mkdir(dirPath, { recursive: true });
await fs.writeFile(path.join(process.cwd(), filePath), buffer);
const asset = await prisma.mediaAsset.create({
data: {
name: file.name,
path: filePath,
url: `/uploads/${fileName}`,
mimeType: file.type || 'application/octet-stream',
size: file.size,
width: 0,
height: 0,
alt: file.name,
storageType: 'local',
createdBy: payload.username,
},
});
await prisma.auditLog.create({
data: {
module: 'media',
targetId: asset.id,
action: 'create',
operator: payload.username,
afterData: JSON.stringify(asset),
},
});
return success(asset, 201);
} catch (error) {
console.error('Upload media error:', error);
return internalError();
}
}
// DELETE /api/admin/media/[id] - 删除媒体文件
export async function DELETE(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return validationError('缺少 ID');
const existing = await prisma.mediaAsset.findUnique({ where: { id } });
if (!existing) return notFound('文件不存在');
// 删除物理文件
try {
const fs = await import('fs/promises');
const path = await import('path');
await fs.unlink(path.join(process.cwd(), existing.path));
} catch {
// 文件可能已被删除,忽略
}
await prisma.mediaAsset.delete({ where: { id } });
await prisma.auditLog.create({
data: {
module: 'media',
targetId: id,
action: 'delete',
operator: payload.username,
beforeData: JSON.stringify(existing),
},
});
return success({ message: '删除成功' });
} catch (error) {
console.error('Delete media error:', error);
return internalError();
}
}
+24
View File
@@ -0,0 +1,24 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth';
import { success, unauthorized, internalError } from '@/lib/api-response';
// GET /api/admin/models - 获取所有内容模型
export async function GET(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const models = await prisma.contentModel.findMany({
orderBy: { sortOrder: 'asc' },
});
return success(models.map((m) => ({
...m,
fields: JSON.parse(m.fields),
})));
} catch (error) {
console.error('Get models error:', error);
return internalError();
}
}
+136
View File
@@ -0,0 +1,136 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { authenticateRequest } from '@/lib/auth';
import {
success,
unauthorized,
notFound,
validationError,
internalError,
} from '@/lib/api-response';
// GET /api/admin/zones - 获取所有内容区域
export async function GET(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const { searchParams } = new URL(request.url);
const pageCode = searchParams.get('pageCode');
const where = pageCode ? { pageCode } : {};
const zones = await prisma.contentZone.findMany({ where });
return success(zones.map((z) => ({
...z,
allowedModels: JSON.parse(z.allowedModels),
items: JSON.parse(z.items),
settings: JSON.parse(z.settings),
})));
} catch (error) {
console.error('Get zones error:', error);
return internalError();
}
}
// POST /api/admin/zones - 创建/更新内容区域
export async function POST(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const body = await request.json();
const { id, code, name, description, pageCode, zoneKey, allowedModels, items, settings } = body;
if (!code) return validationError('缺少必填字段:code');
const data = {
code,
name: name || code,
description: description || '',
pageCode: pageCode || '',
zoneKey: zoneKey || '',
allowedModels: JSON.stringify(allowedModels || []),
items: JSON.stringify(items || []),
settings: JSON.stringify(settings || {}),
};
let zone;
if (id) {
const existing = await prisma.contentZone.findUnique({ where: { id } });
if (!existing) return notFound('区域不存在');
zone = await prisma.contentZone.update({ where: { id }, data });
await prisma.auditLog.create({
data: {
module: 'zone',
targetId: id,
action: 'update',
operator: payload.username,
beforeData: JSON.stringify(existing),
afterData: JSON.stringify(zone),
},
});
} else {
zone = await prisma.contentZone.create({ data });
await prisma.auditLog.create({
data: {
module: 'zone',
targetId: zone.id,
action: 'create',
operator: payload.username,
afterData: JSON.stringify(zone),
},
});
}
return success({
...zone,
allowedModels: JSON.parse(zone.allowedModels),
items: JSON.parse(zone.items),
settings: JSON.parse(zone.settings),
});
} catch (error) {
console.error('Save zone error:', error);
return internalError();
}
}
// PUT /api/admin/zones - 更新区域设置
export async function PUT(request: NextRequest) {
return POST(request);
}
// DELETE /api/admin/zones/[id] - 删除内容区域
export async function DELETE(request: NextRequest) {
const payload = authenticateRequest(request);
if (!payload) return unauthorized();
try {
const { searchParams } = new URL(request.url);
const id = searchParams.get('id');
if (!id) return validationError('缺少 ID');
const existing = await prisma.contentZone.findUnique({ where: { id } });
if (!existing) return notFound('区域不存在');
await prisma.contentZone.delete({ where: { id } });
await prisma.auditLog.create({
data: {
module: 'zone',
targetId: id,
action: 'delete',
operator: payload.username,
beforeData: JSON.stringify(existing),
},
});
return success({ message: '删除成功' });
} catch (error) {
console.error('Delete zone error:', error);
return internalError();
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest } from 'next/server';
import { prisma } from '@/lib/db';
import { verifyPassword, generateTokens, setTokenCookie } from '@/lib/auth';
import { success, unauthorized, validationError, internalError } from '@/lib/api-response';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { username, password } = body;
if (!username || !password) {
return validationError('请输入用户名和密码');
}
const user = await prisma.user.findUnique({ where: { username } });
if (!user) {
return unauthorized('用户名或密码错误');
}
if (user.status === 0) {
return unauthorized('账号已被禁用,请联系管理员');
}
const isValid = await verifyPassword(password, user.password);
if (!isValid) {
return unauthorized('用户名或密码错误');
}
const { accessToken, refreshToken } = generateTokens({
userId: user.id,
username: user.username,
role: user.role,
});
const response = success({
user: {
id: user.id,
username: user.username,
nickname: user.nickname,
role: user.role,
avatar: user.avatar,
},
accessToken,
refreshToken,
});
setTokenCookie(response, accessToken, refreshToken);
return response;
} catch (error) {
console.error('Login error:', error);
return internalError();
}
}
+8
View File
@@ -0,0 +1,8 @@
import { clearTokenCookie } from '@/lib/auth';
import { success } from '@/lib/api-response';
export async function POST() {
const response = success({ message: '已登出' });
clearTokenCookie(response);
return response;
}
+21
View File
@@ -0,0 +1,21 @@
import { NextRequest } from 'next/server';
import { authenticateRequest } from '@/lib/auth';
import { success, unauthorized, internalError } from '@/lib/api-response';
export async function GET(request: NextRequest) {
try {
const payload = authenticateRequest(request);
if (!payload) {
return unauthorized();
}
return success({
userId: payload.userId,
username: payload.username,
role: payload.role,
});
} catch (error) {
console.error('Me error:', error);
return internalError();
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest } from 'next/server';
import { verifyRefreshToken, generateTokens, setTokenCookie } from '@/lib/auth';
import { success, unauthorized, internalError } from '@/lib/api-response';
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const { refreshToken } = body;
if (!refreshToken) {
return unauthorized('缺少刷新令牌');
}
let payload;
try {
payload = verifyRefreshToken(refreshToken);
} catch {
return unauthorized('刷新令牌无效或已过期');
}
const tokens = generateTokens({
userId: payload.userId,
username: payload.username,
role: payload.role,
});
const response = success({
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
});
setTokenCookie(response, tokens.accessToken, tokens.refreshToken);
return response;
} catch (error) {
console.error('Refresh error:', error);
return internalError();
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { draftMode } from 'next/headers';
// 静态导出(output: 'export')不支持 GET + searchParams 的 API 路由。
// 使用 POST 避免 Next.js 在构建时尝试预渲染。未来迁移到真实后端时可改回 GET。
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const redirect = typeof body.redirect === 'string' ? body.redirect : null;
draftMode().disable();
if (redirect) {
return NextResponse.redirect(new URL(redirect, request.url));
}
return NextResponse.json({
code: 0,
message: 'Draft mode disabled',
data: {
enabled: false,
timestamp: new Date().toISOString(),
},
});
} catch {
return NextResponse.json(
{ code: 500, message: 'Internal server error', data: null },
{ status: 500 }
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { draftMode } from 'next/headers';
// 静态导出(output: 'export')不支持 GET + searchParams 的 API 路由。
// 使用 POST 避免 Next.js 在构建时尝试预渲染。未来迁移到真实后端时可改回 GET。
export async function POST(request: NextRequest) {
try {
const body = await request.json().catch(() => ({}));
const secret = typeof body.secret === 'string' ? body.secret : null;
const slug = typeof body.slug === 'string' ? body.slug : null;
const modelCode = typeof body.modelCode === 'string' ? body.modelCode : null;
const redirect = typeof body.redirect === 'string' ? body.redirect : null;
const expectedSecret = process.env.CMS_PREVIEW_SECRET;
if (expectedSecret && secret !== expectedSecret) {
return NextResponse.json(
{ code: 401, message: 'Invalid secret token', data: null },
{ status: 401 }
);
}
draftMode().enable();
if (redirect) {
return NextResponse.redirect(new URL(redirect, request.url));
}
if (modelCode && slug) {
const redirectPath = `/cases/${slug}?preview=true`;
return NextResponse.redirect(new URL(redirectPath, request.url));
}
return NextResponse.json({
code: 0,
message: 'Draft mode enabled',
data: {
enabled: true,
timestamp: new Date().toISOString(),
},
});
} catch {
return NextResponse.json(
{ code: 500, message: 'Internal server error', data: null },
{ status: 500 }
);
}
}
+106
View File
@@ -0,0 +1,106 @@
import { NextRequest, NextResponse } from 'next/server';
import { revalidatePath, revalidateTag } from 'next/cache';
import {
getContentTypeConfig,
getAllContentTypeConfigs,
} from '@/lib/cms/registry';
interface RevalidateRequestBody {
event?: string;
modelCode?: string;
itemId?: string;
slug?: string;
paths?: string[];
tags?: string[];
secret?: string;
}
export async function POST(request: NextRequest) {
try {
const body = (await request.json()) as RevalidateRequestBody;
const secret = body.secret ?? request.headers.get('x-revalidate-secret');
const expectedSecret = process.env.CMS_REVALIDATE_SECRET;
if (expectedSecret && secret !== expectedSecret) {
return NextResponse.json(
{ code: 401, message: 'Invalid secret', data: null },
{ status: 401 }
);
}
const revalidatedPaths: string[] = [];
const revalidatedTags: string[] = [];
if (body.paths && body.paths.length > 0) {
for (const path of body.paths) {
revalidatePath(path);
revalidatedPaths.push(path);
}
}
if (body.tags && body.tags.length > 0) {
for (const tag of body.tags) {
revalidateTag(tag);
revalidatedTags.push(tag);
}
}
if (body.modelCode) {
const config = getContentTypeConfig(body.modelCode);
if (config) {
if (config.listPage?.route) {
revalidatePath(config.listPage.route);
revalidatedPaths.push(config.listPage.route);
}
if (config.detailPage?.routePattern && body.slug) {
const detailPath = config.detailPage.routePattern.replace('{slug}', body.slug);
revalidatePath(detailPath);
revalidatedPaths.push(detailPath);
}
const tag = `cms:${body.modelCode}`;
revalidateTag(tag);
revalidatedTags.push(tag);
}
}
if (body.event === 'content.published' || body.event === 'content.updated') {
const allConfigs = getAllContentTypeConfigs();
for (const config of allConfigs) {
if (config.listPage?.route) {
revalidatePath(config.listPage.route);
if (!revalidatedPaths.includes(config.listPage.route)) {
revalidatedPaths.push(config.listPage.route);
}
}
}
revalidateTag('cms:all');
revalidatedTags.push('cms:all');
}
return NextResponse.json({
code: 0,
message: 'success',
data: {
revalidatedPaths,
revalidatedTags,
timestamp: new Date().toISOString(),
},
});
} catch (error) {
console.error('Revalidation error:', error);
return NextResponse.json(
{
code: 500,
message: error instanceof Error ? error.message : 'Internal server error',
data: null,
},
{ status: 500 }
);
}
}
// 注:静态导出(output: 'export')不支持 GET + searchParams 的 API 路由。
// GET 方法已移除,使用 POST 触发 revalidation。未来迁移到真实后端时可按需恢复。