mirror of
https://github.com/massbug/judge4c.git
synced 2025-07-04 07:40:51 +00:00
feat(user-management): 实现用户管理和题目管理功能
- 新增用户管理和题目管理的 API 接口 - 实现用户管理和题目管理的后端逻辑 - 添加保护布局组件,用于权限控制 - 创建用户管理的不同角色页面 - 移除不必要的数据文件和旧的页面组件
This commit is contained in:
parent
6bd06929a7
commit
42e576876e
40
src/api/problem.ts
Normal file
40
src/api/problem.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { Problem } from "@/types/problem";
|
||||
|
||||
// 获取所有题目
|
||||
export async function getProblems(): Promise<Problem[]> {
|
||||
const res = await fetch("/api/problem");
|
||||
if (!res.ok) throw new Error("获取题目失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 新建题目
|
||||
export async function createProblem(data: Partial<Problem>): Promise<Problem> {
|
||||
const res = await fetch("/api/problem", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("新建题目失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 编辑题目
|
||||
export async function updateProblem(data: Partial<Problem>): Promise<Problem> {
|
||||
const res = await fetch("/api/problem", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("更新题目失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 删除题目
|
||||
export async function deleteProblem(id: string): Promise<void> {
|
||||
const res = await fetch("/api/problem", {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (!res.ok) throw new Error("删除题目失败");
|
||||
}
|
@ -2,39 +2,51 @@ 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("获取用户失败");
|
||||
const res = await fetch(`/api/user`);
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || "获取用户失败");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 新建用户
|
||||
export async function createUser(userType: string, data: Partial<UserBase>): Promise<UserBase> {
|
||||
const res = await fetch(`/api/${userType}`, {
|
||||
const res = await fetch(`/api/user`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("新建用户失败");
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || "新建用户失败");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 更新用户
|
||||
export async function updateUser(userType: string, data: Partial<UserBase>): Promise<UserBase> {
|
||||
const res = await fetch(`/api/${userType}`, {
|
||||
const res = await fetch(`/api/user`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (!res.ok) throw new Error("更新用户失败");
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || "更新用户失败");
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 删除用户
|
||||
export async function deleteUser(userType: string, id: string): Promise<void> {
|
||||
const res = await fetch(`/api/${userType}`, {
|
||||
const res = await fetch(`/api/user`, {
|
||||
method: "DELETE",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id }),
|
||||
});
|
||||
if (!res.ok) throw new Error("删除用户失败");
|
||||
if (!res.ok) {
|
||||
const error = await res.json();
|
||||
throw new Error(error.error || "删除用户失败");
|
||||
}
|
||||
}
|
28
src/app/(app)/usermanagement/_components/ProtectedLayout.tsx
Normal file
28
src/app/(app)/usermanagement/_components/ProtectedLayout.tsx
Normal file
@ -0,0 +1,28 @@
|
||||
import { auth } from "@/lib/auth";
|
||||
import prisma from "@/lib/prisma";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
interface ProtectedLayoutProps {
|
||||
children: React.ReactNode;
|
||||
allowedRoles: string[];
|
||||
}
|
||||
|
||||
export default async function ProtectedLayout({ children, allowedRoles }: ProtectedLayoutProps) {
|
||||
const session = await auth();
|
||||
const userId = session?.user?.id;
|
||||
|
||||
if (!userId) {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { role: true }
|
||||
});
|
||||
|
||||
if (!user || !allowedRoles.includes(user.role)) {
|
||||
redirect("/sign-in");
|
||||
}
|
||||
|
||||
return <div className="w-full h-full">{children}</div>;
|
||||
}
|
5
src/app/(app)/usermanagement/admin/layout.tsx
Normal file
5
src/app/(app)/usermanagement/admin/layout.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import ProtectedLayout from "../_components/ProtectedLayout";
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
return <ProtectedLayout allowedRoles={["ADMIN"]}>{children}</ProtectedLayout>;
|
||||
}
|
5
src/app/(app)/usermanagement/admin/page.tsx
Normal file
5
src/app/(app)/usermanagement/admin/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { UserManagement } from "@/features/user-management"
|
||||
|
||||
export default function Page() {
|
||||
return <UserManagement userType="admin" />
|
||||
}
|
5
src/app/(app)/usermanagement/problem/layout.tsx
Normal file
5
src/app/(app)/usermanagement/problem/layout.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import ProtectedLayout from "../_components/ProtectedLayout";
|
||||
|
||||
export default function ProblemLayout({ children }: { children: React.ReactNode }) {
|
||||
return <ProtectedLayout allowedRoles={["ADMIN", "TEACHER"]}>{children}</ProtectedLayout>;
|
||||
}
|
5
src/app/(app)/usermanagement/problem/page.tsx
Normal file
5
src/app/(app)/usermanagement/problem/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { UserManagement } from "@/features/user-management"
|
||||
|
||||
export default function Page() {
|
||||
return <UserManagement userType="problem" />
|
||||
}
|
5
src/app/(app)/usermanagement/student/layout.tsx
Normal file
5
src/app/(app)/usermanagement/student/layout.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import ProtectedLayout from "../_components/ProtectedLayout";
|
||||
|
||||
export default function StudentLayout({ children }: { children: React.ReactNode }) {
|
||||
return <ProtectedLayout allowedRoles={["ADMIN", "TEACHER"]}>{children}</ProtectedLayout>;
|
||||
}
|
5
src/app/(app)/usermanagement/student/page.tsx
Normal file
5
src/app/(app)/usermanagement/student/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { UserManagement } from "@/features/user-management"
|
||||
|
||||
export default function Page() {
|
||||
return <UserManagement userType="student" />
|
||||
}
|
5
src/app/(app)/usermanagement/teacher/layout.tsx
Normal file
5
src/app/(app)/usermanagement/teacher/layout.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import ProtectedLayout from "../_components/ProtectedLayout";
|
||||
|
||||
export default function TeacherLayout({ children }: { children: React.ReactNode }) {
|
||||
return <ProtectedLayout allowedRoles={["ADMIN", "TEACHER"]}>{children}</ProtectedLayout>;
|
||||
}
|
5
src/app/(app)/usermanagement/teacher/page.tsx
Normal file
5
src/app/(app)/usermanagement/teacher/page.tsx
Normal file
@ -0,0 +1,5 @@
|
||||
import { UserManagement } from "@/features/user-management"
|
||||
|
||||
export default function Page() {
|
||||
return <UserManagement userType="teacher" />
|
||||
}
|
@ -1,122 +0,0 @@
|
||||
[
|
||||
{
|
||||
"id": "1",
|
||||
"name": "张三",
|
||||
"email": "zhangsan@example.com",
|
||||
"createdAt": "2023-10-01T08:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
"name": "李四",
|
||||
"email": "lisi@example.com",
|
||||
"createdAt": "2023-10-02T09:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
"name": "王五",
|
||||
"email": "wangwu@example.com",
|
||||
"createdAt": "2023-10-03T11:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
"name": "赵六",
|
||||
"email": "zhaoliu@example.org",
|
||||
"createdAt": "2023-10-04T14:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
"name": "孙七",
|
||||
"email": "sunqi@example.net",
|
||||
"createdAt": "2023-10-05T16:15:00Z"
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
"name": "周八",
|
||||
"email": "zhouba@example.org",
|
||||
"createdAt": "2023-10-06T18:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
"name": "吴九",
|
||||
"email": "wujiu@example.net",
|
||||
"createdAt": "2023-10-07T20:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
"name": "郑十",
|
||||
"email": "zhengshi@example.org",
|
||||
"createdAt": "2023-10-08T23:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "9",
|
||||
"name": "钱十一",
|
||||
"email": "qian11@example.net",
|
||||
"createdAt": "2023-10-09T01:15:00Z"
|
||||
},
|
||||
{
|
||||
"id": "10",
|
||||
"name": "孙十二",
|
||||
"email": "sun12@example.org",
|
||||
"createdAt": "2023-10-10T03:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "11",
|
||||
"name": "周十三",
|
||||
"email": "zhou13@example.net",
|
||||
"createdAt": "2023-10-11T05:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "12",
|
||||
"name": "吴十四",
|
||||
"email": "wushi14@example.org",
|
||||
"createdAt": "2023-10-12T08:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "13",
|
||||
"name": "郑十五",
|
||||
"email": "zheng15@example.net",
|
||||
"createdAt": "2023-10-13T10:15:00Z"
|
||||
},
|
||||
{
|
||||
"id": "14",
|
||||
"name": "钱十六",
|
||||
"email": "qian16@example.org",
|
||||
"createdAt": "2023-10-14T12:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "15",
|
||||
"name": "孙十七",
|
||||
"email": "sun17@example.net",
|
||||
"createdAt": "2023-10-15T14:45:00Z"
|
||||
},
|
||||
{
|
||||
"id": "16",
|
||||
"name": "周十八",
|
||||
"email": "zhou18@example.org",
|
||||
"createdAt": "2023-10-16T17:00:00Z"
|
||||
},
|
||||
{
|
||||
"id": "17",
|
||||
"name": "吴十九",
|
||||
"email": "wujiu19@example.net",
|
||||
"createdAt": "2023-10-17T19:15:00Z"
|
||||
},
|
||||
{
|
||||
"id": "18",
|
||||
"name": "郑二十",
|
||||
"email": "zheng20@example.org",
|
||||
"createdAt": "2023-10-18T21:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "19",
|
||||
"name": "周十九",
|
||||
"email": "zhou19@example.org",
|
||||
"createdAt": "2023-10-19T15:30:00Z"
|
||||
},
|
||||
{
|
||||
"id": "20",
|
||||
"name": "郑二十",
|
||||
"email": "zheng20@example.net",
|
||||
"createdAt": "2023-10-20T17:45:00Z"
|
||||
}
|
||||
]
|
@ -1,15 +0,0 @@
|
||||
import { DataTable } from "@/components/data-table"
|
||||
import { SiteHeader } from "@/components/site-header"
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<SiteHeader />
|
||||
<div className="flex flex-1 flex-col">
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<DataTable data={[]} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
@ -1,43 +0,0 @@
|
||||
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 });
|
||||
}
|
96
src/app/api/problem/route.ts
Normal file
96
src/app/api/problem/route.ts
Normal file
@ -0,0 +1,96 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
// 获取所有题目
|
||||
export async function GET() {
|
||||
try {
|
||||
// 权限校验(可选:只允许管理员/教师)
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
// 可根据需要校验角色
|
||||
// const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { role: true } });
|
||||
// if (user?.role !== "ADMIN" && user?.role !== "TEACHER") {
|
||||
// return NextResponse.json({ error: "权限不足" }, { status: 403 });
|
||||
// }
|
||||
const problems = await prisma.problem.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
displayId: true,
|
||||
difficulty: true,
|
||||
}
|
||||
});
|
||||
return NextResponse.json(problems);
|
||||
} catch (error) {
|
||||
console.error("获取题目列表失败:", error);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 新建题目
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
// 只允许管理员/教师添加
|
||||
// const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { role: true } });
|
||||
// if (user?.role !== "ADMIN" && user?.role !== "TEACHER") {
|
||||
// return NextResponse.json({ error: "权限不足" }, { status: 403 });
|
||||
// }
|
||||
const data = await req.json();
|
||||
const newProblem = await prisma.problem.create({ data });
|
||||
return NextResponse.json(newProblem);
|
||||
} catch (error) {
|
||||
console.error("创建题目失败:", error);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑题目
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
// 只允许管理员/教师编辑
|
||||
// const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { role: true } });
|
||||
// if (user?.role !== "ADMIN" && user?.role !== "TEACHER") {
|
||||
// return NextResponse.json({ error: "权限不足" }, { status: 403 });
|
||||
// }
|
||||
const data = await req.json();
|
||||
const updatedProblem = await prisma.problem.update({
|
||||
where: { id: data.id },
|
||||
data,
|
||||
});
|
||||
return NextResponse.json(updatedProblem);
|
||||
} catch (error) {
|
||||
console.error("更新题目失败:", error);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 删除题目
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
// 只允许管理员/教师删除
|
||||
// const user = await prisma.user.findUnique({ where: { id: session.user.id }, select: { role: true } });
|
||||
// if (user?.role !== "ADMIN" && user?.role !== "TEACHER") {
|
||||
// return NextResponse.json({ error: "权限不足" }, { status: 403 });
|
||||
// }
|
||||
const { id } = await req.json();
|
||||
await prisma.problem.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("删除题目失败:", error);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
@ -1,41 +1,189 @@
|
||||
import prisma from "@/lib/prisma";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import bcrypt from "bcryptjs";
|
||||
import { auth } from "@/lib/auth";
|
||||
|
||||
// 获取所有管理员
|
||||
// 获取所有用户
|
||||
export async function GET() {
|
||||
const users = await prisma.user.findMany();
|
||||
return NextResponse.json(users);
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
|
||||
// 新建管理员
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true }
|
||||
});
|
||||
|
||||
if (user?.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "权限不足" }, { status: 403 });
|
||||
}
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
password: true, // 包含密码字段用于处理
|
||||
}
|
||||
});
|
||||
|
||||
// 在服务器端处理密码显示逻辑
|
||||
const processedUsers = users.map(user => ({
|
||||
...user,
|
||||
password: user.password ? "******" : "(无)", // 服务器端处理密码显示
|
||||
createdAt: user.createdAt instanceof Date ? user.createdAt.toLocaleString() : user.createdAt, // 服务器端处理日期格式
|
||||
}));
|
||||
|
||||
return NextResponse.json(processedUsers);
|
||||
} catch (error) {
|
||||
console.error("获取用户列表失败:", error);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 新建用户
|
||||
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);
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true }
|
||||
});
|
||||
|
||||
if (user?.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "权限不足" }, { status: 403 });
|
||||
}
|
||||
|
||||
// 编辑管理员
|
||||
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({
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
password: true,
|
||||
}
|
||||
});
|
||||
|
||||
// 处理返回数据
|
||||
const processedUser = {
|
||||
...newUser,
|
||||
password: newUser.password ? "******" : "(无)",
|
||||
createdAt: newUser.createdAt instanceof Date ? newUser.createdAt.toLocaleString() : newUser.createdAt,
|
||||
};
|
||||
|
||||
return NextResponse.json(processedUser);
|
||||
} catch (error) {
|
||||
console.error("创建用户失败:", error);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 编辑用户
|
||||
export async function PUT(req: NextRequest) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true }
|
||||
});
|
||||
|
||||
if (user?.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "权限不足" }, { status: 403 });
|
||||
}
|
||||
|
||||
const data = await req.json();
|
||||
|
||||
// 如果提供了密码且不为空,进行加密
|
||||
if (data.password && data.password.trim() !== '') {
|
||||
data.password = await bcrypt.hash(data.password, 10);
|
||||
} else {
|
||||
// 如果密码为空,则不更新密码字段
|
||||
delete data.password;
|
||||
}
|
||||
|
||||
const updatedUser = await prisma.user.update({
|
||||
where: { id: data.id },
|
||||
data,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
email: true,
|
||||
role: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
password: true,
|
||||
}
|
||||
});
|
||||
const { password, ...userWithoutPassword } = user;
|
||||
return NextResponse.json(userWithoutPassword);
|
||||
|
||||
// 处理返回数据
|
||||
const processedUser = {
|
||||
...updatedUser,
|
||||
password: updatedUser.password ? "******" : "(无)",
|
||||
createdAt: updatedUser.createdAt instanceof Date ? updatedUser.createdAt.toLocaleString() : updatedUser.createdAt,
|
||||
};
|
||||
|
||||
return NextResponse.json(processedUser);
|
||||
} catch (error) {
|
||||
console.error("更新用户失败:", error);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
// 删除管理员
|
||||
// 删除用户
|
||||
export async function DELETE(req: NextRequest) {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.user.id },
|
||||
select: { role: true }
|
||||
});
|
||||
|
||||
if (user?.role !== "ADMIN") {
|
||||
return NextResponse.json({ error: "权限不足" }, { status: 403 });
|
||||
}
|
||||
|
||||
const { id } = await req.json();
|
||||
|
||||
// 防止删除自己
|
||||
if (id === session.user.id) {
|
||||
return NextResponse.json({ error: "不能删除自己的账户" }, { status: 400 });
|
||||
}
|
||||
|
||||
await prisma.user.delete({ where: { id } });
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (error) {
|
||||
console.error("删除用户失败:", error);
|
||||
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
|
||||
}
|
||||
}
|
@ -1,10 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
@ -1,917 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
DndContext,
|
||||
KeyboardSensor,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
closestCenter,
|
||||
useSensor,
|
||||
useSensors,
|
||||
type DragEndEvent,
|
||||
type UniqueIdentifier,
|
||||
} from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import {
|
||||
SortableContext,
|
||||
arrayMove,
|
||||
useSortable,
|
||||
verticalListSortingStrategy,
|
||||
} from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
Row,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
ColumnsIcon,
|
||||
GripVerticalIcon,
|
||||
LoaderIcon,
|
||||
MoreHorizontalIcon,
|
||||
MoreVerticalIcon,
|
||||
PlusIcon,
|
||||
EyeIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
ListFilter,
|
||||
} from "lucide-react"
|
||||
import { Area, AreaChart, CartesianGrid, XAxis } from "recharts"
|
||||
import { toast } from "sonner"
|
||||
import { z } from "zod"
|
||||
import { useState, useEffect, useRef } from "react"
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
|
||||
import { useIsMobile } from "@/hooks/use-mobile"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
ChartConfig,
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
} from "@/components/ui/chart"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import {
|
||||
Sheet,
|
||||
SheetClose,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetFooter,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
SheetTrigger,
|
||||
} from "@/components/ui/sheet"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Tabs,
|
||||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@/components/ui/tabs"
|
||||
|
||||
import * as userApi from "@/api/user"
|
||||
|
||||
// 定义管理员数据的 schema,与后端 User 类型保持一致
|
||||
const schema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
email: z.string(),
|
||||
password: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
|
||||
export type Admin = z.infer<typeof schema>
|
||||
|
||||
// 表单校验 schema
|
||||
const addAdminSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email("请输入有效的邮箱地址").optional().or(z.literal("")),
|
||||
password: z.string().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
})
|
||||
type AddAdminFormData = z.infer<typeof addAdminSchema>
|
||||
|
||||
const editAdminSchema = z.object({
|
||||
id: z.string().min(1, "ID不能为空"),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email("请输入有效的邮箱地址").optional().or(z.literal("")),
|
||||
password: z.string().optional(),
|
||||
createdAt: z.string().optional(),
|
||||
})
|
||||
type EditAdminFormData = z.infer<typeof editAdminSchema>
|
||||
|
||||
// 生成唯一id
|
||||
function generateUniqueId(existingIds: string[]): string {
|
||||
let id;
|
||||
do {
|
||||
id = 'c' + Math.random().toString(36).slice(2, 12) + Date.now().toString(36).slice(-4);
|
||||
} while (existingIds.includes(id));
|
||||
return id;
|
||||
}
|
||||
|
||||
// Create a separate component for the drag handle
|
||||
function DragHandle({ id }: { id: string }) {
|
||||
const { attributes, listeners } = useSortable({
|
||||
id,
|
||||
})
|
||||
|
||||
return (
|
||||
<Button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-muted-foreground size-7 hover:bg-transparent cursor-grab active:cursor-grabbing"
|
||||
>
|
||||
<GripVerticalIcon className="text-muted-foreground size-3" />
|
||||
<span className="sr-only">Drag to reorder</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
export function DataTable({
|
||||
data: initialData,
|
||||
}: {
|
||||
data: Admin[]
|
||||
}) {
|
||||
const [data, setData] = useState<Admin[]>(initialData)
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [editingAdmin, setEditingAdmin] = useState<Admin | null>(null)
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
})
|
||||
const sortableId = React.useId()
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {}),
|
||||
useSensor(TouchSensor, {}),
|
||||
useSensor(KeyboardSensor, {})
|
||||
)
|
||||
|
||||
const dataIds = React.useMemo<UniqueIdentifier[]>(
|
||||
() => data?.map(({ id }) => id) || [],
|
||||
[data]
|
||||
)
|
||||
|
||||
// 搜索输入本地 state
|
||||
const [nameSearch, setNameSearch] = useState("");
|
||||
const [emailSearch, setEmailSearch] = useState("");
|
||||
|
||||
// 删除确认对话框相关state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
|
||||
const [deleteBatch, setDeleteBatch] = useState(false)
|
||||
|
||||
// 页码输入本地state
|
||||
const [pageInput, setPageInput] = useState(pagination.pageIndex + 1)
|
||||
useEffect(() => {
|
||||
setPageInput(pagination.pageIndex + 1)
|
||||
}, [pagination.pageIndex])
|
||||
|
||||
const tableColumns = React.useMemo<ColumnDef<Admin>[]>(() => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="选择所有"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="选择行"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>姓名</span>
|
||||
<Input
|
||||
placeholder="搜索"
|
||||
className="h-6 w-24 text-xs px-1"
|
||||
value={(() => {
|
||||
const v = column.getFilterValue();
|
||||
return typeof v === 'string' ? v : '';
|
||||
})()}
|
||||
onChange={e => column.setFilterValue(e.target.value)}
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "email",
|
||||
header: ({ column }) => (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>邮箱</span>
|
||||
<Input
|
||||
placeholder="搜索"
|
||||
className="h-6 w-32 text-xs px-1"
|
||||
value={(() => {
|
||||
const v = column.getFilterValue();
|
||||
return typeof v === 'string' ? v : '';
|
||||
})()}
|
||||
onChange={e => column.setFilterValue(e.target.value)}
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "password",
|
||||
header: "密码",
|
||||
cell: ({ row }) => row.getValue("password") ? "******" : "(无)",
|
||||
},
|
||||
{
|
||||
accessorKey: "createdAt",
|
||||
header: "创建时间",
|
||||
cell: ({ row }) => {
|
||||
const date = new Date(row.getValue("createdAt"))
|
||||
return date.toLocaleString()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <div className="text-right">操作</div>,
|
||||
cell: ({ row }) => {
|
||||
const admin = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1"
|
||||
onClick={() => {
|
||||
setEditingAdmin(admin)
|
||||
setIsEditDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="size-4 mr-1" /> 编辑
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setDeleteTargetId(admin.id)
|
||||
setDeleteBatch(false)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<TrashIcon className="size-4 mr-1" /> 删除
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
], []);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
});
|
||||
|
||||
// useEffect 同步 filterValue 到本地 state
|
||||
useEffect(() => {
|
||||
const v = table.getColumn("name")?.getFilterValue();
|
||||
setNameSearch(typeof v === 'string' ? v : "");
|
||||
}, [table.getColumn("name")?.getFilterValue()]);
|
||||
useEffect(() => {
|
||||
const v = table.getColumn("email")?.getFilterValue();
|
||||
setEmailSearch(typeof v === 'string' ? v : "");
|
||||
}, [table.getColumn("email")?.getFilterValue()]);
|
||||
|
||||
function handleDragEnd(event: DragEndEvent) {
|
||||
const { active, over } = event
|
||||
if (active && over && active.id !== over.id) {
|
||||
setData((data) => {
|
||||
const oldIndex = dataIds.indexOf(active.id)
|
||||
const newIndex = dataIds.indexOf(over.id)
|
||||
return arrayMove(data, oldIndex, newIndex)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 数据加载与API对接
|
||||
useEffect(() => {
|
||||
userApi.getUsers()
|
||||
.then(setData)
|
||||
.catch(() => toast.error('获取管理员数据失败'))
|
||||
}, [])
|
||||
|
||||
// 添加管理员对话框组件
|
||||
function AddAdminDialog({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const form = useForm<AddAdminFormData>({
|
||||
resolver: zodResolver(addAdminSchema),
|
||||
defaultValues: {
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
createdAt: new Date().toISOString().slice(0, 16),
|
||||
},
|
||||
})
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
name: "",
|
||||
email: "",
|
||||
password: "",
|
||||
createdAt: new Date().toISOString().slice(0, 16),
|
||||
})
|
||||
}
|
||||
}, [open, form])
|
||||
async function onSubmit(data: AddAdminFormData) {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const existingIds = dataRef.current.map(item => item.id)
|
||||
const id = generateUniqueId(existingIds)
|
||||
const submitData = {
|
||||
...data,
|
||||
id,
|
||||
createdAt: data.createdAt ? new Date(data.createdAt).toISOString() : new Date().toISOString(),
|
||||
};
|
||||
await userApi.createUser(submitData)
|
||||
userApi.getUsers().then(setData)
|
||||
onOpenChange(false)
|
||||
toast.success('添加成功')
|
||||
} catch (error) {
|
||||
toast.error("添加管理员失败")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加管理员</DialogTitle>
|
||||
<DialogDescription>
|
||||
请填写管理员信息,ID自动生成。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
姓名
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...form.register("name")}
|
||||
className="col-span-3"
|
||||
placeholder="请输入管理员姓名(选填)"
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="email" className="text-right">
|
||||
邮箱
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
className="col-span-3"
|
||||
placeholder="请输入管理员邮箱(选填)"
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="password" className="text-right">
|
||||
密码
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
{...form.register("password")}
|
||||
className="col-span-3"
|
||||
placeholder="请输入密码(选填)"
|
||||
/>
|
||||
{form.formState.errors.password && (
|
||||
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||
{form.formState.errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="createdAt" className="text-right">
|
||||
创建时间
|
||||
</Label>
|
||||
<Input
|
||||
id="createdAt"
|
||||
type="datetime-local"
|
||||
{...form.register("createdAt")}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "添加中..." : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// 修改管理员对话框组件
|
||||
function EditAdminDialog({ open, onOpenChange, admin }: { open: boolean; onOpenChange: (open: boolean) => void; admin: Admin }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const form = useForm<EditAdminFormData>({
|
||||
resolver: zodResolver(editAdminSchema),
|
||||
defaultValues: {
|
||||
id: admin.id,
|
||||
name: admin.name || "",
|
||||
email: admin.email || "",
|
||||
password: admin.password || "",
|
||||
createdAt: admin.createdAt ? new Date(admin.createdAt).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16),
|
||||
},
|
||||
})
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
id: admin.id,
|
||||
name: admin.name || "",
|
||||
email: admin.email || "",
|
||||
password: admin.password || "",
|
||||
createdAt: admin.createdAt ? new Date(admin.createdAt).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16),
|
||||
})
|
||||
}
|
||||
}, [open, admin, form])
|
||||
async function onSubmit(data: EditAdminFormData) {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const submitData = {
|
||||
...data,
|
||||
createdAt: data.createdAt ? new Date(data.createdAt).toISOString() : new Date().toISOString(),
|
||||
};
|
||||
await userApi.updateUser(submitData)
|
||||
userApi.getUsers().then(setData)
|
||||
onOpenChange(false)
|
||||
toast.success('修改成功')
|
||||
} catch (error) {
|
||||
toast.error("修改管理员失败")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>修改管理员</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改管理员信息
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="id" className="text-right">
|
||||
ID
|
||||
</Label>
|
||||
<Input
|
||||
id="id"
|
||||
{...form.register("id")}
|
||||
className="col-span-3"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
姓名
|
||||
</Label>
|
||||
<Input
|
||||
id="name"
|
||||
{...form.register("name")}
|
||||
className="col-span-3"
|
||||
/>
|
||||
{form.formState.errors.name && (
|
||||
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="email" className="text-right">
|
||||
邮箱
|
||||
</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
{...form.register("email")}
|
||||
className="col-span-3"
|
||||
/>
|
||||
{form.formState.errors.email && (
|
||||
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||
{form.formState.errors.email.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="password" className="text-right">
|
||||
密码
|
||||
</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
{...form.register("password")}
|
||||
className="col-span-3"
|
||||
placeholder="请输入密码(选填)"
|
||||
/>
|
||||
{form.formState.errors.password && (
|
||||
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||
{form.formState.errors.password.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor="createdAt" className="text-right">
|
||||
创建时间
|
||||
</Label>
|
||||
<Input
|
||||
id="createdAt"
|
||||
type="datetime-local"
|
||||
{...form.register("createdAt")}
|
||||
className="col-span-3"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "修改中..." : "确认修改"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// 用ref保证获取最新data
|
||||
const dataRef = React.useRef<Admin[]>(data)
|
||||
React.useEffect(() => { dataRef.current = data }, [data])
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
defaultValue="outline"
|
||||
className="flex w-full flex-col gap-6"
|
||||
>
|
||||
<div className="flex items-center justify-between px-2 lg:px-4 py-2">
|
||||
<div className="flex items-center gap-1 text-sm font-medium">
|
||||
管理员列表
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-sm"
|
||||
>
|
||||
<ListFilter className="h-4 w-4" />
|
||||
显示列
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
// 中文列名映射
|
||||
const columnNameMap: Record<string, string> = {
|
||||
select: "选择",
|
||||
id: "ID",
|
||||
name: "姓名",
|
||||
email: "邮箱",
|
||||
password: "密码",
|
||||
createdAt: "创建时间",
|
||||
actions: "操作",
|
||||
};
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{columnNameMap[column.id] || column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-sm"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
添加管理员
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-sm"
|
||||
disabled={table.getFilteredSelectedRowModel().rows.length === 0}
|
||||
onClick={() => {
|
||||
setDeleteBatch(true)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-md border">
|
||||
<div style={{ maxHeight: 500, overflowY: 'auto' }}>
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="h-8">
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="py-1 px-2 text-xs">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow key={row.id} className="h-8">
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-1 px-2 text-sm">
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={tableColumns.length}
|
||||
className="h-16 text-center text-sm"
|
||||
>
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
{/* 固定底部的分页和行选中信息 */}
|
||||
<div className="flex items-center justify-end space-x-2 py-2 mt-2 border-t bg-white sticky bottom-0 z-10">
|
||||
<div className="flex-1 text-xs text-muted-foreground">
|
||||
{table.getFilteredSelectedRowModel().rows.length} 行被选中
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs">每页</span>
|
||||
<Select
|
||||
value={String(pagination.pageSize)}
|
||||
onValueChange={value => setPagination(p => ({ ...p, pageSize: Number(value), pageIndex: 0 }))}
|
||||
>
|
||||
<SelectTrigger className="w-16 h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
<SelectItem value="100">100</SelectItem>
|
||||
<SelectItem value="500">500</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-xs">条</span>
|
||||
</div>
|
||||
{/* 页码跳转 */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs">跳转到</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={table.getPageCount()}
|
||||
value={pageInput}
|
||||
onChange={e => setPageInput(Number(e.target.value))}
|
||||
onBlur={() => {
|
||||
let page = Number(pageInput) - 1
|
||||
if (isNaN(page) || page < 0) page = 0
|
||||
if (page >= table.getPageCount()) page = table.getPageCount() - 1
|
||||
setPagination(p => ({ ...p, pageIndex: page }))
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
let page = Number(pageInput) - 1
|
||||
if (isNaN(page) || page < 0) page = 0
|
||||
if (page >= table.getPageCount()) page = table.getPageCount() - 1
|
||||
setPagination(p => ({ ...p, pageIndex: page }))
|
||||
}
|
||||
}}
|
||||
className="w-14 h-7 text-xs px-1"
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
<span className="text-xs">页</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<AddAdminDialog
|
||||
open={isAddDialogOpen}
|
||||
onOpenChange={setIsAddDialogOpen}
|
||||
/>
|
||||
{editingAdmin && (
|
||||
<EditAdminDialog
|
||||
open={isEditDialogOpen}
|
||||
onOpenChange={setIsEditDialogOpen}
|
||||
admin={editingAdmin}
|
||||
/>
|
||||
)}
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteBatch
|
||||
? `确定要删除选中的所有管理员吗?`
|
||||
: `确定要删除该管理员吗?`}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
if (deleteBatch) {
|
||||
const selectedIds = table.getFilteredSelectedRowModel().rows.map(row => row.original.id)
|
||||
await Promise.all(selectedIds.map(id => userApi.deleteUser(id)))
|
||||
userApi.getUsers().then(setData)
|
||||
toast.success('批量删除成功')
|
||||
} else if (deleteTargetId) {
|
||||
await userApi.deleteUser(deleteTargetId)
|
||||
userApi.getUsers().then(setData)
|
||||
toast.success('删除成功')
|
||||
}
|
||||
setDeleteDialogOpen(false)
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = [
|
||||
{ month: "January", desktop: 186, mobile: 80 },
|
||||
{ month: "February", desktop: 305, mobile: 200 },
|
||||
{ month: "March", desktop: 237, mobile: 120 },
|
||||
{ month: "April", desktop: 73, mobile: 190 },
|
||||
{ month: "May", desktop: 209, mobile: 130 },
|
||||
{ month: "June", desktop: 214, mobile: 140 },
|
||||
]
|
||||
|
||||
const chartConfig = {
|
||||
desktop: {
|
||||
label: "Desktop",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
mobile: {
|
||||
label: "Mobile",
|
||||
color: "var(--primary)",
|
||||
},
|
||||
} satisfies ChartConfig
|
@ -1,41 +0,0 @@
|
||||
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}
|
||||
/>
|
||||
);
|
||||
}
|
@ -1,198 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
901
src/features/user-management/components/user-table.tsx
Normal file
901
src/features/user-management/components/user-table.tsx
Normal file
@ -0,0 +1,901 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
ColumnDef,
|
||||
ColumnFiltersState,
|
||||
SortingState,
|
||||
VisibilityState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getFacetedRowModel,
|
||||
getFacetedUniqueValues,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table"
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
ChevronsLeftIcon,
|
||||
ChevronsRightIcon,
|
||||
PlusIcon,
|
||||
PencilIcon,
|
||||
TrashIcon,
|
||||
ListFilter,
|
||||
} from "lucide-react"
|
||||
import { toast } from "sonner"
|
||||
import { useState, useEffect } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { z } from "zod"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
import {
|
||||
Tabs,
|
||||
} from "@/components/ui/tabs"
|
||||
|
||||
import * as userApi from "@/api/user"
|
||||
import * as problemApi from "@/api/problem"
|
||||
|
||||
// 通用用户类型
|
||||
export interface UserConfig {
|
||||
userType: string
|
||||
title: string
|
||||
apiPath: string
|
||||
columns: Array<{
|
||||
key: string
|
||||
label: string
|
||||
sortable?: boolean
|
||||
searchable?: boolean
|
||||
placeholder?: string
|
||||
}>
|
||||
formFields: Array<{
|
||||
key: string
|
||||
label: string
|
||||
type: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
}>
|
||||
actions: {
|
||||
add: { label: string; icon: string }
|
||||
edit: { label: string; icon: string }
|
||||
delete: { label: string; icon: string }
|
||||
batchDelete: { label: string; icon: string }
|
||||
}
|
||||
pagination: {
|
||||
pageSizes: number[]
|
||||
defaultPageSize: number
|
||||
}
|
||||
}
|
||||
|
||||
interface UserTableProps {
|
||||
config: UserConfig
|
||||
data: any[]
|
||||
}
|
||||
|
||||
// 在组件内部定义 schema
|
||||
const addUserSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
const editUserSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
const addProblemSchema = z.object({
|
||||
displayId: z.number(),
|
||||
difficulty: z.string(),
|
||||
})
|
||||
|
||||
const editProblemSchema = z.object({
|
||||
id: z.string(),
|
||||
displayId: z.number(),
|
||||
difficulty: z.string(),
|
||||
})
|
||||
|
||||
export function UserTable({ config, data: initialData }: UserTableProps) {
|
||||
const [data, setData] = useState<any[]>(initialData)
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||
const [editingUser, setEditingUser] = useState<any>(null)
|
||||
const [rowSelection, setRowSelection] = useState({})
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||
const [sorting, setSorting] = useState<SortingState>([])
|
||||
const [pagination, setPagination] = useState({
|
||||
pageIndex: 0,
|
||||
pageSize: config.pagination.defaultPageSize,
|
||||
})
|
||||
|
||||
// 删除确认对话框相关state
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null)
|
||||
const [deleteBatch, setDeleteBatch] = useState(false)
|
||||
|
||||
// 页码输入本地state
|
||||
const [pageInput, setPageInput] = useState(pagination.pageIndex + 1)
|
||||
useEffect(() => {
|
||||
setPageInput(pagination.pageIndex + 1)
|
||||
}, [pagination.pageIndex])
|
||||
|
||||
// 判断是否为题目管理
|
||||
const isProblem = config.userType === "problem"
|
||||
|
||||
// 动态生成表格列
|
||||
const tableColumns = React.useMemo<ColumnDef<any>[]>(() => {
|
||||
const columns: ColumnDef<any>[] = [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllPageRowsSelected()}
|
||||
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||
aria-label="选择所有"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="选择行"
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
|
||||
// 添加配置的列
|
||||
config.columns.forEach((col) => {
|
||||
const column: ColumnDef<any> = {
|
||||
accessorKey: col.key,
|
||||
header: ({ column: tableColumn }) => {
|
||||
if (col.searchable) {
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{col.label}</span>
|
||||
<Input
|
||||
placeholder={col.placeholder || "搜索"}
|
||||
className="h-6 w-24 text-xs px-1"
|
||||
value={(() => {
|
||||
const v = tableColumn.getFilterValue()
|
||||
return typeof v === 'string' ? v : ''
|
||||
})()}
|
||||
onChange={e => tableColumn.setFilterValue(e.target.value)}
|
||||
style={{ minWidth: 0 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return col.label
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const value = row.getValue(col.key)
|
||||
return value
|
||||
},
|
||||
enableSorting: col.sortable !== false,
|
||||
filterFn: col.searchable ? (row, columnId, value) => {
|
||||
const searchValue = String(value).toLowerCase()
|
||||
const cellValue = String(row.getValue(columnId)).toLowerCase()
|
||||
return cellValue.includes(searchValue)
|
||||
} : undefined,
|
||||
}
|
||||
columns.push(column)
|
||||
})
|
||||
|
||||
// 添加操作列
|
||||
columns.push({
|
||||
id: "actions",
|
||||
header: () => <div className="text-right">操作</div>,
|
||||
cell: ({ row }) => {
|
||||
const user = row.original
|
||||
return (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1"
|
||||
onClick={() => {
|
||||
setEditingUser(user)
|
||||
setIsEditDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<PencilIcon className="size-4 mr-1" /> {config.actions.edit.label}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-destructive hover:text-destructive"
|
||||
onClick={() => {
|
||||
setDeleteTargetId(user.id)
|
||||
setDeleteBatch(false)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
aria-label="Delete"
|
||||
>
|
||||
<TrashIcon className="size-4 mr-1" /> {config.actions.delete.label}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})
|
||||
|
||||
return columns
|
||||
}, [config])
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
columns: tableColumns,
|
||||
state: {
|
||||
sorting,
|
||||
columnVisibility,
|
||||
rowSelection,
|
||||
columnFilters,
|
||||
pagination,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onSortingChange: setSorting,
|
||||
onColumnFiltersChange: setColumnFilters,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
onPaginationChange: setPagination,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getFacetedRowModel: getFacetedRowModel(),
|
||||
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||
})
|
||||
|
||||
// 数据加载与API对接
|
||||
useEffect(() => {
|
||||
if (isProblem) {
|
||||
problemApi.getProblems()
|
||||
.then(setData)
|
||||
.catch(() => toast.error('获取数据失败'))
|
||||
} else {
|
||||
userApi.getUsers(config.userType)
|
||||
.then(setData)
|
||||
.catch(() => toast.error('获取数据失败'))
|
||||
}
|
||||
}, [config.userType])
|
||||
|
||||
// 生成唯一ID
|
||||
function generateUniqueId(existingIds: string[]): string {
|
||||
let id: string
|
||||
do {
|
||||
id = Math.random().toString(36).substr(2, 9)
|
||||
} while (existingIds.includes(id))
|
||||
return id
|
||||
}
|
||||
|
||||
// 添加用户对话框组件(仅用户)
|
||||
function AddUserDialogUser({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const form = useForm({
|
||||
resolver: zodResolver(addUserSchema),
|
||||
defaultValues: { name: "", email: "", password: "", createdAt: new Date().toISOString().slice(0, 16) },
|
||||
})
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ name: "", email: "", password: "", createdAt: new Date().toISOString().slice(0, 16) })
|
||||
}
|
||||
}, [open, form])
|
||||
async function onSubmit(formData: any) {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const existingIds = dataRef.current.map(item => item.id)
|
||||
const id = generateUniqueId(existingIds)
|
||||
const submitData = {
|
||||
...formData,
|
||||
id,
|
||||
createdAt: formData.createdAt ? new Date(formData.createdAt).toISOString() : new Date().toISOString(),
|
||||
}
|
||||
await userApi.createUser(config.userType, submitData)
|
||||
userApi.getUsers(config.userType).then(setData)
|
||||
onOpenChange(false)
|
||||
toast.success('添加成功')
|
||||
} catch {
|
||||
toast.error("添加失败")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{config.actions.add.label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
请填写信息,ID自动生成。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 py-4">
|
||||
{config.formFields.map((field) => (
|
||||
<div key={field.key} className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor={field.key} className="text-right">
|
||||
{field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.type}
|
||||
{...form.register(field.key as 'name' | 'email' | 'password' | 'createdAt')}
|
||||
className="col-span-3"
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message && (
|
||||
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "添加中..." : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// 添加题目对话框组件(仅题目)
|
||||
function AddUserDialogProblem({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const form = useForm({
|
||||
resolver: zodResolver(addProblemSchema),
|
||||
defaultValues: { displayId: 0, difficulty: "" },
|
||||
})
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ displayId: 0, difficulty: "" })
|
||||
}
|
||||
}, [open, form])
|
||||
async function onSubmit(formData: any) {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const existingIds = dataRef.current.map(item => item.id)
|
||||
const id = generateUniqueId(existingIds)
|
||||
const submitData = {
|
||||
...formData,
|
||||
displayId: Number(formData.displayId),
|
||||
id,
|
||||
}
|
||||
await problemApi.createProblem(submitData)
|
||||
problemApi.getProblems().then(setData)
|
||||
onOpenChange(false)
|
||||
toast.success('添加成功')
|
||||
} catch {
|
||||
toast.error("添加失败")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{config.actions.add.label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
请填写信息,ID自动生成。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 py-4">
|
||||
{config.formFields.map((field) => (
|
||||
<div key={field.key} className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor={field.key} className="text-right">
|
||||
{field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.type}
|
||||
{...form.register(field.key as 'displayId' | 'difficulty', field.key === 'displayId' ? { valueAsNumber: true } : {})}
|
||||
className="col-span-3"
|
||||
placeholder={field.placeholder}
|
||||
/>
|
||||
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message && (
|
||||
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "添加中..." : "添加"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑用户对话框组件(仅用户)
|
||||
function EditUserDialogUser({ open, onOpenChange, user }: { open: boolean; onOpenChange: (open: boolean) => void; user: any }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const form = useForm({
|
||||
resolver: zodResolver(editUserSchema),
|
||||
defaultValues: { id: user.id, name: user.name || "", email: user.email || "", password: "", createdAt: user.createdAt ? new Date(user.createdAt).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16) },
|
||||
})
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ id: user.id, name: user.name || "", email: user.email || "", password: "", createdAt: user.createdAt ? new Date(user.createdAt).toISOString().slice(0, 16) : new Date().toISOString().slice(0, 16) })
|
||||
}
|
||||
}, [open, user, form])
|
||||
async function onSubmit(formData: any) {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const submitData = {
|
||||
...formData,
|
||||
createdAt: formData.createdAt ? new Date(formData.createdAt).toISOString() : new Date().toISOString(),
|
||||
}
|
||||
if (!submitData.password) {
|
||||
delete submitData.password;
|
||||
}
|
||||
await userApi.updateUser(config.userType, submitData)
|
||||
userApi.getUsers(config.userType).then(setData)
|
||||
onOpenChange(false)
|
||||
toast.success('修改成功')
|
||||
} catch {
|
||||
toast.error("修改失败")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{config.actions.edit.label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改信息
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 py-4">
|
||||
{config.formFields.map((field) => (
|
||||
<div key={field.key} className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor={field.key} className="text-right">
|
||||
{field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.type}
|
||||
{...form.register(field.key as 'name' | 'email' | 'password' | 'createdAt' | 'id')}
|
||||
className="col-span-3"
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.key === 'id'}
|
||||
/>
|
||||
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message && (
|
||||
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "修改中..." : "确认修改"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// 编辑题目对话框组件(仅题目)
|
||||
function EditUserDialogProblem({ open, onOpenChange, user }: { open: boolean; onOpenChange: (open: boolean) => void; user: any }) {
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const form = useForm({
|
||||
resolver: zodResolver(editProblemSchema),
|
||||
defaultValues: { id: user.id, displayId: Number(user.displayId), difficulty: user.difficulty || "" },
|
||||
})
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({ id: user.id, displayId: Number(user.displayId), difficulty: user.difficulty || "" })
|
||||
}
|
||||
}, [open, user, form])
|
||||
async function onSubmit(formData: any) {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const submitData = {
|
||||
...formData,
|
||||
displayId: Number(formData.displayId),
|
||||
}
|
||||
await problemApi.updateProblem(submitData)
|
||||
problemApi.getProblems().then(setData)
|
||||
onOpenChange(false)
|
||||
toast.success('修改成功')
|
||||
} catch {
|
||||
toast.error("修改失败")
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{config.actions.edit.label}</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改信息
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="grid gap-4 py-4">
|
||||
{config.formFields.map((field) => (
|
||||
<div key={field.key} className="grid grid-cols-4 items-center gap-4">
|
||||
<Label htmlFor={field.key} className="text-right">
|
||||
{field.label}
|
||||
</Label>
|
||||
<Input
|
||||
id={field.key}
|
||||
type={field.type}
|
||||
{...form.register(field.key as 'displayId' | 'difficulty', field.key === 'displayId' ? { valueAsNumber: true } : {})}
|
||||
className="col-span-3"
|
||||
placeholder={field.placeholder}
|
||||
disabled={field.key === 'id'}
|
||||
/>
|
||||
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message && (
|
||||
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message as string}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" disabled={isLoading}>
|
||||
{isLoading ? "修改中..." : "确认修改"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// 用ref保证获取最新data
|
||||
const dataRef = React.useRef<any[]>(data)
|
||||
React.useEffect(() => { dataRef.current = data }, [data])
|
||||
|
||||
return (
|
||||
<Tabs defaultValue="outline" className="flex w-full flex-col gap-6">
|
||||
<div className="flex items-center justify-between px-2 lg:px-4 py-2">
|
||||
<div className="flex items-center gap-1 text-sm font-medium">
|
||||
{config.title}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-sm"
|
||||
>
|
||||
<ListFilter className="h-4 w-4" />
|
||||
显示列
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{table
|
||||
.getAllColumns()
|
||||
.filter((column) => column.getCanHide())
|
||||
.map((column) => {
|
||||
const columnNameMap: Record<string, string> = {
|
||||
select: "选择",
|
||||
id: "ID",
|
||||
name: "姓名",
|
||||
email: "邮箱",
|
||||
password: "密码",
|
||||
createdAt: "创建时间",
|
||||
actions: "操作",
|
||||
}
|
||||
return (
|
||||
<DropdownMenuCheckboxItem
|
||||
key={column.id}
|
||||
className="capitalize"
|
||||
checked={column.getIsVisible()}
|
||||
onCheckedChange={(value) =>
|
||||
column.toggleVisibility(!!value)
|
||||
}
|
||||
>
|
||||
{columnNameMap[column.id] || column.id}
|
||||
</DropdownMenuCheckboxItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-sm"
|
||||
onClick={() => setIsAddDialogOpen(true)}
|
||||
>
|
||||
<PlusIcon className="h-4 w-4" />
|
||||
{config.actions.add.label}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 gap-1 px-2 text-sm"
|
||||
disabled={table.getFilteredSelectedRowModel().rows.length === 0}
|
||||
onClick={() => {
|
||||
setDeleteBatch(true)
|
||||
setDeleteDialogOpen(true)
|
||||
}}
|
||||
>
|
||||
<TrashIcon className="h-4 w-4" />
|
||||
{config.actions.batchDelete.label}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<div style={{ maxHeight: 500, overflowY: 'auto' }}>
|
||||
<Table className="text-sm">
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id} className="h-8">
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id} className="py-1 px-2 text-xs">
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
</TableHead>
|
||||
)
|
||||
})}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="h-8"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="py-1 px-2 text-xs">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getAllColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between px-2">
|
||||
<div className="flex-1 text-sm text-muted-foreground">
|
||||
共 {table.getFilteredRowModel().rows.length} 条记录
|
||||
</div>
|
||||
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<p className="text-sm font-medium">每页显示</p>
|
||||
<Select
|
||||
value={`${table.getState().pagination.pageSize}`}
|
||||
onValueChange={(value) => {
|
||||
table.setPageSize(Number(value))
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-[70px]">
|
||||
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||
</SelectTrigger>
|
||||
<SelectContent side="top">
|
||||
{config.pagination.pageSizes.map((pageSize) => (
|
||||
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||
{pageSize}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||
第 {table.getState().pagination.pageIndex + 1} 页,共{" "}
|
||||
{table.getPageCount()} 页
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(0)}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to first page</span>
|
||||
<ChevronsLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.previousPage()}
|
||||
disabled={!table.getCanPreviousPage()}
|
||||
>
|
||||
<span className="sr-only">Go to previous page</span>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-sm">跳转到</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={table.getPageCount()}
|
||||
value={pageInput}
|
||||
onChange={(e) => setPageInput(Number(e.target.value))}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
const page = pageInput - 1
|
||||
if (page >= 0 && page < table.getPageCount()) {
|
||||
table.setPageIndex(page)
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-16 h-8 text-sm"
|
||||
/>
|
||||
<span className="text-sm">页</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="h-8 w-8 p-0"
|
||||
onClick={() => table.nextPage()}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to next page</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="hidden h-8 w-8 p-0 lg:flex"
|
||||
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||
disabled={!table.getCanNextPage()}
|
||||
>
|
||||
<span className="sr-only">Go to last page</span>
|
||||
<ChevronsRightIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加用户对话框 */}
|
||||
{isProblem ? (
|
||||
<AddUserDialogProblem open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen} />
|
||||
) : (
|
||||
<AddUserDialogUser open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen} />
|
||||
)}
|
||||
|
||||
{/* 编辑用户对话框 */}
|
||||
{isProblem && editingUser ? (
|
||||
<EditUserDialogProblem open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} user={editingUser} />
|
||||
) : editingUser ? (
|
||||
<EditUserDialogUser open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} user={editingUser} />
|
||||
) : null}
|
||||
|
||||
{/* 删除确认对话框 */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
{deleteBatch
|
||||
? `确定要删除选中的 ${table.getFilteredSelectedRowModel().rows.length} 条记录吗?此操作不可撤销。`
|
||||
: "确定要删除这条记录吗?此操作不可撤销。"
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={async () => {
|
||||
try {
|
||||
if (deleteBatch) {
|
||||
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||
for (const row of selectedRows) {
|
||||
if (isProblem) {
|
||||
await problemApi.deleteProblem(row.original.id)
|
||||
problemApi.getProblems().then(setData)
|
||||
} else {
|
||||
await userApi.deleteUser(config.userType, row.original.id)
|
||||
userApi.getUsers(config.userType).then(setData)
|
||||
}
|
||||
}
|
||||
toast.success(`成功删除 ${selectedRows.length} 条记录`)
|
||||
} else if (deleteTargetId) {
|
||||
if (isProblem) {
|
||||
await problemApi.deleteProblem(deleteTargetId)
|
||||
problemApi.getProblems().then(setData)
|
||||
} else {
|
||||
await userApi.deleteUser(config.userType, deleteTargetId)
|
||||
userApi.getUsers(config.userType).then(setData)
|
||||
}
|
||||
toast.success('删除成功')
|
||||
}
|
||||
setDeleteDialogOpen(false)
|
||||
} catch {
|
||||
toast.error("删除失败")
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
130
src/features/user-management/config/admin.ts
Normal file
130
src/features/user-management/config/admin.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import { z } from "zod"
|
||||
|
||||
// 管理员数据校验 schema
|
||||
export const adminSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
email: z.string(),
|
||||
password: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string().optional(),
|
||||
})
|
||||
|
||||
export type Admin = z.infer<typeof adminSchema>
|
||||
|
||||
// 添加管理员表单校验 schema
|
||||
export const addAdminSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
// 编辑管理员表单校验 schema
|
||||
export const editAdminSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
export type AddAdminFormData = z.infer<typeof addAdminSchema>
|
||||
export type EditAdminFormData = z.infer<typeof editAdminSchema>
|
||||
|
||||
// 管理员配置
|
||||
export const adminConfig = {
|
||||
userType: "admin",
|
||||
title: "管理员列表",
|
||||
apiPath: "/api/user",
|
||||
|
||||
// 表格列配置
|
||||
columns: [
|
||||
{
|
||||
key: "id",
|
||||
label: "ID",
|
||||
sortable: true,
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
label: "姓名",
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
placeholder: "搜索姓名",
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: "邮箱",
|
||||
sortable: true,
|
||||
searchable: true,
|
||||
placeholder: "搜索邮箱",
|
||||
},
|
||||
{
|
||||
key: "password",
|
||||
label: "密码",
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "创建时间",
|
||||
sortable: true,
|
||||
},
|
||||
],
|
||||
|
||||
// 表单字段配置
|
||||
formFields: [
|
||||
{
|
||||
key: "name",
|
||||
label: "姓名",
|
||||
type: "text",
|
||||
placeholder: "请输入管理员姓名(选填)",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "email",
|
||||
label: "邮箱",
|
||||
type: "email",
|
||||
placeholder: "请输入管理员邮箱",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
key: "password",
|
||||
label: "密码",
|
||||
type: "password",
|
||||
placeholder: "请输入密码(选填)",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
label: "创建时间",
|
||||
type: "datetime-local",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
|
||||
// 操作按钮配置
|
||||
actions: {
|
||||
add: {
|
||||
label: "添加管理员",
|
||||
icon: "PlusIcon",
|
||||
},
|
||||
edit: {
|
||||
label: "编辑",
|
||||
icon: "PencilIcon",
|
||||
},
|
||||
delete: {
|
||||
label: "删除",
|
||||
icon: "TrashIcon",
|
||||
},
|
||||
batchDelete: {
|
||||
label: "批量删除",
|
||||
icon: "TrashIcon",
|
||||
},
|
||||
},
|
||||
|
||||
// 分页配置
|
||||
pagination: {
|
||||
pageSizes: [10, 50, 100, 500],
|
||||
defaultPageSize: 10,
|
||||
},
|
||||
}
|
42
src/features/user-management/config/problem.ts
Normal file
42
src/features/user-management/config/problem.ts
Normal file
@ -0,0 +1,42 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const problemSchema = z.object({
|
||||
id: z.string(),
|
||||
displayId: z.number(),
|
||||
difficulty: z.string(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const addProblemSchema = z.object({
|
||||
displayId: z.number(),
|
||||
difficulty: z.string(),
|
||||
});
|
||||
|
||||
export const editProblemSchema = z.object({
|
||||
id: z.string(),
|
||||
displayId: z.number(),
|
||||
difficulty: z.string(),
|
||||
});
|
||||
|
||||
export const problemConfig = {
|
||||
userType: "problem",
|
||||
title: "题目列表",
|
||||
apiPath: "/api/problem",
|
||||
columns: [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{ key: "displayId", label: "题目编号", sortable: true, searchable: true, placeholder: "搜索编号" },
|
||||
{ key: "difficulty", label: "难度", sortable: true, searchable: true, placeholder: "搜索难度" },
|
||||
{ key: "createdAt", label: "创建时间", sortable: true },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "displayId", label: "题目编号", type: "number", required: true },
|
||||
{ key: "difficulty", label: "难度", type: "text", required: true },
|
||||
],
|
||||
actions: {
|
||||
add: { label: "添加题目", icon: "PlusIcon" },
|
||||
edit: { label: "编辑", icon: "PencilIcon" },
|
||||
delete: { label: "删除", icon: "TrashIcon" },
|
||||
batchDelete: { label: "批量删除", icon: "TrashIcon" },
|
||||
},
|
||||
pagination: { pageSizes: [10, 50, 100, 500], defaultPageSize: 10 },
|
||||
};
|
52
src/features/user-management/config/student.ts
Normal file
52
src/features/user-management/config/student.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const studentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
email: z.string(),
|
||||
password: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const addStudentSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const editStudentSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const studentConfig = {
|
||||
userType: "student",
|
||||
title: "学生列表",
|
||||
apiPath: "/api/user",
|
||||
columns: [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{ key: "name", label: "姓名", sortable: true, searchable: true, placeholder: "搜索姓名" },
|
||||
{ key: "email", label: "邮箱", sortable: true, searchable: true, placeholder: "搜索邮箱" },
|
||||
{ key: "password", label: "密码" },
|
||||
{ key: "createdAt", label: "创建时间", sortable: true },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "name", label: "姓名", type: "text", placeholder: "请输入学生姓名(选填)", required: false },
|
||||
{ key: "email", label: "邮箱", type: "email", placeholder: "请输入学生邮箱", required: true },
|
||||
{ key: "password", label: "密码", type: "password", placeholder: "请输入密码(选填)", required: false },
|
||||
{ key: "createdAt", label: "创建时间", type: "datetime-local", required: true },
|
||||
],
|
||||
actions: {
|
||||
add: { label: "添加学生", icon: "PlusIcon" },
|
||||
edit: { label: "编辑", icon: "PencilIcon" },
|
||||
delete: { label: "删除", icon: "TrashIcon" },
|
||||
batchDelete: { label: "批量删除", icon: "TrashIcon" },
|
||||
},
|
||||
pagination: { pageSizes: [10, 50, 100, 500], defaultPageSize: 10 },
|
||||
};
|
52
src/features/user-management/config/teacher.ts
Normal file
52
src/features/user-management/config/teacher.ts
Normal file
@ -0,0 +1,52 @@
|
||||
import { z } from "zod";
|
||||
|
||||
export const teacherSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
email: z.string(),
|
||||
password: z.string().optional(),
|
||||
role: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string().optional(),
|
||||
});
|
||||
|
||||
export const addTeacherSchema = z.object({
|
||||
name: z.string().optional(),
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const editTeacherSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string().optional(),
|
||||
email: z.string().email("请输入有效的邮箱地址"),
|
||||
password: z.string().optional(),
|
||||
createdAt: z.string(),
|
||||
});
|
||||
|
||||
export const teacherConfig = {
|
||||
userType: "teacher",
|
||||
title: "教师列表",
|
||||
apiPath: "/api/user",
|
||||
columns: [
|
||||
{ key: "id", label: "ID", sortable: true },
|
||||
{ key: "name", label: "姓名", sortable: true, searchable: true, placeholder: "搜索姓名" },
|
||||
{ key: "email", label: "邮箱", sortable: true, searchable: true, placeholder: "搜索邮箱" },
|
||||
{ key: "password", label: "密码" },
|
||||
{ key: "createdAt", label: "创建时间", sortable: true },
|
||||
],
|
||||
formFields: [
|
||||
{ key: "name", label: "姓名", type: "text", placeholder: "请输入教师姓名(选填)", required: false },
|
||||
{ key: "email", label: "邮箱", type: "email", placeholder: "请输入教师邮箱", required: true },
|
||||
{ key: "password", label: "密码", type: "password", placeholder: "请输入密码(选填)", required: false },
|
||||
{ key: "createdAt", label: "创建时间", type: "datetime-local", required: true },
|
||||
],
|
||||
actions: {
|
||||
add: { label: "添加教师", icon: "PlusIcon" },
|
||||
edit: { label: "编辑", icon: "PencilIcon" },
|
||||
delete: { label: "删除", icon: "TrashIcon" },
|
||||
batchDelete: { label: "批量删除", icon: "TrashIcon" },
|
||||
},
|
||||
pagination: { pageSizes: [10, 50, 100, 500], defaultPageSize: 10 },
|
||||
};
|
50
src/features/user-management/index.tsx
Normal file
50
src/features/user-management/index.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
"use client"
|
||||
|
||||
import { UserTable } from "./components/user-table"
|
||||
import { adminConfig } from "./config/admin"
|
||||
import { teacherConfig } from "./config/teacher"
|
||||
import { studentConfig } from "./config/student"
|
||||
import { problemConfig } from "./config/problem"
|
||||
|
||||
interface UserManagementProps {
|
||||
userType: "admin" | "teacher" | "student" | "problem"
|
||||
}
|
||||
|
||||
export function UserManagement({ userType }: UserManagementProps) {
|
||||
// 根据用户类型返回对应的配置
|
||||
if (userType === "admin") {
|
||||
return (
|
||||
<UserTable
|
||||
config={adminConfig}
|
||||
data={[]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (userType === "teacher") {
|
||||
return (
|
||||
<UserTable
|
||||
config={teacherConfig}
|
||||
data={[]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (userType === "student") {
|
||||
return (
|
||||
<UserTable
|
||||
config={studentConfig}
|
||||
data={[]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (userType === "problem") {
|
||||
return (
|
||||
<UserTable
|
||||
config={problemConfig}
|
||||
data={[]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 后续可以添加 teacher 和 student 的配置
|
||||
return <div>暂不支持 {userType} 类型</div>
|
||||
}
|
12
src/types/problem.ts
Normal file
12
src/types/problem.ts
Normal file
@ -0,0 +1,12 @@
|
||||
export interface Problem {
|
||||
id: string;
|
||||
displayId: number;
|
||||
difficulty: string;
|
||||
isPublished: boolean;
|
||||
isTrim: boolean;
|
||||
timeLimit: number;
|
||||
memoryLimit: number;
|
||||
userId?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
Loading…
Reference in New Issue
Block a user