R92 A3-A4 优化发票上传状态与批量上传

这个提交包含在:
2026-05-05 22:16:35 +08:00
父节点 abba3371dd
当前提交 9d8c7e0478
共修改 6 个文件,包含 385 行新增和 122 行删除
+73 -12
查看文件
@@ -19,6 +19,7 @@ import {
unbindInvoice,
uploadInvoice,
type InvoicePoolStatus,
type UploadInvoiceResponse,
} from "../api/invoicePool";
import { fetchReimbursements } from "../api/reimbursements";
import { fetchTravelRequestDetail, fetchTravelRequests } from "../api/travelRequests";
@@ -37,6 +38,17 @@ import { InvoiceDetailPanel } from "./invoice-pool/InvoiceDetailPanel";
const PAGE_SIZE = 20;
type UploadBatchProgress = {
completed: number;
total: number;
currentFileName: string;
};
type UploadBatchResult = {
successes: Array<{ file: File; data: UploadInvoiceResponse }>;
failures: Array<{ file: File; message: string }>;
};
export function InvoicePoolPage() {
const queryClient = useQueryClient();
const { permissions, me } = useAuth();
@@ -51,7 +63,8 @@ export function InvoicePoolPage() {
const [status, setStatus] = useState<InvoicePoolStatus | "">("");
const [ownerScope, setOwnerScope] = useState<"own" | "all">("own");
const [page, setPage] = useState(1);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState<UploadBatchProgress | null>(null);
const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState("");
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
const [bindMode, setBindMode] = useState<InvoiceBindMode>("reimbursement");
@@ -85,6 +98,10 @@ export function InvoicePoolPage() {
page,
pageSize: PAGE_SIZE,
}),
refetchInterval: (query) => {
const items = query.state.data?.items ?? [];
return items.some((item) => item.status === "pending_ocr") ? 3_000 : false;
},
});
const reimbursementQuery = useQuery({
@@ -192,10 +209,14 @@ export function InvoicePoolPage() {
// --- Helper for error handling ---
const handleError = (err: unknown, fallback: string) => {
const msg = isApiError(err)
const toUserError = (err: unknown, fallback: string) => {
return isApiError(err)
? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`
: err instanceof Error ? err.message : fallback;
};
const handleError = (err: unknown, fallback: string) => {
const msg = toUserError(err, fallback);
setError(msg);
toast.error(msg);
};
@@ -204,18 +225,52 @@ export function InvoicePoolPage() {
const uploadMutation = useMutation({
mutationFn: async () => {
if (!selectedFile) {
if (!selectedFiles.length) {
throw new Error("请选择文件");
}
return uploadInvoice(selectedFile, "vat", uploadOwnerEmployeeId || undefined);
const files = [...selectedFiles];
const result: UploadBatchResult = { successes: [], failures: [] };
setUploadProgress({ completed: 0, total: files.length, currentFileName: files[0]?.name ?? "" });
for (const [index, file] of files.entries()) {
setUploadProgress({ completed: index, total: files.length, currentFileName: file.name });
try {
const data = await uploadInvoice(file, "vat", uploadOwnerEmployeeId || undefined);
result.successes.push({ file, data });
} catch (err) {
result.failures.push({ file, message: toUserError(err, "上传失败,请稍后重试") });
}
setUploadProgress({ completed: index + 1, total: files.length, currentFileName: file.name });
}
return result;
},
onSuccess: async (data) => {
onSuccess: async (result) => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setDetailInvoiceId(data.invoicePoolId);
setSelectedFile(null);
const lastSuccess = result.successes.at(-1);
if (lastSuccess) {
setDetailInvoiceId(lastSuccess.data.invoicePoolId);
}
setSelectedFiles(result.failures.map((item) => item.file));
setUploadProgress(null);
if (result.failures.length) {
const message = result.failures.map((item) => `${item.file.name}: ${item.message}`).join(";");
setError(message);
if (result.successes.length) {
toast.error(`已上传 ${result.successes.length} 个,${result.failures.length} 个失败`);
} else {
toast.error("上传失败,请检查文件或稍后重试");
}
return;
}
setError("");
toast.success(data.status === "pending_ocr" ? "上传成功,OCR 识别中" : "上传成功,可直接处理");
const hasPending = result.successes.some((item) => item.data.status === "pending_ocr");
toast.success(hasPending ? `已上传 ${result.successes.length} 个,OCR 识别中` : `已上传 ${result.successes.length} 个,可直接处理`);
},
onSettled: () => setUploadProgress(null),
onError: (err) => handleError(err, "上传失败,请稍后重试"),
});
@@ -422,9 +477,15 @@ export function InvoicePoolPage() {
) : null}
<InvoiceUploadCard
uploadMutation={uploadMutation}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
isUploading={uploadMutation.isPending}
uploadProgressLabel={
uploadProgress
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
: "上传中..."
}
onUpload={() => uploadMutation.mutate()}
showProxyOwnerSelector={showProxyOwnerSelector}
proxyDelegationsQuery={proxyDelegationsQuery}
uploadOwnerEmployeeId={uploadOwnerEmployeeId}