From 98eabdde7f4da5f0d50b6a6b39d904eb25d72323 Mon Sep 17 00:00:00 2001 From: shunli Date: Fri, 11 Sep 2026 22:28:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E7=BB=9F=E4=B8=80=E6=8A=A5=E9=94=80?= =?UTF-8?q?=E4=BB=98=E6=AC=BE=E8=AF=A6=E6=83=85=E5=B8=83=E5=B1=80=E4=B8=8E?= =?UTF-8?q?=E7=A5=A8=E6=8D=AE=E5=B1=95=E7=A4=BA=EF=BC=8809-04-reimbursemen?= =?UTF-8?q?t-state-gap-finance-view=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spec/hyb-oa/frontend-react-vite-design.md | 5 + .../design.md | 6 ++ .../implement.md | 24 +++++ .../prd.md | 8 ++ .../patterns/BusinessDetailTemplate.tsx | 7 +- .../ReimbursementDesktopWorkspaceShell.tsx | 2 +- .../ReimbursementItemEvidenceList.tsx | 39 ++++++- client/src/index.css | 4 + client/src/pages/PaymentBatchesPage.tsx | 24 +++-- client/src/pages/ReimbursementDetailPage.tsx | 17 +-- .../e2e/system-action-confirmation.spec.ts | 100 +++++++++++++++++- 11 files changed, 205 insertions(+), 31 deletions(-) diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 99d3d9d1..d6935b3c 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -14,6 +14,11 @@ and verification contracts. Do not copy broad aesthetic guidance from ## Convention: Reimbursement Payment Handoff Consistency +- Reimbursement desktop shells and payment detail opt into `detail-workspace-grid--payment`: a 320px aside at lg and above, single column below lg. A shared proportional grid does not guarantee equal asides when parent max-width differs. Verify actual widths on both pages at 1280/1440, not only matching class names; leave unrelated detail grids unchanged. +- `BusinessDetailActionPanel({ inlineActions: true })` puts the primary action followed by the More trigger in a wrapping, content-width row. The default layout stays unchanged. Both detail pages use the existing DropdownMenu; menu items are portalled outside the card, so test via page-level menu roles. Keep disabled states, permission conditions, confirmations and mobile payment actions intact. Do not pass known-empty action components as children: a React element that renders null is still a nonempty slot and otherwise creates a phantom divider. +- Linked reimbursement invoices use a desktop table with number preview, seller, date and right-aligned amount; narrow rows stack without duplicating IDs or preview controls. Keep OCR warnings, unlinked correction cards, attachment delete confirmation and evidence anchors. Test five synthetic invoices, long names/numbers, empty attachments, preview calls, narrow layout and the existing approval/detail consumers. +- A compact payee-account reveal control stays beside the masked value as a 44px Eye button with title and aria-label `查看完整账号`. Preserve role/permission/status/account gates and the reason/audit dialog; icon clicks must not immediately request plaintext. Layout measurements wait for fonts and compare aligned centers; round subpixel box heights to hundredths before checking the 44px minimum (Chromium can return 43.99994 for a CSS 44px target). + - `resolveReimbursementPaymentHandoff({ status, deletedAt, progress })` consumes the server's `payment_batch_created`, `payment_batch_review` and `payment_and_receipt` stages. It is a display projection, not a second payment eligibility or write state machine. - Only an explicit current creation stage followed by pending review/payment can advertise creation, and the caller still requires `payment.batch.manage`. Missing stages, unknown status or contradictory facts never produce a guessed creation action. Cancelled and failed batches use the existing stage explanation, not automatic recreation. - Read-only detail states (including reviewing, approved and paid) override global focus caching locally (`staleTime: 0`, refetch on mount and visibility return). Do not enable whole-app focus refetch or overwrite draft/rejected editors for this fix. A query error must not keep a stale actionable payment panel. diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md index 92e6ef51..53e6c472 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/design.md @@ -1,5 +1,11 @@ # 报销状态空隙与财务处理视角:设计 +## 2026-09-11 等宽侧栏与票据表格 + +动作卡通过可选inlineActions布局主动作和更多,默认合同不变。两页更多复用现有Radix DropdownMenu,Portal渲染后测试按page.getByRole("menuitem")定位,而不是动作卡内找按钮。业务回调和确认对话框不变;报销调用方仅在有撤回或动作错误时传ReimbursementActions,避免React组件返回null但外层误判为有内容而生成空分隔线。 + +根因:共享detail-workspace-grid使用比例列,而报销壳另有max-w-7xl,付款壳无同一上限。新增局部共享detail-workspace-grid--payment类供两处使用,lg起固定320px侧栏,主列minmax(0,1fr),保持各自正文宽度和手机规则。frontend-design不可用,仅复用现有表格、边框和按钮样式,不创建新视觉体系。已关联票据采用响应式表格,窄屏按行紧凑排列;未关联票据保留原纠错控件与唯一锚点。姓名和号码允许换行,预览、移除及OCR告警按原capabilities保留。 + ## 2026-09-11 下一步与侧栏一致性方案(已批准本地实施) 本轮只读核对已发布候选,未查询生产。ReimbursementDetailPage 的 approved 分支无条件显示“等待财务组建付款批次”,其主动作只再检查 payment.batch.manage;同一页面 ProcessingProgress 已消费服务端按真实批次状态构造的阶段。修复必须连接这两处投影,不能只把错误文字换成另一句固定文案。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md index 59a0834f..911165fc 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/implement.md @@ -1,5 +1,29 @@ # 报销状态空隙与财务处理视角:实施计划 +## 本轮复核输入与提交准备(未提交) + +用户批准整理两笔独立改动的复核/提交方案。基线均为5d8cdf12;01为制单备注5文件,02为UI布局11文件,路径无重叠。仅生成补丁、摘要清单并用临时索引验证顺序合并,不改变实际HEAD/index,不创建commit,不推送或部署。复核入口位于layout副本的artifacts/qa-layout/review-preparation/README.md;临时合并树不是发布候选commit。既有验证按输入不变复用,本轮不新增测试通过计数;UI补跑外层143、网银导入未验证、未独立复核等边界保留。提交前须核对冻结摘要,发布前还需验证合并后的实际候选,不能把单包测试冒充集成验收。 + +## 2026-09-11 侧栏与票据展示增量 + +独立layout副本基于5d8cdf12,官方current核对本task;不混制单备注及其他未提交工作。本轮已授权本地实施,未授权提交/推送/部署。 + +- [x] 统一两处侧栏,复用表格展示已关联发票,保留异常/手机/附件能力;同轮更多下拉同行、账号旁查看图标已实施。 +- [x] 添加同屏宽度和票据展示回归,运行typecheck/build与相关浏览器测试。 +- [x] 主会话复核、更新规约和验证记录,明确未发布边界。 + +本轮本地实施已完成:图标仅替换展示,原employee.bank.read/finance角色、reviewed/failed及paymentAccount条件未改,点击仍需填写原因后才能请求完整账号。布局/菜单不改API、权限规则和状态机;主会话核源码及合成截图,不冒充独立复核或生产验收。 + +验证回执: +- client下npm run build(含typecheck)重跑退出0,Vite/PWA完成,116项预缓存;最终源文件再次npm run typecheck退出0。首轮build外层143保留,不计为通过。 +- E2E_BASE_URL指向本地4317,npx playwright test tests/e2e/system-action-confirmation.spec.ts tests/e2e/reimbursement-content-consistency.spec.ts --workers=1 --reporter=list:50项中47通过、3失败。手机失败是boundingBox将44px回传为43.99993896484375;改为百分之一像素精度判断,仍要求44px。另两项为未指定BIFF8运行环境,生成器不可用,不是制单内容失败。 +- 使用既有BIFF8虚拟环境的PAYMENT_ORDER_PYTHON_BIN,仅重跑手机交接和双端制单3项,Playwright报告3 passed。外层会话返回143,不能把整条补跑命令称为退出0;进程检查已无该补跑进程。保留日志和结果,不为刷绿再次重复已通过输入。50项用例已有通过结果,但不是一次50/50退出0的运行。 +- 首轮侧栏4项中3通过,同排几何测量出现8px差值,失败截图未见持续错排;等待字体并按中心线比较后,1280/1440均通过且两页侧栏都实测320px。不将字体竞态猜测冒充已独立证明的原因。 +- 图标键盘打开/取消零完整账号请求;5张长名称/长编号在390/1280/1440均完整且不造成页面横向溢出,预览请求命中正确票据;桌面更多、删除/撤回/打印、手机底栏、上传回单、双端真实BIFF8下载与重复下载边界均有回归通过结果。 +- Trellis治理、process-contract baseline、git diff --check通过。本地证据存artifacts/qa-layout(ignored),含首次失败和补跑记录;不提交合成截图或原始运行输出到业务文档。 + +未提交、未推送、未部署、未做生产写入;制单备注仍在另一独立副本。本轮不归档整个task。下一步按路径汇总本UI增量与独立备注增量的候选复核输入,经明确授权后再分别提交/推送及准备发布,不覆盖原integration任务历史。 + ## 2026-09-11 迟到 401 复核接续(最新,门户取消修正本地通过) 主会话只读核对 dcde0d44 的 33 源文件、3 补丁重放及历史证据后,双端合成浏览器补查甲→乙→甲:甲旧请求迟到 401 后按乙会话重试,结果进入甲缓存,甲再次登录等待新响应时显示乙待办。该包 NO_GO;独立复核仍 PENDING_INLINE,原 6/62/真实隔离通过记录不改写。回执在 postpush-identity/readonly-review.md。 diff --git a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md index 5ddd8a5e..ed9a86cf 100644 --- a/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md +++ b/.trellis/tasks/09-04-reimbursement-state-gap-finance-view/prd.md @@ -2,6 +2,14 @@ ## Goal +### 2026-09-11 追加:侧栏等宽与票据紧凑展示(已批准) + +用户同轮要求“查看完整账号”改为账号旁Eye图标,保留44px触控、title/aria-label及键盘入口;账号仍默认脱敏,权限/角色/状态条件和填写原因、审计流程不变,不改为悬浮或点击即解密。 + +用户同轮追加“更多”紧随主按钮:报销和付款桌面动作行左对齐,主按钮按内容宽度,更多为相同下拉菜单;不足则换行,不改变手机付款底栏。保留菜单内删除/打印/下载/上传/取消的资格、禁用与确认语义。去掉空动作组件导致的重复分隔线,不隐藏实际撤回或错误信息。 + +报销详情/审批共用壳与付款详情桌面侧栏统一320px,lg以下仍单列,操作在上、进度在下;不改其他业务布局。发票桌面按号码、开票方、日期、金额、操作展示,号码沿用预览入口,金额右对齐;窄屏紧凑列表。补充附件保留独立文件列表及已有类型/大小、预览和有权删除入口,空态一行。不新增关联步骤,不隐藏未关联/OCR失败等异常处理。使用合成数据验收等宽、多票据、长名称、预览、附件删除及390px无页面溢出。无权限/API/生产变更,不混入制单备注补丁。 + ### 2026-09-11 追加需求:付款下一步与详情侧栏一致性(已批准本地实施) 用户反馈财务在报销详情同时看到“去组建付款批次”和已创建、已复核的付款进度。只记录脱敏状态矛盾,不复制真实截图、票据或金额。已发布 fe025e65 源码中右侧下一步及主动作只按报销 status=approved 判断,忽略已由服务端提供的付款阶段;报销进度插在主列材料之后,付款详情则在右栏操作下方。报销审批通过并不等于尚未组批,也不等于银行已经付款。 diff --git a/client/src/components/patterns/BusinessDetailTemplate.tsx b/client/src/components/patterns/BusinessDetailTemplate.tsx index 64e7b621..1aa68f99 100644 --- a/client/src/components/patterns/BusinessDetailTemplate.tsx +++ b/client/src/components/patterns/BusinessDetailTemplate.tsx @@ -87,6 +87,7 @@ export function BusinessDetailActionPanel({ primaryAction, secondaryActions, moreActions, + inlineActions = false, summary, children, className, @@ -98,6 +99,7 @@ export function BusinessDetailActionPanel({ primaryAction?: ReactNode; secondaryActions?: ReactNode; moreActions?: ReactNode; + inlineActions?: boolean; summary?: ReactNode; children?: ReactNode; className?: string; @@ -121,9 +123,10 @@ export function BusinessDetailActionPanel({
{hasBeforeActions ?
{beforeActions}
: null} - {hasPrimaryAction ?
{primaryAction}
: null} + {inlineActions && (hasPrimaryAction || hasMoreActions) ?
{primaryAction}{moreActions}
: null} + {!inlineActions && hasPrimaryAction ?
{primaryAction}
: null} {hasSecondaryActions ?
{secondaryActions}
: null} - {hasMoreActions ?
{moreActions}
: null} + {!inlineActions && hasMoreActions ?
{moreActions}
: null} {hasChildren ? (
{children}
) : null} diff --git a/client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx b/client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx index 80c092eb..40f46409 100644 --- a/client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx +++ b/client/src/components/reimbursements/ReimbursementDesktopWorkspaceShell.tsx @@ -30,7 +30,7 @@ export function ReimbursementDesktopWorkspaceShell({ model, capabilities, slots )} /> {slots.transientFeedback} - + {slots.headerDetails} {slots.beforeEvidence} diff --git a/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx b/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx index 57212827..a9f7b172 100644 --- a/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx +++ b/client/src/components/reimbursements/ReimbursementItemEvidenceList.tsx @@ -167,6 +167,43 @@ function InvoiceMaterialRow({ invoice, capabilities, linkTargets }: { invoice: R ); } +function InvoiceMaterialsTable({ invoices, capabilities }: { invoices: ReimbursementInvoiceEvidence[]; capabilities: ReimbursementEvidenceCapabilities }) { + return ( +
+ + + + + + + + {capabilities.onUnlinkInvoice ? : null} + + + + {invoices.map((invoice) => { + const title = invoice.invoiceNumber || invoice.originalName || "未命名发票"; + const statusCopy = evidenceStatusCopy(invoice.amountEvidenceStatus); + const hasBlockingIssue = invoice.amountEvidenceStatus === "ocr_failed"; + return ( + + + + + + {capabilities.onUnlinkInvoice ? : null} + + ); + })} + +
发票号码开票方日期金额操作
+ {capabilities.onOpenInvoice ? : {title}} + {statusCopy ?

{statusCopy}

: null} +
{invoice.sellerName || invoice.documentSubtype || invoice.documentCategory || "票据"}{formatDisplayDate(invoice.invoiceDate)}{formatMoney(invoice.totalAmount)}
+
+ ); +} + function AttachmentMaterialRow({ attachment, capabilities }: { attachment: ReimbursementAttachmentEvidence; capabilities: ReimbursementEvidenceCapabilities }) { return
@@ -197,7 +234,7 @@ export function ReimbursementItemEvidenceList({ amountSummary, evidence, capabil

票据与附件

{capabilities.onOpenMaterialBundle ? : null}

发票({linkedInvoices.length + evidence.unlinkedInvoices.length})

- {linkedInvoices.map((invoice) => )} + {linkedInvoices.length ? (mobile ? linkedInvoices.map((invoice) => ) : ) : null} {evidence.unlinkedInvoices.length ?

尚未对应明细({evidence.unlinkedInvoices.length})

这些票据当前没有明确费用归属,暂时无法提交;请为每张票据选择对应的报销明细。

{evidence.unlinkedInvoices.map((invoice) => )}
: null} {!linkedInvoices.length && !evidence.unlinkedInvoices.length ?

暂无发票

: null}
diff --git a/client/src/index.css b/client/src/index.css index b6993a82..ab189d3d 100644 --- a/client/src/index.css +++ b/client/src/index.css @@ -588,6 +588,10 @@ @apply grid gap-5 lg:grid-cols-[minmax(0,1.45fr),minmax(280px,0.72fr)]; } + .detail-workspace-grid--payment { + @apply lg:grid-cols-[minmax(0,1fr),320px]; + } + .detail-command-panel { border-color: rgba(203, 213, 225, 0.92); background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(248, 250, 252, 0.9)); diff --git a/client/src/pages/PaymentBatchesPage.tsx b/client/src/pages/PaymentBatchesPage.tsx index 453f3d40..99642d65 100644 --- a/client/src/pages/PaymentBatchesPage.tsx +++ b/client/src/pages/PaymentBatchesPage.tsx @@ -3,6 +3,7 @@ import { resolveErrorReasonMessage, UNKNOWN_VALIDATION_MESSAGE } from "../api/er import { useEffect, useMemo, useRef, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link, useSearchParams } from "react-router-dom"; +import { Eye } from "lucide-react"; import { cancelPaymentBatch, createPaymentBatch, @@ -827,7 +828,7 @@ export function PaymentBatchesPage() {
- +
@@ -914,7 +915,7 @@ export function PaymentBatchesPage() {
收款人
{item.payeeName || "-"}
开户银行
{item.bankName || "-"}
-
银行账户
{item.bankAccountMasked || "-"}
{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? : null}
+
银行账户
{item.bankAccountMasked || "-"}{canRevealPayeeBank && ["reviewed", "failed"].includes(selectedBatch.status) && selectedBatch.paymentAccount ? : null}
付款渠道
{paymentChannelLabel(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel)}
{["reviewed", "failed"].includes(selectedBatch.status) ? ( @@ -1073,18 +1074,21 @@ export function PaymentBatchesPage() { } primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined} moreActions={paymentActionModel.more.length ? ( -
- 更多 -
- {paymentActionModel.more.map((kind) => - renderPaymentActionButton(kind, kind === "cancel" ? "destructive" : "secondary"), - )} -
-
+ + + + {paymentActionModel.more.map((kind) => ( + activatePaymentAction(kind)}> + {paymentActionLabel(kind)} + + ))} + + ) : undefined} summary={( <> diff --git a/client/src/pages/ReimbursementDetailPage.tsx b/client/src/pages/ReimbursementDetailPage.tsx index 07f5f323..a165c3f0 100644 --- a/client/src/pages/ReimbursementDetailPage.tsx +++ b/client/src/pages/ReimbursementDetailPage.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { ArrowLeft } from "lucide-react"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu"; import { fetchExpenseTypes } from "../api/expenseTypes"; import type { ExpenseStandardCheck, PrecheckResponse } from "../api/feeControl"; import { @@ -114,7 +115,6 @@ export function ReimbursementDetailPage() { const [itemError, setItemError] = useState(""); const [confirmDeleteItemId, setConfirmDeleteItemId] = useState(""); const [confirmDeleteReimbursement, setConfirmDeleteReimbursement] = useState(false); - const [showSecondaryDetail, setShowSecondaryDetail] = useState(false); const [submitConfirmPrecheck, setSubmitConfirmPrecheck] = useState(null); const [isInvoiceDialogOpen, setIsInvoiceDialogOpen] = useState(false); const [invoiceSearchInput, setInvoiceSearchInput] = useState(""); @@ -1041,17 +1041,6 @@ export function ReimbursementDetailPage() { 打印 / PDF ) : null, - hasMoreActions ? ( - - ) : null, ]; const workspaceEvidence = buildReimbursementApprovalEvidence(detail, { @@ -1112,8 +1101,10 @@ export function ReimbursementDetailPage() { ); const workspaceActionPanel = ( - } primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={showSecondaryDetail ?
{detail.status !== "paid" ? : null}{canDeleteReimbursement ? : null}
: null} summary={<>
下一步:{nextStepText}
{workspaceModel.issueAnchors.map((issue) => {issue.label})}} testId="reimbursement-action-panel"> + } primaryAction={primaryAction} secondaryActions={secondaryActions} moreActions={hasMoreActions ? {detail.status !== "paid" ? 打印 / PDF : null}{canDeleteReimbursement ? setConfirmDeleteReimbursement(true)}>删除报销单 : null} : null} summary={<>
下一步:{nextStepText}
{workspaceModel.issueAnchors.map((issue) => {issue.label})}} testId="reimbursement-action-panel"> + {(detail.status === "reviewing" && isReimbursementParticipant) || (["reviewing", "approved"].includes(detail.status) && actionError) ? ( 0} submitHint="" submitNotice="" isPending={mutation.isPending} onSubmit={() => void handleSubmitAction()} onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })} /> + ) : null}
); diff --git a/client/tests/e2e/system-action-confirmation.spec.ts b/client/tests/e2e/system-action-confirmation.spec.ts index 144deb98..2e7ebb1d 100644 --- a/client/tests/e2e/system-action-confirmation.spec.ts +++ b/client/tests/e2e/system-action-confirmation.spec.ts @@ -680,7 +680,7 @@ test.describe("报销付款交接一致性", () => { const link = page.getByRole("link", { name: "查看所在付款批次", exact: true }); await expect(link).toHaveCount(1); const linkBounds = await link.boundingBox(); - expect(linkBounds!.height).toBeGreaterThanOrEqual(44); + expect(Math.round(linkBounds!.height * 100) / 100).toBeGreaterThanOrEqual(44); if (!mobile) expect(linkBounds!.width).toBeLessThan(180); await link.click(); await expect(page.getByTestId("payment-batch-processing-panel")).toBeVisible(); @@ -698,7 +698,99 @@ test.describe("报销付款交接一致性", () => { }); } + test("账号查看图标紧随脱敏值且确认前不请求完整账号", async ({ page }) => { + await page.setViewportSize({ width: 1440, height: 960 }); + await mockSystemActionApis(page, { + canExportPaymentOrder: true, + meOverride: { roleCodes: ["finance"], permissions: [...me.permissions, "employee.bank.read"] }, + }); + let reveals = 0; + page.on("request", (request) => { if (new URL(request.url()).pathname.endsWith("/payee-bank/reveal")) reveals += 1; }); + await login(page, user); + await page.goto(`/payment-batches?id=${paymentBatchId}`); + const reveal = page.getByRole("button", { name: "查看完整账号", exact: true }); + await expect(reveal).toBeVisible(); + await expect(reveal).toHaveText(""); + await expect(reveal).toHaveAttribute("title", "查看完整账号"); + const icon = await reveal.boundingBox(); + const account = await reveal.locator("..").locator("span").boundingBox(); + expect(icon!.width).toBeGreaterThanOrEqual(44); + expect(icon!.height).toBeGreaterThanOrEqual(44); + expect(icon!.x).toBeGreaterThan(account!.x); + await reveal.focus(); + await page.keyboard.press("Enter"); + const dialog = page.getByRole("dialog"); + await expect(dialog).toContainText("本次查看会记录"); + await expect(dialog.getByRole("textbox", { name: "查看收款账号原因" })).toBeVisible(); + expect(reveals).toBe(0); + await dialog.getByRole("button", { name: "返回", exact: true }).click(); + expect(reveals).toBe(0); + }); + + for (const width of [390, 1280, 1440]) { + test(`票据紧凑展示${width}多张长名称及预览不丢失`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 960 }); + await mockSystemActionApis(page, { reimbursementStatus: "approved" }); + const detail = reimbursementDetail("approved"); + const sellerName = "合成开票方长名称".repeat(8); + detail.invoices = Array.from({ length: 5 }, (_, index) => ({ + ...detail.invoices[0], + id: `invoice-layout-${index}`, + invoiceNumber: `SYNTHETIC-LAYOUT-000000000000000000000${index}`, + sellerName, + })); + detail.attachments = []; + await page.route(`**/api/reimbursements/${reimbursementId}`, (route) => fulfillJson(route, detail)); + let previews = 0; + await page.route("**/api/invoice-pool/invoice-layout-0/signed-url", async (route) => { + previews += 1; + await fulfillJson(route, { signedUrl: "about:blank", originalName: "合成预览.pdf", fileSize: 1024 }); + }); + await login(page, user); + await page.goto(`/reimbursements/${reimbursementId}`); + const table = page.getByTestId("reimbursement-invoices-table"); + await expect(table).toBeVisible(); + await expect(table.getByTestId("approval-invoice-evidence")).toHaveCount(5); + await expect(table.getByText(sellerName, { exact: true })).toHaveCount(5); + if (width >= 1024) await expect(table.getByRole("columnheader", { name: "金额" })).toBeVisible(); + else await expect(table.locator("thead")).toBeHidden(); + await expect(page.getByText("未上传补充附件", { exact: true })).toBeVisible(); + await table.getByRole("button", { name: `查看发票:${detail.invoices[0].invoiceNumber}`, exact: true }).click(); + await expect.poll(() => previews).toBe(1); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true); + await table.screenshot({ path: testInfo.outputPath("invoice-table.png") }); + }); + } + for (const width of [1280, 1440]) { + test(`桌面${width}付款与报销侧栏等宽且更多紧随主按钮`, async ({ page }, testInfo) => { + await page.setViewportSize({ width, height: 960 }); + await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("reviewed") }); + await login(page, user); + const widths: number[] = []; + for (const [url, panelId] of [ + [`/reimbursements/${reimbursementId}`, "reimbursement-action-panel"], + [`/payment-batches?id=${paymentBatchId}`, "payment-batch-action-panel"], + ]) { + await page.goto(url); + const panel = page.getByTestId(panelId); + await expect(panel).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + widths.push((await panel.locator("../..").boundingBox())!.width); + const row = panel.getByTestId("detail-inline-actions"); + const primary = await row.locator("a, button").first().boundingBox(); + const more = await row.getByRole("button", { name: "更多", exact: true }).boundingBox(); + expect(Math.abs(primary!.y + primary!.height / 2 - more!.y - more!.height / 2)).toBeLessThan(2); + expect(more!.x).toBeGreaterThan(primary!.x + primary!.width); + await row.getByRole("button", { name: "更多", exact: true }).click(); + await expect(page.getByRole("menu")).toBeVisible(); + await page.keyboard.press("Escape"); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 2)).toBe(true); + await page.screenshot({ path: testInfo.outputPath(`${panelId}.png`), fullPage: true }); + } + expect(widths).toEqual([320, 320]); + }); + test(`桌面${width}创建批次按钮短文案按内容宽度且只导航不组批`, async ({ page }, testInfo) => { await page.setViewportSize({ width, height: 960 }); await mockSystemActionApis(page, { reimbursementStatus: "approved", reimbursementProgress: () => progress("none") }); @@ -913,13 +1005,13 @@ test.describe("R7E 全站高风险操作二次确认", () => { const actionPanel = page.getByTestId("payment-batch-action-panel"); await expect(actionPanel.getByTestId("payment-batch-action-downloadOrder")).toBeVisible(); await actionPanel.getByText("更多", { exact: true }).click(); - await expect(actionPanel.getByRole("button", { name: "上传银行回单" })).toBeVisible(); + await expect(page.getByRole("menuitem", { name: "上传银行回单" })).toBeVisible(); await expect(page.getByTestId("payment-batch-mobile-actions")).toBeHidden(); await expect(page.getByTestId("payment-batch-receipt-panel").getByRole("button", { name: "上传银行回单" })).toHaveCount(0); await expect(page.getByRole("button", { name: "登记付款完成" })).toHaveCount(0); const desktopFileChooser = page.waitForEvent("filechooser"); - await actionPanel.getByRole("button", { name: "上传银行回单" }).click(); + await page.getByRole("menuitem", { name: "上传银行回单" }).click(); await (await desktopFileChooser).setFiles({ name: "合成银行回单.pdf", mimeType: "application/pdf", @@ -1008,7 +1100,7 @@ test.describe("R7E 全站高风险操作二次确认", () => { await page.screenshot({ path: testInfo.outputPath("after-payment-order.png"), fullPage: true }); await actions.getByText("更多", { exact: true }).click(); if (mobile) await page.getByRole("menuitem", { name: "重新下载制单" }).click(); - else await actions.getByTestId("payment-batch-action-redownloadOrder").click(); + else await page.getByRole("menuitem", { name: "重新下载制单" }).click(); await expect(dialog).toContainText("请勿重复提交"); await expect(dialog.getByRole("textbox")).toHaveCount(0); const secondDownload = page.waitForEvent("download");