first commit
Some checks failed
Some checks failed
This commit is contained in:
585
app/testcase-generator/page.tsx
Normal file
585
app/testcase-generator/page.tsx
Normal file
@@ -0,0 +1,585 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import { TestTube, Loader2, Download, CheckCircle2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const API_PREFIX = "/api/testcase-generator";
|
||||
|
||||
interface GenerateRequest {
|
||||
title: string;
|
||||
englishName: string;
|
||||
description: string;
|
||||
standardCode: string;
|
||||
serviceLevel: string;
|
||||
testCaseCount: number;
|
||||
}
|
||||
|
||||
interface TaskStatusResponse {
|
||||
task_id: string;
|
||||
status: "pending" | "running" | "completed" | "failed";
|
||||
progress?: number;
|
||||
title?: string;
|
||||
english_name?: string;
|
||||
test_case_count?: number;
|
||||
generated_cases?: number;
|
||||
error_message?: string;
|
||||
currentMessage?: string;
|
||||
}
|
||||
|
||||
interface CompletedResult {
|
||||
title?: string;
|
||||
english_name?: string;
|
||||
test_case_count?: number;
|
||||
file_size?: string;
|
||||
}
|
||||
|
||||
export default function TestcaseGeneratorPage() {
|
||||
const [problemTitle, setProblemTitle] = useState("");
|
||||
const [problemSlug, setProblemSlug] = useState("");
|
||||
const [problemDescription, setProblemDescription] = useState("");
|
||||
const [stdProgram, setStdProgram] = useState("");
|
||||
const [serviceLevel, setServiceLevel] = useState<"standard" | "pro">("standard");
|
||||
const [testCaseCount, setTestCaseCount] = useState(10);
|
||||
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [progressOpen, setProgressOpen] = useState(false);
|
||||
const [progressTitle, setProgressTitle] = useState("正在生成测试数据");
|
||||
const [progressMessage, setProgressMessage] = useState("请稍候...");
|
||||
const [progressPercent, setProgressPercent] = useState(0);
|
||||
const [progressTaskId, setProgressTaskId] = useState("");
|
||||
const [progressGenerated, setProgressGenerated] = useState(0);
|
||||
const [progressTotal, setProgressTotal] = useState(0);
|
||||
const [progressRunningTime, setProgressRunningTime] = useState(0);
|
||||
const pollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const startTimeRef = useRef(0);
|
||||
|
||||
const [resultModalOpen, setResultModalOpen] = useState(false);
|
||||
const [resultData, setResultData] = useState<CompletedResult & { task_id?: string } | null>(null);
|
||||
const [errorBlock, setErrorBlock] = useState<{ title: string; message: string } | null>(null);
|
||||
|
||||
const slugPattern = /^[a-z][a-z0-9_]*$/;
|
||||
|
||||
const showProgress = useCallback(() => {
|
||||
setProgressOpen(true);
|
||||
setProgressPercent(0);
|
||||
setProgressGenerated(0);
|
||||
setProgressTotal(0);
|
||||
setProgressRunningTime(0);
|
||||
setProgressTaskId("");
|
||||
startTimeRef.current = Date.now();
|
||||
}, []);
|
||||
|
||||
const hideProgress = useCallback(() => {
|
||||
setProgressOpen(false);
|
||||
if (pollTimerRef.current) {
|
||||
clearTimeout(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateProgress = useCallback(
|
||||
(opts: {
|
||||
title?: string;
|
||||
message?: string;
|
||||
progress?: number;
|
||||
taskId?: string;
|
||||
generatedCount?: number;
|
||||
totalCount?: number;
|
||||
}) => {
|
||||
if (opts.title !== undefined) setProgressTitle(opts.title);
|
||||
if (opts.message !== undefined) setProgressMessage(opts.message);
|
||||
if (opts.progress !== undefined) setProgressPercent(Math.min(100, Math.max(0, opts.progress)));
|
||||
if (opts.taskId !== undefined) setProgressTaskId(opts.taskId);
|
||||
if (opts.generatedCount !== undefined) setProgressGenerated(opts.generatedCount);
|
||||
if (opts.totalCount !== undefined) setProgressTotal(opts.totalCount);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const pollTaskStatus = useCallback(
|
||||
async (taskId: string, problemTitle: string, estimatedTimeSec: number) => {
|
||||
const tick = () => {
|
||||
setProgressRunningTime(Math.floor((Date.now() - startTimeRef.current) / 1000));
|
||||
};
|
||||
const interval = setInterval(tick, 1000);
|
||||
|
||||
const check = async () => {
|
||||
try {
|
||||
const res = await fetch(`${API_PREFIX}/task_status/${taskId}`);
|
||||
const data: TaskStatusResponse = await res.json();
|
||||
updateProgress({
|
||||
progress: data.progress ?? 0,
|
||||
generatedCount: data.generated_cases ?? 0,
|
||||
totalCount: data.test_case_count ?? 0,
|
||||
message: data.currentMessage || "正在处理...",
|
||||
});
|
||||
|
||||
if (data.status === "completed") {
|
||||
clearInterval(interval);
|
||||
hideProgress();
|
||||
setIsGenerating(false);
|
||||
setResultData({
|
||||
title: data.title ?? problemTitle,
|
||||
english_name: data.english_name,
|
||||
test_case_count: data.test_case_count,
|
||||
file_size: "计算中...",
|
||||
task_id: taskId,
|
||||
});
|
||||
setResultModalOpen(true);
|
||||
setErrorBlock(null);
|
||||
toast.success("测试数据生成完成!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.status === "failed") {
|
||||
clearInterval(interval);
|
||||
hideProgress();
|
||||
setIsGenerating(false);
|
||||
setErrorBlock({
|
||||
title: "测试数据生成失败",
|
||||
message: data.error_message || "未知错误",
|
||||
});
|
||||
toast.error("测试数据生成失败");
|
||||
return;
|
||||
}
|
||||
|
||||
pollTimerRef.current = setTimeout(check, 2000);
|
||||
} catch (err) {
|
||||
clearInterval(interval);
|
||||
hideProgress();
|
||||
setIsGenerating(false);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error("获取任务状态失败: " + msg);
|
||||
}
|
||||
};
|
||||
|
||||
await check();
|
||||
},
|
||||
[updateProgress, hideProgress]
|
||||
);
|
||||
|
||||
const handleGenerate = useCallback(async () => {
|
||||
const title = problemTitle.trim();
|
||||
const slug = problemSlug.trim();
|
||||
const description = problemDescription.trim();
|
||||
const code = stdProgram.trim();
|
||||
|
||||
if (!title || !slug || !description || !code) {
|
||||
toast.error("请填写所有必填字段");
|
||||
return;
|
||||
}
|
||||
if (!slugPattern.test(slug)) {
|
||||
toast.error("题目英文名只能包含小写字母、数字和下划线,且必须以字母开头");
|
||||
return;
|
||||
}
|
||||
const count = Math.min(50, Math.max(1, testCaseCount));
|
||||
if (count !== testCaseCount) setTestCaseCount(count);
|
||||
|
||||
setIsGenerating(true);
|
||||
setErrorBlock(null);
|
||||
showProgress();
|
||||
updateProgress({ title: "正在生成测试数据", message: "正在初始化...", taskId: "" });
|
||||
|
||||
try {
|
||||
const body: GenerateRequest = {
|
||||
title,
|
||||
englishName: slug,
|
||||
description,
|
||||
standardCode: code,
|
||||
serviceLevel,
|
||||
testCaseCount: count,
|
||||
};
|
||||
const res = await fetch(`${API_PREFIX}/generate`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const result = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(result.error || result.message || "生成请求失败");
|
||||
}
|
||||
|
||||
if (result.success && result.task_id) {
|
||||
const estimatedTime = result.estimated_time ?? 60;
|
||||
updateProgress({
|
||||
taskId: result.task_id,
|
||||
title: `正在生成「${title}」的测试数据`,
|
||||
message: `预计需要 ${Math.ceil(estimatedTime / 60)} 分钟,请耐心等待...`,
|
||||
totalCount: count,
|
||||
});
|
||||
await pollTaskStatus(result.task_id, title, estimatedTime);
|
||||
} else {
|
||||
throw new Error(result.error || "未返回任务 ID");
|
||||
}
|
||||
} catch (err) {
|
||||
hideProgress();
|
||||
setIsGenerating(false);
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast.error("生成失败: " + msg);
|
||||
}
|
||||
}, [
|
||||
problemTitle,
|
||||
problemSlug,
|
||||
problemDescription,
|
||||
stdProgram,
|
||||
serviceLevel,
|
||||
testCaseCount,
|
||||
showProgress,
|
||||
updateProgress,
|
||||
hideProgress,
|
||||
pollTaskStatus,
|
||||
]);
|
||||
|
||||
const handleCancelGeneration = useCallback(() => {
|
||||
hideProgress();
|
||||
setIsGenerating(false);
|
||||
toast.info("生成已取消");
|
||||
}, [hideProgress]);
|
||||
|
||||
const handleDownload = useCallback((taskId: string) => {
|
||||
if (!taskId) return;
|
||||
const url = `${API_PREFIX}/download/${taskId}`;
|
||||
window.open(url, "_blank");
|
||||
toast.success("开始下载测试数据");
|
||||
}, []);
|
||||
|
||||
const handleCloseResultModal = useCallback(() => {
|
||||
setResultModalOpen(false);
|
||||
setResultData(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (pollTimerRef.current) clearTimeout(pollTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const runningTimeStr =
|
||||
progressRunningTime >= 60
|
||||
? `${Math.floor(progressRunningTime / 60)}分${progressRunningTime % 60}秒`
|
||||
: `${progressRunningTime}秒`;
|
||||
|
||||
return (
|
||||
<div className="space-y-8 max-w-3xl mx-auto">
|
||||
<div className="text-center space-y-2">
|
||||
<h1 className="text-3xl font-bold flex items-center justify-center gap-2">
|
||||
<TestTube className="h-8 w-8 text-purple-500" />
|
||||
测试数据生成
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
为信息学奥林匹克竞赛生成高质量测试数据
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 题目信息 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>题目信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
题目标题 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="例如:A+B Problem"
|
||||
value={problemTitle}
|
||||
onChange={(e) => setProblemTitle(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">简短描述题目名称</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
题目英文名 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
placeholder="例如:aplusb"
|
||||
value={problemSlug}
|
||||
onChange={(e) => setProblemSlug(e.target.value.toLowerCase())}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
用作文件名前缀,只能包含小写字母、数字和下划线,必须以字母开头
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
题目描述 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
placeholder={`请输入完整的题目描述,包括:
|
||||
- 题目背景和要求
|
||||
- 输入格式
|
||||
- 输出格式
|
||||
- 数据范围和约束条件
|
||||
- 样例输入输出`}
|
||||
className="min-h-[200px] resize-y"
|
||||
value={problemDescription}
|
||||
onChange={(e) => setProblemDescription(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
详细描述题目要求,包括输入输出格式、数据范围等
|
||||
</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 标准程序 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
标准程序 (C++) <span className="text-destructive">*</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<Label>C++ 标准程序代码</Label>
|
||||
<Textarea
|
||||
placeholder={`请输入 C++ 标准程序代码,例如:
|
||||
|
||||
#include <iostream>
|
||||
using namespace std;
|
||||
|
||||
int main() {
|
||||
int a, b;
|
||||
cin >> a >> b;
|
||||
cout << a + b << endl;
|
||||
return 0;
|
||||
}
|
||||
|
||||
注意:从标准输入读取数据,结果输出到标准输出。`}
|
||||
className="min-h-[280px] font-mono text-sm resize-y"
|
||||
value={stdProgram}
|
||||
onChange={(e) => setStdProgram(e.target.value)}
|
||||
/>
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 dark:bg-blue-950/30 dark:border-blue-800 p-4 text-sm text-blue-900 dark:text-blue-200">
|
||||
<p className="font-semibold mb-2">标程说明</p>
|
||||
<ul className="list-disc list-inside space-y-1 opacity-90">
|
||||
<li>标程用于为生成的输入数据 (.in) 生成正确的输出数据 (.out)</li>
|
||||
<li>请确保代码逻辑正确,能处理边界情况</li>
|
||||
<li>系统将使用 g++ 编译您的代码</li>
|
||||
<li>运行限制:内存 512MB,栈 512MB,时间 30 秒/测试点</li>
|
||||
</ul>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 生成配置 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>生成配置</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<div className="space-y-2">
|
||||
<Label>测试点数量</Label>
|
||||
<div className="flex items-center gap-4 flex-wrap">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={testCaseCount}
|
||||
onChange={(e) =>
|
||||
setTestCaseCount(parseInt(e.target.value, 10) || 10)
|
||||
}
|
||||
className="w-28"
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
建议 1–50 个测试点,数量越多生成时间越长
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>生成模式</Label>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setServiceLevel("standard")}
|
||||
className={`flex items-start gap-3 rounded-xl border-2 p-5 text-left transition-all ${
|
||||
serviceLevel === "standard"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="service_level"
|
||||
checked={serviceLevel === "standard"}
|
||||
readOnly
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold">标准版</p>
|
||||
<p className="text-sm text-muted-foreground">基础测试数据生成</p>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setServiceLevel("pro")}
|
||||
className={`flex items-start gap-3 rounded-xl border-2 p-5 text-left transition-all ${
|
||||
serviceLevel === "pro"
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border hover:border-muted-foreground/50"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="service_level"
|
||||
checked={serviceLevel === "pro"}
|
||||
readOnly
|
||||
className="mt-1"
|
||||
/>
|
||||
<div>
|
||||
<p className="font-semibold">专业版</p>
|
||||
<p className="text-sm text-muted-foreground">高质量智能生成</p>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 提交 */}
|
||||
<div className="flex justify-center">
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleGenerate}
|
||||
disabled={isGenerating}
|
||||
className="gap-2"
|
||||
>
|
||||
{isGenerating ? (
|
||||
<>
|
||||
<Loader2 className="h-5 w-5 animate-spin" />
|
||||
生成中...
|
||||
</>
|
||||
) : (
|
||||
"开始生成"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 错误信息块 */}
|
||||
{errorBlock && (
|
||||
<Card className="border-destructive/50 bg-destructive/5">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-full bg-destructive/10 p-3">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h4 className="font-semibold text-destructive">{errorBlock.title}</h4>
|
||||
<p className="text-sm text-muted-foreground mt-1">{errorBlock.message}</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-400 mt-4">
|
||||
建议:检查题目描述与标准程序是否完整正确,稍后重试或联系技术支持。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 进度遮罩 */}
|
||||
{progressOpen && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||
<Card className="w-full max-w-md shadow-xl">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="rounded-full bg-primary/10 p-3">
|
||||
<Loader2 className="h-6 w-6 text-primary animate-spin" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold">{progressTitle}</h3>
|
||||
<p className="text-sm text-muted-foreground">{progressMessage}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2 mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span>进度</span>
|
||||
<span>{Math.round(progressPercent)}%</span>
|
||||
</div>
|
||||
<div className="h-2 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-300"
|
||||
style={{ width: `${progressPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground space-y-1 mb-4">
|
||||
<div className="flex justify-between">
|
||||
<span>任务 ID</span>
|
||||
<span className="font-mono">{progressTaskId || "-"}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>已生成</span>
|
||||
<span>
|
||||
{progressGenerated}/{progressTotal}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span>运行时间</span>
|
||||
<span>{runningTimeStr}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" className="w-full" onClick={handleCancelGeneration}>
|
||||
取消生成
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 结果弹窗 */}
|
||||
<Dialog open={resultModalOpen} onOpenChange={(open) => !open && handleCloseResultModal()}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>生成完成</DialogTitle>
|
||||
</DialogHeader>
|
||||
{resultData && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 rounded-lg border border-green-200 bg-green-50 dark:bg-green-950/30 dark:border-green-800 p-4">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-600 shrink-0" />
|
||||
<div>
|
||||
<p className="font-semibold text-green-800 dark:text-green-200">
|
||||
测试数据已成功生成并打包为 ZIP 文件
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<span className="text-muted-foreground">题目名称</span>
|
||||
<span className="font-medium">{resultData.title ?? "—"}</span>
|
||||
<span className="text-muted-foreground">测试点数量</span>
|
||||
<span className="font-medium">{resultData.test_case_count ?? "—"} 个</span>
|
||||
<span className="text-muted-foreground">生成时间</span>
|
||||
<span className="font-medium">{new Date().toLocaleString("zh-CN")}</span>
|
||||
<span className="text-muted-foreground">文件大小</span>
|
||||
<span className="font-medium">{resultData.file_size ?? "—"}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="gap-2 flex-1"
|
||||
onClick={() => resultData.task_id && handleDownload(resultData.task_id)}
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
下载测试数据
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleCloseResultModal}>
|
||||
关闭
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user