R92 A3-A4 优化发票上传状态与批量上传

这个提交包含在:
2026-05-05 22:16:35 +08:00
父节点 abba3371dd
当前提交 9d8c7e0478
共修改 6 个文件,包含 385 行新增和 122 行删除
+7 -1
查看文件
@@ -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<UploadInvoiceResponse>(
"/api/invoice-pool/upload",
{
method: "POST",
+73 -12
查看文件
@@ -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<InvoicePoolStatus | "">("");
const [ownerScope, setOwnerScope] = useState<"own" | "all">("own");
const [page, setPage] = useState(1);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState<UploadBatchProgress | null>(null);
const [uploadOwnerEmployeeId, setUploadOwnerEmployeeId] = useState("");
const [selectedInvoiceId, setSelectedInvoiceId] = useState("");
const [bindMode, setBindMode] = useState<InvoiceBindMode>("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}
<InvoiceUploadCard
uploadMutation={uploadMutation}
selectedFile={selectedFile}
setSelectedFile={setSelectedFile}
selectedFiles={selectedFiles}
setSelectedFiles={setSelectedFiles}
isUploading={uploadMutation.isPending}
uploadProgressLabel={
uploadProgress
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
: "上传中..."
}
onUpload={() => uploadMutation.mutate()}
showProxyOwnerSelector={showProxyOwnerSelector}
proxyDelegationsQuery={proxyDelegationsQuery}
uploadOwnerEmployeeId={uploadOwnerEmployeeId}
+73 -28
查看文件
@@ -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<File | null>(null);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 });
const [isOnline, setIsOnline] = useState<boolean>(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"
/>
<div className="rounded-xl border border-zinc-200 bg-white px-3 py-3 text-sm text-zinc-600">
<span className="font-medium text-zinc-800">当前文件:</span>
{selectedFile ? `${selectedFile.name} (${Math.ceil(selectedFile.size / 1024)} KB)` : "未选择"}
{selectedFiles.length ? `已选择 ${selectedFiles.length} 个` : "未选择"}
{selectedFiles.length ? (
<div className="mt-2 space-y-1 text-xs">
{selectedFiles.slice(0, 5).map((file) => (
<div key={`${file.name}-${file.size}`} className="truncate">
{file.name} ({Math.ceil(file.size / 1024)} KB)
</div>
))}
{selectedFiles.length > 5 ? <div>另有 {selectedFiles.length - 5} 个文件</div> : null}
</div>
) : null}
</div>
<div className="grid gap-2 sm:grid-cols-2">
<Button
className="min-h-12 w-full"
data-testid="mobile-capture-upload"
disabled={uploadMutation.isPending || !selectedFile}
disabled={uploadMutation.isPending || selectedFiles.length === 0}
onClick={async () => {
if (!selectedFile) return;
if (!selectedFiles.length) return;
setMessage("");
setError("");
if (!isOnline) {
await enqueueInvoiceUpload(selectedFile);
for (const file of selectedFiles) {
await enqueueInvoiceUpload(file);
}
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
setSelectedFile(null);
setMessage("当前离线,已加入待上传队列");
setSelectedFiles([]);
setMessage(`当前离线,已加入待上传队列 ${selectedFiles.length} 项`);
return;
}
try {
await uploadMutation.mutateAsync(selectedFile);
} catch (err) {
if (isLikelyNetworkError(err)) {
await enqueueInvoiceUpload(selectedFile);
await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] });
setSelectedFile(null);
setError("");
setMessage("上传失败,已加入离线队列等待重试");
return;
}
throw err;
}
uploadMutation.mutate(selectedFiles);
}}
type="button"
>
{uploadMutation.isPending ? "上传中..." : "立即上传"}
{uploadMutation.isPending
? `上传中 ${uploadProgress.completed}/${uploadProgress.total}`
: "立即上传"}
</Button>
<Button
+127 -64
查看文件
@@ -124,9 +124,10 @@ export function ReimbursementDetailPage() {
const [invoiceSearchInput, setInvoiceSearchInput] = useState("");
const [invoiceSearch, setInvoiceSearch] = useState("");
const [selectedInvoices, setSelectedInvoices] = useState<SelectedInvoiceMap>({});
const [selectedInvoiceFile, setSelectedInvoiceFile] = useState<File | null>(null);
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [pendingOcrInvoiceId, setPendingOcrInvoiceId] = useState<string | null>(null);
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
expenseTypeId: "",
expenseDate: "",
@@ -185,6 +186,7 @@ export function ReimbursementDetailPage() {
search: invoiceSearch || undefined,
limit: 20,
}),
refetchInterval: pendingOcrInvoiceIds.length ? 3_000 : false,
});
useEffect(() => {
@@ -442,8 +444,8 @@ export function ReimbursementDetailPage() {
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoices({});
setSelectedInvoiceFile(null);
setPendingOcrInvoiceId(null);
setSelectedInvoiceFiles([]);
setPendingOcrInvoiceIds([]);
toast.success(`已绑定 ${succeededIds.length} 张发票`);
return;
}
@@ -484,70 +486,113 @@ export function ReimbursementDetailPage() {
});
const uploadInvoiceMutation = useMutation({
mutationFn: async (file: File) => {
mutationFn: async (files: File[]) => {
const ownerEmployeeId = detailQuery.data?.ownerId;
if (!ownerEmployeeId) {
throw new Error("missing ownerEmployeeId");
}
return uploadInvoice(file, "vat", ownerEmployeeId);
if (!files.length) {
throw new Error("请选择发票文件");
}
const uploadFailures: Array<{ file: File; message: string }> = [];
const pendingById = new Map<string, string>();
const availableIds: string[] = [];
const ocrFailures: string[] = [];
setInvoiceUploadProgress({ completed: 0, total: files.length });
for (const [index, file] of files.entries()) {
try {
const result = await uploadInvoice(file, "vat", ownerEmployeeId);
if (result.status === "available") {
availableIds.push(result.invoicePoolId);
} else if (result.status === "pending_ocr") {
pendingById.set(result.invoicePoolId, file.name);
}
} catch (error) {
uploadFailures.push({ file, message: buildActionErrorMessage(error, "上传发票失败") });
}
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 Promise.all(
Array.from(pendingById.keys()).map(async (invoiceId) => {
try {
return await fetchInvoicePoolDetail(invoiceId);
} catch {
return null;
}
}),
);
for (const invoice of invoiceResults) {
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}: ${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}`);
pendingById.delete(invoice.id);
}
}
setPendingOcrInvoiceIds(Array.from(pendingById.keys()));
}
return {
uploadedCount: files.length - uploadFailures.length,
uploadFailures,
availableIds,
pendingIds: Array.from(pendingById.keys()),
ocrFailures,
};
},
onSuccess: async (result) => {
setInvoiceActionError("");
setSelectedInvoiceFile(null);
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoiceFiles(result.uploadFailures.map((item) => item.file));
setPendingOcrInvoiceIds(result.pendingIds);
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("发票上传成功,可直接绑定");
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
const refreshed = await bindableInvoicesQuery.refetch();
const bindableIds = new Set((refreshed.data?.items ?? []).map((item) => item.id));
const selectableIds = result.availableIds.filter((invoiceId) => bindableIds.has(invoiceId));
if (selectableIds.length) {
setSelectedInvoices((previous) => ({
...previous,
...Object.fromEntries(selectableIds.map((invoiceId) => [invoiceId, previous[invoiceId] ?? ""])),
}));
}
const messages = [
...result.uploadFailures.map((item) => `${item.file.name}: ${item.message}`),
...result.ocrFailures,
];
if (result.pendingIds.length) {
messages.push(`${result.pendingIds.length} 张发票仍在 OCR 处理中,请稍后刷新列表或到票据中心查看`);
}
if (messages.length) {
const message = messages.join(";");
setInvoiceActionError(message);
toast.error(`发票上传完成:${result.uploadedCount} 张已上传,${messages.length} 项需处理`);
return;
}
setPendingOcrInvoiceId(result.invoicePoolId);
toast.success("发票上传成功,OCR 识别中");
const deadline = Date.now() + 60_000;
while (Date.now() < deadline) {
await sleep(2_000);
try {
const invoice = await fetchInvoicePoolDetail(result.invoicePoolId);
if (invoice.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("发票 OCR 已完成,可直接绑定");
return;
}
if (invoice.status === "ocr_failed") {
setPendingOcrInvoiceId(null);
const message = `发票 OCR 未完成:${formatInvoiceOcrErrorMessage(invoice.ocrLastError)}可前往票据中心继续修正或重试。`;
setInvoiceActionError(message);
toast.error("发票 OCR 识别失败,请在当前提示或票据中心继续处理");
return;
}
} catch {
// Polling errors should not block eventual refresh; keep retrying until timeout.
}
}
setPendingOcrInvoiceId(null);
await queryClient.invalidateQueries({ queryKey: ["invoice-pool", "bindable", reimbursementId] });
setInvoiceActionError("发票仍在 OCR 处理中,请稍后刷新列表;若长时间未完成,可前往票据中心查看详情。");
toast("发票仍在 OCR 处理中,请稍后手动刷新候选列表");
setInvoiceActionError("");
toast.success(`发票上传完成:${selectableIds.length || result.uploadedCount} 张可绑定`);
},
onSettled: () => setInvoiceUploadProgress({ completed: 0, total: 0 }),
onError: (error) => {
const message = buildActionErrorMessage(error, "上传发票失败");
setInvoiceActionError(message);
@@ -698,9 +743,9 @@ export function ReimbursementDetailPage() {
setInvoiceSearchInput("");
setInvoiceSearch("");
setSelectedInvoices({});
setSelectedInvoiceFile(null);
setSelectedInvoiceFiles([]);
setInvoiceActionError("");
setPendingOcrInvoiceId(null);
setPendingOcrInvoiceIds([]);
}
};
@@ -1376,7 +1421,11 @@ export function ReimbursementDetailPage() {
htmlFor="reimbursement-inline-invoice-upload"
className="inline-flex cursor-pointer items-center justify-center rounded-md border border-dashed border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-600 transition-colors hover:bg-zinc-100 hover:text-zinc-900"
>
{selectedInvoiceFile ? selectedInvoiceFile.name : "选择发票文件"}
{selectedInvoiceFiles.length
? selectedInvoiceFiles.length === 1
? selectedInvoiceFiles[0].name
: `已选择 ${selectedInvoiceFiles.length} 个文件`
: "选择发票文件"}
</label>
<input
id="reimbursement-inline-invoice-upload"
@@ -1384,21 +1433,35 @@ export function ReimbursementDetailPage() {
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)}
multiple
onChange={(event) => setSelectedInvoiceFiles(Array.from(event.target.files ?? []))}
/>
<Button
data-testid={TEST_IDS.reimbursement.invoiceUploadSubmit}
type="button"
disabled={!selectedInvoiceFile || uploadInvoiceMutation.isPending}
onClick={() => selectedInvoiceFile && uploadInvoiceMutation.mutate(selectedInvoiceFile)}
disabled={!selectedInvoiceFiles.length || uploadInvoiceMutation.isPending}
onClick={() => selectedInvoiceFiles.length && uploadInvoiceMutation.mutate(selectedInvoiceFiles)}
>
{uploadInvoiceMutation.isPending ? "上传中..." : "上传并等待 OCR"}
{uploadInvoiceMutation.isPending
? `上传中 ${invoiceUploadProgress.completed}/${invoiceUploadProgress.total}`
: "上传并等待 OCR"}
</Button>
</div>
{pendingOcrInvoiceId ? (
{selectedInvoiceFiles.length ? (
<div className="mt-3 rounded-lg border border-zinc-200 bg-white px-3 py-2 text-xs text-zinc-500">
{selectedInvoiceFiles.slice(0, 5).map((file) => (
<div key={`${file.name}-${file.size}`} className="truncate">
{file.name} · {Math.ceil(file.size / 1024)} KB
</div>
))}
{selectedInvoiceFiles.length > 5 ? <div>另有 {selectedInvoiceFiles.length - 5} 个文件</div> : null}
</div>
) : null}
{pendingOcrInvoiceIds.length ? (
<p className="mt-3 text-xs text-zinc-500" data-testid={TEST_IDS.reimbursement.invoicePendingHint}>
发票 {pendingOcrInvoiceId.slice(0, 8)} 正在 OCR 识别中,系统会在可绑定后自动刷新列表。
{pendingOcrInvoiceIds.length} 张发票正在 OCR 识别中,系统会在可绑定后自动刷新列表。
</p>
) : null}
</div>
@@ -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 (
<div className="card-std p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h3 className="card-title">筛选与列表</h3>
<div className="flex items-center gap-2">
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<select
className="select-sm"
data-testid="invoice-filter-status"
@@ -72,7 +73,72 @@ export function InvoiceListCard({
{listQuery.isLoading ? <TableSkeleton columns={5} rows={5} /> : null}
{!listQuery.isLoading ? (
<>
<div className="table-container">
<div className="space-y-3 md:hidden" data-testid="invoice-pool-mobile-list">
{(listQuery.data?.items ?? []).length ? (
listQuery.data?.items.map((item) => (
<article className="rounded-xl border border-zinc-200 bg-white p-4 shadow-sm" data-testid="invoice-mobile-row" key={item.id}>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{item.originalName || item.id}</div>
<div className="mt-1 font-mono text-xs text-zinc-500">{item.id.slice(0, 8)}</div>
</div>
<StatusBadge className="shrink-0" status={item.status} />
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-sm">
<div>
<div className="text-xs text-zinc-500">发票号码</div>
<div className="truncate font-medium text-zinc-900">
{item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</div>
</div>
<div>
<div className="text-xs text-zinc-500">金额</div>
<div className="font-medium text-zinc-900">{item.totalAmount ?? "-"}</div>
</div>
</div>
<div className="mt-3 rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{item.status === "pending_ocr"
? `OCR 识别中,重试 ${item.ocrAttempts} 次`
: item.status === "ocr_failed"
? `OCR 失败:${formatInvoiceOcrErrorMessage(item.ocrLastError)}`
: "OCR 已完成或无需处理"}
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
<Button
className="min-h-10"
data-testid="invoice-mobile-detail-button"
onClick={() => setDetailInvoiceId(item.id)}
size="sm"
type="button"
variant="secondary"
>
查看详情
</Button>
<Button
className="min-h-10"
data-testid="invoice-mobile-preview-button"
disabled={signedUrlMutation.isPending}
onClick={() => signedUrlMutation.mutate(item.id)}
size="sm"
type="button"
variant="secondary"
>
预览
</Button>
</div>
</article>
))
) : (
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
暂无票据数据
</div>
)}
</div>
<div className="table-container hidden md:block">
<table className="w-full text-left text-sm text-zinc-600" data-testid="invoice-pool-table">
<thead className="bg-zinc-50/50">
<tr>
@@ -1,26 +1,36 @@
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { FetchMyDelegationsResponse } from "../../api/delegations";
import { Button } from "../../components/ui/button";
export type InvoiceUploadCardProps = {
uploadMutation: UseMutationResult<{ invoicePoolId: string; status: string; fileSha256: string }, Error, void>;
selectedFile: File | null;
setSelectedFile: (file: File | null) => void;
selectedFiles: File[];
setSelectedFiles: (files: File[]) => void;
isUploading: boolean;
uploadProgressLabel: string;
onUpload: () => void;
showProxyOwnerSelector: boolean;
proxyDelegationsQuery: UseQueryResult<FetchMyDelegationsResponse, Error>;
proxyDelegationsQuery: { data?: FetchMyDelegationsResponse };
uploadOwnerEmployeeId: string;
setUploadOwnerEmployeeId: (id: string) => void;
};
export function InvoiceUploadCard({
uploadMutation,
selectedFile,
setSelectedFile,
selectedFiles,
setSelectedFiles,
isUploading,
uploadProgressLabel,
onUpload,
showProxyOwnerSelector,
proxyDelegationsQuery,
uploadOwnerEmployeeId,
setUploadOwnerEmployeeId,
}: InvoiceUploadCardProps) {
const selectedLabel =
selectedFiles.length === 0
? "选择文件"
: selectedFiles.length === 1
? selectedFiles[0].name
: `已选择 ${selectedFiles.length} 个文件`;
return (
<div className="card-std p-6 space-y-4">
<h3 className="card-title">上传票据(VAT OCR)</h3>
@@ -47,27 +57,39 @@ export function InvoiceUploadCard({
className="cursor-pointer inline-flex h-9 items-center justify-center rounded-md border border-dashed border-zinc-300 bg-zinc-50 px-4 py-2 text-sm font-medium text-zinc-600 shadow-sm transition-colors hover:bg-zinc-100 hover:text-zinc-900 focus-within:ring-1 focus-within:ring-zinc-400"
>
<span className="material-symbols-outlined mr-2 text-[18px]">cloud_upload</span>
{selectedFile ? selectedFile.name : "选择文件"}
{selectedLabel}
</label>
<input
id="file-upload"
className="hidden"
accept="application/pdf,image/jpeg,image/png,image/webp"
data-testid="invoice-upload-file"
onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)}
multiple
onChange={(event) => setSelectedFiles(Array.from(event.target.files ?? []))}
type="file"
/>
<Button
data-testid="invoice-upload-submit"
className={selectedFile ? "" : "opacity-50"}
disabled={uploadMutation.isPending || !selectedFile}
onClick={() => uploadMutation.mutate()}
className={selectedFiles.length ? "" : "opacity-50"}
disabled={isUploading || selectedFiles.length === 0}
onClick={onUpload}
type="button"
>
{uploadMutation.isPending ? "上传中..." : "上传并触发 OCR"}
{isUploading ? uploadProgressLabel || "上传中..." : "上传并触发 OCR"}
</Button>
</div>
{selectedFiles.length ? (
<div className="rounded-xl border border-zinc-200 bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
{selectedFiles.slice(0, 4).map((file) => (
<div key={`${file.name}-${file.size}`} className="truncate">
{file.name} · {Math.ceil(file.size / 1024)} KB
</div>
))}
{selectedFiles.length > 4 ? <div>另有 {selectedFiles.length - 4} 个文件</div> : null}
</div>
) : null}
</div>
);
}