Files
i-tools/app/testcase-generator/page.tsx
yfan 3d175d75af
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Sync to CNB / sync (push) Has been cancelled
Delete old workflow runs / del_runs (push) Has been cancelled
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
first commit
2026-01-30 16:57:44 +08:00

586 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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">
150
</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>
);
}