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