feat(admin): enhance admin dashboard, user management, and content editor UX
- Dashboard: add stats API with content status distribution, recent notifications, and recent content updates; display active users, pending reviews, unread counts - User management: full CRUD with role assignment, search, pagination, delete dialog - Notification center: list with unread filter, mark as read, mark all as read, pagination, and auto-refresh unread count - Content editor: form validation (required fields, slug format, blur-triggered errors), auto-save with 3s debounce and status indicator, publish confirmation dialog, unsaved changes warning on leave - Admin layout: add navigation links for user management, roles, and notifications - admin-api: make request() method public for custom API calls - gitignore: add reports/mutation/ to exclude mutation test output
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
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/stats - 获取仪表盘统计数据
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
try {
|
||||
const [modelCount, itemCount, zoneCount, userCount, pendingReviewCount, unreadCount] = await Promise.all([
|
||||
prisma.contentModel.count(),
|
||||
prisma.contentItem.count(),
|
||||
prisma.contentZone.count(),
|
||||
prisma.user.count({ where: { status: 1 } }),
|
||||
prisma.contentItem.count({ where: { status: 'review' } }),
|
||||
prisma.notification.count({ where: { userId: user.userId, read: false } }),
|
||||
]);
|
||||
|
||||
// 获取每个内容模型的条目数
|
||||
const modelItemCounts = await prisma.contentItem.groupBy({
|
||||
by: ['modelCode'],
|
||||
_count: { modelCode: true },
|
||||
});
|
||||
|
||||
// 获取不同状态的条目数
|
||||
const statusCounts = await prisma.contentItem.groupBy({
|
||||
by: ['status'],
|
||||
_count: { status: true },
|
||||
});
|
||||
|
||||
// 获取最近 5 条通知
|
||||
const recentNotifications = await prisma.notification.findMany({
|
||||
where: { userId: user.userId },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
title: true,
|
||||
read: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
// 获取最近 5 条更新的内容
|
||||
const recentItems = await prisma.contentItem.findMany({
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 5,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
modelCode: true,
|
||||
status: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
return success({
|
||||
models: modelCount,
|
||||
items: itemCount,
|
||||
zones: zoneCount,
|
||||
activeUsers: userCount,
|
||||
pendingReviews: pendingReviewCount,
|
||||
unreadNotifications: unreadCount,
|
||||
modelItemCounts: modelItemCounts.map((m) => ({
|
||||
modelCode: m.modelCode,
|
||||
count: m._count.modelCode,
|
||||
})),
|
||||
statusCounts: statusCounts.map((s) => ({
|
||||
status: s.status,
|
||||
count: s._count.status,
|
||||
})),
|
||||
recentNotifications,
|
||||
recentItems,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get stats error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { NextRequest } from 'next/server';
|
||||
import { prisma } from '@/lib/db';
|
||||
import { authenticateRequest, hashPassword } from '@/lib/auth';
|
||||
import { success, unauthorized, forbidden, internalError, validationError } from '@/lib/api-response';
|
||||
|
||||
// GET /api/admin/users - 获取用户列表
|
||||
export async function GET(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
|
||||
const roleCodes = userRoles.map((ur) => ur.roleCode);
|
||||
if (!roleCodes.includes('super_admin') && !roleCodes.includes('content_admin')) {
|
||||
return forbidden('仅管理员可查看用户列表');
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10));
|
||||
const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '20', 10)));
|
||||
const search = searchParams.get('search') || '';
|
||||
|
||||
const where = search
|
||||
? {
|
||||
OR: [
|
||||
{ username: { contains: search } },
|
||||
{ nickname: { contains: search } },
|
||||
{ email: { contains: search } },
|
||||
],
|
||||
}
|
||||
: {};
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
email: true,
|
||||
phone: true,
|
||||
status: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
}),
|
||||
prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
// 获取每个用户的角色
|
||||
const usersWithRoles = await Promise.all(
|
||||
users.map(async (u) => {
|
||||
const roles = await prisma.userRole.findMany({
|
||||
where: { userId: u.id },
|
||||
include: { role: { select: { code: true, name: true } } },
|
||||
});
|
||||
return {
|
||||
...u,
|
||||
roles: roles.map((r) => ({ code: r.role.code, name: r.role.name })),
|
||||
// 保留向后兼容
|
||||
role: roles[0]?.role?.code || 'readonly',
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return success({
|
||||
users: usersWithRoles,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages: Math.ceil(total / pageSize),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Get users error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
|
||||
// POST /api/admin/users - 创建用户
|
||||
export async function POST(request: NextRequest) {
|
||||
const user = authenticateRequest(request);
|
||||
if (!user) return unauthorized();
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({ where: { userId: user.userId } });
|
||||
const roleCodes = userRoles.map((ur) => ur.roleCode);
|
||||
if (!roleCodes.includes('super_admin') && !roleCodes.includes('content_admin')) {
|
||||
return forbidden('仅管理员可创建用户');
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json() as {
|
||||
username: string;
|
||||
password: string;
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
roleCodes?: string[];
|
||||
};
|
||||
|
||||
if (!body.username || !body.password) {
|
||||
return validationError('用户名和密码必填');
|
||||
}
|
||||
|
||||
if (body.username.length < 3 || body.username.length > 32) {
|
||||
return validationError('用户名长度需在 3-32 字符之间');
|
||||
}
|
||||
|
||||
if (body.password.length < 6) {
|
||||
return validationError('密码长度不能少于 6 位');
|
||||
}
|
||||
|
||||
// 检查用户名是否已存在
|
||||
const existing = await prisma.user.findUnique({ where: { username: body.username } });
|
||||
if (existing) {
|
||||
return validationError('用户名已存在');
|
||||
}
|
||||
|
||||
const hashedPassword = await hashPassword(body.password);
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
username: body.username,
|
||||
password: hashedPassword,
|
||||
nickname: body.nickname || '',
|
||||
email: body.email || '',
|
||||
phone: body.phone || '',
|
||||
status: 1,
|
||||
},
|
||||
});
|
||||
|
||||
// 分配角色
|
||||
const rolesToAssign = body.roleCodes?.length ? body.roleCodes : ['readonly'];
|
||||
for (const roleCode of rolesToAssign) {
|
||||
const role = await prisma.role.findUnique({ where: { code: roleCode } });
|
||||
if (role) {
|
||||
await prisma.userRole.create({
|
||||
data: { userId: newUser.id, roleCode },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return success({
|
||||
id: newUser.id,
|
||||
username: newUser.username,
|
||||
nickname: newUser.nickname,
|
||||
email: newUser.email,
|
||||
phone: newUser.phone,
|
||||
roles: rolesToAssign,
|
||||
}, 201);
|
||||
} catch (error) {
|
||||
console.error('Create user error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
|
||||
// PUT /api/admin/users - 更新用户
|
||||
export async function PUT(request: NextRequest) {
|
||||
const currentUser = authenticateRequest(request);
|
||||
if (!currentUser) return unauthorized();
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({ where: { userId: currentUser.userId } });
|
||||
const roleCodes = userRoles.map((ur) => ur.roleCode);
|
||||
if (!roleCodes.includes('super_admin') && !roleCodes.includes('content_admin')) {
|
||||
return forbidden('仅管理员可编辑用户');
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get('id');
|
||||
if (!userId) return validationError('用户 ID 必填');
|
||||
|
||||
const body = await request.json() as {
|
||||
nickname?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
password?: string;
|
||||
status?: number;
|
||||
roleCodes?: string[];
|
||||
};
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
if (body.nickname !== undefined) updateData.nickname = body.nickname;
|
||||
if (body.email !== undefined) updateData.email = body.email;
|
||||
if (body.phone !== undefined) updateData.phone = body.phone;
|
||||
if (body.status !== undefined) updateData.status = body.status;
|
||||
if (body.password) {
|
||||
updateData.password = await hashPassword(body.password);
|
||||
}
|
||||
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
// 更新角色分配
|
||||
if (body.roleCodes) {
|
||||
// 防止将自己从 super_admin 移除
|
||||
if (userId === currentUser.userId) {
|
||||
const currentRoles = await prisma.userRole.findMany({ where: { userId } });
|
||||
const currentIsSuperAdmin = currentRoles.some((r) => r.roleCode === 'super_admin');
|
||||
const stillHasSuperAdmin = body.roleCodes.includes('super_admin');
|
||||
if (currentIsSuperAdmin && !stillHasSuperAdmin) {
|
||||
return forbidden('不能移除自己的超级管理员角色');
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.userRole.deleteMany({ where: { userId } });
|
||||
for (const roleCode of body.roleCodes) {
|
||||
const role = await prisma.role.findUnique({ where: { code: roleCode } });
|
||||
if (role) {
|
||||
await prisma.userRole.create({ data: { userId, roleCode } });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return success({ id: userId });
|
||||
} catch (error) {
|
||||
console.error('Update user error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
|
||||
// DELETE /api/admin/users - 删除用户
|
||||
export async function DELETE(request: NextRequest) {
|
||||
const currentUser = authenticateRequest(request);
|
||||
if (!currentUser) return unauthorized();
|
||||
|
||||
const userRoles = await prisma.userRole.findMany({ where: { userId: currentUser.userId } });
|
||||
const roleCodes = userRoles.map((ur) => ur.roleCode);
|
||||
if (!roleCodes.includes('super_admin')) {
|
||||
return forbidden('仅超级管理员可删除用户');
|
||||
}
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const userId = searchParams.get('id');
|
||||
if (!userId) return validationError('用户 ID 必填');
|
||||
|
||||
if (userId === currentUser.userId) {
|
||||
return forbidden('不能删除自己的账号');
|
||||
}
|
||||
|
||||
// 检查是否是最后一个 super_admin
|
||||
const targetRoles = await prisma.userRole.findMany({ where: { userId } });
|
||||
const isTargetSuperAdmin = targetRoles.some((r) => r.roleCode === 'super_admin');
|
||||
if (isTargetSuperAdmin) {
|
||||
const superAdminCount = await prisma.userRole.count({ where: { roleCode: 'super_admin' } });
|
||||
if (superAdminCount <= 1) {
|
||||
return forbidden('不能删除最后一个超级管理员');
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.userRole.deleteMany({ where: { userId } });
|
||||
await prisma.user.delete({ where: { id: userId } });
|
||||
|
||||
return success({ deleted: true });
|
||||
} catch (error) {
|
||||
console.error('Delete user error:', error);
|
||||
return internalError();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user