mirror of
https://github.com/massbug/judge4c.git
synced 2025-07-03 23:30:50 +00:00
commit
66403dbdb9
@ -1,3 +1,4 @@
|
|||||||
|
import prisma from "@/lib/prisma";
|
||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { ProblemHeader } from "@/features/problems/components/header";
|
import { ProblemHeader } from "@/features/problems/components/header";
|
||||||
|
|
||||||
@ -13,6 +14,19 @@ const Layout = async ({ children, params }: LayoutProps) => {
|
|||||||
return notFound();
|
return notFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const problem = await prisma.problem.findUnique({
|
||||||
|
select: {
|
||||||
|
isPublished: true,
|
||||||
|
},
|
||||||
|
where: {
|
||||||
|
id: problemId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!problem?.isPublished) {
|
||||||
|
return notFound();
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen">
|
<div className="flex flex-col h-screen">
|
||||||
<ProblemHeader />
|
<ProblemHeader />
|
||||||
|
149
src/app/actions/ai-improve.ts
Normal file
149
src/app/actions/ai-improve.ts
Normal file
@ -0,0 +1,149 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import {
|
||||||
|
OptimizeCodeInput,
|
||||||
|
OptimizeCodeOutput,
|
||||||
|
OptimizeCodeOutputSchema,
|
||||||
|
} from "@/types/ai-improve";
|
||||||
|
import { deepseek } from "@/lib/ai";
|
||||||
|
import { CoreMessage, generateText } from "ai";
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用AI优化代码
|
||||||
|
* @param input 包含代码、错误信息、题目ID的输入
|
||||||
|
* @returns 优化后的代码和说明
|
||||||
|
*/
|
||||||
|
export const optimizeCode = async (
|
||||||
|
input: OptimizeCodeInput
|
||||||
|
): Promise<OptimizeCodeOutput> => {
|
||||||
|
const model = deepseek("chat");
|
||||||
|
|
||||||
|
// 获取题目详情(如果提供了problemId)
|
||||||
|
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 following programming code for potential errors, inefficiencies or code style issues.
|
||||||
|
Provide an optimized version of the code with explanations. Focus on:
|
||||||
|
1. Fixing any syntax errors
|
||||||
|
2. Improving performance
|
||||||
|
3. Enhancing code readability
|
||||||
|
4. Following best practices
|
||||||
|
|
||||||
|
Original code:
|
||||||
|
\`\`\`
|
||||||
|
${input.code}
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Error message (if any): ${input.error || "No error message provided"}
|
||||||
|
|
||||||
|
${problemDetails}
|
||||||
|
|
||||||
|
Respond ONLY with the JSON object containing the optimized code and explanations.
|
||||||
|
Format:
|
||||||
|
{
|
||||||
|
"optimizedCode": "optimized code here",
|
||||||
|
"explanation": "explanation of changes made",
|
||||||
|
"issuesFixed": ["list of issues fixed"]
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
console.log("Prompt:", prompt);
|
||||||
|
|
||||||
|
// 发送请求给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 {
|
||||||
|
const cleanedText = text.trim();
|
||||||
|
llmResponseJson = JSON.parse(cleanedText);
|
||||||
|
} 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 = OptimizeCodeOutputSchema.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;
|
||||||
|
};
|
155
src/app/actions/ai-testcase.ts
Normal file
155
src/app/actions/ai-testcase.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
"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: 2–100, 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;
|
||||||
|
};
|
63
src/app/actions/getProblem.ts
Normal file
63
src/app/actions/getProblem.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
// app/actions/get-problem-data.ts
|
||||||
|
"use server";
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { Locale } from "@/generated/client";
|
||||||
|
import { serialize } from "next-mdx-remote/serialize";
|
||||||
|
|
||||||
|
export async function getProblemData(problemId: string, locale?: string) {
|
||||||
|
const selectedLocale = locale as Locale;
|
||||||
|
|
||||||
|
const problem = await prisma.problem.findUnique({
|
||||||
|
where: { id: problemId },
|
||||||
|
include: {
|
||||||
|
templates: true,
|
||||||
|
testcases: {
|
||||||
|
include: { inputs: true },
|
||||||
|
},
|
||||||
|
localizations: {
|
||||||
|
where: {
|
||||||
|
locale: selectedLocale,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!problem) {
|
||||||
|
throw new Error("Problem not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const getContent = (type: string) =>
|
||||||
|
problem.localizations.find((loc) => loc.type === type)?.content || "";
|
||||||
|
|
||||||
|
const rawDescription = getContent("DESCRIPTION");
|
||||||
|
|
||||||
|
const mdxDescription = await serialize(rawDescription, {
|
||||||
|
parseFrontmatter: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: problem.id,
|
||||||
|
displayId: problem.displayId,
|
||||||
|
difficulty: problem.difficulty,
|
||||||
|
isPublished: problem.isPublished,
|
||||||
|
timeLimit: problem.timeLimit,
|
||||||
|
memoryLimit: problem.memoryLimit,
|
||||||
|
title: getContent("TITLE"),
|
||||||
|
description: rawDescription,
|
||||||
|
mdxDescription,
|
||||||
|
solution: getContent("SOLUTION"),
|
||||||
|
templates: problem.templates.map((t) => ({
|
||||||
|
language: t.language,
|
||||||
|
content: t.content,
|
||||||
|
})),
|
||||||
|
testcases: problem.testcases.map((tc) => ({
|
||||||
|
id: tc.id,
|
||||||
|
expectedOutput: tc.expectedOutput,
|
||||||
|
inputs: tc.inputs.map((input) => ({
|
||||||
|
name: input.name,
|
||||||
|
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);
|
||||||
|
}
|
127
src/components/ai-optimized-editor.tsx
Normal file
127
src/components/ai-optimized-editor.tsx
Normal file
@ -0,0 +1,127 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { DiffEditor } from "@monaco-editor/react";
|
||||||
|
import { optimizeCode } from "@/app/actions/ai-improve";
|
||||||
|
import type { OptimizeCodeInput } from "@/types/ai-improve";
|
||||||
|
import { CoreEditor } from "./core-editor"; // 引入你刚刚的组件
|
||||||
|
// import { Loading } from "@/components/loading";
|
||||||
|
import type { LanguageServerConfig } from "@/generated/client";
|
||||||
|
|
||||||
|
interface AIEditorWrapperProps {
|
||||||
|
language?: string;
|
||||||
|
value?: string;
|
||||||
|
path?: string;
|
||||||
|
problemId?: string;
|
||||||
|
languageServerConfigs?: LanguageServerConfig[];
|
||||||
|
onChange?: (value: string) => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AIEditorWrapper = ({
|
||||||
|
language,
|
||||||
|
value,
|
||||||
|
path,
|
||||||
|
problemId,
|
||||||
|
languageServerConfigs,
|
||||||
|
onChange,
|
||||||
|
}: // className,
|
||||||
|
AIEditorWrapperProps) => {
|
||||||
|
const [currentCode, setCurrentCode] = useState(value ?? "");
|
||||||
|
const [optimizedCode, setOptimizedCode] = useState("");
|
||||||
|
const [isOptimizing, setIsOptimizing] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [showDiff, setShowDiff] = useState(false);
|
||||||
|
|
||||||
|
const handleCodeChange = useCallback(
|
||||||
|
(val: string) => {
|
||||||
|
setCurrentCode(val);
|
||||||
|
onChange?.(val);
|
||||||
|
},
|
||||||
|
[onChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleOptimize = useCallback(async () => {
|
||||||
|
if (!problemId || !currentCode) return;
|
||||||
|
setIsOptimizing(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input: OptimizeCodeInput = {
|
||||||
|
code: currentCode,
|
||||||
|
problemId,
|
||||||
|
};
|
||||||
|
const result = await optimizeCode(input);
|
||||||
|
setOptimizedCode(result.optimizedCode);
|
||||||
|
setShowDiff(true);
|
||||||
|
} catch (err) {
|
||||||
|
setError("AI 优化失败,请稍后重试");
|
||||||
|
console.error(err);
|
||||||
|
} finally {
|
||||||
|
setIsOptimizing(false);
|
||||||
|
}
|
||||||
|
}, [currentCode, problemId]);
|
||||||
|
|
||||||
|
const handleApplyOptimized = useCallback(() => {
|
||||||
|
setCurrentCode(optimizedCode);
|
||||||
|
onChange?.(optimizedCode);
|
||||||
|
setShowDiff(false);
|
||||||
|
}, [optimizedCode, onChange]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full w-full">
|
||||||
|
<div className="flex items-center justify-between p-4">
|
||||||
|
<button
|
||||||
|
onClick={handleOptimize}
|
||||||
|
disabled={isOptimizing}
|
||||||
|
className="px-4 py-2 bg-primary text-white rounded hover:bg-primary/90"
|
||||||
|
>
|
||||||
|
{isOptimizing ? "优化中..." : "AI优化代码"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showDiff && (
|
||||||
|
<div className="space-x-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDiff(false)}
|
||||||
|
className="px-4 py-2 bg-secondary text-white rounded"
|
||||||
|
>
|
||||||
|
隐藏对比
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleApplyOptimized}
|
||||||
|
className="px-4 py-2 bg-green-500 text-white rounded"
|
||||||
|
>
|
||||||
|
应用优化结果
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 bg-red-100 text-red-600 rounded-md">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-grow overflow-hidden">
|
||||||
|
{showDiff ? (
|
||||||
|
<DiffEditor
|
||||||
|
original={currentCode}
|
||||||
|
modified={optimizedCode}
|
||||||
|
language={language}
|
||||||
|
theme="vs-dark"
|
||||||
|
className="h-full w-full"
|
||||||
|
options={{ readOnly: true, minimap: { enabled: false } }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CoreEditor
|
||||||
|
language={language}
|
||||||
|
value={currentCode}
|
||||||
|
path={path}
|
||||||
|
languageServerConfigs={languageServerConfigs}
|
||||||
|
onChange={handleCodeChange}
|
||||||
|
className="h-full w-full"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
118
src/components/creater/edit-code-panel.tsx
Normal file
118
src/components/creater/edit-code-panel.tsx
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Language } from "@/generated/client";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { CoreEditor } from "@/components/core-editor";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { getProblemData } from "@/app/actions/getProblem";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
|
import { updateProblemTemplate } from "@/components/creater/problem-maintain";
|
||||||
|
|
||||||
|
interface Template {
|
||||||
|
language: string;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EditCodePanelProps {
|
||||||
|
problemId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditCodePanel({ problemId }: EditCodePanelProps) {
|
||||||
|
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,
|
||||||
|
codeTemplate.language as Language,
|
||||||
|
codeTemplate.content
|
||||||
|
);
|
||||||
|
if (res.success) {
|
||||||
|
toast.success("保存成功");
|
||||||
|
} else {
|
||||||
|
toast.error("保存失败");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("保存异常:", error);
|
||||||
|
toast.error("保存异常");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PanelLayout>
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<Card className="w-full rounded-none border-none bg-background">
|
||||||
|
<CardHeader className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>代码模板</span>
|
||||||
|
<Button onClick={handleSave}>保存</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="language-select">编程语言</Label>
|
||||||
|
<select
|
||||||
|
id="language-select"
|
||||||
|
className="block w-full p-2 border border-gray-300 rounded-md dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
value={codeTemplate.language}
|
||||||
|
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||||
|
>
|
||||||
|
{templates.map((t) => (
|
||||||
|
<option key={t.language} value={t.language}>
|
||||||
|
{t.language.toUpperCase()}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="code-editor">代码模板内容</Label>
|
||||||
|
<div className="border rounded-md h-[500px]">
|
||||||
|
<CoreEditor
|
||||||
|
language={codeTemplate.language}
|
||||||
|
value={codeTemplate.content}
|
||||||
|
path={`/${problemId}.${codeTemplate.language}`}
|
||||||
|
onChange={(value) =>
|
||||||
|
setCodeTemplate({ ...codeTemplate, content: value || "" })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ScrollArea>
|
||||||
|
</PanelLayout>
|
||||||
|
);
|
||||||
|
}
|
217
src/components/creater/edit-description-panel.tsx
Normal file
217
src/components/creater/edit-description-panel.tsx
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Locale } from "@/generated/client";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
updateProblemDescription,
|
||||||
|
updateProblemTitle,
|
||||||
|
} from "@/components/creater/problem-maintain";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import MdxPreview from "@/components/mdx-preview";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
|
import { CoreEditor } from "@/components/core-editor";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { getProblemData } from "@/app/actions/getProblem";
|
||||||
|
import { VideoEmbed } from "@/components/content/video-embed";
|
||||||
|
import { getProblemLocales } from "@/app/actions/getProblemLocales";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function EditDescriptionPanel({
|
||||||
|
problemId,
|
||||||
|
}: {
|
||||||
|
problemId: string;
|
||||||
|
}) {
|
||||||
|
const [locales, setLocales] = useState<string[]>([]);
|
||||||
|
const [currentLocale, setCurrentLocale] = useState<string>("");
|
||||||
|
const [customLocale, setCustomLocale] = useState("");
|
||||||
|
|
||||||
|
const [description, setDescription] = useState({ title: "", content: "" });
|
||||||
|
const [viewMode, setViewMode] = useState<"edit" | "preview" | "compare">(
|
||||||
|
"edit"
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchLocales() {
|
||||||
|
try {
|
||||||
|
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 (
|
||||||
|
<PanelLayout>
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<Card className="w-full rounded-none border-none bg-background">
|
||||||
|
<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 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="输入题目标题"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 编辑/预览切换 */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center 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="flex items-center">
|
||||||
|
<Button onClick={handleSave}>保存更改</Button>
|
||||||
|
</div>
|
||||||
|
</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]">
|
||||||
|
<CoreEditor
|
||||||
|
value={description.content}
|
||||||
|
onChange={(newVal) =>
|
||||||
|
setDescription({ ...description, content: newVal || "" })
|
||||||
|
}
|
||||||
|
language="markdown"
|
||||||
|
className="absolute inset-0 rounded-md border border-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{viewMode !== "edit" && (
|
||||||
|
<div className="prose dark:prose-invert">
|
||||||
|
<MdxPreview
|
||||||
|
source={description.content}
|
||||||
|
components={{ Accordion, VideoEmbed }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ScrollArea>
|
||||||
|
</PanelLayout>
|
||||||
|
);
|
||||||
|
}
|
169
src/components/creater/edit-detail-panel.tsx
Normal file
169
src/components/creater/edit-detail-panel.tsx
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Difficulty } from "@/generated/client";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { getProblemData } from "@/app/actions/getProblem";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
|
import { updateProblemDetail } from "@/components/creater/problem-maintain";
|
||||||
|
|
||||||
|
export default function EditDetailPanel({ problemId }: { problemId: string }) {
|
||||||
|
const [problemDetails, setProblemDetails] = useState({
|
||||||
|
displayId: 1000,
|
||||||
|
difficulty: "EASY" as Difficulty,
|
||||||
|
timeLimit: 1000,
|
||||||
|
memoryLimit: 134217728,
|
||||||
|
isPublished: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchData() {
|
||||||
|
try {
|
||||||
|
const problemData = await getProblemData(problemId);
|
||||||
|
setProblemDetails({
|
||||||
|
displayId: problemData.displayId,
|
||||||
|
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 (
|
||||||
|
<PanelLayout>
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<Card className="w-full rounded-none border-none bg-background">
|
||||||
|
<CardHeader className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>题目详情</span>
|
||||||
|
<Button type="button" onClick={handleSave}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="display-id">显示ID</Label>
|
||||||
|
<Input
|
||||||
|
id="display-id"
|
||||||
|
type="number"
|
||||||
|
value={problemDetails.displayId}
|
||||||
|
onChange={(e) => handleNumberInputChange(e, "displayId")}
|
||||||
|
placeholder="输入显示ID"
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<Label htmlFor="time-limit">时间限制 (ms)</Label>
|
||||||
|
<Input
|
||||||
|
id="time-limit"
|
||||||
|
type="number"
|
||||||
|
value={problemDetails.timeLimit}
|
||||||
|
onChange={(e) => handleNumberInputChange(e, "timeLimit")}
|
||||||
|
placeholder="输入时间限制"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="memory-limit">内存限制 (字节)</Label>
|
||||||
|
<Input
|
||||||
|
id="memory-limit"
|
||||||
|
type="number"
|
||||||
|
value={problemDetails.memoryLimit}
|
||||||
|
onChange={(e) => handleNumberInputChange(e, "memoryLimit")}
|
||||||
|
placeholder="输入内存限制"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<input
|
||||||
|
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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ScrollArea>
|
||||||
|
</PanelLayout>
|
||||||
|
);
|
||||||
|
}
|
210
src/components/creater/edit-solution-panel.tsx
Normal file
210
src/components/creater/edit-solution-panel.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Locale } from "@/generated/client";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import MdxPreview from "@/components/mdx-preview";
|
||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { Accordion } from "@/components/ui/accordion";
|
||||||
|
import { CoreEditor } from "@/components/core-editor";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { getProblemData } from "@/app/actions/getProblem";
|
||||||
|
import { VideoEmbed } from "@/components/content/video-embed";
|
||||||
|
import { getProblemLocales } from "@/app/actions/getProblemLocales";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
|
import { updateProblemSolution } from "@/components/creater/problem-maintain";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
export default function EditSolutionPanel({
|
||||||
|
problemId,
|
||||||
|
}: {
|
||||||
|
problemId: string;
|
||||||
|
}) {
|
||||||
|
const [locales, setLocales] = useState<string[]>([]);
|
||||||
|
const [currentLocale, setCurrentLocale] = useState<string>("");
|
||||||
|
const [customLocale, setCustomLocale] = useState("");
|
||||||
|
|
||||||
|
const [solution, setSolution] = useState({ title: "", content: "" });
|
||||||
|
const [viewMode, setViewMode] = useState<"edit" | "preview" | "compare">(
|
||||||
|
"edit"
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function fetchLocales() {
|
||||||
|
try {
|
||||||
|
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 (
|
||||||
|
<PanelLayout>
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<Card className="w-full rounded-none border-none bg-background">
|
||||||
|
<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">
|
||||||
|
<Label htmlFor="solution-title">题解标题</Label>
|
||||||
|
<Input
|
||||||
|
id="solution-title"
|
||||||
|
value={solution.title}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSolution({ ...solution, title: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder="输入题解标题"
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</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]">
|
||||||
|
<CoreEditor
|
||||||
|
value={solution.content}
|
||||||
|
onChange={(val) =>
|
||||||
|
setSolution({ ...solution, content: val || "" })
|
||||||
|
}
|
||||||
|
language="markdown"
|
||||||
|
className="absolute inset-0 rounded-md border border-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{viewMode !== "edit" && (
|
||||||
|
<div className="prose dark:prose-invert">
|
||||||
|
<MdxPreview
|
||||||
|
source={solution.content}
|
||||||
|
components={{ Accordion, VideoEmbed }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="button" onClick={handleSave}>
|
||||||
|
保存更改
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ScrollArea>
|
||||||
|
</PanelLayout>
|
||||||
|
);
|
||||||
|
}
|
278
src/components/creater/edit-testcase-panel.tsx
Normal file
278
src/components/creater/edit-testcase-panel.tsx
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import {
|
||||||
|
addProblemTestcase,
|
||||||
|
updateProblemTestcase,
|
||||||
|
deleteProblemTestcase,
|
||||||
|
} from "@/components/creater/problem-maintain";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import React, { useState, useEffect } from "react";
|
||||||
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
|
import { getProblemData } from "@/app/actions/getProblem";
|
||||||
|
import { generateAITestcase } from "@/app/actions/ai-testcase";
|
||||||
|
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
|
|
||||||
|
interface Testcase {
|
||||||
|
id: string;
|
||||||
|
expectedOutput: string;
|
||||||
|
inputs: { name: string; value: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditTestcasePanel({
|
||||||
|
problemId,
|
||||||
|
}: {
|
||||||
|
problemId: string;
|
||||||
|
}) {
|
||||||
|
const [testcases, setTestcases] = useState<Testcase[]>([]);
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// 本地添加测试用例
|
||||||
|
const handleAddTestcase = () =>
|
||||||
|
setTestcases((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `new-${Date.now()}-${Math.random()}`,
|
||||||
|
expectedOutput: "",
|
||||||
|
inputs: [{ name: "input1", value: "" }],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// AI 生成测试用例
|
||||||
|
const handleAITestcase = async () => {
|
||||||
|
setIsGenerating(true);
|
||||||
|
try {
|
||||||
|
const ai = await generateAITestcase({ problemId });
|
||||||
|
setTestcases((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
id: `new-${Date.now()}-${Math.random()}`,
|
||||||
|
expectedOutput: ai.expectedOutput,
|
||||||
|
inputs: ai.inputs,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
toast.error("AI 生成测试用例失败");
|
||||||
|
} finally {
|
||||||
|
setIsGenerating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 删除测试用例(本地 + 服务器)
|
||||||
|
const handleRemoveTestcase = async (idx: number) => {
|
||||||
|
const tc = testcases[idx];
|
||||||
|
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 handleExpectedOutputChange = (idx: number, val: string) =>
|
||||||
|
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 (
|
||||||
|
<PanelLayout>
|
||||||
|
<ScrollArea className="h-full">
|
||||||
|
<Card className="w-full rounded-none border-none bg-background">
|
||||||
|
<CardHeader className="px-6 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>测试用例</span>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button onClick={handleAITestcase} disabled={isGenerating}>
|
||||||
|
{isGenerating ? "生成中..." : "AI生成"}
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleAddTestcase}>添加</Button>
|
||||||
|
<Button variant="secondary" onClick={handleSaveAll}>
|
||||||
|
保存
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-6">
|
||||||
|
{testcases.map((tc, idx) => (
|
||||||
|
<div key={tc.id} className="border p-4 rounded space-y-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<h3 className="font-medium">测试用例 {idx + 1}</h3>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => handleRemoveTestcase(idx)}
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<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>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</ScrollArea>
|
||||||
|
</PanelLayout>
|
||||||
|
);
|
||||||
|
}
|
268
src/components/creater/problem-maintain.tsx
Normal file
268
src/components/creater/problem-maintain.tsx
Normal file
@ -0,0 +1,268 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
import prisma from "@/lib/prisma";
|
||||||
|
import { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
Difficulty,
|
||||||
|
Locale,
|
||||||
|
ProblemContentType,
|
||||||
|
Language,
|
||||||
|
} from "@/generated/client";
|
||||||
|
|
||||||
|
export async function updateProblemDetail(
|
||||||
|
problemId: string,
|
||||||
|
data: {
|
||||||
|
displayId?: number;
|
||||||
|
difficulty?: Difficulty;
|
||||||
|
timeLimit?: number;
|
||||||
|
memoryLimit?: number;
|
||||||
|
isPublished?: boolean;
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const updatedProblem = await prisma.problem.update({
|
||||||
|
where: { id: problemId },
|
||||||
|
data: {
|
||||||
|
displayId: data.displayId,
|
||||||
|
difficulty: data.difficulty,
|
||||||
|
timeLimit: data.timeLimit,
|
||||||
|
memoryLimit: data.memoryLimit,
|
||||||
|
isPublished: data.isPublished,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/problem-editor/${problemId}`);
|
||||||
|
return { success: true, problem: updatedProblem };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update problem detail:", error);
|
||||||
|
return { success: false, error: "Failed to update problem detail" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProblemDescription(
|
||||||
|
problemId: string,
|
||||||
|
locale: Locale,
|
||||||
|
content: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const updatedLocalization = await prisma.problemLocalization.upsert({
|
||||||
|
where: {
|
||||||
|
problemId_locale_type: {
|
||||||
|
problemId: problemId,
|
||||||
|
locale: locale,
|
||||||
|
type: ProblemContentType.DESCRIPTION,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
problemId: problemId,
|
||||||
|
locale: locale,
|
||||||
|
type: ProblemContentType.DESCRIPTION,
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/problem-editor/${problemId}`);
|
||||||
|
return { success: true, localization: updatedLocalization };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update problem description:", error);
|
||||||
|
return { success: false, error: "Failed to update problem description" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProblemSolution(
|
||||||
|
problemId: string,
|
||||||
|
locale: Locale,
|
||||||
|
content: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const updatedLocalization = await prisma.problemLocalization.upsert({
|
||||||
|
where: {
|
||||||
|
problemId_locale_type: {
|
||||||
|
problemId: problemId,
|
||||||
|
locale: locale,
|
||||||
|
type: ProblemContentType.SOLUTION,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
problemId: problemId,
|
||||||
|
locale: locale,
|
||||||
|
type: ProblemContentType.SOLUTION,
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/problem-editor/${problemId}`);
|
||||||
|
return { success: true, localization: updatedLocalization };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update problem solution:", error);
|
||||||
|
return { success: false, error: "Failed to update problem solution" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProblemTemplate(
|
||||||
|
problemId: string,
|
||||||
|
language: Language,
|
||||||
|
content: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const updatedTemplate = await prisma.template.upsert({
|
||||||
|
where: {
|
||||||
|
problemId_language: {
|
||||||
|
problemId: problemId,
|
||||||
|
language: language,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
problemId: problemId,
|
||||||
|
language: language,
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
content: content,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/problem-editor/${problemId}`);
|
||||||
|
return { success: true, template: updatedTemplate };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update problem template:", error);
|
||||||
|
return { success: false, error: "Failed to update problem template" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateProblemTestcase(
|
||||||
|
problemId: string,
|
||||||
|
testcaseId: string,
|
||||||
|
expectedOutput: string,
|
||||||
|
inputs: { name: string; value: string }[]
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Update testcase
|
||||||
|
const updatedTestcase = await prisma.testcase.update({
|
||||||
|
where: { id: testcaseId },
|
||||||
|
data: {
|
||||||
|
expectedOutput: expectedOutput,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete old inputs
|
||||||
|
await prisma.testcaseInput.deleteMany({
|
||||||
|
where: { testcaseId: testcaseId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create new inputs
|
||||||
|
const createdInputs = await prisma.testcaseInput.createMany({
|
||||||
|
data: inputs.map((input, index) => ({
|
||||||
|
testcaseId: testcaseId,
|
||||||
|
index: index,
|
||||||
|
name: input.name,
|
||||||
|
value: input.value,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/problem-editor/${problemId}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
testcase: updatedTestcase,
|
||||||
|
inputs: createdInputs,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update problem testcase:", error);
|
||||||
|
return { success: false, error: "Failed to update problem testcase" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addProblemTestcase(
|
||||||
|
problemId: string,
|
||||||
|
expectedOutput: string,
|
||||||
|
inputs: { name: string; value: string }[]
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
// Create testcase
|
||||||
|
const newTestcase = await prisma.testcase.create({
|
||||||
|
data: {
|
||||||
|
problemId: problemId,
|
||||||
|
expectedOutput: expectedOutput,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create inputs
|
||||||
|
const createdInputs = await prisma.testcaseInput.createMany({
|
||||||
|
data: inputs.map((input, index) => ({
|
||||||
|
testcaseId: newTestcase.id,
|
||||||
|
index: index,
|
||||||
|
name: input.name,
|
||||||
|
value: input.value,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/problem-editor/${problemId}`);
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
testcase: newTestcase,
|
||||||
|
inputs: createdInputs,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to add problem testcase:", error);
|
||||||
|
return { success: false, error: "Failed to add problem testcase" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteProblemTestcase(
|
||||||
|
problemId: string,
|
||||||
|
testcaseId: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const deletedTestcase = await prisma.testcase.delete({
|
||||||
|
where: { id: testcaseId },
|
||||||
|
});
|
||||||
|
|
||||||
|
revalidatePath(`/problem-editor/${problemId}`);
|
||||||
|
return { success: true, testcase: deletedTestcase };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to delete problem testcase:", error);
|
||||||
|
return { success: false, error: "Failed to delete problem testcase" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新题目标题(TITLE)
|
||||||
|
*/
|
||||||
|
export async function updateProblemTitle(
|
||||||
|
problemId: string,
|
||||||
|
locale: Locale,
|
||||||
|
title: string
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const updated = await prisma.problemLocalization.upsert({
|
||||||
|
where: {
|
||||||
|
problemId_locale_type: {
|
||||||
|
problemId,
|
||||||
|
locale,
|
||||||
|
type: ProblemContentType.TITLE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
problemId,
|
||||||
|
locale,
|
||||||
|
type: ProblemContentType.TITLE,
|
||||||
|
content: title,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
content: title,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
// 重新缓存编辑页
|
||||||
|
revalidatePath(`/problem-editor/${problemId}`);
|
||||||
|
return { success: true, localization: updated };
|
||||||
|
} catch (error) {
|
||||||
|
console.error("更新题目标题失败:", error);
|
||||||
|
return { success: false, error: "更新题目标题失败" };
|
||||||
|
}
|
||||||
|
}
|
@ -11,9 +11,9 @@ interface ProblemEditViewProps {
|
|||||||
|
|
||||||
export const ProblemEditView = ({ problemId }: ProblemEditViewProps) => {
|
export const ProblemEditView = ({ problemId }: ProblemEditViewProps) => {
|
||||||
const components: Record<string, React.ReactNode> = {
|
const components: Record<string, React.ReactNode> = {
|
||||||
|
detail: <EditDetailPanel problemId={problemId} />,
|
||||||
description: <EditDescriptionPanel problemId={problemId} />,
|
description: <EditDescriptionPanel problemId={problemId} />,
|
||||||
solution: <EditSolutionPanel problemId={problemId} />,
|
solution: <EditSolutionPanel problemId={problemId} />,
|
||||||
detail: <EditDetailPanel problemId={problemId} />,
|
|
||||||
code: <EditCodePanel problemId={problemId} />,
|
code: <EditCodePanel problemId={problemId} />,
|
||||||
testcase: <EditTestcasePanel problemId={problemId} />,
|
testcase: <EditTestcasePanel problemId={problemId} />,
|
||||||
};
|
};
|
||||||
|
@ -37,13 +37,7 @@ export const BotContent = async ({ problemId }: BotContentProps) => {
|
|||||||
|
|
||||||
const description = getLocalizedDescription(descriptions, locale as Locale);
|
const description = getLocalizedDescription(descriptions, locale as Locale);
|
||||||
|
|
||||||
return (
|
return <BotForm description={description} />;
|
||||||
<div className="relative flex-1">
|
|
||||||
<div className="absolute h-full w-full">
|
|
||||||
<BotForm description={description} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const BotContentSkeleton = () => {
|
export const BotContentSkeleton = () => {
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
BotContent,
|
BotContent,
|
||||||
BotContentSkeleton,
|
BotContentSkeleton,
|
||||||
} from "@/features/problems/bot/components/content";
|
} from "@/features/problems/bot/components/content";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
|
|
||||||
interface BotPanelProps {
|
interface BotPanelProps {
|
||||||
problemId: string;
|
problemId: string;
|
||||||
@ -10,10 +11,10 @@ interface BotPanelProps {
|
|||||||
|
|
||||||
export const BotPanel = ({ problemId }: BotPanelProps) => {
|
export const BotPanel = ({ problemId }: BotPanelProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
<PanelLayout>
|
||||||
<Suspense fallback={<BotContentSkeleton />}>
|
<Suspense fallback={<BotContentSkeleton />}>
|
||||||
<BotContent problemId={problemId} />
|
<BotContent problemId={problemId} />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
</div>
|
</PanelLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
CodeContent,
|
CodeContent,
|
||||||
CodeContentSkeleton,
|
CodeContentSkeleton,
|
||||||
} from "@/features/problems/code/components/content";
|
} from "@/features/problems/code/components/content";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
import { CodeFooter } from "@/features/problems/code/components/footer";
|
import { CodeFooter } from "@/features/problems/code/components/footer";
|
||||||
import { CodeToolbar } from "@/features/problems/code/components/toolbar/code-toolbar";
|
import { CodeToolbar } from "@/features/problems/code/components/toolbar/code-toolbar";
|
||||||
|
|
||||||
@ -12,16 +13,14 @@ interface CodePanelProps {
|
|||||||
|
|
||||||
export const CodePanel = ({ problemId }: CodePanelProps) => {
|
export const CodePanel = ({ problemId }: CodePanelProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
<PanelLayout>
|
||||||
<CodeToolbar className="border-b" />
|
<div className="h-full flex flex-col">
|
||||||
<div className="relative flex-1">
|
<CodeToolbar className="border-b" />
|
||||||
<div className="absolute h-full w-full">
|
<Suspense fallback={<CodeContentSkeleton />}>
|
||||||
<Suspense fallback={<CodeContentSkeleton />}>
|
<CodeContent problemId={problemId} />
|
||||||
<CodeContent problemId={problemId} />
|
</Suspense>
|
||||||
</Suspense>
|
<CodeFooter />
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<CodeFooter />
|
</PanelLayout>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
DescriptionContent,
|
DescriptionContent,
|
||||||
DescriptionContentSkeleton,
|
DescriptionContentSkeleton,
|
||||||
} from "@/features/problems/description/components/content";
|
} from "@/features/problems/description/components/content";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
|
|
||||||
interface DescriptionPanelProps {
|
interface DescriptionPanelProps {
|
||||||
problemId: string;
|
problemId: string;
|
||||||
@ -10,14 +11,10 @@ interface DescriptionPanelProps {
|
|||||||
|
|
||||||
export const DescriptionPanel = ({ problemId }: DescriptionPanelProps) => {
|
export const DescriptionPanel = ({ problemId }: DescriptionPanelProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
<PanelLayout>
|
||||||
<div className="relative flex-1">
|
<Suspense fallback={<DescriptionContentSkeleton />}>
|
||||||
<div className="absolute h-full w-full">
|
<DescriptionContent problemId={problemId} />
|
||||||
<Suspense fallback={<DescriptionContentSkeleton />}>
|
</Suspense>
|
||||||
<DescriptionContent problemId={problemId} />
|
</PanelLayout>
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
DetailContent,
|
DetailContent,
|
||||||
DetailContentSkeleton,
|
DetailContentSkeleton,
|
||||||
} from "@/features/problems/detail/components/content";
|
} from "@/features/problems/detail/components/content";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
import { DetailHeader } from "@/features/problems/detail/components/header";
|
import { DetailHeader } from "@/features/problems/detail/components/header";
|
||||||
|
|
||||||
interface DetailPanelProps {
|
interface DetailPanelProps {
|
||||||
@ -15,15 +16,11 @@ export const DetailPanel = ({ submissionId }: DetailPanelProps) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
<PanelLayout>
|
||||||
<DetailHeader />
|
<DetailHeader />
|
||||||
<div className="relative flex-1">
|
<Suspense fallback={<DetailContentSkeleton />}>
|
||||||
<div className="absolute h-full w-full">
|
<DetailContent submissionId={submissionId} />
|
||||||
<Suspense fallback={<DetailContentSkeleton />}>
|
</Suspense>
|
||||||
<DetailContent submissionId={submissionId} />
|
</PanelLayout>
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
13
src/features/problems/layouts/panel-layout.tsx
Normal file
13
src/features/problems/layouts/panel-layout.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
interface PanelLayoutProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const PanelLayout = ({ children }: PanelLayoutProps) => {
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<div className="absolute h-full w-full">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -3,6 +3,7 @@ import {
|
|||||||
SolutionContent,
|
SolutionContent,
|
||||||
SolutionContentSkeleton,
|
SolutionContentSkeleton,
|
||||||
} from "@/features/problems/solution/components/content";
|
} from "@/features/problems/solution/components/content";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
|
|
||||||
interface SolutionPanelProps {
|
interface SolutionPanelProps {
|
||||||
problemId: string;
|
problemId: string;
|
||||||
@ -10,14 +11,10 @@ interface SolutionPanelProps {
|
|||||||
|
|
||||||
export const SolutionPanel = ({ problemId }: SolutionPanelProps) => {
|
export const SolutionPanel = ({ problemId }: SolutionPanelProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
<PanelLayout>
|
||||||
<div className="relative flex-1">
|
<Suspense fallback={<SolutionContentSkeleton />}>
|
||||||
<div className="absolute h-full w-full">
|
<SolutionContent problemId={problemId} />
|
||||||
<Suspense fallback={<SolutionContentSkeleton />}>
|
</Suspense>
|
||||||
<SolutionContent problemId={problemId} />
|
</PanelLayout>
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
SubmissionContent,
|
SubmissionContent,
|
||||||
SubmissionContentSkeleton,
|
SubmissionContentSkeleton,
|
||||||
} from "@/features/problems/submission/components/content";
|
} from "@/features/problems/submission/components/content";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
|
|
||||||
interface SubmissionPanelProps {
|
interface SubmissionPanelProps {
|
||||||
problemId: string;
|
problemId: string;
|
||||||
@ -10,14 +11,10 @@ interface SubmissionPanelProps {
|
|||||||
|
|
||||||
export const SubmissionPanel = ({ problemId }: SubmissionPanelProps) => {
|
export const SubmissionPanel = ({ problemId }: SubmissionPanelProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
<PanelLayout>
|
||||||
<div className="relative flex-1">
|
<Suspense fallback={<SubmissionContentSkeleton />}>
|
||||||
<div className="absolute h-full w-full">
|
<SubmissionContent problemId={problemId} />
|
||||||
<Suspense fallback={<SubmissionContentSkeleton />}>
|
</Suspense>
|
||||||
<SubmissionContent problemId={problemId} />
|
</PanelLayout>
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -3,6 +3,7 @@ import {
|
|||||||
TestcaseContent,
|
TestcaseContent,
|
||||||
TestcaseContentSkeleton,
|
TestcaseContentSkeleton,
|
||||||
} from "@/features/problems/testcase/content";
|
} from "@/features/problems/testcase/content";
|
||||||
|
import { PanelLayout } from "@/features/problems/layouts/panel-layout";
|
||||||
|
|
||||||
interface TestcasePanelProps {
|
interface TestcasePanelProps {
|
||||||
problemId: string;
|
problemId: string;
|
||||||
@ -10,14 +11,10 @@ interface TestcasePanelProps {
|
|||||||
|
|
||||||
export const TestcasePanel = ({ problemId }: TestcasePanelProps) => {
|
export const TestcasePanel = ({ problemId }: TestcasePanelProps) => {
|
||||||
return (
|
return (
|
||||||
<div className="h-full flex flex-col border border-t-0 border-muted rounded-b-lg bg-background overflow-hidden">
|
<PanelLayout>
|
||||||
<div className="relative flex-1">
|
<Suspense fallback={<TestcaseContentSkeleton />}>
|
||||||
<div className="absolute h-full w-full">
|
<TestcaseContent problemId={problemId} />
|
||||||
<Suspense fallback={<TestcaseContentSkeleton />}>
|
</Suspense>
|
||||||
<TestcaseContent problemId={problemId} />
|
</PanelLayout>
|
||||||
</Suspense>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -144,6 +144,13 @@ const initialProblemEditFlexLayoutJsonModel: IJsonModel = {
|
|||||||
id: "1",
|
id: "1",
|
||||||
weight: 50,
|
weight: 50,
|
||||||
children: [
|
children: [
|
||||||
|
{
|
||||||
|
type: "tab",
|
||||||
|
id: "detail",
|
||||||
|
name: "Details",
|
||||||
|
component: "detail",
|
||||||
|
enableClose: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
type: "tab",
|
type: "tab",
|
||||||
id: "description",
|
id: "description",
|
||||||
@ -158,13 +165,6 @@ const initialProblemEditFlexLayoutJsonModel: IJsonModel = {
|
|||||||
component: "solution",
|
component: "solution",
|
||||||
enableClose: false,
|
enableClose: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
type: "tab",
|
|
||||||
id: "detail",
|
|
||||||
name: "Details",
|
|
||||||
component: "detail",
|
|
||||||
enableClose: false,
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
19
src/types/ai-improve.ts
Normal file
19
src/types/ai-improve.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
// 优化代码的输入类型
|
||||||
|
export const OptimizeCodeInputSchema = z.object({
|
||||||
|
code: z.string(), // 用户输入的代码
|
||||||
|
error: z.string().optional(), // 可选的错误信息
|
||||||
|
problemId: z.string().optional(), // 可选的题目ID
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OptimizeCodeInput = z.infer<typeof OptimizeCodeInputSchema>;
|
||||||
|
|
||||||
|
// 优化代码的输出类型
|
||||||
|
export const OptimizeCodeOutputSchema = z.object({
|
||||||
|
optimizedCode: z.string(), // 优化后的代码
|
||||||
|
explanation: z.string(), // 优化说明
|
||||||
|
issuesFixed: z.array(z.string()).optional(), // 修复的问题列表
|
||||||
|
});
|
||||||
|
|
||||||
|
export type OptimizeCodeOutput = z.infer<typeof OptimizeCodeOutputSchema>;
|
19
src/types/ai-testcase.ts
Normal file
19
src/types/ai-testcase.ts
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
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>;
|
Loading…
Reference in New Issue
Block a user