mirror of
https://github.com/massbug/judge4c.git
synced 2025-07-03 23:30:50 +00:00
feat(creater): add feature to display multi-language description and solution in problem-creater
- 在编辑题目描述和解析面板中添加语言切换功能 - 实现获取题目支持的语言列表和对应语言的题目数据 - 增加添加新语言的功能(仅前端) -优化题目描述和解析的编辑、预览和对比功能 - 在预览中添加 Accordion 和 VideoEmbed 组件支持
This commit is contained in:
parent
95a1817419
commit
79d56204ce
@ -2,18 +2,25 @@
|
|||||||
'use server';
|
'use server';
|
||||||
|
|
||||||
import prisma from '@/lib/prisma';
|
import prisma from '@/lib/prisma';
|
||||||
|
import { Locale } from '@/generated/client'; // ✅ 导入 enum Locale
|
||||||
import { serialize } from 'next-mdx-remote/serialize';
|
import { serialize } from 'next-mdx-remote/serialize';
|
||||||
|
|
||||||
export async function getProblemData(problemId: string) {
|
export async function getProblemData(problemId: string, locale: string) {
|
||||||
|
const selectedLocale = locale as Locale; // ✅ 强制转换 string 为 Prisma enum
|
||||||
|
|
||||||
const problem = await prisma.problem.findUnique({
|
const problem = await prisma.problem.findUnique({
|
||||||
where: { id: problemId },
|
where: { id: problemId },
|
||||||
include: {
|
include: {
|
||||||
localizations: true,
|
|
||||||
templates: true,
|
templates: true,
|
||||||
testcases: {
|
testcases: {
|
||||||
include: { inputs: true }
|
include: { inputs: true }
|
||||||
}
|
},
|
||||||
}
|
localizations: {
|
||||||
|
where: {
|
||||||
|
locale: selectedLocale, // ✅ 这里使用枚举
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!problem) {
|
if (!problem) {
|
||||||
@ -25,9 +32,7 @@ export async function getProblemData(problemId: string) {
|
|||||||
|
|
||||||
const rawDescription = getContent('DESCRIPTION');
|
const rawDescription = getContent('DESCRIPTION');
|
||||||
|
|
||||||
// MDX序列化,给客户端渲染用
|
|
||||||
const mdxDescription = await serialize(rawDescription, {
|
const mdxDescription = await serialize(rawDescription, {
|
||||||
// 可以根据需要添加MDX插件配置
|
|
||||||
parseFrontmatter: false,
|
parseFrontmatter: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -40,19 +45,19 @@ export async function getProblemData(problemId: string) {
|
|||||||
memoryLimit: problem.memoryLimit,
|
memoryLimit: problem.memoryLimit,
|
||||||
title: getContent('TITLE'),
|
title: getContent('TITLE'),
|
||||||
description: rawDescription,
|
description: rawDescription,
|
||||||
mdxDescription, // 新增序列化后的字段
|
mdxDescription,
|
||||||
solution: getContent('SOLUTION'),
|
solution: getContent('SOLUTION'),
|
||||||
templates: problem.templates.map(t => ({
|
templates: problem.templates.map(t => ({
|
||||||
language: t.language,
|
language: t.language,
|
||||||
content: t.content
|
content: t.content,
|
||||||
})),
|
})),
|
||||||
testcases: problem.testcases.map(tc => ({
|
testcases: problem.testcases.map(tc => ({
|
||||||
id: tc.id,
|
id: tc.id,
|
||||||
expectedOutput: tc.expectedOutput,
|
expectedOutput: tc.expectedOutput,
|
||||||
inputs: tc.inputs.map(input => ({
|
inputs: tc.inputs.map(input => ({
|
||||||
name: input.name,
|
name: input.name,
|
||||||
value: input.value
|
value: input.value,
|
||||||
}))
|
})),
|
||||||
}))
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
14
src/app/actions/getProblemLocales.ts
Normal file
14
src/app/actions/getProblemLocales.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
// src/app/actions/getProblemLocales.ts
|
||||||
|
'use server';
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
export async function getProblemLocales(problemId: string): Promise<string[]> {
|
||||||
|
const locales = await prisma.problemLocalization.findMany({
|
||||||
|
where: { problemId },
|
||||||
|
select: { locale: true },
|
||||||
|
distinct: ['locale'],
|
||||||
|
});
|
||||||
|
|
||||||
|
return locales.map(l => l.locale);
|
||||||
|
}
|
@ -1,49 +1,92 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import MdxPreview from "@/components/mdx-preview";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CoreEditor } from '@/components/core-editor';
|
import { CoreEditor } from "@/components/core-editor";
|
||||||
import { getProblemData } from '@/app/actions/getProblem';
|
import MdxPreview from "@/components/mdx-preview";
|
||||||
import { Accordion } from "@/components/ui/accordion"; // ← 这里导入 Accordion
|
import { getProblemData } from "@/app/actions/getProblem";
|
||||||
|
import { getProblemLocales } from "@/app/actions/getProblemLocales";
|
||||||
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
|
import { VideoEmbed } from "@/components/content/video-embed";
|
||||||
|
|
||||||
export default function EditDescriptionPanel({
|
export default function EditDescriptionPanel({ problemId }: { problemId: string }) {
|
||||||
problemId,
|
const [locales, setLocales] = useState<string[]>([]);
|
||||||
}: {
|
const [currentLocale, setCurrentLocale] = useState<string>("");
|
||||||
problemId: string;
|
const [customLocale, setCustomLocale] = useState("");
|
||||||
}) {
|
|
||||||
const [description, setDescription] = useState({
|
|
||||||
title: `Description for Problem ${problemId}`,
|
|
||||||
content: `Description content for Problem ${problemId}...`
|
|
||||||
});
|
|
||||||
const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'compare'>('edit');
|
|
||||||
|
|
||||||
|
const [description, setDescription] = useState({ title: "", content: "" });
|
||||||
|
const [viewMode, setViewMode] = useState<"edit" | "preview" | "compare">("edit");
|
||||||
|
|
||||||
|
// 获取语言列表
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchData() {
|
async function fetchLocales() {
|
||||||
try {
|
const langs = await getProblemLocales(problemId);
|
||||||
const problemData = await getProblemData(problemId);
|
setLocales(langs);
|
||||||
setDescription({
|
if (langs.length > 0) {
|
||||||
title: problemData.title,
|
setCurrentLocale(langs[0]);
|
||||||
content: problemData.description
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('获取题目信息失败:', error);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
fetchLocales();
|
||||||
fetchData();
|
|
||||||
}, [problemId]);
|
}, [problemId]);
|
||||||
|
|
||||||
|
// 获取对应语言的题目数据
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentLocale) return;
|
||||||
|
async function fetchProblem() {
|
||||||
|
const data = await getProblemData(problemId, currentLocale);
|
||||||
|
setDescription({
|
||||||
|
title: data?.title || "",
|
||||||
|
content: data?.description || "",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fetchProblem();
|
||||||
|
}, [problemId, currentLocale]);
|
||||||
|
|
||||||
|
// 添加新语言(仅前端)
|
||||||
|
function handleAddCustomLocale() {
|
||||||
|
if (customLocale && !locales.includes(customLocale)) {
|
||||||
|
const newLocales = [...locales, customLocale];
|
||||||
|
setLocales(newLocales);
|
||||||
|
setCurrentLocale(customLocale);
|
||||||
|
setCustomLocale("");
|
||||||
|
setDescription({ title: "", content: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>题目描述</CardTitle>
|
<CardTitle>题目描述</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-6">
|
{/* 语言切换 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>选择语言</Label>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<select
|
||||||
|
value={currentLocale}
|
||||||
|
onChange={(e) => setCurrentLocale(e.target.value)}
|
||||||
|
className="border rounded-md px-3 py-2"
|
||||||
|
>
|
||||||
|
{locales.map((locale) => (
|
||||||
|
<option key={locale} value={locale}>
|
||||||
|
{locale}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
placeholder="添加新语言"
|
||||||
|
value={customLocale}
|
||||||
|
onChange={(e) => setCustomLocale(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleAddCustomLocale}>添加</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标题输入 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="description-title">标题</Label>
|
<Label htmlFor="description-title">标题</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -54,55 +97,51 @@ export default function EditDescriptionPanel({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑/预览切换 */}
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={viewMode === 'edit' ? 'default' : 'outline'}
|
variant={viewMode === "edit" ? "default" : "outline"}
|
||||||
onClick={() => setViewMode('edit')}
|
onClick={() => setViewMode("edit")}
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={viewMode === 'preview' ? 'default' : 'outline'}
|
variant={viewMode === "preview" ? "default" : "outline"}
|
||||||
onClick={() => setViewMode(viewMode === 'preview' ? 'edit' : 'preview')}
|
onClick={() => setViewMode(viewMode === "preview" ? "edit" : "preview")}
|
||||||
>
|
>
|
||||||
{viewMode === 'preview' ? '取消' : '预览'}
|
{viewMode === "preview" ? "取消" : "预览"}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
variant={viewMode === 'compare' ? 'default' : 'outline'}
|
variant={viewMode === "compare" ? "default" : "outline"}
|
||||||
onClick={() => setViewMode('compare')}
|
onClick={() => setViewMode("compare")}
|
||||||
>
|
>
|
||||||
对比
|
对比
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={viewMode === 'compare' ? "grid grid-cols-2 gap-6" : "flex flex-col gap-6"}>
|
{/* 编辑/预览区域 */}
|
||||||
<div className={viewMode === 'edit' || viewMode === 'compare' ? "block" : "hidden"}>
|
<div className={viewMode === "compare" ? "grid grid-cols-2 gap-6" : "flex flex-col gap-6"}>
|
||||||
|
{(viewMode === "edit" || viewMode === "compare") && (
|
||||||
<div className="relative h-[600px]">
|
<div className="relative h-[600px]">
|
||||||
<CoreEditor
|
<CoreEditor
|
||||||
value={description.content}
|
value={description.content}
|
||||||
onChange={(newContent) =>
|
onChange={(newVal) => setDescription({ ...description, content: newVal || "" })}
|
||||||
setDescription({ ...description, content: newContent || '' })
|
|
||||||
}
|
|
||||||
language="markdown"
|
language="markdown"
|
||||||
className="absolute inset-0 rounded-md border border-input"
|
className="absolute inset-0 rounded-md border border-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
{viewMode !== 'edit' && (
|
{viewMode !== "edit" && (
|
||||||
<div className="prose dark:prose-invert">
|
<div className="prose dark:prose-invert">
|
||||||
<MdxPreview
|
<MdxPreview source={description.content} components={{ Accordion, VideoEmbed }} />
|
||||||
source={description.content}
|
|
||||||
components={{ Accordion }} // ← 这里传入 Accordion
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button>保存更改</Button>
|
<Button>保存更改</Button>
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
@ -1,47 +1,87 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useEffect } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import MdxPreview from "@/components/mdx-preview";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { CoreEditor } from "@/components/core-editor";
|
import { CoreEditor } from "@/components/core-editor";
|
||||||
import { getProblemData } from "@/app/actions/getProblem";// 修改为你实际路径
|
import MdxPreview from "@/components/mdx-preview";
|
||||||
|
import { getProblemData } from "@/app/actions/getProblem";
|
||||||
|
import { getProblemLocales } from "@/app/actions/getProblemLocales";
|
||||||
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
|
import { VideoEmbed } from "@/components/content/video-embed";
|
||||||
|
|
||||||
export default function EditSolutionPanel({
|
export default function EditSolutionPanel({ problemId }: { problemId: string }) {
|
||||||
problemId,
|
const [locales, setLocales] = useState<string[]>([]);
|
||||||
}: {
|
const [currentLocale, setCurrentLocale] = useState<string>("");
|
||||||
problemId: string;
|
const [customLocale, setCustomLocale] = useState("");
|
||||||
}) {
|
|
||||||
const [solution, setSolution] = useState({
|
const [solution, setSolution] = useState({ title: "", content: "" });
|
||||||
title: `Solution for Problem ${problemId}`,
|
|
||||||
content: `Solution content for Problem ${problemId}...`,
|
|
||||||
});
|
|
||||||
const [viewMode, setViewMode] = useState<"edit" | "preview" | "compare">("edit");
|
const [viewMode, setViewMode] = useState<"edit" | "preview" | "compare">("edit");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchSolution() {
|
async function fetchLocales() {
|
||||||
try {
|
const langs = await getProblemLocales(problemId);
|
||||||
const data = await getProblemData(problemId);
|
setLocales(langs);
|
||||||
setSolution({
|
if (langs.length > 0) setCurrentLocale(langs[0]);
|
||||||
title: data.title ? data.title + " 解析" : `Solution for Problem ${problemId}`,
|
|
||||||
content: data.solution || "",
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载题解失败", error);
|
|
||||||
}
|
}
|
||||||
|
fetchLocales();
|
||||||
|
}, [problemId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentLocale) return;
|
||||||
|
async function fetchSolution() {
|
||||||
|
const data = await getProblemData(problemId, currentLocale);
|
||||||
|
setSolution({
|
||||||
|
title: (data?.title || "") + " 解析",
|
||||||
|
content: data?.solution || "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
fetchSolution();
|
fetchSolution();
|
||||||
}, [problemId]);
|
}, [problemId, currentLocale]);
|
||||||
|
|
||||||
|
function handleAddCustomLocale() {
|
||||||
|
if (customLocale && !locales.includes(customLocale)) {
|
||||||
|
const newLocales = [...locales, customLocale];
|
||||||
|
setLocales(newLocales);
|
||||||
|
setCurrentLocale(customLocale);
|
||||||
|
setCustomLocale("");
|
||||||
|
setSolution({ title: "", content: "" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="w-full">
|
<Card className="w-full">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle>题目解析</CardTitle>
|
<CardTitle>题目解析</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent className="space-y-6">
|
||||||
<div className="space-y-6">
|
{/* 语言切换 */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>选择语言</Label>
|
||||||
|
<div className="flex space-x-2">
|
||||||
|
<select
|
||||||
|
value={currentLocale}
|
||||||
|
onChange={(e) => setCurrentLocale(e.target.value)}
|
||||||
|
className="border rounded-md px-3 py-2"
|
||||||
|
>
|
||||||
|
{locales.map((locale) => (
|
||||||
|
<option key={locale} value={locale}>
|
||||||
|
{locale}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Input
|
||||||
|
placeholder="添加新语言"
|
||||||
|
value={customLocale}
|
||||||
|
onChange={(e) => setCustomLocale(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleAddCustomLocale}>添加</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 标题输入 */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="solution-title">题解标题</Label>
|
<Label htmlFor="solution-title">题解标题</Label>
|
||||||
<Input
|
<Input
|
||||||
@ -51,6 +91,8 @@ export default function EditSolutionPanel({
|
|||||||
placeholder="输入题解标题"
|
placeholder="输入题解标题"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑/预览切换 */}
|
||||||
<div className="flex space-x-2">
|
<div className="flex space-x-2">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
@ -75,25 +117,26 @@ export default function EditSolutionPanel({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑/预览区域 */}
|
||||||
<div className={viewMode === "compare" ? "grid grid-cols-2 gap-6" : "flex flex-col gap-6"}>
|
<div className={viewMode === "compare" ? "grid grid-cols-2 gap-6" : "flex flex-col gap-6"}>
|
||||||
<div className={viewMode === "edit" || viewMode === "compare" ? "block" : "hidden"}>
|
{(viewMode === "edit" || viewMode === "compare") && (
|
||||||
<div className="relative h-[600px]">
|
<div className="relative h-[600px]">
|
||||||
<CoreEditor
|
<CoreEditor
|
||||||
value={solution.content}
|
value={solution.content}
|
||||||
onChange={(newContent) => setSolution({ ...solution, content: newContent })}
|
onChange={(val) => setSolution({ ...solution, content: val || "" })}
|
||||||
language="markdown"
|
language="markdown"
|
||||||
className="absolute inset-0 rounded-md border border-input"
|
className="absolute inset-0 rounded-md border border-input"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
|
|
||||||
{viewMode !== "edit" && (
|
{viewMode !== "edit" && (
|
||||||
<div className="prose dark:prose-invert">
|
<div className="prose dark:prose-invert">
|
||||||
<MdxPreview source={solution.content} />
|
<MdxPreview source={solution.content} components={{ Accordion, VideoEmbed }} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<Button>保存更改</Button>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
Loading…
Reference in New Issue
Block a user