实现报销材料合并预览与差旅概要(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 行删除
@@ -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 ? (