feat(client): 完成 P3 业务页联动闭环(D6/D8/D10/D11)

- 待办:列表/详情 + approve/reject/return/transfer(D6)
- 报销:列表/详情 + create/submit/withdraw/pay(D8)
- 票据:列表/上传/OCR轮询/绑定/预览(D10)
- 统一:Api types + 错误映射 + react-query缓存失效(D11)
这个提交包含在:
2026-02-08 23:08:41 +08:00
父节点 65e7b37ebd
当前提交 7a6f5573a6
共修改 16 个文件,包含 1762 行新增和 31 行删除
+311 -7
查看文件
@@ -1,17 +1,321 @@
import { EmptyStateCard } from "../components/common/EmptyStateCard";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import {
bindInvoice,
fetchInvoicePoolDetail,
fetchInvoicePoolList,
fetchInvoiceSignedUrl,
uploadInvoice,
type InvoicePoolStatus,
} from "../api/invoicePool";
import { fetchReimbursements } from "../api/reimbursements";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
const PAGE_SIZE = 20;
export function InvoicePoolPage() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
const canManageInvoice = hasPermission(permissions, "invoice.manage");
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 [selectedInvoiceId, setSelectedInvoiceId] = useState("");
const [selectedReimbursementId, setSelectedReimbursementId] = useState("");
const [detailInvoiceId, setDetailInvoiceId] = useState("");
const [error, setError] = useState("");
const listQuery = useQuery({
queryKey: ["invoice-pool", "list", status, ownerScope, page],
queryFn: () =>
fetchInvoicePoolList({
status: status || undefined,
ownerScope,
page,
pageSize: PAGE_SIZE,
}),
});
const reimbursementQuery = useQuery({
queryKey: ["reimbursements", "for-bind"],
queryFn: () => fetchReimbursements({ page: 1, pageSize: 50, scope: "own" }),
});
const detailQuery = useQuery({
enabled: Boolean(detailInvoiceId),
queryKey: ["invoice-pool", "detail", detailInvoiceId],
queryFn: () => fetchInvoicePoolDetail(detailInvoiceId),
refetchInterval: (query) => {
const statusValue = query.state.data?.status;
if (statusValue === "pending_ocr") {
return 3_000;
}
return false;
},
});
const uploadMutation = useMutation({
mutationFn: async () => {
if (!selectedFile) {
throw new Error("请选择文件");
}
return uploadInvoice(selectedFile, "vat");
},
onSuccess: async (data) => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setDetailInvoiceId(data.invoicePoolId);
setSelectedFile(null);
setError("");
},
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("上传失败,请稍后重试");
}
},
});
const bindMutation = useMutation({
mutationFn: async () => {
if (!selectedInvoiceId || !selectedReimbursementId) {
throw new Error("请选择票据与报销单");
}
return bindInvoice(selectedInvoiceId, selectedReimbursementId);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["invoice-pool"] });
setError("");
},
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("绑定失败,请稍后重试");
}
},
});
const signedUrlMutation = useMutation({
mutationFn: async (invoiceId: string) => fetchInvoiceSignedUrl(invoiceId),
onSuccess: (data) => {
window.open(data.signedUrl, "_blank", "noopener,noreferrer");
},
onError: (err) => {
if (isApiError(err)) {
setError(`${err.code}: ${mapApiErrorMessage(err.code, err.details, err.message)}`);
} else {
setError("预览链接获取失败");
}
},
});
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 totalPages = Math.max(1, Math.ceil((listQuery.data?.total ?? 0) / PAGE_SIZE));
return (
<div>
<div className="space-y-6">
<PageHeader
title="票据中心"
subtitle="此页面已接入权限守卫,后续将接入上传、绑定、签名 URL 等功能。"
/>
<EmptyStateCard
title="功能建设中"
description="后续迭代将实现发票池列表、上传、绑定、归档与签名预览。"
subtitle="支持上传、OCR 状态轮询、绑定报销与签名预览。"
/>
{error ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
) : null}
<section className="card-std space-y-3">
<h3 className="text-base font-semibold text-slate-900">上传票据(VAT OCR)</h3>
<input
accept="application/pdf,image/jpeg,image/png,image/webp"
onChange={(event) => setSelectedFile(event.target.files?.[0] ?? null)}
type="file"
/>
<button className="btn-primary" onClick={() => uploadMutation.mutate()} type="button">
{uploadMutation.isPending ? "上传中..." : "上传并触发OCR"}
</button>
</section>
<section className="card-std space-y-3">
<h3 className="text-base font-semibold text-slate-900">筛选与列表</h3>
<div className="flex flex-wrap gap-2">
<select
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
onChange={(event) => {
setStatus(event.target.value as InvoicePoolStatus | "");
setPage(1);
}}
value={status}
>
<option value="">全部状态</option>
<option value="pending_ocr">pending_ocr</option>
<option value="available">available</option>
<option value="ocr_failed">ocr_failed</option>
<option value="bound">bound</option>
<option value="archived">archived</option>
</select>
<select
className="rounded-lg border border-slate-300 px-3 py-2 text-sm"
onChange={(event) => {
setOwnerScope(event.target.value as "own" | "all");
setPage(1);
}}
value={ownerScope}
>
<option value="own">own</option>
<option value="all">all</option>
</select>
</div>
<div className="overflow-x-auto rounded-lg border border-slate-200">
<table className="min-w-full text-left text-sm">
<thead className="bg-slate-50 text-slate-600">
<tr>
<th className="px-3 py-2">ID</th>
<th className="px-3 py-2">状态</th>
<th className="px-3 py-2">发票号</th>
<th className="px-3 py-2">OCR</th>
<th className="px-3 py-2">操作</th>
</tr>
</thead>
<tbody>
{(listQuery.data?.items ?? []).length ? (
listQuery.data?.items.map((item) => (
<tr className="border-t border-slate-100" key={item.id}>
<td className="px-3 py-2 font-mono text-xs">{item.id}</td>
<td className="px-3 py-2">{item.status}</td>
<td className="px-3 py-2">
{item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
</td>
<td className="px-3 py-2">
attempts={item.ocrAttempts}
{item.ocrLastError ? ` / ${item.ocrLastError}` : ""}
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-2">
<button
className="btn-secondary"
onClick={() => setDetailInvoiceId(item.id)}
type="button"
>
详情
</button>
<button
className="btn-secondary"
onClick={() => signedUrlMutation.mutate(item.id)}
type="button"
>
预览
</button>
</div>
</td>
</tr>
))
) : (
<tr>
<td className="px-3 py-3 text-slate-500" colSpan={5}>
暂无数据
</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between text-sm">
<span>
第 {page} / {totalPages} 页
</span>
<div className="flex gap-2">
<button
className="btn-secondary"
disabled={page <= 1}
onClick={() => setPage((current) => Math.max(1, current - 1))}
type="button"
>
上一页
</button>
<button
className="btn-secondary"
disabled={page >= totalPages}
onClick={() => setPage((current) => Math.min(totalPages, current + 1))}
type="button"
>
下一页
</button>
</div>
</div>
</section>
<section className="card-std space-y-3">
<h3 className="text-base font-semibold text-slate-900">绑定报销</h3>
<select
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
onChange={(event) => setSelectedInvoiceId(event.target.value)}
value={selectedInvoiceId}
>
<option value="">选择票据</option>
{(listQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.id} ({item.status})
</option>
))}
</select>
<select
className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm"
onChange={(event) => setSelectedReimbursementId(event.target.value)}
value={selectedReimbursementId}
>
<option value="">选择报销单</option>
{(reimbursementQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.formNumber ?? item.id} ({item.status})
</option>
))}
</select>
<button className="btn-secondary" onClick={() => bindMutation.mutate()} type="button">
{bindMutation.isPending ? "绑定中..." : "绑定票据"}
</button>
</section>
<section className="card-std space-y-2 text-sm">
<h3 className="text-base font-semibold text-slate-900">详情轮询</h3>
{detailQuery.data ? (
<>
<div>票据ID:{detailQuery.data.id}</div>
<div>状态:{detailQuery.data.status}</div>
<div>OCR错误:{detailQuery.data.ocrLastError || "-"}</div>
<div>
发票号:{detailQuery.data.invoiceCode || "-"}-{detailQuery.data.invoiceNumber || "-"}
</div>
</>
) : (
<p className="text-slate-500">请选择一条票据查看详情</p>
)}
</section>
</div>
);
}