update: uploads

This commit is contained in:
2026-03-06 15:52:34 +08:00
parent b1b14fd964
commit f9b9b821df
19 changed files with 1333 additions and 106 deletions

View File

@@ -1,5 +1,5 @@
import { Layout, Menu, message } from "antd";
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Navigate, Route, Routes, useLocation, useNavigate } from "react-router-dom";
import LoginModal from "./components/LoginModal";
import Categories from "./pages/Categories";
@@ -26,6 +26,14 @@ export default function App() {
const location = useLocation();
const [loggedIn, setLoggedIn] = useState(Boolean(localStorage.getItem("pb_token")));
useEffect(() => {
const handleUnauthorized = () => {
setLoggedIn(false);
};
window.addEventListener("pb-auth-unauthorized", handleUnauthorized);
return () => window.removeEventListener("pb-auth-unauthorized", handleUnauthorized);
}, []);
const selectedKey = useMemo(() => {
const hit = menuItems.find((m) => location.pathname.startsWith(m.key));
return hit ? [hit.key] : ["/dashboard"];

View File

@@ -12,4 +12,80 @@ api.interceptors.request.use((config) => {
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error?.response?.status === 401) {
localStorage.removeItem("pb_token");
window.dispatchEvent(new CustomEvent("pb-auth-unauthorized"));
}
return Promise.reject(error);
}
);
// 导入任务(持久化队列)类型与 API
export interface ImportJobItemOut {
id: number;
job_id: number;
seq: number;
filename: string;
stored_path: string;
status: string;
attempt: number;
error: string;
question_count: number;
started_at: string | null;
ended_at: string | null;
}
export interface ImportJobOut {
id: number;
status: string;
method: string;
total: number;
processed: number;
success_count: number;
failed_count: number;
current_index: number;
current_file: string;
error: string;
attempt: number;
created_at: string;
started_at: string | null;
ended_at: string | null;
updated_at: string;
items: ImportJobItemOut[];
}
export async function createImportJob(files: File[], method: "excel" | "ai"): Promise<ImportJobOut> {
const formData = new FormData();
formData.append("method", method);
files.forEach((file) => formData.append("files", file));
const { data } = await api.post<ImportJobOut>("/import/jobs", formData, {
headers: { "Content-Type": "multipart/form-data" }
});
return data;
}
export async function getImportJob(jobId: number): Promise<ImportJobOut> {
const { data } = await api.get<ImportJobOut>(`/import/jobs/${jobId}`);
return data;
}
export async function listImportJobs(status?: string): Promise<ImportJobOut[]> {
const params = status ? { status } : {};
const { data } = await api.get<ImportJobOut[]>("/import/jobs", { params });
return data;
}
export async function retryImportJob(jobId: number): Promise<ImportJobOut> {
const { data } = await api.post<ImportJobOut>(`/import/jobs/${jobId}/retry`);
return data;
}
export async function cancelImportJob(jobId: number): Promise<ImportJobOut> {
const { data } = await api.post<ImportJobOut>(`/import/jobs/${jobId}/cancel`);
return data;
}
export default api;

View File

@@ -1,92 +1,334 @@
import { Button, Card, Table, Tabs, Upload, message } from "antd";
import type { UploadProps } from "antd";
import { useState } from "react";
import api from "../api";
import { Button, Card, Progress, Select, Space, Table, Tag, Upload, message } from "antd";
import type { UploadFile, UploadProps } from "antd";
import { useCallback, useEffect, useRef, useState } from "react";
import {
createImportJob,
getImportJob,
listImportJobs,
retryImportJob,
cancelImportJob,
type ImportJobOut
} from "../api";
type ImportMethod = "excel" | "ai";
const POLL_INTERVAL_MS = 1500;
const TERMINAL_STATUSES = ["success", "failed", "cancelled"];
const IMPORT_METHOD_KEY = "pb_import_method";
function isTerminal(status: string) {
return TERMINAL_STATUSES.includes(status);
}
function loadStoredMethod(): ImportMethod {
try {
const v = sessionStorage.getItem(IMPORT_METHOD_KEY);
if (v === "excel" || v === "ai") return v;
} catch {
/* ignore */
}
return "ai";
}
export default function ImportPage() {
const [preview, setPreview] = useState<Record<string, string>[]>([]);
const [method, setMethod] = useState<ImportMethod>(loadStoredMethod);
const [jobs, setJobs] = useState<ImportJobOut[]>([]);
const [pendingFiles, setPendingFiles] = useState<UploadFile[]>([]);
const [loading, setLoading] = useState(false);
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const aiProps: UploadProps = {
name: "file",
customRequest: async ({ file, onSuccess, onError }) => {
// Persist method so remounts (e.g. Strict Mode, route switch) restore selection
const setMethodAndStore = useCallback((value: ImportMethod) => {
setMethod(value);
try {
sessionStorage.setItem(IMPORT_METHOD_KEY, value);
} catch {
/* ignore */
}
}, []);
const activeJobs = jobs.filter((j) => !isTerminal(j.status));
const hasActive = activeJobs.length > 0;
const pendingCount = pendingFiles.length;
const fetchJob = useCallback(async (jobId: number) => {
try {
const job = await getImportJob(jobId);
setJobs((prev) => {
const next = prev.map((j) => (j.id === jobId ? job : j));
if (!next.find((j) => j.id === jobId)) next.unshift(job);
return next;
});
return job;
} catch {
return null;
}
}, []);
const startPolling = useCallback(() => {
if (pollTimerRef.current) return;
pollTimerRef.current = setInterval(() => {
setJobs((prev) => {
const toPoll = prev.filter((j) => !isTerminal(j.status));
toPoll.forEach((j) => {
getImportJob(j.id).then((job) => {
setJobs((p) => p.map((x) => (x.id === job.id ? job : x)));
}).catch(() => {});
});
return prev;
});
}, POLL_INTERVAL_MS);
}, []);
const stopPolling = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
}, []);
useEffect(() => {
if (!hasActive) {
stopPolling();
return;
}
startPolling();
return () => { stopPolling(); };
}, [hasActive, startPolling, stopPolling]);
useEffect(() => {
let cancelled = false;
(async () => {
try {
setLoading(true);
const formData = new FormData();
formData.append("file", file as File);
const { data } = await api.post("/import/ai/parse", formData);
setPreview(data.preview || []);
message.success("AI 解析完成");
onSuccess?.({});
} catch (err) {
onError?.(err as Error);
} finally {
setLoading(false);
const list = await listImportJobs("queued,running");
if (!cancelled) {
setJobs((prev) => {
const byId = new Map(prev.map((j) => [j.id, j]));
list.forEach((j) => byId.set(j.id, j));
return Array.from(byId.values()).sort((a, b) => b.id - a.id);
});
}
} catch {
// ignore
}
})();
return () => { cancelled = true; };
}, []);
const uploadProps: UploadProps = {
multiple: true,
beforeUpload: () => false,
fileList: pendingFiles,
onChange: (info) => {
setPendingFiles(info.fileList.slice(-100));
},
disabled: loading
};
const startImport = async () => {
if (!pendingFiles.length) {
message.info("请先添加文档");
return;
}
const files = pendingFiles.map((f) => f.originFileObj).filter(Boolean) as File[];
if (!files.length) {
message.info("没有可上传的文件");
return;
}
setLoading(true);
try {
const job = await createImportJob(files, method);
setJobs((prev) => [job, ...prev]);
setPendingFiles([]);
message.success("任务已入队,将按顺序处理");
startPolling();
} catch (err: unknown) {
const detail = err && typeof err === "object" && "response" in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: "创建任务失败";
message.error(String(detail));
} finally {
setLoading(false);
}
};
const excelProps: UploadProps = {
name: "file",
customRequest: async ({ file, onSuccess, onError }) => {
try {
const formData = new FormData();
formData.append("file", file as File);
const { data } = await api.post("/import/excel", formData);
message.success(`Excel 导入成功,共 ${data.length}`);
onSuccess?.({});
} catch (err) {
onError?.(err as Error);
}
const handleRetry = async (jobId: number) => {
if (loading) return;
setLoading(true);
try {
const newJob = await retryImportJob(jobId);
setJobs((prev) => [newJob, ...prev]);
message.success("已创建重试任务");
startPolling();
} catch (err: unknown) {
const detail = err && typeof err === "object" && "response" in err
? (err as { response?: { data?: { detail?: string } } }).response?.data?.detail
: "重试失败";
message.error(String(detail));
} finally {
setLoading(false);
}
};
const confirmSave = async () => {
await api.post("/import/ai/confirm", preview);
message.success(`已保存 ${preview.length} 道题`);
setPreview([]);
const handleCancel = async (jobId: number) => {
if (loading) return;
setLoading(true);
try {
const job = await cancelImportJob(jobId);
setJobs((prev) => prev.map((j) => (j.id === jobId ? job : j)));
message.success("已取消任务");
} catch {
message.error("取消失败");
} finally {
setLoading(false);
}
};
const clearFinished = () => {
setJobs((prev) => prev.filter((j) => !isTerminal(j.status)));
};
const progressPercent = (job: ImportJobOut) =>
job.total ? Number(((job.processed / job.total) * 100).toFixed(2)) : 0;
const statusTag = (status: string) => {
if (status === "queued") return <Tag></Tag>;
if (status === "running") return <Tag color="processing"></Tag>;
if (status === "success") return <Tag color="success"></Tag>;
if (status === "failed") return <Tag color="error"></Tag>;
if (status === "cancelled") return <Tag></Tag>;
if (status === "retrying") return <Tag color="processing"></Tag>;
return <Tag>{status}</Tag>;
};
return (
<Tabs
items={[
{
key: "ai",
label: "AI 智能导入",
children: (
<Card>
<Upload {...aiProps} showUploadList={false}>
<Button type="primary" loading={loading}> PDF/Word </Button>
</Upload>
<Card title="导入中心">
<Space direction="vertical" size={16} style={{ width: "100%" }}>
<Space wrap>
<span></span>
<Select
value={method}
onChange={(v) => {
setMethodAndStore(v as ImportMethod);
}}
options={[
{ value: "excel", label: "Excel 批量导入" },
{ value: "ai", label: "AI 文档解析导入" }
]}
style={{ width: 220 }}
disabled={loading}
/>
<Upload {...uploadProps} showUploadList={{ showRemoveIcon: true }}>
<Button></Button>
</Upload>
<Button
type="primary"
onClick={startImport}
loading={loading}
disabled={!pendingCount}
>
{pendingCount}
</Button>
<Button onClick={clearFinished} disabled={loading}>
</Button>
</Space>
<div>
{activeJobs.length} | //
</div>
<Table
rowKey="id"
dataSource={jobs}
pagination={{ pageSize: 10 }}
loading={loading}
expandable={{
expandedRowRender: (record) => (
<Table
style={{ marginTop: 16 }}
rowKey={(_, index) => String(index)}
dataSource={preview}
size="small"
rowKey="id"
dataSource={record.items || []}
columns={[
{ title: "题干", dataIndex: "stem" },
{ title: "答案", dataIndex: "answer", width: 120 },
{ title: "题型", dataIndex: "question_type", width: 120 }
{ title: "序号", dataIndex: "seq", width: 60 },
{ title: "文件名", dataIndex: "filename" },
{
title: "状态",
dataIndex: "status",
width: 90,
render: (s: string) => statusTag(s)
},
{ title: "题目数", dataIndex: "question_count", width: 80 },
{ title: "错误", dataIndex: "error" }
]}
pagination={{ pageSize: 10 }}
pagination={false}
/>
<Button type="primary" onClick={confirmSave} disabled={!preview.length}>
</Button>
</Card>
)
},
{
key: "excel",
label: "Excel 导入",
children: (
<Card>
<Upload {...excelProps} showUploadList={false}>
<Button type="primary"> Excel </Button>
</Upload>
</Card>
)
}
]}
/>
)
}}
columns={[
{ title: "任务 ID", dataIndex: "id", width: 80 },
{
title: "状态",
dataIndex: "status",
width: 90,
render: (s: string) => statusTag(s)
},
{ title: "方式", dataIndex: "method", width: 80 },
{
title: "进度",
key: "progress",
width: 120,
render: (_: unknown, row: ImportJobOut) =>
`${row.processed}/${row.total}`
},
{
title: "进度条",
key: "progressBar",
width: 160,
render: (_: unknown, row: ImportJobOut) => (
<Progress
percent={progressPercent(row)}
size="small"
status={row.status === "failed" ? "exception" : undefined}
/>
)
},
{ title: "当前文件", dataIndex: "current_file", ellipsis: true },
{ title: "成功", dataIndex: "success_count", width: 70 },
{ title: "失败", dataIndex: "failed_count", width: 70 },
{ title: "错误", dataIndex: "error", ellipsis: true },
{
title: "操作",
width: 160,
render: (_: unknown, row: ImportJobOut) => (
<Space size="small">
{row.status === "failed" && row.failed_count > 0 && (
<Button
size="small"
disabled={loading}
onClick={() => handleRetry(row.id)}
>
</Button>
)}
{(row.status === "queued" || row.status === "running") && (
<Button
size="small"
danger
disabled={loading}
onClick={() => handleCancel(row.id)}
>
</Button>
)}
</Space>
)
}
]}
/>
</Space>
</Card>
);
}