mirror of
https://github.com/massbug/judge4c.git
synced 2025-07-04 15:50: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[]> {
|
export async function getUsers(userType: string): Promise<UserBase[]> {
|
||||||
const res = await fetch(`/api/${userType}`);
|
const res = await fetch(`/api/user`);
|
||||||
if (!res.ok) throw new Error("获取用户失败");
|
if (!res.ok) {
|
||||||
|
const error = await res.json();
|
||||||
|
throw new Error(error.error || "获取用户失败");
|
||||||
|
}
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 新建用户
|
// 新建用户
|
||||||
export async function createUser(userType: string, data: Partial<UserBase>): Promise<UserBase> {
|
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",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新用户
|
// 更新用户
|
||||||
export async function updateUser(userType: string, data: Partial<UserBase>): Promise<UserBase> {
|
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",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(data),
|
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();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除用户
|
// 删除用户
|
||||||
export async function deleteUser(userType: string, id: string): Promise<void> {
|
export async function deleteUser(userType: string, id: string): Promise<void> {
|
||||||
const res = await fetch(`/api/${userType}`, {
|
const res = await fetch(`/api/user`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ id }),
|
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 prisma from "@/lib/prisma";
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
// 获取所有管理员
|
// 获取所有用户
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
const users = await prisma.user.findMany();
|
try {
|
||||||
return NextResponse.json(users);
|
// 验证管理员权限
|
||||||
|
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) {
|
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") {
|
||||||
|
return NextResponse.json({ error: "权限不足" }, { status: 403 });
|
||||||
|
}
|
||||||
|
|
||||||
const data = await req.json();
|
const data = await req.json();
|
||||||
|
|
||||||
|
// 如果提供了密码,进行加密
|
||||||
if (data.password) {
|
if (data.password) {
|
||||||
data.password = await bcrypt.hash(data.password, 10);
|
data.password = await bcrypt.hash(data.password, 10);
|
||||||
}
|
}
|
||||||
const user = await prisma.user.create({ data });
|
|
||||||
const { password, ...userWithoutPassword } = user;
|
const newUser = await prisma.user.create({
|
||||||
return NextResponse.json(userWithoutPassword);
|
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) {
|
export async function PUT(req: NextRequest) {
|
||||||
const data = await req.json();
|
try {
|
||||||
if (data.password) {
|
// 验证管理员权限
|
||||||
data.password = await bcrypt.hash(data.password, 10);
|
const session = await auth();
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return NextResponse.json({ error: "未授权" }, { status: 401 });
|
||||||
}
|
}
|
||||||
const user = await prisma.user.update({
|
|
||||||
|
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 },
|
where: { id: data.id },
|
||||||
data,
|
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) {
|
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();
|
const { id } = await req.json();
|
||||||
|
|
||||||
|
// 防止删除自己
|
||||||
|
if (id === session.user.id) {
|
||||||
|
return NextResponse.json({ error: "不能删除自己的账户" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
await prisma.user.delete({ where: { id } });
|
await prisma.user.delete({ where: { id } });
|
||||||
return NextResponse.json({ success: true });
|
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