feat(creater): add feature to display multi-language description and solution in problem-creater

- 在编辑题目描述和解析面板中添加语言切换功能
- 实现获取题目支持的语言列表和对应语言的题目数据
- 增加添加新语言的功能(仅前端)
-优化题目描述和解析的编辑、预览和对比功能
- 在预览中添加 Accordion 和 VideoEmbed 组件支持
This commit is contained in:
fly6516 2025-06-17 16:18:59 +08:00 committed by cfngc4594
parent 95a1817419
commit 79d56204ce
4 changed files with 251 additions and 150 deletions

View File

@ -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,
})) })),
})) })),
}; };
} }

View 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);
}

View File

@ -1,108 +1,147 @@
"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"> <div className="space-y-2">
<Label htmlFor="description-title"></Label> <Label></Label>
<Input
id="description-title"
value={description.title}
onChange={(e) => setDescription({...description, title: e.target.value})}
placeholder="输入题目标题"
/>
</div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <select
type="button" value={currentLocale}
variant={viewMode === 'edit' ? 'default' : 'outline'} onChange={(e) => setCurrentLocale(e.target.value)}
onClick={() => setViewMode('edit')} className="border rounded-md px-3 py-2"
> >
{locales.map((locale) => (
</Button> <option key={locale} value={locale}>
<Button {locale}
type="button" </option>
variant={viewMode === 'preview' ? 'default' : 'outline'} ))}
onClick={() => setViewMode(viewMode === 'preview' ? 'edit' : 'preview')} </select>
> <Input
{viewMode === 'preview' ? '取消' : '预览'} placeholder="添加新语言"
</Button> value={customLocale}
<Button onChange={(e) => setCustomLocale(e.target.value)}
type="button" />
variant={viewMode === 'compare' ? 'default' : 'outline'} <Button onClick={handleAddCustomLocale}></Button>
onClick={() => setViewMode('compare')}
>
</Button>
</div> </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="space-y-2">
<Label htmlFor="description-title"></Label>
<Input
id="description-title"
value={description.title}
onChange={(e) => setDescription({ ...description, title: e.target.value })}
placeholder="输入题目标题"
/>
</div>
{/* 编辑/预览切换 */}
<div className="flex space-x-2">
<Button
type="button"
variant={viewMode === "edit" ? "default" : "outline"}
onClick={() => setViewMode("edit")}
>
</Button>
<Button
type="button"
variant={viewMode === "preview" ? "default" : "outline"}
onClick={() => setViewMode(viewMode === "preview" ? "edit" : "preview")}
>
{viewMode === "preview" ? "取消" : "预览"}
</Button>
<Button
type="button"
variant={viewMode === "compare" ? "default" : "outline"}
onClick={() => setViewMode("compare")}
>
</Button>
</div>
{/* 编辑/预览区域 */}
<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} </div>
components={{ Accordion }} // ← 这里传入 Accordion )}
/>
</div>
)}
</div>
<Button></Button>
</div> </div>
<Button></Button>
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@ -1,99 +1,142 @@
"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 fetchLocales() {
const langs = await getProblemLocales(problemId);
setLocales(langs);
if (langs.length > 0) setCurrentLocale(langs[0]);
}
fetchLocales();
}, [problemId]);
useEffect(() => {
if (!currentLocale) return;
async function fetchSolution() { async function fetchSolution() {
try { const data = await getProblemData(problemId, currentLocale);
const data = await getProblemData(problemId); setSolution({
setSolution({ title: (data?.title || "") + " 解析",
title: data.title ? data.title + " 解析" : `Solution for Problem ${problemId}`, content: data?.solution || "",
content: data.solution || "", });
});
} catch (error) {
console.error("加载题解失败", error);
}
} }
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"> <div className="space-y-2">
<Label htmlFor="solution-title"></Label> <Label></Label>
<Input
id="solution-title"
value={solution.title}
onChange={(e) => setSolution({ ...solution, title: e.target.value })}
placeholder="输入题解标题"
/>
</div>
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button <select
type="button" value={currentLocale}
variant={viewMode === "edit" ? "default" : "outline"} onChange={(e) => setCurrentLocale(e.target.value)}
onClick={() => setViewMode("edit")} className="border rounded-md px-3 py-2"
> >
{locales.map((locale) => (
</Button> <option key={locale} value={locale}>
<Button {locale}
type="button" </option>
variant={viewMode === "preview" ? "default" : "outline"} ))}
onClick={() => setViewMode(viewMode === "preview" ? "edit" : "preview")} </select>
> <Input
{viewMode === "preview" ? "取消" : "预览"} placeholder="添加新语言"
</Button> value={customLocale}
<Button onChange={(e) => setCustomLocale(e.target.value)}
type="button" />
variant={viewMode === "compare" ? "default" : "outline"} <Button onClick={handleAddCustomLocale}></Button>
onClick={() => setViewMode("compare")}
>
</Button>
</div> </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="space-y-2">
<Label htmlFor="solution-title"></Label>
<Input
id="solution-title"
value={solution.title}
onChange={(e) => setSolution({ ...solution, title: e.target.value })}
placeholder="输入题解标题"
/>
</div>
{/* 编辑/预览切换 */}
<div className="flex space-x-2">
<Button
type="button"
variant={viewMode === "edit" ? "default" : "outline"}
onClick={() => setViewMode("edit")}
>
</Button>
<Button
type="button"
variant={viewMode === "preview" ? "default" : "outline"}
onClick={() => setViewMode(viewMode === "preview" ? "edit" : "preview")}
>
{viewMode === "preview" ? "取消" : "预览"}
</Button>
<Button
type="button"
variant={viewMode === "compare" ? "default" : "outline"}
onClick={() => setViewMode("compare")}
>
</Button>
</div>
{/* 编辑/预览区域 */}
<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={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} components={{ Accordion, VideoEmbed }} />
<MdxPreview source={solution.content} /> </div>
</div> )}
)}
</div>
</div> </div>
<Button></Button>
</CardContent> </CardContent>
</Card> </Card>
); );