refactor: format and relocate code

This commit is contained in:
cfngc4594 2025-06-21 23:19:49 +08:00
parent 47feffd62c
commit 0695dd2f61
64 changed files with 1799 additions and 1714 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,11 +0,0 @@
import { AdminProtectedLayout } from "@/features/admin/ui/layouts/admin-protected-layout";
interface LayoutProps {
children: React.ReactNode;
}
const Layout = ({ children }: LayoutProps) => {
return <AdminProtectedLayout>{children}</AdminProtectedLayout>;
};
export default Layout;

View File

@ -22,7 +22,7 @@ export async function getStudentDashboardData() {
// 检查用户是否存在 // 检查用户是否存在
const currentUser = await prisma.user.findUnique({ const currentUser = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { id: true, name: true, email: true, role: true } select: { id: true, name: true, email: true, role: true },
}); });
console.log("当前用户信息:", currentUser); console.log("当前用户信息:", currentUser);
@ -31,7 +31,7 @@ export async function getStudentDashboardData() {
} }
// 获取所有已发布的题目(包含英文标题) // 获取所有已发布的题目(包含英文标题)
const allProblems = await prisma.problem.findMany({ const allProblems = await prisma.problem.findMany({
where: { isPublished: true }, where: { isPublished: true },
select: { select: {
id: true, id: true,
@ -41,19 +41,22 @@ const allProblems = await prisma.problem.findMany({
where: { where: {
type: "TITLE", type: "TITLE",
}, },
} },
} },
}); });
console.log("已发布题目数量:", allProblems.length); console.log("已发布题目数量:", allProblems.length);
console.log("题目列表:", allProblems.map(p => ({ console.log(
"题目列表:",
allProblems.map((p) => ({
id: p.id, id: p.id,
displayId: p.displayId, displayId: p.displayId,
title: p.localizations[0]?.content || "无标题", title: p.localizations[0]?.content || "无标题",
difficulty: p.difficulty difficulty: p.difficulty,
}))); }))
);
// 获取当前学生的所有提交记录(包含题目英文标题) // 获取当前学生的所有提交记录(包含题目英文标题)
const userSubmissions = await prisma.submission.findMany({ const userSubmissions = await prisma.submission.findMany({
where: { userId: userId }, where: { userId: userId },
include: { include: {
@ -65,25 +68,28 @@ console.log("题目列表:", allProblems.map(p => ({
localizations: { localizations: {
where: { where: {
type: "TITLE", type: "TITLE",
locale: "en" // 或者根据需求使用其他语言 locale: "en", // 或者根据需求使用其他语言
}, },
select: { select: {
content: true content: true,
} },
} },
} },
} },
} },
}); });
console.log("当前用户提交记录数量:", userSubmissions.length); console.log("当前用户提交记录数量:", userSubmissions.length);
console.log("提交记录详情:", userSubmissions.map(s => ({ console.log(
"提交记录详情:",
userSubmissions.map((s) => ({
problemId: s.problemId, problemId: s.problemId,
problemDisplayId: s.problem.displayId, problemDisplayId: s.problem.displayId,
title: s.problem.localizations[0]?.content || "无标题", title: s.problem.localizations[0]?.content || "无标题",
difficulty: s.problem.difficulty, difficulty: s.problem.difficulty,
status: s.status status: s.status,
}))); }))
);
// 计算题目完成情况 // 计算题目完成情况
const completedProblems = new Set<string | number>(); const completedProblems = new Set<string | number>();
@ -110,7 +116,10 @@ console.log("提交记录详情:", userSubmissions.map(s => ({
const completionData = { const completionData = {
total: allProblems.length, total: allProblems.length,
completed: completedProblems.size, completed: completedProblems.size,
percentage: allProblems.length > 0 ? Math.round((completedProblems.size / allProblems.length) * 100) : 0, percentage:
allProblems.length > 0
? Math.round((completedProblems.size / allProblems.length) * 100)
: 0,
}; };
// 错题比例数据 - 基于已完成的题目计算 // 错题比例数据 - 基于已完成的题目计算
@ -118,7 +127,10 @@ console.log("提交记录详情:", userSubmissions.map(s => ({
// 统计在已完成的题目中,哪些题目曾经有过错误提交 // 统计在已完成的题目中,哪些题目曾经有过错误提交
userSubmissions.forEach((submission) => { userSubmissions.forEach((submission) => {
if (submission.status !== "AC" && completedProblems.has(submission.problemId)) { if (
submission.status !== "AC" &&
completedProblems.has(submission.problemId)
) {
wrongProblems.add(submission.problemId); wrongProblems.add(submission.problemId);
} }
}); });
@ -126,7 +138,10 @@ console.log("提交记录详情:", userSubmissions.map(s => ({
const errorData = { const errorData = {
total: completedProblems.size, // 已完成的题目总数 total: completedProblems.size, // 已完成的题目总数
wrong: wrongProblems.size, // 在已完成的题目中有过错误的题目数 wrong: wrongProblems.size, // 在已完成的题目中有过错误的题目数
percentage: completedProblems.size > 0 ? Math.round((wrongProblems.size / completedProblems.size) * 100) : 0, percentage:
completedProblems.size > 0
? Math.round((wrongProblems.size / completedProblems.size) * 100)
: 0,
}; };
// 易错题列表(按错误次数排序) // 易错题列表(按错误次数排序)
@ -135,7 +150,9 @@ console.log("提交记录详情:", userSubmissions.map(s => ({
const problem = allProblems.find((p) => p.id === problemId); const problem = allProblems.find((p) => p.id === problemId);
// 从 problem.localizations 中获取标题 // 从 problem.localizations 中获取标题
const title = problem?.localizations?.find((loc) => loc.type === "TITLE")?.content || "未知题目"; const title =
problem?.localizations?.find((loc) => loc.type === "TITLE")
?.content || "未知题目";
return { return {
id: problem?.displayId || problemId, id: problem?.displayId || problemId,
@ -153,7 +170,10 @@ console.log("提交记录详情:", userSubmissions.map(s => ({
difficultProblems, difficultProblems,
pieChartData: [ pieChartData: [
{ name: "已完成", value: completionData.completed }, { name: "已完成", value: completionData.completed },
{ name: "未完成", value: completionData.total - completionData.completed }, {
name: "未完成",
value: completionData.total - completionData.completed,
},
], ],
errorPieChartData: [ errorPieChartData: [
{ name: "正确", value: errorData.total - errorData.wrong }, { name: "正确", value: errorData.total - errorData.wrong },
@ -170,6 +190,8 @@ console.log("提交记录详情:", userSubmissions.map(s => ({
return result; return result;
} catch (error) { } catch (error) {
console.error("获取学生仪表板数据失败:", error); console.error("获取学生仪表板数据失败:", error);
throw new Error(`获取数据失败: ${error instanceof Error ? error.message : '未知错误'}`); throw new Error(
`获取数据失败: ${error instanceof Error ? error.message : "未知错误"}`
);
} }
} }

View File

@ -1,8 +1,8 @@
"use server"; "use server";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { Locale, Status, ProblemLocalization } from "@/generated/client";
import { getLocale } from "next-intl/server"; import { getLocale } from "next-intl/server";
import { Locale, Status, ProblemLocalization } from "@/generated/client";
const getLocalizedTitle = ( const getLocalizedTitle = (
localizations: ProblemLocalization[], localizations: ProblemLocalization[],
@ -38,32 +38,37 @@ export interface DifficultProblemData {
problemDisplayId: number; problemDisplayId: number;
} }
export async function getProblemCompletionData(): Promise<ProblemCompletionData[]> { export async function getProblemCompletionData(): Promise<
ProblemCompletionData[]
> {
// 获取所有提交记录,按题目分组统计 // 获取所有提交记录,按题目分组统计
const submissions = await prisma.submission.findMany({ const submissions = await prisma.submission.findMany({
include: { include: {
user: true, user: true,
problem: { problem: {
include:{ include: {
localizations:true localizations: true,
} },
} },
}, },
}); });
const locale = await getLocale(); const locale = await getLocale();
// 按题目分组统计完成情况(统计独立用户数) // 按题目分组统计完成情况(统计独立用户数)
const problemStats = new Map<string, { const problemStats = new Map<
string,
{
completedUsers: Set<string>; completedUsers: Set<string>;
totalUsers: Set<string>; totalUsers: Set<string>;
title: string; title: string;
displayId: number; displayId: number;
}>(); }
>();
submissions.forEach((submission) => { submissions.forEach((submission) => {
const localizations=submission.problem.localizations; const localizations = submission.problem.localizations;
const title=getLocalizedTitle(localizations,locale as Locale); const title = getLocalizedTitle(localizations, locale as Locale);
const problemId = submission.problemId; const problemId = submission.problemId;
const problemTitle = title; const problemTitle = title;
const problemDisplayId = submission.problem.displayId; const problemDisplayId = submission.problem.displayId;
@ -92,7 +97,8 @@ export async function getProblemCompletionData(): Promise<ProblemCompletionData[
} }
// 转换为图表数据格式按题目displayId排序 // 转换为图表数据格式按题目displayId排序
const problemDataArray = Array.from(problemStats.entries()).map(([problemId, stats]) => { const problemDataArray = Array.from(problemStats.entries()).map(
([problemId, stats]) => {
const completed = stats.completedUsers.size; const completed = stats.completedUsers.size;
const total = stats.totalUsers.size; const total = stats.totalUsers.size;
@ -106,13 +112,18 @@ export async function getProblemCompletionData(): Promise<ProblemCompletionData[
completedPercent: total > 0 ? (completed / total) * 100 : 0, completedPercent: total > 0 ? (completed / total) * 100 : 0,
uncompletedPercent: total > 0 ? ((total - completed) / total) * 100 : 0, uncompletedPercent: total > 0 ? ((total - completed) / total) * 100 : 0,
}; };
}); }
);
// 按题目编号排序 // 按题目编号排序
return problemDataArray.sort((a, b) => a.problemDisplayId - b.problemDisplayId); return problemDataArray.sort(
(a, b) => a.problemDisplayId - b.problemDisplayId
);
} }
export async function getDifficultProblemsData(): Promise<DifficultProblemData[]> { export async function getDifficultProblemsData(): Promise<
DifficultProblemData[]
> {
// 获取所有测试用例结果 // 获取所有测试用例结果
const testcaseResults = await prisma.testcaseResult.findMany({ const testcaseResults = await prisma.testcaseResult.findMany({
include: { include: {
@ -120,8 +131,8 @@ export async function getDifficultProblemsData(): Promise<DifficultProblemData[]
include: { include: {
problem: { problem: {
include: { include: {
localizations: true localizations: true,
} },
}, },
}, },
}, },
@ -134,19 +145,22 @@ export async function getDifficultProblemsData(): Promise<DifficultProblemData[]
}); });
// 按问题分组统计错误率 // 按问题分组统计错误率
const problemStats = new Map<string, { const problemStats = new Map<
string,
{
totalAttempts: number; totalAttempts: number;
wrongAttempts: number; wrongAttempts: number;
title: string; title: string;
displayId: number; displayId: number;
users: Set<string>; users: Set<string>;
}>(); }
>();
testcaseResults.forEach((result) => { testcaseResults.forEach((result) => {
const problemId = result.testcase.problemId; const problemId = result.testcase.problemId;
const problemTitle = result.testcase.problem.localizations?.find( const problemTitle =
(loc) => loc.type === "TITLE" result.testcase.problem.localizations?.find((loc) => loc.type === "TITLE")
)?.content || "无标题"; ?.content || "无标题";
const problemDisplayId = result.testcase.problem.displayId; const problemDisplayId = result.testcase.problem.displayId;
const userId = result.submission.userId; const userId = result.submission.userId;
const isWrong = !result.isCorrect; const isWrong = !result.isCorrect;
@ -181,7 +195,8 @@ export async function getDifficultProblemsData(): Promise<DifficultProblemData[]
uniqueUsers: stats.users.size, uniqueUsers: stats.users.size,
totalAttempts: stats.totalAttempts, totalAttempts: stats.totalAttempts,
})) }))
.filter((problem) => .filter(
(problem) =>
problem.errorRate > 30 && // 错误率超过30% problem.errorRate > 30 && // 错误率超过30%
problem.totalAttempts >= 3 // 至少有3次尝试 problem.totalAttempts >= 3 // 至少有3次尝试
) )

View File

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

View File

@ -1,16 +1,16 @@
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { AdminSidebar } from "@/components/sidebar/admin-sidebar";
import { TeacherSidebar } from "@/components/sidebar/teacher-sidebar";
import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb";
import { Separator } from "@/components/ui/separator";
import { import {
SidebarInset, SidebarInset,
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { Separator } from "@/components/ui/separator";
import { AppSidebar } from "@/components/sidebar/app-sidebar";
import { AdminSidebar } from "@/components/sidebar/admin-sidebar";
import { DynamicBreadcrumb } from "@/components/dynamic-breadcrumb";
import { TeacherSidebar } from "@/components/sidebar/teacher-sidebar";
interface LayoutProps { interface LayoutProps {
children: React.ReactNode; children: React.ReactNode;
@ -33,7 +33,7 @@ export default async function Layout({ children }: LayoutProps) {
// 获取用户的完整信息(包括角色) // 获取用户的完整信息(包括角色)
const fullUser = await prisma.user.findUnique({ const fullUser = await prisma.user.findUnique({
where: { id: user.id }, where: { id: user.id },
select: { id: true, name: true, email: true, image: true, role: true } select: { id: true, name: true, email: true, image: true, role: true },
}); });
if (!fullUser) { if (!fullUser) {

View File

@ -1,9 +1,8 @@
// changePassword.ts
"use server"; "use server";
import bcrypt from "bcryptjs";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import bcrypt from "bcryptjs";
export async function changePassword(formData: FormData) { export async function changePassword(formData: FormData) {
const oldPassword = formData.get("oldPassword") as string; const oldPassword = formData.get("oldPassword") as string;

View File

@ -1,4 +1,3 @@
// getUserInfo.ts
"use server"; "use server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";

View File

@ -1,4 +1,3 @@
// index.ts
export { getUserInfo } from "./getUserInfo"; export { getUserInfo } from "./getUserInfo";
export { updateUserInfo } from "./updateUserInfo"; export { updateUserInfo } from "./updateUserInfo";
export { changePassword } from "./changePassword"; export { changePassword } from "./changePassword";

View File

@ -1,4 +1,3 @@
// updateUserInfo.ts
"use server"; "use server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";

View File

@ -1,7 +1,8 @@
// src/app/(app)/management/change-password/page.tsx
"use client"; "use client";
import { useState } from "react"; import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { changePassword } from "@/app/(protected)/dashboard/management/actions/changePassword"; import { changePassword } from "@/app/(protected)/dashboard/management/actions/changePassword";
export default function ChangePasswordPage() { export default function ChangePasswordPage() {
@ -51,40 +52,46 @@ export default function ChangePasswordPage() {
setShowSuccess(true); setShowSuccess(true);
setTimeout(() => setShowSuccess(false), 3000); setTimeout(() => setShowSuccess(false), 3000);
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '修改密码失败'; const errorMessage =
error instanceof Error ? error.message : "修改密码失败";
alert(errorMessage); alert(errorMessage);
} }
}; };
return ( return (
<div className="h-full w-full p-6"> <div className="h-full w-full p-6">
<div className="h-full w-full bg-white shadow-lg rounded-xl p-8 flex flex-col"> <div className="h-full w-full bg-card shadow-lg rounded-xl p-8 flex flex-col">
<h1 className="text-2xl font-bold mb-6"></h1> <h1 className="text-2xl font-bold mb-6"></h1>
<form onSubmit={handleSubmit} className="space-y-5 flex-1 flex flex-col"> <form
onSubmit={handleSubmit}
className="space-y-5 flex-1 flex flex-col"
>
<div> <div>
<label className="block text-sm font-medium mb-1"></label> <label className="block text-sm font-medium mb-1"></label>
<input <Input
type="password" type="password"
value={oldPassword} value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)} onChange={(e) => setOldPassword(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required required
/> />
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1"></label> <label className="block text-sm font-medium mb-1"></label>
<input <Input
type="password" type="password"
value={newPassword} value={newPassword}
onChange={(e) => setNewPassword(e.target.value)} onChange={(e) => setNewPassword(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required required
/> />
{newPassword && ( {newPassword && (
<p className="mt-1 text-xs text-gray-500"> <p className="mt-1 text-xs">
<span className={`inline-block w-12 h-2 rounded ${strengthColor}`}></span> <span
className={`inline-block w-12 h-2 rounded ${strengthColor}`}
></span>
&nbsp; &nbsp;
<span className="text-sm">{strengthLabel}</span> <span className="text-sm">{strengthLabel}</span>
</p> </p>
@ -93,25 +100,27 @@ export default function ChangePasswordPage() {
<div> <div>
<label className="block text-sm font-medium mb-1"></label> <label className="block text-sm font-medium mb-1"></label>
<input <Input
type="password" type="password"
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full border border-gray-300 rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500" className="w-full border rounded-lg px-4 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
required required
/> />
{newPassword && confirmPassword && newPassword !== confirmPassword && ( {newPassword &&
confirmPassword &&
newPassword !== confirmPassword && (
<p className="mt-1 text-xs text-red-500"></p> <p className="mt-1 text-xs text-red-500"></p>
)} )}
</div> </div>
<div className="mt-auto"> <div className="mt-auto">
<button <Button
type="submit" type="submit"
className="w-full bg-black hover:bg-gray-800 text-white font-semibold py-2 px-4 rounded-lg transition-colors" className="w-full font-semibold py-2 px-4 rounded-lg transition-colors"
> >
</button> </Button>
</div> </div>
</form> </form>
</div> </div>

View File

@ -1,58 +1,50 @@
"use client" "use client";
import React, { useState } from "react"
import { Separator } from "@/components/ui/separator" import { cn } from "@/lib/utils";
import ProfilePage from "./profile/page" import React, { useState } from "react";
import ChangePasswordPage from "./change-password/page" import ProfilePage from "./profile/page";
import { Button } from "@/components/ui/button";
import ChangePasswordPage from "./change-password/page";
export default function ManagementDefaultPage() { export default function ManagementDefaultPage() {
const [activePage, setActivePage] = useState("profile") const [activePage, setActivePage] = useState("profile");
const renderContent = () => { const renderContent = () => {
switch (activePage) { switch (activePage) {
case "profile": case "profile":
return <ProfilePage /> return <ProfilePage />;
case "change-password": case "change-password":
return <ChangePasswordPage /> return <ChangePasswordPage />;
default: default:
return <ProfilePage /> return <ProfilePage />;
}
} }
};
return ( return (
<div className="flex h-full w-full flex-col"> <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"> <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"> <div className="ml-auto flex space-x-2">
<button <Button
className={cn("px-3 py-1 rounded-md text-sm transition-colors")}
variant={activePage === "profile" ? "default" : "secondary"}
onClick={() => setActivePage("profile")} 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>
<button <Button
className={cn("px-3 py-1 rounded-md text-sm transition-colors")}
variant={activePage === "change-password" ? "default" : "secondary"}
onClick={() => setActivePage("change-password")} 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> </Button>
</div> </div>
</header> </header>
{/* 主体内容 */} {/* 主体内容 */}
<main className="flex-1 overflow-auto"> <main className="flex-1 overflow-auto">{renderContent()}</main>
{renderContent()}
</main>
</div> </div>
) );
} }

View File

@ -1,7 +1,8 @@
// src/app/(app)/management/profile/page.tsx
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { getUserInfo } from "@/app/(protected)/dashboard/management/actions/getUserInfo"; import { getUserInfo } from "@/app/(protected)/dashboard/management/actions/getUserInfo";
import { updateUserInfo } from "@/app/(protected)/dashboard/management/actions/updateUserInfo"; import { updateUserInfo } from "@/app/(protected)/dashboard/management/actions/updateUserInfo";
@ -34,8 +35,12 @@ export default function ProfilePage() {
}, []); }, []);
const handleSave = async () => { const handleSave = async () => {
const nameInput = document.getElementById("name") as HTMLInputElement | null; const nameInput = document.getElementById(
const emailInput = document.getElementById("email") as HTMLInputElement | null; "name"
) as HTMLInputElement | null;
const emailInput = document.getElementById(
"email"
) as HTMLInputElement | null;
if (!nameInput || !emailInput) { if (!nameInput || !emailInput) {
alert("表单元素缺失"); alert("表单元素缺失");
@ -51,16 +56,17 @@ export default function ProfilePage() {
setUser(updatedUser); setUser(updatedUser);
setIsEditing(false); setIsEditing(false);
} catch (error: unknown) { } catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '更新用户信息失败'; const errorMessage =
error instanceof Error ? error.message : "更新用户信息失败";
alert(errorMessage); alert(errorMessage);
} }
}; };
if (!user) return <p>...</p>; if (!user) return <p>...</p>;
return ( return (
<div className="h-full w-full p-6"> <div className="h-full w-full p-6">
<div className="h-full w-full bg-white shadow-lg rounded-xl p-8 flex flex-col"> <div className="h-full w-full bg-card shadow-lg rounded-xl p-8 flex flex-col">
<h1 className="text-2xl font-bold mb-6"></h1> <h1 className="text-2xl font-bold mb-6"></h1>
<div className="flex items-center space-x-6 mb-6"> <div className="flex items-center space-x-6 mb-6">
@ -71,79 +77,91 @@ export default function ProfilePage() {
</div> </div>
<div> <div>
{isEditing ? ( {isEditing ? (
<input <Input
id="name" id="name"
type="text" type="text"
defaultValue={user?.name || ""} defaultValue={user?.name || ""}
className="mt-1 block w-full border border-gray-300 rounded-md p-2" className="mt-1 block w-full border rounded-md p-2"
/> />
) : ( ) : (
<h2 className="text-xl font-semibold">{user?.name || "未提供"}</h2> <h2 className="text-xl font-semibold">
{user?.name || "未提供"}
</h2>
)} )}
<p className="text-gray-500">{user?.role}</p> <p>{user?.role}</p>
<p className="text-gray-500">{user.emailVerified ? new Date(user.emailVerified).toLocaleString() : "未验证"}</p> <p>
{user.emailVerified
? new Date(user.emailVerified).toLocaleString()
: "未验证"}
</p>
</div> </div>
</div> </div>
<hr className="border-gray-200 mb-6" /> <hr className="border-border mb-6" />
<div className="space-y-4 flex-1"> <div className="space-y-4 flex-1">
<div> <div>
<label className="block text-sm font-medium text-gray-700">ID</label> <label className="block text-sm font-medium">ID</label>
<p className="mt-1 text-lg font-medium text-gray-900">{user.id}</p> <p className="mt-1 text-lg font-medium">{user.id}</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700"></label> <label className="block text-sm font-medium"></label>
{isEditing ? ( {isEditing ? (
<input <Input
id="email" id="email"
type="email" type="email"
defaultValue={user.email} defaultValue={user.email}
className="mt-1 block w-full border border-gray-300 rounded-md p-2" className="mt-1 block w-full border rounded-md p-2"
/> />
) : ( ) : (
<p className="mt-1 text-lg font-medium text-gray-900">{user.email}</p> <p className="mt-1 text-lg font-medium">{user.email}</p>
)} )}
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700"></label> <label className="block text-sm font-medium"></label>
<p className="mt-1 text-lg font-medium text-gray-900">{new Date(user.createdAt).toLocaleString()}</p> <p className="mt-1 text-lg font-medium">
{new Date(user.createdAt).toLocaleString()}
</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-gray-700"></label> <label className="block text-sm font-medium"></label>
<p className="mt-1 text-lg font-medium text-gray-900">{new Date(user.updatedAt).toLocaleString()}</p> <p className="mt-1 text-lg font-medium">
{new Date(user.updatedAt).toLocaleString()}
</p>
</div> </div>
</div> </div>
<div className="pt-4 flex justify-end space-x-2"> <div className="pt-4 flex justify-end space-x-2">
{isEditing ? ( {isEditing ? (
<> <>
<button <Button
onClick={() => setIsEditing(false)} onClick={() => setIsEditing(false)}
type="button" type="button"
className="px-4 py-2 bg-gray-300 rounded-md hover:bg-gray-400 transition-colors" className="px-4 py-2 rounded-md transition-colors"
> >
</button> </Button>
<button <Button
onClick={handleSave} onClick={handleSave}
type="button" type="button"
className="px-4 py-2 bg-black text-white rounded-md hover:bg-gray-800 transition-colors" variant="secondary"
className="px-4 py-2 rounded-md transition-colors"
> >
</button> </Button>
</> </>
) : ( ) : (
<button <Button
onClick={() => setIsEditing(true)} onClick={() => setIsEditing(true)}
type="button" type="button"
className="px-4 py-2 bg-black text-white rounded-md hover:bg-gray-800 transition-colors" className="px-4 py-2 rounded-md transition-colors"
> >
</button> </Button>
)} )}
</div> </div>
</div> </div>

View File

@ -1,10 +1,3 @@
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma";
import { redirect } from "next/navigation";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { import {
Users, Users,
BookOpen, BookOpen,
@ -14,9 +7,22 @@ import {
AlertCircle, AlertCircle,
BarChart3, BarChart3,
Target, Target,
Activity Activity,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link"; import Link from "next/link";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import prisma from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
interface Stats { interface Stats {
totalUsers?: number; totalUsers?: number;
@ -45,7 +51,7 @@ export default async function DashboardPage() {
// 获取用户的完整信息 // 获取用户的完整信息
const fullUser = await prisma.user.findUnique({ const fullUser = await prisma.user.findUnique({
where: { id: user.id }, where: { id: user.id },
select: { id: true, name: true, email: true, image: true, role: true } select: { id: true, name: true, email: true, image: true, role: true },
}); });
if (!fullUser) { if (!fullUser) {
@ -58,27 +64,35 @@ export default async function DashboardPage() {
if (fullUser.role === "ADMIN") { if (fullUser.role === "ADMIN") {
// 管理员统计 // 管理员统计
const [totalUsers, totalProblems, totalSubmissions, recentUsers] = await Promise.all([ const [totalUsers, totalProblems, totalSubmissions, recentUsers] =
await Promise.all([
prisma.user.count(), prisma.user.count(),
prisma.problem.count(), prisma.problem.count(),
prisma.submission.count(), prisma.submission.count(),
prisma.user.findMany({ prisma.user.findMany({
take: 5, take: 5,
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
select: { id: true, name: true, email: true, role: true, createdAt: true } select: {
}) id: true,
name: true,
email: true,
role: true,
createdAt: true,
},
}),
]); ]);
stats = { totalUsers, totalProblems, totalSubmissions }; stats = { totalUsers, totalProblems, totalSubmissions };
recentActivity = recentUsers.map(user => ({ recentActivity = recentUsers.map((user) => ({
type: "新用户注册", type: "新用户注册",
title: user.name || user.email, title: user.name || user.email,
description: `角色: ${user.role}`, description: `角色: ${user.role}`,
time: user.createdAt time: user.createdAt,
})); }));
} else if (fullUser.role === "TEACHER") { } else if (fullUser.role === "TEACHER") {
// 教师统计 // 教师统计
const [totalStudents, totalProblems, totalSubmissions, recentSubmissions] = await Promise.all([ const [totalStudents, totalProblems, totalSubmissions, recentSubmissions] =
await Promise.all([
prisma.user.count({ where: { role: "GUEST" } }), prisma.user.count({ where: { role: "GUEST" } }),
prisma.problem.count({ where: { isPublished: true } }), prisma.problem.count({ where: { isPublished: true } }),
prisma.submission.count(), prisma.submission.count(),
@ -90,30 +104,41 @@ export default async function DashboardPage() {
problem: { problem: {
select: { select: {
displayId: true, displayId: true,
localizations: { where: { type: "TITLE", locale: "zh" }, select: { content: true } } localizations: {
} where: { type: "TITLE", locale: "zh" },
} select: { content: true },
} },
}) },
},
},
}),
]); ]);
stats = { totalStudents, totalProblems, totalSubmissions }; stats = { totalStudents, totalProblems, totalSubmissions };
recentActivity = recentSubmissions.map(sub => ({ recentActivity = recentSubmissions.map((sub) => ({
type: "学生提交", type: "学生提交",
title: `${sub.user.name || sub.user.email} 提交了题目 ${sub.problem.displayId}`, title: `${sub.user.name || sub.user.email} 提交了题目 ${
description: sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`, sub.problem.displayId
}`,
description:
sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`,
time: sub.createdAt, time: sub.createdAt,
status: sub.status status: sub.status,
})); }));
} else { } else {
// 学生统计 // 学生统计
const [totalProblems, completedProblems, totalSubmissions, recentSubmissions] = await Promise.all([ const [
totalProblems,
completedProblems,
totalSubmissions,
recentSubmissions,
] = await Promise.all([
prisma.problem.count({ where: { isPublished: true } }), prisma.problem.count({ where: { isPublished: true } }),
prisma.submission.count({ prisma.submission.count({
where: { where: {
userId: user.id, userId: user.id,
status: "AC" status: "AC",
} },
}), }),
prisma.submission.count({ where: { userId: user.id } }), prisma.submission.count({ where: { userId: user.id } }),
prisma.submission.findMany({ prisma.submission.findMany({
@ -124,20 +149,24 @@ export default async function DashboardPage() {
problem: { problem: {
select: { select: {
displayId: true, displayId: true,
localizations: { where: { type: "TITLE", locale: "zh" }, select: { content: true } } localizations: {
} where: { type: "TITLE", locale: "zh" },
} select: { content: true },
} },
}) },
},
},
}),
]); ]);
stats = { totalProblems, completedProblems, totalSubmissions }; stats = { totalProblems, completedProblems, totalSubmissions };
recentActivity = recentSubmissions.map(sub => ({ recentActivity = recentSubmissions.map((sub) => ({
type: "我的提交", type: "我的提交",
title: `题目 ${sub.problem.displayId}`, title: `题目 ${sub.problem.displayId}`,
description: sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`, description:
sub.problem.localizations[0]?.content || `题目${sub.problem.displayId}`,
time: sub.createdAt, time: sub.createdAt,
status: sub.status status: sub.status,
})); }));
} }
@ -148,52 +177,129 @@ export default async function DashboardPage() {
title: "系统管理后台", title: "系统管理后台",
description: "管理整个系统的用户、题目和统计数据", description: "管理整个系统的用户、题目和统计数据",
stats: [ stats: [
{ label: "总用户数", value: stats.totalUsers, icon: Users, color: "text-blue-600" }, {
{ label: "总题目数", value: stats.totalProblems, icon: BookOpen, color: "text-green-600" }, label: "总用户数",
{ label: "总提交数", value: stats.totalSubmissions, icon: Activity, color: "text-purple-600" } 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: [ actions: [
{ label: "用户管理", href: "/dashboard/usermanagement/guest", icon: Users }, {
{ label: "题目管理", href: "/dashboard/usermanagement/problem", icon: BookOpen }, label: "用户管理",
{ label: "管理员设置", href: "/dashboard/management", icon: Target } href: "/dashboard/usermanagement/guest",
] icon: Users,
},
{
label: "题目管理",
href: "/dashboard/usermanagement/problem",
icon: BookOpen,
},
{
label: "管理员设置",
href: "/dashboard/management",
icon: Target,
},
],
}; };
case "TEACHER": case "TEACHER":
return { return {
title: "教师教学平台", title: "教师教学平台",
description: "查看学生学习情况,管理教学资源", description: "查看学生学习情况,管理教学资源",
stats: [ stats: [
{ label: "学生数量", value: stats.totalStudents, icon: Users, color: "text-blue-600" }, {
{ label: "题目数量", value: stats.totalProblems, icon: BookOpen, color: "text-green-600" }, label: "学生数量",
{ label: "提交数量", value: stats.totalSubmissions, icon: Activity, color: "text-purple-600" } 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: [ actions: [
{ label: "学生管理", href: "/dashboard/usermanagement/guest", icon: Users }, {
{ label: "题目管理", href: "/dashboard/usermanagement/problem", icon: BookOpen }, label: "学生管理",
{ label: "统计分析", href: "/dashboard/teacher/dashboard", icon: BarChart3 } href: "/dashboard/usermanagement/guest",
] icon: Users,
},
{
label: "题目管理",
href: "/dashboard/usermanagement/problem",
icon: BookOpen,
},
{
label: "统计分析",
href: "/dashboard/teacher/dashboard",
icon: BarChart3,
},
],
}; };
default: default:
return { return {
title: "我的学习中心", title: "我的学习中心",
description: "继续您的编程学习之旅", description: "继续您的编程学习之旅",
stats: [ stats: [
{ label: "总题目数", value: stats.totalProblems, icon: BookOpen, color: "text-blue-600" }, {
{ label: "已完成", value: stats.completedProblems, icon: CheckCircle, color: "text-green-600" }, label: "总题目数",
{ label: "提交次数", value: stats.totalSubmissions, icon: Activity, color: "text-purple-600" } 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: [ actions: [
{ label: "开始做题", href: "/problemset", icon: BookOpen }, { label: "开始做题", href: "/problemset", icon: BookOpen },
{ label: "我的进度", href: "/dashboard/student/dashboard", icon: TrendingUp }, {
{ label: "个人设置", href: "/dashboard/management", icon: Target } label: "我的进度",
] href: "/dashboard/student/dashboard",
icon: TrendingUp,
},
{ label: "个人设置", href: "/dashboard/management", icon: Target },
],
}; };
} }
}; };
const config = getRoleConfig(); const config = getRoleConfig();
const completionRate = fullUser.role === "GUEST" ? const completionRate =
((stats.totalProblems || 0) > 0 ? ((stats.completedProblems || 0) / (stats.totalProblems || 1)) * 100 : 0) : 0; fullUser.role === "GUEST"
? (stats.totalProblems || 0) > 0
? ((stats.completedProblems || 0) / (stats.totalProblems || 1)) * 100
: 0
: 0;
return ( return (
<div className="space-y-6 p-6"> <div className="space-y-6 p-6">
@ -214,7 +320,9 @@ export default async function DashboardPage() {
{config.stats.map((stat, index) => ( {config.stats.map((stat, index) => (
<Card key={index}> <Card key={index}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.label}</CardTitle> <CardTitle className="text-sm font-medium">
{stat.label}
</CardTitle>
<stat.icon className={`h-4 w-4 ${stat.color}`} /> <stat.icon className={`h-4 w-4 ${stat.color}`} />
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -233,7 +341,8 @@ export default async function DashboardPage() {
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{stats.completedProblems || 0} / {stats.totalProblems || 0} {stats.completedProblems || 0} / {stats.totalProblems || 0}{" "}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
@ -287,7 +396,9 @@ export default async function DashboardPage() {
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-medium">{activity.title}</p> <p className="text-sm font-medium">{activity.title}</p>
<p className="text-sm text-muted-foreground">{activity.description}</p> <p className="text-sm text-muted-foreground">
{activity.description}
</p>
</div> </div>
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{new Date(activity.time).toLocaleDateString()} {new Date(activity.time).toLocaleDateString()}

View File

@ -1,7 +1,5 @@
"use client"; "use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Progress } from "@/components/ui/progress";
import { import {
Table, Table,
TableBody, TableBody,
@ -10,9 +8,11 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { getStudentDashboardData } from "@/app/(protected)/dashboard/(userdashboard)/_actions/student-dashboard"; import { Progress } from "@/components/ui/progress";
import { PieChart, Pie, Cell, ResponsiveContainer } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getStudentDashboardData } from "@/app/(protected)/dashboard/actions/student-dashboard";
interface DashboardData { interface DashboardData {
completionData: { completionData: {
@ -86,7 +86,13 @@ export default function StudentDashboard() {
); );
} }
const { completionData, errorData, difficultProblems, pieChartData, errorPieChartData } = data; const {
completionData,
errorData,
difficultProblems,
pieChartData,
errorPieChartData,
} = data;
const COLORS = ["#4CAF50", "#FFC107"]; const COLORS = ["#4CAF50", "#FFC107"];
return ( return (
@ -102,8 +108,12 @@ export default function StudentDashboard() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>{completionData.completed}/{completionData.total}</span> <span>
<span className="text-green-500">{completionData.percentage}%</span> {completionData.completed}/{completionData.total}
</span>
<span className="text-green-500">
{completionData.percentage}%
</span>
</div> </div>
<Progress value={completionData.percentage} className="h-2" /> <Progress value={completionData.percentage} className="h-2" />
<div className="h-[200px]"> <div className="h-[200px]">
@ -119,9 +129,17 @@ export default function StudentDashboard() {
paddingAngle={5} paddingAngle={5}
dataKey="value" dataKey="value"
> >
{pieChartData.map((entry: { name: string; value: number }, index: number) => ( {pieChartData.map(
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> (
))} entry: { name: string; value: number },
index: number
) => (
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
)
)}
</Pie> </Pie>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
@ -138,7 +156,9 @@ export default function StudentDashboard() {
<CardContent> <CardContent>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span>{errorData.wrong}/{errorData.total}</span> <span>
{errorData.wrong}/{errorData.total}
</span>
<span className="text-yellow-500">{errorData.percentage}%</span> <span className="text-yellow-500">{errorData.percentage}%</span>
</div> </div>
<Progress value={errorData.percentage} className="h-2" /> <Progress value={errorData.percentage} className="h-2" />
@ -155,9 +175,17 @@ export default function StudentDashboard() {
paddingAngle={5} paddingAngle={5}
dataKey="value" dataKey="value"
> >
{errorPieChartData.map((entry: { name: string; value: number }, index: number) => ( {errorPieChartData.map(
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} /> (
))} entry: { name: string; value: number },
index: number
) => (
<Cell
key={`cell-${index}`}
fill={COLORS[index % COLORS.length]}
/>
)
)}
</Pie> </Pie>
</PieChart> </PieChart>
</ResponsiveContainer> </ResponsiveContainer>
@ -188,14 +216,21 @@ export default function StudentDashboard() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{difficultProblems.map((problem: { id: string | number; title: string; difficulty: string; errorCount: number }) => ( {difficultProblems.map(
(problem: {
id: string | number;
title: string;
difficulty: string;
errorCount: number;
}) => (
<TableRow key={problem.id}> <TableRow key={problem.id}>
<TableCell>{problem.id}</TableCell> <TableCell>{problem.id}</TableCell>
<TableCell>{problem.title}</TableCell> <TableCell>{problem.title}</TableCell>
<TableCell>{problem.difficulty}</TableCell> <TableCell>{problem.difficulty}</TableCell>
<TableCell>{problem.errorCount}</TableCell> <TableCell>{problem.errorCount}</TableCell>
</TableRow> </TableRow>
))} )
)}
</TableBody> </TableBody>
</Table> </Table>
) : ( ) : (

View File

@ -1,10 +1,13 @@
"use client"; "use client";
import { TrendingUp } from "lucide-react"; import {
import { Bar, BarChart, XAxis, YAxis, LabelList, CartesianGrid } from "recharts"; Bar,
import { Button } from "@/components/ui/button"; BarChart,
import { useState, useEffect } from "react"; XAxis,
YAxis,
LabelList,
CartesianGrid,
} from "recharts";
import { import {
Card, Card,
CardContent, CardContent,
@ -27,7 +30,14 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "@/components/ui/table"; } from "@/components/ui/table";
import { getDashboardStats, ProblemCompletionData, DifficultProblemData } from "@/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard"; import { TrendingUp } from "lucide-react";
import { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import {
getDashboardStats,
ProblemCompletionData,
DifficultProblemData,
} from "@/app/(protected)/dashboard/actions/teacher-dashboard";
const ITEMS_PER_PAGE = 5; // 每页显示的题目数量 const ITEMS_PER_PAGE = 5; // 每页显示的题目数量
@ -45,7 +55,9 @@ const chartConfig = {
export default function TeacherDashboard() { export default function TeacherDashboard() {
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [chartData, setChartData] = useState<ProblemCompletionData[]>([]); const [chartData, setChartData] = useState<ProblemCompletionData[]>([]);
const [difficultProblems, setDifficultProblems] = useState<DifficultProblemData[]>([]); const [difficultProblems, setDifficultProblems] = useState<
DifficultProblemData[]
>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -57,8 +69,8 @@ export default function TeacherDashboard() {
setChartData(data.problemData); setChartData(data.problemData);
setDifficultProblems(data.difficultProblems); setDifficultProblems(data.difficultProblems);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '获取数据失败'); setError(err instanceof Error ? err.message : "获取数据失败");
console.error('Failed to fetch dashboard data:', err); console.error("Failed to fetch dashboard data:", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -178,7 +190,9 @@ export default function TeacherDashboard() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} onClick={() =>
setCurrentPage((prev) => Math.max(prev - 1, 1))
}
disabled={currentPage === 1} disabled={currentPage === 1}
> >
@ -189,7 +203,9 @@ export default function TeacherDashboard() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
> >
@ -222,7 +238,9 @@ export default function TeacherDashboard() {
</div> </div>
{difficultProblems.length === 0 ? ( {difficultProblems.length === 0 ? (
<div className="flex items-center justify-center h-64"> <div className="flex items-center justify-center h-64">
<div className="text-lg text-muted-foreground"></div> <div className="text-lg text-muted-foreground">
</div>
</div> </div>
) : ( ) : (
<Table> <Table>
@ -236,7 +254,10 @@ export default function TeacherDashboard() {
<TableBody> <TableBody>
{difficultProblems.map((problem) => ( {difficultProblems.map((problem) => (
<TableRow key={problem.id}> <TableRow key={problem.id}>
<TableCell>{problem.problemDisplayId || problem.id.substring(0, 8)}</TableCell> <TableCell>
{problem.problemDisplayId ||
problem.id.substring(0, 8)}
</TableCell>
<TableCell>{problem.problemTitle}</TableCell> <TableCell>{problem.problemTitle}</TableCell>
<TableCell>{problem.problemCount}</TableCell> <TableCell>{problem.problemCount}</TableCell>
</TableRow> </TableRow>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { getDashboardStats } from "@/app/(protected)/dashboard/(userdashboard)/_actions/teacher-dashboard"; import { getDashboardStats } from "@/app/(protected)/dashboard/actions/teacher-dashboard";
interface DashboardData { interface DashboardData {
problemData: Array<{ problemData: Array<{
@ -37,8 +37,8 @@ export default function TestDataPage() {
const result = await getDashboardStats(); const result = await getDashboardStats();
setData(result); setData(result);
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : '获取数据失败'); setError(err instanceof Error ? err.message : "获取数据失败");
console.error('Failed to fetch data:', err); console.error("Failed to fetch data:", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -77,10 +77,14 @@ export default function TestDataPage() {
<div> <div>
<h2 className="text-xl font-semibold mb-2"></h2> <h2 className="text-xl font-semibold mb-2"></h2>
<pre className="bg-gray-100 p-4 rounded overflow-auto"> <pre className="bg-gray-100 p-4 rounded overflow-auto">
{JSON.stringify({ {JSON.stringify(
{
totalProblems: data?.totalProblems, totalProblems: data?.totalProblems,
totalDifficultProblems: data?.totalDifficultProblems, totalDifficultProblems: data?.totalDifficultProblems,
}, null, 2)} },
null,
2
)}
</pre> </pre>
</div> </div>
</div> </div>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { auth } from "@/lib/auth";
import prisma from "@/lib/prisma"; import prisma from "@/lib/prisma";
import { auth } from "@/lib/auth";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
interface ProtectedLayoutProps { interface ProtectedLayoutProps {
@ -7,7 +7,10 @@ interface ProtectedLayoutProps {
allowedRoles: string[]; allowedRoles: string[];
} }
export default async function ProtectedLayout({ children, allowedRoles }: ProtectedLayoutProps) { export default async function ProtectedLayout({
children,
allowedRoles,
}: ProtectedLayoutProps) {
const session = await auth(); const session = await auth();
const userId = session?.user?.id; const userId = session?.user?.id;
@ -17,7 +20,7 @@ export default async function ProtectedLayout({ children, allowedRoles }: Protec
const user = await prisma.user.findUnique({ const user = await prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { role: true } select: { role: true },
}); });
if (!user || !allowedRoles.includes(user.role)) { if (!user || !allowedRoles.includes(user.role)) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,13 +33,13 @@
--chart-5: 213 16% 16%; --chart-5: 213 16% 16%;
--radius: 0.5rem; --radius: 0.5rem;
--sidebar-background: 0 0% 98%; --sidebar-background: 0 0% 98%;
--sidebar-foreground: 240 5.3% 26.1%; --sidebar-foreground: 213 13% 6%;
--sidebar-primary: 240 5.9% 10%; --sidebar-primary: 213 13% 16%;
--sidebar-primary-foreground: 0 0% 98%; --sidebar-primary-foreground: 213 13% 76%;
--sidebar-accent: 240 4.8% 95.9%; --sidebar-accent: 0 0% 85%;
--sidebar-accent-foreground: 240 5.9% 10%; --sidebar-accent-foreground: 0 0% 25%;
--sidebar-border: 220 13% 91%; --sidebar-border: 0 0% 95%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 213 13% 16%;
} }
.dark { .dark {
@ -67,14 +67,14 @@
--chart-3: 216 28% 22%; --chart-3: 216 28% 22%;
--chart-4: 210 7% 28%; --chart-4: 210 7% 28%;
--chart-5: 210 20% 82%; --chart-5: 210 20% 82%;
--sidebar-background: 240 5.9% 10%; --sidebar-background: 216 28% 5%;
--sidebar-foreground: 240 4.8% 95.9%; --sidebar-foreground: 210 17% 92%;
--sidebar-primary: 224.3 76.3% 48%; --sidebar-primary: 210 17% 82%;
--sidebar-primary-foreground: 0 0% 100%; --sidebar-primary-foreground: 210 17% 22%;
--sidebar-accent: 240 3.7% 15.9%; --sidebar-accent: 216 28% 22%;
--sidebar-accent-foreground: 240 4.8% 95.9%; --sidebar-accent-foreground: 216 28% 82%;
--sidebar-border: 240 3.7% 15.9%; --sidebar-border: 216 18% 12%;
--sidebar-ring: 217.2 91.2% 59.8%; --sidebar-ring: 210 17% 82%;
} }
} }
@ -119,14 +119,3 @@ code[data-theme*=" "] span {
color: var(--shiki-dark); color: var(--shiki-dark);
background-color: var(--shiki-dark-bg); background-color: var(--shiki-dark-bg);
} }
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@ -6,7 +6,6 @@ import { NextIntlClientProvider } from "next-intl";
import { ThemeProvider } from "@/components/theme-provider"; import { ThemeProvider } from "@/components/theme-provider";
import { SettingsDialog } from "@/components/settings-dialog"; import { SettingsDialog } from "@/components/settings-dialog";
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Judge4c", title: "Judge4c",
description: description:

View File

@ -1,4 +1,3 @@
import { Button } from "@/components/ui/button"
import { import {
DialogContent, DialogContent,
DialogDescription, DialogDescription,
@ -6,9 +5,10 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogClose, DialogClose,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label" import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
export function ShareDialogContent({ link }: { link: string }) { export function ShareDialogContent({ link }: { link: string }) {
return ( return (
@ -35,5 +35,5 @@ export function ShareDialogContent({ link }: { link: string }) {
</DialogClose> </DialogClose>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
) );
} }

View File

@ -1,37 +1,54 @@
"use client" "use client";
import * as React from "react" import {
Check,
X,
Info,
AlertTriangle,
Copy,
Check as CheckIcon,
} from "lucide-react";
import Link from "next/link";
import * as React from "react";
import { import {
Dialog, Dialog,
DialogTrigger, DialogTrigger,
DialogContent, DialogContent,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/components/ui/dialog" } from "@/components/ui/dialog";
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 { Button } from "@/components/ui/button"
import Link from "next/link"
export function WrongbookDialog({ problems, children }: { problems: { id: string; name: string; status: string; url?: string }[]; children?: React.ReactNode }) { export function WrongbookDialog({
const [copiedId, setCopiedId] = React.useState<string | null>(null) 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 handleCopyLink = async (item: { id: string; url?: string }) => {
const link = `${window.location.origin}/problems/${item.id}` const link = `${window.location.origin}/problems/${item.id}`;
try { try {
await navigator.clipboard.writeText(link) await navigator.clipboard.writeText(link);
setCopiedId(item.id) setCopiedId(item.id);
setTimeout(() => setCopiedId(null), 2000) // 2秒后重置状态 setTimeout(() => setCopiedId(null), 2000); // 2秒后重置状态
} catch (err) { } catch (err) {
console.error('Failed to copy link:', err) console.error("Failed to copy link:", err);
}
} }
};
return ( return (
<Dialog> <Dialog>
<DialogTrigger asChild> <DialogTrigger asChild>
{children ? children : ( {children ? (
<button className="px-4 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90"></button> children
) : (
<button className="px-4 py-2 rounded bg-primary text-primary-foreground hover:bg-primary/90">
</button>
)} )}
</DialogTrigger> </DialogTrigger>
<DialogContent className="max-w-2xl p-0"> <DialogContent className="max-w-2xl p-0">
@ -44,13 +61,18 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string
<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"></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>
</thead> </thead>
<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"> <td className="px-3 py-2">
<Button <Button
variant="ghost" variant="ghost"
@ -66,7 +88,10 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string
</Button> </Button>
</td> </td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<Link href={item.url || `/problems/${item.id}`} className="text-primary underline underline-offset-2 hover:text-primary/80"> <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>
@ -74,28 +99,46 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string
{(() => { {(() => {
if (item.status === "AC") { if (item.status === "AC") {
return ( return (
<Badge className="bg-green-500 text-white" variant="default"> <Badge
<Check className="w-3 h-3 mr-1" />{item.status} className="bg-green-500 text-white"
variant="default"
>
<Check className="w-3 h-3 mr-1" />
{item.status}
</Badge> </Badge>
) );
} else if (item.status === "WA") { } else if (item.status === "WA") {
return ( return (
<Badge className="bg-red-500 text-white" variant="destructive"> <Badge
<X className="w-3 h-3 mr-1" />{item.status} className="bg-red-500 text-white"
variant="destructive"
>
<X className="w-3 h-3 mr-1" />
{item.status}
</Badge> </Badge>
) );
} else if (["RE", "CE", "MLE", "TLE"].includes(item.status)) { } else if (
["RE", "CE", "MLE", "TLE"].includes(item.status)
) {
return ( return (
<Badge className="bg-orange-500 text-white" variant="secondary"> <Badge
<AlertTriangle className="w-3 h-3 mr-1" />{item.status} className="bg-orange-500 text-white"
variant="secondary"
>
<AlertTriangle className="w-3 h-3 mr-1" />
{item.status}
</Badge> </Badge>
) );
} else { } else {
return ( return (
<Badge className="bg-gray-200 text-gray-700" variant="secondary"> <Badge
<Info className="w-3 h-3 mr-1" />{item.status} className="bg-gray-200 text-gray-700"
variant="secondary"
>
<Info className="w-3 h-3 mr-1" />
{item.status}
</Badge> </Badge>
) );
} }
})()} })()}
</td> </td>
@ -107,5 +150,5 @@ export function WrongbookDialog({ problems, children }: { problems: { id: string
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
) );
} }

View File

@ -0,0 +1,16 @@
"use client";
import { useRouter } from "next/navigation";
import { LayoutDashboardIcon } from "lucide-react";
import { DropdownMenuItem } from "./ui/dropdown-menu";
export const DashboardButton = () => {
const router = useRouter();
return (
<DropdownMenuItem onClick={() => router.push("/dashboard")}>
<LayoutDashboardIcon />
Dashboard
</DropdownMenuItem>
);
};

View File

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

View File

@ -1,12 +1,5 @@
"use client" "use client";
import { ChevronRight, type LucideIcon } from "lucide-react"
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible"
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupLabel, SidebarGroupLabel,
@ -17,21 +10,27 @@ import {
SidebarMenuSub, SidebarMenuSub,
SidebarMenuSubButton, SidebarMenuSubButton,
SidebarMenuSubItem, SidebarMenuSubItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar";
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import { ChevronRight, type LucideIcon } from "lucide-react";
export function NavMain({ export function NavMain({
items, items,
}: { }: {
items: { items: {
title: string title: string;
url: string url: string;
icon: LucideIcon icon: LucideIcon;
isActive?: boolean isActive?: boolean;
items?: { items?: {
title: string title: string;
url: string url: string;
}[] }[];
}[] }[];
}) { }) {
return ( return (
<SidebarGroup> <SidebarGroup>
@ -74,5 +73,5 @@ export function NavMain({
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>
) );
} }

View File

@ -1,4 +1,4 @@
"use client" "use client";
import { import {
BookX, BookX,
@ -9,19 +9,7 @@ import {
X, X,
Info, Info,
AlertTriangle, AlertTriangle,
} from "lucide-react" } from "lucide-react";
import React, { useState } from "react"
import { useRouter } from "next/navigation"
import {
Dialog,
} from "@/components/ui/dialog"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupLabel, SidebarGroupLabel,
@ -30,24 +18,33 @@ import {
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar";
import { WrongbookDialog } from "@/components/UncompletedProject/wrongbook-dialog" import {
import { ShareDialogContent } from "@/components/UncompletedProject/sharedialog" DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import React, { useState } from "react";
import { useRouter } from "next/navigation";
import { Dialog } from "@/components/ui/dialog";
import { ShareDialogContent } from "@/components/UncompletedProject/sharedialog";
import { WrongbookDialog } from "@/components/UncompletedProject/wrongbook-dialog";
export function NavProjects({ export function NavProjects({
projects, projects,
}: { }: {
projects: { projects: {
id: string id: string;
name: string name: string;
status: string status: string;
url?: 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() const router = useRouter();
return ( return (
<> <>
@ -73,28 +70,30 @@ export function NavProjects({
<Check className="w-3 h-3" /> <Check className="w-3 h-3" />
{item.status} {item.status}
</span> </span>
) );
} else if (item.status === "WA") { } else if (item.status === "WA") {
return ( return (
<span className="ml-2 min-w-[60px] text-xs text-right px-2 py-0.5 rounded-full border flex items-center gap-1 border-red-500 bg-red-500 text-white"> <span className="ml-2 min-w-[60px] text-xs text-right px-2 py-0.5 rounded-full border flex items-center gap-1 border-red-500 bg-red-500 text-white">
<X className="w-3 h-3" /> <X className="w-3 h-3" />
{item.status} {item.status}
</span> </span>
) );
} else if (["RE", "CE", "MLE", "TLE"].includes(item.status)) { } else if (
["RE", "CE", "MLE", "TLE"].includes(item.status)
) {
return ( return (
<span className="ml-2 min-w-[60px] text-xs text-right px-2 py-0.5 rounded-full border flex items-center gap-1 border-orange-500 bg-orange-500 text-white"> <span className="ml-2 min-w-[60px] text-xs text-right px-2 py-0.5 rounded-full border flex items-center gap-1 border-orange-500 bg-orange-500 text-white">
<AlertTriangle className="w-3 h-3" /> <AlertTriangle className="w-3 h-3" />
{item.status} {item.status}
</span> </span>
) );
} else { } else {
return ( return (
<span className="ml-2 min-w-[60px] text-xs text-right px-2 py-0.5 rounded-full border flex items-center gap-1 border-gray-400 bg-gray-100 text-gray-700"> <span className="ml-2 min-w-[60px] text-xs text-right px-2 py-0.5 rounded-full border flex items-center gap-1 border-gray-400 bg-gray-100 text-gray-700">
<Info className="w-3 h-3" /> <Info className="w-3 h-3" />
{item.status} {item.status}
</span> </span>
) );
} }
})()} })()}
</span> </span>
@ -114,11 +113,11 @@ export function NavProjects({
> >
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
if (item.url) { if (item.url) {
router.push(item.url) router.push(item.url);
} else { } else {
router.push(`/problems/${item.id}`) router.push(`/problems/${item.id}`);
} }
}} }}
> >
@ -127,9 +126,11 @@ export function NavProjects({
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation();
setShareLink(`${window.location.origin}/problems/${item.id}`) setShareLink(
setShareOpen(true) `${window.location.origin}/problems/${item.id}`
);
setShareOpen(true);
}} }}
> >
<Share className="text-muted-foreground mr-2" /> <Share className="text-muted-foreground mr-2" />
@ -153,5 +154,5 @@ export function NavProjects({
<ShareDialogContent link={shareLink} /> <ShareDialogContent link={shareLink} />
</Dialog> </Dialog>
</> </>
) );
} }

View File

@ -1,23 +1,22 @@
import * as React from "react" import * as React from "react";
import { type LucideIcon } from "lucide-react"
import { import {
SidebarGroup, SidebarGroup,
SidebarGroupContent, SidebarGroupContent,
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar";
import { type LucideIcon } from "lucide-react";
export function NavSecondary({ export function NavSecondary({
items, items,
...props ...props
}: { }: {
items: { items: {
title: string title: string;
url: string url: string;
icon: LucideIcon icon: LucideIcon;
}[] }[];
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) { } & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
return ( return (
<SidebarGroup {...props}> <SidebarGroup {...props}>
@ -36,5 +35,5 @@ export function NavSecondary({
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
) );
} }

View File

@ -1,21 +1,11 @@
"use client" "use client";
import { import {
BadgeCheck, SidebarMenu,
// Bell, SidebarMenuButton,
ChevronsUpDown, SidebarMenuItem,
UserPen, useSidebar,
LogOut, } from "@/components/ui/sidebar";
// Sparkles,
} from "lucide-react"
import { useRouter } from "next/navigation"
import { signOut } from "next-auth/react"
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/ui/avatar"
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
@ -24,30 +14,28 @@ import {
DropdownMenuLabel, DropdownMenuLabel,
DropdownMenuSeparator, DropdownMenuSeparator,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu";
import { import { signOut } from "next-auth/react";
SidebarMenu, import { useRouter } from "next/navigation";
SidebarMenuButton, import { BadgeCheck, ChevronsUpDown, UserPen, LogOut } from "lucide-react";
SidebarMenuItem, import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
useSidebar,
} from "@/components/ui/sidebar"
export function NavUser({ export function NavUser({
user, user,
}: { }: {
user: { user: {
name: string name: string;
email: string email: string;
avatar: string avatar: string;
} };
}) { }) {
const { isMobile } = useSidebar() const { isMobile } = useSidebar();
const router = useRouter() const router = useRouter();
async function handleLogout() { async function handleLogout() {
await signOut({ await signOut({
callbackUrl: "/sign-in", callbackUrl: "/sign-in",
redirect: true redirect: true,
}); });
} }
@ -98,13 +86,6 @@ export function NavUser({
</div> </div>
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
{/* <DropdownMenuGroup>
<DropdownMenuItem>
<Sparkles />
Update
</DropdownMenuItem>
</DropdownMenuGroup> */}
{/* <DropdownMenuSeparator /> */}
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuItem onClick={handleAccount}> <DropdownMenuItem onClick={handleAccount}>
<BadgeCheck /> <BadgeCheck />
@ -114,10 +95,6 @@ export function NavUser({
<UserPen /> <UserPen />
Switch User Switch User
</DropdownMenuItem> </DropdownMenuItem>
{/* <DropdownMenuItem >
<Bell />
Notifications
</DropdownMenuItem> */}
</DropdownMenuGroup> </DropdownMenuGroup>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}> <DropdownMenuItem onClick={handleLogout}>
@ -128,5 +105,5 @@ export function NavUser({
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
) );
} }

View File

@ -1,15 +1,6 @@
"use client" "use client";
import { siteConfig } from "@/config/site"
import * as React from "react"
import {
LifeBuoy,
Send,
Shield,
} from "lucide-react"
import { NavMain } from "@/components/nav-main" import * as React from "react";
import { NavSecondary } from "@/components/nav-secondary"
import { NavUser } from "@/components/nav-user"
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@ -18,14 +9,19 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar";
import { User } from "next-auth" import { User } from "next-auth";
import { siteConfig } from "@/config/site";
import { NavMain } from "@/components/nav-main";
import { NavUser } from "@/components/nav-user";
import { LifeBuoy, Send, Shield } from "lucide-react";
import { NavSecondary } from "@/components/nav-secondary";
const adminData = { const adminData = {
navMain: [ navMain: [
{ {
title: "管理面板", title: "管理面板",
url: "#", url: "/dashboard",
icon: Shield, icon: Shield,
isActive: true, isActive: true,
items: [ items: [
@ -35,19 +31,21 @@ const adminData = {
{ title: "题目管理", url: "/dashboard/usermanagement/problem" }, { title: "题目管理", url: "/dashboard/usermanagement/problem" },
], ],
}, },
], ],
navSecondary: [ navSecondary: [
{ title: "帮助", url: "/", icon: LifeBuoy }, { title: "帮助", url: "/", icon: LifeBuoy },
{ title: "反馈", url: siteConfig.url.repo.github, icon: Send }, { title: "反馈", url: siteConfig.url.repo.github, icon: Send },
], ],
} };
interface AdminSidebarProps { interface AdminSidebarProps {
user: User; user: User;
} }
export function AdminSidebar({ user, ...props }: AdminSidebarProps & React.ComponentProps<typeof Sidebar>) { export function AdminSidebar({
user,
...props
}: AdminSidebarProps & React.ComponentProps<typeof Sidebar>) {
const userInfo = { const userInfo = {
name: user.name ?? "管理员", name: user.name ?? "管理员",
email: user.email ?? "", email: user.email ?? "",
@ -81,5 +79,5 @@ export function AdminSidebar({ user, ...props }: AdminSidebarProps & React.Compo
<NavUser user={userInfo} /> <NavUser user={userInfo} />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
) );
} }

View File

@ -1,20 +1,6 @@
"use client"; "use client";
import { siteConfig } from "@/config/site";
import * as React from "react"; import * as React from "react";
import {
// BookOpen,
Command,
LifeBuoy,
Send,
// Settings2,
SquareTerminal,
} from "lucide-react";
import { NavMain } from "@/components/nav-main";
import { NavProjects } from "@/components/nav-projects";
import { NavSecondary } from "@/components/nav-secondary";
import { NavUser } from "@/components/nav-user";
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@ -25,6 +11,12 @@ import {
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar"; } from "@/components/ui/sidebar";
import { User } from "next-auth"; import { User } from "next-auth";
import { siteConfig } from "@/config/site";
import { NavMain } from "@/components/nav-main";
import { NavUser } from "@/components/nav-user";
import { NavProjects } from "@/components/nav-projects";
import { NavSecondary } from "@/components/nav-secondary";
import { Command, LifeBuoy, Send, SquareTerminal } from "lucide-react";
const data = { const data = {
navMain: [ navMain: [

View File

@ -1,18 +1,13 @@
"use client" "use client";
import { siteConfig } from "@/config/site"
import * as React from "react"
import { import {
Command, Command,
LifeBuoy, LifeBuoy,
PieChart, PieChart,
Send, Send,
// Settings2,
SquareTerminal, SquareTerminal,
} from "lucide-react" } from "lucide-react";
import * as React from "react";
import { NavMain } from "@/components/nav-main"
import { NavSecondary } from "@/components/nav-secondary"
import { NavUser } from "@/components/nav-user"
import { import {
Sidebar, Sidebar,
SidebarContent, SidebarContent,
@ -21,8 +16,12 @@ import {
SidebarMenu, SidebarMenu,
SidebarMenuButton, SidebarMenuButton,
SidebarMenuItem, SidebarMenuItem,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar";
import { User } from "next-auth" import { User } from "next-auth";
import { siteConfig } from "@/config/site";
import { NavMain } from "@/components/nav-main";
import { NavUser } from "@/components/nav-user";
import { NavSecondary } from "@/components/nav-secondary";
const data = { const data = {
navMain: [ navMain: [
@ -81,13 +80,16 @@ const data = {
icon: Send, icon: Send,
}, },
], ],
} };
interface TeacherSidebarProps { interface TeacherSidebarProps {
user: User; user: User;
} }
export function TeacherSidebar({ user, ...props }: TeacherSidebarProps & React.ComponentProps<typeof Sidebar>) { export function TeacherSidebar({
user,
...props
}: TeacherSidebarProps & React.ComponentProps<typeof Sidebar>) {
const userInfo = { const userInfo = {
name: user.name ?? "", name: user.name ?? "",
email: user.email ?? "", email: user.email ?? "",
@ -121,5 +123,5 @@ export function TeacherSidebar({ user, ...props }: TeacherSidebarProps & React.C
<NavUser user={userInfo} /> <NavUser user={userInfo} />
</SidebarFooter> </SidebarFooter>
</Sidebar> </Sidebar>
) );
} }

View File

@ -13,6 +13,7 @@ import { auth, signIn, signOut } from "@/lib/auth";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { Skeleton } from "@/components/ui/skeleton"; import { Skeleton } from "@/components/ui/skeleton";
import { SettingsButton } from "@/components/settings-button"; import { SettingsButton } from "@/components/settings-button";
import { DashboardButton } from "@/components/dashboard-button";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
const handleLogIn = async () => { const handleLogIn = async () => {
@ -88,6 +89,7 @@ const UserAvatar = async () => {
</DropdownMenuLabel> </DropdownMenuLabel>
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuGroup> <DropdownMenuGroup>
<DashboardButton />
<SettingsButton /> <SettingsButton />
<DropdownMenuItem onClick={handleLogOut}> <DropdownMenuItem onClick={handleLogOut}>
<LogOutIcon /> <LogOutIcon />

View File

@ -0,0 +1,32 @@
import prisma from "@/lib/prisma";
import { Role } from "@/generated/client";
import { auth, signIn } from "@/lib/auth";
import { redirect } from "next/navigation";
interface ProtectedLayoutProps {
roles: Role[];
children: React.ReactNode;
}
export const ProtectedLayout = async ({
roles,
children,
}: ProtectedLayoutProps) => {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
await signIn();
}
const user = await prisma.user.findUnique({
where: { id: userId },
select: { role: true },
});
if (!user || !roles.includes(user.role)) {
redirect("unauthorized");
}
return <>{children}</>;
};

View File

@ -1,28 +1,26 @@
// 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 = ( export const ProblemEditView = ({ problemId }: ProblemEditViewProps) => {
// { problemId }: ProblemEditViewProps const components: Record<string, React.ReactNode> = {
) => { detail: <EditDetailPanel problemId={problemId} />,
// const components: Record<string, React.ReactNode> = { description: <EditDescriptionPanel problemId={problemId} />,
// description: <EditDescriptionPanel problemId={problemId} />, solution: <EditSolutionPanel problemId={problemId} />,
// solution: <EditSolutionPanel problemId={problemId} />, code: <EditCodePanel problemId={problemId} />,
// detail: <EditDetailPanel problemId={problemId} />, testcase: <EditTestcasePanel 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>
); );
}; };

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,5 +1,10 @@
import {
createUserConfig,
baseUserSchema,
baseAddUserSchema,
baseEditUserSchema,
} from "./base-config";
import { z } from "zod"; import { z } from "zod";
import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config'
export const guestSchema = baseUserSchema; export const guestSchema = baseUserSchema;
export type Guest = z.infer<typeof guestSchema>; export type Guest = z.infer<typeof guestSchema>;

View File

@ -24,8 +24,20 @@ export const problemConfig = {
apiPath: "/api/problem", apiPath: "/api/problem",
columns: [ columns: [
{ key: "id", label: "ID", sortable: true }, { key: "id", label: "ID", sortable: true },
{ key: "displayId", label: "题目编号", sortable: true, searchable: true, placeholder: "搜索编号" }, {
{ key: "difficulty", label: "难度", sortable: true, searchable: true, placeholder: "搜索难度" }, key: "displayId",
label: "题目编号",
sortable: true,
searchable: true,
placeholder: "搜索编号",
},
{
key: "difficulty",
label: "难度",
sortable: true,
searchable: true,
placeholder: "搜索难度",
},
], ],
formFields: [ formFields: [
{ key: "displayId", label: "题目编号", type: "number", required: true }, { key: "displayId", label: "题目编号", type: "number", required: true },

View File

@ -1,5 +1,10 @@
import {
createUserConfig,
baseUserSchema,
baseAddUserSchema,
baseEditUserSchema,
} from "./base-config";
import { z } from "zod"; import { z } from "zod";
import { createUserConfig, baseUserSchema, baseAddUserSchema, baseEditUserSchema } from './base-config'
export const teacherSchema = baseUserSchema; export const teacherSchema = baseUserSchema;
export type Teacher = z.infer<typeof teacherSchema>; export type Teacher = z.infer<typeof teacherSchema>;

View File

@ -247,8 +247,3 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
}, },
}, },
}); });
export const getCurrentUser=async ()=>{
const session=await auth();
return session?.user
}

View File

@ -14,85 +14,85 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
background: 'hsl(var(--background))', background: "hsl(var(--background))",
foreground: 'hsl(var(--foreground))', foreground: "hsl(var(--foreground))",
card: { card: {
DEFAULT: 'hsl(var(--card))', DEFAULT: "hsl(var(--card))",
foreground: 'hsl(var(--card-foreground))' foreground: "hsl(var(--card-foreground))",
}, },
popover: { popover: {
DEFAULT: 'hsl(var(--popover))', DEFAULT: "hsl(var(--popover))",
foreground: 'hsl(var(--popover-foreground))' foreground: "hsl(var(--popover-foreground))",
}, },
primary: { primary: {
DEFAULT: 'hsl(var(--primary))', DEFAULT: "hsl(var(--primary))",
foreground: 'hsl(var(--primary-foreground))' foreground: "hsl(var(--primary-foreground))",
}, },
secondary: { secondary: {
DEFAULT: 'hsl(var(--secondary))', DEFAULT: "hsl(var(--secondary))",
foreground: 'hsl(var(--secondary-foreground))' foreground: "hsl(var(--secondary-foreground))",
}, },
muted: { muted: {
DEFAULT: 'hsl(var(--muted))', DEFAULT: "hsl(var(--muted))",
foreground: 'hsl(var(--muted-foreground))' foreground: "hsl(var(--muted-foreground))",
}, },
accent: { accent: {
DEFAULT: 'hsl(var(--accent))', DEFAULT: "hsl(var(--accent))",
foreground: 'hsl(var(--accent-foreground))' foreground: "hsl(var(--accent-foreground))",
}, },
destructive: { destructive: {
DEFAULT: 'hsl(var(--destructive))', DEFAULT: "hsl(var(--destructive))",
foreground: 'hsl(var(--destructive-foreground))' foreground: "hsl(var(--destructive-foreground))",
}, },
border: 'hsl(var(--border))', border: "hsl(var(--border))",
input: 'hsl(var(--input))', input: "hsl(var(--input))",
ring: 'hsl(var(--ring))', ring: "hsl(var(--ring))",
chart: { chart: {
'1': 'hsl(var(--chart-1))', "1": "hsl(var(--chart-1))",
'2': 'hsl(var(--chart-2))', "2": "hsl(var(--chart-2))",
'3': 'hsl(var(--chart-3))', "3": "hsl(var(--chart-3))",
'4': 'hsl(var(--chart-4))', "4": "hsl(var(--chart-4))",
'5': 'hsl(var(--chart-5))' "5": "hsl(var(--chart-5))",
}, },
sidebar: { sidebar: {
DEFAULT: 'hsl(var(--sidebar-background))', DEFAULT: "hsl(var(--sidebar-background))",
foreground: 'hsl(var(--sidebar-foreground))', foreground: "hsl(var(--sidebar-foreground))",
primary: 'hsl(var(--sidebar-primary))', primary: "hsl(var(--sidebar-primary))",
'primary-foreground': 'hsl(var(--sidebar-primary-foreground))', "primary-foreground": "hsl(var(--sidebar-primary-foreground))",
accent: 'hsl(var(--sidebar-accent))', accent: "hsl(var(--sidebar-accent))",
'accent-foreground': 'hsl(var(--sidebar-accent-foreground))', "accent-foreground": "hsl(var(--sidebar-accent-foreground))",
border: 'hsl(var(--sidebar-border))', border: "hsl(var(--sidebar-border))",
ring: 'hsl(var(--sidebar-ring))' ring: "hsl(var(--sidebar-ring))",
} },
}, },
borderRadius: { borderRadius: {
lg: 'var(--radius)', lg: "var(--radius)",
md: 'calc(var(--radius) - 2px)', md: "calc(var(--radius) - 2px)",
sm: 'calc(var(--radius) - 4px)' sm: "calc(var(--radius) - 4px)",
}, },
keyframes: { keyframes: {
'accordion-down': { "accordion-down": {
from: { from: {
height: '0' height: "0",
}, },
to: { to: {
height: 'var(--radix-accordion-content-height)' height: "var(--radix-accordion-content-height)",
}
}, },
'accordion-up': { },
"accordion-up": {
from: { from: {
height: 'var(--radix-accordion-content-height)' height: "var(--radix-accordion-content-height)",
}, },
to: { to: {
height: '0' height: "0",
} },
} },
}, },
animation: { animation: {
'accordion-down': 'accordion-down 0.2s ease-out', "accordion-down": "accordion-down 0.2s ease-out",
'accordion-up': 'accordion-up 0.2s ease-out' "accordion-up": "accordion-up 0.2s ease-out",
} },
} },
}, },
plugins: [animate], plugins: [animate],
} satisfies Config; } satisfies Config;