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

这个提交包含在:
2026-05-09 11:48:57 +08:00
父节点 549bd81e30
当前提交 c26cf9b954
共修改 34 个文件,包含 1217 行新增和 184 行删除
+3 -3
查看文件
@@ -105,11 +105,11 @@ export function mapApiErrorMessage(
}
if (code === "VALID_006") {
return "OCR 重试次数已达上限,请稍后人工处理。";
return "票据识别重试次数已达上限,请稍后人工处理。";
}
if (code === "VALID_007") {
return "OCR 重试过于频繁,请稍后再试。";
return "票据识别重试过于频繁,请稍后再试。";
}
if (code === "VALID_001") {
@@ -188,7 +188,7 @@ export function mapApiErrorMessage(
}
if (details?.ocr === "disabled") {
return "当前环境未开启 OCR,请联系管理员或切换到 mock/tencent。";
return "当前环境未开启票据识别,请联系管理员处理。";
}
if (details?.ocr === "missing_required_fields") {
+81
查看文件
@@ -0,0 +1,81 @@
export type AppEvent = {
id?: string;
type: string;
invoiceId?: string;
status?: string | null;
updatedAt?: string | null;
createdAt?: string | null;
};
type SubscribeAppEventsOptions = {
accessToken: string;
onEvent: (event: AppEvent) => void;
onError?: (error: unknown) => void;
};
function parseSseMessage(message: string): AppEvent | null {
const dataLines = message
.split(/\r?\n/)
.filter((line) => line.startsWith("data:"))
.map((line) => line.slice("data:".length).trimStart());
if (!dataLines.length) {
return null;
}
try {
return JSON.parse(dataLines.join("\n")) as AppEvent;
} catch {
return null;
}
}
export function subscribeAppEvents(options: SubscribeAppEventsOptions) {
const controller = new AbortController();
void (async () => {
try {
const response = await fetch("/api/events", {
method: "GET",
credentials: "include",
headers: {
authorization: `Bearer ${options.accessToken}`,
accept: "text/event-stream",
},
signal: controller.signal,
});
if (!response.ok || !response.body) {
throw new Error(`events_stream_unavailable:${response.status}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
while (!controller.signal.aborted) {
const { done, value } = await reader.read();
if (done) {
break;
}
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split(/\r?\n\r?\n/);
buffer = chunks.pop() ?? "";
for (const chunk of chunks) {
const event = parseSseMessage(chunk);
if (event) {
options.onEvent(event);
}
}
}
} catch (error) {
if (!controller.signal.aborted) {
options.onError?.(error);
}
}
})();
return () => controller.abort();
}
+8
查看文件
@@ -21,6 +21,8 @@ export type InvoiceVerificationRecord = {
export type InvoicePoolListItem = {
id: string;
status: InvoicePoolStatus;
availabilityReason: string | null;
availabilityMessage: string | null;
ocrType: string;
ocrProvider: string;
ocrAttempts: number;
@@ -68,11 +70,15 @@ export type UploadInvoiceResponse = {
uploadAction?: "create" | "reuse_existing" | "blocked";
unavailableReason?: string | null;
unavailableMessage?: string | null;
availabilityReason?: string | null;
availabilityMessage?: string | null;
};
export type InvoicePoolDetail = {
id: string;
status: InvoicePoolStatus;
availabilityReason: string | null;
availabilityMessage: string | null;
archivedReason: string | null;
archivedAt: string | null;
ownerEmployeeId: string;
@@ -144,6 +150,8 @@ export type InvoiceStatusItem = {
status: InvoicePoolStatus;
reason: string;
message: string;
availabilityReason: string | null;
availabilityMessage: string | null;
invoiceCode: string | null;
invoiceNumber: string | null;
invoiceDate: string | null;
+125
查看文件
@@ -0,0 +1,125 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { subscribeAppEvents } from "../api/events";
import {
fetchInvoiceStatuses,
type InvoiceStatusItem,
} from "../api/invoicePool";
type UseInvoiceOcrTrackerOptions = {
accessToken: string | null;
enabled: boolean;
pendingInvoiceIds: string[];
setPendingInvoiceIds: (updater: string[] | ((previous: string[]) => string[])) => void;
onAvailable: (invoiceIds: string[]) => void;
onUnavailable: (items: InvoiceStatusItem[]) => void;
};
export function useInvoiceOcrTracker({
accessToken,
enabled,
pendingInvoiceIds,
setPendingInvoiceIds,
onAvailable,
onUnavailable,
}: UseInvoiceOcrTrackerOptions) {
const [streamFailed, setStreamFailed] = useState(false);
const firstPendingAtRef = useRef<number | null>(null);
const handledIdsRef = useRef(new Set<string>());
const pendingKey = pendingInvoiceIds.join(",");
useEffect(() => {
if (pendingInvoiceIds.length && firstPendingAtRef.current == null) {
firstPendingAtRef.current = Date.now();
}
if (!pendingInvoiceIds.length) {
firstPendingAtRef.current = null;
handledIdsRef.current.clear();
}
}, [pendingInvoiceIds.length]);
const statusQuery = useQuery({
enabled: enabled && pendingInvoiceIds.length > 0,
queryKey: ["invoice-pool", "status", pendingInvoiceIds],
queryFn: () => fetchInvoiceStatuses(pendingInvoiceIds),
refetchInterval: pendingInvoiceIds.length ? 3_000 : false,
});
useEffect(() => {
if (!enabled || !accessToken || !pendingInvoiceIds.length) {
return;
}
setStreamFailed(false);
return subscribeAppEvents({
accessToken,
onEvent: (event) => {
if (event.type !== "invoice.updated" || !event.invoiceId) {
return;
}
if (pendingInvoiceIds.includes(event.invoiceId)) {
void statusQuery.refetch();
}
},
onError: () => setStreamFailed(true),
});
}, [accessToken, enabled, pendingKey]);
useEffect(() => {
const statuses = statusQuery.data?.items ?? [];
if (!statuses.length) {
return;
}
const availableIds = statuses
.filter((item) => item.status === "available")
.map((item) => item.id)
.filter((invoiceId) => !handledIdsRef.current.has(invoiceId));
const unavailableItems = statuses
.filter((item) => ["ocr_failed", "archived", "bound"].includes(item.status))
.filter((item) => !handledIdsRef.current.has(item.id));
const finishedIds = new Set([...availableIds, ...unavailableItems.map((item) => item.id)]);
if (!finishedIds.size) {
return;
}
for (const invoiceId of finishedIds) {
handledIdsRef.current.add(invoiceId);
}
if (availableIds.length) {
onAvailable(availableIds);
}
if (unavailableItems.length) {
onUnavailable(unavailableItems);
}
setPendingInvoiceIds((previous) => previous.filter((invoiceId) => !finishedIds.has(invoiceId)));
}, [statusQuery.data?.items, onAvailable, onUnavailable, setPendingInvoiceIds]);
const hasLongWait = Boolean(
pendingInvoiceIds.length &&
firstPendingAtRef.current &&
Date.now() - firstPendingAtRef.current > 60_000,
);
const statusMessage = useMemo(() => {
if (!pendingInvoiceIds.length) {
return "";
}
if (hasLongWait) {
return `${pendingInvoiceIds.length} 张发票仍在识别,系统会继续自动刷新,完成后会自动添加。`;
}
return `${pendingInvoiceIds.length} 张发票正在识别,正在自动刷新,完成后会自动添加。`;
}, [hasLongWait, pendingInvoiceIds.length]);
return {
statusMessage,
isTracking: pendingInvoiceIds.length > 0,
streamFailed,
refetch: statusQuery.refetch,
};
}
+4 -4
查看文件
@@ -29,19 +29,19 @@ export function formatInvoiceOcrFieldLabels(fields: string[]) {
export function formatInvoiceOcrErrorMessage(rawError: string | null | undefined) {
const missingFields = parseMissingInvoiceOcrFields(rawError);
if (missingFields.length) {
return `OCR 缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`;
return `票据识别缺少关键字段:${formatInvoiceOcrFieldLabels(missingFields).join("、")}。`;
}
if (rawError === "duplicate_invoice_code_number") {
return "OCR 识别到的发票代码/号码与已使用票据重复,请核对后再处理。";
return "已识别出相同发票,不能重复添加。";
}
if (rawError === "mock_ocr_failed") {
return "OCR 识别失败,请重试或人工补齐关键信息。";
return "票据识别失败,请重试或人工补齐关键信息。";
}
if (!rawError) {
return "OCR 识别失败,请稍后重试。";
return "票据识别失败,请稍后重试。";
}
return rawError;
@@ -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>
+1 -1
查看文件
@@ -69,7 +69,7 @@ test("E2E 轻交互 OCR:票据中心缺关键字段后可在当前页修正并
await page.getByTestId("invoice-upload-submit").click();
await expect(page.getByTestId("invoice-detail-panel")).toContainText("状态:识别失败", { timeout: 90_000 });
await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("OCR 缺少关键字段:发票代码、销售方。");
await expect(page.getByTestId("invoice-ocr-status-banner")).toContainText("票据识别缺少关键字段:发票代码、销售方。");
await page.getByTestId("invoice-edit-open").click();
await page.getByTestId("invoice-edit-set-available").check();
@@ -354,6 +354,8 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
uploadAction: "create",
unavailableReason: null,
unavailableMessage: null,
availabilityReason: "pending_ocr",
availabilityMessage: "发票正在识别,完成后会自动刷新",
});
return;
}
@@ -368,6 +370,8 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
status: stillPending ? "pending_ocr" : "available",
reason: stillPending ? "pending_ocr" : "available",
message: stillPending ? "发票正在识别" : "发票可添加",
availabilityReason: stillPending ? "pending_ocr" : "available",
availabilityMessage: stillPending ? "发票正在识别" : "发票可添加",
invoiceCode: stillPending ? null : "044002600111",
invoiceNumber: stillPending ? null : "12345678",
invoiceDate: stillPending ? null : "2026-05-08",
@@ -515,7 +519,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
buffer: Buffer.from("%PDF r111 invoice"),
});
await page.getByTestId("reimbursement-invoice-upload-submit").click();
await expect(page.getByText("发票上传完成:1 张已添加")).toBeVisible({ timeout: 10_000 });
await expect(page.getByText("已添加 1 张发票")).toBeVisible({ timeout: 15_000 });
expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
await page.keyboard.press("Escape");
@@ -588,8 +592,20 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await expect(invoiceDialog.getByText("暂无可添加发票")).toBeVisible();
await invoiceDialog.getByRole("button", { name: "上传" }).click();
await expect(invoiceDialog.getByText("拍照或选图片")).toBeVisible();
await expect(invoiceDialog.getByText("选择 PDF/文件")).toBeVisible();
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("accept", "image/*");
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-file")).toHaveAttribute("capture", "environment");
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).toHaveAttribute(
"accept",
/application\/pdf/,
);
await expect(mobile.page.getByTestId("reimbursement-invoice-upload-document")).not.toHaveAttribute(
"capture",
/.+/,
);
await mobile.page.getByTestId("reimbursement-invoice-upload-file").setInputFiles({
await mobile.page.getByTestId("reimbursement-invoice-upload-document").setInputFiles({
name: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
buffer: Buffer.from("%PDF r111 mobile invoice"),