R73-A3 优化轻交互 OCR 前端处理
这个提交包含在:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
@@ -26,6 +26,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { toast } from "sonner";
|
||||
import { downloadBlob } from "../lib/download";
|
||||
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
|
||||
@@ -64,9 +65,14 @@ export function InvoicePoolPage() {
|
||||
const [editInvoiceCode, setEditInvoiceCode] = useState("");
|
||||
const [editInvoiceNumber, setEditInvoiceNumber] = useState("");
|
||||
const [editInvoiceDate, setEditInvoiceDate] = useState("");
|
||||
const [editSellerName, setEditSellerName] = useState("");
|
||||
const [editBuyerName, setEditBuyerName] = useState("");
|
||||
const [editAmount, setEditAmount] = useState("");
|
||||
const [editTaxAmount, setEditTaxAmount] = useState("");
|
||||
const [editTotalAmount, setEditTotalAmount] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null);
|
||||
const previousDetailStatusRef = useRef<InvoicePoolStatus | null>(null);
|
||||
|
||||
// --- Queries ---
|
||||
|
||||
@@ -140,9 +146,29 @@ export function InvoicePoolPage() {
|
||||
setEditInvoiceCode(detailQuery.data.invoiceCode ?? "");
|
||||
setEditInvoiceNumber(detailQuery.data.invoiceNumber ?? "");
|
||||
setEditInvoiceDate(detailQuery.data.invoiceDate ?? "");
|
||||
setEditSellerName(detailQuery.data.sellerName ?? "");
|
||||
setEditBuyerName(detailQuery.data.buyerName ?? "");
|
||||
setEditAmount(detailQuery.data.amount ?? "");
|
||||
setEditTaxAmount(detailQuery.data.taxAmount ?? "");
|
||||
setEditTotalAmount(detailQuery.data.totalAmount ?? "");
|
||||
}, [detailQuery.data, editOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
const currentStatus = detailQuery.data?.status ?? null;
|
||||
const previousStatus = previousDetailStatusRef.current;
|
||||
|
||||
if (previousStatus === "pending_ocr" && currentStatus && currentStatus !== "pending_ocr") {
|
||||
void queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
|
||||
if (currentStatus === "available") {
|
||||
toast.success("票据 OCR 已完成,可继续绑定或验真");
|
||||
} else if (currentStatus === "ocr_failed") {
|
||||
toast.error(`票据 OCR 未完成:${formatInvoiceOcrErrorMessage(detailQuery.data?.ocrLastError)}`);
|
||||
}
|
||||
}
|
||||
|
||||
previousDetailStatusRef.current = currentStatus;
|
||||
}, [detailQuery.data?.ocrLastError, detailQuery.data?.status, queryClient]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManageInvoice && ownerScope === "all") {
|
||||
setOwnerScope("own");
|
||||
@@ -188,7 +214,7 @@ export function InvoicePoolPage() {
|
||||
setDetailInvoiceId(data.invoicePoolId);
|
||||
setSelectedFile(null);
|
||||
setError("");
|
||||
toast.success("上传成功");
|
||||
toast.success(data.status === "pending_ocr" ? "上传成功,OCR 识别中" : "上传成功,可直接处理");
|
||||
},
|
||||
onError: (err) => handleError(err, "上传失败,请稍后重试"),
|
||||
});
|
||||
@@ -246,6 +272,10 @@ export function InvoicePoolPage() {
|
||||
invoiceCode: editInvoiceCode.trim() ? editInvoiceCode.trim() : null,
|
||||
invoiceNumber: editInvoiceNumber.trim() ? editInvoiceNumber.trim() : null,
|
||||
invoiceDate: editInvoiceDate ? editInvoiceDate : null,
|
||||
sellerName: editSellerName.trim() ? editSellerName.trim() : null,
|
||||
buyerName: editBuyerName.trim() ? editBuyerName.trim() : null,
|
||||
amount: editAmount.trim() ? editAmount.trim() : null,
|
||||
taxAmount: editTaxAmount.trim() ? editTaxAmount.trim() : null,
|
||||
totalAmount: editTotalAmount.trim() ? editTotalAmount.trim() : null,
|
||||
});
|
||||
},
|
||||
@@ -461,6 +491,14 @@ export function InvoicePoolPage() {
|
||||
setEditInvoiceNumber={setEditInvoiceNumber}
|
||||
editInvoiceDate={editInvoiceDate}
|
||||
setEditInvoiceDate={setEditInvoiceDate}
|
||||
editSellerName={editSellerName}
|
||||
setEditSellerName={setEditSellerName}
|
||||
editBuyerName={editBuyerName}
|
||||
setEditBuyerName={setEditBuyerName}
|
||||
editAmount={editAmount}
|
||||
setEditAmount={setEditAmount}
|
||||
editTaxAmount={editTaxAmount}
|
||||
setEditTaxAmount={setEditTaxAmount}
|
||||
editTotalAmount={editTotalAmount}
|
||||
setEditTotalAmount={setEditTotalAmount}
|
||||
confirmAction={confirmAction}
|
||||
|
||||
@@ -48,6 +48,7 @@ import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||
import {
|
||||
@@ -437,6 +438,12 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
if (!failedResults.length) {
|
||||
setInvoiceActionError("");
|
||||
setIsInvoiceDialogOpen(false);
|
||||
setInvoiceSearchInput("");
|
||||
setInvoiceSearch("");
|
||||
setSelectedInvoices({});
|
||||
setSelectedInvoiceFile(null);
|
||||
setPendingOcrInvoiceId(null);
|
||||
toast.success(`已绑定 ${succeededIds.length} 张发票`);
|
||||
return;
|
||||
}
|
||||
@@ -482,13 +489,27 @@ export function ReimbursementDetailPage() {
|
||||
if (!ownerEmployeeId) {
|
||||
throw new Error("missing ownerEmployeeId");
|
||||
}
|
||||
return uploadInvoice(file, undefined, ownerEmployeeId);
|
||||
return uploadInvoice(file, "vat", ownerEmployeeId);
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
setInvoiceActionError("");
|
||||
setSelectedInvoiceFile(null);
|
||||
setInvoiceSearchInput("");
|
||||
setInvoiceSearch("");
|
||||
|
||||
if (result.status === "available") {
|
||||
const refreshed = await bindableInvoicesQuery.refetch();
|
||||
if (refreshed.data?.items.some((item) => item.id === result.invoicePoolId)) {
|
||||
setSelectedInvoices((previous) => ({
|
||||
...previous,
|
||||
[result.invoicePoolId]: previous[result.invoicePoolId] ?? "",
|
||||
}));
|
||||
}
|
||||
setPendingOcrInvoiceId(null);
|
||||
toast.success("发票上传成功,可直接绑定");
|
||||
return;
|
||||
}
|
||||
|
||||
setPendingOcrInvoiceId(result.invoicePoolId);
|
||||
toast.success("发票上传成功,OCR 识别中");
|
||||
|
||||
@@ -512,7 +533,9 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
if (invoice.status === "ocr_failed") {
|
||||
setPendingOcrInvoiceId(null);
|
||||
toast.error("发票 OCR 识别失败,请前往票据中心处理");
|
||||
const message = `发票 OCR 未完成:${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}可前往票据中心继续修正或重试。`;
|
||||
setInvoiceActionError(message);
|
||||
toast.error("发票 OCR 识别失败,请在当前提示或票据中心继续处理");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
@@ -522,6 +545,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
setPendingOcrInvoiceId(null);
|
||||
await queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] });
|
||||
setInvoiceActionError("发票仍在 OCR 处理中,请稍后刷新列表;若长时间未完成,可前往票据中心查看详情。");
|
||||
toast("发票仍在 OCR 处理中,请稍后手动刷新候选列表");
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -1002,7 +1026,7 @@ export function ReimbursementDetailPage() {
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-sm" data-testid="reimbursement-invoices">
|
||||
<div className="space-y-4 text-sm" data-testid={TEST_IDS.reimbursement.invoices}>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
|
||||
@@ -1010,6 +1034,7 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
{canOpenInvoiceBindDialog ? (
|
||||
<Button
|
||||
data-testid={TEST_IDS.reimbursement.invoiceAdd}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => handleInvoiceDialogOpenChange(true)}
|
||||
@@ -1022,7 +1047,10 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
|
||||
{invoiceActionError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceActionError}
|
||||
>
|
||||
{invoiceActionError}
|
||||
</p>
|
||||
) : null}
|
||||
@@ -1231,7 +1259,10 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
|
||||
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
|
||||
<DialogContent className="max-h-[85vh] max-w-3xl overflow-y-auto">
|
||||
<DialogContent
|
||||
className="max-h-[85vh] max-w-3xl overflow-y-auto"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceDialog}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加发票</DialogTitle>
|
||||
<DialogDescription>选择当前报销人可绑定的可用发票,或上传新发票等待 OCR 完成后直接绑定。</DialogDescription>
|
||||
@@ -1350,11 +1381,13 @@ export function ReimbursementDetailPage() {
|
||||
<input
|
||||
id="reimbursement-inline-invoice-upload"
|
||||
className="hidden"
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadFile}
|
||||
type="file"
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp"
|
||||
onChange={(event) => setSelectedInvoiceFile(event.target.files?.[0] ?? null)}
|
||||
/>
|
||||
<Button
|
||||
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
|
||||
type="button"
|
||||
disabled={!selectedInvoiceFile || uploadInvoiceMutation.isPending}
|
||||
onClick={() => selectedInvoiceFile && uploadInvoiceMutation.mutate(selectedInvoiceFile)}
|
||||
@@ -1364,7 +1397,7 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
|
||||
{pendingOcrInvoiceId ? (
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
|
||||
发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
|
||||
import type { InvoicePoolDetail, InvoiceVerificationRecord } from "../../api/invoicePool";
|
||||
import { ConfirmDialog } from "../../components/common/ConfirmDialog";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { formatInvoiceOcrErrorMessage } from "../../lib/invoiceOcr";
|
||||
|
||||
export type InvoiceDetailPanelProps = {
|
||||
detail: InvoicePoolDetail | undefined;
|
||||
@@ -30,6 +31,14 @@ export type InvoiceDetailPanelProps = {
|
||||
setEditInvoiceNumber: (value: string) => void;
|
||||
editInvoiceDate: string;
|
||||
setEditInvoiceDate: (value: string) => void;
|
||||
editSellerName: string;
|
||||
setEditSellerName: (value: string) => void;
|
||||
editBuyerName: string;
|
||||
setEditBuyerName: (value: string) => void;
|
||||
editAmount: string;
|
||||
setEditAmount: (value: string) => void;
|
||||
editTaxAmount: string;
|
||||
setEditTaxAmount: (value: string) => void;
|
||||
editTotalAmount: string;
|
||||
setEditTotalAmount: (value: string) => void;
|
||||
confirmAction: { type: string; invoiceId: string } | null;
|
||||
@@ -62,6 +71,14 @@ export function InvoiceDetailPanel({
|
||||
setEditInvoiceNumber,
|
||||
editInvoiceDate,
|
||||
setEditInvoiceDate,
|
||||
editSellerName,
|
||||
setEditSellerName,
|
||||
editBuyerName,
|
||||
setEditBuyerName,
|
||||
editAmount,
|
||||
setEditAmount,
|
||||
editTaxAmount,
|
||||
setEditTaxAmount,
|
||||
editTotalAmount,
|
||||
setEditTotalAmount,
|
||||
confirmAction,
|
||||
@@ -80,6 +97,8 @@ export function InvoiceDetailPanel({
|
||||
<span className="text-zinc-500">发票号:</span>
|
||||
{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"}
|
||||
</div>
|
||||
<div><span className="text-zinc-500">销售方:</span>{detail.sellerName || "-"}</div>
|
||||
<div><span className="text-zinc-500">购买方:</span>{detail.buyerName || "-"}</div>
|
||||
<div className="col-span-2">
|
||||
<span className="text-zinc-500">总金额:</span>
|
||||
{detail.totalAmount ? new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(Number(detail.totalAmount)) : "-"}
|
||||
@@ -101,7 +120,7 @@ export function InvoiceDetailPanel({
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{detail.ocrLastError ? <div className="col-span-2 text-red-600"><span className="text-zinc-500">OCR错误:</span>{detail.ocrLastError}</div> : null}
|
||||
{detail.ocrLastError ? <div className="col-span-2 text-red-600"><span className="text-zinc-500">OCR错误:</span>{formatInvoiceOcrErrorMessage(detail.ocrLastError)}</div> : null}
|
||||
{detail.remarks ? <div className="col-span-2"><span className="text-zinc-500">备注:</span>{detail.remarks}</div> : null}
|
||||
<div className="col-span-2">
|
||||
<span className="text-zinc-500">判重例外:</span>
|
||||
@@ -109,6 +128,18 @@ export function InvoiceDetailPanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detail.status === "pending_ocr" ? (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-700" data-testid="invoice-ocr-status-banner">
|
||||
OCR 正在处理中,当前页面会自动轮询;完成后可直接继续绑定、验真或查看详情。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail.status === "ocr_failed" ? (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" data-testid="invoice-ocr-status-banner">
|
||||
{formatInvoiceOcrErrorMessage(detail.ocrLastError)} 可在下方直接重试 OCR,或补齐关键字段后标记为可用。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex flex-wrap gap-2 pt-4 border-t border-zinc-100 mt-4">
|
||||
{canVerifyInvoice ? (
|
||||
<Button
|
||||
@@ -294,6 +325,54 @@ export function InvoiceDetailPanel({
|
||||
value={editInvoiceDate}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceSellerName">
|
||||
销售方
|
||||
</label>
|
||||
<input
|
||||
className="input-std"
|
||||
data-testid="invoice-edit-seller-name"
|
||||
id="invoiceSellerName"
|
||||
onChange={(event) => setEditSellerName(event.target.value)}
|
||||
value={editSellerName}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceBuyerName">
|
||||
购买方
|
||||
</label>
|
||||
<input
|
||||
className="input-std"
|
||||
data-testid="invoice-edit-buyer-name"
|
||||
id="invoiceBuyerName"
|
||||
onChange={(event) => setEditBuyerName(event.target.value)}
|
||||
value={editBuyerName}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceAmount">
|
||||
不含税金额
|
||||
</label>
|
||||
<input
|
||||
className="input-std"
|
||||
data-testid="invoice-edit-amount"
|
||||
id="invoiceAmount"
|
||||
onChange={(event) => setEditAmount(event.target.value)}
|
||||
value={editAmount}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceTaxAmount">
|
||||
税额
|
||||
</label>
|
||||
<input
|
||||
className="input-std"
|
||||
data-testid="invoice-edit-tax-amount"
|
||||
id="invoiceTaxAmount"
|
||||
onChange={(event) => setEditTaxAmount(event.target.value)}
|
||||
value={editTaxAmount}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs font-medium text-zinc-600" htmlFor="invoiceTotalAmount">
|
||||
总金额
|
||||
|
||||
@@ -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 { formatInvoiceOcrErrorMessage } from "../../lib/invoiceOcr";
|
||||
|
||||
export type InvoiceListCardProps = {
|
||||
listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
|
||||
@@ -102,7 +103,7 @@ export function InvoiceListCard({
|
||||
) : item.status === "ocr_failed" ? (
|
||||
<span className="flex items-center gap-1.5 text-red-600">
|
||||
<span className="size-1.5 rounded-full bg-red-500" />
|
||||
识别失败 <span className="text-zinc-400 text-xs">({item.ocrLastError || "未知错误"})</span>
|
||||
识别失败 <span className="text-zinc-400 text-xs">({formatInvoiceOcrErrorMessage(item.ocrLastError)})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-zinc-400 text-xs">解析成功</span>
|
||||
|
||||
在新工单中引用
屏蔽一个用户