mirror of
https://github.com/massbug/judge4c.git
synced 2025-07-04 07:40:51 +00:00
feat(admin): 新增管理员管理功能
- 新增管理员管理页面和相关 API - 实现管理员用户的增删改查功能 - 添加用户管理相关的 API 和页面组件 - 更新 VSCode 设置,添加数据库连接配置
This commit is contained in:
parent
6df1f64376
commit
6bd06929a7
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
@ -2,5 +2,16 @@
|
|||||||
"i18n-ally.localesPaths": [
|
"i18n-ally.localesPaths": [
|
||||||
"messages"
|
"messages"
|
||||||
],
|
],
|
||||||
"i18n-ally.keystyle": "nested"
|
"i18n-ally.keystyle": "nested",
|
||||||
|
"sqltools.connections": [
|
||||||
|
{
|
||||||
|
"previewLimit": 50,
|
||||||
|
"server": "localhost",
|
||||||
|
"port": 5432,
|
||||||
|
"driver": "PostgreSQL",
|
||||||
|
"username": "postgres",
|
||||||
|
"database": "abc",
|
||||||
|
"name": "beiyu"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
40
src/api/user.ts
Normal file
40
src/api/user.ts
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
import type { UserBase } from "@/types/user";
|
||||||
|
|
||||||
|
// 获取所有用户
|
||||||
|
export async function getUsers(userType: string): Promise<UserBase[]> {
|
||||||
|
const res = await fetch(`/api/${userType}`);
|
||||||
|
if (!res.ok) throw new Error("获取用户失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建用户
|
||||||
|
export async function createUser(userType: string, data: Partial<UserBase>): Promise<UserBase> {
|
||||||
|
const res = await fetch(`/api/${userType}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("新建用户失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户
|
||||||
|
export async function updateUser(userType: string, data: Partial<UserBase>): Promise<UserBase> {
|
||||||
|
const res = await fetch(`/api/${userType}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("更新用户失败");
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户
|
||||||
|
export async function deleteUser(userType: string, id: string): Promise<void> {
|
||||||
|
const res = await fetch(`/api/${userType}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ id }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error("删除用户失败");
|
||||||
|
}
|
@ -1,15 +1,13 @@
|
|||||||
import { DataTable } from "@/components/data-table"
|
import { DataTable } from "@/components/data-table"
|
||||||
import { SiteHeader } from "@/components/site-header"
|
import { SiteHeader } from "@/components/site-header"
|
||||||
|
|
||||||
import data from "./data.json"
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<SiteHeader />
|
<SiteHeader />
|
||||||
<div className="flex flex-1 flex-col">
|
<div className="flex flex-1 flex-col">
|
||||||
<div className="flex flex-1 flex-col p-4">
|
<div className="flex flex-1 flex-col p-4">
|
||||||
<DataTable data={data} />
|
<DataTable data={[]} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
43
src/app/api/admin/route.ts
Normal file
43
src/app/api/admin/route.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
// 获取所有管理员
|
||||||
|
export async function GET() {
|
||||||
|
const users = await prisma.user.findMany({ where: { role: "ADMIN" } });
|
||||||
|
return NextResponse.json(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建管理员
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const data = await req.json();
|
||||||
|
if (data.password) {
|
||||||
|
data.password = await bcrypt.hash(data.password, 10);
|
||||||
|
}
|
||||||
|
data.role = "ADMIN";
|
||||||
|
const user = await prisma.user.create({ data });
|
||||||
|
const { password, ...userWithoutPassword } = user;
|
||||||
|
return NextResponse.json(userWithoutPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑管理员
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const data = await req.json();
|
||||||
|
if (data.password) {
|
||||||
|
data.password = await bcrypt.hash(data.password, 10);
|
||||||
|
}
|
||||||
|
data.role = "ADMIN";
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: data.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
const { password, ...userWithoutPassword } = user;
|
||||||
|
return NextResponse.json(userWithoutPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除管理员
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const { id } = await req.json();
|
||||||
|
await prisma.user.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
41
src/app/api/user/route.ts
Normal file
41
src/app/api/user/route.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
|
// 获取所有管理员
|
||||||
|
export async function GET() {
|
||||||
|
const users = await prisma.user.findMany();
|
||||||
|
return NextResponse.json(users);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新建管理员
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
const data = await req.json();
|
||||||
|
if (data.password) {
|
||||||
|
data.password = await bcrypt.hash(data.password, 10);
|
||||||
|
}
|
||||||
|
const user = await prisma.user.create({ data });
|
||||||
|
const { password, ...userWithoutPassword } = user;
|
||||||
|
return NextResponse.json(userWithoutPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑管理员
|
||||||
|
export async function PUT(req: NextRequest) {
|
||||||
|
const data = await req.json();
|
||||||
|
if (data.password) {
|
||||||
|
data.password = await bcrypt.hash(data.password, 10);
|
||||||
|
}
|
||||||
|
const user = await prisma.user.update({
|
||||||
|
where: { id: data.id },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
const { password, ...userWithoutPassword } = user;
|
||||||
|
return NextResponse.json(userWithoutPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除管理员
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
const { id } = await req.json();
|
||||||
|
await prisma.user.delete({ where: { id } });
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
}
|
10
src/app/dashboard/admin.tsx
Normal file
10
src/app/dashboard/admin.tsx
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import AdminTable from "@/components/user-table/admin-table";
|
||||||
|
|
||||||
|
export default function AdminDashboardPage() {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="text-xl font-bold mb-4">管理员管理</h2>
|
||||||
|
<AdminTable />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
41
src/components/user-table/admin-table.tsx
Normal file
41
src/components/user-table/admin-table.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { UserTable } from "./index";
|
||||||
|
import type { Admin } from "@/types/user";
|
||||||
|
|
||||||
|
// 管理员表单校验 schema
|
||||||
|
const adminSchema = z.object({
|
||||||
|
name: z.string().optional(),
|
||||||
|
email: z.string().email("请输入有效的邮箱地址"),
|
||||||
|
password: z.string().optional(),
|
||||||
|
role: z.string().optional(),
|
||||||
|
createdAt: z.string().optional(),
|
||||||
|
updatedAt: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// 管理员表格列配置
|
||||||
|
const adminColumns = [
|
||||||
|
{ key: "id" as keyof Admin, label: "ID" },
|
||||||
|
{ key: "name" as keyof Admin, label: "姓名" },
|
||||||
|
{ key: "email" as keyof Admin, label: "邮箱" },
|
||||||
|
{ key: "role" as keyof Admin, label: "角色" },
|
||||||
|
{ key: "createdAt" as keyof Admin, label: "创建时间" },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 管理员表单字段配置
|
||||||
|
const adminFormFields = [
|
||||||
|
{ key: "name" as keyof Admin, label: "姓名" },
|
||||||
|
{ key: "email" as keyof Admin, label: "邮箱", type: "email", required: true },
|
||||||
|
{ key: "password" as keyof Admin, label: "密码", type: "password" },
|
||||||
|
{ key: "role" as keyof Admin, label: "角色" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminTable() {
|
||||||
|
return (
|
||||||
|
<UserTable<Admin>
|
||||||
|
userType="admin"
|
||||||
|
columns={adminColumns}
|
||||||
|
schema={adminSchema}
|
||||||
|
formFields={adminFormFields}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
198
src/components/user-table/index.tsx
Normal file
198
src/components/user-table/index.tsx
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useForm } from "react-hook-form";
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import * as userApi from "@/api/user";
|
||||||
|
import type { UserBase } from "@/types/user";
|
||||||
|
|
||||||
|
interface UserTableProps<T extends UserBase> {
|
||||||
|
userType: string;
|
||||||
|
columns: { key: keyof T; label: string; render?: (value: any, row: T) => React.ReactNode }[];
|
||||||
|
schema: any; // zod schema
|
||||||
|
formFields: { key: keyof T; label: string; type?: string; required?: boolean }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserTable<T extends UserBase>({ userType, columns, schema, formFields }: UserTableProps<T>) {
|
||||||
|
const [data, setData] = useState<T[]>([]);
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<T | null>(null);
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||||
|
const [deleteBatch, setDeleteBatch] = useState(false);
|
||||||
|
const [selectedIds, setSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 获取数据
|
||||||
|
useEffect(() => {
|
||||||
|
userApi.getUsers(userType)
|
||||||
|
.then(res => setData(res as T[]))
|
||||||
|
.catch(() => toast.error('获取数据失败'))
|
||||||
|
}, [userType])
|
||||||
|
|
||||||
|
// 添加用户表单
|
||||||
|
function AddUserDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: {},
|
||||||
|
});
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) form.reset({});
|
||||||
|
}, [open, form]);
|
||||||
|
async function onSubmit(values: any) {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await userApi.createUser(userType, values);
|
||||||
|
userApi.getUsers(userType).then(res => setData(res as T[]));
|
||||||
|
onOpenChange(false);
|
||||||
|
toast.success('添加成功');
|
||||||
|
} catch {
|
||||||
|
toast.error('添加失败');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>添加</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{formFields.map(field => (
|
||||||
|
<div key={String(field.key)} className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor={String(field.key)} className="text-right">{field.label}</Label>
|
||||||
|
<Input
|
||||||
|
id={String(field.key)}
|
||||||
|
type={field.type || "text"}
|
||||||
|
{...form.register(String(field.key))}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isLoading}>{isLoading ? "添加中..." : "添加"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑用户表单
|
||||||
|
function EditUserDialog({ open, onOpenChange, user }: { open: boolean; onOpenChange: (open: boolean) => void; user: T }) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const form = useForm<any>({
|
||||||
|
resolver: zodResolver(schema),
|
||||||
|
defaultValues: user,
|
||||||
|
});
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) form.reset(user);
|
||||||
|
}, [open, user, form]);
|
||||||
|
async function onSubmit(values: any) {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
await userApi.updateUser(userType, { ...user, ...values });
|
||||||
|
userApi.getUsers(userType).then(res => setData(res as T[]));
|
||||||
|
onOpenChange(false);
|
||||||
|
toast.success('修改成功');
|
||||||
|
} catch {
|
||||||
|
toast.error('修改失败');
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>编辑</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
{formFields.map(field => (
|
||||||
|
<div key={String(field.key)} className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor={String(field.key)} className="text-right">{field.label}</Label>
|
||||||
|
<Input
|
||||||
|
id={String(field.key)}
|
||||||
|
type={field.type || "text"}
|
||||||
|
{...form.register(String(field.key))}
|
||||||
|
className="col-span-3"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isLoading}>{isLoading ? "修改中..." : "确认修改"}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除确认
|
||||||
|
async function handleDelete(ids: string[]) {
|
||||||
|
try {
|
||||||
|
await Promise.all(ids.map(id => userApi.deleteUser(userType, id)));
|
||||||
|
userApi.getUsers(userType).then(res => setData(res as T[]));
|
||||||
|
toast.success('删除成功');
|
||||||
|
} catch {
|
||||||
|
toast.error('删除失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex gap-2 mb-2">
|
||||||
|
<Button onClick={() => setIsAddDialogOpen(true)}>添加</Button>
|
||||||
|
<Button variant="destructive" disabled={selectedIds.length === 0} onClick={() => { setDeleteBatch(true); setDeleteDialogOpen(true); }}>批量删除</Button>
|
||||||
|
</div>
|
||||||
|
<table className="w-full border text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th><input type="checkbox" checked={selectedIds.length === data.length && data.length > 0} onChange={e => setSelectedIds(e.target.checked ? data.map(d => d.id) : [])} /></th>
|
||||||
|
{columns.map(col => <th key={String(col.key)}>{col.label}</th>)}
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.map(row => (
|
||||||
|
<tr key={row.id}>
|
||||||
|
<td><input type="checkbox" checked={selectedIds.includes(row.id)} onChange={e => setSelectedIds(ids => e.target.checked ? [...ids, row.id] : ids.filter(id => id !== row.id))} /></td>
|
||||||
|
{columns.map(col => <td key={String(col.key)}>{col.render ? col.render(row[col.key], row) : String(row[col.key] ?? "")}</td>)}
|
||||||
|
<td>
|
||||||
|
<Button size="sm" onClick={() => { setEditingUser(row); setIsEditDialogOpen(true); }}>编辑</Button>
|
||||||
|
<Button size="sm" variant="destructive" onClick={() => { setDeleteTargetId(row.id); setDeleteDialogOpen(true); }}>删除</Button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<AddUserDialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen} />
|
||||||
|
{editingUser && <EditUserDialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} user={editingUser} />}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
||||||
|
<Button variant="destructive" onClick={async () => {
|
||||||
|
if (deleteBatch) {
|
||||||
|
await handleDelete(selectedIds);
|
||||||
|
setSelectedIds([]);
|
||||||
|
} else if (deleteTargetId) {
|
||||||
|
await handleDelete([deleteTargetId]);
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false);
|
||||||
|
}}>确认删除</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
19
src/types/user.ts
Normal file
19
src/types/user.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
export interface UserBase {
|
||||||
|
id: string;
|
||||||
|
name?: string;
|
||||||
|
email: string;
|
||||||
|
password?: string;
|
||||||
|
role?: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Admin extends UserBase {
|
||||||
|
// 管理员特有字段(如有)
|
||||||
|
}
|
||||||
|
export interface Teacher extends UserBase {
|
||||||
|
// 教师特有字段(如有)
|
||||||
|
}
|
||||||
|
export interface Student extends UserBase {
|
||||||
|
// 学生特有字段(如有)
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user