mirror of
https://github.com/massbug/judge4c.git
synced 2025-07-03 23:30:50 +00:00
feat: add dashboard
This commit is contained in:
parent
dff0515dbb
commit
47feffd62c
8
.idea/.gitignore
vendored
8
.idea/.gitignore
vendored
@ -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
|
|
@ -1,6 +0,0 @@
|
|||||||
<component name="InspectionProjectProfileManager">
|
|
||||||
<profile version="1.0">
|
|
||||||
<option name="myName" value="Project Default" />
|
|
||||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
|
||||||
</profile>
|
|
||||||
</component>
|
|
@ -1,9 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<module type="JAVA_MODULE" version="4">
|
|
||||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
|
||||||
<exclude-output />
|
|
||||||
<content url="file://$MODULE_DIR$" />
|
|
||||||
<orderEntry type="inheritedJdk" />
|
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
|
||||||
</component>
|
|
||||||
</module>
|
|
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
|
|
||||||
<output url="file://$PROJECT_DIR$/out" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
@ -1,8 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="ProjectModuleManager">
|
|
||||||
<modules>
|
|
||||||
<module fileurl="file://$PROJECT_DIR$/.idea/judge4c.iml" filepath="$PROJECT_DIR$/.idea/judge4c.iml" />
|
|
||||||
</modules>
|
|
||||||
</component>
|
|
||||||
</project>
|
|
@ -1,6 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<project version="4">
|
|
||||||
<component name="VcsDirectoryMappings">
|
|
||||||
<mapping directory="" vcs="Git" />
|
|
||||||
</component>
|
|
||||||
</project>
|
|
67
prisma/check-problem-submissions.ts
Normal file
67
prisma/check-problem-submissions.ts
Normal file
@ -0,0 +1,67 @@
|
|||||||
|
import { PrismaClient } from "@/generated/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function checkProblemSubmissions() {
|
||||||
|
console.log("检查所有题目的提交记录情况...");
|
||||||
|
|
||||||
|
// 获取所有题目
|
||||||
|
const problems = await prisma.problem.findMany({
|
||||||
|
orderBy: { displayId: 'asc' }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`总题目数: ${problems.length}`);
|
||||||
|
|
||||||
|
for (const problem of problems) {
|
||||||
|
// 统计该题目的提交记录
|
||||||
|
const submissionCount = await prisma.submission.count({
|
||||||
|
where: { problemId: problem.id }
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统计该题目的完成情况
|
||||||
|
const completedCount = await prisma.submission.count({
|
||||||
|
where: {
|
||||||
|
problemId: problem.id,
|
||||||
|
status: "AC"
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 查询时包含 localizations
|
||||||
|
const problems = await prisma.problem.findMany({
|
||||||
|
include: {
|
||||||
|
localizations: {
|
||||||
|
where: { type: "TITLE" },
|
||||||
|
select: { content: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
problems.forEach(problem => {
|
||||||
|
const title = problem.localizations[0]?.content || "无标题";
|
||||||
|
console.log(`题目${problem.displayId} (${title}): ...`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 统计有提交记录的题目数量
|
||||||
|
const problemsWithSubmissions = await prisma.problem.findMany({
|
||||||
|
where: {
|
||||||
|
submissions: {
|
||||||
|
some: {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`\n有提交记录的题目数量: ${problemsWithSubmissions.length}`);
|
||||||
|
console.log("有提交记录的题目编号:");
|
||||||
|
problemsWithSubmissions.forEach(p => {
|
||||||
|
console.log(` ${p.displayId}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
checkProblemSubmissions()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("检查数据时出错:", e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
})};
|
37
prisma/fill-testcase-results.ts
Normal file
37
prisma/fill-testcase-results.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { PrismaClient, Status } from "@/generated/client";
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function fillTestcaseResults() {
|
||||||
|
const submissions = await prisma.submission.findMany();
|
||||||
|
let count = 0;
|
||||||
|
for (const submission of submissions) {
|
||||||
|
const testcases = await prisma.testcase.findMany({
|
||||||
|
where: { problemId: submission.problemId },
|
||||||
|
});
|
||||||
|
for (const testcase of testcases) {
|
||||||
|
// 检查是否已存在,避免重复
|
||||||
|
const exists = await prisma.testcaseResult.findFirst({
|
||||||
|
where: {
|
||||||
|
submissionId: submission.id,
|
||||||
|
testcaseId: testcase.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!exists) {
|
||||||
|
await prisma.testcaseResult.create({
|
||||||
|
data: {
|
||||||
|
isCorrect: submission.status === Status.AC,
|
||||||
|
output: submission.status === Status.AC ? "正确答案" : "错误答案",
|
||||||
|
timeUsage: Math.floor(Math.random() * 1000) + 1,
|
||||||
|
memoryUsage: Math.floor(Math.random() * 128) + 1,
|
||||||
|
submissionId: submission.id,
|
||||||
|
testcaseId: testcase.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`已为 ${count} 个提交生成测试用例结果`);
|
||||||
|
}
|
||||||
|
|
||||||
|
fillTestcaseResults().finally(() => prisma.$disconnect());
|
118
prisma/generate-user-data.ts
Normal file
118
prisma/generate-user-data.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { PrismaClient, Status, Language } from "@/generated/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function generateUserData() {
|
||||||
|
console.log("为 student@example.com 生成测试数据...");
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 查找用户
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { email: "student@example.com" }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
console.log("用户不存在,创建用户...");
|
||||||
|
const newUser = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: "测试学生",
|
||||||
|
email: "student@example.com",
|
||||||
|
password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u",
|
||||||
|
role: "GUEST",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log("创建用户成功:", newUser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("找到用户:", user.name || user.email);
|
||||||
|
|
||||||
|
// 获取所有已发布的题目
|
||||||
|
const problems = await prisma.problem.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
select: { id: true, displayId: true, localizations: {
|
||||||
|
where: { locale: "en", type: "TITLE" },
|
||||||
|
select: { content: true }
|
||||||
|
} }
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`找到 ${problems.length} 道已发布题目`);
|
||||||
|
|
||||||
|
// 为这个用户生成提交记录
|
||||||
|
const submissionCount = Math.min(problems.length, 8); // 最多8道题目
|
||||||
|
const selectedProblems = problems.slice(0, submissionCount);
|
||||||
|
|
||||||
|
for (const problem of selectedProblems) {
|
||||||
|
// 为每道题目生成1-3次提交
|
||||||
|
const attempts = Math.floor(Math.random() * 3) + 1;
|
||||||
|
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
// 60%概率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();
|
2
prisma/migrations/20250621055555_lg/migration.sql
Normal file
2
prisma/migrations/20250621055555_lg/migration.sql
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
-- AlterEnum
|
||||||
|
ALTER TYPE "Role" ADD VALUE 'TEACHER';
|
@ -11,6 +11,7 @@ generator client {
|
|||||||
enum Role {
|
enum Role {
|
||||||
ADMIN
|
ADMIN
|
||||||
GUEST
|
GUEST
|
||||||
|
TEACHER
|
||||||
}
|
}
|
||||||
|
|
||||||
enum Difficulty {
|
enum Difficulty {
|
||||||
|
144
prisma/test-student-dashboard.ts
Normal file
144
prisma/test-student-dashboard.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
import { PrismaClient } from "@/generated/client";
|
||||||
|
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
async function testStudentDashboard() {
|
||||||
|
console.log("测试学生仪表板数据获取...");
|
||||||
|
|
||||||
|
// 获取一个学生用户
|
||||||
|
const student = await prisma.user.findFirst({
|
||||||
|
where: { role: "GUEST" },
|
||||||
|
select: { id: true, name: true, email: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!student) {
|
||||||
|
console.log("未找到学生用户,创建测试学生...");
|
||||||
|
const newStudent = await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
name: "测试学生",
|
||||||
|
email: "test_student@example.com",
|
||||||
|
password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u",
|
||||||
|
role: "GUEST",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
console.log(`创建学生: ${newStudent.name} (${newStudent.email})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有已发布的题目
|
||||||
|
const allProblems = await prisma.problem.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayId: true,
|
||||||
|
difficulty: true,
|
||||||
|
localizations: {
|
||||||
|
where: {
|
||||||
|
type: "TITLE",
|
||||||
|
locale: "en" // 或者根据需求使用其他语言
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
content: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`总题目数: ${allProblems.length}`);
|
||||||
|
|
||||||
|
// 获取学生的所有提交记录
|
||||||
|
const userSubmissions = await prisma.submission.findMany({
|
||||||
|
where: { userId: student?.id },
|
||||||
|
include: {
|
||||||
|
problem: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayId: true,
|
||||||
|
difficulty: true,
|
||||||
|
localizations: {
|
||||||
|
where: {
|
||||||
|
type: "TITLE",
|
||||||
|
locale: "en" // 或者根据需求使用其他语言
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
content: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`学生提交记录数: ${userSubmissions.length}`);
|
||||||
|
|
||||||
|
// 计算题目完成情况
|
||||||
|
const completedProblems = new Set();
|
||||||
|
const attemptedProblems = new Set();
|
||||||
|
const wrongSubmissions = new Map(); // problemId -> count
|
||||||
|
|
||||||
|
userSubmissions.forEach(submission => {
|
||||||
|
attemptedProblems.add(submission.problemId);
|
||||||
|
|
||||||
|
if (submission.status === "AC") {
|
||||||
|
completedProblems.add(submission.problemId);
|
||||||
|
} else {
|
||||||
|
// 统计错误提交次数
|
||||||
|
const currentCount = wrongSubmissions.get(submission.problemId) || 0;
|
||||||
|
wrongSubmissions.set(submission.problemId, currentCount + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 题目完成比例数据
|
||||||
|
const completionData = {
|
||||||
|
total: allProblems.length,
|
||||||
|
completed: completedProblems.size,
|
||||||
|
percentage: allProblems.length > 0 ? Math.round((completedProblems.size / allProblems.length) * 100) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 错题比例数据
|
||||||
|
const totalSubmissions = userSubmissions.length;
|
||||||
|
const wrongSubmissionsCount = userSubmissions.filter(s => s.status !== "AC").length;
|
||||||
|
const errorData = {
|
||||||
|
total: totalSubmissions,
|
||||||
|
wrong: wrongSubmissionsCount,
|
||||||
|
percentage: totalSubmissions > 0 ? Math.round((wrongSubmissionsCount / totalSubmissions) * 100) : 0,
|
||||||
|
};
|
||||||
|
//易错题列表(按错误次数排序)
|
||||||
|
const difficultProblems = Array.from(wrongSubmissions.entries())
|
||||||
|
.map(([problemId, errorCount]) => {
|
||||||
|
const problem = allProblems.find(p => p.id === problemId);
|
||||||
|
// 从 localizations 获取标题(英文优先)
|
||||||
|
const title = problem?.localizations?.find(l => l.content === "TITLE")?.content || "未知题目";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: problem?.displayId || problemId,
|
||||||
|
title: title, // 使用从 localizations 获取的标题
|
||||||
|
difficulty: problem?.difficulty || "未知",
|
||||||
|
errorCount: errorCount as number,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.errorCount - a.errorCount)
|
||||||
|
.slice(0, 10);
|
||||||
|
|
||||||
|
console.log("\n=== 学生仪表板数据 ===");
|
||||||
|
console.log(`题目完成情况: ${completionData.completed}/${completionData.total} (${completionData.percentage}%)`);
|
||||||
|
console.log(`提交正确率: ${errorData.total - errorData.wrong}/${errorData.total} (${100 - errorData.percentage}%)`);
|
||||||
|
console.log(`易错题数量: ${difficultProblems.length}`);
|
||||||
|
|
||||||
|
if (difficultProblems.length > 0) {
|
||||||
|
console.log("\n易错题列表:");
|
||||||
|
difficultProblems.forEach((problem, index) => {
|
||||||
|
console.log(`${index + 1}. 题目${problem.id} (${problem.title}) - ${problem.difficulty} - 错误${problem.errorCount}次`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("\n测试完成!");
|
||||||
|
}
|
||||||
|
|
||||||
|
testStudentDashboard()
|
||||||
|
.catch((e) => {
|
||||||
|
console.error("测试学生仪表板时出错:", e);
|
||||||
|
process.exit(1);
|
||||||
|
})
|
||||||
|
.finally(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
79
prisma/test-student-data-access.ts
Normal file
79
prisma/test-student-data-access.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// import { PrismaClient } from "@/generated/client";
|
||||||
|
|
||||||
|
// const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
// async function testStudentDataAccess() {
|
||||||
|
// console.log("测试学生数据访问...");
|
||||||
|
|
||||||
|
// try {
|
||||||
|
// // 1. 检查是否有学生用户
|
||||||
|
// const students = await prisma.user.findMany({
|
||||||
|
// where: { role: "GUEST" },
|
||||||
|
// select: { id: true, name: true, email: true }
|
||||||
|
// });
|
||||||
|
// console.log(`找到 ${students.length} 个学生用户:`);
|
||||||
|
// students.forEach(s => console.log(` - ${s.name} (${s.email})`));
|
||||||
|
|
||||||
|
// if (students.length === 0) {
|
||||||
|
// console.log("没有学生用户,创建测试学生...");
|
||||||
|
// const testStudent = await prisma.user.create({
|
||||||
|
// data: {
|
||||||
|
// name: "测试学生",
|
||||||
|
// email: "test_student@example.com",
|
||||||
|
// password: "$2b$10$SD1T/dYvKTArGdTmf8ERxuBKIONxY01/wSboRNaNsHnKZzDhps/0u",
|
||||||
|
// role: "GUEST",
|
||||||
|
// },
|
||||||
|
// });
|
||||||
|
// console.log(`创建学生: ${testStudent.name}`);
|
||||||
|
// }
|
||||||
|
|
||||||
|
// // 2. 检查已发布的题目
|
||||||
|
// const publishedProblems = await prisma.problem.findMany({
|
||||||
|
// where: { isPublished: true },
|
||||||
|
// select: { id: true,
|
||||||
|
// displayId: true,
|
||||||
|
// localizations: {
|
||||||
|
// where: { locale: "en", type: "TITLE" },
|
||||||
|
// select: { content: true }
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// console.log(`\n已发布题目数量: ${publishedProblems.length}`);
|
||||||
|
// publishedProblems.slice(0, 5).forEach((p: any) => {
|
||||||
|
// const title = p.localizations?.find((l: any) => l.type === "TITLE")?.content || "无标题";
|
||||||
|
// console.log(` - ${p.displayId}: ${title}`);
|
||||||
|
// });
|
||||||
|
|
||||||
|
// // 3. 检查提交记录
|
||||||
|
// const allSubmissions = await prisma.submission.findMany({
|
||||||
|
// select: { id: true, userId: true, problemId: true, status: true }
|
||||||
|
// });
|
||||||
|
// console.log(`\n总提交记录数: ${allSubmissions.length}`);
|
||||||
|
|
||||||
|
// // 4. 检查特定学生的提交记录
|
||||||
|
// const firstStudent = students[0] || await prisma.user.findFirst({ where: { role: "GUEST" } });
|
||||||
|
// if (firstStudent) {
|
||||||
|
// const studentSubmissions = await prisma.submission.findMany({
|
||||||
|
// where: { userId: firstStudent.id },
|
||||||
|
// include: {
|
||||||
|
// problem: { select: { displayId: true, localizations: {
|
||||||
|
// where: { locale: "en", type: "TITLE" },
|
||||||
|
// select: { content: true }
|
||||||
|
// } } }
|
||||||
|
// }
|
||||||
|
// });
|
||||||
|
// console.log(`\n学生 ${firstStudent.name} 的提交记录数: ${studentSubmissions.length}`);
|
||||||
|
// studentSubmissions.slice(0, 3).forEach(s => {
|
||||||
|
// console.log(` - 题目${s.problem.displayId}: ${s.status}`);
|
||||||
|
// });
|
||||||
|
// }
|
||||||
|
|
||||||
|
// console.log("\n数据访问测试完成!");
|
||||||
|
// } catch (error) {
|
||||||
|
// console.error("测试过程中出错:", error);
|
||||||
|
// } finally {
|
||||||
|
// await prisma.$disconnect();
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
|
||||||
|
// testStudentDataAccess();
|
@ -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("获取用户信息失败");
|
|
||||||
}
|
|
||||||
}
|
|
@ -1,90 +0,0 @@
|
|||||||
"use client"
|
|
||||||
import React, { useState } from "react"
|
|
||||||
import { AppSidebar } from "@/components/management-sidebar/manage-sidebar"
|
|
||||||
import {
|
|
||||||
Breadcrumb,
|
|
||||||
BreadcrumbItem,
|
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@/components/ui/breadcrumb"
|
|
||||||
import { Separator } from "@/components/ui/separator"
|
|
||||||
import {
|
|
||||||
SidebarInset,
|
|
||||||
SidebarProvider,
|
|
||||||
SidebarTrigger,
|
|
||||||
} from "@/components/ui/sidebar"
|
|
||||||
import ProfilePage from "./profile/page"
|
|
||||||
import ChangePasswordPage from "./change-password/page"
|
|
||||||
|
|
||||||
// 模拟菜单数据
|
|
||||||
const menuItems = [
|
|
||||||
{ title: "登录信息", key: "profile" },
|
|
||||||
{ title: "修改密码", key: "change-password" },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function ManagementDefaultPage() {
|
|
||||||
const [activePage, setActivePage] = useState("profile")
|
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false)
|
|
||||||
|
|
||||||
const renderContent = () => {
|
|
||||||
switch (activePage) {
|
|
||||||
case "profile":
|
|
||||||
return <ProfilePage />
|
|
||||||
case "change-password":
|
|
||||||
return <ChangePasswordPage />
|
|
||||||
default:
|
|
||||||
return <ProfilePage />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const toggleSidebar = () => {
|
|
||||||
setIsCollapsed((prev) => !prev)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarProvider>
|
|
||||||
<div className="flex h-screen w-screen overflow-hidden">
|
|
||||||
{/* 左侧侧边栏 */}
|
|
||||||
{!isCollapsed && (
|
|
||||||
<div className="w-64 border-r bg-background flex-shrink-0 p-4">
|
|
||||||
<AppSidebar onItemClick={setActivePage} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 右侧主内容区域 */}
|
|
||||||
<SidebarInset className="h-full w-full overflow-auto">
|
|
||||||
<header className="bg-background sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
|
||||||
{/* 折叠按钮 */}
|
|
||||||
<SidebarTrigger className="-ml-1" onClick={toggleSidebar} />
|
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
|
||||||
|
|
||||||
{/* 面包屑导航 */}
|
|
||||||
<Breadcrumb>
|
|
||||||
<BreadcrumbList>
|
|
||||||
<BreadcrumbItem className="hidden md:block">
|
|
||||||
<BreadcrumbLink href="/management">管理面板</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbPage>
|
|
||||||
{menuItems.find((item) => item.key === activePage)?.title}
|
|
||||||
</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
</header>
|
|
||||||
{/* 主体内容:根据 isCollapsed 切换样式 */}
|
|
||||||
<main
|
|
||||||
className={`flex-1 p-6 bg-background transition-all duration-300 ${
|
|
||||||
isCollapsed ? "w-full" : "md:w-[calc(100%-16rem)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{renderContent()}
|
|
||||||
</main>
|
|
||||||
</SidebarInset>
|
|
||||||
</div>
|
|
||||||
</SidebarProvider>
|
|
||||||
)
|
|
||||||
}
|
|
@ -0,0 +1,17 @@
|
|||||||
|
import { ProblemEditView } from "@/features/admin/ui/views/problem-edit-view";
|
||||||
|
|
||||||
|
// interface PageProps {
|
||||||
|
// params: Promise<{ problemId: string }>;
|
||||||
|
// }
|
||||||
|
|
||||||
|
const Page = async (
|
||||||
|
// { params }: PageProps
|
||||||
|
) => {
|
||||||
|
// const { problemId } = await params;
|
||||||
|
|
||||||
|
return <ProblemEditView
|
||||||
|
// problemId={problemId}
|
||||||
|
/>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Page;
|
@ -1,13 +0,0 @@
|
|||||||
import { ProblemEditView } from "@/features/admin/ui/views/problem-edit-view";
|
|
||||||
|
|
||||||
interface PageProps {
|
|
||||||
params: Promise<{ problemId: string }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Page = async ({ params }: PageProps) => {
|
|
||||||
const { problemId } = await params;
|
|
||||||
|
|
||||||
return <ProblemEditView problemId={problemId} />;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Page;
|
|
@ -0,0 +1,175 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
|
||||||
|
export async function getStudentDashboardData() {
|
||||||
|
try {
|
||||||
|
console.log("=== 开始获取学生仪表板数据 ===");
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
console.log("Session 获取成功:", !!session);
|
||||||
|
console.log("Session user:", session?.user);
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
console.log("用户未登录或session无效");
|
||||||
|
throw new Error("未登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = session.user.id;
|
||||||
|
console.log("当前用户ID:", userId);
|
||||||
|
|
||||||
|
// 检查用户是否存在
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { id: true, name: true, email: true, role: true }
|
||||||
|
});
|
||||||
|
console.log("当前用户信息:", currentUser);
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
throw new Error("用户不存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有已发布的题目(包含英文标题)
|
||||||
|
const allProblems = await prisma.problem.findMany({
|
||||||
|
where: { isPublished: true },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayId: true,
|
||||||
|
difficulty: true,
|
||||||
|
localizations: {
|
||||||
|
where: {
|
||||||
|
type: "TITLE",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("已发布题目数量:", allProblems.length);
|
||||||
|
console.log("题目列表:", allProblems.map(p => ({
|
||||||
|
id: p.id,
|
||||||
|
displayId: p.displayId,
|
||||||
|
title: p.localizations[0]?.content || "无标题",
|
||||||
|
difficulty: p.difficulty
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 获取当前学生的所有提交记录(包含题目英文标题)
|
||||||
|
const userSubmissions = await prisma.submission.findMany({
|
||||||
|
where: { userId: userId },
|
||||||
|
include: {
|
||||||
|
problem: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayId: true,
|
||||||
|
difficulty: true,
|
||||||
|
localizations: {
|
||||||
|
where: {
|
||||||
|
type: "TITLE",
|
||||||
|
locale: "en" // 或者根据需求使用其他语言
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
content: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("当前用户提交记录数量:", userSubmissions.length);
|
||||||
|
console.log("提交记录详情:", userSubmissions.map(s => ({
|
||||||
|
problemId: s.problemId,
|
||||||
|
problemDisplayId: s.problem.displayId,
|
||||||
|
title: s.problem.localizations[0]?.content || "无标题",
|
||||||
|
difficulty: s.problem.difficulty,
|
||||||
|
status: s.status
|
||||||
|
})));
|
||||||
|
|
||||||
|
// 计算题目完成情况
|
||||||
|
const completedProblems = new Set<string | number>();
|
||||||
|
const attemptedProblems = new Set<string | number>();
|
||||||
|
const wrongSubmissions = new Map<string | number, number>(); // problemId -> count
|
||||||
|
|
||||||
|
userSubmissions.forEach((submission) => {
|
||||||
|
attemptedProblems.add(submission.problemId);
|
||||||
|
|
||||||
|
if (submission.status === "AC") {
|
||||||
|
completedProblems.add(submission.problemId);
|
||||||
|
} else {
|
||||||
|
// 统计错误提交次数
|
||||||
|
const currentCount = wrongSubmissions.get(submission.problemId) || 0;
|
||||||
|
wrongSubmissions.set(submission.problemId, currentCount + 1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("尝试过的题目数:", attemptedProblems.size);
|
||||||
|
console.log("完成的题目数:", completedProblems.size);
|
||||||
|
console.log("错误提交统计:", Object.fromEntries(wrongSubmissions));
|
||||||
|
|
||||||
|
// 题目完成比例数据
|
||||||
|
const completionData = {
|
||||||
|
total: allProblems.length,
|
||||||
|
completed: completedProblems.size,
|
||||||
|
percentage: allProblems.length > 0 ? Math.round((completedProblems.size / allProblems.length) * 100) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 错题比例数据 - 基于已完成的题目计算
|
||||||
|
const wrongProblems = new Set<string | number>();
|
||||||
|
|
||||||
|
// 统计在已完成的题目中,哪些题目曾经有过错误提交
|
||||||
|
userSubmissions.forEach((submission) => {
|
||||||
|
if (submission.status !== "AC" && completedProblems.has(submission.problemId)) {
|
||||||
|
wrongProblems.add(submission.problemId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const errorData = {
|
||||||
|
total: completedProblems.size, // 已完成的题目总数
|
||||||
|
wrong: wrongProblems.size, // 在已完成的题目中有过错误的题目数
|
||||||
|
percentage: completedProblems.size > 0 ? Math.round((wrongProblems.size / completedProblems.size) * 100) : 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 易错题列表(按错误次数排序)
|
||||||
|
const difficultProblems = Array.from(wrongSubmissions.entries())
|
||||||
|
.map(([problemId, errorCount]) => {
|
||||||
|
const problem = allProblems.find((p) => p.id === problemId);
|
||||||
|
|
||||||
|
// 从 problem.localizations 中获取标题
|
||||||
|
const title = problem?.localizations?.find((loc) => loc.type === "TITLE")?.content || "未知题目";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: problem?.displayId || problemId,
|
||||||
|
title: title, // 使用从 localizations 获取的标题
|
||||||
|
difficulty: problem?.difficulty || "未知",
|
||||||
|
errorCount: errorCount as number,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.errorCount - a.errorCount)
|
||||||
|
.slice(0, 10); // 只显示前10个
|
||||||
|
|
||||||
|
const result = {
|
||||||
|
completionData,
|
||||||
|
errorData,
|
||||||
|
difficultProblems,
|
||||||
|
pieChartData: [
|
||||||
|
{ name: "已完成", value: completionData.completed },
|
||||||
|
{ name: "未完成", value: completionData.total - completionData.completed },
|
||||||
|
],
|
||||||
|
errorPieChartData: [
|
||||||
|
{ name: "正确", value: errorData.total - errorData.wrong },
|
||||||
|
{ name: "错误", value: errorData.wrong },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
console.log("=== 返回的数据 ===");
|
||||||
|
console.log("完成情况:", completionData);
|
||||||
|
console.log("错误情况:", errorData);
|
||||||
|
console.log("易错题数量:", difficultProblems.length);
|
||||||
|
console.log("=== 数据获取完成 ===");
|
||||||
|
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取学生仪表板数据失败:", error);
|
||||||
|
throw new Error(`获取数据失败: ${error instanceof Error ? error.message : '未知错误'}`);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,206 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Locale, Status, ProblemLocalization } from "@/generated/client";
|
||||||
|
import { getLocale } from "next-intl/server";
|
||||||
|
|
||||||
|
const getLocalizedTitle = (
|
||||||
|
localizations: ProblemLocalization[],
|
||||||
|
locale: Locale
|
||||||
|
) => {
|
||||||
|
if (!localizations || localizations.length === 0) {
|
||||||
|
return "Unknown Title";
|
||||||
|
}
|
||||||
|
|
||||||
|
const localization = localizations.find(
|
||||||
|
(localization) => localization.locale === locale
|
||||||
|
);
|
||||||
|
|
||||||
|
return localization?.content ?? localizations[0].content ?? "Unknown Title";
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ProblemCompletionData {
|
||||||
|
problemId: string;
|
||||||
|
problemDisplayId: number;
|
||||||
|
problemTitle: string;
|
||||||
|
completed: number;
|
||||||
|
uncompleted: number;
|
||||||
|
total: number;
|
||||||
|
completedPercent: number;
|
||||||
|
uncompletedPercent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DifficultProblemData {
|
||||||
|
id: string;
|
||||||
|
className: string;
|
||||||
|
problemCount: number;
|
||||||
|
problemTitle: string;
|
||||||
|
problemDisplayId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getProblemCompletionData(): Promise<ProblemCompletionData[]> {
|
||||||
|
// 获取所有提交记录,按题目分组统计
|
||||||
|
const submissions = await prisma.submission.findMany({
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
problem: {
|
||||||
|
include:{
|
||||||
|
localizations:true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const locale = await getLocale();
|
||||||
|
|
||||||
|
// 按题目分组统计完成情况(统计独立用户数)
|
||||||
|
const problemStats = new Map<string, {
|
||||||
|
completedUsers: Set<string>;
|
||||||
|
totalUsers: Set<string>;
|
||||||
|
title: string;
|
||||||
|
displayId: number;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
submissions.forEach((submission) => {
|
||||||
|
const localizations=submission.problem.localizations;
|
||||||
|
const title=getLocalizedTitle(localizations,locale as Locale);
|
||||||
|
const problemId = submission.problemId;
|
||||||
|
const problemTitle = title;
|
||||||
|
const problemDisplayId = submission.problem.displayId;
|
||||||
|
const userId = submission.userId;
|
||||||
|
const isCompleted = submission.status === Status.AC; // 只有 Accepted 才算完成
|
||||||
|
|
||||||
|
if (!problemStats.has(problemId)) {
|
||||||
|
problemStats.set(problemId, {
|
||||||
|
completedUsers: new Set(),
|
||||||
|
totalUsers: new Set(),
|
||||||
|
title: problemTitle,
|
||||||
|
displayId: problemDisplayId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = problemStats.get(problemId)!;
|
||||||
|
stats.totalUsers.add(userId);
|
||||||
|
if (isCompleted) {
|
||||||
|
stats.completedUsers.add(userId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 如果没有数据,返回空数组
|
||||||
|
if (problemStats.size === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为图表数据格式,按题目displayId排序
|
||||||
|
const problemDataArray = Array.from(problemStats.entries()).map(([problemId, stats]) => {
|
||||||
|
const completed = stats.completedUsers.size;
|
||||||
|
const total = stats.totalUsers.size;
|
||||||
|
|
||||||
|
return {
|
||||||
|
problemId: problemId,
|
||||||
|
problemDisplayId: stats.displayId,
|
||||||
|
problemTitle: stats.title,
|
||||||
|
completed: completed,
|
||||||
|
uncompleted: total - completed,
|
||||||
|
total: total,
|
||||||
|
completedPercent: total > 0 ? (completed / total) * 100 : 0,
|
||||||
|
uncompletedPercent: total > 0 ? ((total - completed) / total) * 100 : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按题目编号排序
|
||||||
|
return problemDataArray.sort((a, b) => a.problemDisplayId - b.problemDisplayId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDifficultProblemsData(): Promise<DifficultProblemData[]> {
|
||||||
|
// 获取所有测试用例结果
|
||||||
|
const testcaseResults = await prisma.testcaseResult.findMany({
|
||||||
|
include: {
|
||||||
|
testcase: {
|
||||||
|
include: {
|
||||||
|
problem: {
|
||||||
|
include: {
|
||||||
|
localizations: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
submission: {
|
||||||
|
include: {
|
||||||
|
user: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 按问题分组统计错误率
|
||||||
|
const problemStats = new Map<string, {
|
||||||
|
totalAttempts: number;
|
||||||
|
wrongAttempts: number;
|
||||||
|
title: string;
|
||||||
|
displayId: number;
|
||||||
|
users: Set<string>;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
testcaseResults.forEach((result) => {
|
||||||
|
const problemId = result.testcase.problemId;
|
||||||
|
const problemTitle = result.testcase.problem.localizations?.find(
|
||||||
|
(loc) => loc.type === "TITLE"
|
||||||
|
)?.content || "无标题";
|
||||||
|
const problemDisplayId = result.testcase.problem.displayId;
|
||||||
|
const userId = result.submission.userId;
|
||||||
|
const isWrong = !result.isCorrect;
|
||||||
|
|
||||||
|
if (!problemStats.has(problemId)) {
|
||||||
|
problemStats.set(problemId, {
|
||||||
|
totalAttempts: 0,
|
||||||
|
wrongAttempts: 0,
|
||||||
|
title: problemTitle,
|
||||||
|
displayId: problemDisplayId,
|
||||||
|
users: new Set(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const stats = problemStats.get(problemId)!;
|
||||||
|
stats.totalAttempts++;
|
||||||
|
stats.users.add(userId);
|
||||||
|
if (isWrong) {
|
||||||
|
stats.wrongAttempts++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 计算错误率并筛选易错题(错误率 > 30% 且至少有3次尝试)
|
||||||
|
const difficultProblems = Array.from(problemStats.entries())
|
||||||
|
.map(([problemId, stats]) => ({
|
||||||
|
id: problemId,
|
||||||
|
className: `题目 ${stats.title}`,
|
||||||
|
problemCount: stats.wrongAttempts,
|
||||||
|
problemTitle: stats.title,
|
||||||
|
problemDisplayId: stats.displayId,
|
||||||
|
errorRate: (stats.wrongAttempts / stats.totalAttempts) * 100,
|
||||||
|
uniqueUsers: stats.users.size,
|
||||||
|
totalAttempts: stats.totalAttempts,
|
||||||
|
}))
|
||||||
|
.filter((problem) =>
|
||||||
|
problem.errorRate > 30 && // 错误率超过30%
|
||||||
|
problem.totalAttempts >= 3 // 至少有3次尝试
|
||||||
|
)
|
||||||
|
.sort((a, b) => b.errorRate - a.errorRate) // 按错误率降序排列
|
||||||
|
.slice(0, 10); // 取前10个最难的题目
|
||||||
|
|
||||||
|
return difficultProblems;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDashboardStats() {
|
||||||
|
const [problemData, difficultProblems] = await Promise.all([
|
||||||
|
getProblemCompletionData(),
|
||||||
|
getDifficultProblemsData(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
problemData,
|
||||||
|
difficultProblems,
|
||||||
|
totalProblems: problemData.length,
|
||||||
|
totalDifficultProblems: difficultProblems.length,
|
||||||
|
};
|
||||||
|
}
|
@ -0,0 +1,211 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { getStudentDashboardData } from "@/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard";
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
completionData: {
|
||||||
|
total: number;
|
||||||
|
completed: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
errorData: {
|
||||||
|
total: number;
|
||||||
|
wrong: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
difficultProblems: Array<{
|
||||||
|
id: string | number;
|
||||||
|
title: string;
|
||||||
|
difficulty: string;
|
||||||
|
errorCount: number;
|
||||||
|
}>;
|
||||||
|
pieChartData: Array<{ name: string; value: number }>;
|
||||||
|
errorPieChartData: Array<{ name: string; value: number }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StudentDashboard() {
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
console.log("开始获取学生仪表板数据...");
|
||||||
|
setLoading(true);
|
||||||
|
const dashboardData = await getStudentDashboardData();
|
||||||
|
console.log("获取到的数据:", dashboardData);
|
||||||
|
console.log("完成情况:", dashboardData.completionData);
|
||||||
|
console.log("错误情况:", dashboardData.errorData);
|
||||||
|
console.log("易错题:", dashboardData.difficultProblems);
|
||||||
|
setData(dashboardData);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("获取数据时出错:", err);
|
||||||
|
setError(err instanceof Error ? err.message : "获取数据失败");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="text-center">加载中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="text-center text-red-500">错误: {error}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6">
|
||||||
|
<div className="text-center">暂无数据</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { completionData, errorData, difficultProblems, pieChartData, errorPieChartData } = data;
|
||||||
|
const COLORS = ["#4CAF50", "#FFC107"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">学生仪表板</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* 题目完成比例模块 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>题目完成比例</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span>已完成题目:{completionData.completed}/{completionData.total}</span>
|
||||||
|
<span className="text-green-500">{completionData.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={completionData.percentage} className="h-2" />
|
||||||
|
<div className="h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={pieChartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{pieChartData.map((entry: { name: string; value: number }, index: number) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 错题比例模块 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>错题比例</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span>错题数量:{errorData.wrong}/{errorData.total}</span>
|
||||||
|
<span className="text-yellow-500">{errorData.percentage}%</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={errorData.percentage} className="h-2" />
|
||||||
|
<div className="h-[200px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={errorPieChartData}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={60}
|
||||||
|
outerRadius={80}
|
||||||
|
fill="#8884d8"
|
||||||
|
paddingAngle={5}
|
||||||
|
dataKey="value"
|
||||||
|
>
|
||||||
|
{errorPieChartData.map((entry: { name: string; value: number }, index: number) => (
|
||||||
|
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 易错题练习模块 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>易错题练习</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span>易错题数量:{difficultProblems.length}</span>
|
||||||
|
</div>
|
||||||
|
{difficultProblems.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>题目ID</TableHead>
|
||||||
|
<TableHead>题目名称</TableHead>
|
||||||
|
<TableHead>难度</TableHead>
|
||||||
|
<TableHead>错误次数</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{difficultProblems.map((problem: { id: string | number; title: string; difficulty: string; errorCount: number }) => (
|
||||||
|
<TableRow key={problem.id}>
|
||||||
|
<TableCell>{problem.id}</TableCell>
|
||||||
|
<TableCell>{problem.title}</TableCell>
|
||||||
|
<TableCell>{problem.difficulty}</TableCell>
|
||||||
|
<TableCell>{problem.errorCount}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-gray-500 py-8">
|
||||||
|
暂无易错题数据
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,253 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { TrendingUp } from "lucide-react";
|
||||||
|
import { Bar, BarChart, XAxis, YAxis, LabelList, CartesianGrid } from "recharts";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from "@/components/ui/chart";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table";
|
||||||
|
import { getDashboardStats, ProblemCompletionData, DifficultProblemData } from "@/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard";
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE = 5; // 每页显示的题目数量
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
completed: {
|
||||||
|
label: "已完成",
|
||||||
|
color: "#4CAF50", // 使用更鲜明的颜色
|
||||||
|
},
|
||||||
|
uncompleted: {
|
||||||
|
label: "未完成",
|
||||||
|
color: "#FFA726", // 使用更鲜明的颜色
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
export default function TeacherDashboard() {
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [chartData, setChartData] = useState<ProblemCompletionData[]>([]);
|
||||||
|
const [difficultProblems, setDifficultProblems] = useState<DifficultProblemData[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const data = await getDashboardStats();
|
||||||
|
setChartData(data.problemData);
|
||||||
|
setDifficultProblems(data.difficultProblems);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||||
|
console.error('Failed to fetch dashboard data:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(chartData.length / ITEMS_PER_PAGE);
|
||||||
|
|
||||||
|
// 获取当前页的数据
|
||||||
|
const currentPageData = chartData.slice(
|
||||||
|
(currentPage - 1) * ITEMS_PER_PAGE,
|
||||||
|
currentPage * ITEMS_PER_PAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">教师仪表板</h1>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-lg">加载中...</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">教师仪表板</h1>
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-lg text-red-500">错误: {error}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
|
<h1 className="text-3xl font-bold mb-6">教师仪表板</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
{/* 题目完成情况模块 */}
|
||||||
|
<Card className="min-h-[450px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>题目完成情况</CardTitle>
|
||||||
|
<CardDescription>各题目完成及未完成人数图表</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{chartData.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-lg text-muted-foreground">暂无数据</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChartContainer config={chartConfig} className="height-{400px}">
|
||||||
|
<BarChart
|
||||||
|
data={currentPageData}
|
||||||
|
layout="vertical"
|
||||||
|
margin={{
|
||||||
|
top: 20,
|
||||||
|
right: 30,
|
||||||
|
left: 40,
|
||||||
|
bottom: 5,
|
||||||
|
}}
|
||||||
|
barCategoryGap={20}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
domain={[0, 100]}
|
||||||
|
tickFormatter={(value) => `${value}%`}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
dataKey="problemDisplayId"
|
||||||
|
type="category"
|
||||||
|
tickLine={false}
|
||||||
|
tickMargin={10}
|
||||||
|
width={80}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={<ChartTooltipContent />}
|
||||||
|
/>
|
||||||
|
<Bar
|
||||||
|
dataKey="completedPercent"
|
||||||
|
name="已完成"
|
||||||
|
fill={chartConfig.completed.color}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
>
|
||||||
|
<LabelList
|
||||||
|
dataKey="completed"
|
||||||
|
position="right"
|
||||||
|
fill="#000"
|
||||||
|
formatter={(value: number) => `${value}人`}
|
||||||
|
/>
|
||||||
|
</Bar>
|
||||||
|
<Bar
|
||||||
|
dataKey="uncompletedPercent"
|
||||||
|
name="未完成"
|
||||||
|
fill={chartConfig.uncompleted.color}
|
||||||
|
radius={[4, 4, 0, 0]}
|
||||||
|
>
|
||||||
|
<LabelList
|
||||||
|
dataKey="uncompleted"
|
||||||
|
position="right"
|
||||||
|
fill="#000"
|
||||||
|
formatter={(value: number) => `${value}人`}
|
||||||
|
/>
|
||||||
|
</Bar>
|
||||||
|
</BarChart>
|
||||||
|
</ChartContainer>
|
||||||
|
{/* 分页控制 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex justify-center items-center gap-2 mt-4">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span className="text-sm">
|
||||||
|
第 {currentPage} 页,共 {totalPages} 页
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="flex-col items-start gap-2 text-sm">
|
||||||
|
<div className="flex gap-2 leading-none font-medium">
|
||||||
|
完成度趋势 <TrendingUp className="h-4 w-4" />
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground leading-none">
|
||||||
|
显示各题目完成情况(已完成/未完成)
|
||||||
|
</div>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 学生易错题模块 */}
|
||||||
|
<Card className="min-h-[450px]">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>学生易错题</CardTitle>
|
||||||
|
<CardDescription>各班级易错题数量及列表</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span>出错率较高题目数量:{difficultProblems.length}</span>
|
||||||
|
</div>
|
||||||
|
{difficultProblems.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="text-lg text-muted-foreground">暂无易错题数据</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>题目编号</TableHead>
|
||||||
|
<TableHead>题目名称</TableHead>
|
||||||
|
<TableHead>错误次数</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{difficultProblems.map((problem) => (
|
||||||
|
<TableRow key={problem.id}>
|
||||||
|
<TableCell>{problem.problemDisplayId || problem.id.substring(0, 8)}</TableCell>
|
||||||
|
<TableCell>{problem.problemTitle}</TableCell>
|
||||||
|
<TableCell>{problem.problemCount}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -0,0 +1,89 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { getDashboardStats } from "@/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard";
|
||||||
|
|
||||||
|
interface DashboardData {
|
||||||
|
problemData: Array<{
|
||||||
|
problemId: string;
|
||||||
|
problemDisplayId: number;
|
||||||
|
problemTitle: string;
|
||||||
|
completed: number;
|
||||||
|
uncompleted: number;
|
||||||
|
total: number;
|
||||||
|
completedPercent: number;
|
||||||
|
uncompletedPercent: number;
|
||||||
|
}>;
|
||||||
|
difficultProblems: Array<{
|
||||||
|
id: string;
|
||||||
|
className: string;
|
||||||
|
problemCount: number;
|
||||||
|
problemTitle: string;
|
||||||
|
problemDisplayId: number;
|
||||||
|
}>;
|
||||||
|
totalProblems: number;
|
||||||
|
totalDifficultProblems: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TestDataPage() {
|
||||||
|
const [data, setData] = useState<DashboardData | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchData = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const result = await getDashboardStats();
|
||||||
|
setData(result);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof Error ? err.message : '获取数据失败');
|
||||||
|
console.error('Failed to fetch data:', err);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return <div>加载中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return <div>错误: {error}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<h1 className="text-2xl font-bold mb-4">数据测试页面</h1>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">题目完成数据</h2>
|
||||||
|
<pre className="bg-gray-100 p-4 rounded overflow-auto">
|
||||||
|
{JSON.stringify(data?.problemData, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">易错题数据</h2>
|
||||||
|
<pre className="bg-gray-100 p-4 rounded overflow-auto">
|
||||||
|
{JSON.stringify(data?.difficultProblems, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-semibold mb-2">统计信息</h2>
|
||||||
|
<pre className="bg-gray-100 p-4 rounded overflow-auto">
|
||||||
|
{JSON.stringify({
|
||||||
|
totalProblems: data?.totalProblems,
|
||||||
|
totalDifficultProblems: data?.totalDifficultProblems,
|
||||||
|
}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -1,12 +1,7 @@
|
|||||||
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
import { AppSidebar } from "@/components/sidebar/app-sidebar";
|
||||||
import {
|
import { AdminSidebar } from "@/components/sidebar/admin-sidebar";
|
||||||
Breadcrumb,
|
import { TeacherSidebar } from "@/components/sidebar/teacher-sidebar";
|
||||||
BreadcrumbItem,
|
import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb";
|
||||||
BreadcrumbLink,
|
|
||||||
BreadcrumbList,
|
|
||||||
BreadcrumbPage,
|
|
||||||
BreadcrumbSeparator,
|
|
||||||
} from "@/components/ui/breadcrumb";
|
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import {
|
import {
|
||||||
SidebarInset,
|
SidebarInset,
|
||||||
@ -14,39 +9,106 @@ import {
|
|||||||
SidebarTrigger,
|
SidebarTrigger,
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { auth } from "@/lib/auth";
|
import { auth } from "@/lib/auth";
|
||||||
import { notFound } from "next/navigation";
|
import prisma from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
interface LayoutProps {
|
interface LayoutProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface WrongProblem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function Layout({ children }: LayoutProps) {
|
export default async function Layout({ children }: LayoutProps) {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
if (!user) {
|
if (!user) {
|
||||||
notFound();
|
redirect("/sign-in");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 获取用户的完整信息(包括角色)
|
||||||
|
const fullUser = await prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { id: true, name: true, email: true, image: true, role: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fullUser) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据用户角色决定显示哪个侧边栏
|
||||||
|
const renderSidebar = () => {
|
||||||
|
switch (fullUser.role) {
|
||||||
|
case "ADMIN":
|
||||||
|
return <AdminSidebar user={user} />;
|
||||||
|
case "TEACHER":
|
||||||
|
return <TeacherSidebar user={user} />;
|
||||||
|
case "GUEST":
|
||||||
|
default:
|
||||||
|
// 学生(GUEST)需要查询错题数据
|
||||||
|
return <AppSidebar user={user} wrongProblems={[]} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 只有学生才需要查询错题数据
|
||||||
|
let wrongProblemsData: WrongProblem[] = [];
|
||||||
|
if (fullUser.role === "GUEST") {
|
||||||
|
// 查询未完成(未AC)题目的最新一次提交
|
||||||
|
const wrongProblems = await prisma.problem.findMany({
|
||||||
|
where: {
|
||||||
|
submissions: {
|
||||||
|
some: { userId: user.id },
|
||||||
|
},
|
||||||
|
NOT: {
|
||||||
|
submissions: {
|
||||||
|
some: { userId: user.id, status: "AC" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
displayId: true,
|
||||||
|
localizations: {
|
||||||
|
where: { locale: "zh", type: "TITLE" },
|
||||||
|
select: { content: true },
|
||||||
|
},
|
||||||
|
submissions: {
|
||||||
|
where: { userId: user.id },
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
take: 1,
|
||||||
|
select: {
|
||||||
|
status: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// 组装传递给 AppSidebar 的数据格式
|
||||||
|
wrongProblemsData = wrongProblems.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
name: p.localizations[0]?.content || `题目${p.displayId}`,
|
||||||
|
status: p.submissions[0]?.status || "-",
|
||||||
|
url: `/problems/${p.id}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarProvider>
|
<SidebarProvider>
|
||||||
<AppSidebar user={user} />
|
{fullUser.role === "GUEST" ? (
|
||||||
|
<AppSidebar user={user} wrongProblems={wrongProblemsData} />
|
||||||
|
) : (
|
||||||
|
renderSidebar()
|
||||||
|
)}
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="flex h-16 shrink-0 items-center gap-2">
|
<header className="flex h-16 shrink-0 items-center gap-2">
|
||||||
<div className="flex items-center gap-2 px-4">
|
<div className="flex items-center gap-2 px-4">
|
||||||
<SidebarTrigger className="-ml-1" />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mr-2 h-4" />
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
<Breadcrumb>
|
<DynamicBreadcrumb />
|
||||||
<BreadcrumbList>
|
|
||||||
<BreadcrumbItem className="hidden md:block">
|
|
||||||
<BreadcrumbLink href="#">
|
|
||||||
Building Your Application
|
|
||||||
</BreadcrumbLink>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
<BreadcrumbSeparator className="hidden md:block" />
|
|
||||||
<BreadcrumbItem>
|
|
||||||
<BreadcrumbPage>Data Fetching</BreadcrumbPage>
|
|
||||||
</BreadcrumbItem>
|
|
||||||
</BreadcrumbList>
|
|
||||||
</Breadcrumb>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
|
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">{children}</div>
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
// changePassword.ts
|
// changePassword.ts
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
import bcrypt from "bcryptjs";
|
import bcrypt from "bcryptjs";
|
||||||
|
|
||||||
@ -13,24 +14,40 @@ export async function changePassword(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 获取当前登录用户
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("用户未登录");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询当前用户信息
|
||||||
const user = await prisma.user.findUnique({
|
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) {
|
if (!user.password) {
|
||||||
throw new Error("用户密码未设置");
|
throw new Error("用户密码未设置");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 验证旧密码
|
||||||
const passwordHash: string = user.password as string;
|
const passwordHash: string = user.password as string;
|
||||||
const isMatch = await bcrypt.compare(oldPassword, passwordHash);
|
const isMatch = await bcrypt.compare(oldPassword, passwordHash);
|
||||||
if (!isMatch) throw new Error("旧密码错误");
|
if (!isMatch) {
|
||||||
|
throw new Error("旧密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 加密新密码
|
||||||
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
const hashedPassword = await bcrypt.hash(newPassword, 10);
|
||||||
|
|
||||||
|
// 更新密码
|
||||||
await prisma.user.update({
|
await prisma.user.update({
|
||||||
where: { id: '1' },
|
where: { id: userId },
|
||||||
data: { password: hashedPassword },
|
data: { password: hashedPassword },
|
||||||
});
|
});
|
||||||
|
|
@ -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("获取用户信息失败");
|
||||||
|
}
|
||||||
|
}
|
@ -1,6 +1,7 @@
|
|||||||
// updateUserInfo.ts
|
// updateUserInfo.ts
|
||||||
"use server";
|
"use server";
|
||||||
|
|
||||||
|
import { auth } from "@/lib/auth";
|
||||||
import prisma from "@/lib/prisma";
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
export async function updateUserInfo(formData: FormData) {
|
export async function updateUserInfo(formData: FormData) {
|
||||||
@ -12,8 +13,16 @@ export async function updateUserInfo(formData: FormData) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 获取当前会话
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
throw new Error("用户未登录");
|
||||||
|
}
|
||||||
|
|
||||||
const updatedUser = await prisma.user.update({
|
const updatedUser = await prisma.user.update({
|
||||||
where: { id: 'user_001' },
|
where: { id: userId },
|
||||||
data: { name, email },
|
data: { name, email },
|
||||||
});
|
});
|
||||||
|
|
@ -2,7 +2,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { changePassword } from "@/app/(app)/management/actions";
|
import { changePassword } from "@/app/(protected)/dashboard/management/actions/changePassword";
|
||||||
|
|
||||||
export default function ChangePasswordPage() {
|
export default function ChangePasswordPage() {
|
||||||
const [oldPassword, setOldPassword] = useState("");
|
const [oldPassword, setOldPassword] = useState("");
|
||||||
@ -50,8 +50,9 @@ export default function ChangePasswordPage() {
|
|||||||
await changePassword(formData);
|
await changePassword(formData);
|
||||||
setShowSuccess(true);
|
setShowSuccess(true);
|
||||||
setTimeout(() => setShowSuccess(false), 3000);
|
setTimeout(() => setShowSuccess(false), 3000);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
alert(error.message);
|
const errorMessage = error instanceof Error ? error.message : '修改密码失败';
|
||||||
|
alert(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
58
src/app/(protected)/dashboard/management/page.tsx
Normal file
58
src/app/(protected)/dashboard/management/page.tsx
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
"use client"
|
||||||
|
import React, { useState } from "react"
|
||||||
|
import { Separator } from "@/components/ui/separator"
|
||||||
|
import ProfilePage from "./profile/page"
|
||||||
|
import ChangePasswordPage from "./change-password/page"
|
||||||
|
|
||||||
|
export default function ManagementDefaultPage() {
|
||||||
|
const [activePage, setActivePage] = useState("profile")
|
||||||
|
|
||||||
|
const renderContent = () => {
|
||||||
|
switch (activePage) {
|
||||||
|
case "profile":
|
||||||
|
return <ProfilePage />
|
||||||
|
case "change-password":
|
||||||
|
return <ChangePasswordPage />
|
||||||
|
default:
|
||||||
|
return <ProfilePage />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-full w-full flex-col">
|
||||||
|
{/* 顶部导航栏 */}
|
||||||
|
<header className="bg-background sticky top-0 z-10 flex h-16 shrink-0 items-center gap-2 border-b px-4">
|
||||||
|
<Separator orientation="vertical" className="mr-2 h-4" />
|
||||||
|
|
||||||
|
{/* 页面切换按钮 */}
|
||||||
|
<div className="ml-auto flex space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActivePage("profile")}
|
||||||
|
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
||||||
|
activePage === "profile"
|
||||||
|
? "bg-black text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
登录信息
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActivePage("change-password")}
|
||||||
|
className={`px-3 py-1 rounded-md text-sm transition-colors ${
|
||||||
|
activePage === "change-password"
|
||||||
|
? "bg-black text-white"
|
||||||
|
: "bg-gray-100 text-gray-700 hover:bg-gray-200"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
修改密码
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* 主体内容 */}
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
{renderContent()}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
@ -2,17 +2,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
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 {
|
interface User {
|
||||||
id: string; // TEXT 类型
|
id: string;
|
||||||
name: string | null; // 可能为空
|
name: string | null;
|
||||||
email: string; // NOT NULL
|
email: string;
|
||||||
emailVerified: Date | null; // TIMESTAMP 转换为字符串
|
emailVerified?: Date | null;
|
||||||
image: string | null;
|
image: string | null;
|
||||||
role: "GUEST" | "USER" | "ADMIN"; // 枚举类型
|
role: "GUEST" | "USER" | "ADMIN" | "TEACHER";
|
||||||
createdAt: Date; // TIMESTAMP 转换为字符串
|
createdAt: Date;
|
||||||
updatedAt: Date; // TIMESTAMP 转换为字符串
|
updatedAt: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
@ -49,8 +50,9 @@ export default function ProfilePage() {
|
|||||||
const updatedUser = await updateUserInfo(formData);
|
const updatedUser = await updateUserInfo(formData);
|
||||||
setUser(updatedUser);
|
setUser(updatedUser);
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
} catch (error: any) {
|
} catch (error: unknown) {
|
||||||
alert(error.message);
|
const errorMessage = error instanceof Error ? error.message : '更新用户信息失败';
|
||||||
|
alert(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -1,3 +1,305 @@
|
|||||||
export default function Page() {
|
import { auth } from "@/lib/auth";
|
||||||
return <div className="h-full w-full border bg-blue-200">Dashboard</div>
|
import prisma from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Progress } from "@/components/ui/progress";
|
||||||
|
import {
|
||||||
|
Users,
|
||||||
|
BookOpen,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
TrendingUp,
|
||||||
|
AlertCircle,
|
||||||
|
BarChart3,
|
||||||
|
Target,
|
||||||
|
Activity
|
||||||
|
} from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
interface Stats {
|
||||||
|
totalUsers?: number;
|
||||||
|
totalProblems?: number;
|
||||||
|
totalSubmissions?: number;
|
||||||
|
totalStudents?: number;
|
||||||
|
completedProblems?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Activity {
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
time: Date;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function DashboardPage() {
|
||||||
|
const session = await auth();
|
||||||
|
const user = session?.user;
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户的完整信息
|
||||||
|
const fullUser = await prisma.user.findUnique({
|
||||||
|
where: { id: user.id },
|
||||||
|
select: { id: true, name: true, email: true, image: true, role: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!fullUser) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据用户角色获取不同的统计数据
|
||||||
|
let stats: Stats = {};
|
||||||
|
let recentActivity: Activity[] = [];
|
||||||
|
|
||||||
|
if (fullUser.role === "ADMIN") {
|
||||||
|
// 管理员统计
|
||||||
|
const [totalUsers, totalProblems, totalSubmissions, recentUsers] = await Promise.all([
|
||||||
|
prisma.user.count(),
|
||||||
|
prisma.problem.count(),
|
||||||
|
prisma.submission.count(),
|
||||||
|
prisma.user.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
select: { id: true, name: true, email: true, role: true, createdAt: true }
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
stats = { totalUsers, totalProblems, totalSubmissions };
|
||||||
|
recentActivity = recentUsers.map(user => ({
|
||||||
|
type: "新用户注册",
|
||||||
|
title: user.name || user.email,
|
||||||
|
description: `角色: ${user.role}`,
|
||||||
|
time: user.createdAt
|
||||||
|
}));
|
||||||
|
} else if (fullUser.role === "TEACHER") {
|
||||||
|
// 教师统计
|
||||||
|
const [totalStudents, totalProblems, totalSubmissions, recentSubmissions] = await Promise.all([
|
||||||
|
prisma.user.count({ where: { role: "GUEST" } }),
|
||||||
|
prisma.problem.count({ where: { isPublished: true } }),
|
||||||
|
prisma.submission.count(),
|
||||||
|
prisma.submission.findMany({
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
user: { select: { name: true, email: true } },
|
||||||
|
problem: {
|
||||||
|
select: {
|
||||||
|
displayId: true,
|
||||||
|
localizations: { where: { type: "TITLE", locale: "zh" }, select: { content: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
stats = { totalStudents, totalProblems, totalSubmissions };
|
||||||
|
recentActivity = recentSubmissions.map(sub => ({
|
||||||
|
type: "学生提交",
|
||||||
|
title: `${sub.user.name || sub.user.email} 提交了题目 ${sub.problem.displayId}`,
|
||||||
|
description: sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`,
|
||||||
|
time: sub.createdAt,
|
||||||
|
status: sub.status
|
||||||
|
}));
|
||||||
|
} else {
|
||||||
|
// 学生统计
|
||||||
|
const [totalProblems, completedProblems, totalSubmissions, recentSubmissions] = await Promise.all([
|
||||||
|
prisma.problem.count({ where: { isPublished: true } }),
|
||||||
|
prisma.submission.count({
|
||||||
|
where: {
|
||||||
|
userId: user.id,
|
||||||
|
status: "AC"
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
prisma.submission.count({ where: { userId: user.id } }),
|
||||||
|
prisma.submission.findMany({
|
||||||
|
where: { userId: user.id },
|
||||||
|
take: 5,
|
||||||
|
orderBy: { createdAt: "desc" },
|
||||||
|
include: {
|
||||||
|
problem: {
|
||||||
|
select: {
|
||||||
|
displayId: true,
|
||||||
|
localizations: { where: { type: "TITLE", locale: "zh" }, select: { content: true } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
|
stats = { totalProblems, completedProblems, totalSubmissions };
|
||||||
|
recentActivity = recentSubmissions.map(sub => ({
|
||||||
|
type: "我的提交",
|
||||||
|
title: `题目 ${sub.problem.displayId}`,
|
||||||
|
description: sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`,
|
||||||
|
time: sub.createdAt,
|
||||||
|
status: sub.status
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRoleConfig = () => {
|
||||||
|
switch (fullUser.role) {
|
||||||
|
case "ADMIN":
|
||||||
|
return {
|
||||||
|
title: "系统管理后台",
|
||||||
|
description: "管理整个系统的用户、题目和统计数据",
|
||||||
|
stats: [
|
||||||
|
{ label: "总用户数", value: stats.totalUsers, icon: Users, color: "text-blue-600" },
|
||||||
|
{ label: "总题目数", value: stats.totalProblems, icon: BookOpen, color: "text-green-600" },
|
||||||
|
{ label: "总提交数", value: stats.totalSubmissions, icon: Activity, color: "text-purple-600" }
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ label: "用户管理", href: "/dashboard/usermanagement/guest", icon: Users },
|
||||||
|
{ label: "题目管理", href: "/dashboard/usermanagement/problem", icon: BookOpen },
|
||||||
|
{ label: "管理员设置", href: "/dashboard/management", icon: Target }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
case "TEACHER":
|
||||||
|
return {
|
||||||
|
title: "教师教学平台",
|
||||||
|
description: "查看学生学习情况,管理教学资源",
|
||||||
|
stats: [
|
||||||
|
{ label: "学生数量", value: stats.totalStudents, icon: Users, color: "text-blue-600" },
|
||||||
|
{ label: "题目数量", value: stats.totalProblems, icon: BookOpen, color: "text-green-600" },
|
||||||
|
{ label: "提交数量", value: stats.totalSubmissions, icon: Activity, color: "text-purple-600" }
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ label: "学生管理", href: "/dashboard/usermanagement/guest", icon: Users },
|
||||||
|
{ label: "题目管理", href: "/dashboard/usermanagement/problem", icon: BookOpen },
|
||||||
|
{ label: "统计分析", href: "/dashboard/teacher/dashboard", icon: BarChart3 }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: "我的学习中心",
|
||||||
|
description: "继续您的编程学习之旅",
|
||||||
|
stats: [
|
||||||
|
{ label: "总题目数", value: stats.totalProblems, icon: BookOpen, color: "text-blue-600" },
|
||||||
|
{ label: "已完成", value: stats.completedProblems, icon: CheckCircle, color: "text-green-600" },
|
||||||
|
{ label: "提交次数", value: stats.totalSubmissions, icon: Activity, color: "text-purple-600" }
|
||||||
|
],
|
||||||
|
actions: [
|
||||||
|
{ label: "开始做题", href: "/problemset", icon: BookOpen },
|
||||||
|
{ label: "我的进度", href: "/dashboard/student/dashboard", icon: TrendingUp },
|
||||||
|
{ label: "个人设置", href: "/dashboard/management", icon: Target }
|
||||||
|
]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const config = getRoleConfig();
|
||||||
|
const completionRate = fullUser.role === "GUEST" ?
|
||||||
|
((stats.totalProblems || 0) > 0 ? ((stats.completedProblems || 0) / (stats.totalProblems || 1)) * 100 : 0) : 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-6">
|
||||||
|
{/* 欢迎区域 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-3xl font-bold tracking-tight">{config.title}</h1>
|
||||||
|
<p className="text-muted-foreground">{config.description}</p>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary">{fullUser.role}</Badge>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
欢迎回来,{fullUser.name || fullUser.email}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 统计卡片 */}
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{config.stats.map((stat, index) => (
|
||||||
|
<Card key={index}>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||||
|
<CardTitle className="text-sm font-medium">{stat.label}</CardTitle>
|
||||||
|
<stat.icon className={`h-4 w-4 ${stat.color}`} />
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-2xl font-bold">{stat.value}</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 学生进度条 */}
|
||||||
|
{fullUser.role === "GUEST" && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="flex items-center gap-2">
|
||||||
|
<Target className="h-5 w-5" />
|
||||||
|
学习进度
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
已完成 {stats.completedProblems || 0} / {stats.totalProblems || 0} 道题目
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<Progress value={completionRate} className="w-full" />
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
完成率: {completionRate.toFixed(1)}%
|
||||||
|
</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 快速操作 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>快速操作</CardTitle>
|
||||||
|
<CardDescription>常用功能快速访问</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
{config.actions.map((action, index) => (
|
||||||
|
<Link key={index} href={action.href}>
|
||||||
|
<Button variant="outline" className="w-full justify-start">
|
||||||
|
<action.icon className="mr-2 h-4 w-4" />
|
||||||
|
{action.label}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 最近活动 */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>最近活动</CardTitle>
|
||||||
|
<CardDescription>查看最新的系统活动</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{recentActivity.length > 0 ? (
|
||||||
|
recentActivity.map((activity, index) => (
|
||||||
|
<div key={index} className="flex items-center space-x-4">
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{activity.status === "AC" ? (
|
||||||
|
<CheckCircle className="h-5 w-5 text-green-500" />
|
||||||
|
) : activity.status ? (
|
||||||
|
<AlertCircle className="h-5 w-5 text-yellow-500" />
|
||||||
|
) : (
|
||||||
|
<Clock className="h-5 w-5 text-gray-500" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium">{activity.title}</p>
|
||||||
|
<p className="text-sm text-muted-foreground">{activity.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{new Date(activity.time).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">暂无活动</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,14 @@
|
|||||||
|
'use server'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import type { Problem } from '@/generated/client'
|
||||||
|
|
||||||
|
export async function createProblem(data: Omit<Problem, 'id'|'createdAt'|'updatedAt'>) {
|
||||||
|
await prisma.problem.create({ data })
|
||||||
|
revalidatePath('/usermanagement/problem')
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProblem(id: string) {
|
||||||
|
await prisma.problem.delete({ where: { id } })
|
||||||
|
revalidatePath('/usermanagement/problem')
|
||||||
|
}
|
@ -0,0 +1,46 @@
|
|||||||
|
'use server'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import { revalidatePath } from 'next/cache'
|
||||||
|
import bcrypt from 'bcryptjs'
|
||||||
|
import type { User } from '@/generated/client'
|
||||||
|
import { Role } from '@/generated/client'
|
||||||
|
|
||||||
|
type UserType = 'admin' | 'teacher' | 'guest'
|
||||||
|
|
||||||
|
export async function createUser(
|
||||||
|
userType: UserType,
|
||||||
|
data: Omit<User, 'id'|'createdAt'|'updatedAt'> & { password?: string }
|
||||||
|
) {
|
||||||
|
let password = data.password
|
||||||
|
if (password) {
|
||||||
|
password = await bcrypt.hash(password, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = userType.toUpperCase() as Role
|
||||||
|
await prisma.user.create({ data: { ...data, password, role } })
|
||||||
|
revalidatePath(`/usermanagement/${userType}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateUser(
|
||||||
|
userType: UserType,
|
||||||
|
id: string,
|
||||||
|
data: Partial<Omit<User, 'id'|'createdAt'|'updatedAt'>>
|
||||||
|
) {
|
||||||
|
const updateData = { ...data }
|
||||||
|
|
||||||
|
// 如果包含密码字段且不为空,则进行加密
|
||||||
|
if (data.password && data.password.trim() !== '') {
|
||||||
|
updateData.password = await bcrypt.hash(data.password, 10)
|
||||||
|
} else {
|
||||||
|
// 如果密码为空,则从更新数据中移除密码字段,保持原密码不变
|
||||||
|
delete updateData.password
|
||||||
|
}
|
||||||
|
|
||||||
|
await prisma.user.update({ where: { id }, data: updateData })
|
||||||
|
revalidatePath(`/usermanagement/${userType}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteUser(userType: UserType, id: string) {
|
||||||
|
await prisma.user.delete({ where: { id } })
|
||||||
|
revalidatePath(`/usermanagement/${userType}`)
|
||||||
|
}
|
@ -0,0 +1,10 @@
|
|||||||
|
import ProtectedLayout from "./ProtectedLayout";
|
||||||
|
|
||||||
|
interface GenericLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
allowedRoles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GenericLayout({ children, allowedRoles }: GenericLayoutProps) {
|
||||||
|
return <ProtectedLayout allowedRoles={allowedRoles}>{children}</ProtectedLayout>;
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
import { auth } from "@/lib/auth";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
interface ProtectedLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
allowedRoles: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function ProtectedLayout({ children, allowedRoles }: ProtectedLayoutProps) {
|
||||||
|
const session = await auth();
|
||||||
|
const userId = session?.user?.id;
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { role: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!user || !allowedRoles.includes(user.role)) {
|
||||||
|
redirect("/sign-in");
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="w-full h-full">{children}</div>;
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import GenericLayout from "../_components/GenericLayout";
|
||||||
|
|
||||||
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <GenericLayout allowedRoles={["ADMIN"]}>{children}</GenericLayout>;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import GenericPage from '@/features/user-management/components/generic-page'
|
||||||
|
import { adminConfig } from '@/features/user-management/config/admin'
|
||||||
|
|
||||||
|
export default function AdminPage() {
|
||||||
|
return <GenericPage userType="admin" config={adminConfig} />
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import GenericLayout from "../_components/GenericLayout";
|
||||||
|
|
||||||
|
export default function GuestLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <GenericLayout allowedRoles={["ADMIN", "TEACHER"]}>{children}</GenericLayout>;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import GenericPage from '@/features/user-management/components/generic-page'
|
||||||
|
import { guestConfig } from '@/features/user-management/config/guest'
|
||||||
|
|
||||||
|
export default function GuestPage() {
|
||||||
|
return <GenericPage userType="guest" config={guestConfig} />
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import GenericLayout from "../_components/GenericLayout";
|
||||||
|
|
||||||
|
export default function ProblemLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <GenericLayout allowedRoles={["ADMIN", "TEACHER"]}>{children}</GenericLayout>;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import GenericPage from '@/features/user-management/components/generic-page'
|
||||||
|
import { problemConfig } from '@/features/user-management/config/problem'
|
||||||
|
|
||||||
|
export default function ProblemPage() {
|
||||||
|
return <GenericPage userType="problem" config={problemConfig} />
|
||||||
|
}
|
@ -0,0 +1,5 @@
|
|||||||
|
import GenericLayout from "../_components/GenericLayout";
|
||||||
|
|
||||||
|
export default function TeacherLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <GenericLayout allowedRoles={["ADMIN"]}>{children}</GenericLayout>;
|
||||||
|
}
|
@ -0,0 +1,6 @@
|
|||||||
|
import GenericPage from '@/features/user-management/components/generic-page'
|
||||||
|
import { teacherConfig } from '@/features/user-management/config/teacher'
|
||||||
|
|
||||||
|
export default function TeacherPage() {
|
||||||
|
return <GenericPage userType="teacher" config={teacherConfig} />
|
||||||
|
}
|
@ -8,11 +8,25 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog"
|
} 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 { Badge } from "@/components/ui/badge"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
import Link from "next/link"
|
import Link from "next/link"
|
||||||
|
|
||||||
export function WrongbookDialog({ problems, children }: { problems: { id: string; name: string; status: string }[]; children?: React.ReactNode }) {
|
export function WrongbookDialog({ problems, children }: { problems: { id: string; name: string; status: string; url?: string }[]; children?: React.ReactNode }) {
|
||||||
|
const [copiedId, setCopiedId] = React.useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleCopyLink = async (item: { id: string; url?: string }) => {
|
||||||
|
const link = `${window.location.origin}/problems/${item.id}`
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(link)
|
||||||
|
setCopiedId(item.id)
|
||||||
|
setTimeout(() => setCopiedId(null), 2000) // 2秒后重置状态
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to copy link:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
@ -29,7 +43,7 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string
|
|||||||
<table className="min-w-full text-sm">
|
<table className="min-w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b bg-muted/50">
|
<tr className="border-b bg-muted/50">
|
||||||
<th className="px-3 py-2 text-left font-semibold">ID</th>
|
<th className="px-3 py-2 text-left font-semibold">操作</th>
|
||||||
<th className="px-3 py-2 text-left font-semibold">题目名称</th>
|
<th className="px-3 py-2 text-left font-semibold">题目名称</th>
|
||||||
<th className="px-3 py-2 text-left font-semibold">状态</th>
|
<th className="px-3 py-2 text-left font-semibold">状态</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -37,9 +51,22 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string
|
|||||||
<tbody>
|
<tbody>
|
||||||
{problems.map((item) => (
|
{problems.map((item) => (
|
||||||
<tr key={item.id} className="border-b last:border-0 hover:bg-muted/30 transition">
|
<tr key={item.id} className="border-b last:border-0 hover:bg-muted/30 transition">
|
||||||
<td className="px-3 py-2 text-gray-500 font-mono">{item.id}</td>
|
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<Link href={`/problem/${item.id}`} className="text-primary underline underline-offset-2 hover:text-primary/80">
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleCopyLink(item)}
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
>
|
||||||
|
{copiedId === item.id ? (
|
||||||
|
<CheckIcon className="h-4 w-4 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Link href={item.url || `/problems/${item.id}`} className="text-primary underline underline-offset-2 hover:text-primary/80">
|
||||||
{item.name}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
</td>
|
</td>
|
||||||
|
106
src/components/dynamic-breadcrumb.tsx
Normal file
106
src/components/dynamic-breadcrumb.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation"
|
||||||
|
import {
|
||||||
|
Breadcrumb,
|
||||||
|
BreadcrumbItem,
|
||||||
|
BreadcrumbLink,
|
||||||
|
BreadcrumbList,
|
||||||
|
BreadcrumbPage,
|
||||||
|
BreadcrumbSeparator,
|
||||||
|
} from "@/components/ui/breadcrumb"
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
label: string
|
||||||
|
href?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DynamicBreadcrumb() {
|
||||||
|
const pathname = usePathname()
|
||||||
|
|
||||||
|
const generateBreadcrumbs = (): BreadcrumbItem[] => {
|
||||||
|
const segments = pathname.split('/').filter(Boolean)
|
||||||
|
const breadcrumbs: BreadcrumbItem[] = []
|
||||||
|
|
||||||
|
// 添加首页
|
||||||
|
breadcrumbs.push({ label: "首页", href: "/" })
|
||||||
|
|
||||||
|
let currentPath = ""
|
||||||
|
|
||||||
|
segments.forEach((segment, index) => {
|
||||||
|
currentPath += `/${segment}`
|
||||||
|
|
||||||
|
// 根据路径段生成标签
|
||||||
|
let label = segment
|
||||||
|
|
||||||
|
// 路径映射
|
||||||
|
const pathMap: Record<string, string> = {
|
||||||
|
'dashboard': '仪表板',
|
||||||
|
'management': '管理面板',
|
||||||
|
'profile': '用户信息',
|
||||||
|
'change-password': '修改密码',
|
||||||
|
'problems': '题目',
|
||||||
|
'problemset': '题目集',
|
||||||
|
'admin': '管理后台',
|
||||||
|
'teacher': '教师平台',
|
||||||
|
'student': '学生平台',
|
||||||
|
'usermanagement': '用户管理',
|
||||||
|
'userdashboard': '用户仪表板',
|
||||||
|
'protected': '受保护',
|
||||||
|
'app': '应用',
|
||||||
|
'auth': '认证',
|
||||||
|
'sign-in': '登录',
|
||||||
|
'sign-up': '注册',
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是数字,可能是题目ID,显示为"题目详情"
|
||||||
|
if (/^\d+$/.test(segment)) {
|
||||||
|
label = "详情"
|
||||||
|
} else if (pathMap[segment]) {
|
||||||
|
label = pathMap[segment]
|
||||||
|
} else {
|
||||||
|
// 将 kebab-case 转换为中文
|
||||||
|
label = segment
|
||||||
|
.split('-')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最后一个项目不添加链接
|
||||||
|
if (index === segments.length - 1) {
|
||||||
|
breadcrumbs.push({ label })
|
||||||
|
} else {
|
||||||
|
breadcrumbs.push({ label, href: currentPath })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return breadcrumbs
|
||||||
|
}
|
||||||
|
|
||||||
|
const breadcrumbs = generateBreadcrumbs()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Breadcrumb>
|
||||||
|
<BreadcrumbList>
|
||||||
|
{breadcrumbs.map((item, index) => (
|
||||||
|
<div key={index} className="flex items-center">
|
||||||
|
<BreadcrumbItem className="hidden md:block">
|
||||||
|
{item.href ? (
|
||||||
|
<BreadcrumbLink href={item.href}>
|
||||||
|
{item.label}
|
||||||
|
</BreadcrumbLink>
|
||||||
|
) : (
|
||||||
|
<BreadcrumbPage>
|
||||||
|
{item.label}
|
||||||
|
</BreadcrumbPage>
|
||||||
|
)}
|
||||||
|
</BreadcrumbItem>
|
||||||
|
{index < breadcrumbs.length - 1 && (
|
||||||
|
<BreadcrumbSeparator className="hidden md:block" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</BreadcrumbList>
|
||||||
|
</Breadcrumb>
|
||||||
|
)
|
||||||
|
}
|
@ -1,28 +0,0 @@
|
|||||||
import { Search } from "lucide-react"
|
|
||||||
|
|
||||||
import { Label } from "@/components/ui/label"
|
|
||||||
import {
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarInput,
|
|
||||||
} from "@/components/ui/sidebar"
|
|
||||||
|
|
||||||
export function SearchForm({ ...props }: React.ComponentProps<"form">) {
|
|
||||||
return (
|
|
||||||
<form {...props}>
|
|
||||||
<SidebarGroup className="py-0">
|
|
||||||
<SidebarGroupContent className="relative">
|
|
||||||
<Label htmlFor="search" className="sr-only">
|
|
||||||
Search
|
|
||||||
</Label>
|
|
||||||
<SidebarInput
|
|
||||||
id="search"
|
|
||||||
placeholder="Search the docs..."
|
|
||||||
className="pl-8"
|
|
||||||
/>
|
|
||||||
<Search className="pointer-events-none absolute left-2 top-1/2 size-4 -translate-y-1/2 select-none opacity-50" />
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</form>
|
|
||||||
)
|
|
||||||
}
|
|
@ -1,97 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
import { ChevronRight } from "lucide-react";
|
|
||||||
|
|
||||||
import { VersionSwitcher } from "@/components//management-sidebar/manage-switcher";
|
|
||||||
import {
|
|
||||||
Collapsible,
|
|
||||||
CollapsibleContent,
|
|
||||||
CollapsibleTrigger,
|
|
||||||
} from "@/components/ui/collapsible";
|
|
||||||
import {
|
|
||||||
Sidebar,
|
|
||||||
SidebarContent,
|
|
||||||
SidebarGroup,
|
|
||||||
SidebarGroupContent,
|
|
||||||
SidebarGroupLabel,
|
|
||||||
SidebarHeader,
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
SidebarRail,
|
|
||||||
} from "@/components/ui/sidebar";
|
|
||||||
|
|
||||||
// 自定义数据:包含用户相关菜单项
|
|
||||||
const data = {
|
|
||||||
versions: ["1.0.1", "1.1.0-alpha", "2.0.0-beta1"],
|
|
||||||
navUser: [
|
|
||||||
{
|
|
||||||
title: "个人中心",
|
|
||||||
url: "#",
|
|
||||||
items: [
|
|
||||||
{ title: "登录信息", url: "#", key: "profile" },
|
|
||||||
{ title: "修改密码", url: "#", key: "change-password" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// 显式定义 props 类型
|
|
||||||
interface AppSidebarProps {
|
|
||||||
onItemClick?: (key: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppSidebar({ onItemClick = (key: string) => {}, ...props }: AppSidebarProps) {
|
|
||||||
return (
|
|
||||||
<Sidebar {...props}>
|
|
||||||
<SidebarHeader>
|
|
||||||
<VersionSwitcher
|
|
||||||
versions={data.versions}
|
|
||||||
defaultVersion={data.versions[0]}
|
|
||||||
/>
|
|
||||||
</SidebarHeader>
|
|
||||||
<SidebarContent className="gap-0">
|
|
||||||
{/* 渲染用户相关的侧边栏菜单 */}
|
|
||||||
{data.navUser.map((item) => (
|
|
||||||
<Collapsible
|
|
||||||
key={item.title}
|
|
||||||
title={item.title}
|
|
||||||
defaultOpen
|
|
||||||
className="group/collapsible"
|
|
||||||
>
|
|
||||||
<SidebarGroup>
|
|
||||||
<SidebarGroupLabel
|
|
||||||
asChild
|
|
||||||
className="group/label text-sm text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground"
|
|
||||||
>
|
|
||||||
<CollapsibleTrigger>
|
|
||||||
{item.title}
|
|
||||||
<ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" />
|
|
||||||
</CollapsibleTrigger>
|
|
||||||
</SidebarGroupLabel>
|
|
||||||
<CollapsibleContent>
|
|
||||||
<SidebarGroupContent>
|
|
||||||
<SidebarMenu>
|
|
||||||
{item.items.map((subItem) => (
|
|
||||||
<SidebarMenuItem key={subItem.title}>
|
|
||||||
<SidebarMenuButton
|
|
||||||
asChild
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
onItemClick(subItem.key);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<a href="#">{subItem.title}</a>
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
))}
|
|
||||||
</SidebarMenu>
|
|
||||||
</SidebarGroupContent>
|
|
||||||
</CollapsibleContent>
|
|
||||||
</SidebarGroup>
|
|
||||||
</Collapsible>
|
|
||||||
))}
|
|
||||||
</SidebarContent>
|
|
||||||
<SidebarRail />
|
|
||||||
</Sidebar>
|
|
||||||
);
|
|
||||||
}
|
|
@ -1,64 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import * as React from "react"
|
|
||||||
import { Check, ChevronsUpDown, GalleryVerticalEnd } from "lucide-react"
|
|
||||||
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger,
|
|
||||||
} from "@/components/ui/dropdown-menu"
|
|
||||||
import {
|
|
||||||
SidebarMenu,
|
|
||||||
SidebarMenuButton,
|
|
||||||
SidebarMenuItem,
|
|
||||||
} from "@/components/ui/sidebar"
|
|
||||||
|
|
||||||
export function VersionSwitcher({
|
|
||||||
versions,
|
|
||||||
defaultVersion,
|
|
||||||
}: {
|
|
||||||
versions: string[]
|
|
||||||
defaultVersion: string
|
|
||||||
}) {
|
|
||||||
const [selectedVersion, setSelectedVersion] = React.useState(defaultVersion)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SidebarMenu>
|
|
||||||
<SidebarMenuItem>
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<SidebarMenuButton
|
|
||||||
size="lg"
|
|
||||||
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
|
|
||||||
>
|
|
||||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
|
||||||
<GalleryVerticalEnd className="size-4" />
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
|
||||||
<span className="font-semibold">Documentation</span>
|
|
||||||
<span className="">v{selectedVersion}</span>
|
|
||||||
</div>
|
|
||||||
<ChevronsUpDown className="ml-auto" />
|
|
||||||
</SidebarMenuButton>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
className="w-[--radix-dropdown-menu-trigger-width]"
|
|
||||||
align="start"
|
|
||||||
>
|
|
||||||
{versions.map((version) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={version}
|
|
||||||
onSelect={() => setSelectedVersion(version)}
|
|
||||||
>
|
|
||||||
v{version}{" "}
|
|
||||||
{version === selectedVersion && <Check className="ml-auto" />}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</SidebarMenuItem>
|
|
||||||
</SidebarMenu>
|
|
||||||
)
|
|
||||||
}
|
|
@ -5,13 +5,13 @@ import {
|
|||||||
Folder,
|
Folder,
|
||||||
MoreHorizontal,
|
MoreHorizontal,
|
||||||
Share,
|
Share,
|
||||||
Trash2,
|
|
||||||
Check,
|
Check,
|
||||||
X,
|
X,
|
||||||
Info,
|
Info,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import React, { useState } from "react"
|
import React, { useState } from "react"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
} from "@/components/ui/dialog"
|
} from "@/components/ui/dialog"
|
||||||
@ -20,7 +20,6 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuSeparator,
|
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu"
|
} from "@/components/ui/dropdown-menu"
|
||||||
import {
|
import {
|
||||||
@ -42,11 +41,13 @@ export function NavProjects({
|
|||||||
id: string
|
id: string
|
||||||
name: string
|
name: string
|
||||||
status: string
|
status: string
|
||||||
|
url?: string
|
||||||
}[]
|
}[]
|
||||||
}) {
|
}) {
|
||||||
const { isMobile } = useSidebar()
|
const { isMobile } = useSidebar()
|
||||||
const [shareOpen, setShareOpen] = useState(false)
|
const [shareOpen, setShareOpen] = useState(false)
|
||||||
const [shareLink, setShareLink] = useState("")
|
const [shareLink, setShareLink] = useState("")
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -56,7 +57,7 @@ export function NavProjects({
|
|||||||
{projects.slice(0, 1).map((item) => (
|
{projects.slice(0, 1).map((item) => (
|
||||||
<SidebarMenuItem key={item.id}>
|
<SidebarMenuItem key={item.id}>
|
||||||
<SidebarMenuButton asChild>
|
<SidebarMenuButton asChild>
|
||||||
<a href={`/problem/${item.id}`}>
|
<a href={item.url}>
|
||||||
<BookX />
|
<BookX />
|
||||||
<span className="flex w-full items-center">
|
<span className="flex w-full items-center">
|
||||||
<span
|
<span
|
||||||
@ -111,25 +112,29 @@ export function NavProjects({
|
|||||||
side={isMobile ? "bottom" : "right"}
|
side={isMobile ? "bottom" : "right"}
|
||||||
align={isMobile ? "end" : "start"}
|
align={isMobile ? "end" : "start"}
|
||||||
>
|
>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (item.url) {
|
||||||
|
router.push(item.url)
|
||||||
|
} else {
|
||||||
|
router.push(`/problems/${item.id}`)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
<Folder className="text-muted-foreground" />
|
<Folder className="text-muted-foreground" />
|
||||||
<span>查看</span>
|
<span>查看</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setShareLink(`${window.location.origin}/problem/${item.id}`)
|
setShareLink(`${window.location.origin}/problems/${item.id}`)
|
||||||
setShareOpen(true)
|
setShareOpen(true)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Share className="text-muted-foreground mr-2" />
|
<Share className="text-muted-foreground mr-2" />
|
||||||
<span>复制链接</span>
|
<span>复制链接</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem>
|
|
||||||
<Trash2 className="text-muted-foreground" />
|
|
||||||
<span>移除</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
</SidebarMenuItem>
|
</SidebarMenuItem>
|
||||||
|
@ -2,13 +2,14 @@
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
BadgeCheck,
|
BadgeCheck,
|
||||||
Bell,
|
// Bell,
|
||||||
ChevronsUpDown,
|
ChevronsUpDown,
|
||||||
UserPen,
|
UserPen,
|
||||||
LogOut,
|
LogOut,
|
||||||
Sparkles,
|
// Sparkles,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { useRouter } from "next/navigation"
|
import { useRouter } from "next/navigation"
|
||||||
|
import { signOut } from "next-auth/react"
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
@ -44,13 +45,15 @@ export function NavUser({
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await fetch("/api/auth/signout", { method: "POST" });
|
await signOut({
|
||||||
router.replace("/sign-in");
|
callbackUrl: "/sign-in",
|
||||||
|
redirect: true
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleAccount() {
|
function handleAccount() {
|
||||||
if (user && user.email) {
|
if (user && user.email) {
|
||||||
router.replace("/user/profile");
|
router.replace("/dashboard/management");
|
||||||
} else {
|
} else {
|
||||||
router.replace("/sign-in");
|
router.replace("/sign-in");
|
||||||
}
|
}
|
||||||
@ -95,13 +98,13 @@ export function NavUser({
|
|||||||
</div>
|
</div>
|
||||||
</DropdownMenuLabel>
|
</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
{/* <DropdownMenuGroup>
|
||||||
<DropdownMenuItem>
|
<DropdownMenuItem>
|
||||||
<Sparkles />
|
<Sparkles />
|
||||||
Update
|
Update
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup> */}
|
||||||
<DropdownMenuSeparator />
|
{/* <DropdownMenuSeparator /> */}
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem onClick={handleAccount}>
|
<DropdownMenuItem onClick={handleAccount}>
|
||||||
<BadgeCheck />
|
<BadgeCheck />
|
||||||
@ -111,10 +114,10 @@ export function NavUser({
|
|||||||
<UserPen />
|
<UserPen />
|
||||||
Switch User
|
Switch User
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem >
|
{/* <DropdownMenuItem >
|
||||||
<Bell />
|
<Bell />
|
||||||
Notifications
|
Notifications
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem> */}
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem onClick={handleLogout}>
|
<DropdownMenuItem onClick={handleLogout}>
|
||||||
|
@ -8,7 +8,6 @@ import {
|
|||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
import { NavMain } from "@/components/nav-main"
|
import { NavMain } from "@/components/nav-main"
|
||||||
import { NavProjects } from "@/components/nav-projects"
|
|
||||||
import { NavSecondary } from "@/components/nav-secondary"
|
import { NavSecondary } from "@/components/nav-secondary"
|
||||||
import { NavUser } from "@/components/nav-user"
|
import { NavUser } from "@/components/nav-user"
|
||||||
import {
|
import {
|
||||||
@ -20,8 +19,7 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import { User } from "next-auth"
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
|
|
||||||
const adminData = {
|
const adminData = {
|
||||||
navMain: [
|
navMain: [
|
||||||
@ -31,10 +29,10 @@ const adminData = {
|
|||||||
icon: Shield,
|
icon: Shield,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
items: [
|
items: [
|
||||||
{ title: "管理员管理", url: "/usermanagement/admin" },
|
{ title: "管理员管理", url: "/dashboard/usermanagement/admin" },
|
||||||
{ title: "用户管理", url: "/usermanagement/guest" },
|
{ title: "用户管理", url: "/dashboard/usermanagement/guest" },
|
||||||
{ title: "教师管理", url: "/usermanagement/teacher" },
|
{ title: "教师管理", url: "/dashboard/usermanagement/teacher" },
|
||||||
{ title: "题目管理", url: "/usermanagement/problem" },
|
{ title: "题目管理", url: "/dashboard/usermanagement/problem" },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -43,38 +41,18 @@ const adminData = {
|
|||||||
{ title: "帮助", url: "/", icon: LifeBuoy },
|
{ title: "帮助", url: "/", icon: LifeBuoy },
|
||||||
{ title: "反馈", url: siteConfig.url.repo.github, icon: Send },
|
{ title: "反馈", url: siteConfig.url.repo.github, icon: Send },
|
||||||
],
|
],
|
||||||
wrongProblems: [],
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCurrentUser() {
|
interface AdminSidebarProps {
|
||||||
try {
|
user: User;
|
||||||
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",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AdminSidebar(props: React.ComponentProps<typeof Sidebar>) {
|
export function AdminSidebar({ user, ...props }: AdminSidebarProps & React.ComponentProps<typeof Sidebar>) {
|
||||||
const [user, setUser] = useState({
|
const userInfo = {
|
||||||
name: "未登录管理员",
|
name: user.name ?? "管理员",
|
||||||
email: "",
|
email: user.email ?? "",
|
||||||
avatar: "/avatars/default.jpg",
|
avatar: user.image ?? "/avatars/default.jpg",
|
||||||
});
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchCurrentUser().then(u => u && setUser(u));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar {...props}>
|
<Sidebar {...props}>
|
||||||
@ -97,11 +75,10 @@ export function AdminSidebar(props: React.ComponentProps<typeof Sidebar>) {
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={adminData.navMain} />
|
<NavMain items={adminData.navMain} />
|
||||||
<NavProjects projects={adminData.wrongProblems} />
|
|
||||||
<NavSecondary items={adminData.navSecondary} className="mt-auto" />
|
<NavSecondary items={adminData.navSecondary} className="mt-auto" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser user={user} />
|
<NavUser user={userInfo} />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
@ -3,11 +3,11 @@
|
|||||||
import { siteConfig } from "@/config/site";
|
import { siteConfig } from "@/config/site";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import {
|
import {
|
||||||
BookOpen,
|
// BookOpen,
|
||||||
Command,
|
Command,
|
||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
Send,
|
Send,
|
||||||
Settings2,
|
// Settings2,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
@ -26,9 +26,6 @@ import {
|
|||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { User } from "next-auth";
|
import { User } from "next-auth";
|
||||||
|
|
||||||
// import { useEffect, useState } from "react"
|
|
||||||
// import { auth, signIn } from "@/lib/auth"
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
@ -39,11 +36,7 @@ const data = {
|
|||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "主页",
|
title: "主页",
|
||||||
url: "/student/dashboard",
|
url: "/dashboard/student/dashboard",
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "历史记录",
|
|
||||||
url: "#",
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "题目集",
|
title: "题目集",
|
||||||
@ -52,36 +45,36 @@ const data = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
{
|
// {
|
||||||
title: "已完成事项",
|
// title: "已完成事项",
|
||||||
url: "#",
|
// url: "#",
|
||||||
icon: BookOpen,
|
// icon: BookOpen,
|
||||||
items: [
|
// items: [
|
||||||
{
|
// {
|
||||||
title: "全部编程集",
|
// title: "全部编程集",
|
||||||
url: "#",
|
// url: "#",
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
title: "错题集",
|
// title: "错题集",
|
||||||
url: "#",
|
// url: "#",
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
title: "收藏集",
|
// title: "收藏集",
|
||||||
url: "#",
|
// url: "#",
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
},
|
// },
|
||||||
{
|
// {
|
||||||
title: "设置",
|
// title: "设置",
|
||||||
url: "#",
|
// url: "#",
|
||||||
icon: Settings2,
|
// icon: Settings2,
|
||||||
items: [
|
// items: [
|
||||||
{
|
// {
|
||||||
title: "语言",
|
// title: "语言",
|
||||||
url: "#",
|
// url: "#",
|
||||||
},
|
// },
|
||||||
],
|
// ],
|
||||||
},
|
// },
|
||||||
],
|
],
|
||||||
navSecondary: [
|
navSecondary: [
|
||||||
{
|
{
|
||||||
@ -95,59 +88,18 @@ const data = {
|
|||||||
icon: Send,
|
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
|
interface AppSidebarProps {
|
||||||
// async function fetchCurrentUser() {
|
user: User;
|
||||||
// try {
|
wrongProblems: {
|
||||||
// const res = await fetch("/api/auth/session");
|
id: string;
|
||||||
// if (!res.ok) return null;
|
name: string;
|
||||||
// const session = await res.json();
|
status: string;
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppSidebar({ user, ...props }: AppSidebarProps) {
|
export function AppSidebar({ user, wrongProblems, ...props }: AppSidebarProps) {
|
||||||
// const [user, setUser] = useState({
|
|
||||||
// name: "未登录用户",
|
|
||||||
// email: "",
|
|
||||||
// avatar: "/avatars/default.jpg",
|
|
||||||
// });
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// fetchCurrentUser().then(u => u && setUser(u));
|
|
||||||
// }, []);
|
|
||||||
const userInfo = {
|
const userInfo = {
|
||||||
name: user.name ?? "",
|
name: user.name ?? "",
|
||||||
email: user.email ?? "",
|
email: user.email ?? "",
|
||||||
@ -175,7 +127,7 @@ export function AppSidebar({ user, ...props }: AppSidebarProps) {
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={data.navMain} />
|
||||||
<NavProjects projects={data.wrongProblems} />
|
<NavProjects projects={wrongProblems} />
|
||||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
|
@ -6,7 +6,7 @@ import {
|
|||||||
LifeBuoy,
|
LifeBuoy,
|
||||||
PieChart,
|
PieChart,
|
||||||
Send,
|
Send,
|
||||||
Settings2,
|
// Settings2,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
|
|
||||||
@ -22,60 +22,52 @@ import {
|
|||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
} from "@/components/ui/sidebar"
|
} from "@/components/ui/sidebar"
|
||||||
|
import { User } from "next-auth"
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
user: {
|
|
||||||
name: "teacher",
|
|
||||||
email: "teacher@example.com",
|
|
||||||
avatar: "/avatars/teacher.jpg",
|
|
||||||
},
|
|
||||||
navMain: [
|
navMain: [
|
||||||
{
|
{
|
||||||
title: "教师首页",
|
title: "教师管理",
|
||||||
url: "/teacher/dashboard",
|
url: "#",
|
||||||
icon: SquareTerminal,
|
icon: SquareTerminal,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "学生管理",
|
title: "用户管理",
|
||||||
url: "/teacher/students",
|
url: "/dashboard/usermanagement/guest",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "题库管理",
|
title: "题库管理",
|
||||||
url: "/teacher/problems",
|
url: "/dashboard/usermanagement/problem",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "统计分析",
|
title: "统计分析",
|
||||||
url: "/teacher/statistics",
|
url: "#",
|
||||||
icon: PieChart,
|
icon: PieChart,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
title: "完成情况",
|
title: "完成情况",
|
||||||
url: "/teacher/statistics/grades",
|
url: "/dashboard/teacher/dashboard",
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "错题统计",
|
|
||||||
url: "/teacher/statistics/activity",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "设置",
|
|
||||||
url: "#",
|
|
||||||
icon: Settings2,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
title: "一般设置",
|
|
||||||
url: "/teacher/profile",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "语言",
|
|
||||||
url: "/teacher/settings",
|
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// title: "错题统计",
|
||||||
|
// url: "/dashboard/teacher/dashboard",
|
||||||
|
// },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// {
|
||||||
|
// title: "设置",
|
||||||
|
// url: "#",
|
||||||
|
// icon: Settings2,
|
||||||
|
// items: [
|
||||||
|
// {
|
||||||
|
// title: "语言",
|
||||||
|
// url: "#",
|
||||||
|
// },
|
||||||
|
// ],
|
||||||
|
// },
|
||||||
],
|
],
|
||||||
navSecondary: [
|
navSecondary: [
|
||||||
{
|
{
|
||||||
@ -91,7 +83,17 @@ const data = {
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TeacherSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
interface TeacherSidebarProps {
|
||||||
|
user: User;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TeacherSidebar({ user, ...props }: TeacherSidebarProps & React.ComponentProps<typeof Sidebar>) {
|
||||||
|
const userInfo = {
|
||||||
|
name: user.name ?? "",
|
||||||
|
email: user.email ?? "",
|
||||||
|
avatar: user.image ?? "/avatars/teacher.jpg",
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sidebar variant="inset" {...props}>
|
<Sidebar variant="inset" {...props}>
|
||||||
<SidebarHeader>
|
<SidebarHeader>
|
||||||
@ -113,11 +115,10 @@ export function TeacherSidebar({ ...props }: React.ComponentProps<typeof Sidebar
|
|||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
<NavMain items={data.navMain} />
|
<NavMain items={data.navMain} />
|
||||||
{/* 教师端可自定义更多内容 */}
|
|
||||||
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
<NavSecondary items={data.navSecondary} className="mt-auto" />
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter>
|
<SidebarFooter>
|
||||||
<NavUser user={data.user} />
|
<NavUser user={userInfo} />
|
||||||
</SidebarFooter>
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
)
|
)
|
||||||
|
@ -1,26 +1,28 @@
|
|||||||
import EditCodePanel from "@/components/creater/edit-code-panel";
|
// import EditCodePanel from "@/components/creater/edit-code-panel";
|
||||||
import EditDetailPanel from "@/components/creater/edit-detail-panel";
|
// import EditDetailPanel from "@/components/creater/edit-detail-panel";
|
||||||
import EditSolutionPanel from "@/components/creater/edit-solution-panel";
|
// import EditSolutionPanel from "@/components/creater/edit-solution-panel";
|
||||||
import EditTestcasePanel from "@/components/creater/edit-testcase-panel";
|
// import EditTestcasePanel from "@/components/creater/edit-testcase-panel";
|
||||||
import EditDescriptionPanel from "@/components/creater/edit-description-panel";
|
// import EditDescriptionPanel from "@/components/creater/edit-description-panel";
|
||||||
import { ProblemEditFlexLayout } from "@/features/admin/ui/components/problem-edit-flexlayout";
|
// import { ProblemEditFlexLayout } from "@/features/admin/ui/components/problem-edit-flexlayout";
|
||||||
|
|
||||||
interface ProblemEditViewProps {
|
// interface ProblemEditViewProps {
|
||||||
problemId: string;
|
// problemId: string;
|
||||||
}
|
// }
|
||||||
|
|
||||||
export const ProblemEditView = ({ problemId }: ProblemEditViewProps) => {
|
export const ProblemEditView = (
|
||||||
const components: Record<string, React.ReactNode> = {
|
// { problemId }: ProblemEditViewProps
|
||||||
detail: <EditDetailPanel problemId={problemId} />,
|
) => {
|
||||||
description: <EditDescriptionPanel problemId={problemId} />,
|
// const components: Record<string, React.ReactNode> = {
|
||||||
solution: <EditSolutionPanel problemId={problemId} />,
|
// description: <EditDescriptionPanel problemId={problemId} />,
|
||||||
code: <EditCodePanel problemId={problemId} />,
|
// solution: <EditSolutionPanel problemId={problemId} />,
|
||||||
testcase: <EditTestcasePanel problemId={problemId} />,
|
// detail: <EditDetailPanel problemId={problemId} />,
|
||||||
};
|
// code: <EditCodePanel problemId={problemId} />,
|
||||||
|
// testcase: <EditTestcasePanel problemId={problemId} />,
|
||||||
|
// };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex h-full w-full">
|
<div className="relative flex h-full w-full">
|
||||||
<ProblemEditFlexLayout components={components} />
|
{/* <ProblemEditFlexLayout components={components} /> */}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
21
src/features/user-management/components/generic-page.tsx
Normal file
21
src/features/user-management/components/generic-page.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { UserTable } from './user-table'
|
||||||
|
import { UserConfig } from './user-table'
|
||||||
|
import prisma from '@/lib/prisma'
|
||||||
|
import type { User, Problem } from '@/generated/client'
|
||||||
|
import { Role } from '@/generated/client'
|
||||||
|
|
||||||
|
interface GenericPageProps {
|
||||||
|
userType: 'admin' | 'teacher' | 'guest' | 'problem'
|
||||||
|
config: UserConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default async function GenericPage({ userType, config }: GenericPageProps) {
|
||||||
|
if (userType === 'problem') {
|
||||||
|
const data: Problem[] = await prisma.problem.findMany({})
|
||||||
|
return <UserTable config={config} data={data} />
|
||||||
|
} else {
|
||||||
|
const role = userType.toUpperCase() as Role
|
||||||
|
const data: User[] = await prisma.user.findMany({ where: { role } })
|
||||||
|
return <UserTable config={config} data={data} />
|
||||||
|
}
|
||||||
|
}
|
954
src/features/user-management/components/user-table.tsx
Normal file
954
src/features/user-management/components/user-table.tsx
Normal file
@ -0,0 +1,954 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import * as React from "react"
|
||||||
|
import {
|
||||||
|
ColumnDef,
|
||||||
|
ColumnFiltersState,
|
||||||
|
SortingState,
|
||||||
|
VisibilityState,
|
||||||
|
flexRender,
|
||||||
|
getCoreRowModel,
|
||||||
|
getFacetedRowModel,
|
||||||
|
getFacetedUniqueValues,
|
||||||
|
getFilteredRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
} from "@tanstack/react-table"
|
||||||
|
import {
|
||||||
|
ChevronLeftIcon,
|
||||||
|
ChevronRightIcon,
|
||||||
|
ChevronsLeftIcon,
|
||||||
|
ChevronsRightIcon,
|
||||||
|
PlusIcon,
|
||||||
|
PencilIcon,
|
||||||
|
TrashIcon,
|
||||||
|
ListFilter,
|
||||||
|
} from "lucide-react"
|
||||||
|
import { toast } from "sonner"
|
||||||
|
import { useState, useEffect } from "react"
|
||||||
|
import { useForm } from "react-hook-form"
|
||||||
|
import { zodResolver } from "@hookform/resolvers/zod"
|
||||||
|
import { z } from "zod"
|
||||||
|
import { useRouter } from "next/navigation"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog"
|
||||||
|
import { Button } from "@/components/ui/button"
|
||||||
|
import { Checkbox } from "@/components/ui/checkbox"
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuCheckboxItem,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu"
|
||||||
|
import { Input } from "@/components/ui/input"
|
||||||
|
import { Label } from "@/components/ui/label"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select"
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@/components/ui/table"
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
} from "@/components/ui/tabs"
|
||||||
|
|
||||||
|
import { createUser, updateUser, deleteUser } from '@/app/(protected)/dashboard/usermanagement/_actions/userActions'
|
||||||
|
import { createProblem, deleteProblem } from '@/app/(protected)/dashboard/usermanagement/_actions/problemActions'
|
||||||
|
import type { User, Problem } from '@/generated/client'
|
||||||
|
import { Difficulty, Role } from '@/generated/client'
|
||||||
|
|
||||||
|
export interface UserConfig {
|
||||||
|
userType: string
|
||||||
|
title: string
|
||||||
|
apiPath: string
|
||||||
|
columns: Array<{
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
sortable?: boolean
|
||||||
|
searchable?: boolean
|
||||||
|
placeholder?: string
|
||||||
|
}>
|
||||||
|
formFields: Array<{
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
type: string
|
||||||
|
placeholder?: string
|
||||||
|
required?: boolean
|
||||||
|
options?: Array<{ value: string; label: string }>
|
||||||
|
}>
|
||||||
|
actions: {
|
||||||
|
add: { label: string; icon: string }
|
||||||
|
edit: { label: string; icon: string }
|
||||||
|
delete: { label: string; icon: string }
|
||||||
|
batchDelete: { label: string; icon: string }
|
||||||
|
}
|
||||||
|
pagination: {
|
||||||
|
pageSizes: number[]
|
||||||
|
defaultPageSize: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type UserTableProps =
|
||||||
|
| { config: UserConfig; data: User[] }
|
||||||
|
| { config: UserConfig; data: Problem[] }
|
||||||
|
|
||||||
|
type UserForm = {
|
||||||
|
id?: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
password: string
|
||||||
|
createdAt: string
|
||||||
|
role: Role
|
||||||
|
image: string | null
|
||||||
|
emailVerified: Date | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增用户表单类型
|
||||||
|
type AddUserForm = Omit<UserForm, 'id'>
|
||||||
|
|
||||||
|
const addUserSchema = z.object({
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string().min(1, "密码不能为空").min(8, "密码长度至少8位"),
|
||||||
|
createdAt: z.string(),
|
||||||
|
image: z.string().nullable(),
|
||||||
|
emailVerified: z.date().nullable(),
|
||||||
|
role: z.nativeEnum(Role),
|
||||||
|
})
|
||||||
|
|
||||||
|
const editUserSchema = z.object({
|
||||||
|
id: z.string().default(''),
|
||||||
|
name: z.string(),
|
||||||
|
email: z.string().email(),
|
||||||
|
password: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
image: z.string().nullable(),
|
||||||
|
emailVerified: z.date().nullable(),
|
||||||
|
role: z.nativeEnum(Role),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 题目表单 schema 兼容 null/undefined
|
||||||
|
const addProblemSchema = z.object({
|
||||||
|
displayId: z.number().optional().default(0),
|
||||||
|
difficulty: z.nativeEnum(Difficulty).default(Difficulty.EASY),
|
||||||
|
})
|
||||||
|
|
||||||
|
export function UserTable(props: UserTableProps) {
|
||||||
|
const isProblem = props.config.userType === 'problem'
|
||||||
|
const router = useRouter()
|
||||||
|
const problemData = isProblem ? (props.data as Problem[]) : undefined
|
||||||
|
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||||
|
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false)
|
||||||
|
const [editingUser, setEditingUser] = useState<User | Problem | null>(null)
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||||
|
const [deleteBatch, setDeleteBatch] = useState(false)
|
||||||
|
const [rowSelection, setRowSelection] = useState({})
|
||||||
|
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({})
|
||||||
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [pagination, setPagination] = useState({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize: props.config.pagination.defaultPageSize,
|
||||||
|
})
|
||||||
|
const [pageInput, setPageInput] = useState(pagination.pageIndex + 1)
|
||||||
|
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false)
|
||||||
|
const [pendingDeleteItem, setPendingDeleteItem] = useState<User | Problem | null>(null)
|
||||||
|
useEffect(() => {
|
||||||
|
setPageInput(pagination.pageIndex + 1)
|
||||||
|
}, [pagination.pageIndex])
|
||||||
|
|
||||||
|
// 表格列
|
||||||
|
const tableColumns = React.useMemo<ColumnDef<User | Problem>[]>(() => {
|
||||||
|
const columns: ColumnDef<User | Problem>[] = [
|
||||||
|
{
|
||||||
|
id: "select",
|
||||||
|
header: ({ table }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={table.getIsAllPageRowsSelected()}
|
||||||
|
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
|
||||||
|
aria-label="选择所有"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Checkbox
|
||||||
|
checked={row.getIsSelected()}
|
||||||
|
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||||
|
aria-label="选择行"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
enableSorting: false,
|
||||||
|
enableHiding: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
props.config.columns.forEach((col) => {
|
||||||
|
const column: ColumnDef<User | Problem> = {
|
||||||
|
accessorKey: col.key,
|
||||||
|
header: col.label,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
// 类型安全分流
|
||||||
|
if (col.key === 'displayId' && isProblem) {
|
||||||
|
return (row.original as Problem).displayId
|
||||||
|
}
|
||||||
|
if ((col.key === 'createdAt' || col.key === 'updatedAt')) {
|
||||||
|
const value = row.getValue(col.key)
|
||||||
|
if (value instanceof Date) {
|
||||||
|
return value.toLocaleString()
|
||||||
|
}
|
||||||
|
if (typeof value === 'string' && !isNaN(Date.parse(value))) {
|
||||||
|
return new Date(value).toLocaleString()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return row.getValue(col.key)
|
||||||
|
},
|
||||||
|
enableSorting: col.sortable !== false,
|
||||||
|
filterFn: col.searchable ? (row, columnId, value) => {
|
||||||
|
const searchValue = String(value).toLowerCase()
|
||||||
|
const cellValue = String(row.getValue(columnId)).toLowerCase()
|
||||||
|
return cellValue.includes(searchValue)
|
||||||
|
} : undefined,
|
||||||
|
}
|
||||||
|
columns.push(column)
|
||||||
|
})
|
||||||
|
columns.push({
|
||||||
|
id: "actions",
|
||||||
|
header: () => <div className="text-right">操作</div>,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const item = row.original
|
||||||
|
return (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1"
|
||||||
|
onClick={() => {
|
||||||
|
if (isProblem) {
|
||||||
|
// 如果是problem类型,跳转到编辑路由,使用displayId
|
||||||
|
const problem = item as Problem
|
||||||
|
router.push(`/admin/problems/${problem.displayId}/edit`)
|
||||||
|
} else {
|
||||||
|
// 如果是用户类型,打开编辑弹窗
|
||||||
|
setEditingUser(item)
|
||||||
|
setIsEditDialogOpen(true)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilIcon className="size-4 mr-1" /> {props.config.actions.edit.label}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 gap-1 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => {
|
||||||
|
setPendingDeleteItem(item)
|
||||||
|
setDeleteConfirmOpen(true)
|
||||||
|
}}
|
||||||
|
aria-label="Delete"
|
||||||
|
>
|
||||||
|
<TrashIcon className="size-4 mr-1" /> {props.config.actions.delete.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return columns
|
||||||
|
}, [props.config, router, isProblem])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: props.data,
|
||||||
|
columns: tableColumns,
|
||||||
|
state: {
|
||||||
|
sorting,
|
||||||
|
columnVisibility,
|
||||||
|
rowSelection,
|
||||||
|
columnFilters,
|
||||||
|
pagination,
|
||||||
|
},
|
||||||
|
enableRowSelection: true,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onColumnFiltersChange: setColumnFilters,
|
||||||
|
onColumnVisibilityChange: setColumnVisibility,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getFacetedRowModel: getFacetedRowModel(),
|
||||||
|
getFacetedUniqueValues: getFacetedUniqueValues(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加用户对话框组件(仅用户)
|
||||||
|
function AddUserDialogUser({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const form = useForm<AddUserForm>({
|
||||||
|
resolver: zodResolver(addUserSchema),
|
||||||
|
defaultValues: { name: '', email: '', password: '', createdAt: '', image: null, emailVerified: null, role: Role.GUEST },
|
||||||
|
})
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({ name: '', email: '', password: '', createdAt: '', image: null, emailVerified: null, role: Role.GUEST })
|
||||||
|
}
|
||||||
|
}, [open, form])
|
||||||
|
async function onSubmit(data: AddUserForm) {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
|
||||||
|
// 验证必填字段
|
||||||
|
if (!data.password || data.password.trim() === '') {
|
||||||
|
toast.error('密码不能为空', { duration: 1500 })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const submitData = {
|
||||||
|
...data,
|
||||||
|
image: data.image ?? null,
|
||||||
|
emailVerified: data.emailVerified ?? null,
|
||||||
|
role: data.role ?? Role.GUEST,
|
||||||
|
}
|
||||||
|
if (!submitData.name) submitData.name = ''
|
||||||
|
if (!submitData.createdAt) submitData.createdAt = new Date().toISOString()
|
||||||
|
else submitData.createdAt = new Date(submitData.createdAt).toISOString()
|
||||||
|
if (props.config.userType === 'admin') await createUser('admin', submitData)
|
||||||
|
else if (props.config.userType === 'teacher') await createUser('teacher', submitData)
|
||||||
|
else if (props.config.userType === 'guest') await createUser('guest', submitData)
|
||||||
|
onOpenChange(false)
|
||||||
|
toast.success('添加成功', { duration: 1500 })
|
||||||
|
router.refresh()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加失败:', error)
|
||||||
|
toast.error('添加失败', { duration: 1500 })
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{props.config.actions.add.label}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
请填写信息,ID自动生成。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
{props.config.formFields.filter(field => field.key !== 'id').map((field) => (
|
||||||
|
<div key={field.key} className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor={field.key} className="text-right">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
{field.type === 'select' && field.options ? (
|
||||||
|
<Select
|
||||||
|
value={form.watch(field.key as 'name' | 'email' | 'password' | 'createdAt' | 'role') ?? ''}
|
||||||
|
onValueChange={value => form.setValue(field.key as 'name' | 'email' | 'password' | 'createdAt' | 'role', value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder={`请选择${field.label}`} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{field.options.map((opt) => (
|
||||||
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={field.key}
|
||||||
|
type={field.type}
|
||||||
|
{...form.register(field.key as 'name' | 'email' | 'password' | 'createdAt' | 'role')}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message && (
|
||||||
|
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||||
|
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message as string}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "添加中..." : "添加"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加题目对话框组件(仅题目)
|
||||||
|
function AddUserDialogProblem({ open, onOpenChange }: { open: boolean; onOpenChange: (open: boolean) => void }) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const form = useForm<Partial<Problem>>({
|
||||||
|
resolver: zodResolver(addProblemSchema),
|
||||||
|
defaultValues: { displayId: 0, difficulty: Difficulty.EASY },
|
||||||
|
})
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
form.reset({ displayId: 0, difficulty: Difficulty.EASY })
|
||||||
|
}
|
||||||
|
}, [open, form])
|
||||||
|
async function onSubmit(formData: Partial<Problem>) {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const submitData: Partial<Problem> = { ...formData, displayId: Number(formData.displayId) }
|
||||||
|
await createProblem({
|
||||||
|
displayId: Number(submitData.displayId),
|
||||||
|
difficulty: submitData.difficulty ?? Difficulty.EASY,
|
||||||
|
isPublished: false,
|
||||||
|
isTrim: false,
|
||||||
|
timeLimit: 1000,
|
||||||
|
memoryLimit: 134217728,
|
||||||
|
userId: null,
|
||||||
|
})
|
||||||
|
onOpenChange(false)
|
||||||
|
toast.success('添加成功', { duration: 1500 })
|
||||||
|
router.refresh()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('添加失败:', error)
|
||||||
|
toast.error('添加失败', { duration: 1500 })
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{props.config.actions.add.label}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
请填写信息,ID自动生成。
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
{props.config.formFields.map((field) => (
|
||||||
|
<div key={field.key} className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor={field.key} className="text-right">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
{field.key === 'difficulty' ? (
|
||||||
|
<Select
|
||||||
|
value={form.watch('difficulty') ?? Difficulty.EASY}
|
||||||
|
onValueChange={value => form.setValue('difficulty', value as typeof Difficulty.EASY | typeof Difficulty.MEDIUM | typeof Difficulty.HARD)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="请选择难度" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={Difficulty.EASY}>简单</SelectItem>
|
||||||
|
<SelectItem value={Difficulty.MEDIUM}>中等</SelectItem>
|
||||||
|
<SelectItem value={Difficulty.HARD}>困难</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
id={field.key}
|
||||||
|
type={field.type}
|
||||||
|
{...form.register(field.key as 'displayId' | 'difficulty' | 'id', field.key === 'displayId' ? { valueAsNumber: true } : {})}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message && (
|
||||||
|
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||||
|
{form.formState.errors[field.key as keyof typeof form.formState.errors]?.message as string}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "添加中..." : "添加"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑用户对话框组件(仅用户)
|
||||||
|
function EditUserDialogUser({ open, onOpenChange, user }: { open: boolean; onOpenChange: (open: boolean) => void; user: User }) {
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const editForm = useForm<UserForm>({
|
||||||
|
resolver: zodResolver(editUserSchema),
|
||||||
|
defaultValues: {
|
||||||
|
id: typeof user.id === 'string' ? user.id : '',
|
||||||
|
name: user.name ?? '',
|
||||||
|
email: user.email ?? '',
|
||||||
|
password: '',
|
||||||
|
role: user.role ?? Role.GUEST,
|
||||||
|
createdAt: user.createdAt ? new Date(user.createdAt).toISOString().slice(0, 16) : '',
|
||||||
|
image: user.image ?? null,
|
||||||
|
emailVerified: user.emailVerified ?? null,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
editForm.reset({
|
||||||
|
id: typeof user.id === 'string' ? user.id : '',
|
||||||
|
name: user.name ?? '',
|
||||||
|
email: user.email ?? '',
|
||||||
|
password: '',
|
||||||
|
role: user.role ?? Role.GUEST,
|
||||||
|
createdAt: user.createdAt ? new Date(user.createdAt).toISOString().slice(0, 16) : '',
|
||||||
|
image: user.image ?? null,
|
||||||
|
emailVerified: user.emailVerified ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [open, user, editForm])
|
||||||
|
async function onSubmit(data: UserForm) {
|
||||||
|
try {
|
||||||
|
setIsLoading(true)
|
||||||
|
const submitData = {
|
||||||
|
...data,
|
||||||
|
createdAt: data.createdAt ? new Date(data.createdAt).toISOString() : new Date().toISOString(),
|
||||||
|
image: data.image ?? null,
|
||||||
|
emailVerified: data.emailVerified ?? null,
|
||||||
|
role: data.role ?? Role.GUEST,
|
||||||
|
}
|
||||||
|
const id = typeof submitData.id === 'string' ? submitData.id : ''
|
||||||
|
if (props.config.userType === 'admin') await updateUser('admin', id, submitData)
|
||||||
|
else if (props.config.userType === 'teacher') await updateUser('teacher', id, submitData)
|
||||||
|
else if (props.config.userType === 'guest') await updateUser('guest', id, submitData)
|
||||||
|
onOpenChange(false)
|
||||||
|
toast.success('修改成功', { duration: 1500 })
|
||||||
|
} catch {
|
||||||
|
toast.error('修改失败', { duration: 1500 })
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="sm:max-w-[425px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{props.config.actions.edit.label}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
修改信息
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={editForm.handleSubmit(onSubmit)} className="space-y-4">
|
||||||
|
<div className="grid gap-4 py-4">
|
||||||
|
{props.config.formFields.map((field) => (
|
||||||
|
<div key={field.key} className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor={field.key} className="text-right">
|
||||||
|
{field.label}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id={field.key}
|
||||||
|
type={field.type}
|
||||||
|
{...editForm.register(field.key as 'name' | 'email' | 'password' | 'createdAt' | 'role')}
|
||||||
|
className="col-span-3"
|
||||||
|
placeholder={field.placeholder}
|
||||||
|
disabled={field.key === 'id'}
|
||||||
|
/>
|
||||||
|
{editForm.formState.errors[field.key as keyof typeof editForm.formState.errors]?.message && (
|
||||||
|
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||||
|
{editForm.formState.errors[field.key as keyof typeof editForm.formState.errors]?.message as string}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{/* 编辑时显示角色选择 */}
|
||||||
|
{props.config.userType !== 'problem' && (
|
||||||
|
<div className="grid grid-cols-4 items-center gap-4">
|
||||||
|
<Label htmlFor="role" className="text-right">
|
||||||
|
角色
|
||||||
|
</Label>
|
||||||
|
<Select
|
||||||
|
value={editForm.watch('role') ?? ''}
|
||||||
|
onValueChange={value => editForm.setValue('role', value as Role)}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="col-span-3">
|
||||||
|
<SelectValue placeholder="请选择角色" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{props.config.userType === 'guest' && (
|
||||||
|
<>
|
||||||
|
<SelectItem value="GUEST">学生</SelectItem>
|
||||||
|
<SelectItem value="TEACHER">老师</SelectItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{(props.config.userType === 'teacher' || props.config.userType === 'admin') && (
|
||||||
|
<>
|
||||||
|
<SelectItem value="ADMIN">管理员</SelectItem>
|
||||||
|
<SelectItem value="TEACHER">老师</SelectItem>
|
||||||
|
<SelectItem value="GUEST">学生</SelectItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{editForm.formState.errors.role?.message && (
|
||||||
|
<p className="col-span-3 col-start-2 text-sm text-red-500">
|
||||||
|
{editForm.formState.errors.role?.message as string}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="submit" disabled={isLoading}>
|
||||||
|
{isLoading ? "修改中..." : "确认修改"}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用ref保证获取最新data
|
||||||
|
const dataRef = React.useRef<User[] | Problem[]>(props.data)
|
||||||
|
React.useEffect(() => { dataRef.current = props.data }, [props.data])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tabs defaultValue="outline" className="flex w-full flex-col gap-6">
|
||||||
|
<div className="flex items-center justify-between px-2 lg:px-4 py-2">
|
||||||
|
<div className="flex items-center gap-1 text-sm font-medium">
|
||||||
|
{props.config.title}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 px-2 text-sm"
|
||||||
|
>
|
||||||
|
<ListFilter className="h-4 w-4" />
|
||||||
|
显示列
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
{table
|
||||||
|
.getAllColumns()
|
||||||
|
.filter((column) => column.getCanHide())
|
||||||
|
.map((column) => {
|
||||||
|
const columnNameMap: Record<string, string> = {
|
||||||
|
select: "选择",
|
||||||
|
id: "ID",
|
||||||
|
name: "姓名",
|
||||||
|
email: "邮箱",
|
||||||
|
password: "密码",
|
||||||
|
createdAt: "创建时间",
|
||||||
|
actions: "操作",
|
||||||
|
displayId: "题目编号",
|
||||||
|
difficulty: "难度",
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DropdownMenuCheckboxItem
|
||||||
|
key={column.id}
|
||||||
|
className="capitalize"
|
||||||
|
checked={column.getIsVisible()}
|
||||||
|
onCheckedChange={(value) =>
|
||||||
|
column.toggleVisibility(!!value)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{columnNameMap[column.id] || column.id}
|
||||||
|
</DropdownMenuCheckboxItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{isProblem && props.config.actions.add && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 px-2 text-sm"
|
||||||
|
onClick={async () => {
|
||||||
|
const maxDisplayId = Array.isArray(problemData) && problemData.length > 0
|
||||||
|
? Math.max(...problemData.map(item => Number(item.displayId) || 0), 1000)
|
||||||
|
: 1000;
|
||||||
|
await createProblem({
|
||||||
|
displayId: maxDisplayId + 1,
|
||||||
|
difficulty: Difficulty.EASY,
|
||||||
|
isPublished: false,
|
||||||
|
isTrim: false,
|
||||||
|
timeLimit: 1000,
|
||||||
|
memoryLimit: 134217728,
|
||||||
|
userId: null,
|
||||||
|
});
|
||||||
|
router.refresh();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
{props.config.actions.add.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{!isProblem && props.config.actions.add && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 px-2 text-sm"
|
||||||
|
onClick={() => setIsAddDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<PlusIcon className="h-4 w-4" />
|
||||||
|
{props.config.actions.add.label}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 gap-1 px-2 text-sm"
|
||||||
|
disabled={table.getFilteredSelectedRowModel().rows.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
setDeleteBatch(true)
|
||||||
|
setDeleteDialogOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TrashIcon className="h-4 w-4" />
|
||||||
|
{props.config.actions.batchDelete.label}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<div style={{ maxHeight: 500, overflowY: 'auto' }}>
|
||||||
|
<Table className="text-sm">
|
||||||
|
<TableHeader>
|
||||||
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
|
<TableRow key={headerGroup.id} className="h-8">
|
||||||
|
{headerGroup.headers.map((header) => {
|
||||||
|
return (
|
||||||
|
<TableHead key={header.id} className="py-1 px-2 text-xs">
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows?.length ? (
|
||||||
|
table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className="h-8"
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell key={cell.id} className="py-1 px-2 text-xs">
|
||||||
|
{flexRender(
|
||||||
|
cell.column.columnDef.cell,
|
||||||
|
cell.getContext()
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell
|
||||||
|
colSpan={table.getAllColumns().length}
|
||||||
|
className="h-24 text-center"
|
||||||
|
>
|
||||||
|
暂无数据
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between px-2">
|
||||||
|
<div className="flex-1 text-sm text-muted-foreground">
|
||||||
|
共 {table.getFilteredRowModel().rows.length} 条记录
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-6 lg:space-x-8">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<p className="text-sm font-medium">每页显示</p>
|
||||||
|
<Select
|
||||||
|
value={`${table.getState().pagination.pageSize}`}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
table.setPageSize(Number(value))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-8 w-[70px]">
|
||||||
|
<SelectValue placeholder={table.getState().pagination.pageSize} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent side="top">
|
||||||
|
{props.config.pagination.pageSizes.map((pageSize) => (
|
||||||
|
<SelectItem key={pageSize} value={`${pageSize}`}>
|
||||||
|
{pageSize}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
|
||||||
|
第 {table.getState().pagination.pageIndex + 1} 页,共{" "}
|
||||||
|
{table.getPageCount()} 页
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(0)}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to first page</span>
|
||||||
|
<ChevronsLeftIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.previousPage()}
|
||||||
|
disabled={!table.getCanPreviousPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to previous page</span>
|
||||||
|
<ChevronLeftIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-sm">跳转到</span>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={table.getPageCount()}
|
||||||
|
value={pageInput}
|
||||||
|
onChange={(e) => setPageInput(Number(e.target.value))}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
const page = pageInput - 1
|
||||||
|
if (page >= 0 && page < table.getPageCount()) {
|
||||||
|
table.setPageIndex(page)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-16 h-8 text-sm"
|
||||||
|
/>
|
||||||
|
<span className="text-sm">页</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => table.nextPage()}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to next page</span>
|
||||||
|
<ChevronRightIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="hidden h-8 w-8 p-0 lg:flex"
|
||||||
|
onClick={() => table.setPageIndex(table.getPageCount() - 1)}
|
||||||
|
disabled={!table.getCanNextPage()}
|
||||||
|
>
|
||||||
|
<span className="sr-only">Go to last page</span>
|
||||||
|
<ChevronsRightIcon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 添加用户对话框 */}
|
||||||
|
{isProblem && props.config.actions.add ? (
|
||||||
|
<AddUserDialogProblem open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen} />
|
||||||
|
) : !isProblem && props.config.actions.add ? (
|
||||||
|
<AddUserDialogUser open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen} />
|
||||||
|
) : null}
|
||||||
|
{/* 编辑用户对话框 */}
|
||||||
|
{!isProblem && editingUser ? (
|
||||||
|
<EditUserDialogUser open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen} user={editingUser as User} />
|
||||||
|
) : null}
|
||||||
|
{/* 删除确认对话框 */}
|
||||||
|
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{deleteBatch
|
||||||
|
? `确定要删除选中的 ${table.getFilteredSelectedRowModel().rows.length} 条记录吗?此操作不可撤销。`
|
||||||
|
: "确定要删除这条记录吗?此操作不可撤销。"
|
||||||
|
}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
try {
|
||||||
|
if (deleteBatch) {
|
||||||
|
const selectedRows = table.getFilteredSelectedRowModel().rows
|
||||||
|
for (const row of selectedRows) {
|
||||||
|
if (isProblem) {
|
||||||
|
await deleteProblem((row.original as Problem).id)
|
||||||
|
} else {
|
||||||
|
await deleteUser(props.config.userType as 'admin' | 'teacher' | 'guest', (row.original as User).id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toast.success(`成功删除 ${selectedRows.length} 条记录`, { duration: 1500 })
|
||||||
|
}
|
||||||
|
setDeleteDialogOpen(false)
|
||||||
|
router.refresh()
|
||||||
|
} catch {
|
||||||
|
toast.error('删除失败', { duration: 1500 })
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
<Dialog open={deleteConfirmOpen} onOpenChange={setDeleteConfirmOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>确认删除</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div>确定要删除该条数据吗?此操作不可撤销。</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setDeleteConfirmOpen(false)}>取消</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={async () => {
|
||||||
|
if (pendingDeleteItem) {
|
||||||
|
if (isProblem) {
|
||||||
|
await deleteProblem((pendingDeleteItem as Problem).id)
|
||||||
|
} else {
|
||||||
|
await deleteUser(props.config.userType as 'admin' | 'teacher' | 'guest', (pendingDeleteItem as User).id)
|
||||||
|
}
|
||||||
|
toast.success('删除成功', { duration: 1500 })
|
||||||
|
router.refresh()
|
||||||
|
}
|
||||||
|
setDeleteConfirmOpen(false)
|
||||||
|
setPendingDeleteItem(null)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
确认删除
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
23
src/features/user-management/config/admin.ts
Normal file
23
src/features/user-management/config/admin.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config'
|
||||||
|
|
||||||
|
// 管理员数据校验 schema
|
||||||
|
export const adminSchema = baseUserSchema
|
||||||
|
export type Admin = z.infer<typeof adminSchema>
|
||||||
|
|
||||||
|
// 添加管理员表单校验 schema
|
||||||
|
export const addAdminSchema = baseAddUserSchema
|
||||||
|
export type AddAdminFormData = z.infer<typeof addAdminSchema>
|
||||||
|
|
||||||
|
// 编辑管理员表单校验 schema
|
||||||
|
export const editAdminSchema = baseEditUserSchema
|
||||||
|
export type EditAdminFormData = z.infer<typeof editAdminSchema>
|
||||||
|
|
||||||
|
// 管理员配置
|
||||||
|
export const adminConfig = createUserConfig(
|
||||||
|
"admin",
|
||||||
|
"管理员列表",
|
||||||
|
"添加管理员",
|
||||||
|
"请输入管理员姓名",
|
||||||
|
"请输入管理员邮箱"
|
||||||
|
)
|
86
src/features/user-management/config/base-config.ts
Normal file
86
src/features/user-management/config/base-config.ts
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import { z } from "zod"
|
||||||
|
|
||||||
|
// 基础用户 schema
|
||||||
|
export const baseUserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().optional(),
|
||||||
|
email: z.string(),
|
||||||
|
password: z.string().optional(),
|
||||||
|
role: z.string().optional(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
updatedAt: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 基础添加用户 schema
|
||||||
|
export const baseAddUserSchema = z.object({
|
||||||
|
name: z.string().min(1, "姓名为必填项"),
|
||||||
|
email: z.string().email("请输入有效的邮箱地址"),
|
||||||
|
password: z.string().min(8, "密码长度8-32位").max(32, "密码长度8-32位"),
|
||||||
|
createdAt: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 基础编辑用户 schema
|
||||||
|
export const baseEditUserSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string().min(1, "姓名为必填项"),
|
||||||
|
email: z.string().email("请输入有效的邮箱地址"),
|
||||||
|
password: z.string().min(8, "密码长度8-32位").max(32, "密码长度8-32位"),
|
||||||
|
createdAt: z.string(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 基础表格列配置
|
||||||
|
export const baseColumns = [
|
||||||
|
{ key: "id", label: "ID", sortable: true },
|
||||||
|
{ key: "name", label: "姓名", sortable: true, searchable: true, placeholder: "搜索姓名" },
|
||||||
|
{ key: "email", label: "邮箱", sortable: true, searchable: true, placeholder: "搜索邮箱" },
|
||||||
|
{ key: "createdAt", label: "创建时间", sortable: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 基础表单字段配置
|
||||||
|
export const baseFormFields = [
|
||||||
|
{ key: "name", label: "姓名", type: "text", placeholder: "请输入姓名", required: true },
|
||||||
|
{ key: "email", label: "邮箱", type: "email", placeholder: "请输入邮箱", required: true },
|
||||||
|
{ key: "password", label: "密码", type: "password", placeholder: "请输入8-32位密码", required: true },
|
||||||
|
{ key: "createdAt", label: "创建时间", type: "datetime-local", required: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
// 基础操作配置
|
||||||
|
export const baseActions = {
|
||||||
|
add: { label: "添加", icon: "PlusIcon" },
|
||||||
|
edit: { label: "编辑", icon: "PencilIcon" },
|
||||||
|
delete: { label: "删除", icon: "TrashIcon" },
|
||||||
|
batchDelete: { label: "批量删除", icon: "TrashIcon" },
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基础分页配置
|
||||||
|
export const basePagination = {
|
||||||
|
pageSizes: [10, 50, 100, 500],
|
||||||
|
defaultPageSize: 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建用户配置的工厂函数
|
||||||
|
export function createUserConfig(
|
||||||
|
userType: string,
|
||||||
|
title: string,
|
||||||
|
addLabel: string,
|
||||||
|
namePlaceholder: string,
|
||||||
|
emailPlaceholder: string
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
userType,
|
||||||
|
title,
|
||||||
|
apiPath: "/api/user",
|
||||||
|
columns: baseColumns,
|
||||||
|
formFields: baseFormFields.map(field => ({
|
||||||
|
...field,
|
||||||
|
placeholder: field.key === 'name' ? namePlaceholder :
|
||||||
|
field.key === 'email' ? emailPlaceholder :
|
||||||
|
field.placeholder
|
||||||
|
})),
|
||||||
|
actions: {
|
||||||
|
...baseActions,
|
||||||
|
add: { ...baseActions.add, label: addLabel }
|
||||||
|
},
|
||||||
|
pagination: basePagination,
|
||||||
|
}
|
||||||
|
}
|
19
src/features/user-management/config/guest.ts
Normal file
19
src/features/user-management/config/guest.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config'
|
||||||
|
|
||||||
|
export const guestSchema = baseUserSchema;
|
||||||
|
export type Guest = z.infer<typeof guestSchema>;
|
||||||
|
|
||||||
|
export const addGuestSchema = baseAddUserSchema;
|
||||||
|
export type AddGuestFormData = z.infer<typeof addGuestSchema>;
|
||||||
|
|
||||||
|
export const editGuestSchema = baseEditUserSchema;
|
||||||
|
export type EditGuestFormData = z.infer<typeof editGuestSchema>;
|
||||||
|
|
||||||
|
export const guestConfig = createUserConfig(
|
||||||
|
"guest",
|
||||||
|
"客户列表",
|
||||||
|
"添加客户",
|
||||||
|
"请输入客户姓名",
|
||||||
|
"请输入客户邮箱"
|
||||||
|
);
|
41
src/features/user-management/config/problem.ts
Normal file
41
src/features/user-management/config/problem.ts
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
export const problemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
displayId: z.number(),
|
||||||
|
difficulty: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const addProblemSchema = z.object({
|
||||||
|
displayId: z.number(),
|
||||||
|
difficulty: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const editProblemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
displayId: z.number(),
|
||||||
|
difficulty: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const problemConfig = {
|
||||||
|
userType: "problem",
|
||||||
|
title: "题目列表",
|
||||||
|
apiPath: "/api/problem",
|
||||||
|
columns: [
|
||||||
|
{ key: "id", label: "ID", sortable: true },
|
||||||
|
{ key: "displayId", label: "题目编号", sortable: true, searchable: true, placeholder: "搜索编号" },
|
||||||
|
{ key: "difficulty", label: "难度", sortable: true, searchable: true, placeholder: "搜索难度" },
|
||||||
|
],
|
||||||
|
formFields: [
|
||||||
|
{ key: "displayId", label: "题目编号", type: "number", required: true },
|
||||||
|
{ key: "difficulty", label: "难度", type: "text", required: true },
|
||||||
|
],
|
||||||
|
actions: {
|
||||||
|
add: { label: "添加题目", icon: "PlusIcon" },
|
||||||
|
edit: { label: "编辑", icon: "PencilIcon" },
|
||||||
|
delete: { label: "删除", icon: "TrashIcon" },
|
||||||
|
batchDelete: { label: "批量删除", icon: "TrashIcon" },
|
||||||
|
},
|
||||||
|
pagination: { pageSizes: [10, 50, 100, 500], defaultPageSize: 10 },
|
||||||
|
};
|
19
src/features/user-management/config/teacher.ts
Normal file
19
src/features/user-management/config/teacher.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config'
|
||||||
|
|
||||||
|
export const teacherSchema = baseUserSchema;
|
||||||
|
export type Teacher = z.infer<typeof teacherSchema>;
|
||||||
|
|
||||||
|
export const addTeacherSchema = baseAddUserSchema;
|
||||||
|
export type AddTeacherFormData = z.infer<typeof addTeacherSchema>;
|
||||||
|
|
||||||
|
export const editTeacherSchema = baseEditUserSchema;
|
||||||
|
export type EditTeacherFormData = z.infer<typeof editTeacherSchema>;
|
||||||
|
|
||||||
|
export const teacherConfig = createUserConfig(
|
||||||
|
"teacher",
|
||||||
|
"教师列表",
|
||||||
|
"添加教师",
|
||||||
|
"请输入教师姓名",
|
||||||
|
"请输入教师邮箱"
|
||||||
|
);
|
Loading…
Reference in New Issue
Block a user