H1-H10 优化OCR实时体验闭环

这个提交包含在:
2026-05-09 11:48:57 +08:00
父节点 549bd81e30
当前提交 c26cf9b954
共修改 34 个文件,包含 1217 行新增和 184 行删除
+46 -45
查看文件
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
@@ -27,7 +27,6 @@ import {
import {
bindInvoice,
fetchBindableInvoices,
fetchInvoiceStatuses,
unbindInvoice,
uploadInvoice,
} from "../api/invoicePool";
@@ -54,6 +53,7 @@ import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds";
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
@@ -89,10 +89,6 @@ function formatDateTime(value: string | null) {
return value.slice(0, 19).replace("T", " ");
}
function sleep(ms: number) {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
@@ -142,7 +138,7 @@ function resolveErpSyncStatus(
export function ReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
const { permissions, me } = useAuth();
const { permissions, me, accessToken } = useAuth();
const queryClient = useQueryClient();
const [actionError, setActionError] = useState("");
@@ -501,7 +497,9 @@ export function ReimbursementDetailPage() {
setInvoiceSearch("");
setSelectedInvoices({});
setSelectedInvoiceFiles([]);
setPendingOcrInvoiceIds([]);
setPendingOcrInvoiceIds((previous) =>
previous.filter((invoiceId) => !succeededIds.includes(invoiceId)),
);
toast.success(`已添加 ${succeededIds.length} 张发票`);
return;
}
@@ -523,6 +521,33 @@ export function ReimbursementDetailPage() {
},
});
const handleTrackedInvoicesAvailable = useCallback(
(invoiceIds: string[]) => {
if (!invoiceIds.length) {
return;
}
bindInvoicesMutation.mutate(invoiceIds.map((invoiceId) => ({ invoiceId })));
},
[bindInvoicesMutation],
);
const handleTrackedInvoicesUnavailable = useCallback((items: Array<{ id: string; originalName: string | null; message: string; availabilityMessage?: string | null; ocrLastError?: string | null }>) => {
const message = items
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}: ${item.availabilityMessage ?? item.message ?? formatInvoiceOcrErrorMessage(item.ocrLastError)}`)
.join(";");
setInvoiceActionError(message);
toast.error("部分发票需要处理");
}, []);
const invoiceOcrTracker = useInvoiceOcrTracker({
accessToken,
enabled: Boolean(reimbursementId),
pendingInvoiceIds: pendingOcrInvoiceIds,
setPendingInvoiceIds: setPendingOcrInvoiceIds,
onAvailable: handleTrackedInvoicesAvailable,
onUnavailable: handleTrackedInvoicesUnavailable,
});
const unbindInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
onSuccess: async () => {
@@ -584,34 +609,9 @@ export function ReimbursementDetailPage() {
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 fetchInvoiceStatuses(Array.from(pendingById.keys()));
for (const invoice of invoiceResults.items) {
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}: ${invoice.message || formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`);
pendingById.delete(invoice.id);
} else if (invoice.status === "archived" || invoice.status === "bound") {
const fileName = pendingById.get(invoice.id) ?? invoice.id.slice(0, 8);
ocrFailures.push(`${fileName}: ${invoice.message}`);
pendingById.delete(invoice.id);
}
}
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
}
setPendingOcrInvoiceIds((previous) =>
Array.from(new Set([...previous, ...Array.from(pendingById.keys())])),
);
for (const invoiceId of availableIds) {
try {
@@ -635,30 +635,31 @@ export function ReimbursementDetailPage() {
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file));
setPendingOcrInvoiceIds(result.pendingIds);
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
await bindableInvoicesQuery.refetch();
const messages = [
const blockingMessages = [
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
...result.ocrFailures,
...result.bindFailures,
];
if (result.pendingIds.length) {
messages.push(`${result.pendingIds.length} 张发票仍在识别中,可稍后刷新`);
}
if (messages.length) {
const message = messages.join(";");
if (blockingMessages.length) {
const message = blockingMessages.join(";");
setInvoiceActionError(message);
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${messages.length} 项需处理`);
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${blockingMessages.length} 项需处理`);
return;
}
setInvoiceActionError("");
if (result.pendingIds.length) {
toast.success(`已上传 ${result.pendingIds.length} 张发票,识别完成后会自动添加`);
return;
}
toast.success(`发票上传完成:${result.boundCount || result.uploadedCount} 张已添加`);
},
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
@@ -1644,7 +1645,7 @@ export function ReimbursementDetailPage() {
{pendingOcrInvoiceIds.length ? (
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
{pendingOcrInvoiceIds.length} 张发票正在识别,可稍后刷新。
{invoiceOcrTracker.statusMessage}
</p>
) : null}
</div>