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:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user