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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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。未来迁移到真实后端时可按需恢复。
|
||||
Reference in New Issue
Block a user