feat: expand platform management, admin controls, and learning workflows

这个提交包含在:
Codex CLI
2026-02-15 15:41:56 +08:00
父节点 ad29a9f62d
当前提交 f209ae82da
修改 75 个文件,包含 9663 行新增794 行删除

查看文件

@@ -1,8 +1,11 @@
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { apiFetch } from "@/lib/api";
import { readToken } from "@/lib/auth";
import { useI18nText } from "@/lib/i18n";
type ImportJob = {
id: number;
@@ -46,19 +49,50 @@ type ItemsResp = {
page_size: number;
};
type MeProfile = {
username?: string;
};
function fmtTs(v: number | null | undefined): string {
if (!v) return "-";
return new Date(v * 1000).toLocaleString();
}
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;
}
}
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 [pageSize, setPageSize] = useState(100);
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(() => {
@@ -66,29 +100,30 @@ export default function ImportsPage() {
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");
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 (jobId: number) => {
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()}`);
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();
const latestJob = await loadLatest(token);
if (latestJob) {
await loadItems(latestJob.id);
await loadItems(token, latestJob.id);
} else {
setItems([]);
}
@@ -100,159 +135,369 @@ export default function ImportsPage() {
};
const runImport = async () => {
if (!isAdmin || !token) {
setError(tx("请先登录管理员账号", "Please sign in with admin account first"));
return;
}
setError("");
try {
await apiFetch<{ started: boolean }>("/api/v1/import/jobs/run", {
method: "POST",
body: JSON.stringify({ clear_all_problems: clearAllBeforeRun }),
});
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));
}
};
useEffect(() => {
void refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageSize, statusFilter]);
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
}, [running, pageSize, statusFilter]);
}, [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-6 py-8">
<h1 className="text-2xl font-semibold">Luogu CSP J/S</h1>
<main className="mx-auto max-w-7xl px-3 py-6 max-[390px]:px-2 sm:px-4 md:px-6 md:py-8">
<h1 className="text-xl font-semibold max-[390px]:text-lg sm:text-2xl">
{tx("题库导入/出题任务", "Import / Generation Jobs")}
</h1>
<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 / whoami139", "Default admin account: admin / whoami139")}
</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="rounded bg-zinc-900 px-4 py-2 text-white disabled:opacity-50"
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 ? "导入中..." : "启动导入任务"}
{running ? tx("导入中...", "Importing...") : tx("启动导入任务", "Start Import Job")}
</button>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={clearAllBeforeRun}
onChange={(e) => setClearAllBeforeRun(e.target.checked)}
/>
</label>
{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" onClick={() => void refresh()} disabled={loading}>
{tx("刷新", "Refresh")}
</button>
<span className={`text-sm ${running ? "text-emerald-700" : "text-zinc-600"}`}>
{running ? "运行中" : "空闲"}
{running ? tx("运行中", "Running") : tx("空闲", "Idle")}
</span>
</div>
<p className="mt-2 text-xs text-zinc-500">
3 线 CSP-J/CSP-S/NOIP
</p>
{runMode === "luogu" && (
<p className="mt-2 text-xs text-zinc-500">
{tx(
"抓取洛谷 CSP-J/CSP-S/NOIP 标签题;容器重启后可自动触发(可通过环境变量关闭)。",
"Fetch Luogu problems tagged CSP-J/CSP-S/NOIP. It can auto-start after container restart (configurable via env)."
)}
</p>
)}
{runMode === "local_pdf_rag" && (
<p className="mt-2 text-xs text-zinc-500">
{tx(
"从本地 PDF 提取文本做 RAG,调用 LLM 生成 CSP-J/S 题目,按现有题库难度分布补齐到目标题量并自动去重跳过。",
"Extract text from local PDFs for RAG, then call LLM to generate CSP-J/S problems with dedupe and target distribution."
)}
</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>}
<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>
#{job.id} · <b>{job.status}</b> · {job.trigger}
{tx("任务", "Job")} #{job.id} · {tx("状态", "Status")} <b>{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>
{job.total_count} {job.processed_count} {job.success_count} {job.failed_count}
{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">
{progress}% · {fmtTs(job.started_at)} · {fmtTs(job.finished_at)}
{tx("进度", "Progress")} {progress}% · {tx("开始", "Start")} {fmtTs(job.started_at)} · {tx("结束", "End")} {fmtTs(job.finished_at)}
</p>
{job.last_error && <p className="text-red-600">{job.last_error}</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"></h2>
<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=""></option>
<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}>50 </option>
<option value={100}>100 </option>
<option value={200}>200 </option>
<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 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 && (
<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>{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>
<td className="px-2 py-4 text-center text-zinc-500" colSpan={7}>
</td>
<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>
)}
</tbody>
</table>
</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}>
{tx("暂无明细", "No details")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</section>
</main>