feat: add dashboard

This commit is contained in:
Asuka 2025-06-21 17:44:14 +08:00
parent dff0515dbb
commit 47feffd62c
63 changed files with 3522 additions and 608 deletions

8
.idea/.gitignore vendored
View File

@ -1,8 +0,0 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -1,6 +0,0 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>

View File

@ -1,9 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

View File

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/judge4c.iml" filepath="$PROJECT_DIR$/.idea/judge4c.iml" />
</modules>
</component>
</project>

View File

@ -1,6 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>

View File

@ -0,0 +1,67 @@
import { PrismaClient } from "@/generated/client";
const prisma = new PrismaClient();
async function checkProblemSubmissions() {
console.log("检查所有题目的提交记录情况...");
// 获取所有题目
const problems = await prisma.problem.findMany({
orderBy: { displayId: 'asc' }
});
console.log(`总题目数: ${problems.length}`);
for (const problem of problems) {
// 统计该题目的提交记录
const submissionCount = await prisma.submission.count({
where: { problemId: problem.id }
});
// 统计该题目的完成情况
const completedCount = await prisma.submission.count({
where: {
problemId: problem.id,
status: "AC"
}
});
// 查询时包含 localizations
const problems = await prisma.problem.findMany({
include: {
localizations: {
where: { type: "TITLE" },
select: { content: true }
}
}
});
problems.forEach(problem => {
const title = problem.localizations[0]?.content || "无标题";
console.log(`题目${problem.displayId} (${title}): ...`);
});
// 统计有提交记录的题目数量
const problemsWithSubmissions = await prisma.problem.findMany({
where: {
submissions: {
some: {}
}
}
});
console.log(`\n有提交记录的题目数量: ${problemsWithSubmissions.length}`);
console.log("有提交记录的题目编号:");
problemsWithSubmissions.forEach(p => {
console.log(` ${p.displayId}`);
});
}
checkProblemSubmissions()
.catch((e) => {
console.error("检查数据时出错:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
})};

View File

@ -0,0 +1,37 @@
import { PrismaClient, Status } from "@/generated/client";
const prisma = new PrismaClient();
async function fillTestcaseResults() {
const submissions = await prisma.submission.findMany();
let count = 0;
for (const submission of submissions) {
const testcases = await prisma.testcase.findMany({
where: { problemId: submission.problemId },
});
for (const testcase of testcases) {
// 检查是否已存在,避免重复
const exists = await prisma.testcaseResult.findFirst({
where: {
submissionId: submission.id,
testcaseId: testcase.id,
},
});
if (!exists) {
await prisma.testcaseResult.create({
data: {
isCorrect: submission.status === Status.AC,
output: submission.status === Status.AC ? "正确答案" : "错误答案",
timeUsage: Math.floor(Math.random() * 1000) + 1,
memoryUsage: Math.floor(Math.random() * 128) + 1,
submissionId: submission.id,
testcaseId: testcase.id,
},
});
count++;
}
}
}
console.log(`已为 ${count} 个提交生成测试用例结果`);
}
fillTestcaseResults().finally(() => prisma.$disconnect());

View File

@ -0,0 +1,118 @@
import { PrismaClient, Status, Language } from "@/generated/client";
const prisma = new PrismaClient();
async function generateUserData() {
console.log("为 student@example.com 生成测试数据...");
try {
// 查找用户
const user = await prisma.user.findUnique({
where: { email: "student@example.com" }
});
if (!user) {
console.log("用户不存在,创建用户...");
const newUser = await prisma.user.create({
data: {
name: "测试学生",
email: "student@example.com",
password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u",
role: "GUEST",
},
});
console.log("创建用户成功:", newUser);
return;
}
console.log("找到用户:", user.name || user.email);
// 获取所有已发布的题目
const problems = await prisma.problem.findMany({
where: { isPublished: true },
select: { id: true, displayId: true, localizations: {
where: { locale: "en", type: "TITLE" },
select: { content: true }
} }
});
console.log(`找到 ${problems.length} 道已发布题目`);
// 为这个用户生成提交记录
const submissionCount = Math.min(problems.length, 8); // 最多8道题目
const selectedProblems = problems.slice(0, submissionCount);
for (const problem of selectedProblems) {
// 为每道题目生成1-3次提交
const attempts = Math.floor(Math.random() * 3) + 1;
for (let i = 0; i < attempts; i++) {
// 60%概率AC40%概率WA
const isAC = Math.random() < 0.6 || i === attempts - 1; // 最后一次提交更可能是AC
const submission = await prisma.submission.create({
data: {
language: Math.random() > 0.5 ? Language.c : Language.cpp,
content: `// ${user.name || user.email} 针对题目${problem.displayId}的第${i + 1}次提交`,
status: isAC ? Status.AC : Status.WA,
message: isAC ? "Accepted" : "Wrong Answer",
timeUsage: Math.floor(Math.random() * 1000) + 1,
memoryUsage: Math.floor(Math.random() * 128) + 1,
userId: user.id,
problemId: problem.id,
},
});
// 获取题目的测试用例
const testcases = await prisma.testcase.findMany({
where: { problemId: problem.id }
});
// 为每个提交生成测试用例结果
for (const testcase of testcases) {
await prisma.testcaseResult.create({
data: {
isCorrect: isAC,
output: isAC ? "正确答案" : "错误答案",
timeUsage: Math.floor(Math.random() * 1000) + 1,
memoryUsage: Math.floor(Math.random() * 128) + 1,
submissionId: submission.id,
testcaseId: testcase.id,
},
});
}
console.log(`题目${problem.displayId}: 第${i + 1}次提交 - ${isAC ? 'AC' : 'WA'}`);
// 如果AC了就不再继续提交这道题
if (isAC) break;
}
}
console.log("数据生成完成!");
// 验证生成的数据
const userSubmissions = await prisma.submission.findMany({
where: { userId: user.id },
include: {
problem: { select: { displayId: true, localizations: {
where: { locale: "en", type: "TITLE" },
select: { content: true }
} } }
}
});
console.log(`\n用户 ${user.name || user.email} 现在有 ${userSubmissions.length} 条提交记录:`);
userSubmissions.forEach((s, index) => {
const title = s.problem.localizations.find(l => l.content === "TITLE")?.content || "无标题";
console.log(`${index + 1}. 题目${s.problem.displayId} (${title}) - ${s.status}`);
});
} catch (error) {
console.error("生成数据时出错:", error);
} finally {
await prisma.$disconnect();
}
}
generateUserData();

View File

@ -0,0 +1,2 @@
-- AlterEnum
ALTER TYPE "Role" ADD VALUE 'TEACHER';

View File

@ -11,6 +11,7 @@ generator client {
enum Role {
ADMIN
GUEST
TEACHER
}
enum Difficulty {

View File

@ -0,0 +1,144 @@
import { PrismaClient } from "@/generated/client";
const prisma = new PrismaClient();
async function testStudentDashboard() {
console.log("测试学生仪表板数据获取...");
// 获取一个学生用户
const student = await prisma.user.findFirst({
where: { role: "GUEST" },
select: { id: true, name: true, email: true }
});
if (!student) {
console.log("未找到学生用户,创建测试学生...");
const newStudent = await prisma.user.create({
data: {
name: "测试学生",
email: "test_student@example.com",
password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u",
role: "GUEST",
},
});
console.log(`创建学生: ${newStudent.name} (${newStudent.email})`);
}
// 获取所有已发布的题目
const allProblems = await prisma.problem.findMany({
where: { isPublished: true },
select: {
id: true,
displayId: true,
difficulty: true,
localizations: {
where: {
type: "TITLE",
locale: "en" // 或者根据需求使用其他语言
},
select: {
content: true
}
}
}
});
console.log(`总题目数: ${allProblems.length}`);
// 获取学生的所有提交记录
const userSubmissions = await prisma.submission.findMany({
where: { userId: student?.id },
include: {
problem: {
select: {
id: true,
displayId: true,
difficulty: true,
localizations: {
where: {
type: "TITLE",
locale: "en" // 或者根据需求使用其他语言
},
select: {
content: true
}
}
}
}
}
});
console.log(`学生提交记录数: ${userSubmissions.length}`);
// 计算题目完成情况
const completedProblems = new Set();
const attemptedProblems = new Set();
const wrongSubmissions = new Map(); // problemId -> count
userSubmissions.forEach(submission => {
attemptedProblems.add(submission.problemId);
if (submission.status === "AC") {
completedProblems.add(submission.problemId);
} else {
// 统计错误提交次数
const currentCount = wrongSubmissions.get(submission.problemId) || 0;
wrongSubmissions.set(submission.problemId, currentCount + 1);
}
});
// 题目完成比例数据
const completionData = {
total: allProblems.length,
completed: completedProblems.size,
percentage: allProblems.length > 0 ? Math.round((completedProblems.size / allProblems.length) * 100) : 0,
};
// 错题比例数据
const totalSubmissions = userSubmissions.length;
const wrongSubmissionsCount = userSubmissions.filter(s => s.status !== "AC").length;
const errorData = {
total: totalSubmissions,
wrong: wrongSubmissionsCount,
percentage: totalSubmissions > 0 ? Math.round((wrongSubmissionsCount / totalSubmissions) * 100) : 0,
};
//易错题列表(按错误次数排序)
const difficultProblems = Array.from(wrongSubmissions.entries())
.map(([problemId, errorCount]) => {
const problem = allProblems.find(p => p.id === problemId);
// 从 localizations 获取标题(英文优先)
const title = problem?.localizations?.find(l => l.content === "TITLE")?.content || "未知题目";
return {
id: problem?.displayId || problemId,
title: title, // 使用从 localizations 获取的标题
difficulty: problem?.difficulty || "未知",
errorCount: errorCount as number,
};
})
.sort((a, b) => b.errorCount - a.errorCount)
.slice(0, 10);
console.log("\n=== 学生仪表板数据 ===");
console.log(`题目完成情况: ${completionData.completed}/${completionData.total} (${completionData.percentage}%)`);
console.log(`提交正确率: ${errorData.total - errorData.wrong}/${errorData.total} (${100 - errorData.percentage}%)`);
console.log(`易错题数量: ${difficultProblems.length}`);
if (difficultProblems.length > 0) {
console.log("\n易错题列表:");
difficultProblems.forEach((problem, index) => {
console.log(`${index + 1}. 题目${problem.id} (${problem.title}) - ${problem.difficulty} - 错误${problem.errorCount}`);
});
}
console.log("\n测试完成");
}
testStudentDashboard()
.catch((e) => {
console.error("测试学生仪表板时出错:", e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -0,0 +1,79 @@
// import { PrismaClient } from "@/generated/client";
// const prisma = new PrismaClient();
// async function testStudentDataAccess() {
// console.log("测试学生数据访问...");
// try {
// // 1. 检查是否有学生用户
// const students = await prisma.user.findMany({
// where: { role: "GUEST" },
// select: { id: true, name: true, email: true }
// });
// console.log(`找到 ${students.length} 个学生用户:`);
// students.forEach(s => console.log(` - ${s.name} (${s.email})`));
// if (students.length === 0) {
// console.log("没有学生用户,创建测试学生...");
// const testStudent = await prisma.user.create({
// data: {
// name: "测试学生",
// email: "test_student@example.com",
// password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u",
// role: "GUEST",
// },
// });
// console.log(`创建学生: ${testStudent.name}`);
// }
// // 2. 检查已发布的题目
// const publishedProblems = await prisma.problem.findMany({
// where: { isPublished: true },
// select: { id: true,
// displayId: true,
// localizations: {
// where: { locale: "en", type: "TITLE" },
// select: { content: true }
// }
// }
// });
// console.log(`\n已发布题目数量: ${publishedProblems.length}`);
// publishedProblems.slice(0, 5).forEach((p: any) => {
// const title = p.localizations?.find((l: any) => l.type === "TITLE")?.content || "无标题";
// console.log(` - ${p.displayId}: ${title}`);
// });
// // 3. 检查提交记录
// const allSubmissions = await prisma.submission.findMany({
// select: { id: true, userId: true, problemId: true, status: true }
// });
// console.log(`\n总提交记录数: ${allSubmissions.length}`);
// // 4. 检查特定学生的提交记录
// const firstStudent = students[0] || await prisma.user.findFirst({ where: { role: "GUEST" } });
// if (firstStudent) {
// const studentSubmissions = await prisma.submission.findMany({
// where: { userId: firstStudent.id },
// include: {
// problem: { select: { displayId: true, localizations: {
// where: { locale: "en", type: "TITLE" },
// select: { content: true }
// } } }
// }
// });
// console.log(`\n学生 ${firstStudent.name} 的提交记录数: ${studentSubmissions.length}`);
// studentSubmissions.slice(0, 3).forEach(s => {
// console.log(` - 题目${s.problem.displayId}: ${s.status}`);
// });
// }
// console.log("\n数据访问测试完成");
// } catch (error) {
// console.error("测试过程中出错:", error);
// } finally {
// await prisma.$disconnect();
// }
// }
// testStudentDataAccess();

View File

@ -1,19 +0,0 @@
// getUserInfo.ts
"use server";
import prisma from "@/lib/prisma";
export async function getUserInfo() {
try {
const user = await prisma.user.findUnique({
where: { id: 'user_001' },
});
if (!user) throw new Error("用户不存在");
return user;
} catch (error) {
console.error("获取用户信息失败:", error);
throw new Error("获取用户信息失败");
}
}

View File

@ -1,90 +0,0 @@
"use client"
import React, { useState } from "react"
import { AppSidebar } from "@/components/management-sidebar/manage-sidebar"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
import { Separator } from "@/components/ui/separator"
import {
SidebarInset,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar"
import ProfilePage from "./profile/page"
import ChangePasswordPage from "./change-password/page"
// 模拟菜单数据
const menuItems = [
{ title: "登录信息", key: "profile" },
{ title: "修改密码", key: "change-password" },
]
export default function ManagementDefaultPage() {
const [activePage, setActivePage] = useState("profile")
const [isCollapsed, setIsCollapsed] = useState(false)
const renderContent = () => {
switch (activePage) {
case "profile":
return <ProfilePage />
case "change-password":
return <ChangePasswordPage />
default:
return <ProfilePage />
}
}
const toggleSidebar = () => {
setIsCollapsed((prev) => !prev)
}
return (
<SidebarProvider>
<div className="flex h-screen w-screen overflow-hidden">
{/* 左侧侧边栏 */}
{!isCollapsed && (
<div className="w-64 border-r bg-background flex-shrink-0 p-4">
<AppSidebar onItemClick={setActivePage} />
</div>
)}
{/* 右侧主内容区域 */}
<SidebarInset className="h-full w-full overflow-auto">
<header className="bg-background sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
{/* 折叠按钮 */}
<SidebarTrigger className="-ml-1" onClick={toggleSidebar} />
<Separator orientation="vertical" className="mr-2 h-4" />
{/* 面包屑导航 */}
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="/management"></BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>
{menuItems.find((item) => item.key === activePage)?.title}
</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
</header>
{/* 主体内容:根据 isCollapsed 切换样式 */}
<main
className={`flex-1 p-6 bg-background transition-all duration-300 ${
isCollapsed ? "w-full" : "md:w-[calc(100%-16rem)]"
}`}
>
{renderContent()}
</main>
</SidebarInset>
</div>
</SidebarProvider>
)
}

View File

@ -16,4 +16,4 @@ const Layout = async ({ children, params }: LayoutProps) => {
return <ProblemEditLayout>{children}</ProblemEditLayout>;
};
export default Layout;
export default Layout;

View File

@ -0,0 +1,17 @@
import { ProblemEditView } from "@/features/admin/ui/views/problem-edit-view";
// interface PageProps {
// params: Promise<{ problemId: string }>;
// }
const Page = async (
// { params }: PageProps
) => {
// const { problemId } = await params;
return <ProblemEditView
// problemId={problemId}
/>;
};
export default Page;

View File

@ -8,4 +8,4 @@ const Layout = ({ children }: LayoutProps) => {
return <AdminProtectedLayout>{children}</AdminProtectedLayout>;
};
export default Layout;
export default Layout;

View File

@ -1,13 +0,0 @@
import { ProblemEditView } from "@/features/admin/ui/views/problem-edit-view";
interface PageProps {
params: Promise<{ problemId: string }>;
}
const Page = async ({ params }: PageProps) => {
const { problemId } = await params;
return <ProblemEditView problemId={problemId} />;
};
export default Page;

View File

@ -0,0 +1,175 @@
"use server";
import prisma from "@/lib/prisma";
import { auth } from "@/lib/auth";
export async function getStudentDashboardData() {
try {
console.log("=== 开始获取学生仪表板数据 ===");
const session = await auth();
console.log("Session 获取成功:", !!session);
console.log("Session user:", session?.user);
if (!session?.user?.id) {
console.log("用户未登录或session无效");
throw new Error("未登录");
}
const userId = session.user.id;
console.log("当前用户ID:", userId);
// 检查用户是否存在
const currentUser = await prisma.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true, role: true }
});
console.log("当前用户信息:", currentUser);
if (!currentUser) {
throw new Error("用户不存在");
}
// 获取所有已发布的题目(包含英文标题)
const allProblems = await prisma.problem.findMany({
where: { isPublished: true },
select: {
id: true,
displayId: true,
difficulty: true,
localizations: {
where: {
type: "TITLE",
},
}
}
});
console.log("已发布题目数量:", allProblems.length);
console.log("题目列表:", allProblems.map(p => ({
id: p.id,
displayId: p.displayId,
title: p.localizations[0]?.content || "无标题",
difficulty: p.difficulty
})));
// 获取当前学生的所有提交记录(包含题目英文标题)
const userSubmissions = await prisma.submission.findMany({
where: { userId: userId },
include: {
problem: {
select: {
id: true,
displayId: true,
difficulty: true,
localizations: {
where: {
type: "TITLE",
locale: "en" // 或者根据需求使用其他语言
},
select: {
content: true
}
}
}
}
}
});
console.log("当前用户提交记录数量:", userSubmissions.length);
console.log("提交记录详情:", userSubmissions.map(s => ({
problemId: s.problemId,
problemDisplayId: s.problem.displayId,
title: s.problem.localizations[0]?.content || "无标题",
difficulty: s.problem.difficulty,
status: s.status
})));
// 计算题目完成情况
const completedProblems = new Set<string | number>();
const attemptedProblems = new Set<string | number>();
const wrongSubmissions = new Map<string | number, number>(); // problemId -> count
userSubmissions.forEach((submission) => {
attemptedProblems.add(submission.problemId);
if (submission.status === "AC") {
completedProblems.add(submission.problemId);
} else {
// 统计错误提交次数
const currentCount = wrongSubmissions.get(submission.problemId) || 0;
wrongSubmissions.set(submission.problemId, currentCount + 1);
}
});
console.log("尝试过的题目数:", attemptedProblems.size);
console.log("完成的题目数:", completedProblems.size);
console.log("错误提交统计:", Object.fromEntries(wrongSubmissions));
// 题目完成比例数据
const completionData = {
total: allProblems.length,
completed: completedProblems.size,
percentage: allProblems.length > 0 ? Math.round((completedProblems.size / allProblems.length) * 100) : 0,
};
// 错题比例数据 - 基于已完成的题目计算
const wrongProblems = new Set<string | number>();
// 统计在已完成的题目中,哪些题目曾经有过错误提交
userSubmissions.forEach((submission) => {
if (submission.status !== "AC" && completedProblems.has(submission.problemId)) {
wrongProblems.add(submission.problemId);
}
});
const errorData = {
total: completedProblems.size, // 已完成的题目总数
wrong: wrongProblems.size, // 在已完成的题目中有过错误的题目数
percentage: completedProblems.size > 0 ? Math.round((wrongProblems.size / completedProblems.size) * 100) : 0,
};
// 易错题列表(按错误次数排序)
const difficultProblems = Array.from(wrongSubmissions.entries())
.map(([problemId, errorCount]) => {
const problem = allProblems.find((p) => p.id === problemId);
// 从 problem.localizations 中获取标题
const title = problem?.localizations?.find((loc) => loc.type === "TITLE")?.content || "未知题目";
return {
id: problem?.displayId || problemId,
title: title, // 使用从 localizations 获取的标题
difficulty: problem?.difficulty || "未知",
errorCount: errorCount as number,
};
})
.sort((a, b) => b.errorCount - a.errorCount)
.slice(0, 10); // 只显示前10个
const result = {
completionData,
errorData,
difficultProblems,
pieChartData: [
{ name: "已完成", value: completionData.completed },
{ name: "未完成", value: completionData.total - completionData.completed },
],
errorPieChartData: [
{ name: "正确", value: errorData.total - errorData.wrong },
{ name: "错误", value: errorData.wrong },
],
};
console.log("=== 返回的数据 ===");
console.log("完成情况:", completionData);
console.log("错误情况:", errorData);
console.log("易错题数量:", difficultProblems.length);
console.log("=== 数据获取完成 ===");
return result;
} catch (error) {
console.error("获取学生仪表板数据失败:", error);
throw new Error(`获取数据失败: ${error instanceof Error ? error.message : '未知错误'}`);
}
}

View File

@ -0,0 +1,206 @@
"use server";
import prisma from "@/lib/prisma";
import { Locale, Status, ProblemLocalization } from "@/generated/client";
import { getLocale } from "next-intl/server";
const getLocalizedTitle = (
localizations: ProblemLocalization[],
locale: Locale
) => {
if (!localizations || localizations.length === 0) {
return "Unknown Title";
}
const localization = localizations.find(
(localization) => localization.locale === locale
);
return localization?.content ?? localizations[0].content ?? "Unknown Title";
};
export interface ProblemCompletionData {
problemId: string;
problemDisplayId: number;
problemTitle: string;
completed: number;
uncompleted: number;
total: number;
completedPercent: number;
uncompletedPercent: number;
}
export interface DifficultProblemData {
id: string;
className: string;
problemCount: number;
problemTitle: string;
problemDisplayId: number;
}
export async function getProblemCompletionData(): Promise<ProblemCompletionData[]> {
// 获取所有提交记录,按题目分组统计
const submissions = await prisma.submission.findMany({
include: {
user: true,
problem: {
include:{
localizations:true
}
}
},
});
const locale = await getLocale();
// 按题目分组统计完成情况(统计独立用户数)
const problemStats = new Map<string, {
completedUsers: Set<string>;
totalUsers: Set<string>;
title: string;
displayId: number;
}>();
submissions.forEach((submission) => {
const localizations=submission.problem.localizations;
const title=getLocalizedTitle(localizations,locale as Locale);
const problemId = submission.problemId;
const problemTitle = title;
const problemDisplayId = submission.problem.displayId;
const userId = submission.userId;
const isCompleted = submission.status === Status.AC; // 只有 Accepted 才算完成
if (!problemStats.has(problemId)) {
problemStats.set(problemId, {
completedUsers: new Set(),
totalUsers: new Set(),
title: problemTitle,
displayId: problemDisplayId,
});
}
const stats = problemStats.get(problemId)!;
stats.totalUsers.add(userId);
if (isCompleted) {
stats.completedUsers.add(userId);
}
});
// 如果没有数据,返回空数组
if (problemStats.size === 0) {
return [];
}
// 转换为图表数据格式按题目displayId排序
const problemDataArray = Array.from(problemStats.entries()).map(([problemId, stats]) => {
const completed = stats.completedUsers.size;
const total = stats.totalUsers.size;
return {
problemId: problemId,
problemDisplayId: stats.displayId,
problemTitle: stats.title,
completed: completed,
uncompleted: total - completed,
total: total,
completedPercent: total > 0 ? (completed / total) * 100 : 0,
uncompletedPercent: total > 0 ? ((total - completed) / total) * 100 : 0,
};
});
// 按题目编号排序
return problemDataArray.sort((a, b) => a.problemDisplayId - b.problemDisplayId);
}
export async function getDifficultProblemsData(): Promise<DifficultProblemData[]> {
// 获取所有测试用例结果
const testcaseResults = await prisma.testcaseResult.findMany({
include: {
testcase: {
include: {
problem: {
include: {
localizations: true
}
},
},
},
submission: {
include: {
user: true,
},
},
},
});
// 按问题分组统计错误率
const problemStats = new Map<string, {
totalAttempts: number;
wrongAttempts: number;
title: string;
displayId: number;
users: Set<string>;
}>();
testcaseResults.forEach((result) => {
const problemId = result.testcase.problemId;
const problemTitle = result.testcase.problem.localizations?.find(
(loc) => loc.type === "TITLE"
)?.content || "无标题";
const problemDisplayId = result.testcase.problem.displayId;
const userId = result.submission.userId;
const isWrong = !result.isCorrect;
if (!problemStats.has(problemId)) {
problemStats.set(problemId, {
totalAttempts: 0,
wrongAttempts: 0,
title: problemTitle,
displayId: problemDisplayId,
users: new Set(),
});
}
const stats = problemStats.get(problemId)!;
stats.totalAttempts++;
stats.users.add(userId);
if (isWrong) {
stats.wrongAttempts++;
}
});
// 计算错误率并筛选易错题(错误率 > 30% 且至少有3次尝试
const difficultProblems = Array.from(problemStats.entries())
.map(([problemId, stats]) => ({
id: problemId,
className: `题目 ${stats.title}`,
problemCount: stats.wrongAttempts,
problemTitle: stats.title,
problemDisplayId: stats.displayId,
errorRate: (stats.wrongAttempts / stats.totalAttempts) * 100,
uniqueUsers: stats.users.size,
totalAttempts: stats.totalAttempts,
}))
.filter((problem) =>
problem.errorRate > 30 && // 错误率超过30%
problem.totalAttempts >= 3 // 至少有3次尝试
)
.sort((a, b) => b.errorRate - a.errorRate) // 按错误率降序排列
.slice(0, 10); // 取前10个最难的题目
return difficultProblems;
}
export async function getDashboardStats() {
const [problemData, difficultProblems] = await Promise.all([
getProblemCompletionData(),
getDifficultProblemsData(),
]);
return {
problemData,
difficultProblems,
totalProblems: problemData.length,
totalDifficultProblems: difficultProblems.length,
};
}

View File

@ -0,0 +1,211 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
import { useEffect, useState } from "react";
import { getStudentDashboardData } from "@/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard";
interface DashboardData {
completionData: {
total: number;
completed: number;
percentage: number;
};
errorData: {
total: number;
wrong: number;
percentage: number;
};
difficultProblems: Array<{
id: string | number;
title: string;
difficulty: string;
errorCount: number;
}>;
pieChartData: Array<{ name: string; value: number }>;
errorPieChartData: Array<{ name: string; value: number }>;
}
export default function StudentDashboard() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
async function fetchData() {
try {
console.log("开始获取学生仪表板数据...");
setLoading(true);
const dashboardData = await getStudentDashboardData();
console.log("获取到的数据:", dashboardData);
console.log("完成情况:", dashboardData.completionData);
console.log("错误情况:", dashboardData.errorData);
console.log("易错题:", dashboardData.difficultProblems);
setData(dashboardData);
} catch (err) {
console.error("获取数据时出错:", err);
setError(err instanceof Error ? err.message : "获取数据失败");
} finally {
setLoading(false);
}
}
fetchData();
}, []);
if (loading) {
return (
<div className="container mx-auto p-6">
<div className="text-center">...</div>
</div>
);
}
if (error) {
return (
<div className="container mx-auto p-6">
<div className="text-center text-red-500">: {error}</div>
</div>
);
}
if (!data) {
return (
<div className="container mx-auto p-6">
<div className="text-center"></div>
</div>
);
}
const { completionData, errorData, difficultProblems, pieChartData, errorPieChartData } = data;
const COLORS = ["#4CAF50", "#FFC107"];
return (
<div className="container mx-auto p-6 space-y-6">
<h1 className="text-3xl font-bold mb-6"></h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 题目完成比例模块 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span>{completionData.completed}/{completionData.total}</span>
<span className="text-green-500">{completionData.percentage}%</span>
</div>
<Progress value={completionData.percentage} className="h-2" />
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={pieChartData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
fill="#8884d8"
paddingAngle={5}
dataKey="value"
>
{pieChartData.map((entry: { name: string; value: number }, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
</div>
</CardContent>
</Card>
{/* 错题比例模块 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span>{errorData.wrong}/{errorData.total}</span>
<span className="text-yellow-500">{errorData.percentage}%</span>
</div>
<Progress value={errorData.percentage} className="h-2" />
<div className="h-[200px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={errorPieChartData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
fill="#8884d8"
paddingAngle={5}
dataKey="value"
>
{errorPieChartData.map((entry: { name: string; value: number }, index: number) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
</PieChart>
</ResponsiveContainer>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 易错题练习模块 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span>{difficultProblems.length}</span>
</div>
{difficultProblems.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead>ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{difficultProblems.map((problem: { id: string | number; title: string; difficulty: string; errorCount: number }) => (
<TableRow key={problem.id}>
<TableCell>{problem.id}</TableCell>
<TableCell>{problem.title}</TableCell>
<TableCell>{problem.difficulty}</TableCell>
<TableCell>{problem.errorCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="text-center text-gray-500 py-8">
</div>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,253 @@
"use client";
import { TrendingUp } from "lucide-react";
import { Bar, BarChart, XAxis, YAxis, LabelList, CartesianGrid } from "recharts";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import {
ChartConfig,
ChartContainer,
ChartTooltip,
ChartTooltipContent,
} from "@/components/ui/chart";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { getDashboardStats, ProblemCompletionData, DifficultProblemData } from "@/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard";
const ITEMS_PER_PAGE = 5; // 每页显示的题目数量
const chartConfig = {
completed: {
label: "已完成",
color: "#4CAF50", // 使用更鲜明的颜色
},
uncompleted: {
label: "未完成",
color: "#FFA726", // 使用更鲜明的颜色
},
} satisfies ChartConfig;
export default function TeacherDashboard() {
const [currentPage, setCurrentPage] = useState(1);
const [chartData, setChartData] = useState<ProblemCompletionData[]>([]);
const [difficultProblems, setDifficultProblems] = useState<DifficultProblemData[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const data = await getDashboardStats();
setChartData(data.problemData);
setDifficultProblems(data.difficultProblems);
} catch (err) {
setError(err instanceof Error ? err.message : '获取数据失败');
console.error('Failed to fetch dashboard data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const totalPages = Math.ceil(chartData.length / ITEMS_PER_PAGE);
// 获取当前页的数据
const currentPageData = chartData.slice(
(currentPage - 1) * ITEMS_PER_PAGE,
currentPage * ITEMS_PER_PAGE
);
if (loading) {
return (
<div className="container mx-auto p-6 space-y-6">
<h1 className="text-3xl font-bold mb-6"></h1>
<div className="flex items-center justify-center h-64">
<div className="text-lg">...</div>
</div>
</div>
);
}
if (error) {
return (
<div className="container mx-auto p-6 space-y-6">
<h1 className="text-3xl font-bold mb-6"></h1>
<div className="flex items-center justify-center h-64">
<div className="text-lg text-red-500">: {error}</div>
</div>
</div>
);
}
return (
<div className="container mx-auto p-6 space-y-6">
<h1 className="text-3xl font-bold mb-6"></h1>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 题目完成情况模块 */}
<Card className="min-h-[450px]">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{chartData.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-lg text-muted-foreground"></div>
</div>
) : (
<>
<ChartContainer config={chartConfig} className="height-{400px}">
<BarChart
data={currentPageData}
layout="vertical"
margin={{
top: 20,
right: 30,
left: 40,
bottom: 5,
}}
barCategoryGap={20}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis
type="number"
domain={[0, 100]}
tickFormatter={(value) => `${value}%`}
/>
<YAxis
dataKey="problemDisplayId"
type="category"
tickLine={false}
tickMargin={10}
width={80}
/>
<ChartTooltip
cursor={false}
content={<ChartTooltipContent />}
/>
<Bar
dataKey="completedPercent"
name="已完成"
fill={chartConfig.completed.color}
radius={[4, 4, 0, 0]}
>
<LabelList
dataKey="completed"
position="right"
fill="#000"
formatter={(value: number) => `${value}`}
/>
</Bar>
<Bar
dataKey="uncompletedPercent"
name="未完成"
fill={chartConfig.uncompleted.color}
radius={[4, 4, 0, 0]}
>
<LabelList
dataKey="uncompleted"
position="right"
fill="#000"
formatter={(value: number) => `${value}`}
/>
</Bar>
</BarChart>
</ChartContainer>
{/* 分页控制 */}
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-4">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm">
{currentPage} {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</>
)}
</CardContent>
<CardFooter className="flex-col items-start gap-2 text-sm">
<div className="flex gap-2 leading-none font-medium">
<TrendingUp className="h-4 w-4" />
</div>
<div className="text-muted-foreground leading-none">
/
</div>
</CardFooter>
</Card>
{/* 学生易错题模块 */}
<Card className="min-h-[450px]">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex justify-between items-center">
<span>{difficultProblems.length}</span>
</div>
{difficultProblems.length === 0 ? (
<div className="flex items-center justify-center h-64">
<div className="text-lg text-muted-foreground"></div>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{difficultProblems.map((problem) => (
<TableRow key={problem.id}>
<TableCell>{problem.problemDisplayId || problem.id.substring(0, 8)}</TableCell>
<TableCell>{problem.problemTitle}</TableCell>
<TableCell>{problem.problemCount}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@ -0,0 +1,89 @@
"use client";
import { useState, useEffect } from "react";
import { getDashboardStats } from "@/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard";
interface DashboardData {
problemData: Array<{
problemId: string;
problemDisplayId: number;
problemTitle: string;
completed: number;
uncompleted: number;
total: number;
completedPercent: number;
uncompletedPercent: number;
}>;
difficultProblems: Array<{
id: string;
className: string;
problemCount: number;
problemTitle: string;
problemDisplayId: number;
}>;
totalProblems: number;
totalDifficultProblems: number;
}
export default function TestDataPage() {
const [data, setData] = useState<DashboardData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
try {
setLoading(true);
const result = await getDashboardStats();
setData(result);
} catch (err) {
setError(err instanceof Error ? err.message : '获取数据失败');
console.error('Failed to fetch data:', err);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
if (loading) {
return <div>...</div>;
}
if (error) {
return <div>: {error}</div>;
}
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4"></h1>
<div className="space-y-6">
<div>
<h2 className="text-xl font-semibold mb-2"></h2>
<pre className="bg-gray-100 p-4 rounded overflow-auto">
{JSON.stringify(data?.problemData, null, 2)}
</pre>
</div>
<div>
<h2 className="text-xl font-semibold mb-2"></h2>
<pre className="bg-gray-100 p-4 rounded overflow-auto">
{JSON.stringify(data?.difficultProblems, null, 2)}
</pre>
</div>
<div>
<h2 className="text-xl font-semibold mb-2"></h2>
<pre className="bg-gray-100 p-4 rounded overflow-auto">
{JSON.stringify({
totalProblems: data?.totalProblems,
totalDifficultProblems: data?.totalDifficultProblems,
}, null, 2)}
</pre>
</div>
</div>
</div>
);
}

View File

@ -1,12 +1,7 @@
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb";
import { AdminSidebar } from "@/components/sidebar/admin-sidebar";
import { TeacherSidebar } from "@/components/sidebar/teacher-sidebar";
import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb";
import { Separator } from "@/components/ui/separator";
import {
SidebarInset,
@ -14,39 +9,106 @@ import {
SidebarTrigger,
} from "@/components/ui/sidebar";
import { auth } from "@/lib/auth";
import { notFound } from "next/navigation";
import prisma from "@/lib/prisma";
import { redirect } from "next/navigation";
interface LayoutProps {
children: React.ReactNode;
}
interface WrongProblem {
id: string;
name: string;
status: string;
url?: string;
}
export default async function Layout({ children }: LayoutProps) {
const session = await auth();
const user = session?.user;
if (!user) {
notFound();
redirect("/sign-in");
}
// 获取用户的完整信息(包括角色)
const fullUser = await prisma.user.findUnique({
where: { id: user.id },
select: { id: true, name: true, email: true, image: true, role: true }
});
if (!fullUser) {
redirect("/sign-in");
}
// 根据用户角色决定显示哪个侧边栏
const renderSidebar = () => {
switch (fullUser.role) {
case "ADMIN":
return <AdminSidebar user={user} />;
case "TEACHER":
return <TeacherSidebar user={user} />;
case "GUEST":
default:
// 学生GUEST需要查询错题数据
return <AppSidebar user={user} wrongProblems={[]} />;
}
};
// 只有学生才需要查询错题数据
let wrongProblemsData: WrongProblem[] = [];
if (fullUser.role === "GUEST") {
// 查询未完成未AC题目的最新一次提交
const wrongProblems = await prisma.problem.findMany({
where: {
submissions: {
some: { userId: user.id },
},
NOT: {
submissions: {
some: { userId: user.id, status: "AC" },
},
},
},
select: {
id: true,
displayId: true,
localizations: {
where: { locale: "zh", type: "TITLE" },
select: { content: true },
},
submissions: {
where: { userId: user.id },
orderBy: { createdAt: "desc" },
take: 1,
select: {
status: true,
},
},
},
});
// 组装传递给 AppSidebar 的数据格式
wrongProblemsData = wrongProblems.map((p) => ({
id: p.id,
name: p.localizations[0]?.content || `题目${p.displayId}`,
status: p.submissions[0]?.status || "-",
url: `/problems/${p.id}`,
}));
}
return (
<SidebarProvider>
<AppSidebar user={user} />
{fullUser.role === "GUEST" ? (
<AppSidebar user={user} wrongProblems={wrongProblemsData} />
) : (
renderSidebar()
)}
<SidebarInset>
<header className="flex h-16 shrink-0 items-center gap-2">
<div className="flex items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-2 h-4" />
<Breadcrumb>
<BreadcrumbList>
<BreadcrumbItem className="hidden md:block">
<BreadcrumbLink href="#">
Building Your Application
</BreadcrumbLink>
</BreadcrumbItem>
<BreadcrumbSeparator className="hidden md:block" />
<BreadcrumbItem>
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
</BreadcrumbItem>
</BreadcrumbList>
</Breadcrumb>
<DynamicBreadcrumb />
</div>
</header>
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>

View File

@ -1,7 +1,8 @@
// changePassword.ts
"use server";
import prisma from "@/lib/prisma";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function changePassword(formData: FormData) {
@ -13,24 +14,40 @@ export async function changePassword(formData: FormData) {
}
try {
// 获取当前登录用户
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
throw new Error("用户未登录");
}
// 查询当前用户信息
const user = await prisma.user.findUnique({
where: { id: '1' },
where: { id: userId },
});
if (!user) throw new Error("用户不存在");
if (!user) {
throw new Error("用户不存在");
}
if (!user.password) {
throw new Error("用户密码未设置");
}
// 验证旧密码
const passwordHash: string = user.password as string;
const isMatch = await bcrypt.compare(oldPassword, passwordHash);
if (!isMatch) throw new Error("旧密码错误");
if (!isMatch) {
throw new Error("旧密码错误");
}
// 加密新密码
const hashedPassword = await bcrypt.hash(newPassword, 10);
// 更新密码
await prisma.user.update({
where: { id: '1' },
where: { id: userId },
data: { password: hashedPassword },
});

View File

@ -0,0 +1,40 @@
// getUserInfo.ts
"use server";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
export async function getUserInfo() {
try {
// 获取当前会话
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
throw new Error("用户未登录");
}
// 根据当前用户ID获取用户信息
const user = await prisma.user.findUnique({
where: { id: userId },
select: {
id: true,
name: true,
email: true,
role: true,
image: true,
createdAt: true,
updatedAt: true,
},
});
if (!user) {
throw new Error("用户不存在");
}
return user;
} catch (error) {
console.error("获取用户信息失败:", error);
throw new Error("获取用户信息失败");
}
}

View File

@ -1,7 +1,8 @@
// updateUserInfo.ts
"use server";
import prisma from "@/lib/prisma";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
export async function updateUserInfo(formData: FormData) {
const name = formData.get("name") as string;
@ -12,8 +13,16 @@ export async function updateUserInfo(formData: FormData) {
}
try {
// 获取当前会话
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
throw new Error("用户未登录");
}
const updatedUser = await prisma.user.update({
where: { id: 'user_001' },
where: { id: userId },
data: { name, email },
});

View File

@ -2,7 +2,7 @@
"use client";
import { useState } from "react";
import { changePassword } from "@/app/(app)/management/actions";
import { changePassword } from "@/app/(protected)/dashboard/management/actions/changePassword";
export default function ChangePasswordPage() {
const [oldPassword, setOldPassword] = useState("");
@ -50,8 +50,9 @@ export default function ChangePasswordPage() {
await changePassword(formData);
setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 3000);
} catch (error: any) {
alert(error.message);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '修改密码失败';
alert(errorMessage);
}
};

View File

@ -0,0 +1,58 @@
"use client"
import React, { useState } from "react"
import { Separator } from "@/components/ui/separator"
import ProfilePage from "./profile/page"
import ChangePasswordPage from "./change-password/page"
export default function ManagementDefaultPage() {
const [activePage, setActivePage] = useState("profile")
const renderContent = () => {
switch (activePage) {
case "profile":
return <ProfilePage />
case "change-password":
return <ChangePasswordPage />
default:
return <ProfilePage />
}
}
return (
<div className="flex h-full w-full flex-col">
{/* 顶部导航栏 */}
<header className="bg-background sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
<Separator orientation="vertical" className="mr-2 h-4" />
{/* 页面切换按钮 */}
<div className="ml-auto flex space-x-2">
<button
onClick={() => setActivePage("profile")}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
activePage === "profile"
? "bg-black text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
</button>
<button
onClick={() => setActivePage("change-password")}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
activePage === "change-password"
? "bg-black text-white"
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
}`}
>
</button>
</div>
</header>
{/* 主体内容 */}
<main className="flex-1 overflow-auto">
{renderContent()}
</main>
</div>
)
}

View File

@ -2,17 +2,18 @@
"use client";
import { useEffect, useState } from "react";
import { getUserInfo, updateUserInfo } from "@/app/(app)/management/actions";
import { getUserInfo } from "@/app/(protected)/dashboard/management/actions/getUserInfo";
import { updateUserInfo } from "@/app/(protected)/dashboard/management/actions/updateUserInfo";
interface User {
id: string; // TEXT 类型
name: string | null; // 可能为空
email: string; // NOT NULL
emailVerified: Date | null; // TIMESTAMP 转换为字符串
id: string;
name: string | null;
email: string;
emailVerified?: Date | null;
image: string | null;
role: "GUEST" | "USER" | "ADMIN"; // 枚举类型
createdAt: Date; // TIMESTAMP 转换为字符串
updatedAt: Date; // TIMESTAMP 转换为字符串
role: "GUEST" | "USER" | "ADMIN" | "TEACHER";
createdAt: Date;
updatedAt: Date;
}
export default function ProfilePage() {
@ -49,8 +50,9 @@ export default function ProfilePage() {
const updatedUser = await updateUserInfo(formData);
setUser(updatedUser);
setIsEditing(false);
} catch (error: any) {
alert(error.message);
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '更新用户信息失败';
alert(errorMessage);
}
};

View File

@ -1,3 +1,305 @@
export default function Page() {
return <div className="h-full w-full border bg-blue-200">Dashboard</div>
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import {
Users,
BookOpen,
CheckCircle,
Clock,
TrendingUp,
AlertCircle,
BarChart3,
Target,
Activity
} from "lucide-react";
import Link from "next/link";
interface Stats {
totalUsers?: number;
totalProblems?: number;
totalSubmissions?: number;
totalStudents?: number;
completedProblems?: number;
}
interface Activity {
type: string;
title: string;
description: string;
time: Date;
status?: string;
}
export default async function DashboardPage() {
const session = await auth();
const user = session?.user;
if (!user) {
redirect("/sign-in");
}
// 获取用户的完整信息
const fullUser = await prisma.user.findUnique({
where: { id: user.id },
select: { id: true, name: true, email: true, image: true, role: true }
});
if (!fullUser) {
redirect("/sign-in");
}
// 根据用户角色获取不同的统计数据
let stats: Stats = {};
let recentActivity: Activity[] = [];
if (fullUser.role === "ADMIN") {
// 管理员统计
const [totalUsers, totalProblems, totalSubmissions, recentUsers] = await Promise.all([
prisma.user.count(),
prisma.problem.count(),
prisma.submission.count(),
prisma.user.findMany({
take: 5,
orderBy: { createdAt: "desc" },
select: { id: true, name: true, email: true, role: true, createdAt: true }
})
]);
stats = { totalUsers, totalProblems, totalSubmissions };
recentActivity = recentUsers.map(user => ({
type: "新用户注册",
title: user.name || user.email,
description: `角色: ${user.role}`,
time: user.createdAt
}));
} else if (fullUser.role === "TEACHER") {
// 教师统计
const [totalStudents, totalProblems, totalSubmissions, recentSubmissions] = await Promise.all([
prisma.user.count({ where: { role: "GUEST" } }),
prisma.problem.count({ where: { isPublished: true } }),
prisma.submission.count(),
prisma.submission.findMany({
take: 5,
orderBy: { createdAt: "desc" },
include: {
user: { select: { name: true, email: true } },
problem: {
select: {
displayId: true,
localizations: { where: { type: "TITLE", locale: "zh" }, select: { content: true } }
}
}
}
})
]);
stats = { totalStudents, totalProblems, totalSubmissions };
recentActivity = recentSubmissions.map(sub => ({
type: "学生提交",
title: `${sub.user.name || sub.user.email} 提交了题目 ${sub.problem.displayId}`,
description: sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`,
time: sub.createdAt,
status: sub.status
}));
} else {
// 学生统计
const [totalProblems, completedProblems, totalSubmissions, recentSubmissions] = await Promise.all([
prisma.problem.count({ where: { isPublished: true } }),
prisma.submission.count({
where: {
userId: user.id,
status: "AC"
}
}),
prisma.submission.count({ where: { userId: user.id } }),
prisma.submission.findMany({
where: { userId: user.id },
take: 5,
orderBy: { createdAt: "desc" },
include: {
problem: {
select: {
displayId: true,
localizations: { where: { type: "TITLE", locale: "zh" }, select: { content: true } }
}
}
}
})
]);
stats = { totalProblems, completedProblems, totalSubmissions };
recentActivity = recentSubmissions.map(sub => ({
type: "我的提交",
title: `题目 ${sub.problem.displayId}`,
description: sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`,
time: sub.createdAt,
status: sub.status
}));
}
const getRoleConfig = () => {
switch (fullUser.role) {
case "ADMIN":
return {
title: "系统管理后台",
description: "管理整个系统的用户、题目和统计数据",
stats: [
{ label: "总用户数", value: stats.totalUsers, icon: Users, color: "text-blue-600" },
{ label: "总题目数", value: stats.totalProblems, icon: BookOpen, color: "text-green-600" },
{ label: "总提交数", value: stats.totalSubmissions, icon: Activity, color: "text-purple-600" }
],
actions: [
{ label: "用户管理", href: "/dashboard/usermanagement/guest", icon: Users },
{ label: "题目管理", href: "/dashboard/usermanagement/problem", icon: BookOpen },
{ label: "管理员设置", href: "/dashboard/management", icon: Target }
]
};
case "TEACHER":
return {
title: "教师教学平台",
description: "查看学生学习情况,管理教学资源",
stats: [
{ label: "学生数量", value: stats.totalStudents, icon: Users, color: "text-blue-600" },
{ label: "题目数量", value: stats.totalProblems, icon: BookOpen, color: "text-green-600" },
{ label: "提交数量", value: stats.totalSubmissions, icon: Activity, color: "text-purple-600" }
],
actions: [
{ label: "学生管理", href: "/dashboard/usermanagement/guest", icon: Users },
{ label: "题目管理", href: "/dashboard/usermanagement/problem", icon: BookOpen },
{ label: "统计分析", href: "/dashboard/teacher/dashboard", icon: BarChart3 }
]
};
default:
return {
title: "我的学习中心",
description: "继续您的编程学习之旅",
stats: [
{ label: "总题目数", value: stats.totalProblems, icon: BookOpen, color: "text-blue-600" },
{ label: "已完成", value: stats.completedProblems, icon: CheckCircle, color: "text-green-600" },
{ label: "提交次数", value: stats.totalSubmissions, icon: Activity, color: "text-purple-600" }
],
actions: [
{ label: "开始做题", href: "/problemset", icon: BookOpen },
{ label: "我的进度", href: "/dashboard/student/dashboard", icon: TrendingUp },
{ label: "个人设置", href: "/dashboard/management", icon: Target }
]
};
}
};
const config = getRoleConfig();
const completionRate = fullUser.role === "GUEST" ?
((stats.totalProblems || 0) > 0 ? ((stats.completedProblems || 0) / (stats.totalProblems || 1)) * 100 : 0) : 0;
return (
<div className="space-y-6 p-6">
{/* 欢迎区域 */}
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">{config.title}</h1>
<p className="text-muted-foreground">{config.description}</p>
<div className="flex items-center gap-2">
<Badge variant="secondary">{fullUser.role}</Badge>
<span className="text-sm text-muted-foreground">
{fullUser.name || fullUser.email}
</span>
</div>
</div>
{/* 统计卡片 */}
<div className="grid gap-4 md:grid-cols-3">
{config.stats.map((stat, index) => (
<Card key={index}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.label}</CardTitle>
<stat.icon className={`h-4 w-4 ${stat.color}`} />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
</CardContent>
</Card>
))}
</div>
{/* 学生进度条 */}
{fullUser.role === "GUEST" && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Target className="h-5 w-5" />
</CardTitle>
<CardDescription>
{stats.completedProblems || 0} / {stats.totalProblems || 0}
</CardDescription>
</CardHeader>
<CardContent>
<Progress value={completionRate} className="w-full" />
<p className="mt-2 text-sm text-muted-foreground">
: {completionRate.toFixed(1)}%
</p>
</CardContent>
</Card>
)}
{/* 快速操作 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>访</CardDescription>
</CardHeader>
<CardContent>
<div className="grid gap-4 md:grid-cols-3">
{config.actions.map((action, index) => (
<Link key={index} href={action.href}>
<Button variant="outline" className="w-full justify-start">
<action.icon className="mr-2 h-4 w-4" />
{action.label}
</Button>
</Link>
))}
</div>
</CardContent>
</Card>
{/* 最近活动 */}
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentActivity.length > 0 ? (
recentActivity.map((activity, index) => (
<div key={index} className="flex items-center space-x-4">
<div className="flex-shrink-0">
{activity.status === "AC" ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : activity.status ? (
<AlertCircle className="h-5 w-5 text-yellow-500" />
) : (
<Clock className="h-5 w-5 text-gray-500" />
)}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium">{activity.title}</p>
<p className="text-sm text-muted-foreground">{activity.description}</p>
</div>
<div className="text-sm text-muted-foreground">
{new Date(activity.time).toLocaleDateString()}
</div>
</div>
))
) : (
<p className="text-sm text-muted-foreground"></p>
)}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,14 @@
'use server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import type { Problem } from '@/generated/client'
export async function createProblem(data: Omit<Problem, 'id'|'createdAt'|'updatedAt'>) {
await prisma.problem.create({ data })
revalidatePath('/usermanagement/problem')
}
export async function deleteProblem(id: string) {
await prisma.problem.delete({ where: { id } })
revalidatePath('/usermanagement/problem')
}

View File

@ -0,0 +1,46 @@
'use server'
import prisma from '@/lib/prisma'
import { revalidatePath } from 'next/cache'
import bcrypt from 'bcryptjs'
import type { User } from '@/generated/client'
import { Role } from '@/generated/client'
type UserType = 'admin' | 'teacher' | 'guest'
export async function createUser(
userType: UserType,
data: Omit<User, 'id'|'createdAt'|'updatedAt'> & { password?: string }
) {
let password = data.password
if (password) {
password = await bcrypt.hash(password, 10)
}
const role = userType.toUpperCase() as Role
await prisma.user.create({ data: { ...data, password, role } })
revalidatePath(`/usermanagement/${userType}`)
}
export async function updateUser(
userType: UserType,
id: string,
data: Partial<Omit<User, 'id'|'createdAt'|'updatedAt'>>
) {
const updateData = { ...data }
// 如果包含密码字段且不为空,则进行加密
if (data.password && data.password.trim() !== '') {
updateData.password = await bcrypt.hash(data.password, 10)
} else {
// 如果密码为空,则从更新数据中移除密码字段,保持原密码不变
delete updateData.password
}
await prisma.user.update({ where: { id }, data: updateData })
revalidatePath(`/usermanagement/${userType}`)
}
export async function deleteUser(userType: UserType, id: string) {
await prisma.user.delete({ where: { id } })
revalidatePath(`/usermanagement/${userType}`)
}

View File

@ -0,0 +1,10 @@
import ProtectedLayout from "./ProtectedLayout";
interface GenericLayoutProps {
children: React.ReactNode;
allowedRoles: string[];
}
export default function GenericLayout({ children, allowedRoles }: GenericLayoutProps) {
return <ProtectedLayout allowedRoles={allowedRoles}>{children}</ProtectedLayout>;
}

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 GenericLayout from "../_components/GenericLayout";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
return <GenericLayout allowedRoles={["ADMIN"]}>{children}</GenericLayout>;
}

View File

@ -0,0 +1,6 @@
import GenericPage from '@/features/user-management/components/generic-page'
import { adminConfig } from '@/features/user-management/config/admin'
export default function AdminPage() {
return <GenericPage userType="admin" config={adminConfig} />
}

View File

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

View File

@ -0,0 +1,6 @@
import GenericPage from '@/features/user-management/components/generic-page'
import { guestConfig } from '@/features/user-management/config/guest'
export default function GuestPage() {
return <GenericPage userType="guest" config={guestConfig} />
}

View File

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

View File

@ -0,0 +1,6 @@
import GenericPage from '@/features/user-management/components/generic-page'
import { problemConfig } from '@/features/user-management/config/problem'
export default function ProblemPage() {
return <GenericPage userType="problem" config={problemConfig} />
}

View File

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

View File

@ -0,0 +1,6 @@
import GenericPage from '@/features/user-management/components/generic-page'
import { teacherConfig } from '@/features/user-management/config/teacher'
export default function TeacherPage() {
return <GenericPage userType="teacher" config={teacherConfig} />
}

View File

@ -8,11 +8,25 @@ import {
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Check, X, Info, AlertTriangle } from "lucide-react"
import { Check, X, Info, AlertTriangle, Copy, Check as CheckIcon } from "lucide-react"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import Link from "next/link"
export function WrongbookDialog({ problems, children }: { problems: { id: string; name: string; status: string }[]; children?: React.ReactNode }) {
export function WrongbookDialog({ problems, children }: { problems: { id: string; name: string; status: string; url?: string }[]; children?: React.ReactNode }) {
const [copiedId, setCopiedId] = React.useState<string | null>(null)
const handleCopyLink = async (item: { id: string; url?: string }) => {
const link = `${window.location.origin}/problems/${item.id}`
try {
await navigator.clipboard.writeText(link)
setCopiedId(item.id)
setTimeout(() => setCopiedId(null), 2000) // 2秒后重置状态
} catch (err) {
console.error('Failed to copy link:', err)
}
}
return (
<Dialog>
<DialogTrigger asChild>
@ -29,7 +43,7 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string
<table className="min-w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="px-3 py-2 text-left font-semibold">ID</th>
<th className="px-3 py-2 text-left font-semibold"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
<th className="px-3 py-2 text-left font-semibold"></th>
</tr>
@ -37,9 +51,22 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string
<tbody>
{problems.map((item) => (
<tr key={item.id} className="border-b last:border-0 hover:bg-muted/30 transition">
<td className="px-3 py-2 text-gray-500 font-mono">{item.id}</td>
<td className="px-3 py-2">
<Link href={`/problem/${item.id}`} className="text-primary underline underline-offset-2 hover:text-primary/80">
<Button
variant="ghost"
size="sm"
onClick={() => handleCopyLink(item)}
className="h-8 w-8 p-0"
>
{copiedId === item.id ? (
<CheckIcon className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</td>
<td className="px-3 py-2">
<Link href={item.url || `/problems/${item.id}`} className="text-primary underline underline-offset-2 hover:text-primary/80">
{item.name}
</Link>
</td>

View File

@ -0,0 +1,106 @@
"use client"
import { usePathname } from "next/navigation"
import {
Breadcrumb,
BreadcrumbItem,
BreadcrumbLink,
BreadcrumbList,
BreadcrumbPage,
BreadcrumbSeparator,
} from "@/components/ui/breadcrumb"
interface BreadcrumbItem {
label: string
href?: string
}
export function DynamicBreadcrumb() {
const pathname = usePathname()
const generateBreadcrumbs = (): BreadcrumbItem[] => {
const segments = pathname.split('/').filter(Boolean)
const breadcrumbs: BreadcrumbItem[] = []
// 添加首页
breadcrumbs.push({ label: "首页", href: "/" })
let currentPath = ""
segments.forEach((segment, index) => {
currentPath += `/${segment}`
// 根据路径段生成标签
let label = segment
// 路径映射
const pathMap: Record<string, string> = {
'dashboard': '仪表板',
'management': '管理面板',
'profile': '用户信息',
'change-password': '修改密码',
'problems': '题目',
'problemset': '题目集',
'admin': '管理后台',
'teacher': '教师平台',
'student': '学生平台',
'usermanagement': '用户管理',
'userdashboard': '用户仪表板',
'protected': '受保护',
'app': '应用',
'auth': '认证',
'sign-in': '登录',
'sign-up': '注册',
}
// 如果是数字可能是题目ID显示为"题目详情"
if (/^\d+$/.test(segment)) {
label = "详情"
} else if (pathMap[segment]) {
label = pathMap[segment]
} else {
// 将 kebab-case 转换为中文
label = segment
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
// 最后一个项目不添加链接
if (index === segments.length - 1) {
breadcrumbs.push({ label })
} else {
breadcrumbs.push({ label, href: currentPath })
}
})
return breadcrumbs
}
const breadcrumbs = generateBreadcrumbs()
return (
<Breadcrumb>
<BreadcrumbList>
{breadcrumbs.map((item, index) => (
<div key={index} className="flex items-center">
<BreadcrumbItem className="hidden md:block">
{item.href ? (
<BreadcrumbLink href={item.href}>
{item.label}
</BreadcrumbLink>
) : (
<BreadcrumbPage>
{item.label}
</BreadcrumbPage>
)}
</BreadcrumbItem>
{index < breadcrumbs.length - 1 && (
<BreadcrumbSeparator className="hidden md:block" />
)}
</div>
))}
</BreadcrumbList>
</Breadcrumb>
)
}

View File

@ -1,28 +0,0 @@
import { Search } from "lucide-react"
import { Label } from "@/components/ui/label"
import {
SidebarGroup,
SidebarGroupContent,
SidebarInput,
} from "@/components/ui/sidebar"
export function SearchForm({ ...props }: React.ComponentProps<"form">) {
return (
<form {...props}>
<SidebarGroup className="py-0">
<SidebarGroupContent className="relative">
<Label htmlFor="search" className="sr-only">
Search
</Label>
<SidebarInput
id="search"
placeholder="Search the docs..."
className="pl-8"
/>
<Search className="pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-50" />
</SidebarGroupContent>
</SidebarGroup>
</form>
)
}

View File

@ -1,97 +0,0 @@
import * as React from "react";
import { ChevronRight } from "lucide-react";
import { VersionSwitcher } from "@/components//management-sidebar/manage-switcher";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Sidebar,
SidebarContent,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail,
} from "@/components/ui/sidebar";
// 自定义数据:包含用户相关菜单项
const data = {
versions: ["1.0.1", "1.1.0-alpha", "2.0.0-beta1"],
navUser: [
{
title: "个人中心",
url: "#",
items: [
{ title: "登录信息", url: "#", key: "profile" },
{ title: "修改密码", url: "#", key: "change-password" },
],
},
],
};
// 显式定义 props 类型
interface AppSidebarProps {
onItemClick?: (key: string) => void;
}
export function AppSidebar({ onItemClick = (key: string) => {}, ...props }: AppSidebarProps) {
return (
<Sidebar {...props}>
<SidebarHeader>
<VersionSwitcher
versions={data.versions}
defaultVersion={data.versions[0]}
/>
</SidebarHeader>
<SidebarContent className="gap-0">
{/* 渲染用户相关的侧边栏菜单 */}
{data.navUser.map((item) => (
<Collapsible
key={item.title}
title={item.title}
defaultOpen
className="group/collapsible"
>
<SidebarGroup>
<SidebarGroupLabel
asChild
className="group/label text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
>
<CollapsibleTrigger>
{item.title}
<ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{item.items.map((subItem) => (
<SidebarMenuItem key={subItem.title}>
<SidebarMenuButton
asChild
onClick={(e) => {
e.preventDefault();
onItemClick(subItem.key);
}}
>
<a href="#">{subItem.title}</a>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
))}
</SidebarContent>
<SidebarRail />
</Sidebar>
);
}

View File

@ -1,64 +0,0 @@
"use client"
import * as React from "react"
import { Check, ChevronsUpDown, GalleryVerticalEnd } from "lucide-react"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
export function VersionSwitcher({
versions,
defaultVersion,
}: {
versions: string[]
defaultVersion: string
}) {
const [selectedVersion, setSelectedVersion] = React.useState(defaultVersion)
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
<GalleryVerticalEnd className="size-4" />
</div>
<div className="flex flex-col gap-0.5 leading-none">
<span className="font-semibold">Documentation</span>
<span className="">v{selectedVersion}</span>
</div>
<ChevronsUpDown className="ml-auto" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-[--radix-dropdown-menu-trigger-width]"
align="start"
>
{versions.map((version) => (
<DropdownMenuItem
key={version}
onSelect={() => setSelectedVersion(version)}
>
v{version}{" "}
{version === selectedVersion && <Check className="ml-auto" />}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
}

View File

@ -5,13 +5,13 @@ import {
Folder,
MoreHorizontal,
Share,
Trash2,
Check,
X,
Info,
AlertTriangle,
} from "lucide-react"
import React, { useState } from "react"
import { useRouter } from "next/navigation"
import {
Dialog,
} from "@/components/ui/dialog"
@ -20,7 +20,6 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import {
@ -42,11 +41,13 @@ export function NavProjects({
id: string
name: string
status: string
url?: string
}[]
}) {
const { isMobile } = useSidebar()
const [shareOpen, setShareOpen] = useState(false)
const [shareLink, setShareLink] = useState("")
const router = useRouter()
return (
<>
@ -56,9 +57,9 @@ export function NavProjects({
{projects.slice(0, 1).map((item) => (
<SidebarMenuItem key={item.id}>
<SidebarMenuButton asChild>
<a href={`/problem/${item.id}`}>
<a href={item.url}>
<BookX />
<span className="flex w-full items-center">
<span className="flex w-full items-center">
<span
className="truncate max-w-[120px] flex-1"
title={item.name}
@ -111,25 +112,29 @@ export function NavProjects({
side={isMobile ? "bottom" : "right"}
align={isMobile ? "end" : "start"}
>
<DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
if (item.url) {
router.push(item.url)
} else {
router.push(`/problems/${item.id}`)
}
}}
>
<Folder className="text-muted-foreground" />
<span></span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation()
setShareLink(`${window.location.origin}/problem/${item.id}`)
setShareLink(`${window.location.origin}/problems/${item.id}`)
setShareOpen(true)
}}
>
<Share className="text-muted-foreground mr-2" />
<span></span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<Trash2 className="text-muted-foreground" />
<span></span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>

View File

@ -2,13 +2,14 @@
import {
BadgeCheck,
Bell,
// Bell,
ChevronsUpDown,
UserPen,
LogOut,
Sparkles,
// Sparkles,
} from "lucide-react"
import { useRouter } from "next/navigation"
import { signOut } from "next-auth/react"
import {
Avatar,
@ -44,13 +45,15 @@ export function NavUser({
const router = useRouter()
async function handleLogout() {
await fetch("/api/auth/signout", { method: "POST" });
router.replace("/sign-in");
await signOut({
callbackUrl: "/sign-in",
redirect: true
});
}
function handleAccount() {
if (user && user.email) {
router.replace("/user/profile");
router.replace("/dashboard/management");
} else {
router.replace("/sign-in");
}
@ -95,13 +98,13 @@ export function NavUser({
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{/* <DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Update
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
</DropdownMenuGroup> */}
{/* <DropdownMenuSeparator /> */}
<DropdownMenuGroup>
<DropdownMenuItem onClick={handleAccount}>
<BadgeCheck />
@ -111,10 +114,10 @@ export function NavUser({
<UserPen />
Switch User
</DropdownMenuItem>
<DropdownMenuItem >
{/* <DropdownMenuItem >
<Bell />
Notifications
</DropdownMenuItem>
</DropdownMenuItem> */}
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>

View File

@ -8,7 +8,6 @@ import {
} from "lucide-react"
import { NavMain } from "@/components/nav-main"
import { NavProjects } from "@/components/nav-projects"
import { NavSecondary } from "@/components/nav-secondary"
import { NavUser } from "@/components/nav-user"
import {
@ -20,8 +19,7 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { useEffect, useState } from "react"
import { User } from "next-auth"
const adminData = {
navMain: [
@ -31,10 +29,10 @@ const adminData = {
icon: Shield,
isActive: true,
items: [
{ title: "管理员管理", url: "/usermanagement/admin" },
{ title: "用户管理", url: "/usermanagement/guest" },
{ title: "教师管理", url: "/usermanagement/teacher" },
{ title: "题目管理", url: "/usermanagement/problem" },
{ title: "管理员管理", url: "/dashboard/usermanagement/admin" },
{ title: "用户管理", url: "/dashboard/usermanagement/guest" },
{ title: "教师管理", url: "/dashboard/usermanagement/teacher" },
{ title: "题目管理", url: "/dashboard/usermanagement/problem" },
],
},
@ -43,38 +41,18 @@ const adminData = {
{ title: "帮助", url: "/", icon: LifeBuoy },
{ title: "反馈", url: siteConfig.url.repo.github, icon: Send },
],
wrongProblems: [],
}
async function fetchCurrentUser() {
try {
const res = await fetch("/api/auth/session");
if (!res.ok) return null;
const session = await res.json();
return {
name: session?.user?.name ?? "未登录管理员",
email: session?.user?.email ?? "",
avatar: session?.user?.image ?? "/avatars/default.jpg",
};
} catch {
return {
name: "未登录管理员",
email: "",
avatar: "/avatars/default.jpg",
};
}
interface AdminSidebarProps {
user: User;
}
export function AdminSidebar(props: React.ComponentProps<typeof Sidebar>) {
const [user, setUser] = useState({
name: "未登录管理员",
email: "",
avatar: "/avatars/default.jpg",
});
useEffect(() => {
fetchCurrentUser().then(u => u && setUser(u));
}, []);
export function AdminSidebar({ user, ...props }: AdminSidebarProps & React.ComponentProps<typeof Sidebar>) {
const userInfo = {
name: user.name ?? "管理员",
email: user.email ?? "",
avatar: user.image ?? "/avatars/default.jpg",
};
return (
<Sidebar {...props}>
@ -97,11 +75,10 @@ export function AdminSidebar(props: React.ComponentProps<typeof Sidebar>) {
</SidebarHeader>
<SidebarContent>
<NavMain items={adminData.navMain} />
<NavProjects projects={adminData.wrongProblems} />
<NavSecondary items={adminData.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser user={user} />
<NavUser user={userInfo} />
</SidebarFooter>
</Sidebar>
)

View File

@ -3,11 +3,11 @@
import { siteConfig } from "@/config/site";
import * as React from "react";
import {
BookOpen,
// BookOpen,
Command,
LifeBuoy,
Send,
Settings2,
// Settings2,
SquareTerminal,
} from "lucide-react";
@ -26,9 +26,6 @@ import {
} from "@/components/ui/sidebar";
import { User } from "next-auth";
// import { useEffect, useState } from "react"
// import { auth, signIn } from "@/lib/auth"
const data = {
navMain: [
{
@ -39,11 +36,7 @@ const data = {
items: [
{
title: "主页",
url: "/student/dashboard",
},
{
title: "历史记录",
url: "#",
url: "/dashboard/student/dashboard",
},
{
title: "题目集",
@ -52,36 +45,36 @@ const data = {
],
},
{
title: "已完成事项",
url: "#",
icon: BookOpen,
items: [
{
title: "全部编程集",
url: "#",
},
{
title: "错题集",
url: "#",
},
{
title: "收藏集",
url: "#",
},
],
},
{
title: "设置",
url: "#",
icon: Settings2,
items: [
{
title: "语言",
url: "#",
},
],
},
// {
// title: "已完成事项",
// url: "#",
// icon: BookOpen,
// items: [
// {
// title: "全部编程集",
// url: "#",
// },
// {
// title: "错题集",
// url: "#",
// },
// {
// title: "收藏集",
// url: "#",
// },
// ],
// },
// {
// title: "设置",
// url: "#",
// icon: Settings2,
// items: [
// {
// title: "语言",
// url: "#",
// },
// ],
// },
],
navSecondary: [
{
@ -95,59 +88,18 @@ const data = {
icon: Send,
},
],
wrongProblems: [
{
id: "abc123",
name: "Two Sum",
status: "WA",
},
{
id: "def456",
name: "Reverse Linked List",
status: "RE",
},
{
id: "ghi789",
name: "Binary Tree Paths",
status: "TLE",
},
],
};
// // 获取当前登录用户信息的 API
// async function fetchCurrentUser() {
// try {
// const res = await fetch("/api/auth/session");
// if (!res.ok) return null;
// const session = await res.json();
// return {
// name: session?.user?.name ?? "未登录用户",
// email: session?.user?.email ?? "",
// avatar: session?.user?.image ?? "/avatars/default.jpg",
// };
// } catch {
// return {
// name: "未登录用户",
// email: "",
// avatar: "/avatars/default.jpg",
// };
// }
// }
interface AppSidebarProps{
user:User
interface AppSidebarProps {
user: User;
wrongProblems: {
id: string;
name: string;
status: string;
}[];
}
export function AppSidebar({ user, ...props }: AppSidebarProps) {
// const [user, setUser] = useState({
// name: "未登录用户",
// email: "",
// avatar: "/avatars/default.jpg",
// });
// useEffect(() => {
// fetchCurrentUser().then(u => u && setUser(u));
// }, []);
export function AppSidebar({ user, wrongProblems, ...props }: AppSidebarProps) {
const userInfo = {
name: user.name ?? "",
email: user.email ?? "",
@ -175,7 +127,7 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
<NavProjects projects={data.wrongProblems} />
<NavProjects projects={wrongProblems} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>

View File

@ -6,7 +6,7 @@ import {
LifeBuoy,
PieChart,
Send,
Settings2,
// Settings2,
SquareTerminal,
} from "lucide-react"
@ -22,60 +22,52 @@ import {
SidebarMenuButton,
SidebarMenuItem,
} from "@/components/ui/sidebar"
import { User } from "next-auth"
const data = {
user: {
name: "teacher",
email: "teacher@example.com",
avatar: "/avatars/teacher.jpg",
},
navMain: [
{
title: "教师首页",
url: "/teacher/dashboard",
title: "教师管理",
url: "#",
icon: SquareTerminal,
isActive: true,
items: [
{
title: "学生管理",
url: "/teacher/students",
title: "用户管理",
url: "/dashboard/usermanagement/guest",
},
{
title: "题库管理",
url: "/teacher/problems",
url: "/dashboard/usermanagement/problem",
},
],
},
{
title: "统计分析",
url: "/teacher/statistics",
url: "#",
icon: PieChart,
items: [
{
title: "完成情况",
url: "/teacher/statistics/grades",
},
{
title: "错题统计",
url: "/teacher/statistics/activity",
},
],
},
{
title: "设置",
url: "#",
icon: Settings2,
items: [
{
title: "一般设置",
url: "/teacher/profile",
},
{
title: "语言",
url: "/teacher/settings",
url: "/dashboard/teacher/dashboard",
},
// {
// title: "错题统计",
// url: "/dashboard/teacher/dashboard",
// },
],
},
// {
// title: "设置",
// url: "#",
// icon: Settings2,
// items: [
// {
// title: "语言",
// url: "#",
// },
// ],
// },
],
navSecondary: [
{
@ -91,7 +83,17 @@ const data = {
],
}
export function TeacherSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
interface TeacherSidebarProps {
user: User;
}
export function TeacherSidebar({ user, ...props }: TeacherSidebarProps & React.ComponentProps<typeof Sidebar>) {
const userInfo = {
name: user.name ?? "",
email: user.email ?? "",
avatar: user.image ?? "/avatars/teacher.jpg",
};
return (
<Sidebar variant="inset" {...props}>
<SidebarHeader>
@ -113,11 +115,10 @@ export function TeacherSidebar({ ...props }: React.ComponentProps<typeof Sidebar
</SidebarHeader>
<SidebarContent>
<NavMain items={data.navMain} />
{/* 教师端可自定义更多内容 */}
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter>
<NavUser user={data.user} />
<NavUser user={userInfo} />
</SidebarFooter>
</Sidebar>
)

View File

@ -1,26 +1,28 @@
import EditCodePanel from "@/components/creater/edit-code-panel";
import EditDetailPanel from "@/components/creater/edit-detail-panel";
import EditSolutionPanel from "@/components/creater/edit-solution-panel";
import EditTestcasePanel from "@/components/creater/edit-testcase-panel";
import EditDescriptionPanel from "@/components/creater/edit-description-panel";
import { ProblemEditFlexLayout } from "@/features/admin/ui/components/problem-edit-flexlayout";
// import EditCodePanel from "@/components/creater/edit-code-panel";
// import EditDetailPanel from "@/components/creater/edit-detail-panel";
// import EditSolutionPanel from "@/components/creater/edit-solution-panel";
// import EditTestcasePanel from "@/components/creater/edit-testcase-panel";
// import EditDescriptionPanel from "@/components/creater/edit-description-panel";
// import { ProblemEditFlexLayout } from "@/features/admin/ui/components/problem-edit-flexlayout";
interface ProblemEditViewProps {
problemId: string;
}
// interface ProblemEditViewProps {
// problemId: string;
// }
export const ProblemEditView = ({ problemId }: ProblemEditViewProps) => {
const components: Record<string, React.ReactNode> = {
detail: <EditDetailPanel problemId={problemId} />,
description: <EditDescriptionPanel problemId={problemId} />,
solution: <EditSolutionPanel problemId={problemId} />,
code: <EditCodePanel problemId={problemId} />,
testcase: <EditTestcasePanel problemId={problemId} />,
};
export const ProblemEditView = (
// { problemId }: ProblemEditViewProps
) => {
// const components: Record<string, React.ReactNode> = {
// description: <EditDescriptionPanel problemId={problemId} />,
// solution: <EditSolutionPanel problemId={problemId} />,
// detail: <EditDetailPanel problemId={problemId} />,
// code: <EditCodePanel problemId={problemId} />,
// testcase: <EditTestcasePanel problemId={problemId} />,
// };
return (
<div className="relative flex h-full w-full">
<ProblemEditFlexLayout components={components} />
{/* <ProblemEditFlexLayout components={components} /> */}
</div>
);
};

View File

@ -0,0 +1,21 @@
import { UserTable } from './user-table'
import { UserConfig } from './user-table'
import prisma from '@/lib/prisma'
import type { User, Problem } from '@/generated/client'
import { Role } from '@/generated/client'
interface GenericPageProps {
userType: 'admin' | 'teacher' | 'guest' | 'problem'
config: UserConfig
}
export default async function GenericPage({ userType, config }: GenericPageProps) {
if (userType === 'problem') {
const data: Problem[] = await prisma.problem.findMany({})
return <UserTable config={config} data={data} />
} else {
const role = userType.toUpperCase() as Role
const data: User[] = await prisma.user.findMany({ where: { role } })
return <UserTable config={config} data={data} />
}
}

View File

@ -0,0 +1,954 @@
"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 { useRouter } from "next/navigation"
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 { createUser, updateUser, deleteUser } from '@/app/(protected)/dashboard/usermanagement/_actions/userActions'
import { createProblem, deleteProblem } from '@/app/(protected)/dashboard/usermanagement/_actions/problemActions'
import type { User, Problem } from '@/generated/client'
import { Difficulty, Role } from '@/generated/client'
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
options?: Array<{ value: string; label: string }>
}>
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
}
}
type UserTableProps =
| { config: UserConfig; data: User[] }
| { config: UserConfig; data: Problem[] }
type UserForm = {
id?: string
name: string
email: string
password: string
createdAt: string
role: Role
image: string | null
emailVerified: Date | null
}
// 新增用户表单类型
type AddUserForm = Omit<UserForm, 'id'>
const addUserSchema = z.object({
name: z.string(),
email: z.string().email(),
password: z.string().min(1, "密码不能为空").min(8, "密码长度至少8位"),
createdAt: z.string(),
image: z.string().nullable(),
emailVerified: z.date().nullable(),
role: z.nativeEnum(Role),
})
const editUserSchema = z.object({
id: z.string().default(''),
name: z.string(),
email: z.string().email(),
password: z.string(),
createdAt: z.string(),
image: z.string().nullable(),
emailVerified: z.date().nullable(),
role: z.nativeEnum(Role),
})
// 题目表单 schema 兼容 null/undefined
const addProblemSchema = z.object({
displayId: z.number().optional().default(0),
difficulty: z.nativeEnum(Difficulty).default(Difficulty.EASY),
})
export function UserTable(props: UserTableProps) {
const isProblem = props.config.userType === 'problem'
const router = useRouter()
const problemData = isProblem ? (props.data as Problem[]) : undefined
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
const [editingUser, setEditingUser] = useState<User | Problem | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deleteBatch, setDeleteBatch] = useState(false)
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: props.config.pagination.defaultPageSize,
})
const [pageInput, setPageInput] = useState(pagination.pageIndex + 1)
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
const [pendingDeleteItem, setPendingDeleteItem] = useState<User | Problem | null>(null)
useEffect(() => {
setPageInput(pagination.pageIndex + 1)
}, [pagination.pageIndex])
// 表格列
const tableColumns = React.useMemo<ColumnDef<User | Problem>[]>(() => {
const columns: ColumnDef<User | Problem>[] = [
{
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,
},
]
props.config.columns.forEach((col) => {
const column: ColumnDef<User | Problem> = {
accessorKey: col.key,
header: col.label,
cell: ({ row }) => {
// 类型安全分流
if (col.key === 'displayId' && isProblem) {
return (row.original as Problem).displayId
}
if ((col.key === 'createdAt' || col.key === 'updatedAt')) {
const value = row.getValue(col.key)
if (value instanceof Date) {
return value.toLocaleString()
}
if (typeof value === 'string' && !isNaN(Date.parse(value))) {
return new Date(value).toLocaleString()
}
}
return row.getValue(col.key)
},
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 item = row.original
return (
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
className="h-8 gap-1"
onClick={() => {
if (isProblem) {
// 如果是problem类型跳转到编辑路由使用displayId
const problem = item as Problem
router.push(`/admin/problems/${problem.displayId}/edit`)
} else {
// 如果是用户类型,打开编辑弹窗
setEditingUser(item)
setIsEditDialogOpen(true)
}
}}
>
<PencilIcon className="size-4 mr-1" /> {props.config.actions.edit.label}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 gap-1 text-destructive hover:text-destructive"
onClick={() => {
setPendingDeleteItem(item)
setDeleteConfirmOpen(true)
}}
aria-label="Delete"
>
<TrashIcon className="size-4 mr-1" /> {props.config.actions.delete.label}
</Button>
</div>
)
},
})
return columns
}, [props.config, router, isProblem])
const table = useReactTable({
data: props.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(),
})
// 添加用户对话框组件(仅用户)
function AddUserDialogUser({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
const [isLoading, setIsLoading] = useState(false)
const form = useForm<AddUserForm>({
resolver: zodResolver(addUserSchema),
defaultValues: { name: '', email: '', password: '', createdAt: '', image: null, emailVerified: null, role: Role.GUEST },
})
React.useEffect(() => {
if (open) {
form.reset({ name: '', email: '', password: '', createdAt: '', image: null, emailVerified: null, role: Role.GUEST })
}
}, [open, form])
async function onSubmit(data: AddUserForm) {
try {
setIsLoading(true)
// 验证必填字段
if (!data.password || data.password.trim() === '') {
toast.error('密码不能为空', { duration: 1500 })
return
}
const submitData = {
...data,
image: data.image ?? null,
emailVerified: data.emailVerified ?? null,
role: data.role ?? Role.GUEST,
}
if (!submitData.name) submitData.name = ''
if (!submitData.createdAt) submitData.createdAt = new Date().toISOString()
else submitData.createdAt = new Date(submitData.createdAt).toISOString()
if (props.config.userType === 'admin') await createUser('admin', submitData)
else if (props.config.userType === 'teacher') await createUser('teacher', submitData)
else if (props.config.userType === 'guest') await createUser('guest', submitData)
onOpenChange(false)
toast.success('添加成功', { duration: 1500 })
router.refresh()
} catch (error) {
console.error('添加失败:', error)
toast.error('添加失败', { duration: 1500 })
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{props.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">
{props.config.formFields.filter(field => field.key !== 'id').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>
{field.type === 'select' && field.options ? (
<Select
value={form.watch(field.key as 'name' | 'email' | 'password' | 'createdAt' | 'role') ?? ''}
onValueChange={value => form.setValue(field.key as 'name' | 'email' | 'password' | 'createdAt' | 'role', value)}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder={`请选择${field.label}`} />
</SelectTrigger>
<SelectContent>
{field.options.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
) : (
<Input
id={field.key}
type={field.type}
{...form.register(field.key as 'name' | 'email' | 'password' | 'createdAt' | 'role')}
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<Partial<Problem>>({
resolver: zodResolver(addProblemSchema),
defaultValues: { displayId: 0, difficulty: Difficulty.EASY },
})
React.useEffect(() => {
if (open) {
form.reset({ displayId: 0, difficulty: Difficulty.EASY })
}
}, [open, form])
async function onSubmit(formData: Partial<Problem>) {
try {
setIsLoading(true)
const submitData: Partial<Problem> = { ...formData, displayId: Number(formData.displayId) }
await createProblem({
displayId: Number(submitData.displayId),
difficulty: submitData.difficulty ?? Difficulty.EASY,
isPublished: false,
isTrim: false,
timeLimit: 1000,
memoryLimit: 134217728,
userId: null,
})
onOpenChange(false)
toast.success('添加成功', { duration: 1500 })
router.refresh()
} catch (error) {
console.error('添加失败:', error)
toast.error('添加失败', { duration: 1500 })
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{props.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">
{props.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>
{field.key === 'difficulty' ? (
<Select
value={form.watch('difficulty') ?? Difficulty.EASY}
onValueChange={value => form.setValue('difficulty', value as typeof Difficulty.EASY | typeof Difficulty.MEDIUM | typeof Difficulty.HARD)}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="请选择难度" />
</SelectTrigger>
<SelectContent>
<SelectItem value={Difficulty.EASY}></SelectItem>
<SelectItem value={Difficulty.MEDIUM}></SelectItem>
<SelectItem value={Difficulty.HARD}></SelectItem>
</SelectContent>
</Select>
) : (
<Input
id={field.key}
type={field.type}
{...form.register(field.key as 'displayId' | 'difficulty' | 'id', 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: User }) {
const [isLoading, setIsLoading] = useState(false)
const editForm = useForm<UserForm>({
resolver: zodResolver(editUserSchema),
defaultValues: {
id: typeof user.id === 'string' ? user.id : '',
name: user.name ?? '',
email: user.email ?? '',
password: '',
role: user.role ?? Role.GUEST,
createdAt: user.createdAt ? new Date(user.createdAt).toISOString().slice(0, 16) : '',
image: user.image ?? null,
emailVerified: user.emailVerified ?? null,
},
})
React.useEffect(() => {
if (open) {
editForm.reset({
id: typeof user.id === 'string' ? user.id : '',
name: user.name ?? '',
email: user.email ?? '',
password: '',
role: user.role ?? Role.GUEST,
createdAt: user.createdAt ? new Date(user.createdAt).toISOString().slice(0, 16) : '',
image: user.image ?? null,
emailVerified: user.emailVerified ?? null,
})
}
}, [open, user, editForm])
async function onSubmit(data: UserForm) {
try {
setIsLoading(true)
const submitData = {
...data,
createdAt: data.createdAt ? new Date(data.createdAt).toISOString() : new Date().toISOString(),
image: data.image ?? null,
emailVerified: data.emailVerified ?? null,
role: data.role ?? Role.GUEST,
}
const id = typeof submitData.id === 'string' ? submitData.id : ''
if (props.config.userType === 'admin') await updateUser('admin', id, submitData)
else if (props.config.userType === 'teacher') await updateUser('teacher', id, submitData)
else if (props.config.userType === 'guest') await updateUser('guest', id, submitData)
onOpenChange(false)
toast.success('修改成功', { duration: 1500 })
} catch {
toast.error('修改失败', { duration: 1500 })
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle>{props.config.actions.edit.label}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<form onSubmit={editForm.handleSubmit(onSubmit)} className="space-y-4">
<div className="grid gap-4 py-4">
{props.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}
{...editForm.register(field.key as 'name' | 'email' | 'password' | 'createdAt' | 'role')}
className="col-span-3"
placeholder={field.placeholder}
disabled={field.key === 'id'}
/>
{editForm.formState.errors[field.key as keyof typeof editForm.formState.errors]?.message && (
<p className="col-span-3 col-start-2 text-sm text-red-500">
{editForm.formState.errors[field.key as keyof typeof editForm.formState.errors]?.message as string}
</p>
)}
</div>
))}
{/* 编辑时显示角色选择 */}
{props.config.userType !== 'problem' && (
<div className="grid grid-cols-4 items-center gap-4">
<Label htmlFor="role" className="text-right">
</Label>
<Select
value={editForm.watch('role') ?? ''}
onValueChange={value => editForm.setValue('role', value as Role)}
>
<SelectTrigger className="col-span-3">
<SelectValue placeholder="请选择角色" />
</SelectTrigger>
<SelectContent>
{props.config.userType === 'guest' && (
<>
<SelectItem value="GUEST"></SelectItem>
<SelectItem value="TEACHER"></SelectItem>
</>
)}
{(props.config.userType === 'teacher' || props.config.userType === 'admin') && (
<>
<SelectItem value="ADMIN"></SelectItem>
<SelectItem value="TEACHER"></SelectItem>
<SelectItem value="GUEST"></SelectItem>
</>
)}
</SelectContent>
</Select>
{editForm.formState.errors.role?.message && (
<p className="col-span-3 col-start-2 text-sm text-red-500">
{editForm.formState.errors.role?.message as string}
</p>
)}
</div>
)}
</div>
<DialogFooter>
<Button type="submit" disabled={isLoading}>
{isLoading ? "修改中..." : "确认修改"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}
// 用ref保证获取最新data
const dataRef = React.useRef<User[] | Problem[]>(props.data)
React.useEffect(() => { dataRef.current = props.data }, [props.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">
{props.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: "操作",
displayId: "题目编号",
difficulty: "难度",
}
return (
<DropdownMenuCheckboxItem
key={column.id}
className="capitalize"
checked={column.getIsVisible()}
onCheckedChange={(value) =>
column.toggleVisibility(!!value)
}
>
{columnNameMap[column.id] || column.id}
</DropdownMenuCheckboxItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
{isProblem && props.config.actions.add && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-sm"
onClick={async () => {
const maxDisplayId = Array.isArray(problemData) && problemData.length > 0
? Math.max(...problemData.map(item => Number(item.displayId) || 0), 1000)
: 1000;
await createProblem({
displayId: maxDisplayId + 1,
difficulty: Difficulty.EASY,
isPublished: false,
isTrim: false,
timeLimit: 1000,
memoryLimit: 134217728,
userId: null,
});
router.refresh();
}}
>
<PlusIcon className="h-4 w-4" />
{props.config.actions.add.label}
</Button>
)}
{!isProblem && props.config.actions.add && (
<Button
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-sm"
onClick={() => setIsAddDialogOpen(true)}
>
<PlusIcon className="h-4 w-4" />
{props.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" />
{props.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">
{props.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 && props.config.actions.add ? (
<AddUserDialogProblem open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen} />
) : !isProblem && props.config.actions.add ? (
<AddUserDialogUser open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen} />
) : null}
{/* 编辑用户对话框 */}
{!isProblem && editingUser ? (
<EditUserDialogUser open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} user={editingUser as User} />
) : 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 deleteProblem((row.original as Problem).id)
} else {
await deleteUser(props.config.userType as 'admin' | 'teacher' | 'guest', (row.original as User).id)
}
}
toast.success(`成功删除 ${selectedRows.length} 条记录`, { duration: 1500 })
}
setDeleteDialogOpen(false)
router.refresh()
} catch {
toast.error('删除失败', { duration: 1500 })
}
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
<div></div>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}></Button>
<Button
variant="destructive"
onClick={async () => {
if (pendingDeleteItem) {
if (isProblem) {
await deleteProblem((pendingDeleteItem as Problem).id)
} else {
await deleteUser(props.config.userType as 'admin' | 'teacher' | 'guest', (pendingDeleteItem as User).id)
}
toast.success('删除成功', { duration: 1500 })
router.refresh()
}
setDeleteConfirmOpen(false)
setPendingDeleteItem(null)
}}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</Tabs>
)
}

View File

@ -0,0 +1,23 @@
import { z } from "zod"
import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config'
// 管理员数据校验 schema
export const adminSchema = baseUserSchema
export type Admin = z.infer<typeof adminSchema>
// 添加管理员表单校验 schema
export const addAdminSchema = baseAddUserSchema
export type AddAdminFormData = z.infer<typeof addAdminSchema>
// 编辑管理员表单校验 schema
export const editAdminSchema = baseEditUserSchema
export type EditAdminFormData = z.infer<typeof editAdminSchema>
// 管理员配置
export const adminConfig = createUserConfig(
"admin",
"管理员列表",
"添加管理员",
"请输入管理员姓名",
"请输入管理员邮箱"
)

View File

@ -0,0 +1,86 @@
import { z } from "zod"
// 基础用户 schema
export const baseUserSchema = 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(),
})
// 基础添加用户 schema
export const baseAddUserSchema = z.object({
name: z.string().min(1, "姓名为必填项"),
email: z.string().email("请输入有效的邮箱地址"),
password: z.string().min(8, "密码长度8-32位").max(32, "密码长度8-32位"),
createdAt: z.string(),
})
// 基础编辑用户 schema
export const baseEditUserSchema = z.object({
id: z.string(),
name: z.string().min(1, "姓名为必填项"),
email: z.string().email("请输入有效的邮箱地址"),
password: z.string().min(8, "密码长度8-32位").max(32, "密码长度8-32位"),
createdAt: z.string(),
})
// 基础表格列配置
export const baseColumns = [
{ key: "id", label: "ID", sortable: true },
{ key: "name", label: "姓名", sortable: true, searchable: true, placeholder: "搜索姓名" },
{ key: "email", label: "邮箱", sortable: true, searchable: true, placeholder: "搜索邮箱" },
{ key: "createdAt", label: "创建时间", sortable: true },
]
// 基础表单字段配置
export const baseFormFields = [
{ key: "name", label: "姓名", type: "text", placeholder: "请输入姓名", required: true },
{ key: "email", label: "邮箱", type: "email", placeholder: "请输入邮箱", required: true },
{ key: "password", label: "密码", type: "password", placeholder: "请输入8-32位密码", required: true },
{ key: "createdAt", label: "创建时间", type: "datetime-local", required: false },
]
// 基础操作配置
export const baseActions = {
add: { label: "添加", icon: "PlusIcon" },
edit: { label: "编辑", icon: "PencilIcon" },
delete: { label: "删除", icon: "TrashIcon" },
batchDelete: { label: "批量删除", icon: "TrashIcon" },
}
// 基础分页配置
export const basePagination = {
pageSizes: [10, 50, 100, 500],
defaultPageSize: 10,
}
// 创建用户配置的工厂函数
export function createUserConfig(
userType: string,
title: string,
addLabel: string,
namePlaceholder: string,
emailPlaceholder: string
) {
return {
userType,
title,
apiPath: "/api/user",
columns: baseColumns,
formFields: baseFormFields.map(field => ({
...field,
placeholder: field.key === 'name' ? namePlaceholder :
field.key === 'email' ? emailPlaceholder :
field.placeholder
})),
actions: {
...baseActions,
add: { ...baseActions.add, label: addLabel }
},
pagination: basePagination,
}
}

View File

@ -0,0 +1,19 @@
import { z } from "zod";
import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config'
export const guestSchema = baseUserSchema;
export type Guest = z.infer<typeof guestSchema>;
export const addGuestSchema = baseAddUserSchema;
export type AddGuestFormData = z.infer<typeof addGuestSchema>;
export const editGuestSchema = baseEditUserSchema;
export type EditGuestFormData = z.infer<typeof editGuestSchema>;
export const guestConfig = createUserConfig(
"guest",
"客户列表",
"添加客户",
"请输入客户姓名",
"请输入客户邮箱"
);

View File

@ -0,0 +1,41 @@
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: "搜索难度" },
],
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,19 @@
import { z } from "zod";
import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config'
export const teacherSchema = baseUserSchema;
export type Teacher = z.infer<typeof teacherSchema>;
export const addTeacherSchema = baseAddUserSchema;
export type AddTeacherFormData = z.infer<typeof addTeacherSchema>;
export const editTeacherSchema = baseEditUserSchema;
export type EditTeacherFormData = z.infer<typeof editTeacherSchema>;
export const teacherConfig = createUserConfig(
"teacher",
"教师列表",
"添加教师",
"请输入教师姓名",
"请输入教师邮箱"
);