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();
}
}