feat: rebuild CSP practice workflow, UX and automation
这个提交包含在:
260
frontend/src/app/imports/page.tsx
普通文件
260
frontend/src/app/imports/page.tsx
普通文件
@@ -0,0 +1,260 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
type ImportJob = {
|
||||
id: number;
|
||||
status: string;
|
||||
trigger: string;
|
||||
total_count: number;
|
||||
processed_count: number;
|
||||
success_count: number;
|
||||
failed_count: number;
|
||||
options_json: string;
|
||||
last_error: string;
|
||||
started_at: number;
|
||||
finished_at: number | null;
|
||||
updated_at: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type ImportItem = {
|
||||
id: number;
|
||||
job_id: number;
|
||||
source_path: string;
|
||||
status: string;
|
||||
title: string;
|
||||
difficulty: number;
|
||||
problem_id: number | null;
|
||||
error_text: string;
|
||||
started_at: number | null;
|
||||
finished_at: number | null;
|
||||
updated_at: number;
|
||||
created_at: number;
|
||||
};
|
||||
|
||||
type LatestResp = {
|
||||
runner_running: boolean;
|
||||
job: ImportJob | null;
|
||||
};
|
||||
|
||||
type ItemsResp = {
|
||||
items: ImportItem[];
|
||||
page: number;
|
||||
page_size: number;
|
||||
};
|
||||
|
||||
function fmtTs(v: number | null | undefined): string {
|
||||
if (!v) return "-";
|
||||
return new Date(v * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export default function ImportsPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [running, setRunning] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [job, setJob] = useState<ImportJob | null>(null);
|
||||
const [items, setItems] = useState<ImportItem[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState("");
|
||||
const [pageSize, setPageSize] = useState(100);
|
||||
const [clearAllBeforeRun, setClearAllBeforeRun] = useState(true);
|
||||
|
||||
const progress = useMemo(() => {
|
||||
if (!job || job.total_count <= 0) return 0;
|
||||
return Math.min(100, Math.floor((job.processed_count / job.total_count) * 100));
|
||||
}, [job]);
|
||||
|
||||
const loadLatest = async () => {
|
||||
const latest = await apiFetch<LatestResp>("/api/v1/import/jobs/latest");
|
||||
setJob(latest.job ?? null);
|
||||
setRunning(Boolean(latest.runner_running) || latest.job?.status === "running");
|
||||
return latest.job;
|
||||
};
|
||||
|
||||
const loadItems = async (jobId: number) => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("page", "1");
|
||||
params.set("page_size", String(pageSize));
|
||||
if (statusFilter) params.set("status", statusFilter);
|
||||
const data = await apiFetch<ItemsResp>(`/api/v1/import/jobs/${jobId}/items?${params.toString()}`);
|
||||
setItems(data.items ?? []);
|
||||
};
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const latestJob = await loadLatest();
|
||||
if (latestJob) {
|
||||
await loadItems(latestJob.id);
|
||||
} else {
|
||||
setItems([]);
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runImport = async () => {
|
||||
setError("");
|
||||
try {
|
||||
await apiFetch<{ started: boolean }>("/api/v1/import/jobs/run", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ clear_all_problems: clearAllBeforeRun }),
|
||||
});
|
||||
await refresh();
|
||||
} catch (e: unknown) {
|
||||
setError(String(e));
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pageSize, statusFilter]);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => {
|
||||
void refresh();
|
||||
}, running ? 3000 : 15000);
|
||||
return () => clearInterval(timer);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [running, pageSize, statusFilter]);
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||||
<h1 className="text-2xl font-semibold">题库导入任务(Luogu CSP J/S)</h1>
|
||||
|
||||
<div className="mt-4 rounded-xl border bg-white p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<button
|
||||
className="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
|
||||
onClick={() => void runImport()}
|
||||
disabled={loading || running}
|
||||
>
|
||||
{running ? "导入中..." : "启动导入任务"}
|
||||
</button>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={clearAllBeforeRun}
|
||||
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
|
||||
/>
|
||||
启动前清空历史题库
|
||||
</label>
|
||||
<button className="rounded border px-3 py-2 text-sm" onClick={() => void refresh()} disabled={loading}>
|
||||
刷新
|
||||
</button>
|
||||
<span className={`text-sm ${running ? "text-emerald-700" : "text-zinc-600"}`}>
|
||||
{running ? "运行中" : "空闲"}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-zinc-500">
|
||||
默认按后端配置以 3 线程执行,抓取洛谷 CSP-J/CSP-S/NOIP 标签题;容器重启后会自动触发导入(可通过环境变量关闭)。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-600">{error}</p>}
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<h2 className="text-lg font-medium">最新任务</h2>
|
||||
{!job && <p className="mt-2 text-sm text-zinc-500">暂无任务记录</p>}
|
||||
{job && (
|
||||
<div className="mt-3 space-y-2 text-sm">
|
||||
<p>
|
||||
任务 #{job.id} · 状态 <b>{job.status}</b> · 触发方式 {job.trigger}
|
||||
</p>
|
||||
<p>
|
||||
总数 {job.total_count},已处理 {job.processed_count},成功 {job.success_count},失败 {job.failed_count}
|
||||
</p>
|
||||
<div className="h-2 w-full rounded bg-zinc-100">
|
||||
<div className="h-2 rounded bg-emerald-500" style={{ width: `${progress}%` }} />
|
||||
</div>
|
||||
<p className="text-zinc-600">
|
||||
进度 {progress}% · 开始 {fmtTs(job.started_at)} · 结束 {fmtTs(job.finished_at)}
|
||||
</p>
|
||||
{job.last_error && <p className="text-red-600">最近错误:{job.last_error}</p>}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<h2 className="text-lg font-medium">任务明细</h2>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="queued">queued</option>
|
||||
<option value="running">running</option>
|
||||
<option value="success">success</option>
|
||||
<option value="failed">failed</option>
|
||||
</select>
|
||||
<select
|
||||
className="rounded border px-2 py-1 text-sm"
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||
>
|
||||
<option value={50}>50 条</option>
|
||||
<option value={100}>100 条</option>
|
||||
<option value={200}>200 条</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="min-w-full text-xs">
|
||||
<thead className="bg-zinc-100 text-left">
|
||||
<tr>
|
||||
<th className="px-2 py-2">ID</th>
|
||||
<th className="px-2 py-2">路径</th>
|
||||
<th className="px-2 py-2">状态</th>
|
||||
<th className="px-2 py-2">标题</th>
|
||||
<th className="px-2 py-2">难度</th>
|
||||
<th className="px-2 py-2">题目ID</th>
|
||||
<th className="px-2 py-2">错误</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-t align-top">
|
||||
<td className="px-2 py-2">{item.id}</td>
|
||||
<td className="max-w-[400px] px-2 py-2">
|
||||
<div className="truncate" title={item.source_path}>
|
||||
{item.source_path}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">{item.status}</td>
|
||||
<td className="max-w-[220px] px-2 py-2">
|
||||
<div className="truncate" title={item.title}>
|
||||
{item.title || "-"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-2 py-2">{item.difficulty || "-"}</td>
|
||||
<td className="px-2 py-2">{item.problem_id ?? "-"}</td>
|
||||
<td className="max-w-[320px] px-2 py-2 text-red-600">
|
||||
<div className="truncate" title={item.error_text}>
|
||||
{item.error_text || "-"}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{items.length === 0 && (
|
||||
<tr>
|
||||
<td className="px-2 py-4 text-center text-zinc-500" colSpan={7}>
|
||||
暂无明细
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
在新工单中引用
屏蔽一个用户