import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { uploadInvoice } from "../api/invoicePool"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; import { PageHeader } from "../components/common/PageHeader"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { Button } from "../components/ui/button"; import { Spinner } from "../components/ui/spinner"; import { enqueueInvoiceUpload, listQueuedInvoiceUploads, queuedItemToFile, removeQueuedInvoiceUpload, type MobileUploadQueueItem, } from "../mobile/uploadQueue"; function formatTime(value: string) { try { return new Date(value).toLocaleString("zh-CN"); } catch { return value; } } function isLikelyNetworkError(error: unknown) { if (isApiError(error)) { return error.status >= 500; } 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 [selectedFiles, setSelectedFiles] = useState([]); const [uploadProgress, setUploadProgress] = useState({ completed: 0, total: 0 }); const [isOnline, setIsOnline] = useState(navigator.onLine); const queueQuery = useQuery({ queryKey: ["mobile", "invoice-upload-queue"], queryFn: () => listQueuedInvoiceUploads(), }); const uploadMutation = useMutation({ 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(""); 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)) { const msg = `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`; setError(msg); toast.error(msg); return; } const msg = err instanceof Error ? err.message : "上传失败"; setError(msg); toast.error(msg); }, onSettled: () => setUploadProgress({ completed: 0, total: 0 }), }); const syncMutation = useMutation({ mutationFn: async (items: MobileUploadQueueItem[]) => { let succeeded = 0; let failed = 0; for (const item of items) { try { const file = queuedItemToFile(item); await uploadInvoice(file, "vat"); await removeQueuedInvoiceUpload(item.id); succeeded += 1; } catch { failed += 1; } } return { succeeded, failed }; }, onSuccess: async (result) => { await queryClient.invalidateQueries({ queryKey: ["mobile", "invoice-upload-queue"] }); setError(""); setMessage(""); toast.success(`同步完成:成功 ${result.succeeded},失败 ${result.failed}`); }, onError: (err) => { const msg = err instanceof Error ? err.message : "队列同步失败"; setError(msg); toast.error(msg); }, }); const queuedItems = queueQuery.data ?? []; const queuedCountLabel = useMemo(() => { if (!queuedItems.length) return "无待上传项"; return `待上传 ${queuedItems.length} 项`; }, [queuedItems.length]); useEffect(() => { const onOnline = () => { setIsOnline(true); if (queuedItems.length) { syncMutation.mutate(queuedItems); } }; const onOffline = () => { setIsOnline(false); }; window.addEventListener("online", onOnline); window.addEventListener("offline", onOffline); return () => { window.removeEventListener("online", onOnline); window.removeEventListener("offline", onOffline); }; }, [queuedItems, syncMutation]); return ( {error ? (

{error}

) : null} {message ? (

{message}

) : null}

选择票据

图片或 PDF。

{isOnline ? "在线" : "离线"}
{ setSelectedFiles(Array.from(event.target.files ?? [])); }} multiple type="file" />
当前文件: {selectedFiles.length ? `已选择 ${selectedFiles.length} 个` : "未选择"} {selectedFiles.length ? (
{selectedFiles.slice(0, 5).map((file) => (
{file.name} ({Math.ceil(file.size / 1024)} KB)
))} {selectedFiles.length > 5 ?
另有 {selectedFiles.length - 5} 个文件
: null}
) : null}

离线上传队列

{queuedCountLabel}
{queueQuery.isLoading ?
加载中...
: null} {!queueQuery.isLoading && queuedItems.length === 0 ? (

暂无待上传文件。

) : null} {queuedItems.map((item) => (
{item.fileName}
创建时间:{formatTime(item.createdAt)}
))}
); }