update: uploads
This commit is contained in:
@@ -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"];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user