H1-H10 优化OCR实时体验闭环
这个提交包含在:
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户