实现报销材料连续预览(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-01 00:17:54 +08:00
父节点 b6485a278e
当前提交 8a9279efce
共修改 26 个文件,包含 1211 行新增和 178 行删除
@@ -23,6 +23,7 @@ import {
downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
MAX_REIMBURSEMENT_ATTACHMENT_FILES,
precheckReimbursement,
submitReimbursement,
@@ -56,6 +57,7 @@ import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFi
import { buildPrecheckDialogMessage } from "../lib/precheck";
import {
formatInvoiceDisplayTitle,
resolveInvoicePartyText,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
@@ -63,13 +65,13 @@ import {
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { formatDisplayDateTime } from "../lib/displayFormat";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { TEST_IDS } from "../lib/testIds";
import { withUploadTimeout } from "../lib/uploadTimeout";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { cn } from "../lib/utils";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
type ItemFormState = {
mode: "create" | "edit";
@@ -194,6 +196,7 @@ export function MobileReimbursementDetailPage() {
const [selectedInvoiceFiles, setSelectedInvoiceFiles] = useState<File[]>([]);
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [materialPreviewError, setMaterialPreviewError] = useState("");
const [invoiceActionMessage, setInvoiceActionMessage] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
@@ -285,6 +288,9 @@ export function MobileReimbursementDetailPage() {
invoiceNumber: item.invoiceNumber,
invoiceDate: item.invoiceDate,
sellerName: null,
buyerName: null,
documentCategory: item.documentCategory,
documentSubtype: item.documentSubtype,
totalAmount: item.totalAmount,
originalName: item.originalName,
createdAt: item.createdAt,
@@ -731,6 +737,36 @@ export function MobileReimbursementDetailPage() {
openAttachmentMutation.mutate({ attachment, reason });
}
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: async (bundle) => {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewImagesInWecom(bundle.pages.map((page) => page.url));
return;
} catch {
toast.error("企业微信滑动预览未打开,已切换到页面预览");
}
}
navigate(`/mobile/reimbursements/${reimbursementId}/materials`, {
state: { bundle, from: `/mobile/reimbursements/${reimbursementId}` },
});
},
onError: (error) => {
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
setMaterialPreviewError(message);
toast.error("连续预览生成失败,可逐份打开原件");
},
});
async function handleOpenAllMaterials() {
const governedAttachment = (detail?.attachments ?? []).find(attachmentRequiresDownloadReason);
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
if (reason === null) return;
materialPreviewMutation.mutate(reason);
}
const handleSubmit = async () => {
if (!detail) {
return;
@@ -905,6 +941,24 @@ export function MobileReimbursementDetailPage() {
) : null}
</div>
{detail.invoices.length || (detail.attachments ?? []).length ? (
<Button
className="mt-3 min-h-12 w-full"
data-testid="mobile-reimbursement-materials-open-all"
type="button"
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "生成预览中..." : "查看全部材料"}
</Button>
) : null}
{materialPreviewError ? (
<p className="mt-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{materialPreviewError},可在下方逐份打开原件。
</p>
) : null}
{itemError ? (
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{itemError}</div>
) : null}
@@ -984,28 +1038,6 @@ export function MobileReimbursementDetailPage() {
<div className="shrink-0 text-base font-semibold text-zinc-900">{formatCurrency(item.amount)}</div>
</div>
{item.description ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.description}</p> : null}
{detail.invoices
.filter((invoice) => invoice.linkedItemId === item.id)
.map((invoice) => (
<button
key={invoice.id}
type="button"
className="mt-3 flex min-h-11 w-full items-center gap-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-left transition-colors hover:bg-zinc-100 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openInvoiceMutation.isPending}
aria-label={`查看关联发票:${formatInvoiceDisplayTitle(invoice)}`}
onClick={() => openInvoiceMutation.mutate(invoice.id)}
>
<div className="min-w-0 flex-1">
<div className="truncate text-xs font-semibold text-zinc-800">
发票 · {formatInvoiceDisplayTitle(invoice)}
</div>
<div className="mt-0.5 truncate text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatCurrency(invoice.totalAmount)}
</div>
</div>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
))}
{canEdit ? (
<div className="mobile-two-action-grid mt-3">
<Button size="sm" variant="secondary" type="button" onClick={() => startEditItem(item)}>
@@ -1116,6 +1148,7 @@ export function MobileReimbursementDetailPage() {
) : null}
{detail.invoices.length || (detail.attachments ?? []).length ? (
canOpenInvoiceDialog || canManageAttachments || Boolean(materialPreviewError) ? (
<div className="mt-3 space-y-2">
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
{detail.invoices.map((invoice) => {
@@ -1133,7 +1166,7 @@ export function MobileReimbursementDetailPage() {
<span className="inline-flex rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
<span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
<span className="mt-1 block text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)}
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatCurrency(invoice.totalAmount)].filter(Boolean).join(" · ")}
</span>
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
@@ -1198,6 +1231,7 @@ export function MobileReimbursementDetailPage() {
))}
</div>
</div>
) : null
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无报销材料
@@ -1475,7 +1509,7 @@ export function MobileReimbursementDetailPage() {
) : null}
</div>
<div className="mt-1 text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)} · {formatCurrency(invoice.totalAmount)}
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDate(invoice.invoiceDate) : "", formatCurrency(invoice.totalAmount)].filter(Boolean).join(" · ")}
</div>
</div>
</div>
+68 -37
查看文件
@@ -15,6 +15,7 @@ import {
downloadReimbursementAttachment,
fetchReimbursementAttachmentPreviewUrl,
fetchReimbursementDetail,
fetchReimbursementMaterialPreviewBundle,
MAX_REIMBURSEMENT_ATTACHMENT_FILES,
precheckReimbursement,
submitReimbursement,
@@ -34,7 +35,7 @@ import {
} from "../api/invoicePool";
import { toast } from "sonner";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
@@ -61,13 +62,14 @@ import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import {
formatInvoiceDisplayTitle,
resolveInvoicePartyText,
resolveInvoiceNextStepText,
resolveInvoiceStatusLabel,
resolveInvoiceUserMessage,
} from "../lib/invoiceStatus";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
import { TEST_IDS } from "../lib/testIds";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
@@ -152,6 +154,7 @@ export function ReimbursementDetailPage() {
const [selectedAttachmentFiles, setSelectedAttachmentFiles] = useState<File[]>([]);
const [deleteAttachmentTarget, setDeleteAttachmentTarget] = useState<{ id: string; originalName: string } | null>(null);
const [invoiceActionError, setInvoiceActionError] = useState("");
const [materialPreviewError, setMaterialPreviewError] = useState("");
const [pendingOcrInvoiceIds, setPendingOcrInvoiceIds] = useState<string[]>([]);
const [invoiceUploadProgress, setInvoiceUploadProgress] = useState({ completed: 0, total: 0 });
const [editorInitialValues, setEditorInitialValues] = useState<ItemEditorInitialValues>({
@@ -727,6 +730,36 @@ export function ReimbursementDetailPage() {
openAttachmentMutation.mutate({ attachment, reason });
}
const materialPreviewMutation = useMutation({
mutationFn: (reason?: string) => fetchReimbursementMaterialPreviewBundle(reimbursementId, reason),
onSuccess: async (bundle) => {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewImagesInWecom(bundle.pages.map((page) => page.url));
return;
} catch {
toast.error("企业微信滑动预览未打开,已切换到页面预览");
}
}
navigate(`/reimbursements/${reimbursementId}/materials`, {
state: { bundle, from: `/reimbursements/${reimbursementId}` },
});
},
onError: (error) => {
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
setMaterialPreviewError(message);
toast.error("连续预览生成失败,可逐份打开原件");
},
});
async function handleOpenAllMaterials() {
const governedAttachment = (detailQuery.data?.attachments ?? []).find(attachmentRequiresDownloadReason);
const reason = governedAttachment ? await requestDownloadReason(governedAttachment) : undefined;
if (reason === null) return;
materialPreviewMutation.mutate(reason);
}
if (!reimbursementId) {
return (
<div className="card-std p-5">
@@ -1339,28 +1372,6 @@ export function ReimbursementDetailPage() {
{check.standardName}:当前金额超出费用标准。
</div>
))}
{detail.invoices
.filter((invoice) => invoice.linkedItemId === item.id)
.map((invoice) => (
<button
key={invoice.id}
type="button"
className="mt-2 flex min-h-11 w-full items-center gap-2 rounded-md border border-zinc-200 bg-zinc-50 px-3 py-2 text-left transition-colors hover:bg-zinc-100 active:bg-zinc-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2 disabled:cursor-wait disabled:opacity-60"
disabled={openInvoiceMutation.isPending}
aria-label={`查看关联发票:${formatInvoiceDisplayTitle(invoice)}`}
onClick={() => openInvoiceMutation.mutate(invoice.id)}
>
<span className="min-w-0 flex-1">
<span className="block truncate text-xs font-semibold text-zinc-800">
发票 · {formatInvoiceDisplayTitle(invoice)}
</span>
<span className="mt-0.5 block truncate text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · ¥{invoice.totalAmount ?? "-"}
</span>
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
))}
</TableCell>
<TableCell>
{canEditItems ? (
@@ -1426,18 +1437,36 @@ export function ReimbursementDetailPage() {
<p className="text-sm text-zinc-600">
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
</p>
{canOpenInvoiceBindDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
type="button"
variant="secondary"
onClick={() => handleInvoiceDialogOpenChange(true)}
>
添加发票
</Button>
) : null}
<div className="flex flex-wrap gap-2">
{detail.invoices.length || (detail.attachments ?? []).length ? (
<Button
data-testid="reimbursement-materials-open-all"
type="button"
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "生成预览中..." : "查看全部材料"}
</Button>
) : null}
{canOpenInvoiceBindDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
type="button"
variant="secondary"
onClick={() => handleInvoiceDialogOpenChange(true)}
>
添加发票
</Button>
) : null}
</div>
</div>
{materialPreviewError ? (
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
{materialPreviewError},可在下方逐份打开原件。
</p>
) : null}
{invoiceActionError ? (
<p
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
@@ -1447,6 +1476,7 @@ export function ReimbursementDetailPage() {
</p>
) : null}
{canOpenInvoiceBindDialog || materialPreviewError ? (
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
{detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice);
@@ -1463,7 +1493,7 @@ export function ReimbursementDetailPage() {
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</span>
<span className="mt-1 block text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {invoice.invoiceDate ? formatDisplayDate(invoice.invoiceDate) : "未识别日期"} · ¥{invoice.totalAmount ?? "-"}
{[resolveInvoicePartyText(invoice), invoice.invoiceDate ? formatDisplayDate(invoice.invoiceDate) : "", `¥${invoice.totalAmount ?? "-"}`].filter(Boolean).join(" · ")}
</span>
<span className="mt-1 block text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</span>
{invoiceHint ? <span className="mt-1 block text-xs text-zinc-500">{invoiceHint}</span> : null}
@@ -1486,6 +1516,7 @@ export function ReimbursementDetailPage() {
);
})}
</div>
) : null}
<div className="space-y-4" data-testid="reimbursement-attachments">
{canEditItems ? (
@@ -1531,7 +1562,7 @@ export function ReimbursementDetailPage() {
</div>
) : null}
{(detail.attachments ?? []).length ? (
{(detail.attachments ?? []).length && (canEditItems || Boolean(materialPreviewError)) ? (
<div className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
@@ -1820,7 +1851,7 @@ export function ReimbursementDetailPage() {
<div className="space-y-1">
<div className="font-medium text-zinc-900">{formatInvoiceDisplayTitle(item)}</div>
<div className="text-zinc-600">
{item.sellerName || "未识别销售方"} · {item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
{[resolveInvoicePartyText(item), item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "", `金额 ¥${item.totalAmount ?? "-"}`].filter(Boolean).join(" · ")}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDisplayDateTime(item.createdAt)}
@@ -0,0 +1,96 @@
import { useQuery } from "@tanstack/react-query";
import { ArrowLeft, RefreshCw } from "lucide-react";
import { useLocation, useNavigate, useParams } from "react-router-dom";
import { fetchReimbursementMaterialPreviewBundle, type ReimbursementMaterialPreviewBundle } from "../api/reimbursements";
import { CardSkeleton } from "../components/common/CardSkeleton";
import { Button } from "../components/ui/button";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
type MaterialPageState = {
bundle?: ReimbursementMaterialPreviewBundle;
from?: string;
};
function pageLabel(page: ReimbursementMaterialPreviewBundle["pages"][number]) {
const type = page.materialType === "invoice" ? "发票" : "补充附件";
return `${type} ${page.materialIndex}${page.pageNumber > 1 ? ` · 第 ${page.pageNumber} 页` : ""}`;
}
export function ReimbursementMaterialsPage() {
const { reimbursementId = "" } = useParams();
const location = useLocation();
const navigate = useNavigate();
const state = location.state as MaterialPageState | null;
const isMobile = location.pathname.startsWith("/mobile/");
const fallback = isMobile
? `/mobile/reimbursements/${reimbursementId}`
: `/reimbursements/${reimbursementId}`;
const bundleQuery = useQuery({
enabled: Boolean(reimbursementId) && !state?.bundle,
queryKey: ["reimbursements", reimbursementId, "material-preview-bundle"],
queryFn: () => fetchReimbursementMaterialPreviewBundle(reimbursementId),
staleTime: 0,
retry: false,
});
const bundle = state?.bundle ?? bundleQuery.data;
const handleBack = () => {
const from = typeof state?.from === "string" && state.from.startsWith("/")
? state.from
: fallback;
navigate(from, { replace: true });
};
if (!reimbursementId) {
return <div className="card-std p-6 text-sm text-red-600">缺少报销单参数。</div>;
}
return (
<div className={isMobile ? "space-y-3 pb-6" : "mx-auto max-w-5xl space-y-4 pb-8"} data-testid="reimbursement-material-viewer">
<header className="sticky top-0 z-20 flex min-h-14 items-center gap-3 border-b border-zinc-200 bg-white/95 px-3 py-2 backdrop-blur md:rounded-xl md:border">
<Button aria-label="返回报销单" className="h-11 w-11 shrink-0 p-0" type="button" variant="secondary" onClick={handleBack}>
<ArrowLeft aria-hidden="true" className="size-5" />
</Button>
<div className="min-w-0 flex-1">
<h1 className="truncate text-base font-semibold text-zinc-900">全部报销材料</h1>
<p className="text-xs text-zinc-500">按发票、补充附件和文件页序排列</p>
</div>
{bundle ? <span className="shrink-0 text-sm text-zinc-600">共 {bundle.pages.length} 页</span> : null}
</header>
{bundleQuery.isLoading && !bundle ? <CardSkeleton lines={8} /> : null}
{bundleQuery.isError && !bundle ? (
<div className="card-std space-y-3 p-5">
<p className="text-sm text-red-600">{resolveErrorMessage(bundleQuery.error)}</p>
<Button type="button" variant="secondary" disabled={bundleQuery.isFetching} onClick={() => void bundleQuery.refetch()}>
<RefreshCw aria-hidden="true" className="mr-2 size-4" />
{bundleQuery.isFetching ? "重新生成中..." : "重新生成预览"}
</Button>
</div>
) : null}
{bundle ? (
<div className="space-y-4 px-3 md:px-0">
{bundle.pages.map((page, index) => (
<figure key={`${page.materialType}-${page.materialIndex}-${page.pageNumber}-${index}`} className="overflow-hidden rounded-xl border border-zinc-200 bg-white shadow-sm">
<figcaption className="border-b border-zinc-100 px-4 py-3 text-sm font-medium text-zinc-700">
{pageLabel(page)}
</figcaption>
<div className="flex min-h-40 items-center justify-center bg-zinc-100 p-2 md:p-4">
<img
alt={`${pageLabel(page)}预览`}
className="h-auto max-w-full rounded-md bg-white"
decoding="async"
loading="eager"
src={page.url}
/>
</div>
</figure>
))}
</div>
) : null}
</div>
);
}
export default ReimbursementMaterialsPage;