feat(user-management): 实现用户管理和题目管理功能

- 新增用户管理和题目管理的 API 接口
- 实现用户管理和题目管理的后端逻辑
- 添加保护布局组件,用于权限控制
- 创建用户管理的不同角色页面
- 移除不必要的数据文件和旧的页面组件
This commit is contained in:
liguang 2025-06-19 16:13:50 +08:00
parent 6bd06929a7
commit 42e576876e
27 changed files with 1635 additions and 1378 deletions

40
src/api/problem.ts Normal file
View 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("删除题目失败");
}

View File

@ -2,39 +2,51 @@ import type { UserBase } from "@/types/user";
// 获取所有用户
export async function getUsers(userType: string): Promise<UserBase[]> {
const res = await fetch(`/api/${userType}`);
if (!res.ok) throw new Error("获取用户失败");
const res = await fetch(`/api/user`);
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "获取用户失败");
}
return res.json();
}
// 新建用户
export async function createUser(userType: string, data: Partial<UserBase>): Promise<UserBase> {
const res = await fetch(`/api/${userType}`, {
const res = await fetch(`/api/user`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("新建用户失败");
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "新建用户失败");
}
return res.json();
}
// 更新用户
export async function updateUser(userType: string, data: Partial<UserBase>): Promise<UserBase> {
const res = await fetch(`/api/${userType}`, {
const res = await fetch(`/api/user`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
if (!res.ok) throw new Error("更新用户失败");
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "更新用户失败");
}
return res.json();
}
// 删除用户
export async function deleteUser(userType: string, id: string): Promise<void> {
const res = await fetch(`/api/${userType}`, {
const res = await fetch(`/api/user`, {
method: "DELETE",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id }),
});
if (!res.ok) throw new Error("删除用户失败");
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || "删除用户失败");
}
}

View 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>;
}

View File

@ -0,0 +1,5 @@
import ProtectedLayout from "../_components/ProtectedLayout";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <ProtectedLayout allowedRoles={["ADMIN"]}>{children}</ProtectedLayout>;
}

View File

@ -0,0 +1,5 @@
import { UserManagement } from "@/features/user-management"
export default function Page() {
return <UserManagement userType="admin" />
}

View 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>;
}

View File

@ -0,0 +1,5 @@
import { UserManagement } from "@/features/user-management"
export default function Page() {
return <UserManagement userType="problem" />
}

View 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>;
}

View File

@ -0,0 +1,5 @@
import { UserManagement } from "@/features/user-management"
export default function Page() {
return <UserManagement userType="student" />
}

View 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>;
}

View File

@ -0,0 +1,5 @@
import { UserManagement } from "@/features/user-management"
export default function Page() {
return <UserManagement userType="teacher" />
}

View File

@ -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"
}
]

View File

@ -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>
)
}

View File

@ -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 });
}

View 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 });
}
}

View File

@ -1,41 +1,189 @@
import prisma from "@/lib/prisma";
import { NextRequest, NextResponse } from "next/server";
import bcrypt from "bcryptjs";
import { auth } from "@/lib/auth";
// 获取所有管理员
// 获取所有用户
export async function GET() {
const users = await prisma.user.findMany();
return NextResponse.json(users);
try {
// 验证管理员权限
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
// 新建管理员
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
});
if (user?.role !== "ADMIN") {
return NextResponse.json({ error: "权限不足" }, { status: 403 });
}
const users = await prisma.user.findMany({
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
password: true, // 包含密码字段用于处理
}
});
// 在服务器端处理密码显示逻辑
const processedUsers = users.map(user => ({
...user,
password: user.password ? "******" : "(无)", // 服务器端处理密码显示
createdAt: user.createdAt instanceof Date ? user.createdAt.toLocaleString() : user.createdAt, // 服务器端处理日期格式
}));
return NextResponse.json(processedUsers);
} catch (error) {
console.error("获取用户列表失败:", error);
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
}
}
// 新建用户
export async function POST(req: NextRequest) {
const data = await req.json();
if (data.password) {
data.password = await bcrypt.hash(data.password, 10);
}
const user = await prisma.user.create({ data });
const { password, ...userWithoutPassword } = user;
return NextResponse.json(userWithoutPassword);
try {
// 验证管理员权限
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
});
if (user?.role !== "ADMIN") {
return NextResponse.json({ error: "权限不足" }, { status: 403 });
}
// 编辑管理员
export async function PUT(req: NextRequest) {
const data = await req.json();
// 如果提供了密码,进行加密
if (data.password) {
data.password = await bcrypt.hash(data.password, 10);
}
const user = await prisma.user.update({
const newUser = await prisma.user.create({
data,
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
password: true,
}
});
// 处理返回数据
const processedUser = {
...newUser,
password: newUser.password ? "******" : "(无)",
createdAt: newUser.createdAt instanceof Date ? newUser.createdAt.toLocaleString() : newUser.createdAt,
};
return NextResponse.json(processedUser);
} catch (error) {
console.error("创建用户失败:", error);
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
}
}
// 编辑用户
export async function PUT(req: NextRequest) {
try {
// 验证管理员权限
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
});
if (user?.role !== "ADMIN") {
return NextResponse.json({ error: "权限不足" }, { status: 403 });
}
const data = await req.json();
// 如果提供了密码且不为空,进行加密
if (data.password && data.password.trim() !== '') {
data.password = await bcrypt.hash(data.password, 10);
} else {
// 如果密码为空,则不更新密码字段
delete data.password;
}
const updatedUser = await prisma.user.update({
where: { id: data.id },
data,
select: {
id: true,
name: true,
email: true,
role: true,
createdAt: true,
updatedAt: true,
password: true,
}
});
const { password, ...userWithoutPassword } = user;
return NextResponse.json(userWithoutPassword);
// 处理返回数据
const processedUser = {
...updatedUser,
password: updatedUser.password ? "******" : "(无)",
createdAt: updatedUser.createdAt instanceof Date ? updatedUser.createdAt.toLocaleString() : updatedUser.createdAt,
};
return NextResponse.json(processedUser);
} catch (error) {
console.error("更新用户失败:", error);
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
}
}
// 删除管理员
// 删除用户
export async function DELETE(req: NextRequest) {
try {
// 验证管理员权限
const session = await auth();
if (!session?.user?.id) {
return NextResponse.json({ error: "未授权" }, { status: 401 });
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { role: true }
});
if (user?.role !== "ADMIN") {
return NextResponse.json({ error: "权限不足" }, { status: 403 });
}
const { id } = await req.json();
// 防止删除自己
if (id === session.user.id) {
return NextResponse.json({ error: "不能删除自己的账户" }, { status: 400 });
}
await prisma.user.delete({ where: { id } });
return NextResponse.json({ success: true });
} catch (error) {
console.error("删除用户失败:", error);
return NextResponse.json({ error: "服务器错误" }, { status: 500 });
}
}

View File

@ -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>
);
}

View File

@ -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

View File

@ -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}
/>
);
}

View File

@ -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>
);
}

View 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>
)
}

View 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,
},
}

View 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 },
};

View 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 },
};

View 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 },
};

View 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
View 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;
}