586 lines
21 KiB
TypeScript
586 lines
21 KiB
TypeScript
"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>
|
||
);
|
||
}
|