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

这个提交包含在:
2026-05-09 11:48:57 +08:00
父节点 549bd81e30
当前提交 c26cf9b954
共修改 34 个文件,包含 1217 行新增和 184 行删除
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
@@ -8,7 +8,6 @@ import {
bindInvoice,
fetchBindableInvoices,
fetchInvoicePoolList,
fetchInvoiceStatuses,
unbindInvoice,
uploadInvoice,
type BindableInvoiceSummary,
@@ -48,6 +47,7 @@ import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { cn } from "../lib/utils";
type ItemFormState = {
@@ -162,8 +162,9 @@ export function MobileReimbursementDetailPage() {
const { reimbursementId = "" } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const { me, permissions } = useAuth();
const { me, permissions, accessToken } = useAuth();
const fileInputRef = useRef<HTMLInputElement | null>(null);
const fileDocumentInputRef = useRef<HTMLInputElement | null>(null);
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
const [itemForm, setItemForm] = useState<ItemFormState | null>(null);
@@ -234,16 +235,6 @@ export function MobileReimbursementDetailPage() {
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
const pendingInvoiceStatusesQuery = useQuery({
enabled: pendingOcrInvoiceIds.length > 0,
queryKey: ["invoice-pool", "status", pendingOcrInvoiceIds],
queryFn: () => fetchInvoiceStatuses(pendingOcrInvoiceIds),
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
const invoiceStatusMessage = pendingOcrInvoiceIds.length
? `${pendingOcrInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后会自动添加。`
: invoiceActionMessage;
const invoiceStatusTone = pendingOcrInvoiceIds.length ? "pending" : "success";
const invoiceSearchLower = invoiceSearch.toLowerCase();
const bindableLibraryInvoices = useMemo<MobileInvoiceLibraryItem[]>(
@@ -446,6 +437,35 @@ export function MobileReimbursementDetailPage() {
},
});
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 }>) => {
const message = items
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.availabilityMessage ?? item.message}`)
.join(";");
setInvoiceActionError(message);
setInvoiceActionMessage("");
toast.error("部分发票需要处理");
}, []);
const invoiceOcrTracker = useInvoiceOcrTracker({
accessToken,
enabled: Boolean(reimbursementId),
pendingInvoiceIds: pendingOcrInvoiceIds,
setPendingInvoiceIds: setPendingOcrInvoiceIds,
onAvailable: handleTrackedInvoicesAvailable,
onUnavailable: handleTrackedInvoicesUnavailable,
});
const invoiceStatusMessage = invoiceOcrTracker.statusMessage || invoiceActionMessage;
const unbindInvoiceMutation = useMutation({
mutationFn: (invoiceId: string) => unbindInvoice(invoiceId),
onSuccess: async () => {
@@ -537,10 +557,15 @@ export function MobileReimbursementDetailPage() {
await invalidateDetail();
await bindableInvoicesQuery.refetch();
setPendingOcrInvoiceIds(result.pendingIds);
setPendingOcrInvoiceIds((previous) => Array.from(new Set([...previous, ...result.pendingIds])));
setSelectedInvoiceFiles(result.failures.map((item) => item.file));
if (!result.failures.length && fileInputRef.current) {
fileInputRef.current.value = "";
if (!result.failures.length) {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
if (fileDocumentInputRef.current) {
fileDocumentInputRef.current.value = "";
}
}
const messages: string[] = [];
@@ -617,38 +642,6 @@ export function MobileReimbursementDetailPage() {
},
});
useEffect(() => {
const statuses = pendingInvoiceStatusesQuery.data?.items ?? [];
if (!statuses.length || bindInvoicesMutation.isPending) {
return;
}
const availableIds = statuses
.filter((item) => item.status === "available")
.map((item) => item.id);
const finishedIds = new Set(
statuses
.filter((item) => item.status !== "pending_ocr")
.map((item) => item.id),
);
const failedMessages = statuses
.filter((item) => item.status === "ocr_failed" || item.status === "archived")
.map((item) => `${item.originalName ?? item.id.slice(0, 8)}:${item.message}`);
if (failedMessages.length) {
setInvoiceActionError(failedMessages.join(";"));
setInvoiceActionMessage("");
}
if (availableIds.length) {
bindInvoicesMutation.mutate(availableIds.map((invoiceId) => ({ invoiceId })));
}
if (finishedIds.size) {
setPendingOcrInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId)));
}
}, [bindInvoicesMutation, pendingInvoiceStatusesQuery.data?.items]);
const handleSubmit = async () => {
if (!detail) {
return;
@@ -704,6 +697,9 @@ export function MobileReimbursementDetailPage() {
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
if (fileDocumentInputRef.current) {
fileDocumentInputRef.current.value = "";
}
};
const toggleInvoiceSelection = (invoice: BindableInvoiceSummary, checked: boolean) => {
@@ -1145,23 +1141,42 @@ export function MobileReimbursementDetailPage() {
{invoicePanel === "upload" ? (
<div className="mt-4 space-y-3">
<label
htmlFor="mobile-reimbursement-invoice-upload"
className="flex min-h-32 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-4 py-5 text-center"
>
<span className="material-symbols-outlined text-[30px] text-zinc-700">upload_file</span>
<span className="mt-2 text-sm font-semibold text-zinc-900">
{selectedInvoiceFiles.length ? `已选择 ${selectedInvoiceFiles.length} 个文件` : "选择或拍摄发票"}
</span>
<span className="mt-1 text-xs text-zinc-500">支持图片和 PDF,可多选</span>
</label>
<div className="grid grid-cols-2 gap-2">
<label
htmlFor="mobile-reimbursement-invoice-upload-image"
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
>
<span className="material-symbols-outlined text-[28px] text-zinc-700">photo_camera</span>
<span className="mt-2 text-sm font-semibold text-zinc-900">拍照或选图片</span>
<span className="mt-1 text-xs text-zinc-500">适合手机拍发票</span>
</label>
<label
htmlFor="mobile-reimbursement-invoice-upload-document"
className="flex min-h-28 cursor-pointer flex-col items-center justify-center rounded-lg border border-dashed border-zinc-300 bg-zinc-50 px-3 py-4 text-center"
>
<span className="material-symbols-outlined text-[28px] text-zinc-700">picture_as_pdf</span>
<span className="mt-2 text-sm font-semibold text-zinc-900">选择 PDF/文件</span>
<span className="mt-1 text-xs text-zinc-500">适合电子发票</span>
</label>
</div>
<input
ref={fileInputRef}
id="mobile-reimbursement-invoice-upload"
id="mobile-reimbursement-invoice-upload-image"
className="sr-only"
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
type="file"
accept="image/*,application/pdf"
accept="image/*"
capture="environment"
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
/>
<input
ref={fileDocumentInputRef}
id="mobile-reimbursement-invoice-upload-document"
className="sr-only"
data-testid="reimbursement-invoice-upload-document"
type="file"
accept="application/pdf,image/jpeg,image/png,image/webp"
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
/>
+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>