Merge pull request #33 from massbug/fix-pr-29

Fix pr 29
This commit is contained in:
cfngc4594 2025-06-19 17:58:44 +08:00 committed by GitHub
commit e6b18884d6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 920 additions and 511 deletions

View File

@ -1,121 +0,0 @@
"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';
import { updateProblem } from '@/app/actions/updateProblem';
interface ProblemEditorPageProps {
params: Promise<{ problemId: string }>;
}
interface UpdateData {
content: string;
language?: 'c' | 'cpp';
inputs?: Array<{ index: number; name: string; value: string }>;
}
const handleUpdate = async (
updateFn: (data: UpdateData) => Promise<{ success: boolean }>,
data: UpdateData
) => {
try {
const result = await updateFn(data);
if (!result.success) {
// 这里可以添加更具体的错误处理
}
return result;
} catch (error) {
console.error('更新失败:', error);
return { success: false };
}
};
export default async function ProblemEditorPage({
params,
}: ProblemEditorPageProps) {
const { problemId } = await params;
const components: Record<string, React.ReactNode> = {
description: <EditDescriptionPanel
problemId={problemId}
onUpdate={async (data) => {
await handleUpdate(
(descriptionData) => updateProblem({
problemId,
displayId: 0,
description: descriptionData.content
}),
data
);
}}
/>,
solution: <EditSolutionPanel
problemId={problemId}
onUpdate={async (data) => {
await handleUpdate(
(solutionData) => updateProblem({
problemId,
displayId: 0,
solution: solutionData.content
}),
data
);
}}
/>,
detail: <EditDetailPanel
problemId={problemId}
onUpdate={async (data) => {
await handleUpdate(
(detailData) => updateProblem({
problemId,
displayId: 0,
detail: detailData.content
}),
data
);
}}
/>,
code: <EditCodePanel
problemId={problemId}
onUpdate={async (data) => {
await handleUpdate(
(codeData) => updateProblem({
problemId,
displayId: 0,
templates: [{
language: codeData.language || 'c', // 添加默认值
content: codeData.content
}]
}),
data
);
}}
/>,
testcase: <EditTestcasePanel
problemId={problemId}
onUpdate={async (data) => {
await handleUpdate(
(testcaseData) => updateProblem({
problemId,
displayId: 0,
testcases: [{
expectedOutput: testcaseData.content,
inputs: testcaseData.inputs || [] // 添加默认空数组
}]
}),
data
);
}}
/>
};
return (
<div className="relative flex h-full w-full">
<ProblemFlexLayout components={components} />
</div>
);
}

View File

@ -1,15 +1,12 @@
import { notFound } from "next/navigation";
import { ProblemHeader } from "@/features/problems/components/header";
interface ProblemLayoutProps {
interface LayoutProps {
children: React.ReactNode;
params: Promise<{ problemId: string }>;
}
export default async function ProblemLayout({
children,
params,
}: ProblemLayoutProps) {
const Layout = async ({ children, params }: LayoutProps) => {
const { problemId } = await params;
if (!problemId) {
@ -24,4 +21,6 @@ export default async function ProblemLayout({
</div>
</div>
);
}
};
export default Layout;

View File

@ -1,39 +1,17 @@
import { TestcasePanel } from "@/features/problems/testcase/panel";
import { BotPanel } from "@/features/problems/bot/components/panel";
import { CodePanel } from "@/features/problems/code/components/panel";
import { DetailPanel } from "@/features/problems/detail/components/panel";
import { SolutionPanel } from "@/features/problems/solution/components/panel";
import { SubmissionPanel } from "@/features/problems/submission/components/panel";
import { DescriptionPanel } from "@/features/problems/description/components/panel";
import { ProblemFlexLayout } from "@/features/problems/components/problem-flexlayout";
import { ProblemView } from "@/features/problems/ui/views/problem-view";
interface ProblemPageProps {
interface PageProps {
params: Promise<{ problemId: string }>;
searchParams: Promise<{
submissionId: string | undefined;
}>;
}
export default async function ProblemPage({
params,
searchParams,
}: ProblemPageProps) {
const Page = async ({ params, searchParams }: PageProps) => {
const { problemId } = await params;
const { submissionId } = await searchParams;
const components: Record<string, React.ReactNode> = {
description: <DescriptionPanel problemId={problemId} />,
solution: <SolutionPanel problemId={problemId} />,
submission: <SubmissionPanel problemId={problemId} />,
detail: <DetailPanel submissionId={submissionId} />,
code: <CodePanel problemId={problemId} />,
testcase: <TestcasePanel problemId={problemId} />,
bot: <BotPanel problemId={problemId} />,
};
return <ProblemView problemId={problemId} submissionId={submissionId} />;
};
return (
<div className="relative flex h-full w-full">
<ProblemFlexLayout components={components} />
</div>
);
}
export default Page;

View File

@ -0,0 +1,19 @@
import { notFound } from "next/navigation";
import { ProblemEditLayout } from "@/features/admin/ui/layouts/problem-edit-layout";
interface LayoutProps {
children: React.ReactNode;
params: Promise<{ problemId: string }>;
}
const Layout = async ({ children, params }: LayoutProps) => {
const { problemId } = await params;
if (!problemId) {
return notFound();
}
return <ProblemEditLayout>{children}</ProblemEditLayout>;
};
export default Layout;

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

@ -0,0 +1,11 @@
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

@ -0,0 +1,150 @@
"use server";
import {AITestCaseInput, AITestCaseOutput, AITestCaseOutputSchema} from "@/types/ai-testcase";
import { deepseek } from "@/lib/ai";
import { CoreMessage, generateText } from "ai";
import prisma from "@/lib/prisma";
/**
*
* @param input
* @returns
*/
export const generateAITestcase = async (
input: AITestCaseInput
): Promise<AITestCaseOutput> => {
const model = deepseek("deepseek-chat");
let problemDetails = "";
if (input.problemId) {
try {
// 尝试获取英文描述
const problemLocalizationEn = await prisma.problemLocalization.findUnique({
where: {
problemId_locale_type: {
problemId: input.problemId,
locale: "en",
type: "DESCRIPTION",
},
},
include: {
problem: true,
},
});
if (problemLocalizationEn) {
problemDetails = `
Problem Requirements:
-------------------
Description: ${problemLocalizationEn.content}
`;
} else {
// 回退到中文描述
const problemLocalizationZh = await prisma.problemLocalization.findUnique({
where: {
problemId_locale_type: {
problemId: input.problemId,
locale: "zh",
type: "DESCRIPTION",
},
},
include: {
problem: true,
},
});
if (problemLocalizationZh) {
problemDetails = `
Problem Requirements:
-------------------
Description: ${problemLocalizationZh.content}
`;
console.warn(`Fallback to Chinese description for problemId: ${input.problemId}`);
} else {
problemDetails = "Problem description not found in any language.";
console.warn(`No description found for problemId: ${input.problemId}`);
}
}
} catch (error) {
console.error("Failed to fetch problem details:", error);
problemDetails = "Error fetching problem description.";
}
}
// 构建AI提示词
const prompt = `
Analyze the problem statement to get the expected input structure, constraints, and output logic. Generate **novel, randomized** inputs/outputs that strictly adhere to the problem's requirements. Focus on:
Your entire response/output is going to consist of a single JSON object {}, and you will NOT wrap it within JSON Markdown markers.
1. **Input Data Structure**: Identify required formats (e.g., arrays, integers, strings).
2. **Input Constraints**: Determine valid ranges (e.g., array length: 2100, integers: -1000 to 1000) and edge cases.
3. **Output Logic**: Ensure outputs correctly reflect problem-specific operations.
4. **Randomization**:
Vary input magnitudes (mix min/max/-edge values with mid-range randomness)
Use diverse data distributions (e.g., sorted/unsorted, negative/positive values)
Avoid patterns from existing examples
Your entire response/output is going to consist of a single JSON object {}, and you will NOT wrap it within JSON Markdown markers.
Here is the problem description:
${problemDetails}
Respond **ONLY** with this JSON structure.
***Do not wrap the json codes in JSON markers*** :
{
"expectedOutput": "Randomized output (e.g., [-5, 100] instead of [1, 2])",
"inputs": [
{
"name": "Parameter 1",
"value": <RANDOMIZED_DATA> // Use string to wrap actual JSON types (arrays/numbers/strings)
},
... // Add parameters as needed
]
}
`;
// 发送请求给OpenAI
const messages: CoreMessage[] = [{ role: "user", content: prompt }];
let text;
try {
const response = await generateText({
model: model,
messages: messages,
});
text = response.text;
} catch (error) {
console.error("Error generating text with OpenAI:", error);
throw new Error("Failed to generate response from OpenAI");
}
// 解析LLM响应
let llmResponseJson;
try {
llmResponseJson = JSON.parse(text)
} catch (error) {
console.error("Failed to parse LLM response as JSON:", error);
console.error("LLM raw output:", text);
throw new Error("Invalid JSON response from LLM");
}
// 验证响应格式
const validationResult = AITestCaseOutputSchema.safeParse(llmResponseJson);
if (!validationResult.success) {
console.error("Zod validation failed:", validationResult.error.format());
throw new Error("Response validation failed");
}
console.log("LLM response:", llmResponseJson);
return validationResult.data;
};

View File

@ -1,13 +1,13 @@
"use client";
import { cn } from "@/lib/utils";
import { Actions } from "flexlayout-react";
import { Button } from "@/components/ui/button";
import { ReactNode, useRef, useState } from "react";
import { CheckIcon, CopyIcon, RepeatIcon } from "lucide-react";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
import { useProblemEditorStore } from "@/stores/problem-editor";
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
import { Actions } from "flexlayout-react";
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
interface PreDetailProps {
children?: ReactNode;

View File

@ -1,180 +1,240 @@
"use client";
import { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { getProblemData } from "@/app/actions/getProblem";
import {useState, useEffect} from "react";
import {generateAITestcase} from "@/app/actions/ai-testcase";
import {Label} from "@/components/ui/label";
import {Input} from "@/components/ui/input";
import {Button} from "@/components/ui/button";
import {Card, CardContent, CardHeader, CardTitle} from "@/components/ui/card";
import {getProblemData} from "@/app/actions/getProblem";
export default function EditTestcasePanel({
problemId,
problemId,
}: {
problemId: string;
problemId: string;
}) {
const [testcases, setTestcases] = useState<
Array<{
id: string;
expectedOutput: string;
inputs: Array<{
name: string;
value: string;
}>;
}>
>([]);
const [testcases, setTestcases] = useState<
Array<{
id: string;
expectedOutput: string;
inputs: Array<{
name: string;
value: string;
}>;
}>
>([]);
useEffect(() => {
async function fetchData() {
try {
const problemData = await getProblemData(problemId);
if (problemData && problemData.testcases) {
setTestcases(problemData.testcases);
} else {
setTestcases([]);
useEffect(() => {
async function fetchData() {
try {
const problemData = await getProblemData(problemId);
if (problemData && problemData.testcases) {
setTestcases(problemData.testcases);
} else {
setTestcases([]);
}
} catch (error) {
console.error("加载测试用例失败:", error);
setTestcases([]);
}
}
fetchData();
}, [problemId]);
const handleAddTestcase = () => {
setTestcases([
...testcases,
{
id: `new-${Date.now()}`,
expectedOutput: "",
inputs: [{name: "input1", value: ""}],
},
]);
};
const [isGenerating, setIsGenerating] = useState(false);
const handleAITestcase = async () => {
setIsGenerating(true);
try {
const AIOutputParsed = await generateAITestcase({problemId: problemId});
setTestcases([
...testcases,
{
id: `new-${Date.now()}`,
expectedOutput: AIOutputParsed.expectedOutput,
inputs: AIOutputParsed.inputs
}
])
window.scrollTo({
top: document.body.scrollHeight,
behavior: 'smooth',
});
} catch (error) {
console.error(error)
} finally {
setIsGenerating(false);
}
} catch (error) {
console.error("加载测试用例失败:", error);
setTestcases([]);
}
}
fetchData();
}, [problemId]);
const handleAddTestcase = () => {
setTestcases([
...testcases,
{
id: `new-${Date.now()}`,
expectedOutput: "",
inputs: [{ name: "input1", value: "" }],
},
]);
};
const handleRemoveTestcase = (index: number) => {
const newTestcases = [...testcases];
newTestcases.splice(index, 1);
setTestcases(newTestcases);
};
const handleRemoveTestcase = (index: number) => {
const newTestcases = [...testcases];
newTestcases.splice(index, 1);
setTestcases(newTestcases);
};
const handleInputChange = (
testcaseIndex: number,
inputIndex: number,
field: "name" | "value",
value: string
) => {
const newTestcases = [...testcases];
newTestcases[testcaseIndex].inputs[inputIndex][field] = value;
setTestcases(newTestcases);
};
const handleInputChange = (
testcaseIndex: number,
inputIndex: number,
field: "name" | "value",
value: string
) => {
const newTestcases = [...testcases];
newTestcases[testcaseIndex].inputs[inputIndex][field] = value;
setTestcases(newTestcases);
};
const handleExpectedOutputChange = (testcaseIndex: number, value: string) => {
const newTestcases = [...testcases];
newTestcases[testcaseIndex].expectedOutput = value;
setTestcases(newTestcases);
};
const handleExpectedOutputChange = (testcaseIndex: number, value: string) => {
const newTestcases = [...testcases];
newTestcases[testcaseIndex].expectedOutput = value;
setTestcases(newTestcases);
};
const handleAddInput = (testcaseIndex: number) => {
const newTestcases = [...testcases];
newTestcases[testcaseIndex].inputs.push({
name: `input${newTestcases[testcaseIndex].inputs.length + 1}`,
value: "",
});
setTestcases(newTestcases);
};
const handleAddInput = (testcaseIndex: number) => {
const newTestcases = [...testcases];
newTestcases[testcaseIndex].inputs.push({
name: `input${newTestcases[testcaseIndex].inputs.length + 1}`,
value: "",
});
setTestcases(newTestcases);
};
const handleRemoveInput = (testcaseIndex: number, inputIndex: number) => {
const newTestcases = [...testcases];
newTestcases[testcaseIndex].inputs.splice(inputIndex, 1);
setTestcases(newTestcases);
};
const handleRemoveInput = (testcaseIndex: number, inputIndex: number) => {
const newTestcases = [...testcases];
newTestcases[testcaseIndex].inputs.splice(inputIndex, 1);
setTestcases(newTestcases);
};
return (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
<Button type="button" onClick={handleAddTestcase}>
</Button>
</CardHeader>
<CardContent>
<div className="space-y-6">
{testcases.map((testcase, index) => (
<div key={testcase.id} className="border p-4 rounded-md space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium"> {index + 1}</h3>
return (
<Card className="w-full">
<CardHeader>
<CardTitle></CardTitle>
<div className="flex items-center space-x-1"> {/* space-x-1 让按钮更接近 */}
<Button type="button" onClick={handleAddTestcase}>
</Button>
<Button
type="button"
variant="destructive"
onClick={() => handleRemoveTestcase(index)}
className="flex items-center gap-1"
onClick={handleAITestcase}
disabled={isGenerating}
style={{
opacity: isGenerating ? 0.7 : 1,
cursor: isGenerating ? 'not-allowed' : 'pointer'
}}
>
<svg
data-testid="geist-icon"
height="16"
strokeLinejoin="round"
style={{color: isGenerating ? '#888' : "currentColor"}}
viewBox="0 0 16 16"
width="16"
>
<path
d="M2.5 0.5V0H3.5V0.5C3.5 1.60457 4.39543 2.5 5.5 2.5H6V3V3.5H5.5C4.39543 3.5 3.5 4.39543 3.5 5.5V6H3H2.5V5.5C2.5 4.39543 1.60457 3.5 0.5 3.5H0V3V2.5H0.5C1.60457 2.5 2.5 1.60457 2.5 0.5Z"
fill="currentColor"></path>
<path
d="M14.5 4.5V5H13.5V4.5C13.5 3.94772 13.0523 3.5 12.5 3.5H12V3V2.5H12.5C13.0523 2.5 13.5 2.05228 13.5 1.5V1H14H14.5V1.5C14.5 2.05228 14.9477 2.5 15.5 2.5H16V3V3.5H15.5C14.9477 3.5 14.5 3.94772 14.5 4.5Z"
fill="currentColor"></path>
<path
d="M8.40706 4.92939L8.5 4H9.5L9.59294 4.92939C9.82973 7.29734 11.7027 9.17027 14.0706 9.40706L15 9.5V10.5L14.0706 10.5929C11.7027 10.8297 9.82973 12.7027 9.59294 15.0706L9.5 16H8.5L8.40706 15.0706C8.17027 12.7027 6.29734 10.8297 3.92939 10.5929L3 10.5V9.5L3.92939 9.40706C6.29734 9.17027 8.17027 7.29734 8.40706 4.92939Z"
fill="currentColor"></path>
</svg>
{isGenerating ? '生成中...' : '使用AI生成测试用例'}
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="space-y-6">
{testcases.map((testcase, index) => (
<div key={testcase.id} className="border p-4 rounded-md space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-medium"> {index + 1}</h3>
<Button
type="button"
variant="destructive"
onClick={() => handleRemoveTestcase(index)}
>
</Button>
</div>
<div className="space-y-2">
<Label htmlFor={`expected-output-${index}`}></Label>
<Input
id={`expected-output-${index}`}
value={testcase.expectedOutput}
onChange={(e) => handleExpectedOutputChange(index, e.target.value)}
placeholder="输入预期输出"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`expected-output-${index}`}></Label>
<Input
id={`expected-output-${index}`}
value={testcase.expectedOutput}
onChange={(e) => handleExpectedOutputChange(index, e.target.value)}
placeholder="输入预期输出"
/>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label></Label>
<Button type="button" onClick={() => handleAddInput(index)}>
</Button>
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label></Label>
<Button type="button" onClick={() => handleAddInput(index)}>
</Button>
</div>
{testcase.inputs.map((input, inputIndex) => (
<div key={input.name} className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={`input-name-${index}-${inputIndex}`}>
</Label>
<Input
id={`input-name-${index}-${inputIndex}`}
value={input.name}
onChange={(e) =>
handleInputChange(index, inputIndex, "name", e.target.value)
}
placeholder="输入参数名称"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`input-value-${index}-${inputIndex}`}>
</Label>
<Input
id={`input-value-${index}-${inputIndex}`}
value={input.value}
onChange={(e) =>
handleInputChange(index, inputIndex, "value", e.target.value)
}
placeholder="输入参数值"
/>
</div>
{inputIndex > 0 && (
<Button
type="button"
variant="outline"
onClick={() => handleRemoveInput(index, inputIndex)}
className="w-full"
>
</Button>
)}
{testcase.inputs.map((input, inputIndex) => (
<div key={input.name} className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor={`input-name-${index}-${inputIndex}`}>
</Label>
<Input
id={`input-name-${index}-${inputIndex}`}
value={input.name}
onChange={(e) =>
handleInputChange(index, inputIndex, "name", e.target.value)
}
placeholder="输入参数名称"
/>
</div>
<div className="space-y-2">
<Label htmlFor={`input-value-${index}-${inputIndex}`}>
</Label>
<Input
id={`input-value-${index}-${inputIndex}`}
value={input.value}
onChange={(e) =>
handleInputChange(index, inputIndex, "value", e.target.value)
}
placeholder="输入参数值"
/>
</div>
{inputIndex > 0 && (
<Button
type="button"
variant="outline"
onClick={() => handleRemoveInput(index, inputIndex)}
className="w-full"
>
</Button>
)}
</div>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
</CardContent>
</Card>
);
}

View File

@ -0,0 +1,26 @@
"use client";
import { useProblemEditFlexLayoutStore } from "@/stores/flexlayout";
import { FlexLayout } from "@/features/problems/components/flexlayout";
interface ProblemEditFlexLayoutProps {
components: Record<string, React.ReactNode>;
}
export const ProblemEditFlexLayout = ({
components,
}: ProblemEditFlexLayoutProps) => {
const { hasHydrated, model, jsonModel, setModel, setJsonModel } =
useProblemEditFlexLayoutStore();
return (
<FlexLayout
components={components}
hasHydrated={hasHydrated}
model={model}
jsonModel={jsonModel}
setModel={setModel}
setJsonModel={setJsonModel}
/>
);
};

View File

@ -0,0 +1,30 @@
import prisma from "@/lib/prisma";
import { auth, signIn } from "@/lib/auth";
import { redirect } from "next/navigation";
interface AdminProtectedLayoutProps {
children: React.ReactNode;
}
export const AdminProtectedLayout = async ({
children,
}: AdminProtectedLayoutProps) => {
const session = await auth();
const userId = session?.user?.id;
if (!userId) {
await signIn();
}
const user = await prisma.user.findUnique({
select: {
role: true,
},
where: {
id: userId,
},
});
if (user?.role !== "ADMIN") redirect("/unauthorized");
return <>{children}</>;
};

View File

@ -0,0 +1,29 @@
import { Suspense } from "react";
import { BackButton } from "@/components/back-button";
import { UserAvatar, UserAvatarSkeleton } from "@/components/user-avatar";
interface ProblemEditLayoutProps {
children: React.ReactNode;
}
export const ProblemEditLayout = ({ children }: ProblemEditLayoutProps) => {
return (
<div className="flex flex-col h-screen">
<header className="relative flex h-12 flex-none items-center">
<div className="container mx-auto flex h-full items-center justify-between px-4">
<div className="flex items-center">
<BackButton href="/problem" />
</div>
<div className="flex items-center gap-4">
<Suspense fallback={<UserAvatarSkeleton />}>
<UserAvatar />
</Suspense>
</div>
</div>
</header>
<div className="flex w-full flex-grow overflow-y-hidden p-2.5 pt-0">
{children}
</div>
</div>
);
};

View File

@ -0,0 +1,26 @@
import EditCodePanel from "@/components/creater/edit-code-panel";
import EditDetailPanel from "@/components/creater/edit-detail-panel";
import EditSolutionPanel from "@/components/creater/edit-solution-panel";
import EditTestcasePanel from "@/components/creater/edit-testcase-panel";
import EditDescriptionPanel from "@/components/creater/edit-description-panel";
import { ProblemEditFlexLayout } from "@/features/admin/ui/components/problem-edit-flexlayout";
interface ProblemEditViewProps {
problemId: string;
}
export const ProblemEditView = ({ problemId }: ProblemEditViewProps) => {
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">
<ProblemEditFlexLayout components={components} />
</div>
);
};

View File

@ -11,7 +11,7 @@ import { useEffect, useState } from "react";
import { useTranslations } from "next-intl";
import { Toggle } from "@/components/ui/toggle";
import { Actions, DockLocation } from "flexlayout-react";
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
export const ViewBotButton = () => {
const t = useTranslations();

View File

@ -0,0 +1,111 @@
"use client";
import {
BotIcon,
CircleCheckBigIcon,
FileTextIcon,
FlaskConicalIcon,
SquareCheckIcon,
SquarePenIcon,
} from "lucide-react";
import {
IJsonModel,
ITabRenderValues,
Layout,
Model,
TabNode,
} from "flexlayout-react";
import "@/styles/flexlayout.css";
import { useTranslations } from "next-intl";
import { useCallback, useEffect } from "react";
import { Skeleton } from "@/components/ui/skeleton";
interface FlexLayoutProps {
components: Record<string, React.ReactNode>;
hasHydrated: boolean;
model: Model | null;
jsonModel: IJsonModel;
setModel: (model: Model) => void;
setJsonModel: (jsonModel: IJsonModel) => void;
}
export const FlexLayout = ({
components,
hasHydrated,
model,
jsonModel,
setModel,
setJsonModel,
}: FlexLayoutProps) => {
const t = useTranslations("ProblemPage");
useEffect(() => {
if (hasHydrated && !model) {
const model = Model.fromJson(jsonModel);
setModel(model);
}
}, [hasHydrated, jsonModel, model, setModel]);
const onModelChange = useCallback(
(model: Model) => {
const jsonModel = model.toJson();
setJsonModel(jsonModel);
},
[setJsonModel]
);
const factory = useCallback(
(node: TabNode) => {
const component = node.getComponent();
return component ? components[component] : null;
},
[components]
);
const onRenderTab = useCallback(
(node: TabNode, renderValues: ITabRenderValues) => {
const Icon = getIconForTab(node.getId());
renderValues.leading = Icon ? (
<Icon className="opacity-60" size={16} aria-hidden="true" />
) : null;
renderValues.content = (
<span className="text-sm font-medium">
{t(node.getName()) || node.getName()}
</span>
);
},
[t]
);
if (!model || !hasHydrated)
return <Skeleton className="h-full w-full rounded-2xl" />;
return (
<Layout
model={model}
factory={factory}
onRenderTab={onRenderTab}
onModelChange={onModelChange}
realtimeResize={true}
/>
);
};
const getIconForTab = (id: string) => {
switch (id) {
case "description":
return FileTextIcon;
case "solution":
return FlaskConicalIcon;
case "submission":
return CircleCheckBigIcon;
case "detail":
return CircleCheckBigIcon;
case "code":
return SquarePenIcon;
case "testcase":
return SquareCheckIcon;
case "bot":
return BotIcon;
}
};

View File

@ -10,7 +10,7 @@ import { LoaderCircleIcon, PlayIcon } from "lucide-react";
import { TooltipButton } from "@/components/tooltip-button";
import { useProblemEditorStore } from "@/stores/problem-editor";
import { JudgeToast } from "@/features/problems/components/judge-toast";
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
interface JudgeButtonProps {
className?: string;

View File

@ -1,94 +1,24 @@
"use client";
import {
BotIcon,
CircleCheckBigIcon,
FileTextIcon,
FlaskConicalIcon,
SquareCheckIcon,
SquarePenIcon,
} from "lucide-react";
import "@/styles/flexlayout.css";
import { useTranslations } from "next-intl";
import { useCallback, useEffect } from "react";
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
import { ITabRenderValues, Layout, Model, TabNode } from "flexlayout-react";
import { FlexLayout } from "./flexlayout";
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
interface ProblemFlexLayoutProps {
components: Record<string, React.ReactNode>;
}
export const ProblemFlexLayout = ({ components }: ProblemFlexLayoutProps) => {
const t = useTranslations("ProblemPage");
const { model, setModel, jsonModel, setJsonModel } =
const { hasHydrated, model, jsonModel, setModel, setJsonModel } =
useProblemFlexLayoutStore();
useEffect(() => {
if (!model) {
const model = Model.fromJson(jsonModel);
setModel(model);
}
}, [jsonModel, model, setModel]);
const onModelChange = useCallback(
(model: Model) => {
const jsonModel = model.toJson();
setJsonModel(jsonModel);
},
[setJsonModel]
);
const factory = useCallback(
(node: TabNode) => {
const component = node.getComponent();
return component ? components[component] : null;
},
[components]
);
const onRenderTab = useCallback(
(node: TabNode, renderValues: ITabRenderValues) => {
const Icon = getIconForTab(node.getId());
renderValues.leading = Icon ? (
<Icon className="opacity-60" size={16} aria-hidden="true" />
) : null;
renderValues.content = (
<span className="text-sm font-medium">
{t(node.getName()) || node.getName()}
</span>
);
},
[t]
);
if (!model) return null;
return (
<Layout
<FlexLayout
components={components}
hasHydrated={hasHydrated}
model={model}
factory={factory}
onRenderTab={onRenderTab}
onModelChange={onModelChange}
realtimeResize={true}
jsonModel={jsonModel}
setModel={setModel}
setJsonModel={setJsonModel}
/>
);
};
const getIconForTab = (id: string) => {
switch (id) {
case "description":
return FileTextIcon;
case "solution":
return FlaskConicalIcon;
case "submission":
return CircleCheckBigIcon;
case "detail":
return CircleCheckBigIcon;
case "code":
return SquarePenIcon;
case "testcase":
return SquareCheckIcon;
case "bot":
return BotIcon;
}
};

View File

@ -4,7 +4,7 @@ import { Actions } from "flexlayout-react";
import { useTranslations } from "next-intl";
import { ArrowLeftIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
export const DetailHeader = () => {

View File

@ -3,7 +3,7 @@
import { Actions } from "flexlayout-react";
import { BookOpenIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
export const ViewSolutionButton = () => {
const { model } = useProblemFlexLayoutStore();

View File

@ -7,7 +7,7 @@ import { Locale, Submission } from "@/generated/client";
import { Actions, DockLocation } from "flexlayout-react";
import { getColorClassForStatus } from "@/config/status";
import { TableCell, TableRow } from "@/components/ui/table";
import { useProblemFlexLayoutStore } from "@/stores/problem-flexlayout";
import { useProblemFlexLayoutStore } from "@/stores/flexlayout";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { getIconForLanguage, getLabelForLanguage } from "@/config/language";

View File

@ -0,0 +1,31 @@
import { TestcasePanel } from "@/features/problems/testcase/panel";
import { BotPanel } from "@/features/problems/bot/components/panel";
import { CodePanel } from "@/features/problems/code/components/panel";
import { DetailPanel } from "@/features/problems/detail/components/panel";
import { SolutionPanel } from "@/features/problems/solution/components/panel";
import { SubmissionPanel } from "@/features/problems/submission/components/panel";
import { DescriptionPanel } from "@/features/problems/description/components/panel";
import { ProblemFlexLayout } from "@/features/problems/components/problem-flexlayout";
interface ProblemViewProps {
problemId: string;
submissionId: string | undefined;
}
export const ProblemView = ({ problemId, submissionId }: ProblemViewProps) => {
const components: Record<string, React.ReactNode> = {
description: <DescriptionPanel problemId={problemId} />,
solution: <SolutionPanel problemId={problemId} />,
submission: <SubmissionPanel problemId={problemId} />,
detail: <DetailPanel submissionId={submissionId} />,
code: <CodePanel problemId={problemId} />,
testcase: <TestcasePanel problemId={problemId} />,
bot: <BotPanel problemId={problemId} />,
};
return (
<div className="relative flex h-full w-full">
<ProblemFlexLayout components={components} />
</div>
);
};

211
src/stores/flexlayout.ts Normal file
View File

@ -0,0 +1,211 @@
import { create } from "zustand";
import { IJsonModel, Model } from "flexlayout-react";
import { createJSONStorage, persist } from "zustand/middleware";
type FlexLayoutState = {
hasHydrated: boolean;
model: Model | null;
jsonModel: IJsonModel;
};
type FlexLayoutAction = {
setHasHydrated: (hasHydrated: boolean) => void;
setModel: (model: Model) => void;
setJsonModel: (jsonModel: IJsonModel) => void;
};
type FlexLayoutStore = FlexLayoutState & FlexLayoutAction;
const createFlexLayoutStore = (storageKey: string, jsonModel: IJsonModel) =>
create<FlexLayoutStore>()(
persist(
(set) => ({
hasHydrated: false,
model: null,
jsonModel: jsonModel,
setHasHydrated: (hasHydrated) => set({ hasHydrated }),
setModel: (model) => set({ model }),
setJsonModel: (jsonModel) => set({ jsonModel }),
}),
{
name: storageKey,
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
jsonModel: state.jsonModel,
}),
onRehydrateStorage: () => {
return (state) => {
state?.setHasHydrated(true);
};
},
}
)
);
const initialProblemFlexLayoutJsonModel: IJsonModel = {
global: {
// tabEnableClose: false,
tabEnableRename: false,
},
borders: [],
layout: {
type: "row",
weight: 100,
children: [
{
type: "tabset",
id: "1",
weight: 50,
children: [
{
type: "tab",
id: "description",
name: "Description",
component: "description",
enableClose: false,
},
{
type: "tab",
id: "solution",
name: "Solutions",
component: "solution",
enableClose: false,
},
{
type: "tab",
id: "submission",
name: "Submissions",
component: "submission",
enableClose: false,
},
// {
// type: "tab",
// id: "detail",
// name: "Details",
// component: "detail",
// },
],
},
{
type: "row",
weight: 50,
children: [
{
type: "tabset",
id: "2",
weight: 50,
children: [
{
type: "tab",
id: "code",
name: "Code",
component: "code",
enableClose: false,
},
],
},
{
type: "tabset",
id: "3",
weight: 50,
children: [
{
type: "tab",
id: "testcase",
name: "Testcase",
component: "testcase",
enableClose: false,
},
],
},
],
},
],
},
};
export const useProblemFlexLayoutStore = createFlexLayoutStore(
"problem-flexlayout",
initialProblemFlexLayoutJsonModel
);
const initialProblemEditFlexLayoutJsonModel: IJsonModel = {
global: {
// tabEnableClose: false,
tabEnableRename: false,
},
borders: [],
layout: {
type: "row",
weight: 100,
children: [
{
type: "tabset",
id: "1",
weight: 50,
children: [
{
type: "tab",
id: "description",
name: "Description",
component: "description",
enableClose: false,
},
{
type: "tab",
id: "solution",
name: "Solutions",
component: "solution",
enableClose: false,
},
{
type: "tab",
id: "detail",
name: "Details",
component: "detail",
enableClose: false,
},
],
},
{
type: "row",
weight: 50,
children: [
{
type: "tabset",
id: "2",
weight: 50,
children: [
{
type: "tab",
id: "code",
name: "Code",
component: "code",
enableClose: false,
},
],
},
{
type: "tabset",
id: "3",
weight: 50,
children: [
{
type: "tab",
id: "testcase",
name: "Testcase",
component: "testcase",
enableClose: false,
},
],
},
],
},
],
},
};
export const useProblemEditFlexLayoutStore = createFlexLayoutStore(
"problem-edit-flexlayout",
initialProblemEditFlexLayoutJsonModel
);

View File

@ -1,115 +0,0 @@
import { create } from "zustand";
import { IJsonModel, Model } from "flexlayout-react";
import { createJSONStorage, persist } from "zustand/middleware";
const initialJsonModel: IJsonModel = {
global: {
// tabEnableClose: false,
tabEnableRename: false,
},
borders: [],
layout: {
type: "row",
weight: 100,
children: [
{
type: "tabset",
id: "1",
weight: 50,
children: [
{
type: "tab",
id: "description",
name: "Description",
component: "description",
enableClose: false,
},
{
type: "tab",
id: "solution",
name: "Solutions",
component: "solution",
enableClose: false,
},
{
type: "tab",
id: "submission",
name: "Submissions",
component: "submission",
enableClose: false,
},
// {
// type: "tab",
// id: "detail",
// name: "Details",
// component: "detail",
// },
],
},
{
type: "row",
weight: 50,
children: [
{
type: "tabset",
id: "2",
weight: 50,
children: [
{
type: "tab",
id: "code",
name: "Code",
component: "code",
enableClose: false,
},
],
},
{
type: "tabset",
id: "3",
weight: 50,
children: [
{
type: "tab",
id: "testcase",
name: "Testcase",
component: "testcase",
enableClose: false,
},
],
},
],
},
],
},
};
type ProblemFlexLayoutState = {
model: Model | null;
jsonModel: IJsonModel;
};
type ProblemFlexLayoutAction = {
setModel: (model: Model) => void;
setJsonModel: (jsonModel: IJsonModel) => void;
};
type ProblemFlexLayoutStore = ProblemFlexLayoutState & ProblemFlexLayoutAction;
export const useProblemFlexLayoutStore = create<ProblemFlexLayoutStore>()(
persist(
(set) => ({
model: null,
jsonModel: initialJsonModel,
setModel: (model) => set({ model }),
setJsonModel: (jsonModel) => set({ jsonModel }),
}),
{
name: "problem-flexlayout",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
jsonModel: state.jsonModel,
}),
}
)
);

21
src/types/ai-testcase.ts Normal file
View File

@ -0,0 +1,21 @@
import {z} from "zod";
export const AITestCaseInputSchema = z.object({
problemId: z.string(),
})
export type AITestCaseInput = z.infer<typeof AITestCaseInputSchema>
const input = z.object({
name: z.string(),
value: z.string()
})
export const AITestCaseOutputSchema = z.object({
expectedOutput: z.string(),
inputs: z.array(input)
})
export type AITestCaseOutput = z.infer<typeof AITestCaseOutputSchema>