first commit
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

This commit is contained in:
2026-01-30 16:57:44 +08:00
commit 3d175d75af
119 changed files with 35834 additions and 0 deletions

View 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">
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>
);
}