"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(null); const [items, setItems] = useState([]); 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("/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(`/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 = { 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("/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 (
{tx("正在校验管理员权限...", "Checking admin access...")}
); } if (!isAdmin) { return (

{tx("平台管理", "Platform Management")}

{error || tx("仅管理员可查看此页面", "This page is available for admin only")}

{tx("去登录", "Go to Sign In")} {tx("返回首页", "Back to Home")}
); } return (

{tx("题库导入/出题任务", "Import / Generation Jobs")}

{tx( "该页面仅管理员可用。支持 Luogu 导入与本地 PDF + RAG 出题两种模式,建议先小规模验证再扩大批量。", "Admin-only page. Supports Luogu import and Local PDF + RAG generation. Start with a small batch before scaling up." )}

{tx("平台管理快捷入口(原 /admin139)", "Platform Shortcuts (moved from /admin139)")}

{tx( "管理员凭据已配置,请使用授权账号登录。", "Admin credentials are configured separately. Sign in with an authorized account." )}

{tx("登录入口", "Sign In")} {tx("用户积分管理", "User Rating")} {tx("积分兑换管理", "Redeem Config")} {tx("后台日志队列", "Backend Logs")} {tx("API 文档", "API Docs")}
{runMode === "local_pdf_rag" && ( <> )} {runMode === "luogu" && ( )} {running ? : } {running ? tx("运行中", "Running") : tx("空闲", "Idle")}
{runMode === "luogu" ? tx("当前模式:Luogu 标签导入。", "Mode: Luogu tag import.") : tx("当前模式:本地 PDF + RAG 出题。", "Mode: Local PDF + RAG generation.")} {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." )}
{error &&

{error}

}

{tx("最新任务", "Latest Job")}

{!job &&

{tx("暂无任务记录", "No job records")}

} {job && (

{tx("任务", "Job")} #{job.id} · {tx("状态", "Status")}{" "} {job.status} · {tx("触发方式", "Trigger")} {job.trigger}

{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}`}

{tx("总数", "Total")} {job.total_count},{tx("已处理", "Processed")} {job.processed_count},{tx("成功", "Success")} {job.success_count},{tx("失败", "Failed")} {job.failed_count}

{tx("进度", "Progress")} {progress}% · {tx("开始", "Start")} {fmtTs(job.started_at)} · {tx("结束", "End")} {fmtTs(job.finished_at)}

{job.last_error &&

{tx("最近错误:", "Latest error: ")}{job.last_error}

}
)}

{tx("任务明细", "Job Items")}

{items.map((item) => (

{tx("明细", "Detail")} #{item.id}

{item.status}

{tx("路径:", "Path: ")}{item.source_path}

{tx("标题:", "Title: ")}{item.title || "-"}

{tx("难度:", "Difficulty: ")}{item.difficulty || "-"} · {tx("题目ID:", "Problem ID: ")}{item.problem_id ?? "-"}

{item.error_text &&

{tx("错误:", "Error: ")}{item.error_text}

}
))} {items.length === 0 &&

{tx("暂无明细", "No details")}

}
{items.map((item) => ( ))} {items.length === 0 && ( )}
ID {tx("路径", "Path")} {tx("状态", "Status")} {tx("标题", "Title")} {tx("难度", "Difficulty")} {tx("题目ID", "Problem ID")} {tx("错误", "Error")}
{item.id}
{item.source_path}
{item.status}
{item.title || "-"}
{item.difficulty || "-"} {item.problem_id ?? "-"}
{item.error_text || "-"}
{tx("暂无明细", "No details")}
); }