实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-08-01 09:25:44 +08:00
父节点 3d9ec4c5a7
当前提交 da35591bd5
共修改 29 个文件,包含 907 行新增和 527 行删除
+33 -6
查看文件
@@ -137,6 +137,13 @@ export type ReimbursementDetail = {
reimbursementDate: string | null;
totalAmount: string | null;
description: string | null;
travelSummary: {
isTravel: boolean;
purpose: string | null;
location: string | null;
startDate: string | null;
endDate: string | null;
};
payment: {
paymentVoucherNo: string | null;
paymentDate: string | null;
@@ -276,6 +283,17 @@ export type ReimbursementCreatePayload = {
description?: string;
templateId?: string;
linkedTravelRequestId?: string;
travelPurpose?: string;
travelLocation?: string;
travelStartDate?: string;
travelEndDate?: string;
};
export type ReimbursementTravelSummaryPayload = {
travelPurpose: string | null;
travelLocation: string | null;
travelStartDate: string | null;
travelEndDate: string | null;
};
export type ReimbursementCreateResponse = {
@@ -459,6 +477,16 @@ export async function deleteReimbursement(reimbursementId: string) {
});
}
export async function updateReimbursementTravelSummary(
reimbursementId: string,
payload: ReimbursementTravelSummaryPayload,
) {
return apiRequestData<{ travelSummary: Omit<ReimbursementDetail["travelSummary"], "isTravel"> }>(
`/api/reimbursements/${reimbursementId}`,
{ method: "PATCH", body: JSON.stringify(payload) },
);
}
export async function createReimbursementItem(
reimbursementId: string,
payload: ReimbursementItemPayload,
@@ -549,12 +577,11 @@ export async function fetchReimbursementAttachmentPreviewUrl(
export type ReimbursementMaterialPreviewBundle = {
expiresAt: string;
pages: Array<{
url: string;
materialType: "invoice" | "attachment";
materialIndex: number;
pageNumber: number;
}>;
signedUrl: string;
originalName: string;
mimeType: "application/pdf";
fileSize: number;
pageCount: number;
};
export async function fetchReimbursementMaterialPreviewBundle(
@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { updateReimbursementTravelSummary, type ReimbursementDetail, type ReimbursementTravelSummaryPayload } from "../../api/reimbursements";
import { resolveErrorMessage } from "../../lib/resolveErrorMessage";
import { isTravelReimbursement } from "../../lib/reimbursementTravel";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
type Props = { detail: ReimbursementDetail; reimbursementId: string; editable: boolean; mobile?: boolean };
const emptyForm: ReimbursementTravelSummaryPayload = { travelPurpose: null, travelLocation: null, travelStartDate: null, travelEndDate: null };
export function ReimbursementTravelSummary({ detail, reimbursementId, editable, mobile = false }: Props) {
const queryClient = useQueryClient();
const [editing, setEditing] = useState(false);
const [form, setForm] = useState(emptyForm);
const isTravel = isTravelReimbursement(detail);
useEffect(() => {
setForm({
travelPurpose: detail.travelSummary?.purpose ?? null,
travelLocation: detail.travelSummary?.location ?? null,
travelStartDate: detail.travelSummary?.startDate ?? null,
travelEndDate: detail.travelSummary?.endDate ?? null,
});
}, [detail.travelSummary]);
const mutation = useMutation({
mutationFn: () => updateReimbursementTravelSummary(reimbursementId, form),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] });
setEditing(false);
toast.success("差旅概要已保存");
},
onError: (error) => toast.error(resolveErrorMessage(error) || "差旅概要保存失败"),
});
if (!isTravel) return null;
const summary = detail.travelSummary ?? { purpose: null, location: null, startDate: null, endDate: null };
return (
<section className={mobile ? "mobile-card" : "rounded-xl border border-zinc-200 bg-white p-5"} data-testid={mobile ? "mobile-reimbursement-travel-summary" : "reimbursement-travel-summary"}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">差旅概要</h2>
{editable && !editing ? <Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>编辑</Button> : null}
</div>
{editing ? (
<div className="mt-3 space-y-3">
<label className="block space-y-1 text-sm text-zinc-700"><span>出差事由</span><Input value={form.travelPurpose ?? ""} onChange={(event) => setForm((current) => ({ ...current, travelPurpose: event.target.value || null }))} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>出差地点</span><Input value={form.travelLocation ?? ""} onChange={(event) => setForm((current) => ({ ...current, travelLocation: event.target.value || null }))} /></label>
<div className="grid grid-cols-2 gap-3">
<label className="block space-y-1 text-sm text-zinc-700"><span>开始日期</span><Input type="date" value={form.travelStartDate ?? ""} onChange={(event) => setForm((current) => ({ ...current, travelStartDate: event.target.value || null }))} /></label>
<label className="block space-y-1 text-sm text-zinc-700"><span>结束日期</span><Input type="date" value={form.travelEndDate ?? ""} onChange={(event) => setForm((current) => ({ ...current, travelEndDate: event.target.value || null }))} /></label>
</div>
<div className="grid grid-cols-2 gap-3">
<Button type="button" disabled={mutation.isPending} onClick={() => mutation.mutate()}>{mutation.isPending ? "保存中…" : "保存"}</Button>
<Button type="button" variant="secondary" disabled={mutation.isPending} onClick={() => setEditing(false)}>取消</Button>
</div>
</div>
) : (
<dl className="mt-3 grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div><dt className="text-xs text-zinc-500">出差事由</dt><dd className="mt-1 text-zinc-900">{summary.purpose || "未填写"}</dd></div>
<div><dt className="text-xs text-zinc-500">出差地点</dt><dd className="mt-1 text-zinc-900">{summary.location || "未填写"}</dd></div>
<div><dt className="text-xs text-zinc-500">开始日期</dt><dd className="mt-1 text-zinc-900">{summary.startDate || "未填写"}</dd></div>
<div><dt className="text-xs text-zinc-500">结束日期</dt><dd className="mt-1 text-zinc-900">{summary.endDate || "未填写"}</dd></div>
</dl>
)}
</section>
);
}
+11
查看文件
@@ -0,0 +1,11 @@
import type { ReimbursementDetail } from "../api/reimbursements";
export function isTravelReimbursement(detail: ReimbursementDetail) {
return Boolean(detail.travelSummary?.isTravel || detail.linkedTravelRequestId || detail.items.some((item) => item.expenseType?.category === "travel"));
}
export function hasCompleteTravelSummary(detail: ReimbursementDetail) {
if (!isTravelReimbursement(detail)) return true;
const summary = detail.travelSummary;
return Boolean(summary?.purpose && summary.location && summary.startDate && summary.endDate);
}
+1 -14
查看文件
@@ -24,7 +24,7 @@ async function configuredWecomSdk() {
}
ww.register({
corpId: config.corpId,
jsApiList: ["previewFile", "previewImage"],
jsApiList: ["previewFile"],
async getConfigSignature(url) {
const signed = await fetchWecomJsSdkSignature(url || currentPageUrl());
return {
@@ -58,19 +58,6 @@ 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");
}
@@ -42,6 +42,7 @@ import { useAttachmentDownloadReasonDialog } from "../components/common/useAttac
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
@@ -64,6 +65,7 @@ import {
} from "../lib/invoiceStatus";
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
import { formatDisplayDateTime } from "../lib/displayFormat";
import { attachmentRequiresDownloadReason, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { TEST_IDS } from "../lib/testIds";
@@ -71,7 +73,7 @@ import { withUploadTimeout } from "../lib/uploadTimeout";
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { cn } from "../lib/utils";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
type ItemFormState = {
mode: "create" | "edit";
@@ -743,15 +745,13 @@ export function MobileReimbursementDetailPage() {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewImagesInWecom(bundle.pages.map((page) => page.url));
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
return;
} catch {
toast.error("企业微信滑动预览未打开,已切换到页面预览");
toast.error("企业微信材料预览未打开,已切换到浏览器预览");
}
}
navigate(`/mobile/reimbursements/${reimbursementId}/materials`, {
state: { bundle, from: `/mobile/reimbursements/${reimbursementId}` },
});
openFileInBrowser(bundle.signedUrl);
},
onError: (error) => {
const message = resolveErrorMessage(error) || "连续预览生成失败,可逐份打开原件";
@@ -777,6 +777,12 @@ export function MobileReimbursementDetailPage() {
toast.error(message);
return;
}
if (!hasCompleteTravelSummary(detail)) {
const message = "请先补全出差事由、地点和起止日期";
setActionError(message);
toast.error(message);
return;
}
if (!detail.invoices.length) {
const message = "请先添加至少 1 张发票";
setActionError(message);
@@ -931,6 +937,13 @@ export function MobileReimbursementDetailPage() {
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
</section>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEdit && isParticipant)}
mobile
/>
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -941,24 +954,6 @@ 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}
@@ -1074,6 +1069,27 @@ export function MobileReimbursementDetailPage() {
</div>
</div>
{detail.invoices.length || (detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2">
<p className="text-xs leading-5 text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</p>
<Button
className="min-h-12 w-full"
data-testid="mobile-reimbursement-materials-open-all"
type="button"
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
</Button>
</div>
) : 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}
{canOpenInvoiceDialog || canManageAttachments ? (
<div className="mobile-two-action-grid mt-3">
{canOpenInvoiceDialog ? (
+20 -7
查看文件
@@ -35,7 +35,8 @@ import {
} from "../api/invoicePool";
import { toast } from "sonner";
import { isWecomWebView } from "../lib/runtimeContext";
import { openFileInBrowser, previewFileInWecom, previewImagesInWecom } from "../lib/wecomFilePreview";
import { openFileInBrowser, previewFileInWecom } from "../lib/wecomFilePreview";
import { hasCompleteTravelSummary } from "../lib/reimbursementTravel";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { CardSkeleton } from "../components/common/CardSkeleton";
@@ -45,6 +46,7 @@ import { AttachmentGovernanceBadges } from "../components/common/AttachmentGover
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import {
@@ -736,15 +738,13 @@ export function ReimbursementDetailPage() {
setMaterialPreviewError("");
if (isWecomWebView()) {
try {
await previewImagesInWecom(bundle.pages.map((page) => page.url));
await previewFileInWecom({ url: bundle.signedUrl, name: bundle.originalName, size: bundle.fileSize });
return;
} catch {
toast.error("企业微信滑动预览未打开,已切换到页面预览");
toast.error("企业微信材料预览未打开,已切换到浏览器预览");
}
}
navigate(`/reimbursements/${reimbursementId}/materials`, {
state: { bundle, from: `/reimbursements/${reimbursementId}` },
});
openFileInBrowser(bundle.signedUrl);
},
onError: (error) => {
const message = buildActionErrorMessage(error, "连续预览生成失败,可逐份打开原件");
@@ -891,6 +891,12 @@ export function ReimbursementDetailPage() {
try {
setActionError("");
if (!hasCompleteTravelSummary(detail)) {
const message = "请先补全出差事由、地点和起止日期";
setActionError(message);
toast.error(message);
return;
}
if (detail.invoices.length < 1) {
setActionError(missingInvoiceSubmitMessage);
toast.error(missingInvoiceSubmitMessage);
@@ -1285,6 +1291,12 @@ export function ReimbursementDetailPage() {
) : null}
</SectionPanel>
<ReimbursementTravelSummary
detail={detail}
reimbursementId={reimbursementId}
editable={Boolean(canEditItems && isReimbursementParticipant)}
/>
<SectionPanel title="报销明细">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
@@ -1436,6 +1448,7 @@ export function ReimbursementDetailPage() {
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-sm text-zinc-600">
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
<span className="mt-1 block text-xs text-zinc-500">打开后上下滚动,连续查看全部材料。合并预览仅供核对,原始文件另行保留。</span>
</p>
<div className="flex flex-wrap gap-2">
{detail.invoices.length || (detail.attachments ?? []).length ? (
@@ -1445,7 +1458,7 @@ export function ReimbursementDetailPage() {
disabled={materialPreviewMutation.isPending}
onClick={() => void handleOpenAllMaterials()}
>
{materialPreviewMutation.isPending ? "生成预览中..." : "查看全部材料"}
{materialPreviewMutation.isPending ? "正在准备材料预览…" : "查看材料合并预览"}
</Button>
) : null}
{canOpenInvoiceBindDialog ? (
@@ -1,96 +0,0 @@
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;
-19
查看文件
@@ -62,7 +62,6 @@ import {
ReimbursementDetailPage,
ReimbursementDashboardPage,
ReimbursementPrintPage,
ReimbursementMaterialsPage,
ReimbursementTemplatesPage,
ReimbursementsPage,
TravelRequestsPage,
@@ -233,15 +232,6 @@ const routeElements = (
}
/>
<Route
path="reimbursements/:reimbursementId/materials"
element={
<RequirePermission anyOf={["expense.view_own", "expense.view_all"]}>
{withRouteFallback(<ReimbursementMaterialsPage />)}
</RequirePermission>
}
/>
<Route
path="leave-requests"
element={
@@ -398,15 +388,6 @@ 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={
@@ -1 +0,0 @@
export { ReimbursementMaterialsPage as default } from "../../pages/ReimbursementMaterialsPage";
-1
查看文件
@@ -51,7 +51,6 @@ 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"));
@@ -180,6 +180,13 @@ function buildReimbursementDetail(state: ReturnType<typeof buildReimbursementSta
reimbursementDate: "2026-05-08",
totalAmount: "1683.00",
description: "客户招待报销",
travelSummary: {
isTravel: true,
purpose: "合成差旅概要",
location: "测试地点",
startDate: "2026-05-07",
endDate: "2026-05-08",
},
projectId: null,
linkedTravelRequestId: null,
linkedTravelRequest: null,
@@ -358,26 +365,19 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
}
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 });
await fulfillJson(route, {
expiresAt: "2026-07-31T10:02:00.000Z",
signedUrl: `https://oa.hyinda.com/api/reimbursements/${reimbursementId}/materials/preview/77777777-7777-4777-8777-777777777777?previewToken=merged-pdf`,
originalName: "reimbursement-materials-preview.pdf",
mimeType: "application/pdf",
fileSize: 8192,
pageCount: state.invoices.length + state.attachments.length,
});
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") });
await route.fulfill({ status: 200, contentType: "application/pdf", body: Buffer.from("%PDF synthetic-preview") });
return;
}
@@ -658,14 +658,12 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
});
await page.getByRole("button", { name: "上传附件" }).click();
await expect(page.getByText("付款截图.png")).toBeVisible();
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("合成差旅概要");
await expect(page.getByTestId("reimbursement-travel-summary")).toContainText("测试地点");
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
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);
await expect.poll(() => page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls.length)).toBe(1);
expect(await page.evaluate(() => (window as typeof window & { __r111OpenCalls: unknown[][] }).__r111OpenCalls[0]?.[0])).toContain("previewToken=merged-pdf");
const bodyText = await page.locator("body").innerText();
expect(bodyText).not.toContain("绑定整单");
@@ -783,6 +781,8 @@ 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-travel-summary")).toContainText("合成差旅概要");
await expect(mobile.page.getByTestId("mobile-reimbursement-travel-summary")).toContainText("测试地点");
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
const openAllButton = mobile.page.getByTestId("mobile-reimbursement-materials-open-all");
const openAllTarget = await openAllButton.boundingBox();
@@ -790,19 +790,16 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await openAllButton.click();
await expect.poll(() => mobile.page.evaluate(() => (
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
).__r111BridgeCalls.filter((call) => call.name === "imagePreview").length)).toBe(1);
).__r111BridgeCalls.filter((call) => call.name === "previewFile").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 === "imagePreview"));
).__r111BridgeCalls.filter((call) => call.name === "previewFile"));
expect(nativePreviewCalls).toEqual([
expect.objectContaining({
params: expect.objectContaining({
current: expect.stringContaining(`/materials/preview/`),
urls: expect.arrayContaining([
expect.stringContaining("previewToken=invoice-0"),
expect.stringContaining("previewToken=invoice-1"),
expect.stringContaining("previewToken=attachment-0"),
]),
url: expect.stringContaining("previewToken=merged-pdf"),
name: "reimbursement-materials-preview.pdf",
size: 8192,
}),
}),
]);