feat: expand platform management, admin controls, and learning workflows
这个提交包含在:
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户