552 行
21 KiB
TypeScript
552 行
21 KiB
TypeScript
"use client";
|
||
|
||
import Link from "next/link";
|
||
import { useEffect, useMemo, useState } from "react";
|
||
|
||
import { HintTip } from "@/components/hint-tip";
|
||
import { apiFetch } from "@/lib/api";
|
||
import { readToken } from "@/lib/auth";
|
||
import { useI18nText } from "@/lib/i18n";
|
||
import { formatUnixDateTime } from "@/lib/time";
|
||
import { Activity, HardDrive, Play, RefreshCw, Server } from "lucide-react";
|
||
|
||
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;
|
||
};
|
||
|
||
type MeProfile = {
|
||
username?: string;
|
||
};
|
||
|
||
function fmtTs(v: number | null | undefined): string {
|
||
return formatUnixDateTime(v);
|
||
}
|
||
|
||
type ImportJobOptions = {
|
||
mode?: string;
|
||
source?: string;
|
||
workers?: number;
|
||
target_total?: number;
|
||
local_pdf_dir?: string;
|
||
pdf_dir?: string;
|
||
};
|
||
|
||
function parseOptions(raw: string): ImportJobOptions | null {
|
||
if (!raw) return null;
|
||
try {
|
||
const parsed = JSON.parse(raw);
|
||
return typeof parsed === "object" && parsed !== null ? (parsed as ImportJobOptions) : null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function statusToneClass(raw: string): string {
|
||
const value = raw.toLowerCase();
|
||
if (value.includes("queue") || value === "queued" || value === "pending") {
|
||
return "mc-status-warning text-amber-700";
|
||
}
|
||
if (value.includes("run") || value === "running" || value === "processing") {
|
||
return "mc-status-running text-blue-700";
|
||
}
|
||
if (value.includes("success") || value === "done" || value === "completed") {
|
||
return "mc-status-success text-emerald-700";
|
||
}
|
||
if (value.includes("fail") || value === "failed" || value === "error") {
|
||
return "mc-status-danger text-red-700";
|
||
}
|
||
return "mc-status-muted text-zinc-700";
|
||
}
|
||
|
||
export default function ImportsPage() {
|
||
const { tx } = useI18nText();
|
||
const [token, setToken] = useState("");
|
||
const [checkingAdmin, setCheckingAdmin] = useState(true);
|
||
const [isAdmin, setIsAdmin] = useState(false);
|
||
const [runMode, setRunMode] = useState<"luogu" | "local_pdf_rag">("luogu");
|
||
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 [workers, setWorkers] = useState(3);
|
||
const [localPdfDir, setLocalPdfDir] = useState("/data/local_pdfs");
|
||
const [targetTotal, setTargetTotal] = useState(5000);
|
||
const [pageSize, setPageSize] = useState(50);
|
||
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 (tk: string) => {
|
||
const latest = await apiFetch<LatestResp>("/api/v1/import/jobs/latest", {}, tk);
|
||
setJob(latest.job ?? null);
|
||
setRunning(Boolean(latest.runner_running) || latest.job?.status === "running");
|
||
return latest.job;
|
||
};
|
||
|
||
const loadItems = async (tk: string, 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()}`, {}, tk);
|
||
setItems(data.items ?? []);
|
||
};
|
||
|
||
const refresh = async () => {
|
||
if (!isAdmin || !token) return;
|
||
setLoading(true);
|
||
setError("");
|
||
try {
|
||
const latestJob = await loadLatest(token);
|
||
if (latestJob) {
|
||
await loadItems(token, latestJob.id);
|
||
} else {
|
||
setItems([]);
|
||
}
|
||
} catch (e: unknown) {
|
||
setError(String(e));
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const runImport = async () => {
|
||
if (!isAdmin || !token) {
|
||
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
|
||
return;
|
||
}
|
||
setError("");
|
||
try {
|
||
const body: Record<string, unknown> = {
|
||
mode: runMode,
|
||
workers,
|
||
};
|
||
if (runMode === "luogu") {
|
||
body.clear_all_problems = clearAllBeforeRun;
|
||
} else {
|
||
body.local_pdf_dir = localPdfDir;
|
||
body.target_total = targetTotal;
|
||
}
|
||
|
||
await apiFetch<{ started: boolean }>(
|
||
"/api/v1/import/jobs/run",
|
||
{
|
||
method: "POST",
|
||
body: JSON.stringify(body),
|
||
},
|
||
token
|
||
);
|
||
await refresh();
|
||
} catch (e: unknown) {
|
||
setError(String(e));
|
||
}
|
||
};
|
||
|
||
const jobOpts = useMemo(() => parseOptions(job?.options_json ?? ""), [job?.options_json]);
|
||
|
||
useEffect(() => {
|
||
let canceled = false;
|
||
const init = async () => {
|
||
setCheckingAdmin(true);
|
||
const tk = readToken();
|
||
setToken(tk);
|
||
if (!tk) {
|
||
if (!canceled) {
|
||
setIsAdmin(false);
|
||
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
|
||
setCheckingAdmin(false);
|
||
}
|
||
return;
|
||
}
|
||
try {
|
||
const me = await apiFetch<MeProfile>("/api/v1/me", {}, tk);
|
||
const allowed = (me?.username ?? "") === "admin";
|
||
if (!canceled) {
|
||
setIsAdmin(allowed);
|
||
if (!allowed) {
|
||
setError(tx("仅管理员可查看平台管理信息", "Platform management is visible to admin only"));
|
||
} else {
|
||
setError("");
|
||
}
|
||
}
|
||
} catch (e: unknown) {
|
||
if (!canceled) {
|
||
setIsAdmin(false);
|
||
setError(String(e));
|
||
}
|
||
} finally {
|
||
if (!canceled) setCheckingAdmin(false);
|
||
}
|
||
};
|
||
void init();
|
||
return () => {
|
||
canceled = true;
|
||
};
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (!isAdmin || !token) return;
|
||
void refresh();
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isAdmin, token, pageSize, statusFilter]);
|
||
|
||
useEffect(() => {
|
||
if (!isAdmin || !token) return;
|
||
const timer = setInterval(() => {
|
||
void refresh();
|
||
}, running ? 3000 : 15000);
|
||
return () => clearInterval(timer);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [isAdmin, token, running, pageSize, statusFilter]);
|
||
|
||
if (checkingAdmin) {
|
||
return (
|
||
<main className="mx-auto max-w-7xl px-3 py-8 text-sm text-zinc-600">
|
||
{tx("正在校验管理员权限...", "Checking admin access...")}
|
||
</main>
|
||
);
|
||
}
|
||
|
||
if (!isAdmin) {
|
||
return (
|
||
<main className="mx-auto max-w-4xl px-3 py-8">
|
||
<h1 className="text-xl font-semibold">{tx("平台管理", "Platform Management")}</h1>
|
||
<p className="mt-3 text-sm text-red-600">
|
||
{error || tx("仅管理员可查看此页面", "This page is available for admin only")}
|
||
</p>
|
||
<div className="mt-4 flex flex-wrap gap-2 text-sm">
|
||
<Link className="rounded border bg-white px-3 py-2 hover:bg-zinc-50" href="/auth">
|
||
{tx("去登录", "Go to Sign In")}
|
||
</Link>
|
||
<Link className="rounded border bg-white px-3 py-2 hover:bg-zinc-50" href="/">
|
||
{tx("返回首页", "Back to Home")}
|
||
</Link>
|
||
</div>
|
||
</main>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl flex items-center gap-2">
|
||
<HardDrive size={24} />
|
||
{tx("题库导入/出题任务", "Import / Generation Jobs")}
|
||
</h1>
|
||
<HintTip title={tx("管理说明", "Management Guide")}>
|
||
{tx(
|
||
"该页面仅管理员可用。支持 Luogu 导入与本地 PDF + RAG 出题两种模式,建议先小规模验证再扩大批量。",
|
||
"Admin-only page. Supports Luogu import and Local PDF + RAG generation. Start with a small batch before scaling up."
|
||
)}
|
||
</HintTip>
|
||
</div>
|
||
|
||
<section className="mt-4 rounded-xl border bg-white p-4">
|
||
<h2 className="text-base font-medium">{tx("平台管理快捷入口(原 /admin139)", "Platform Shortcuts (moved from /admin139)")}</h2>
|
||
<p className="mt-1 text-xs text-zinc-600">
|
||
{tx(
|
||
"管理员凭据已配置,请使用授权账号登录。",
|
||
"Admin credentials are configured separately. Sign in with an authorized account."
|
||
)}
|
||
</p>
|
||
<div className="mt-3 grid gap-2 sm:grid-cols-2 lg:grid-cols-5">
|
||
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/auth">
|
||
{tx("登录入口", "Sign In")}
|
||
</Link>
|
||
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/admin-users">
|
||
{tx("用户积分管理", "User Rating")}
|
||
</Link>
|
||
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/admin-redeem">
|
||
{tx("积分兑换管理", "Redeem Config")}
|
||
</Link>
|
||
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/backend-logs">
|
||
{tx("后台日志队列", "Backend Logs")}
|
||
</Link>
|
||
<Link className="rounded border bg-zinc-50 px-3 py-2 text-sm hover:bg-zinc-100" href="/api-docs">
|
||
{tx("API 文档", "API Docs")}
|
||
</Link>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="mt-4 rounded-xl border bg-white p-4">
|
||
<div className="flex flex-wrap items-center gap-3">
|
||
<select
|
||
className="w-full rounded border px-2 py-2 text-sm sm:w-auto"
|
||
value={runMode}
|
||
onChange={(e) => setRunMode((e.target.value as "luogu" | "local_pdf_rag") ?? "luogu")}
|
||
disabled={loading || running}
|
||
>
|
||
<option value="luogu">{tx("来源:Luogu CSP J/S", "Source: Luogu CSP J/S")}</option>
|
||
<option value="local_pdf_rag">{tx("来源:本地 PDF + RAG + LLM 出题", "Source: Local PDF + RAG + LLM")}</option>
|
||
</select>
|
||
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
|
||
{tx("线程", "Workers")}
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={16}
|
||
className="w-full rounded border px-2 py-1 sm:w-20"
|
||
value={workers}
|
||
onChange={(e) => setWorkers(Math.max(1, Math.min(16, Number(e.target.value) || 1)))}
|
||
disabled={loading || running}
|
||
/>
|
||
</label>
|
||
{runMode === "local_pdf_rag" && (
|
||
<>
|
||
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
|
||
{tx("PDF目录", "PDF Dir")}
|
||
<input
|
||
className="w-full rounded border px-2 py-1 sm:w-64"
|
||
value={localPdfDir}
|
||
onChange={(e) => setLocalPdfDir(e.target.value)}
|
||
disabled={loading || running}
|
||
/>
|
||
</label>
|
||
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
|
||
{tx("目标题量", "Target Total")}
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={50000}
|
||
className="w-full rounded border px-2 py-1 sm:w-28"
|
||
value={targetTotal}
|
||
onChange={(e) =>
|
||
setTargetTotal(Math.max(1, Math.min(50000, Number(e.target.value) || 1)))
|
||
}
|
||
disabled={loading || running}
|
||
/>
|
||
</label>
|
||
</>
|
||
)}
|
||
<button
|
||
className="w-full rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50 sm:ml-auto sm:w-auto"
|
||
onClick={() => void runImport()}
|
||
disabled={loading || running}
|
||
>
|
||
{running ? (
|
||
<span className="flex items-center justify-center gap-2">
|
||
<Activity size={16} className="animate-spin" />
|
||
{tx("导入中...", "Importing...")}
|
||
</span>
|
||
) : (
|
||
<span className="flex items-center justify-center gap-2">
|
||
<Play size={16} />
|
||
{tx("启动导入任务", "Start Import Job")}
|
||
</span>
|
||
)}
|
||
</button>
|
||
{runMode === "luogu" && (
|
||
<label className="flex w-full items-center gap-2 text-sm sm:w-auto">
|
||
<input
|
||
type="checkbox"
|
||
checked={clearAllBeforeRun}
|
||
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
|
||
/>
|
||
{tx("启动前清空历史题库", "Clear old problem set before start")}
|
||
</label>
|
||
)}
|
||
<button className="rounded border px-3 py-2 text-sm flex items-center gap-2" onClick={() => void refresh()} disabled={loading}>
|
||
<RefreshCw size={14} className={loading ? "animate-spin" : ""} />
|
||
{tx("刷新", "Refresh")}
|
||
</button>
|
||
<span className={`text-sm flex items-center gap-1 ${running ? "text-emerald-700" : "text-zinc-600"}`}>
|
||
{running ? <Activity size={14} className="animate-pulse" /> : <Server size={14} />}
|
||
{running ? tx("运行中", "Running") : tx("空闲", "Idle")}
|
||
</span>
|
||
</div>
|
||
<div className="mt-2 text-xs text-zinc-500 inline-flex items-center gap-2">
|
||
<span>
|
||
{runMode === "luogu"
|
||
? tx("当前模式:Luogu 标签导入。", "Mode: Luogu tag import.")
|
||
: tx("当前模式:本地 PDF + RAG 出题。", "Mode: Local PDF + RAG generation.")}
|
||
</span>
|
||
<HintTip title={tx("模式说明", "Mode Notes")} align="left">
|
||
{runMode === "luogu"
|
||
? tx(
|
||
"抓取洛谷 CSP-J/CSP-S/NOIP 标签题。可按需选择启动前清空历史题库。",
|
||
"Fetch Luogu problems tagged CSP-J/CSP-S/NOIP. You can choose to clear historical problem sets before start."
|
||
)
|
||
: tx(
|
||
"从本地 PDF 提取文本做 RAG,再调用 LLM 生成题目。系统会按目标规模与去重策略补齐题库。",
|
||
"Extract text from local PDFs for RAG and generate problems with LLM. The system fills the set based on target size and dedupe strategy."
|
||
)}
|
||
</HintTip>
|
||
</div>
|
||
</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">{tx("最新任务", "Latest Job")}</h2>
|
||
{!job && <p className="mt-2 text-sm text-zinc-500">{tx("暂无任务记录", "No job records")}</p>}
|
||
{job && (
|
||
<div className="mt-3 space-y-2 text-sm">
|
||
<p>
|
||
{tx("任务", "Job")} #{job.id} · {tx("状态", "Status")}{" "}
|
||
<b className={statusToneClass(job.status)}>{job.status}</b> · {tx("触发方式", "Trigger")} {job.trigger}
|
||
</p>
|
||
<p className="text-zinc-600">
|
||
{tx("模式", "Mode")} {jobOpts?.mode || jobOpts?.source || "luogu"} · {tx("线程", "Workers")} {jobOpts?.workers ?? "-"}
|
||
{typeof jobOpts?.target_total === "number" && ` · ${tx("目标题量", "Target total")} ${jobOpts.target_total}`}
|
||
{(jobOpts?.local_pdf_dir || jobOpts?.pdf_dir) &&
|
||
` · ${tx("PDF目录", "PDF dir")} ${jobOpts.local_pdf_dir || jobOpts.pdf_dir}`}
|
||
</p>
|
||
<p>
|
||
{tx("总数", "Total")} {job.total_count},{tx("已处理", "Processed")} {job.processed_count},{tx("成功", "Success")} {job.success_count},{tx("失败", "Failed")} {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">
|
||
{tx("进度", "Progress")} {progress}% · {tx("开始", "Start")} {fmtTs(job.started_at)} · {tx("结束", "End")} {fmtTs(job.finished_at)}
|
||
</p>
|
||
{job.last_error && <p className="text-red-600">{tx("最近错误:", "Latest error: ")}{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">{tx("任务明细", "Job Items")}</h2>
|
||
<select
|
||
className="rounded border px-2 py-1 text-sm"
|
||
value={statusFilter}
|
||
onChange={(e) => setStatusFilter(e.target.value)}
|
||
>
|
||
<option value="">{tx("全部状态", "All Status")}</option>
|
||
<option value="queued">queued</option>
|
||
<option value="running">running</option>
|
||
<option value="success">success</option>
|
||
<option value="failed">failed</option>
|
||
<option value="skipped">skipped</option>
|
||
<option value="interrupted">interrupted</option>
|
||
</select>
|
||
<select
|
||
className="rounded border px-2 py-1 text-sm"
|
||
value={pageSize}
|
||
onChange={(e) => setPageSize(Number(e.target.value))}
|
||
>
|
||
<option value={50}>{tx("50 条", "50 rows")}</option>
|
||
<option value={100}>{tx("100 条", "100 rows")}</option>
|
||
<option value={200}>{tx("200 条", "200 rows")}</option>
|
||
</select>
|
||
</div>
|
||
|
||
<div className="mt-3 rounded-lg border">
|
||
<div className="divide-y md:hidden">
|
||
{items.map((item) => (
|
||
<article key={item.id} className="space-y-1 p-3 text-xs">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<p className="font-medium">
|
||
{tx("明细", "Detail")} #{item.id}
|
||
</p>
|
||
<span className={statusToneClass(item.status)}>{item.status}</span>
|
||
</div>
|
||
<p className="break-all text-zinc-600">{tx("路径:", "Path: ")}{item.source_path}</p>
|
||
<p className="text-zinc-600">{tx("标题:", "Title: ")}{item.title || "-"}</p>
|
||
<p className="text-zinc-600">
|
||
{tx("难度:", "Difficulty: ")}{item.difficulty || "-"} · {tx("题目ID:", "Problem ID: ")}{item.problem_id ?? "-"}
|
||
</p>
|
||
{item.error_text && <p className="break-words text-red-600">{tx("错误:", "Error: ")}{item.error_text}</p>}
|
||
</article>
|
||
))}
|
||
{items.length === 0 && <p className="px-3 py-4 text-center text-sm text-zinc-500">{tx("暂无明细", "No details")}</p>}
|
||
</div>
|
||
|
||
<div className="hidden overflow-x-auto md:block">
|
||
<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">{tx("路径", "Path")}</th>
|
||
<th className="px-2 py-2">{tx("状态", "Status")}</th>
|
||
<th className="px-2 py-2">{tx("标题", "Title")}</th>
|
||
<th className="px-2 py-2">{tx("难度", "Difficulty")}</th>
|
||
<th className="px-2 py-2">{tx("题目ID", "Problem ID")}</th>
|
||
<th className="px-2 py-2">{tx("错误", "Error")}</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 ${statusToneClass(item.status)}`}>{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}>
|
||
{tx("暂无明细", "No details")}
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main >
|
||
);
|
||
}
|