实现报销材料连续预览(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户