From 9d8c7e0478e27176e91a17fb6fd6e5c8a0454f12 Mon Sep 17 00:00:00 2001 From: shunli Date: Tue, 5 May 2026 22:16:35 +0800 Subject: [PATCH] =?UTF-8?q?R92=20A3-A4=20=E4=BC=98=E5=8C=96=E5=8F=91?= =?UTF-8?q?=E7=A5=A8=E4=B8=8A=E4=BC=A0=E7=8A=B6=E6=80=81=E4=B8=8E=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E4=B8=8A=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/invoicePool.ts | 8 +- client/src/pages/InvoicePoolPage.tsx | 85 ++++++-- client/src/pages/MobileCapturePage.tsx | 101 ++++++--- client/src/pages/ReimbursementDetailPage.tsx | 191 ++++++++++++------ .../pages/invoice-pool/InvoiceListCard.tsx | 72 ++++++- .../pages/invoice-pool/InvoiceUploadCard.tsx | 50 +++-- 6 files changed, 385 insertions(+), 122 deletions(-) diff --git a/client/src/api/invoicePool.ts b/client/src/api/invoicePool.ts index 4ef7e895..96412913 100644 --- a/client/src/api/invoicePool.ts +++ b/client/src/api/invoicePool.ts @@ -61,6 +61,12 @@ export type InvoicePoolListResponse = { items: InvoicePoolListItem[]; }; +export type UploadInvoiceResponse = { + invoicePoolId: string; + status: InvoicePoolStatus; + fileSha256: string; +}; + export type InvoicePoolDetail = { id: string; status: InvoicePoolStatus; @@ -174,7 +180,7 @@ export async function uploadInvoice(file: File, ocrType?: string, ownerEmployeeI formData.append("owner_employee_id", ownerEmployeeId); } - return apiRequestData<{ invoicePoolId: string; status: string; fileSha256: string }>( + return apiRequestData( "/api/invoice-pool/upload", { method: "POST", diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index a34424ed..1cf6b19c 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -19,6 +19,7 @@ import { unbindInvoice, uploadInvoice, type InvoicePoolStatus, + type UploadInvoiceResponse, } from "../api/invoicePool"; import { fetchReimbursements } from "../api/reimbursements"; import { fetchTravelRequestDetail, fetchTravelRequests } from "../api/travelRequests"; @@ -37,6 +38,17 @@ import { InvoiceDetailPanel } from "./invoice-pool/InvoiceDetailPanel"; const PAGE_SIZE = 20; +type UploadBatchProgress = { + completed: number; + total: number; + currentFileName: string; +}; + +type UploadBatchResult = { + successes: Array<{ file: File; data: UploadInvoiceResponse }>; + failures: Array<{ file: File; message: string }>; +}; + export function InvoicePoolPage() { const queryClient = useQueryClient(); const { permissions, me } = useAuth(); @@ -51,7 +63,8 @@ export function InvoicePoolPage() { const [status, setStatus] = useState(""); const [ownerScope, setOwnerScope] = useState<"own" | "all">("own"); const [page, setPage] = useState(1); - const [selectedFile, setSelectedFile] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); + const [uploadProgress, setUploadProgress] = useState(null); const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState(""); const [selectedInvoiceId, setSelectedInvoiceId] = useState(""); const [bindMode, setBindMode] = useState("reimbursement"); @@ -85,6 +98,10 @@ export function InvoicePoolPage() { page, pageSize: PAGE_SIZE, }), + refetchInterval: (query) => { + const items = query.state.data?.items ?? []; + return items.some((item) => item.status === "pending_ocr") ? 3_000 : false; + }, }); const reimbursementQuery = useQuery({ @@ -192,10 +209,14 @@ export function InvoicePoolPage() { // --- Helper for error handling --- - const handleError = (err: unknown, fallback: string) => { - const msg = isApiError(err) + const toUserError = (err: unknown, fallback: string) => { + return isApiError(err) ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` : err instanceof Error ? err.message : fallback; + }; + + const handleError = (err: unknown, fallback: string) => { + const msg = toUserError(err, fallback); setError(msg); toast.error(msg); }; @@ -204,18 +225,52 @@ export function InvoicePoolPage() { const uploadMutation = useMutation({ mutationFn: async () => { - if (!selectedFile) { + if (!selectedFiles.length) { throw new Error("请选择文件"); } - return uploadInvoice(selectedFile, "vat", uploadOwnerEmployeeId || undefined); + + const files = [...selectedFiles]; + const result: UploadBatchResult = { successes: [], failures: [] }; + setUploadProgress({ completed: 0, total: files.length, currentFileName: files[0]?.name ?? "" }); + + for (const [index, file] of files.entries()) { + setUploadProgress({ completed: index, total: files.length, currentFileName: file.name }); + try { + const data = await uploadInvoice(file, "vat", uploadOwnerEmployeeId || undefined); + result.successes.push({ file, data }); + } catch (err) { + result.failures.push({ file, message: toUserError(err, "上传失败,请稍后重试") }); + } + setUploadProgress({ completed: index + 1, total: files.length, currentFileName: file.name }); + } + + return result; }, - onSuccess: async (data) => { + onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); - setDetailInvoiceId(data.invoicePoolId); - setSelectedFile(null); + const lastSuccess = result.successes.at(-1); + if (lastSuccess) { + setDetailInvoiceId(lastSuccess.data.invoicePoolId); + } + setSelectedFiles(result.failures.map((item) => item.file)); + setUploadProgress(null); + + if (result.failures.length) { + const message = result.failures.map((item) => `${item.file.name}: ${item.message}`).join(";"); + setError(message); + if (result.successes.length) { + toast.error(`已上传 ${result.successes.length} 个,${result.failures.length} 个失败`); + } else { + toast.error("上传失败,请检查文件或稍后重试"); + } + return; + } + setError(""); - toast.success(data.status === "pending_ocr" ? "上传成功,OCR 识别中" : "上传成功,可直接处理"); + const hasPending = result.successes.some((item) => item.data.status === "pending_ocr"); + toast.success(hasPending ? `已上传 ${result.successes.length} 个,OCR 识别中` : `已上传 ${result.successes.length} 个,可直接处理`); }, + onSettled: () => setUploadProgress(null), onError: (err) => handleError(err, "上传失败,请稍后重试"), }); @@ -422,9 +477,15 @@ export function InvoicePoolPage() { ) : null} uploadMutation.mutate()} showProxyOwnerSelector={showProxyOwnerSelector} proxyDelegationsQuery={proxyDelegationsQuery} uploadOwnerEmployeeId={uploadOwnerEmployeeId} diff --git a/client/src/pages/MobileCapturePage.tsx b/client/src/pages/MobileCapturePage.tsx index 50942a0f..21a14e3d 100644 --- a/client/src/pages/MobileCapturePage.tsx +++ b/client/src/pages/MobileCapturePage.tsx @@ -32,11 +32,18 @@ function isLikelyNetworkError(error: unknown) { return true; } +type CaptureUploadResult = { + succeeded: number; + queued: number; + failed: Array<{ file: File; message: string }>; +}; + export function MobileCapturePage() { const queryClient = useQueryClient(); const [error, setError] = useState(""); const [message, setMessage] = useState(""); - const [selectedFile, setSelectedFile] = useState(null); + const [selectedFiles, setSelectedFiles] = useState([]); + const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 }); const [isOnline, setIsOnline] = useState(navigator.onLine); const queueQuery = useQuery({ @@ -45,12 +52,48 @@ export function MobileCapturePage() { }); const uploadMutation = useMutation({ - mutationFn: async (file: File) => uploadInvoice(file, "vat"), - onSuccess: () => { - setSelectedFile(null); + mutationFn: async (files: File[]) => { + const result: CaptureUploadResult = { succeeded: 0, queued: 0, failed: [] }; + setUploadProgress({ completed: 0, total: files.length }); + + for (const [index, file] of files.entries()) { + try { + await uploadInvoice(file, "vat"); + result.succeeded += 1; + } catch (err) { + if (isLikelyNetworkError(err)) { + await enqueueInvoiceUpload(file); + result.queued += 1; + } else { + result.failed.push({ + file, + message: isApiError(err) + ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` + : err instanceof Error ? err.message : "上传失败", + }); + } + } + + setUploadProgress({ completed: index + 1, total: files.length }); + } + + return result; + }, + onSuccess: async (result) => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] }), + queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }), + ]); + setSelectedFiles(result.failed.map((item) => item.file)); setError(""); setMessage(""); - toast.success("上传成功"); + if (result.failed.length) { + const msg = result.failed.map((item) => `${item.file.name}: ${item.message}`).join(";"); + setError(msg); + toast.error(`上传完成:成功 ${result.succeeded},待重试 ${result.queued},失败 ${result.failed.length}`); + return; + } + toast.success(`上传完成:成功 ${result.succeeded},待重试 ${result.queued}`); }, onError: (err) => { if (isApiError(err)) { @@ -64,6 +107,7 @@ export function MobileCapturePage() { setError(msg); toast.error(msg); }, + onSettled: () => setUploadProgress({ completed: 0, total: 0 }), }); const syncMutation = useMutation({ @@ -166,54 +210,55 @@ export function MobileCapturePage() { className="sr-only" data-testid="mobile-capture-file" onChange={(event) => { - const file = event.target.files?.[0] ?? null; - setSelectedFile(file); + setSelectedFiles(Array.from(event.target.files ?? [])); }} + multiple type="file" />
当前文件: - {selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"} + {selectedFiles.length ? `已选择 ${selectedFiles.length} 个` : "未选择"} + {selectedFiles.length ? ( +
+ {selectedFiles.slice(0, 5).map((file) => ( +
+ {file.name} ({Math.ceil(file.size / 1024)} KB) +
+ ))} + {selectedFiles.length > 5 ?
另有 {selectedFiles.length - 5} 个文件
: null} +
+ ) : null}
- {pendingOcrInvoiceId ? ( + {selectedInvoiceFiles.length ? ( +
+ {selectedInvoiceFiles.slice(0, 5).map((file) => ( +
+ {file.name} · {Math.ceil(file.size / 1024)} KB +
+ ))} + {selectedInvoiceFiles.length > 5 ?
另有 {selectedInvoiceFiles.length - 5} 个文件
: null} +
+ ) : null} + + {pendingOcrInvoiceIds.length ? (

- 发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。 + {pendingOcrInvoiceIds.length} 张发票正在 OCR 识别中,系统会在可绑定后自动刷新列表。

) : null} diff --git a/client/src/pages/invoice-pool/InvoiceListCard.tsx b/client/src/pages/invoice-pool/InvoiceListCard.tsx index 8083a281..42c6df38 100644 --- a/client/src/pages/invoice-pool/InvoiceListCard.tsx +++ b/client/src/pages/invoice-pool/InvoiceListCard.tsx @@ -4,6 +4,7 @@ import { StatusBadge } from "../../components/common/StatusBadge"; import { TableSkeleton } from "../../components/common/TableSkeleton"; import { Pagination } from "../../components/common/Pagination"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../components/ui/dropdown-menu"; +import { Button } from "../../components/ui/button"; import { formatInvoiceOcrErrorMessage } from "../../lib/invoiceOcr"; export type InvoiceListCardProps = { @@ -34,9 +35,9 @@ export function InvoiceListCard({ }: InvoiceListCardProps) { return (
-
+

筛选与列表

-
+
setSelectedFile(event.target.files?.[0] ?? null)} + multiple + onChange={(event) => setSelectedFiles(Array.from(event.target.files ?? []))} type="file" />
+ + {selectedFiles.length ? ( +
+ {selectedFiles.slice(0, 4).map((file) => ( +
+ {file.name} · {Math.ceil(file.size / 1024)} KB +
+ ))} + {selectedFiles.length > 4 ?
另有 {selectedFiles.length - 4} 个文件
: null} +
+ ) : null}
); }