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

这个提交包含在:
2026-05-05 22:16:35 +08:00
父节点 abba3371dd
当前提交 9d8c7e0478
共修改 6 个文件,包含 385 行新增和 122 行删除
+127 -64
查看文件
@@ -124,9 +124,10 @@ export function ReimbursementDetailPage() {
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
const [invoiceSearch, setInvoiceSearch] = useState("");
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFile, setSelectedInvoiceFile] = useState<File | null>(null);
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [pendingOcrInvoiceId, setPendingOcrInvoiceId] = useState<string | null>(null);
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
expenseTypeId: "",
expenseDate: "",
@@ -185,6 +186,7 @@ export function ReimbursementDetailPage() {
search: invoiceSearch || undefined,
limit: 20,
}),
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
useEffect(() => {
@@ -442,8 +444,8 @@ export function ReimbursementDetailPage() {
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoices({});
setSelectedInvoiceFile(null);
setPendingOcrInvoiceId(null);
setSelectedInvoiceFiles([]);
setPendingOcrInvoiceIds([]);
toast.success(`已绑定 ${succeededIds.length} 张发票`);
return;
}
@@ -484,70 +486,113 @@ export function ReimbursementDetailPage() {
});
const uploadInvoiceMutation = useMutation({
mutationFn: async (file: File) => {
mutationFn: async (files: File[]) => {
const ownerEmployeeId = detailQuery.data?.ownerId;
if (!ownerEmployeeId) {
throw new Error("missing ownerEmployeeId");
}
return uploadInvoice(file, "vat", ownerEmployeeId);
if (!files.length) {
throw new Error("请选择发票文件");
}
const uploadFailures: Array<{ file: File; message: string }> = [];
const pendingById = new Map<string, string>();
const availableIds: string[] = [];
const ocrFailures: string[] = [];
setInvoiceUploadProgress({ completed: 0, total: files.length });
for (const [index, file] of files.entries()) {
try {
const result = await uploadInvoice(file, "vat", ownerEmployeeId);
if (result.status === "available") {
availableIds.push(result.invoicePoolId);
} else if (result.status === "pending_ocr") {
pendingById.set(result.invoicePoolId, file.name);
}
} catch (error) {
uploadFailures.push({ file, message: buildActionErrorMessage(error, "上传发票失败") });
}
setInvoiceUploadProgress({ completed: index + 1, total: files.length });
}
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
const deadline = Date.now() + 60_000;
while (pendingById.size && Date.now() < deadline) {
await sleep(2_000);
const invoiceResults = await Promise.all(
Array.from(pendingById.keys()).map(async (invoiceId) => {
try {
return await fetchInvoicePoolDetail(invoiceId);
} catch {
return null;
}
}),
);
for (const invoice of invoiceResults) {
if (!invoice) {
continue;
}
if (invoice.status === "available") {
availableIds.push(invoice.id);
pendingById.delete(invoice.id);
} else if (invoice.status === "ocr_failed") {
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
ocrFailures.push(`${fileName}: ${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`);
pendingById.delete(invoice.id);
}
}
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
}
return {
uploadedCount: files.length - uploadFailures.length,
uploadFailures,
availableIds,
pendingIds: Array.from(pendingById.keys()),
ocrFailures,
};
},
onSuccess: async (result) => {
setInvoiceActionError("");
setSelectedInvoiceFile(null);
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file));
setPendingOcrInvoiceIds(result.pendingIds);
if (result.status === "available") {
const refreshed = await bindableInvoicesQuery.refetch();
if (refreshed.data?.items.some((item) => item.id === result.invoicePoolId)) {
setSelectedInvoices((previous) => ({
...previous,
[result.invoicePoolId]: previous[result.invoicePoolId] ?? "",
}));
}
setPendingOcrInvoiceId(null);
toast.success("发票上传成功,可直接绑定");
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
const refreshed = await bindableInvoicesQuery.refetch();
const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id));
const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId));
if (selectableIds.length) {
setSelectedInvoices((previous) => ({
...previous,
...Object.fromEntries(selectableIds.map((invoiceId) => [invoiceId, previous[invoiceId] ?? ""])),
}));
}
const messages = [
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
...result.ocrFailures,
];
if (result.pendingIds.length) {
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 处理中,请稍后刷新列表或到票据中心查看`);
}
if (messages.length) {
const message = messages.join(";");
setInvoiceActionError(message);
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${messages.length} 项需处理`);
return;
}
setPendingOcrInvoiceId(result.invoicePoolId);
toast.success("发票上传成功,OCR 识别中");
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
await sleep(2_000);
try {
const invoice = await fetchInvoicePoolDetail(result.invoicePoolId);
if (invoice.status === "available") {
const refreshed = await bindableInvoicesQuery.refetch();
if (refreshed.data?.items.some((item) => item.id === result.invoicePoolId)) {
setSelectedInvoices((previous) => ({
...previous,
[result.invoicePoolId]: previous[result.invoicePoolId] ?? "",
}));
}
setPendingOcrInvoiceId(null);
toast.success("发票 OCR 已完成,可直接绑定");
return;
}
if (invoice.status === "ocr_failed") {
setPendingOcrInvoiceId(null);
const message = `发票 OCR 未完成:${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}可前往票据中心继续修正或重试。`;
setInvoiceActionError(message);
toast.error("发票 OCR 识别失败,请在当前提示或票据中心继续处理");
return;
}
} catch {
// Polling errors should not block eventual refresh; keep retrying until timeout.
}
}
setPendingOcrInvoiceId(null);
await queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] });
setInvoiceActionError("发票仍在 OCR 处理中,请稍后刷新列表;若长时间未完成,可前往票据中心查看详情。");
toast("发票仍在 OCR 处理中,请稍后手动刷新候选列表");
setInvoiceActionError("");
toast.success(`发票上传完成:${selectableIds.length || result.uploadedCount} 张可绑定`);
},
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
onError: (error) => {
const message = buildActionErrorMessage(error, "上传发票失败");
setInvoiceActionError(message);
@@ -698,9 +743,9 @@ export function ReimbursementDetailPage() {
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoices({});
setSelectedInvoiceFile(null);
setSelectedInvoiceFiles([]);
setInvoiceActionError("");
setPendingOcrInvoiceId(null);
setPendingOcrInvoiceIds([]);
}
};
@@ -1376,7 +1421,11 @@ export function ReimbursementDetailPage() {
htmlFor="reimbursement-inline-invoice-upload"
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
>
{selectedInvoiceFile ? selectedInvoiceFile.name : "选择发票文件"}
{selectedInvoiceFiles.length
? selectedInvoiceFiles.length === 1
? selectedInvoiceFiles[0].name
: `已选择 ${selectedInvoiceFiles.length} 个文件`
: "选择发票文件"}
</label>
<input
id="reimbursement-inline-invoice-upload"
@@ -1384,21 +1433,35 @@ export function ReimbursementDetailPage() {
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
onChange={(event) => setSelectedInvoiceFile(event.target.files?.[0] ?? null)}
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
/>
<Button
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
type="button"
disabled={!selectedInvoiceFile || uploadInvoiceMutation.isPending}
onClick={() => selectedInvoiceFile && uploadInvoiceMutation.mutate(selectedInvoiceFile)}
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
onClick={() => selectedInvoiceFiles.length && uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
>
{uploadInvoiceMutation.isPending ? "上传中..." : "上传并等待 OCR"}
{uploadInvoiceMutation.isPending
? `上传中 ${invoiceUploadProgress.completed}/${invoiceUploadProgress.total}`
: "上传并等待 OCR"}
</Button>
</div>
{pendingOcrInvoiceId ? (
{selectedInvoiceFiles.length ? (
<div className="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-500">
{selectedInvoiceFiles.slice(0, 5).map((file) => (
<div key={`${file.name}-${file.size}`} className="truncate">
{file.name} · {Math.ceil(file.size / 1024)} KB
</div>
))}
{selectedInvoiceFiles.length > 5 ? <div>另有 {selectedInvoiceFiles.length - 5} 个文件</div> : null}
</div>
) : null}
{pendingOcrInvoiceIds.length ? (
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。
{pendingOcrInvoiceIds.length} 张发票正在 OCR 识别中,系统会在可绑定后自动刷新列表。
</p>
) : null}
</div>