收敛报销明细与材料交互(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-31 22:01:25 +08:00
父节点 665dcabdc7
当前提交 0e44aa72f3
共修改 7 个文件,包含 407 行新增和 238 行删除
+131 -108
查看文件
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight } from "lucide-react";
import { toast } from "sonner";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
@@ -983,6 +984,28 @@ 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)}>
@@ -1009,95 +1032,48 @@ export function MobileReimbursementDetailPage() {
</div>
</section>
<section className="mobile-card" data-testid={TEST_IDS.reimbursement.invoices}>
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">发票</h2>
{canOpenInvoiceDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
size="sm"
variant="secondary"
type="button"
onClick={() => closeInvoiceDialog(true)}
>
添加
</Button>
) : null}
</div>
{detail.invoices.length ? (
<div className="mt-3 space-y-2">
{detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice);
return (
<article key={invoice.id} className="mobile-list-card">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{formatInvoiceDisplayTitle(invoice)}</div>
<div className="mt-1 text-xs text-zinc-500">
{invoice.sellerName || "未识别销售方"} · {formatDate(invoice.invoiceDate)}
</div>
<div className="mt-1 text-xs text-zinc-500">明细:{resolveLinkedItemLabel(invoice.linkedItemId)}</div>
{invoiceHint ? <div className="mt-1 text-xs text-zinc-500">{invoiceHint}</div> : null}
</div>
<div className="shrink-0 text-right">
<div className="text-sm font-semibold text-zinc-900">{formatCurrency(invoice.totalAmount)}</div>
<span className="mt-1 inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{resolveInvoiceStatusLabel(invoice.status)}
</span>
</div>
</div>
{canUnbindInvoices ? (
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
) : null}
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={openInvoiceMutation.isPending}
onClick={() => openInvoiceMutation.mutate(invoice.id)}
>
查看原件
</Button>
</article>
);
})}
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无发票
</div>
)}
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-attachments">
<section className="mobile-card" data-testid="mobile-reimbursement-materials">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">附件材料</h2>
{canManageAttachments ? <p className="mt-1 text-xs text-zinc-500">PDF/JPG/PNG/WebP,单次最多 5 个</p> : null}
<h2 className="text-base font-semibold text-zinc-900">报销材料</h2>
<p className="mt-1 text-xs text-zinc-500">
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
</p>
</div>
{canManageAttachments ? (
<Button
size="sm"
variant="secondary"
type="button"
onClick={() => attachmentInputRef.current?.click()}
>
选择
</Button>
) : null}
</div>
{canOpenInvoiceDialog || canManageAttachments ? (
<div className="mobile-two-action-grid mt-3">
{canOpenInvoiceDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
size="sm"
variant="secondary"
type="button"
onClick={() => closeInvoiceDialog(true)}
>
添加发票
</Button>
) : null}
{canManageAttachments ? (
<Button
size="sm"
variant="secondary"
type="button"
onClick={() => attachmentInputRef.current?.click()}
>
添加附件
</Button>
) : null}
</div>
) : null}
{invoiceActionError ? (
<p className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{invoiceActionError}
</p>
) : null}
{canManageAttachments ? (
<div className="mt-3 space-y-3">
<input
@@ -1139,45 +1115,92 @@ export function MobileReimbursementDetailPage() {
</div>
) : null}
{(detail.attachments ?? []).length ? (
{detail.invoices.length || (detail.attachments ?? []).length ? (
<div className="mt-3 space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<article key={attachment.id} className="mobile-list-card">
<div className="min-w-0">
<div className="truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</div>
<div className="mt-1 text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="mobile-two-action-grid mt-3">
<Button
size="sm"
variant="secondary"
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
{detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice);
return (
<article key={invoice.id} className="mobile-list-card">
<button
type="button"
disabled={openAttachmentMutation.isPending}
onClick={() => void handleOpenAttachment(attachment)}
className="flex min-h-11 w-full items-start gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 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)}
>
查看原件
</Button>
{canManageAttachments ? (
<span className="min-w-0 flex-1">
<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)}
</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}
</span>
<span className="shrink-0 text-right">
<span className="inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">
{resolveInvoiceStatusLabel(invoice.status)}
</span>
<ChevronRight aria-hidden="true" className="ml-auto mt-2 size-4 text-zinc-400" />
</span>
</button>
{canUnbindInvoices ? (
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={deleteAttachmentMutation.isPending}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName })}
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
删除
移除
</Button>
) : null}
</div>
</article>
);
})}
</div>
<div data-testid="mobile-reimbursement-attachments" className="space-y-2">
{(detail.attachments ?? []).map((attachment) => (
<article key={attachment.id} className="mobile-list-card">
<button
type="button"
className="flex min-h-11 w-full items-center gap-3 rounded-lg text-left transition-colors hover:bg-zinc-50 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={openAttachmentMutation.isPending}
aria-label={`查看补充附件:${attachment.originalName}`}
onClick={() => void handleOpenAttachment(attachment)}
>
<span className="min-w-0 flex-1">
<span className="inline-flex rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600">补充附件</span>
<span className="mt-2 block truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</span>
<span className="mt-1 block text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</span>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{canManageAttachments ? (
<Button
className="mt-3 w-full"
size="sm"
variant="secondary"
type="button"
disabled={deleteAttachmentMutation.isPending}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, name: attachment.originalName })}
>
删除
</Button>
) : null}
</article>
))}
</div>
</div>
) : (
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
暂无附件材料
暂无报销材料
</div>
)}
</section>
+111 -116
查看文件
@@ -1,6 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ChevronRight } from "lucide-react";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
@@ -1251,24 +1252,22 @@ export function ReimbursementDetailPage() {
) : null}
</SectionPanel>
<SectionPanel title="明细与发票">
<SectionPanel title="报销明细">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
{canEditItems ? (
<Button
data-testid="reimbursement-item-add"
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
onClick={startCreateItem}
type="button"
variant="secondary"
>
新增明细
</Button>
) : null}
</div>
{canEditItems ? (
<div className="flex justify-end">
<Button
data-testid="reimbursement-item-add"
disabled={itemSaveMutation.isPending || itemDeleteMutation.isPending}
onClick={startCreateItem}
type="button"
variant="secondary"
>
新增明细
</Button>
</div>
) : null}
{itemError ? (
<p
@@ -1340,6 +1339,28 @@ 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 ? (
@@ -1396,12 +1417,15 @@ export function ReimbursementDetailPage() {
) : null}
</div>
<div className="space-y-4 text-sm" data-testid={TEST_IDS.reimbursement.invoices}>
</div>
</SectionPanel>
<SectionPanel title="报销材料">
<div className="space-y-4 text-sm" data-testid="reimbursement-materials">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 className="text-base font-semibold text-zinc-900">发票</h3>
{canEditItems ? <p className="mt-1 text-xs text-zinc-500">提交前至少添加 1 张发票。</p> : null}
</div>
<p className="text-sm text-zinc-600">
发票 {detail.invoices.length} · 补充附件 {(detail.attachments ?? []).length}
</p>
{canOpenInvoiceBindDialog ? (
<Button
data-testid={TEST_IDS.reimbursement.invoiceAdd}
@@ -1411,8 +1435,6 @@ export function ReimbursementDetailPage() {
>
添加发票
</Button>
) : canEditItems ? (
<p className="text-xs text-zinc-500">当前账号不可在此页添加发票</p>
) : null}
</div>
@@ -1425,74 +1447,47 @@ export function ReimbursementDetailPage() {
</p>
) : null}
{(detail.invoices ?? []).length ? (
<div className="space-y-3">
{detail.invoices.map((item) => {
const invoiceHint = resolveInvoiceNextStepText(item);
return (
<div
className="detail-evidence-card"
key={item.id}
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
{detail.invoices.map((invoice) => {
const invoiceHint = resolveInvoiceNextStepText(invoice);
return (
<div className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center" key={invoice.id}>
<button
type="button"
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white 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="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<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 ?? "-"}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || "未记录原始文件"}
</div>
<div className="text-xs text-zinc-500">
明细:{resolveLinkedItemLabel(item.linkedItemId)}
</div>
</div>
<div className="flex flex-col items-start gap-2 md:items-end">
<div className="flex items-center gap-2">
<span className="badge-neutral">
{resolveInvoiceStatusLabel(item.status)}
</span>
<Button
type="button"
variant="secondary"
size="sm"
disabled={openInvoiceMutation.isPending}
onClick={() => openInvoiceMutation.mutate(item.id)}
>
查看原件
</Button>
{canUnbindInvoices ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(item.id)}
>
移除
</Button>
) : null}
</div>
{invoiceHint ? (
<div className="max-w-[260px] text-xs text-zinc-500 md:text-right">{invoiceHint}</div>
) : null}
</div>
</div>
</div>
);
})}
</div>
) : (
<p className="text-sm text-zinc-500">暂无发票</p>
)}
</div>
</div>
</SectionPanel>
<span className="inline-flex shrink-0 rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700">发票</span>
<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 ?? "-"}
</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}
</span>
<span className="badge-neutral shrink-0">{resolveInvoiceStatusLabel(invoice.status)}</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{canUnbindInvoices ? (
<Button
type="button"
variant="secondary"
size="sm"
disabled={unbindInvoiceMutation.isPending}
onClick={() => unbindInvoiceMutation.mutate(invoice.id)}
>
移除
</Button>
) : null}
</div>
);
})}
</div>
<SectionPanel title="附件材料">
<div className="space-y-4 text-sm" data-testid="reimbursement-attachments">
<div className="space-y-4" data-testid="reimbursement-attachments">
{canEditItems ? (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-zinc-500">支持 PDF/JPG/PNG/WebP,单次最多 5 个。</p>
@@ -1539,42 +1534,42 @@ export function ReimbursementDetailPage() {
{(detail.attachments ?? []).length ? (
<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 md:justify-between">
<div className="min-w-0">
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
<div className="text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
<div className="flex gap-2">
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
<button
type="button"
className="flex min-h-11 min-w-0 flex-1 items-center gap-3 rounded-md text-left transition-colors hover:bg-white 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={openAttachmentMutation.isPending}
aria-label={`查看补充附件:${attachment.originalName}`}
onClick={() => void handleOpenAttachment(attachment)}
>
<span className="inline-flex shrink-0 rounded-full bg-zinc-100 px-2 py-1 text-xs font-medium text-zinc-600">补充附件</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-zinc-900">{attachment.originalName}</span>
<span className="mt-1 block text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</span>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</span>
<ChevronRight aria-hidden="true" className="size-4 shrink-0 text-zinc-400" />
</button>
{canEditItems ? (
<Button
type="button"
variant="secondary"
variant="destructive"
size="sm"
disabled={openAttachmentMutation.isPending}
onClick={() => void handleOpenAttachment(attachment)}
disabled={deleteAttachmentMutation.isPending}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
>
查看原件
删除
</Button>
{canEditItems ? (
<Button
type="button"
variant="destructive"
size="sm"
disabled={deleteAttachmentMutation.isPending}
onClick={() => setDeleteAttachmentTarget({ id: attachment.id, originalName: attachment.originalName })}
>
删除
</Button>
) : null}
</div>
) : null}
</div>
))}
</div>
) : (
<p className="text-sm text-zinc-500">暂无附件材料</p>
) : detail.invoices.length ? null : (
<p className="text-sm text-zinc-500">暂无报销材料</p>
)}
</div>
</div>
</SectionPanel>
@@ -10,6 +10,7 @@ const WECOM_UA =
const reimbursementId = "11111111-1111-4111-8111-111111111111";
const ownerId = "employee-r111";
const pendingInvoiceId = "22222222-2222-4222-8222-222222222222";
const unlinkedInvoiceId = "66666666-6666-4666-8666-666666666666";
const attachmentId = "33333333-3333-4333-8333-333333333333";
const availableInvoiceId = "44444444-4444-4444-8444-444444444444";
const failedInvoiceId = "55555555-5555-4555-8555-555555555555";
@@ -80,7 +81,18 @@ function buildInvoice() {
originalName: "北京悦海堂发票.pdf",
mimeType: "application/pdf",
fileSize: 4096,
linkedItemId: "item-r111",
linkedItemId: "item-r111" as string | null,
};
}
function buildUnlinkedInvoice() {
return {
...buildInvoice(),
id: unlinkedInvoiceId,
invoiceNumber: "12345679",
sellerName: "未关联材料供应方",
originalName: "未关联发票.pdf",
linkedItemId: null,
};
}
@@ -432,7 +444,7 @@ async function mockApis(page: Page, state: ReturnType<typeof buildReimbursementS
}
if (pathName === `/api/invoice-pool/${pendingInvoiceId}/bind`) {
state.invoices = [buildInvoice()];
state.invoices = [buildInvoice(), buildUnlinkedInvoice()];
await fulfillJson(route, {
id: pendingInvoiceId,
status: "bound",
@@ -596,9 +608,15 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
expect(state.statusPollCount).toBeGreaterThanOrEqual(1);
await page.keyboard.press("Escape");
await expect(page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
await expect(page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
await expect(page.getByText("明细:业务招待 · 客户招待")).toBeVisible();
await page.getByRole("button", { name: "查看原件" }).first().click();
await expect(page.getByTestId("reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
const itemRow = page.getByTestId("reimbursement-item-row");
await expect(itemRow.getByRole("button", { name: /查看关联发票/ })).toHaveCount(1);
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] ?? ""),
@@ -612,7 +630,8 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
});
await page.getByRole("button", { name: "上传附件" }).click();
await expect(page.getByText("付款截图.png")).toBeVisible();
await page.getByTestId("reimbursement-attachments").getByRole("button", { name: "查看原件" }).click();
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);
const bodyText = await page.locator("body").innerText();
@@ -656,7 +675,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
await login(mobile.page);
await mobile.page.goto(`/mobile/reimbursements/${reimbursementId}`);
await expect(mobile.page.getByTestId("mobile-reimbursement-detail")).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "附件材料" })).toBeVisible();
await expect(mobile.page.getByRole("heading", { name: "报销材料" })).toBeVisible();
const beforeDialogMetrics = await collectVisualMetrics(mobile.page);
expect(beforeDialogMetrics.scrollWidth).toBeLessThanOrEqual(beforeDialogMetrics.width + 2);
@@ -712,21 +731,29 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
mobile.page.getByTestId("reimbursement-invoice-dialog").getByText("已添加 1 张发票"),
).toBeVisible({ timeout: 15_000 });
await mobile.page.keyboard.press("Escape");
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司")).toBeVisible();
await mobile.page.getByRole("button", { name: "查看原件" }).first().click();
await expect(mobile.page.getByText("北京悦海堂餐饮管理有限公司").first()).toBeVisible();
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 0");
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(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.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "选择" }).click();
await mobile.page.locator("[data-testid='mobile-reimbursement-attachments'] input[type='file']").setInputFiles({
await mobile.page.getByRole("button", { name: "添加附件" }).click();
await mobile.page.getByTestId("mobile-reimbursement-materials").locator("input[type='file']").setInputFiles({
name: "付款截图.png",
mimeType: "image/png",
buffer: Buffer.from("r111 mobile attachment"),
});
await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "上传附件" }).click();
await mobile.page.getByTestId("mobile-reimbursement-materials").getByRole("button", { name: "上传附件" }).click();
await expect(mobile.page.getByText("付款截图.png")).toBeVisible();
await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: "查看原件" }).click();
await expect(mobile.page.getByTestId("mobile-reimbursement-materials")).toContainText("发票 2 · 补充附件 1");
await mobile.page.getByTestId("mobile-reimbursement-attachments").getByRole("button", { name: /查看补充附件/ }).click();
await expect.poll(() => mobile.page.evaluate(() => (
window as typeof window & { __r111BridgeCalls: Array<{ name: string }> }
).__r111BridgeCalls.filter((call) => call.name === "previewFile").length)).toBe(2);
@@ -753,6 +780,11 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
window as typeof window & { __r111OpenCalls: unknown[][] }
).__r111OpenCalls.length)).toBe(0);
await mobile.page.setViewportSize({ width: 667, height: 375 });
const landscapeMetrics = await collectVisualMetrics(mobile.page);
expect(landscapeMetrics.scrollWidth).toBeLessThanOrEqual(landscapeMetrics.width + 2);
await mobile.page.setViewportSize({ width: 390, height: 844 });
const bodyText = await mobile.page.locator("body").innerText();
expect(bodyText).not.toContain("绑定整单");
expect(bodyText).not.toContain("不绑定明细");