diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index 13566b8..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -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 diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 03d9549..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/judge4c.iml b/.idea/judge4c.iml deleted file mode 100644 index d6ebd48..0000000 --- a/.idea/judge4c.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 6f29fee..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index ce31b97..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/prisma/check-problem-submissions.ts b/prisma/check-problem-submissions.ts new file mode 100644 index 0000000..b3aca04 --- /dev/null +++ b/prisma/check-problem-submissions.ts @@ -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(); + })}; \ No newline at end of file diff --git a/prisma/fill-testcase-results.ts b/prisma/fill-testcase-results.ts new file mode 100644 index 0000000..56a1fff --- /dev/null +++ b/prisma/fill-testcase-results.ts @@ -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()); \ No newline at end of file diff --git a/prisma/generate-user-data.ts b/prisma/generate-user-data.ts new file mode 100644 index 0000000..176c219 --- /dev/null +++ b/prisma/generate-user-data.ts @@ -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%概率AC,40%概率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(); \ No newline at end of file diff --git a/prisma/migrations/20250621055555_lg/migration.sql b/prisma/migrations/20250621055555_lg/migration.sql new file mode 100644 index 0000000..e3f5f45 --- /dev/null +++ b/prisma/migrations/20250621055555_lg/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "Role" ADD VALUE 'TEACHER'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 6fa0abd..7005118 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -11,6 +11,7 @@ generator client { enum Role { ADMIN GUEST + TEACHER } enum Difficulty { diff --git a/prisma/test-student-dashboard.ts b/prisma/test-student-dashboard.ts new file mode 100644 index 0000000..eb49d54 --- /dev/null +++ b/prisma/test-student-dashboard.ts @@ -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(); + }); \ No newline at end of file diff --git a/prisma/test-student-data-access.ts b/prisma/test-student-data-access.ts new file mode 100644 index 0000000..3a9643f --- /dev/null +++ b/prisma/test-student-data-access.ts @@ -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(); \ No newline at end of file diff --git a/src/app/(app)/management/actions/getUserInfo.ts b/src/app/(app)/management/actions/getUserInfo.ts deleted file mode 100644 index dd83390..0000000 --- a/src/app/(app)/management/actions/getUserInfo.ts +++ /dev/null @@ -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("获取用户信息失败"); - } -} \ No newline at end of file diff --git a/src/app/(app)/management/page.tsx b/src/app/(app)/management/page.tsx deleted file mode 100644 index 03a8f8c..0000000 --- a/src/app/(app)/management/page.tsx +++ /dev/null @@ -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 - case "change-password": - return - default: - return - } - } - - const toggleSidebar = () => { - setIsCollapsed((prev) => !prev) - } - - return ( - -
- {/* 左侧侧边栏 */} - {!isCollapsed && ( -
- -
- )} - - {/* 右侧主内容区域 */} - -
- {/* 折叠按钮 */} - - - - {/* 面包屑导航 */} - - - - 管理面板 - - - - - {menuItems.find((item) => item.key === activePage)?.title} - - - - -
- {/* 主体内容:根据 isCollapsed 切换样式 */} -
- {renderContent()} -
-
-
-
- ) -} \ No newline at end of file diff --git a/src/app/(protected)/admin/problems/[problemId]/edit/layout.tsx b/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/layout.tsx similarity index 95% rename from src/app/(protected)/admin/problems/[problemId]/edit/layout.tsx rename to src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/layout.tsx index fb46c63..0667f52 100644 --- a/src/app/(protected)/admin/problems/[problemId]/edit/layout.tsx +++ b/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/layout.tsx @@ -16,4 +16,4 @@ const Layout = async ({ children, params }: LayoutProps) => { return {children}; }; -export default Layout; +export default Layout; \ No newline at end of file diff --git a/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx b/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx new file mode 100644 index 0000000..87ae266 --- /dev/null +++ b/src/app/(protected)/(protect-admin)/admin/problems/[problemId]/edit/page.tsx @@ -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 ; +}; + +export default Page; \ No newline at end of file diff --git a/src/app/(protected)/layout.tsx b/src/app/(protected)/(protect-admin)/layout.tsx similarity index 92% rename from src/app/(protected)/layout.tsx rename to src/app/(protected)/(protect-admin)/layout.tsx index 8a017f9..105864e 100644 --- a/src/app/(protected)/layout.tsx +++ b/src/app/(protected)/(protect-admin)/layout.tsx @@ -8,4 +8,4 @@ const Layout = ({ children }: LayoutProps) => { return {children}; }; -export default Layout; +export default Layout; \ No newline at end of file diff --git a/src/app/(protected)/admin/problems/[problemId]/edit/page.tsx b/src/app/(protected)/admin/problems/[problemId]/edit/page.tsx deleted file mode 100644 index 8f8302f..0000000 --- a/src/app/(protected)/admin/problems/[problemId]/edit/page.tsx +++ /dev/null @@ -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 ; -}; - -export default Page; diff --git a/src/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard.ts b/src/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard.ts new file mode 100644 index 0000000..d54485d --- /dev/null +++ b/src/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard.ts @@ -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(); + 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); + } + }); + + 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(); + + // 统计在已完成的题目中,哪些题目曾经有过错误提交 + 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 : '未知错误'}`); + } +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard.ts b/src/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard.ts new file mode 100644 index 0000000..aeb9718 --- /dev/null +++ b/src/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard.ts @@ -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 { + // 获取所有提交记录,按题目分组统计 + const submissions = await prisma.submission.findMany({ + include: { + user: true, + problem: { + include:{ + localizations:true + } + } + }, + }); + + const locale = await getLocale(); + + // 按题目分组统计完成情况(统计独立用户数) + const problemStats = new Map; + totalUsers: Set; + 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 { + // 获取所有测试用例结果 + const testcaseResults = await prisma.testcaseResult.findMany({ + include: { + testcase: { + include: { + problem: { + include: { + localizations: true + } + }, + }, + }, + submission: { + include: { + user: true, + }, + }, + }, + }); + + // 按问题分组统计错误率 + const problemStats = new Map; + }>(); + + 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, + }; +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/(userdashboard)/student/dashboard/page.tsx b/src/app/(protected)/dashboard/(userdashboard)/student/dashboard/page.tsx new file mode 100644 index 0000000..d2d9c53 --- /dev/null +++ b/src/app/(protected)/dashboard/(userdashboard)/student/dashboard/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+
加载中...
+
+ ); + } + + if (error) { + return ( +
+
错误: {error}
+
+ ); + } + + if (!data) { + return ( +
+
暂无数据
+
+ ); + } + + const { completionData, errorData, difficultProblems, pieChartData, errorPieChartData } = data; + const COLORS = ["#4CAF50", "#FFC107"]; + + return ( +
+

学生仪表板

+ +
+ {/* 题目完成比例模块 */} + + + 题目完成比例 + + +
+
+ 已完成题目:{completionData.completed}/{completionData.total} + {completionData.percentage}% +
+ +
+ + + + {pieChartData.map((entry: { name: string; value: number }, index: number) => ( + + ))} + + + +
+
+
+
+ + {/* 错题比例模块 */} + + + 错题比例 + + +
+
+ 错题数量:{errorData.wrong}/{errorData.total} + {errorData.percentage}% +
+ +
+ + + + {errorPieChartData.map((entry: { name: string; value: number }, index: number) => ( + + ))} + + + +
+
+
+
+
+ + {/* 易错题练习模块 */} + + + 易错题练习 + + +
+
+ 易错题数量:{difficultProblems.length} +
+ {difficultProblems.length > 0 ? ( + + + + 题目ID + 题目名称 + 难度 + 错误次数 + + + + {difficultProblems.map((problem: { id: string | number; title: string; difficulty: string; errorCount: number }) => ( + + {problem.id} + {problem.title} + {problem.difficulty} + {problem.errorCount} + + ))} + +
+ ) : ( +
+ 暂无易错题数据 +
+ )} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/page.tsx b/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/page.tsx new file mode 100644 index 0000000..01f0ba1 --- /dev/null +++ b/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/page.tsx @@ -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([]); + const [difficultProblems, setDifficultProblems] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( +
+

教师仪表板

+
+
加载中...
+
+
+ ); + } + + if (error) { + return ( +
+

教师仪表板

+
+
错误: {error}
+
+
+ ); + } + + return ( +
+

教师仪表板

+ +
+ {/* 题目完成情况模块 */} + + + 题目完成情况 + 各题目完成及未完成人数图表 + + + {chartData.length === 0 ? ( +
+
暂无数据
+
+ ) : ( + <> + + + + `${value}%`} + /> + + } + /> + + `${value}人`} + /> + + + `${value}人`} + /> + + + + {/* 分页控制 */} + {totalPages > 1 && ( +
+ + + 第 {currentPage} 页,共 {totalPages} 页 + + +
+ )} + + )} +
+ +
+ 完成度趋势 +
+
+ 显示各题目完成情况(已完成/未完成) +
+
+
+ + {/* 学生易错题模块 */} + + + 学生易错题 + 各班级易错题数量及列表 + + +
+
+ 出错率较高题目数量:{difficultProblems.length} +
+ {difficultProblems.length === 0 ? ( +
+
暂无易错题数据
+
+ ) : ( + + + + 题目编号 + 题目名称 + 错误次数 + + + + {difficultProblems.map((problem) => ( + + {problem.problemDisplayId || problem.id.substring(0, 8)} + {problem.problemTitle} + {problem.problemCount} + + ))} + +
+ )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/test-data.tsx b/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/test-data.tsx new file mode 100644 index 0000000..bf518d5 --- /dev/null +++ b/src/app/(protected)/dashboard/(userdashboard)/teacher/dashboard/test-data.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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
加载中...
; + } + + if (error) { + return
错误: {error}
; + } + + return ( +
+

数据测试页面

+ +
+
+

题目完成数据

+
+            {JSON.stringify(data?.problemData, null, 2)}
+          
+
+ +
+

易错题数据

+
+            {JSON.stringify(data?.difficultProblems, null, 2)}
+          
+
+ +
+

统计信息

+
+            {JSON.stringify({
+              totalProblems: data?.totalProblems,
+              totalDifficultProblems: data?.totalDifficultProblems,
+            }, null, 2)}
+          
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/layout.tsx b/src/app/(protected)/dashboard/layout.tsx index 565b2a7..119e21f 100644 --- a/src/app/(protected)/dashboard/layout.tsx +++ b/src/app/(protected)/dashboard/layout.tsx @@ -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 ; + case "TEACHER": + return ; + case "GUEST": + default: + // 学生(GUEST)需要查询错题数据 + return ; + } + }; + + // 只有学生才需要查询错题数据 + 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 ( - + {fullUser.role === "GUEST" ? ( + + ) : ( + renderSidebar() + )}
- - - - - Building Your Application - - - - - Data Fetching - - - +
{children}
diff --git a/src/app/(app)/management/actions/changePassword.ts b/src/app/(protected)/dashboard/management/actions/changePassword.ts similarity index 63% rename from src/app/(app)/management/actions/changePassword.ts rename to src/app/(protected)/dashboard/management/actions/changePassword.ts index 018db7a..554a2be 100644 --- a/src/app/(app)/management/actions/changePassword.ts +++ b/src/app/(protected)/dashboard/management/actions/changePassword.ts @@ -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 }, }); diff --git a/src/app/(protected)/dashboard/management/actions/getUserInfo.ts b/src/app/(protected)/dashboard/management/actions/getUserInfo.ts new file mode 100644 index 0000000..53aacab --- /dev/null +++ b/src/app/(protected)/dashboard/management/actions/getUserInfo.ts @@ -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("获取用户信息失败"); + } +} \ No newline at end of file diff --git a/src/app/(app)/management/actions/index.ts b/src/app/(protected)/dashboard/management/actions/index.ts similarity index 100% rename from src/app/(app)/management/actions/index.ts rename to src/app/(protected)/dashboard/management/actions/index.ts diff --git a/src/app/(app)/management/actions/updateUserInfo.ts b/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts similarity index 66% rename from src/app/(app)/management/actions/updateUserInfo.ts rename to src/app/(protected)/dashboard/management/actions/updateUserInfo.ts index 201284e..66edff6 100644 --- a/src/app/(app)/management/actions/updateUserInfo.ts +++ b/src/app/(protected)/dashboard/management/actions/updateUserInfo.ts @@ -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 }, }); diff --git a/src/app/(app)/management/change-password/page.tsx b/src/app/(protected)/dashboard/management/change-password/page.tsx similarity index 94% rename from src/app/(app)/management/change-password/page.tsx rename to src/app/(protected)/dashboard/management/change-password/page.tsx index 3b9b770..b635a46 100644 --- a/src/app/(app)/management/change-password/page.tsx +++ b/src/app/(protected)/dashboard/management/change-password/page.tsx @@ -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); } }; diff --git a/src/app/(protected)/dashboard/management/page.tsx b/src/app/(protected)/dashboard/management/page.tsx new file mode 100644 index 0000000..7bdf039 --- /dev/null +++ b/src/app/(protected)/dashboard/management/page.tsx @@ -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 + case "change-password": + return + default: + return + } + } + + return ( +
+ {/* 顶部导航栏 */} +
+ + + {/* 页面切换按钮 */} +
+ + +
+
+ + {/* 主体内容 */} +
+ {renderContent()} +
+
+ ) +} \ No newline at end of file diff --git a/src/app/(app)/management/profile/page.tsx b/src/app/(protected)/dashboard/management/profile/page.tsx similarity index 89% rename from src/app/(app)/management/profile/page.tsx rename to src/app/(protected)/dashboard/management/profile/page.tsx index 4659aba..a789024 100644 --- a/src/app/(app)/management/profile/page.tsx +++ b/src/app/(protected)/dashboard/management/profile/page.tsx @@ -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); } }; diff --git a/src/app/(protected)/dashboard/page.tsx b/src/app/(protected)/dashboard/page.tsx index 80efbfb..16d0921 100644 --- a/src/app/(protected)/dashboard/page.tsx +++ b/src/app/(protected)/dashboard/page.tsx @@ -1,3 +1,305 @@ -export default function Page() { - return
Dashboard
+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 ( +
+ {/* 欢迎区域 */} +
+

{config.title}

+

{config.description}

+
+ {fullUser.role} + + 欢迎回来,{fullUser.name || fullUser.email} + +
+
+ + {/* 统计卡片 */} +
+ {config.stats.map((stat, index) => ( + + + {stat.label} + + + +
{stat.value}
+
+
+ ))} +
+ + {/* 学生进度条 */} + {fullUser.role === "GUEST" && ( + + + + + 学习进度 + + + 已完成 {stats.completedProblems || 0} / {stats.totalProblems || 0} 道题目 + + + + +

+ 完成率: {completionRate.toFixed(1)}% +

+
+
+ )} + + {/* 快速操作 */} + + + 快速操作 + 常用功能快速访问 + + +
+ {config.actions.map((action, index) => ( + + + + ))} +
+
+
+ + {/* 最近活动 */} + + + 最近活动 + 查看最新的系统活动 + + +
+ {recentActivity.length > 0 ? ( + recentActivity.map((activity, index) => ( +
+
+ {activity.status === "AC" ? ( + + ) : activity.status ? ( + + ) : ( + + )} +
+
+

{activity.title}

+

{activity.description}

+
+
+ {new Date(activity.time).toLocaleDateString()} +
+
+ )) + ) : ( +

暂无活动

+ )} +
+
+
+
+ ); } diff --git a/src/app/(protected)/dashboard/usermanagement/_actions/problemActions.ts b/src/app/(protected)/dashboard/usermanagement/_actions/problemActions.ts new file mode 100644 index 0000000..ca7ca81 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/_actions/problemActions.ts @@ -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) { + await prisma.problem.create({ data }) + revalidatePath('/usermanagement/problem') +} + +export async function deleteProblem(id: string) { + await prisma.problem.delete({ where: { id } }) + revalidatePath('/usermanagement/problem') +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/_actions/userActions.ts b/src/app/(protected)/dashboard/usermanagement/_actions/userActions.ts new file mode 100644 index 0000000..e49269f --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/_actions/userActions.ts @@ -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 & { 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> +) { + 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}`) +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/_components/GenericLayout.tsx b/src/app/(protected)/dashboard/usermanagement/_components/GenericLayout.tsx new file mode 100644 index 0000000..a182e77 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/_components/GenericLayout.tsx @@ -0,0 +1,10 @@ +import ProtectedLayout from "./ProtectedLayout"; + +interface GenericLayoutProps { + children: React.ReactNode; + allowedRoles: string[]; +} + +export default function GenericLayout({ children, allowedRoles }: GenericLayoutProps) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/_components/ProtectedLayout.tsx b/src/app/(protected)/dashboard/usermanagement/_components/ProtectedLayout.tsx new file mode 100644 index 0000000..ed7830a --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/_components/ProtectedLayout.tsx @@ -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
{children}
; +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/admin/layout.tsx b/src/app/(protected)/dashboard/usermanagement/admin/layout.tsx new file mode 100644 index 0000000..db50dae --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/admin/layout.tsx @@ -0,0 +1,5 @@ +import GenericLayout from "../_components/GenericLayout"; + +export default function AdminLayout({ children }: { children: React.ReactNode }) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/admin/page.tsx b/src/app/(protected)/dashboard/usermanagement/admin/page.tsx new file mode 100644 index 0000000..d911127 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/admin/page.tsx @@ -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 +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx b/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx new file mode 100644 index 0000000..6c084f4 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/guest/layout.tsx @@ -0,0 +1,5 @@ +import GenericLayout from "../_components/GenericLayout"; + +export default function GuestLayout({ children }: { children: React.ReactNode }) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/guest/page.tsx b/src/app/(protected)/dashboard/usermanagement/guest/page.tsx new file mode 100644 index 0000000..5d8d1bc --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/guest/page.tsx @@ -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 +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx b/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx new file mode 100644 index 0000000..b4a002e --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/problem/layout.tsx @@ -0,0 +1,5 @@ +import GenericLayout from "../_components/GenericLayout"; + +export default function ProblemLayout({ children }: { children: React.ReactNode }) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/problem/page.tsx b/src/app/(protected)/dashboard/usermanagement/problem/page.tsx new file mode 100644 index 0000000..e67a3d0 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/problem/page.tsx @@ -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 +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx b/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx new file mode 100644 index 0000000..8051801 --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/teacher/layout.tsx @@ -0,0 +1,5 @@ +import GenericLayout from "../_components/GenericLayout"; + +export default function TeacherLayout({ children }: { children: React.ReactNode }) { + return {children}; +} \ No newline at end of file diff --git a/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx b/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx new file mode 100644 index 0000000..9bccf6f --- /dev/null +++ b/src/app/(protected)/dashboard/usermanagement/teacher/page.tsx @@ -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 +} \ No newline at end of file diff --git a/src/components/UncompletedProject/wrongbook-dialog.tsx b/src/components/UncompletedProject/wrongbook-dialog.tsx index c7edcc7..03241b3 100644 --- a/src/components/UncompletedProject/wrongbook-dialog.tsx +++ b/src/components/UncompletedProject/wrongbook-dialog.tsx @@ -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(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 ( @@ -29,7 +43,7 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string - + @@ -37,9 +51,22 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string {problems.map((item) => ( - + diff --git a/src/components/dynamic-breadcrumb.tsx b/src/components/dynamic-breadcrumb.tsx new file mode 100644 index 0000000..a52799a --- /dev/null +++ b/src/components/dynamic-breadcrumb.tsx @@ -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 = { + '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 ( + + + {breadcrumbs.map((item, index) => ( +
+ + {item.href ? ( + + {item.label} + + ) : ( + + {item.label} + + )} + + {index < breadcrumbs.length - 1 && ( + + )} +
+ ))} +
+
+ ) +} \ No newline at end of file diff --git a/src/components/management-sidebar/manage-form.tsx b/src/components/management-sidebar/manage-form.tsx deleted file mode 100644 index a120602..0000000 --- a/src/components/management-sidebar/manage-form.tsx +++ /dev/null @@ -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 ( -
- - - - - - - - - ) -} \ No newline at end of file diff --git a/src/components/management-sidebar/manage-sidebar.tsx b/src/components/management-sidebar/manage-sidebar.tsx deleted file mode 100644 index fb026d3..0000000 --- a/src/components/management-sidebar/manage-sidebar.tsx +++ /dev/null @@ -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 ( - - - - - - {/* 渲染用户相关的侧边栏菜单 */} - {data.navUser.map((item) => ( - - - - - {item.title} - - - - - - - {item.items.map((subItem) => ( - - { - e.preventDefault(); - onItemClick(subItem.key); - }} - > - {subItem.title} - - - ))} - - - - - - ))} - - - - ); -} \ No newline at end of file diff --git a/src/components/management-sidebar/manage-switcher.tsx b/src/components/management-sidebar/manage-switcher.tsx deleted file mode 100644 index 054995b..0000000 --- a/src/components/management-sidebar/manage-switcher.tsx +++ /dev/null @@ -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 ( - - - - - -
- -
-
- Documentation - v{selectedVersion} -
- -
-
- - {versions.map((version) => ( - setSelectedVersion(version)} - > - v{version}{" "} - {version === selectedVersion && } - - ))} - -
-
-
- ) -} \ No newline at end of file diff --git a/src/components/nav-projects.tsx b/src/components/nav-projects.tsx index 1d075ad..acb0c8d 100644 --- a/src/components/nav-projects.tsx +++ b/src/components/nav-projects.tsx @@ -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) => ( - + - + - + { + e.stopPropagation() + if (item.url) { + router.push(item.url) + } else { + router.push(`/problems/${item.id}`) + } + }} + > 查看 { e.stopPropagation() - setShareLink(`${window.location.origin}/problem/${item.id}`) + setShareLink(`${window.location.origin}/problems/${item.id}`) setShareOpen(true) }} > 复制链接 - - - - 移除 - diff --git a/src/components/nav-user.tsx b/src/components/nav-user.tsx index 1fa7a82..d915947 100644 --- a/src/components/nav-user.tsx +++ b/src/components/nav-user.tsx @@ -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({ - + {/* Update - - + */} + {/* */} @@ -111,10 +114,10 @@ export function NavUser({ Switch User - + {/* Notifications - + */} diff --git a/src/components/sidebar/admin-sidebar.tsx b/src/components/sidebar/admin-sidebar.tsx index b5ead92..4525c75 100644 --- a/src/components/sidebar/admin-sidebar.tsx +++ b/src/components/sidebar/admin-sidebar.tsx @@ -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) { - 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) { + const userInfo = { + name: user.name ?? "管理员", + email: user.email ?? "", + avatar: user.image ?? "/avatars/default.jpg", + }; return ( @@ -97,11 +75,10 @@ export function AdminSidebar(props: React.ComponentProps) { - - + ) diff --git a/src/components/sidebar/app-sidebar.tsx b/src/components/sidebar/app-sidebar.tsx index 9038c9e..84af52c 100644 --- a/src/components/sidebar/app-sidebar.tsx +++ b/src/components/sidebar/app-sidebar.tsx @@ -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) { - + diff --git a/src/components/sidebar/teacher-sidebar.tsx b/src/components/sidebar/teacher-sidebar.tsx index 2eecd71..78abc3a 100644 --- a/src/components/sidebar/teacher-sidebar.tsx +++ b/src/components/sidebar/teacher-sidebar.tsx @@ -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) { +interface TeacherSidebarProps { + user: User; +} + +export function TeacherSidebar({ user, ...props }: TeacherSidebarProps & React.ComponentProps) { + const userInfo = { + name: user.name ?? "", + email: user.email ?? "", + avatar: user.image ?? "/avatars/teacher.jpg", + }; + return ( @@ -113,11 +115,10 @@ export function TeacherSidebar({ ...props }: React.ComponentProps - {/* 教师端可自定义更多内容 */} - + ) diff --git a/src/features/admin/ui/views/problem-edit-view.tsx b/src/features/admin/ui/views/problem-edit-view.tsx index 57e17a3..a9f6f22 100644 --- a/src/features/admin/ui/views/problem-edit-view.tsx +++ b/src/features/admin/ui/views/problem-edit-view.tsx @@ -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 = { - detail: , - description: , - solution: , - code: , - testcase: , - }; +export const ProblemEditView = ( + // { problemId }: ProblemEditViewProps +) => { + // const components: Record = { + // description: , + // solution: , + // detail: , + // code: , + // testcase: , + // }; return (
- + {/* */}
); }; diff --git a/src/features/user-management/components/generic-page.tsx b/src/features/user-management/components/generic-page.tsx new file mode 100644 index 0000000..7063fea --- /dev/null +++ b/src/features/user-management/components/generic-page.tsx @@ -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 + } else { + const role = userType.toUpperCase() as Role + const data: User[] = await prisma.user.findMany({ where: { role } }) + return + } +} \ No newline at end of file diff --git a/src/features/user-management/components/user-table.tsx b/src/features/user-management/components/user-table.tsx new file mode 100644 index 0000000..ad5710e --- /dev/null +++ b/src/features/user-management/components/user-table.tsx @@ -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 + +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(null) + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) + const [deleteBatch, setDeleteBatch] = useState(false) + const [rowSelection, setRowSelection] = useState({}) + const [columnVisibility, setColumnVisibility] = useState({}) + const [columnFilters, setColumnFilters] = useState([]) + const [sorting, setSorting] = useState([]) + 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(null) + useEffect(() => { + setPageInput(pagination.pageIndex + 1) + }, [pagination.pageIndex]) + + // 表格列 + const tableColumns = React.useMemo[]>(() => { + const columns: ColumnDef[] = [ + { + id: "select", + header: ({ table }) => ( + table.toggleAllPageRowsSelected(!!value)} + aria-label="选择所有" + /> + ), + cell: ({ row }) => ( + row.toggleSelected(!!value)} + aria-label="选择行" + /> + ), + enableSorting: false, + enableHiding: false, + }, + ] + props.config.columns.forEach((col) => { + const column: ColumnDef = { + 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: () =>
操作
, + cell: ({ row }) => { + const item = row.original + return ( +
+ + +
+ ) + }, + }) + 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({ + 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 ( + + + + {props.config.actions.add.label} + + 请填写信息,ID自动生成。 + + +
+
+ {props.config.formFields.filter(field => field.key !== 'id').map((field) => ( +
+ + {field.type === 'select' && field.options ? ( + + ) : ( + + )} + {form.formState.errors[field.key as keyof typeof form.formState.errors]?.message && ( +

+ {form.formState.errors[field.key as keyof typeof form.formState.errors]?.message as string} +

+ )} +
+ ))} +
+ + + + +
+
+ ) + } + + // 添加题目对话框组件(仅题目) + function AddUserDialogProblem({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) { + const [isLoading, setIsLoading] = useState(false) + const form = useForm>({ + resolver: zodResolver(addProblemSchema), + defaultValues: { displayId: 0, difficulty: Difficulty.EASY }, + }) + React.useEffect(() => { + if (open) { + form.reset({ displayId: 0, difficulty: Difficulty.EASY }) + } + }, [open, form]) + async function onSubmit(formData: Partial) { + try { + setIsLoading(true) + const submitData: Partial = { ...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 ( + + + + {props.config.actions.add.label} + + 请填写信息,ID自动生成。 + + +
+
+ {props.config.formFields.map((field) => ( +
+ + {field.key === 'difficulty' ? ( + + ) : ( + + )} + {form.formState.errors[field.key as keyof typeof form.formState.errors]?.message && ( +

+ {form.formState.errors[field.key as keyof typeof form.formState.errors]?.message as string} +

+ )} +
+ ))} +
+ + + + +
+
+ ) + } + + // 编辑用户对话框组件(仅用户) + function EditUserDialogUser({ open, onOpenChange, user }: { open: boolean; onOpenChange: (open: boolean) => void; user: User }) { + const [isLoading, setIsLoading] = useState(false) + const editForm = useForm({ + 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 ( + + + + {props.config.actions.edit.label} + + 修改信息 + + +
+
+ {props.config.formFields.map((field) => ( +
+ + + {editForm.formState.errors[field.key as keyof typeof editForm.formState.errors]?.message && ( +

+ {editForm.formState.errors[field.key as keyof typeof editForm.formState.errors]?.message as string} +

+ )} +
+ ))} + {/* 编辑时显示角色选择 */} + {props.config.userType !== 'problem' && ( +
+ + + {editForm.formState.errors.role?.message && ( +

+ {editForm.formState.errors.role?.message as string} +

+ )} +
+ )} +
+ + + + +
+
+ ) + } + + // 用ref保证获取最新data + const dataRef = React.useRef(props.data) + React.useEffect(() => { dataRef.current = props.data }, [props.data]) + + return ( + +
+
+ {props.config.title} +
+
+ + + + + + {table + .getAllColumns() + .filter((column) => column.getCanHide()) + .map((column) => { + const columnNameMap: Record = { + select: "选择", + id: "ID", + name: "姓名", + email: "邮箱", + password: "密码", + createdAt: "创建时间", + actions: "操作", + displayId: "题目编号", + difficulty: "难度", + } + return ( + + column.toggleVisibility(!!value) + } + > + {columnNameMap[column.id] || column.id} + + ) + })} + + + {isProblem && props.config.actions.add && ( + + )} + {!isProblem && props.config.actions.add && ( + + )} + +
+
+
+
+
ID操作 题目名称 状态
{item.id} - + + + {item.name}
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + return ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext() + )} + + ) + })} + + ))} + + + {table.getRowModel().rows?.length ? ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} + + ))} + + )) + ) : ( + + + 暂无数据 + + + )} + +
+ + +
+
+ 共 {table.getFilteredRowModel().rows.length} 条记录 +
+
+
+

每页显示

+ +
+
+ 第 {table.getState().pagination.pageIndex + 1} 页,共{" "} + {table.getPageCount()} 页 +
+
+ + +
+ 跳转到 + 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" + /> + +
+ + +
+
+
+ {/* 添加用户对话框 */} + {isProblem && props.config.actions.add ? ( + + ) : !isProblem && props.config.actions.add ? ( + + ) : null} + {/* 编辑用户对话框 */} + {!isProblem && editingUser ? ( + + ) : null} + {/* 删除确认对话框 */} + + + + 确认删除 + + {deleteBatch + ? `确定要删除选中的 ${table.getFilteredSelectedRowModel().rows.length} 条记录吗?此操作不可撤销。` + : "确定要删除这条记录吗?此操作不可撤销。" + } + + + + + + + + + + + + 确认删除 + +
确定要删除该条数据吗?此操作不可撤销。
+ + + + +
+
+ + ) +} \ No newline at end of file diff --git a/src/features/user-management/config/admin.ts b/src/features/user-management/config/admin.ts new file mode 100644 index 0000000..8be5d50 --- /dev/null +++ b/src/features/user-management/config/admin.ts @@ -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 + +// 添加管理员表单校验 schema +export const addAdminSchema = baseAddUserSchema +export type AddAdminFormData = z.infer + +// 编辑管理员表单校验 schema +export const editAdminSchema = baseEditUserSchema +export type EditAdminFormData = z.infer + +// 管理员配置 +export const adminConfig = createUserConfig( + "admin", + "管理员列表", + "添加管理员", + "请输入管理员姓名", + "请输入管理员邮箱" +) \ No newline at end of file diff --git a/src/features/user-management/config/base-config.ts b/src/features/user-management/config/base-config.ts new file mode 100644 index 0000000..154ac94 --- /dev/null +++ b/src/features/user-management/config/base-config.ts @@ -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, + } +} \ No newline at end of file diff --git a/src/features/user-management/config/guest.ts b/src/features/user-management/config/guest.ts new file mode 100644 index 0000000..f41c910 --- /dev/null +++ b/src/features/user-management/config/guest.ts @@ -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; + +export const addGuestSchema = baseAddUserSchema; +export type AddGuestFormData = z.infer; + +export const editGuestSchema = baseEditUserSchema; +export type EditGuestFormData = z.infer; + +export const guestConfig = createUserConfig( + "guest", + "客户列表", + "添加客户", + "请输入客户姓名", + "请输入客户邮箱" +); \ No newline at end of file diff --git a/src/features/user-management/config/problem.ts b/src/features/user-management/config/problem.ts new file mode 100644 index 0000000..08fcec9 --- /dev/null +++ b/src/features/user-management/config/problem.ts @@ -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 }, +}; \ No newline at end of file diff --git a/src/features/user-management/config/teacher.ts b/src/features/user-management/config/teacher.ts new file mode 100644 index 0000000..ace2564 --- /dev/null +++ b/src/features/user-management/config/teacher.ts @@ -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; + +export const addTeacherSchema = baseAddUserSchema; +export type AddTeacherFormData = z.infer; + +export const editTeacherSchema = baseEditUserSchema; +export type EditTeacherFormData = z.infer; + +export const teacherConfig = createUserConfig( + "teacher", + "教师列表", + "添加教师", + "请输入教师姓名", + "请输入教师邮箱" +); \ No newline at end of file