实现报销材料连续预览(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -28,6 +28,8 @@ export type InvoicePoolListItem = {
|
||||
ocrAttempts: number;
|
||||
ocrLastError: string | null;
|
||||
ocrCompletedAt: string | null;
|
||||
documentCategory: string | null;
|
||||
documentSubtype: string | null;
|
||||
invoiceCode: string | null;
|
||||
invoiceNumber: string | null;
|
||||
invoiceDate: string | null;
|
||||
@@ -94,6 +96,8 @@ export type InvoicePoolDetail = {
|
||||
ocrLastRequestedAt: string | null;
|
||||
ocrLastError: string | null;
|
||||
ocrCompletedAt: string | null;
|
||||
documentCategory: string | null;
|
||||
documentSubtype: string | null;
|
||||
invoiceCode: string | null;
|
||||
invoiceNumber: string | null;
|
||||
invoiceDate: string | null;
|
||||
@@ -136,6 +140,9 @@ export type BindableInvoiceSummary = {
|
||||
invoiceNumber: string | null;
|
||||
invoiceDate: string | null;
|
||||
sellerName: string | null;
|
||||
buyerName: string | null;
|
||||
documentCategory: string | null;
|
||||
documentSubtype: string | null;
|
||||
totalAmount: string | null;
|
||||
originalName: string | null;
|
||||
createdAt: string | null;
|
||||
@@ -156,6 +163,9 @@ export type InvoiceStatusItem = {
|
||||
invoiceNumber: string | null;
|
||||
invoiceDate: string | null;
|
||||
sellerName: string | null;
|
||||
buyerName: string | null;
|
||||
documentCategory: string | null;
|
||||
documentSubtype: string | null;
|
||||
totalAmount: string | null;
|
||||
originalName: string | null;
|
||||
linkedReimbursementId: string | null;
|
||||
|
||||
@@ -173,6 +173,9 @@ export type ReimbursementDetail = {
|
||||
invoiceNumber: string | null;
|
||||
invoiceDate: string | null;
|
||||
sellerName: string | null;
|
||||
buyerName: string | null;
|
||||
documentCategory: string | null;
|
||||
documentSubtype: string | null;
|
||||
totalAmount: string | null;
|
||||
originalName: string | null;
|
||||
mimeType: string | null;
|
||||
@@ -544,6 +547,29 @@ export async function fetchReimbursementAttachmentPreviewUrl(
|
||||
});
|
||||
}
|
||||
|
||||
export type ReimbursementMaterialPreviewBundle = {
|
||||
expiresAt: string;
|
||||
pages: Array<{
|
||||
url: string;
|
||||
materialType: "invoice" | "attachment";
|
||||
materialIndex: number;
|
||||
pageNumber: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export async function fetchReimbursementMaterialPreviewBundle(
|
||||
reimbursementId: string,
|
||||
reason?: string,
|
||||
) {
|
||||
return apiRequestData<ReimbursementMaterialPreviewBundle>(
|
||||
`/api/reimbursements/${reimbursementId}/materials/preview-bundle`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export async function linkReimbursementProject(reimbursementId: string, projectId: string | null) {
|
||||
return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/reimbursements/${reimbursementId}/link-project`, {
|
||||
|
||||
@@ -7,6 +7,8 @@ type InvoiceDisplayLike = {
|
||||
invoiceNumber?: string | null;
|
||||
invoiceDate?: string | null;
|
||||
sellerName?: string | null;
|
||||
buyerName?: string | null;
|
||||
documentCategory?: string | null;
|
||||
originalName?: string | null;
|
||||
unavailableReason?: string | null;
|
||||
unavailableMessage?: string | null;
|
||||
@@ -18,6 +20,26 @@ type InvoiceDisplayLike = {
|
||||
ocrLastError?: string | null;
|
||||
};
|
||||
|
||||
const INVOICE_CATEGORY_LABELS: Record<string, string> = {
|
||||
vat_invoice: "增值税发票",
|
||||
full_vat_invoice: "数电发票",
|
||||
electronic_train_ticket: "电子火车票",
|
||||
};
|
||||
|
||||
export function resolveInvoiceTypeLabel(invoice: InvoiceDisplayLike | null | undefined) {
|
||||
return invoice?.documentCategory && INVOICE_CATEGORY_LABELS[invoice.documentCategory]
|
||||
? INVOICE_CATEGORY_LABELS[invoice.documentCategory]
|
||||
: "发票";
|
||||
}
|
||||
|
||||
export function resolveInvoicePartyText(invoice: InvoiceDisplayLike | null | undefined) {
|
||||
if (!invoice) return "";
|
||||
if (invoice.documentCategory === "electronic_train_ticket") {
|
||||
return invoice.buyerName ? `购买方:${invoice.buyerName}` : "";
|
||||
}
|
||||
return invoice.sellerName ? `销售方:${invoice.sellerName}` : "";
|
||||
}
|
||||
|
||||
const INVOICE_STATUS_LABELS: Record<InvoicePoolStatus, string> = {
|
||||
pending_ocr: "识别中",
|
||||
ocr_failed: "识别失败",
|
||||
@@ -37,11 +59,16 @@ export function formatInvoiceDisplayTitle(invoice: InvoiceDisplayLike | null | u
|
||||
return "未识别发票";
|
||||
}
|
||||
|
||||
if (invoice.invoiceCode || invoice.invoiceNumber) {
|
||||
return `${invoice.invoiceCode || "无代码"}-${invoice.invoiceNumber || "无号码"}`;
|
||||
const typeLabel = resolveInvoiceTypeLabel(invoice);
|
||||
if (invoice.invoiceCode && invoice.invoiceNumber) {
|
||||
return `${typeLabel} · ${invoice.invoiceCode}-${invoice.invoiceNumber}`;
|
||||
}
|
||||
|
||||
return invoice.sellerName || invoice.originalName || "未识别发票";
|
||||
if (invoice.invoiceNumber || invoice.invoiceCode) {
|
||||
return `${typeLabel} · ${invoice.invoiceNumber || invoice.invoiceCode}`;
|
||||
}
|
||||
|
||||
return invoice.sellerName || invoice.originalName || typeLabel;
|
||||
}
|
||||
|
||||
function looksLikeMachineMessage(message: string) {
|
||||
|
||||
@@ -24,7 +24,7 @@ async function configuredWecomSdk() {
|
||||
}
|
||||
ww.register({
|
||||
corpId: config.corpId,
|
||||
jsApiList: ["previewFile"],
|
||||
jsApiList: ["previewFile", "previewImage"],
|
||||
async getConfigSignature(url) {
|
||||
const signed = await fetchWecomJsSdkSignature(url || currentPageUrl());
|
||||
return {
|
||||
@@ -58,6 +58,19 @@ export async function previewFileInWecom(file: NativePreviewFile) {
|
||||
return true;
|
||||
}
|
||||
|
||||
export async function previewImagesInWecom(urls: string[], current = urls[0]) {
|
||||
if (!isWecomWebView()) {
|
||||
return false;
|
||||
}
|
||||
const normalized = urls.map((url) => url.trim()).filter(Boolean);
|
||||
if (!normalized.length || !current || !normalized.includes(current)) {
|
||||
throw new Error("图片预览信息不完整");
|
||||
}
|
||||
const ww = await configuredWecomSdk();
|
||||
await ww.previewImage({ current, urls: normalized });
|
||||
return true;
|
||||
}
|
||||
|
||||
export function openFileInBrowser(url: string) {
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -62,6 +62,7 @@ import {
|
||||
ReimbursementDetailPage,
|
||||
ReimbursementDashboardPage,
|
||||
ReimbursementPrintPage,
|
||||
ReimbursementMaterialsPage,
|
||||
ReimbursementTemplatesPage,
|
||||
ReimbursementsPage,
|
||||
TravelRequestsPage,
|
||||
@@ -232,6 +233,15 @@ const routeElements = (
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="reimbursements/:reimbursementId/materials"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
|
||||
{withRouteFallback(<ReimbursementMaterialsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="leave-requests"
|
||||
element={
|
||||
@@ -388,6 +398,15 @@ const routeElements = (
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/reimbursements/:reimbursementId/materials"
|
||||
element={
|
||||
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
|
||||
{withRouteFallback(<ReimbursementMaterialsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="mobile/leave"
|
||||
element={
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { ReimbursementMaterialsPage as default } from "../../pages/ReimbursementMaterialsPage";
|
||||
@@ -51,6 +51,7 @@ export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"))
|
||||
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
||||
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
|
||||
export const ReimbursementPrintPage = lazy(() => import("./ReimbursementPrintPage"));
|
||||
export const ReimbursementMaterialsPage = lazy(() => import("./ReimbursementMaterialsPage"));
|
||||
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
|
||||
export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemplatesPage"));
|
||||
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
|
||||
|
||||
@@ -73,10 +73,13 @@ function buildInvoice() {
|
||||
return {
|
||||
id: pendingInvoiceId,
|
||||
status: "bound",
|
||||
invoiceCode: "044002600111",
|
||||
invoiceCode: null as string | null,
|
||||
invoiceNumber: "12345678",
|
||||
invoiceDate: "2026-05-08",
|
||||
sellerName: "北京悦海堂餐饮管理有限公司",
|
||||
sellerName: "北京悦海堂餐饮管理有限公司" as string | null,
|
||||
buyerName: null as string | null,
|
||||
documentCategory: "full_vat_invoice" as string | null,
|
||||
documentSubtype: "VatElectronicInvoiceFull" as string | null,
|
||||
totalAmount: "1683.00",
|
||||
originalName: "北京悦海堂发票.pdf",
|
||||
mimeType: "application/pdf",
|
||||
@@ -90,7 +93,10 @@ function buildUnlinkedInvoice() {
|
||||
...buildInvoice(),
|
||||
id: unlinkedInvoiceId,
|
||||
invoiceNumber: "12345679",
|
||||
sellerName: "未关联材料供应方",
|
||||
sellerName: null,
|
||||
buyerName: "测试购买方",
|
||||
documentCategory: "electronic_train_ticket",
|
||||
documentSubtype: "ElectronicTrainTicketFull",
|
||||
originalName: "未关联发票.pdf",
|
||||
linkedItemId: null,
|
||||
};
|
||||
@@ -351,6 +357,30 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName === `/api/reimbursements/${reimbursementId}/materials/preview-bundle` && request.method() === "POST") {
|
||||
const pages = [
|
||||
...state.invoices.map((_, index) => ({
|
||||
url: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777/${index}?previewToken=invoice-${index}`,
|
||||
materialType: "invoice",
|
||||
materialIndex: index + 1,
|
||||
pageNumber: 1,
|
||||
})),
|
||||
...state.attachments.map((_, index) => ({
|
||||
url: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777/${state.invoices.length + index}?previewToken=attachment-${index}`,
|
||||
materialType: "attachment",
|
||||
materialIndex: index + 1,
|
||||
pageNumber: 1,
|
||||
})),
|
||||
];
|
||||
await fulfillJson(route, { expiresAt: "2026-07-31T10:02:00.000Z", pages });
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathName.includes(`/api/reimbursements/${reimbursementId}/materials/preview/`) && request.method() === "GET") {
|
||||
await route.fulfill({ status: 200, contentType: "image/png", body: Buffer.from("synthetic-preview-image") });
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
pathName === `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/preview-url`
|
||||
&& request.method() === "POST"
|
||||
@@ -611,17 +641,15 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await expect(page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
|
||||
await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
|
||||
await expect(page.getByTestId("reimbursement-materials")).toContainText("数电发票 · 12345678");
|
||||
await expect(page.getByTestId("reimbursement-materials")).toContainText("电子火车票 · 12345679");
|
||||
await expect(page.getByTestId("reimbursement-materials")).not.toContainText("无代码");
|
||||
await expect(page.getByTestId("reimbursement-materials")).not.toContainText("未识别销售方");
|
||||
const itemRow = page.getByTestId("reimbursement-item-row");
|
||||
await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(1);
|
||||
await expect(itemRow).not.toContainText("未关联材料供应方");
|
||||
await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
|
||||
await expect(itemRow).not.toContainText("电子火车票");
|
||||
await expect(page.getByRole("button", { name: /查看发票原件/ })).toHaveCount(2);
|
||||
await expect(page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
|
||||
await itemRow.getByRole("button", { name: /查看关联发票/ }).click();
|
||||
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
|
||||
const invoiceOpenUrl = await page.evaluate(
|
||||
() => String((window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0] ?? ""),
|
||||
);
|
||||
expect(invoiceOpenUrl).toContain(`/api/invoice-pool/${pendingInvoiceId}/file?token=`);
|
||||
|
||||
await page.locator("#reimbursement-attachment-upload").setInputFiles({
|
||||
name: "付款截图.png",
|
||||
@@ -631,8 +659,13 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await page.getByRole("button", { name: "上传附件" }).click();
|
||||
await expect(page.getByText("付款截图.png")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
|
||||
await page.getByTestId("reimbursement-attachments").getByRole("button", { name: /查看补充附件/ }).click();
|
||||
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(2);
|
||||
await page.getByTestId("reimbursement-materials-open-all").click();
|
||||
await page.waitForURL(new RegExp(`/reimbursements/${reimbursementId}/materials$`));
|
||||
await expect(page.getByTestId("reimbursement-material-viewer")).toContainText("共 3 页");
|
||||
await expect(page.getByRole("img", { name: /预览/ })).toHaveCount(3);
|
||||
await page.getByRole("button", { name: "返回报销单" }).click();
|
||||
await page.waitForURL(new RegExp(`/reimbursements/${reimbursementId}$`));
|
||||
expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(0);
|
||||
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
expect(bodyText).not.toContain("绑定整单");
|
||||
@@ -733,16 +766,14 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await mobile.page.keyboard.press("Escape");
|
||||
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
|
||||
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
|
||||
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("数电发票 · 12345678");
|
||||
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("电子火车票 · 12345679");
|
||||
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("无代码");
|
||||
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).not.toContainText("未识别销售方");
|
||||
const mobileItem = mobile.page.locator("article.mobile-list-card").filter({ hasText: "客户招待" }).first();
|
||||
await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(1);
|
||||
await expect(mobileItem).not.toContainText("未关联材料供应方");
|
||||
await expect(mobileItem.getByRole("button", { name: /查看关联发票/ })).toHaveCount(0);
|
||||
await expect(mobileItem).not.toContainText("电子火车票");
|
||||
await expect(mobile.page.getByRole("button", { name: "查看原件", exact: true })).toHaveCount(0);
|
||||
const linkedInvoiceTarget = await mobileItem.getByRole("button", { name: /查看关联发票/ }).boundingBox();
|
||||
expect(linkedInvoiceTarget?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
await mobile.page.getByRole("button", { name: /查看发票原件:044002600111-12345678/ }).click();
|
||||
await expect.poll(() => mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
|
||||
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(1);
|
||||
|
||||
await mobile.page.getByRole("button", { name: "添加附件" }).click();
|
||||
await mobile.page.getByTestId("mobile-reimbursement-materials").locator("input[type='file']").setInputFiles({
|
||||
@@ -753,26 +784,25 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click();
|
||||
await expect(mobile.page.getByText("付款截图.png")).toBeVisible();
|
||||
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
|
||||
await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: /查看补充附件/ }).click();
|
||||
const openAllButton = mobile.page.getByTestId("mobile-reimbursement-materials-open-all");
|
||||
const openAllTarget = await openAllButton.boundingBox();
|
||||
expect(openAllTarget?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
await openAllButton.click();
|
||||
await expect.poll(() => mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
|
||||
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(2);
|
||||
).__r111BridgeCalls.filter((call) => call.name === "imagePreview").length)).toBe(1);
|
||||
const nativePreviewCalls = await mobile.page.evaluate(() => (
|
||||
window as typeof window & { __r111BridgeCalls: Array<{ name: string; params: Record<string, unknown> }> }
|
||||
).__r111BridgeCalls.filter((call) => call.name === "previewFile"));
|
||||
).__r111BridgeCalls.filter((call) => call.name === "imagePreview"));
|
||||
expect(nativePreviewCalls).toEqual([
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
url: expect.stringContaining(`/api/invoice-pool/${pendingInvoiceId}/file?token=`),
|
||||
name: "北京悦海堂发票.pdf",
|
||||
size: 4096,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
params: expect.objectContaining({
|
||||
url: expect.stringContaining(`/attachments/${attachmentId}/file?previewToken=`),
|
||||
name: "付款截图.png",
|
||||
size: 2048,
|
||||
current: expect.stringContaining(`/materials/preview/`),
|
||||
urls: expect.arrayContaining([
|
||||
expect.stringContaining("previewToken=invoice-0"),
|
||||
expect.stringContaining("previewToken=invoice-1"),
|
||||
expect.stringContaining("previewToken=attachment-0"),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
|
||||
在新工单中引用
屏蔽一个用户