From c632648e1612803b38eade8900716d5c3645974c Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 6 Mar 2026 09:48:11 +0800 Subject: [PATCH] =?UTF-8?q?refactor(ui):=20[UI7-1]=20=E6=B7=B1=E5=BA=A6?= =?UTF-8?q?=E7=BF=BB=E6=96=B0=E6=8A=A5=E9=94=80=E3=80=81=E5=8F=91=E7=A5=A8?= =?UTF-8?q?=E4=B8=8E=E7=94=A8=E6=88=B7=E7=AE=A1=E7=90=86=E7=AD=89=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E8=A7=86=E7=AA=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/pages/InvoicePoolPage.tsx | 701 ++++-------------- client/src/pages/ReimbursementDetailPage.tsx | 337 +++------ client/src/pages/ReimbursementsPage.tsx | 302 ++++---- client/src/pages/UserManagementPage.tsx | 367 ++------- .../ReimbursementActions.tsx | 131 ++++ .../ReimbursementItemEditor.tsx | 166 +++++ .../user-management/CreateUserDialog.tsx | 246 ++++++ .../pages/user-management/UserRoleDialog.tsx | 105 +++ 8 files changed, 1105 insertions(+), 1250 deletions(-) create mode 100644 client/src/pages/reimbursement-detail/ReimbursementActions.tsx create mode 100644 client/src/pages/reimbursement-detail/ReimbursementItemEditor.tsx create mode 100644 client/src/pages/user-management/CreateUserDialog.tsx create mode 100644 client/src/pages/user-management/UserRoleDialog.tsx diff --git a/client/src/pages/InvoicePoolPage.tsx b/client/src/pages/InvoicePoolPage.tsx index 57e4da72..d0152314 100644 --- a/client/src/pages/InvoicePoolPage.tsx +++ b/client/src/pages/InvoicePoolPage.tsx @@ -23,12 +23,15 @@ import { import { fetchReimbursements } from "../api/reimbursements"; import { useAuth } from "../auth/AuthContext"; import { hasPermission } from "../auth/permissions"; +import { toast } from "sonner"; import { downloadBlob } from "../lib/download"; import { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; -import { Card } from "../components/ui/card"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu"; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; +import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard"; +import { InvoiceListCard } from "./invoice-pool/InvoiceListCard"; +import { InvoiceBindCard } from "./invoice-pool/InvoiceBindCard"; +import { InvoiceDetailPanel } from "./invoice-pool/InvoiceDetailPanel"; const PAGE_SIZE = 20; @@ -58,6 +61,9 @@ export function InvoicePoolPage() { const [editInvoiceDate, setEditInvoiceDate] = useState(""); const [editTotalAmount, setEditTotalAmount] = useState(""); const [error, setError] = useState(""); + const [confirmAction, setConfirmAction] = useState<{ type: string; invoiceId: string } | null>(null); + + // --- Queries --- const listQuery = useQuery({ queryKey: ["invoice-pool", "list", status, ownerScope, page], @@ -105,6 +111,8 @@ export function InvoicePoolPage() { }, }); + // --- Side effects --- + useEffect(() => { if (!detailQuery.data || editOpen) { return; @@ -118,6 +126,30 @@ export function InvoicePoolPage() { setEditTotalAmount(detailQuery.data.totalAmount ?? ""); }, [detailQuery.data, editOpen]); + useEffect(() => { + if (!canManageInvoice && ownerScope === "all") { + setOwnerScope("own"); + } + }, [canManageInvoice, ownerScope]); + + useEffect(() => { + if (!detailInvoiceId && listQuery.data?.items?.length) { + setDetailInvoiceId(listQuery.data.items[0].id); + } + }, [detailInvoiceId, listQuery.data?.items]); + + // --- Helper for error handling --- + + const handleError = (err: unknown, fallback: string) => { + const msg = isApiError(err) + ? `${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}` + : err instanceof Error ? err.message : fallback; + setError(msg); + toast.error(msg); + }; + + // --- Mutations --- + const uploadMutation = useMutation({ mutationFn: async () => { if (!selectedFile) { @@ -130,16 +162,9 @@ export function InvoicePoolPage() { setDetailInvoiceId(data.invoicePoolId); setSelectedFile(null); setError(""); + toast.success("上传成功"); }, - onError: (err) => { - if (isApiError(err)) { - setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - } else if (err instanceof Error) { - setError(err.message); - } else { - setError("上传失败,请稍后重试"); - } - }, + onError: (err) => handleError(err, "上传失败,请稍后重试"), }); const bindMutation = useMutation({ @@ -152,16 +177,9 @@ export function InvoicePoolPage() { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); + toast.success("绑定成功"); }, - onError: (err) => { - if (isApiError(err)) { - setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - } else if (err instanceof Error) { - setError(err.message); - } else { - setError("绑定失败,请稍后重试"); - } - }, + onError: (err) => handleError(err, "绑定失败,请稍后重试"), }); const signedUrlMutation = useMutation({ @@ -178,18 +196,6 @@ export function InvoicePoolPage() { }, }); - useEffect(() => { - if (!canManageInvoice && ownerScope === "all") { - setOwnerScope("own"); - } - }, [canManageInvoice, ownerScope]); - - useEffect(() => { - if (!detailInvoiceId && listQuery.data?.items?.length) { - setDetailInvoiceId(listQuery.data.items[0].id); - } - }, [detailInvoiceId, listQuery.data?.items]); - const patchMutation = useMutation({ mutationFn: async () => { if (!detailInvoiceId) { @@ -209,16 +215,9 @@ export function InvoicePoolPage() { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setEditOpen(false); setError(""); + toast.success("修正已保存"); }, - onError: (err) => { - if (isApiError(err)) { - setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - } else if (err instanceof Error) { - setError(err.message); - } else { - setError("保存失败,请稍后重试"); - } - }, + onError: (err) => handleError(err, "保存失败,请稍后重试"), }); const archiveMutation = useMutation({ @@ -226,14 +225,9 @@ export function InvoicePoolPage() { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); + toast.success("已归档"); }, - onError: (err) => { - if (isApiError(err)) { - setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - } else { - setError("归档失败,请稍后重试"); - } - }, + onError: (err) => handleError(err, "归档失败,请稍后重试"), }); const unbindMutation = useMutation({ @@ -241,14 +235,9 @@ export function InvoicePoolPage() { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); + toast.success("已解绑"); }, - onError: (err) => { - if (isApiError(err)) { - setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - } else { - setError("解绑失败,请稍后重试"); - } - }, + onError: (err) => handleError(err, "解绑失败,请稍后重试"), }); const retryOcrMutation = useMutation({ @@ -256,14 +245,9 @@ export function InvoicePoolPage() { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); + toast.success("OCR 重试已触发"); }, - onError: (err) => { - if (isApiError(err)) { - setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - } else { - setError("OCR 重试失败,请稍后再试"); - } - }, + onError: (err) => handleError(err, "OCR 重试失败,请稍后再试"), }); const triggerVerificationMutation = useMutation({ @@ -274,14 +258,9 @@ export function InvoicePoolPage() { queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }), ]); setError(""); + toast.success("验真已触发"); }, - onError: (err) => { - if (isApiError(err)) { - setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - } else { - setError("触发验真失败,请稍后重试"); - } - }, + onError: (err) => handleError(err, "触发验真失败,请稍后重试"), }); const retryVerificationMutation = useMutation({ @@ -292,14 +271,9 @@ export function InvoicePoolPage() { queryClient.invalidateQueries({ queryKey: ["invoice-pool", "verification"] }), ]); setError(""); + toast.success("验真重试已触发"); }, - onError: (err) => { - if (isApiError(err)) { - setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - } else { - setError("重试验真失败,请稍后重试"); - } - }, + onError: (err) => handleError(err, "重试验真失败,请稍后重试"), }); const overrideDuplicateMutation = useMutation({ @@ -311,14 +285,9 @@ export function InvoicePoolPage() { onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] }); setError(""); + toast.success("判重例外已生效"); }, - onError: (err) => { - if (isApiError(err)) { - setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - } else { - setError("判重例外失败,请稍后重试"); - } - }, + onError: (err) => handleError(err, "判重例外失败,请稍后重试"), }); const exportMutation = useMutation({ @@ -331,25 +300,24 @@ export function InvoicePoolPage() { onSuccess: (response, format) => { downloadBlob(response.blob, response.filename ?? `invoice-pool.${format}`); setError(""); + toast.success("导出完成"); }, - onError: (err) => { - if (isApiError(err)) { - setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`); - } else { - setError("导出失败,请稍后重试"); - } - }, + onError: (err) => handleError(err, "导出失败,请稍后重试"), }); + // --- Derived state --- + const totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE)); const detail = detailQuery.data; const canEditDetail = Boolean( detail && - (canManageInvoice || - detail.ownerEmployeeId === me?.id || - detail.uploadedByEmployeeId === me?.id), + (canManageInvoice || + detail.ownerEmployeeId === me?.id || + detail.uploadedByEmployeeId === me?.id), ); + // --- Render --- + return (
- + - -

筛选与列表

-
- + - -
+ -
- - - - ID - 状态 - 发票号 - OCR - 操作 - - - - {(listQuery.data?.items ?? []).length ? ( - listQuery.data?.items.map((item) => ( - - {item.id} - {item.status} - - {item.invoiceCode || "-"}-{item.invoiceNumber || "-"} - - - {item.status === "pending_ocr" ? ( - - OCR 处理中(attempts={item.ocrAttempts}) - - ) : item.status === "ocr_failed" ? ( - - OCR 失败({item.ocrLastError || "unknown"}) - - ) : ( - attempts={item.ocrAttempts} - )} - - -
- - -
-
-
- )) - ) : ( - - - 暂无数据 - - - )} -
-
-
- -
- - 第 {page} / {totalPages} 页 - -
- - -
-
-
- - -

绑定报销

- - - - - -
- - -

详情轮询

- {detail ? ( - <> -
票据ID:{detail.id}
-
状态:{detail.status}
-
OCR错误:{detail.ocrLastError || "-"}
-
- 发票号:{detail.invoiceCode || "-"}-{detail.invoiceNumber || "-"} -
-
开票日期:{detail.invoiceDate || "-"}
-
总金额:{detail.totalAmount || "-"}
-
备注:{detail.remarks || "-"}
-
关联报销:{detail.linkedReimbursementId || "-"}
-
- 判重例外:{detail.isDuplicateOverride ? "已例外" : "否"}{" "} - {detail.overrideRefFormNumber ? `(ref=${detail.overrideRefFormNumber})` : ""} -
-
- 验真状态: - {detail.verification - ? `${detail.verification.status} (${detail.verification.provider})` - : "未触发"} -
- {detail.verification?.resultMessage ? ( -
验真结果:{detail.verification.resultMessage}
- ) : null} - {detail.verification?.errorMessage ? ( -
验真错误:{detail.verification.errorMessage}
- ) : null} - -
- {canVerifyInvoice ? ( - - ) : null} - - {canVerifyInvoice && - detail.verification && - ["failed", "error"].includes(detail.verification.status) ? ( - - ) : null} - - {canEditDetail ? ( - - ) : null} - - {canEditDetail && detail.status === "ocr_failed" ? ( - - ) : null} - - {canEditDetail && canUploadInvoice && detail.status === "bound" ? ( - - ) : null} - - {canEditDetail && detail.status !== "bound" && detail.status !== "archived" ? ( - - ) : null} - - {canOverrideDuplicate && - !detail.isDuplicateOverride && - ["available", "ocr_failed"].includes(detail.status) ? ( - - ) : null} -
- - {canVerifyInvoice ? ( -
-

验真记录

- {(verificationQuery.data?.items ?? []).length ? ( -
- {(verificationQuery.data?.items ?? []).map((record) => ( -
-
- {record.id} - {record.status} -
-
- provider={record.provider} attempts={record.attempts} requestedAt={record.requestedAt || "-"} -
- {record.resultMessage ?
{record.resultMessage}
: null} - {record.errorMessage ?
{record.errorMessage}
: null} -
- ))} -
- ) : ( -

暂无验真记录

- )} -
- ) : null} - - {editOpen ? ( -
-

人工修正(仅必要字段)

- - {detail.status === "ocr_failed" ? ( - - ) : null} - -
-
- - setEditInvoiceCode(event.target.value)} - value={editInvoiceCode} - /> -
-
- - setEditInvoiceNumber(event.target.value)} - value={editInvoiceNumber} - /> -
-
- - setEditInvoiceDate(event.target.value)} - type="date" - value={editInvoiceDate} - /> -
-
- - setEditTotalAmount(event.target.value)} - value={editTotalAmount} - /> -
-
- -