R92 A3-A4 优化发票上传状态与批量上传
这个提交包含在:
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户