R73-A3 优化轻交互 OCR 前端处理

这个提交包含在:
2026-04-28 21:14:00 +08:00
父节点 b7c018922c
当前提交 03f384fdfe
共修改 8 个文件,包含 227 行新增和 12 行删除
+10
查看文件
@@ -1,3 +1,5 @@
import { formatInvoiceOcrFieldLabels } from "../lib/invoiceOcr";
function readStringList(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
@@ -189,6 +191,14 @@ export function mapApiErrorMessage(
return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。";
}
if (details?.ocr === "missing_required_fields") {
const labels = formatInvoiceOcrFieldLabels(readStringList(details?.missingFields));
if (labels.length) {
return `票据仍缺少关键字段:${labels.join("、")},补齐后才能标记为可用。`;
}
return "票据仍缺少关键字段,补齐后才能标记为可用。";
}
if (details?.verification === "disabled") {
return "当前环境未开启发票验真。";
+1 -2
查看文件
@@ -30,7 +30,7 @@ export const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-lg -tranzinc-x-1/2 -tranzinc-y-1/2 rounded-xl border border-zinc-200 bg-white p-6 shadow-lg focus-visible:outline-none",
"fixed left-1/2 top-1/2 z-50 w-[92vw] max-w-lg -translate-x-1/2 -translate-y-1/2 rounded-xl border border-zinc-200 bg-white p-6 shadow-lg focus-visible:outline-none",
className,
)}
{...props}
@@ -73,4 +73,3 @@ export const DialogDescription = React.forwardRef<
));
DialogDescription.displayName = "DialogDescription";
+48
查看文件
@@ -0,0 +1,48 @@
const OCR_FIELD_LABELS: Record<string, string> = {
invoiceCode: "发票代码",
invoiceNumber: "发票号码",
invoiceDate: "开票日期",
totalAmount: "总金额",
sellerName: "销售方",
};
export function parseMissingInvoiceOcrFields(rawError: string | null | undefined) {
if (typeof rawError !== "string") {
return [];
}
const matched = rawError.match(/^missing_required_fields:(.+)$/);
if (!matched?.[1]) {
return [];
}
return matched[1]
.split(",")
.map((field) => field.trim())
.filter(Boolean);
}
export function formatInvoiceOcrFieldLabels(fields: string[]) {
return fields.map((field) => OCR_FIELD_LABELS[field] ?? field);
}
export function formatInvoiceOcrErrorMessage(rawError: string | null | undefined) {
const missingFields = parseMissingInvoiceOcrFields(rawError);
if (missingFields.length) {
return `OCR 缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`;
}
if (rawError === "duplicate_invoice_code_number") {
return "OCR 识别到的发票代码/号码与已使用票据重复,请核对后再处理。";
}
if (rawError === "mock_ocr_failed") {
return "OCR 识别失败,请重试或人工补齐关键信息。";
}
if (!rawError) {
return "OCR 识别失败,请稍后重试。";
}
return rawError;
}
+7
查看文件
@@ -41,6 +41,13 @@ export const TEST_IDS = {
erpExport: "reimbursement-erp-export",
overview: "reimbursement-overview",
workflowProgress: "reimbursement-workflow-progress",
invoices: "reimbursement-invoices",
invoiceAdd: "reimbursement-invoice-add",
invoiceDialog: "reimbursement-invoice-dialog",
invoiceUploadFile: "reimbursement-invoice-upload-file",
invoiceUploadSubmit: "reimbursement-invoice-upload-submit",
invoicePendingHint: "reimbursement-invoice-pending-hint",
invoiceActionError: "reimbursement-invoice-action-error",
},
weeklyReport: {
save: "weekly-report-save",
+40 -2
查看文件
@@ -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}
+39 -6
查看文件
@@ -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>