feat(问题编辑): add problem-editor page

- 添加了编辑问题描述、解决方案、详细信息、代码模板和测试用例的组件
- 实现了问题编辑页面的基本布局和功能
- 增加了富文本预览和对比功能
- 支持多种编程语言的代码编辑- 提供了测试用例的添加和删除功能
This commit is contained in:
fly6516 2025-06-16 18:37:25 +08:00 committed by cfngc4594
parent 4e6ab68566
commit 4141f0c017
6 changed files with 387 additions and 762 deletions

View File

@ -0,0 +1,32 @@
"use client";
import { ProblemFlexLayout } from "@/features/problems/components/problem-flexlayout";
import { EditDescriptionPanel } from "@/components/creater/edit-description-panel";
import { EditSolutionPanel } from "@/components/creater/edit-solution-panel";
import { EditTestcasePanel } from "@/components/creater/edit-testcase-panel";
import { EditDetailPanel } from "@/components/creater/edit-detail-panel";
import { EditCodePanel } from "@/components/creater/edit-code-panel";
interface ProblemEditorPageProps {
params: Promise<{ problemId: string }>;
}
export default async function ProblemEditorPage({
params,
}: ProblemEditorPageProps) {
const { problemId } = await params;
const components: Record<string, React.ReactNode> = {
description: <EditDescriptionPanel problemId={problemId} />,
solution: <EditSolutionPanel problemId={problemId} />,
detail: <EditDetailPanel problemId={problemId} />,
code: <EditCodePanel problemId={problemId} />,
testcase: <EditTestcasePanel problemId={problemId} />,
};
return (
<div className="relative flex h-full w-full">
<ProblemFlexLayout components={components} />
</div>
);
}

View File

@ -1,109 +1,80 @@
"use client" "use client";
import React, { useEffect, useState } from 'react'; import { useState } from "react";
import { getProblemData } from '@/app/actions/getProblem'; import { Label } from "@/components/ui/label";
import { updateProblemTemplate } from '@/components/creater/problem-maintain'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from '@/components/ui/label'; import { Button } from "@/components/ui/button";
import { Button } from '@/components/ui/button'; import { CoreEditor } from "@/components/core-editor";
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { CoreEditor } from '@/components/core-editor';
import { Language } from '@/generated/client';
import { toast } from 'sonner';
interface Template { interface Template {
id: string;
language: string; language: string;
content: string; code: string;
} }
interface EditCodePanelProps { interface EditCodePanelProps {
problemId: string; problemId: string;
} }
export default function EditCodePanel({ problemId }: EditCodePanelProps) { export const EditCodePanel = ({
const [codeTemplate, setCodeTemplate] = useState<Template>({
language: 'cpp',
content: `// 默认代码模板 for Problem ${problemId}`,
});
const [templates, setTemplates] = useState<Template[]>([]);
useEffect(() => {
async function fetchTemplates() {
try {
const problem = await getProblemData(problemId);
setTemplates(problem.templates);
const sel = problem.templates.find(t => t.language === 'cpp') || problem.templates[0];
if (sel) setCodeTemplate(sel);
} catch (err) {
console.error('加载问题数据失败:', err);
toast.error('加载问题数据失败');
}
}
fetchTemplates();
}, [problemId]);
const handleLanguageChange = (language: string) => {
const sel = templates.find(t => t.language === language);
if (sel) setCodeTemplate(sel);
};
const handleSave = async (): Promise<void> => {
try {
const res = await updateProblemTemplate(
problemId, problemId,
codeTemplate.language as Language, }: EditCodePanelProps) => {
codeTemplate.content const [language, setLanguage] = useState("typescript");
); const [templates, setTemplates] = useState<Template[]>([
if (res.success) { {
toast.success('保存成功'); id: "1",
} else { language: "typescript",
toast.error('保存失败'); code: `// TypeScript模板示例\nfunction twoSum(nums: number[], target: number): number[] {\n const map = new Map();\n for (let i = 0; i < nums.length; i++) {\n const complement = target - nums[i];\n if (map.has(complement)) {\n return [map.get(complement), i];\n }\n map.set(nums[i], i);\n }\n return [];\n}`
} },
} catch (error) { {
console.error('保存异常:', error); id: "2",
toast.error('保存异常'); language: "python",
code: "# Python模板示例\ndef two_sum(nums, target):\n num_dict = {}\n for i, num in enumerate(nums):\n complement = target - num\n if complement in num_dict:\n return [num_dict[complement], i]\n num_dict[num] = i\n return []"
} }
]);
const currentTemplate = templates.find(t => t.language === language) || templates[0];
const handleCodeChange = (value: string | undefined) => {
if (!value) return;
setTemplates(templates.map(t =>
t.language === language
? { ...t, code: value }
: t
));
}; };
return ( return (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="language-select"></Label> <Label htmlFor="language"></Label>
<select <Select value={language} onValueChange={setLanguage}>
id="language-select" <SelectTrigger id="language">
className="block w-full p-2 border border-gray-300 rounded-md dark:bg-gray-800 dark:border-gray-700" <SelectValue placeholder="选择编程语言" />
value={codeTemplate.language} </SelectTrigger>
onChange={e => handleLanguageChange(e.target.value)} <SelectContent>
> <SelectItem value="typescript">TypeScript</SelectItem>
{templates.map(t => ( <SelectItem value="javascript">JavaScript</SelectItem>
<option key={t.language} value={t.language}> <SelectItem value="python">Python</SelectItem>
{t.language.toUpperCase()} <SelectItem value="java">Java</SelectItem>
</option> <SelectItem value="cpp">C++</SelectItem>
))} </SelectContent>
</select> </Select>
</div> </div>
<div className="space-y-2">
<Label htmlFor="code-editor"></Label>
<div className="border rounded-md h-[500px]"> <div className="border rounded-md h-[500px]">
{currentTemplate && (
<CoreEditor <CoreEditor
language={codeTemplate.language} language={language}
value={codeTemplate.content} value={currentTemplate.code}
path={`/${problemId}.${codeTemplate.language}`} path={`/${problemId}.${language}`}
onChange={value => onChange={handleCodeChange}
setCodeTemplate({ ...codeTemplate, content: value || '' })
}
/> />
</div> )}
</div> </div>
<Button onClick={handleSave}></Button> <Button></Button>
</div> </div>
</CardContent>
</Card>
); );
} };

View File

@ -1,173 +1,75 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import { 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 { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CoreEditor } from "@/components/core-editor";
import MdxPreview from "@/components/mdx-preview"; 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";
import { toast } from "sonner";
import { updateProblemDescription, updateProblemTitle } from '@/components/creater/problem-maintain';
import { Locale } from "@/generated/client";
export default function EditDescriptionPanel({ problemId }: { problemId: string }) { interface EditDescriptionPanelProps {
const [locales, setLocales] = useState<string[]>([]); problemId: string;
const [currentLocale, setCurrentLocale] = useState<string>(""); }
const [customLocale, setCustomLocale] = useState("");
const [description, setDescription] = useState({ title: "", content: "" }); export const EditDescriptionPanel = ({
const [viewMode, setViewMode] = useState<"edit" | "preview" | "compare">("edit"); problemId,
}: EditDescriptionPanelProps) => {
useEffect(() => { const [title, setTitle] = useState(`Problem ${problemId} Title`);
async function fetchLocales() { const [content, setContent] = useState(`Problem ${problemId} Description Content...`);
try { const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'compare'>('edit');
const langs = await getProblemLocales(problemId);
setLocales(langs);
if (langs.length > 0) setCurrentLocale(langs[0]);
} catch (err) {
console.error(err);
toast.error('获取语言列表失败');
}
}
fetchLocales();
}, [problemId]);
useEffect(() => {
if (!currentLocale) return;
async function fetchProblem() {
try {
const data = await getProblemData(problemId, currentLocale);
setDescription({ title: data?.title || "", content: data?.description || "" });
} catch (err) {
console.error(err);
toast.error('加载题目描述失败');
}
}
fetchProblem();
}, [problemId, currentLocale]);
const handleAddCustomLocale = () => {
if (customLocale && !locales.includes(customLocale)) {
const newLocales = [...locales, customLocale];
setLocales(newLocales);
setCurrentLocale(customLocale);
setCustomLocale("");
setDescription({ title: "", content: "" });
}
};
const handleSave = async (): Promise<void> => {
if (!currentLocale) {
toast.error('请选择语言');
return;
}
try {
const locale = currentLocale as Locale;
const resTitle = await updateProblemTitle(problemId, locale, description.title);
const resDesc = await updateProblemDescription(problemId, locale, description.content);
if (resTitle.success && resDesc.success) {
toast.success('保存成功');
} else {
toast.error('保存失败');
}
} catch (err) {
console.error(err);
toast.error('保存异常');
}
};
return ( return (
<Card className="w-full"> <div className="space-y-6">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* 语言切换 */}
<div className="space-y-2"> <div className="space-y-2">
<Label></Label> <Label htmlFor="title"></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 <Input
placeholder="添加新语言" id="title"
value={customLocale} value={title}
onChange={(e) => setCustomLocale(e.target.value)} onChange={(e) => setTitle(e.target.value)}
/>
<Button onClick={handleAddCustomLocale}></Button>
</div>
</div>
{/* 标题输入 */}
<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="输入题目标题" placeholder="输入题目标题"
/> />
</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 === "compare" ? "grid grid-cols-2 gap-6" : "flex flex-col gap-6"}> <div className={viewMode === 'edit' || viewMode === 'compare' ? "block" : "hidden"}>
{(viewMode === "edit" || viewMode === "compare") && ( <Textarea
<div className="relative h-[600px]"> id="description"
<CoreEditor value={content}
value={description.content} onChange={(e) => setContent(e.target.value)}
onChange={(newVal) => setDescription({ ...description, content: newVal || "" })} placeholder="输入题目详细描述..."
language="markdown" className="min-h-[300px]"
className="absolute inset-0 rounded-md border border-input"
/> />
</div> </div>
)}
{viewMode !== "edit" && ( <div className={viewMode === 'preview' || viewMode === 'compare' ? "block" : "hidden"}>
<div className="prose dark:prose-invert"> <MdxPreview source={content} />
<MdxPreview source={description.content} components={{ Accordion, VideoEmbed }} />
</div> </div>
)}
</div> </div>
<Button onClick={handleSave}></Button> <Button></Button>
</CardContent> </div>
</Card>
); );
} };

View File

@ -1,157 +1,67 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import { 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getProblemData } from "@/app/actions/getProblem";
import { toast } from "sonner";
import { updateProblemDetail } from '@/components/creater/problem-maintain';
import { Difficulty } from "@/generated/client";
export default function EditDetailPanel({ problemId }: { problemId: string }) { interface EditDetailPanelProps {
const [problemDetails, setProblemDetails] = useState({ problemId: string;
displayId: 1000, }
difficulty: "EASY" as Difficulty,
timeLimit: 1000,
memoryLimit: 134217728,
isPublished: false,
});
useEffect(() => { export const EditDetailPanel = ({
async function fetchData() { problemId,
try { }: EditDetailPanelProps) => {
const problemData = await getProblemData(problemId); const [displayId, setDisplayId] = useState(problemId);
setProblemDetails({ const [difficulty, setDifficulty] = useState("medium");
displayId: problemData.displayId, const [isPublished, setIsPublished] = useState(true);
difficulty: problemData.difficulty,
timeLimit: problemData.timeLimit,
memoryLimit: problemData.memoryLimit,
isPublished: problemData.isPublished,
});
} catch (error) {
console.error("获取题目信息失败:", error);
toast.error('加载详情失败');
}
}
fetchData();
}, [problemId]);
const handleNumberInputChange = (
e: React.ChangeEvent<HTMLInputElement>,
field: keyof typeof problemDetails
) => {
const value = parseInt(e.target.value, 10);
if (!isNaN(value)) {
setProblemDetails({
...problemDetails,
[field]: value,
});
}
};
const handleDifficultyChange = (e: React.ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value as Difficulty;
setProblemDetails({ ...problemDetails, difficulty: value });
};
const handleSave = async () => {
try {
const res = await updateProblemDetail(problemId, {
displayId: problemDetails.displayId,
difficulty: problemDetails.difficulty,
timeLimit: problemDetails.timeLimit,
memoryLimit: problemDetails.memoryLimit,
isPublished: problemDetails.isPublished,
});
if (res.success) {
toast.success('保存成功');
} else {
toast.error('保存失败');
}
} catch (err) {
console.error('保存异常:', err);
toast.error('保存异常');
}
};
return ( return (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="display-id">ID</Label> <Label htmlFor="display-id"></Label>
<Input <Input
id="display-id" id="display-id"
type="number" value={displayId}
value={problemDetails.displayId} onChange={(e) => setDisplayId(e.target.value)}
onChange={(e) => handleNumberInputChange(e, "displayId")} placeholder="输入题号"
placeholder="输入显示ID"
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="difficulty-select"></Label>
<select
id="difficulty-select"
className="block w-full p-2 border border-gray-300 rounded-md dark:bg-gray-800 dark:border-gray-700"
value={problemDetails.difficulty}
onChange={handleDifficultyChange}
>
<option value="EASY"></option>
<option value="MEDIUM"></option>
<option value="HARD"></option>
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="time-limit"> (ms)</Label> <Label htmlFor="difficulty"></Label>
<Input <Select value={difficulty} onValueChange={setDifficulty}>
id="time-limit" <SelectTrigger id="difficulty">
type="number" <SelectValue placeholder="选择难度" />
value={problemDetails.timeLimit} </SelectTrigger>
onChange={(e) => handleNumberInputChange(e, "timeLimit")} <SelectContent>
placeholder="输入时间限制" <SelectItem value="easy"></SelectItem>
/> <SelectItem value="medium"></SelectItem>
</div> <SelectItem value="hard"></SelectItem>
<div className="space-y-2"> </SelectContent>
<Label htmlFor="memory-limit"> ()</Label> </Select>
<Input
id="memory-limit"
type="number"
value={problemDetails.memoryLimit}
onChange={(e) => handleNumberInputChange(e, "memoryLimit")}
placeholder="输入内存限制"
/>
</div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<input <Label htmlFor="is-published" className="text-sm font-normal">
id="is-published"
type="checkbox"
checked={problemDetails.isPublished}
onChange={(e) =>
setProblemDetails({ ...problemDetails, isPublished: e.target.checked })
}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500 dark:focus:ring-blue-600 focus:ring-2"
/>
<Label htmlFor="is-published" className="text-sm font-medium">
</Label> </Label>
<Input
id="is-published"
type="checkbox"
checked={isPublished}
onChange={(e) => setIsPublished(e.target.checked)}
className="h-4 w-4"
/>
</div> </div>
<Button type="button" onClick={handleSave}> <div className="flex space-x-2">
<Button></Button>
<Button variant="outline" type="button">
</Button> </Button>
</div> </div>
</CardContent> </div>
</Card>
); );
} };

View File

@ -1,156 +1,75 @@
"use client"; "use client";
import React, { useEffect, useState } from "react"; import { 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 { Textarea } from "@/components/ui/textarea";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { CoreEditor } from "@/components/core-editor";
import MdxPreview from "@/components/mdx-preview"; 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";
import { toast } from "sonner";
import { updateProblemSolution } from '@/components/creater/problem-maintain';
import { Locale } from "@/generated/client";
export default function EditSolutionPanel({ problemId }: { problemId: string }) { interface EditSolutionPanelProps {
const [locales, setLocales] = useState<string[]>([]); problemId: string;
const [currentLocale, setCurrentLocale] = useState<string>(""); }
const [customLocale, setCustomLocale] = useState("");
const [solution, setSolution] = useState({ title: "", content: "" }); export const EditSolutionPanel = ({
const [viewMode, setViewMode] = useState<"edit" | "preview" | "compare">("edit"); problemId,
}: EditSolutionPanelProps) => {
useEffect(() => { const [title, setTitle] = useState(`Solution for Problem ${problemId}`);
async function fetchLocales() { const [content, setContent] = useState(`Solution content for Problem ${problemId}...`);
try { const [viewMode, setViewMode] = useState<'edit' | 'preview' | 'compare'>('edit');
const langs = await getProblemLocales(problemId);
setLocales(langs);
if (langs.length > 0) setCurrentLocale(langs[0]);
} catch (err) {
console.error(err);
toast.error('获取语言列表失败');
}
}
fetchLocales();
}, [problemId]);
useEffect(() => {
if (!currentLocale) return;
async function fetchSolution() {
try {
const data = await getProblemData(problemId, currentLocale);
setSolution({ title: (data?.title || "") + " 解析", content: data?.solution || "" });
} catch (err) {
console.error(err);
toast.error('加载题目解析失败');
}
}
fetchSolution();
}, [problemId, currentLocale]);
const handleAddCustomLocale = () => {
if (customLocale && !locales.includes(customLocale)) {
setLocales(prev => [...prev, customLocale]);
setCurrentLocale(customLocale);
setCustomLocale("");
setSolution({ title: "", content: "" });
}
};
const handleSave = async (): Promise<void> => {
if (!currentLocale) {
toast.error('请选择语言');
return;
}
try {
const locale = currentLocale as Locale;
const res = await updateProblemSolution(problemId, locale, solution.content);
if (res.success) {
toast.success('保存成功');
} else {
toast.error('保存失败');
}
} catch (err) {
console.error(err);
toast.error('保存异常');
}
};
return ( return (
<Card className="w-full"> <div className="space-y-6">
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent 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 type="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
id="solution-title" id="solution-title"
value={solution.title} value={title}
onChange={(e) => setSolution({ ...solution, title: e.target.value })} onChange={(e) => setTitle(e.target.value)}
placeholder="输入题解标题" placeholder="输入题解标题"
disabled
/> />
</div> </div>
{/* 编辑/预览切换 */}
<div className="flex space-x-2"> <div className="flex space-x-2">
<Button type="button" variant={viewMode === "edit" ? "default" : "outline"} onClick={() => setViewMode("edit")}></Button> <Button
<Button type="button" variant={viewMode === "preview" ? "default" : "outline"} onClick={() => setViewMode(viewMode === "preview" ? "edit" : "preview")}> type="button"
{viewMode === "preview" ? "取消" : "预览"} 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> </Button>
<Button type="button" variant={viewMode === "compare" ? "default" : "outline"} onClick={() => setViewMode("compare")}></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") && ( <Textarea
<div className="relative h-[600px]"> id="solution-content"
<CoreEditor value={content}
value={solution.content} onChange={(e) => setContent(e.target.value)}
onChange={(val) => setSolution({ ...solution, content: val || "" })} placeholder="输入详细题解内容..."
language="markdown" className="min-h-[300px]"
className="absolute inset-0 rounded-md border border-input"
/> />
</div> </div>
)}
{viewMode !== "edit" && ( <div className={viewMode === 'preview' || viewMode === 'compare' ? "block" : "hidden"}>
<div className="prose dark:prose-invert"> <MdxPreview source={content} />
<MdxPreview source={solution.content} components={{ Accordion, VideoEmbed }} />
</div> </div>
)}
</div> </div>
<Button type="button" onClick={handleSave}></Button> <Button></Button>
</CardContent> </div>
</Card>
); );
} };

View File

@ -1,224 +1,115 @@
"use client"; "use client";
import React, { useState, useEffect } from "react"; import { useState } from "react";
import { generateAITestcase } from "@/app/actions/ai-testcase";
import { getProblemData } from "@/app/actions/getProblem";
import {
addProblemTestcase,
updateProblemTestcase,
deleteProblemTestcase,
} from "@/components/creater/problem-maintain";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import {
import { toast } from "sonner"; Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
interface Testcase { interface TestCase {
id: string; id: string;
input: string;
expectedOutput: string; expectedOutput: string;
inputs: { name: string; value: string }[];
} }
export default function EditTestcasePanel({ problemId }: { problemId: string }) { interface EditTestcasePanelProps {
const [testcases, setTestcases] = useState<Testcase[]>([]); problemId: string;
const [isGenerating, setIsGenerating] = useState(false);
// 加载测试用例
useEffect(() => {
async function fetch() {
try {
const data = await getProblemData(problemId);
setTestcases(data.testcases || []);
} catch (err) {
console.error("加载测试用例失败:", err);
toast.error("加载测试用例失败");
} }
}
fetch();
}, [problemId]);
// 本地添加测试用例 export const EditTestcasePanel = ({
const handleAddTestcase = () => problemId,
setTestcases((prev) => [ }: EditTestcasePanelProps) => {
...prev, const [testcases, setTestcases] = useState<TestCase[]>([
{ id: `new-${Date.now()}-${Math.random()}`, expectedOutput: "", inputs: [{ name: "input1", value: "" }] }, { id: "1", input: "input1", expectedOutput: "output1" },
{ id: "2", input: "input2", expectedOutput: "output2" }
]); ]);
// AI 生成测试用例 const [newInput, setNewInput] = useState("");
const handleAITestcase = async () => { const [newOutput, setNewOutput] = useState("");
setIsGenerating(true);
try { const addTestCase = () => {
const ai = await generateAITestcase({ problemId }); if (!newInput || !newOutput) return;
setTestcases((prev) => [
...prev, const newTestCase = {
{ id: `new-${Date.now()}-${Math.random()}`, expectedOutput: ai.expectedOutput, inputs: ai.inputs }, id: (testcases.length + 1).toString(),
]); input: newInput,
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" }); expectedOutput: newOutput
} catch (err) {
console.error(err);
toast.error("AI 生成测试用例失败");
} finally {
setIsGenerating(false);
}
}; };
// 删除测试用例(本地 + 服务器) setTestcases([...testcases, newTestCase]);
const handleRemoveTestcase = async (idx: number) => { setNewInput("");
const tc = testcases[idx]; setNewOutput("");
if (!tc.id.startsWith("new-")) {
try {
const res = await deleteProblemTestcase(problemId, tc.id);
if (res.success) toast.success("删除测试用例成功");
else toast.error("删除测试用例失败");
} catch (err) {
console.error(err);
toast.error("删除测试用例异常");
}
}
setTestcases((prev) => prev.filter((_, i) => i !== idx));
}; };
// 修改预期输出 const deleteTestCase = (id: string) => {
const handleExpectedOutputChange = (idx: number, val: string) => setTestcases(testcases.filter(tc => tc.id !== id));
setTestcases((prev) => {
const c = [...prev];
c[idx] = { ...c[idx], expectedOutput: val };
return c;
});
// 修改输入参数
const handleInputChange = (
tIdx: number,
iIdx: number,
field: "name" | "value",
val: string
) =>
setTestcases((prev) => {
const c = [...prev];
const newInputs = [...c[tIdx].inputs];
newInputs[iIdx] = { ...newInputs[iIdx], [field]: val };
c[tIdx] = { ...c[tIdx], inputs: newInputs };
return c;
});
// 添加输入参数
const handleAddInput = (tIdx: number) =>
setTestcases((prev) => {
const c = [...prev];
const inputs = [...c[tIdx].inputs, { name: `input${c[tIdx].inputs.length + 1}`, value: "" }];
c[tIdx] = { ...c[tIdx], inputs };
return c;
});
// 删除输入参数
const handleRemoveInput = (tIdx: number, iIdx: number) =>
setTestcases((prev) => {
const c = [...prev];
const inputs = c[tIdx].inputs.filter((_, i) => i !== iIdx);
c[tIdx] = { ...c[tIdx], inputs };
return c;
});
// 保存所有测试用例,并刷新最新数据
const handleSaveAll = async () => {
try {
for (let i = 0; i < testcases.length; i++) {
const tc = testcases[i];
if (tc.expectedOutput.trim() === "" || tc.inputs.some(inp => !inp.name.trim() || !inp.value.trim())) {
toast.error(`${i + 1} 个测试用例存在空的输入或输出,保存失败`);
return;
}
if (tc.id.startsWith("new-")) {
const res = await addProblemTestcase(problemId, tc.expectedOutput, tc.inputs);
if (res.success) {
toast.success(`新增测试用例 ${i + 1} 成功`);
} else {
toast.error(`新增测试用例 ${i + 1} 失败`);
}
} else {
const res = await updateProblemTestcase(problemId, tc.id, tc.expectedOutput, tc.inputs);
if (res.success) toast.success(`更新测试用例 ${i + 1} 成功`);
else toast.error(`更新测试用例 ${i + 1} 失败`);
}
}
// 保存完成后刷新最新测试用例
const data = await getProblemData(problemId);
setTestcases(data.testcases || []);
toast.success("测试用例保存并刷新成功");
} catch (err) {
console.error(err);
toast.error("保存测试用例异常");
}
}; };
return ( return (
<Card className="w-full"> <div className="space-y-6">
<CardHeader> <div className="flex space-x-2">
<CardTitle></CardTitle> <div className="flex-1 space-y-2">
<div className="flex items-center space-x-2"> <Label htmlFor="testcase-input"></Label>
<Button onClick={handleAddTestcase}></Button> <Input
<Button onClick={handleAITestcase} disabled={isGenerating}> id="testcase-input"
{isGenerating ? "生成中..." : "使用AI生成测试用例"} value={newInput}
</Button> onChange={(e) => setNewInput(e.target.value)}
<Button variant="secondary" onClick={handleSaveAll}> placeholder="输入测试用例"
/>
</div>
<div className="flex-1 space-y-2">
<Label htmlFor="testcase-output"></Label>
<Input
id="testcase-output"
value={newOutput}
onChange={(e) => setNewOutput(e.target.value)}
placeholder="预期输出"
/>
</div>
<Button onClick={addTestCase} className="self-end">
</Button> </Button>
</div> </div>
</CardHeader>
<CardContent className="space-y-6"> <div className="border rounded-md">
{testcases.map((tc, idx) => ( <Table>
<div key={tc.id} className="border p-4 rounded space-y-4"> <TableHeader>
<div className="flex justify-between items-center"> <TableRow>
<h3 className="font-medium"> {idx + 1}</h3> <TableHead></TableHead>
<Button variant="destructive" onClick={() => handleRemoveTestcase(idx)}> <TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{testcases.map((tc) => (
<TableRow key={tc.id}>
<TableCell>{tc.id}</TableCell>
<TableCell>{tc.input}</TableCell>
<TableCell>{tc.expectedOutput}</TableCell>
<TableCell>
<Button
variant="destructive"
size="sm"
onClick={() => deleteTestCase(tc.id)}
>
</Button> </Button>
</div> </TableCell>
<div className="space-y-2"> </TableRow>
<Label></Label>
<Input
value={tc.expectedOutput}
onChange={(e) => handleExpectedOutputChange(idx, e.target.value)}
placeholder="输入预期输出"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label></Label>
<Button onClick={() => handleAddInput(idx)}></Button>
</div>
{tc.inputs.map((inp, iIdx) => (
<div key={iIdx} className="grid grid-cols-2 gap-4">
<div>
<Label></Label>
<Input
value={inp.name}
onChange={(e) => handleInputChange(idx, iIdx, "name", e.target.value)}
placeholder="参数名称"
/>
</div>
<div>
<Label></Label>
<Input
value={inp.value}
onChange={(e) => handleInputChange(idx, iIdx, "value", e.target.value)}
placeholder="参数值"
/>
</div>
{iIdx > 0 && (
<Button variant="outline" onClick={() => handleRemoveInput(idx, iIdx)}>
</Button>
)}
</div>
))} ))}
</TableBody>
</Table>
</div> </div>
<Button></Button>
</div> </div>
))}
</CardContent>
</Card>
); );
} };