统一页面右上角操作区(05-31-unified-page-header-actions)

这个提交包含在:
2026-05-31 16:41:44 +08:00
父节点 b71d23e661
当前提交 dcf34d7ac6
共修改 18 个文件,包含 480 行新增和 183 行删除
@@ -62,6 +62,12 @@ reference, not a copy of Feishu or Ant Design branding.
action: `新建报销`, `新建采购`, `新增合同`, `新增资产`, `新建项目`. Workbench, action: `新建报销`, `新建采购`, `新增合同`, `新增资产`, `新建项目`. Workbench,
pending-task, notification and read-only record pages must not force a header pending-task, notification and read-only record pages must not force a header
button for visual symmetry. button for visual symmetry.
- PC page header actions follow a strict mature enterprise rule: the title
right side may have zero buttons, one page-level primary action, or one
primary action plus one `更多`. Do not place `筛选`, `导出`, `刷新`,
`显示测试数据`, category managers, export tools or other list-level tools as
persistent header-right buttons. Put those in the list toolbar, filter panel,
explicit management panel, row action or detail action area.
- Button labels use common enterprise verbs: `处理`, `去处理`, `查看详情`, - Button labels use common enterprise verbs: `处理`, `去处理`, `查看详情`,
`查看全部`, `查看进度`, `继续编辑`, `新建`, `新增`, `提交`, `保存`, `取消`, `查看全部`, `查看进度`, `继续编辑`, `新建`, `新增`, `提交`, `保存`, `取消`,
`撤回`, `作废`, `归档`, `更多`. Do not use slogan-like or invented labels `撤回`, `作废`, `归档`, `更多`. Do not use slogan-like or invented labels
@@ -94,6 +100,10 @@ reference, not a copy of Feishu or Ant Design branding.
ranges, contract type, sensitivity, export and test-data toggles belong in ranges, contract type, sensitivity, export and test-data toggles belong in
`筛选`, `更多`, clearly named role-only actions or a tab-specific advanced `筛选`, `更多`, clearly named role-only actions or a tab-specific advanced
area; default-visible filters should not occupy multiple rows. area; default-visible filters should not occupy multiple rows.
- List toolbars may show one or two high-frequency controls beside common
filters. Low-frequency navigation, export, expiry lists, trial-data toggles
and troubleshooting actions should collapse into `更多` or advanced filters
instead of forming a second row of persistent buttons.
- Business list columns show object identity, counterpart/person, amount/date, - Business list columns show object identity, counterpart/person, amount/date,
state and operation. `下一步` is not a default list column or right-side help state and operation. `下一步` is not a default list column or right-side help
panel. Use short state labels such as `待补材料` or `待你处理`; longer guidance panel. Use short state labels such as `待补材料` or `待你处理`; longer guidance
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "检查 Trellis validate、governance、提交和任务收口"}
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "检查右上角操作区是否符合统一规则"}
{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "检查截图、报告和任务记录是否安全"}
@@ -0,0 +1,79 @@
# 设计方案:页面右上角操作区
## 统一模型
页面分三层放动作:
1. **页面标题右上角**
- 只放页面级主操作。
- 最多一个主按钮 + 一个 `更多`。
- 主按钮必须是用户来到该页面最自然的创建/发起动作。
2. **列表工具栏 / 筛选区右侧**
- 放筛选、范围、导出、刷新、统计、批量、测试数据开关。
- 这些动作影响列表视图或当前查询范围,不是页面主操作。
- 默认只露出一到两个高频动作;导出、到期列表、测试数据、关联跳转等低频动作收进 `更多` 或高级筛选,避免工具栏形成第二排常驻按钮。
3. **行操作 / 详情操作区**
- 放 `查看详情`、`处理`、`继续编辑`、`查看进度`、`作废`、`归档` 等对象级动作。
- 风险动作必须在详情页或确认弹窗内完成。
## 右上角按钮规则
- 0 个按钮:工作台、待我处理、通知、只读记录页默认允许没有标题右侧按钮。
- 1 个按钮:有明确创建/发起动作的页面,只保留主按钮。
- 2 个按钮:主按钮 + `更多`。仅当存在必要页面级低频动作。
- 禁止 3 个及以上标题右侧按钮。
- 禁止把 `筛选`、`刷新`、`导出`、`显示测试数据` 直接放在标题右侧。
- 禁止为了视觉对齐给无主操作页面硬塞按钮。
## 页面应用
- `报销`:标题右上角 `新建报销`;统计/导出/范围切换在列表工具栏。
- `采购`:标题右上角 `新建采购`;筛选在列表工具栏。
- `合同`:标题右上角 `新增合同`;到期、导出、筛选进入列表工具栏/更多。
- `项目`:标题右上角 `新建项目`;合同入口和测试数据开关不在标题右上角。
- `资产`:标题右上角 `新增资产` + `更多`;更多内放扫码查找、资产分类。
- `付款`:标题右上角 `新建批次`;刷新在列表工具栏。
- `应付账款`:标题右上角无主按钮;刷新、导出在列表工具栏。
- `票据`:标题右上角 `上传票据` + `更多`;更多内放绑定和导出。
## 其他必须统一的页面规则
1. **命名规则**
菜单、页面标题、面包屑/返回入口使用同一个短名。业务对象内部可以叫 `采购申请`,但一级菜单和列表页标题统一叫 `采购`。
2. **标题区文案**
标题区只放标题、必要标签和主操作。默认不放副标题,不解释页面用途。
3. **筛选区**
状态切换优先用 tabs/chips;高频筛选一行展示;高级筛选统一由 `筛选` 打开。筛选入口属于列表工具栏,不属于页面标题区。
4. **列表工具区**
列表工具栏右侧只保留高频范围切换、`筛选` 或一个 `更多`。低频导出、到期、测试数据、关联跳转、排障动作不常驻外露。
5. **列表区**
所有业务列表保持单列首屏,不自动展开详情。表格列顺序统一为身份、业务关系、金额/日期、状态、操作。
6. **空状态**
空状态只说明当前筛选没有数据,并给一个真实下一步。不要写教学式流程说明。
7. **更多菜单**
`更多` 用于低频、导出、管理、排障或非主路径动作。浮层必须右对齐,不能被表格、卡片或滚动容器遮挡。
8. **详情与编辑**
详情页操作在右侧状态/操作面板;编辑页操作在底部或表单结尾固定区域。不要把详情操作塞回列表页标题右上角。
9. **权限**
无权限动作默认隐藏。配置缺失时才展示边界说明和配置入口。
10. **移动端**
移动端使用底部主操作和 `更多`,不复刻 PC 顶部多按钮。
## 实施策略
优先小步改造,不引入大 UI 框架:
1. 在现有 `WorkspaceHeader` / `PageHeader` 上继续使用 `actions`,先完成页面迁移。
2. 增加一个轻量共享 `HeaderActionGroup` 或等价模式,约束一主一更多。
3. 更新设计规范和 E2E 断言,防止新页面继续把多个工具塞到标题右侧。
@@ -0,0 +1,3 @@
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task、编码前方案和提交规则"}
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "页面标题、业务列表、操作区和成熟企业后台设计规则"}
{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "记录视觉复检证据和避免敏感信息入库"}
@@ -0,0 +1,16 @@
# 实施记录
## 本轮改造
- 新增 `HeaderActionGroup`,统一标题右侧按钮排列。
- 采购、合同、项目、资产、付款、应付账款、票据完成首轮标题右侧操作迁移。
- 创建类标题按钮保持主按钮样式;详情态只保留 `返回列表`。
- 合同和项目的低频列表工具收进 `更多`,避免工具栏持续堆叠。
- 应付账款 tabs 与 `刷新` / `导出` 调整为横向命令栏。
- 票据列表支持把绑定和导出放入列表工具栏,不再占用标题右侧。
## 视觉结论
- 标题区符合“0 个 / 1 个主操作 / 主操作 + 更多”的企业后台常规模式。
- 列表级 `筛选`、`导出`、`刷新`、测试数据和关联跳转不再作为标题右上角常驻按钮。
- 合同、付款、应付账款、票据截图已覆盖;后续页面改造继续按同一模板推进。
@@ -0,0 +1,99 @@
# 统一页面右上角操作区规划
## Goal
统一 PC 页面标题右上角按钮规划,解决当前业务页右上角按钮数量、优先级和位置不一致的问题。页面应采用成熟企业后台/Ant Design Pro 常见模式:页面标题区只承载页面级主操作,列表筛选、导出、刷新、测试数据、分类管理等低频或列表级工具进入列表工具栏、筛选区或 `更多`。
## Scope
- 前端 PC 页面标题右侧操作区、业务列表工具栏和相关 E2E/视觉断言。
- 首轮覆盖报销、采购、合同、项目、资产、付款、应付账款和票据等业务页。
- 只调整操作入口位置、按钮优先级、列表工具收纳和设计规范,不改变业务权限、接口、状态机或数据结构。
## What I Already Know
- 用户反馈:现在页面右上角按钮规划不统一。
- 产品背景:面向国内小于 10 人科技初创公司,系统应低认知负担、成熟常规、不创新。
- 既有规范要求:页面标题默认为 route 名称;Header primary action 只保留真实页面级主操作;低频工具不要堆在右上角。
- 当前代码审计发现:
- `报销`:标题右侧主要是 `新建报销`,列表工具在下方,相对接近目标。
- `采购`:标题右侧同时放 `新建采购` 和 `筛选`,筛选应进入列表工具区。
- `合同`:标题右侧同时放 `新增合同`、`到期列表`、`导出`、`筛选`,过载最明显。
- `项目`:标题右侧放 `合同`、`显示测试数据`、`新建项目`,页面级动作与排障开关混用。
- `资产`:标题右侧放 `新增资产`、`移动扫码查找`、`资产分类`,管理工具和主操作混用。
- `付款`:标题右侧放 `刷新`、`新建批次`,刷新不应占据标题主操作区。
- `应付账款`:标题右侧放 `刷新`、`导出`,只读列表页不应强制占用标题主按钮区。
- `票据`:标题右侧放 `上传票据`、`绑定`、`导出`,主操作与后续处理工具混用。
## Requirements
- 标题右上角只允许:
- 一个页面级主操作按钮,例如 `新建报销`、`新建采购`、`新增合同`、`新建项目`、`新增资产`、`新建批次`、`上传票据`。
- 最多一个 `更多`,用于页面级低频操作;当低频操作只有列表级含义时,放到列表工具栏,不放标题区。
- 详情/编辑模式可用一个 `返回列表`,但不与创建按钮并列。
- `筛选` 不放标题右上角;统一放在列表工具栏/筛选区右侧。
- `导出` 不作为标题区常驻按钮;统一进入列表工具栏右侧或 `更多`。
- `刷新` 不作为标题区常驻按钮;需要保留时进入列表工具栏,或作为低频工具。
- `显示测试数据` 属于排障/试运行开关,不放标题右上角,进入筛选/更多/排障区。
- `分类管理`、`扫码查找`、`到期列表`、`绑定` 等非创建动作,不与主创建按钮并列;按场景进入 `更多`、筛选区、管理入口或下方操作面板。
- 尽量通过共享组件或模板约束新页面,避免继续逐页漂移。
## Additional Consistency Rules To Review
- 页面标题:菜单名、页面主标题和浏览路径用词要一致;例如菜单 `合同`,页面标题也用 `合同`,不要在不同位置混用 `合同台账`、`合同管理`、`合同列表`。
- 说明文案:默认页面不展示解释性副标题;只在空状态、错误、权限、企业微信边界、不可逆操作和试运行边界中解释。
- 筛选布局:业务列表统一为“状态/范围 tabs 或 chips + 一行高频筛选 + 高级筛选入口”;不允许有的页面两行筛选、有的页面无筛选、有的页面把筛选放标题右上角。
- 列表列结构:业务列表统一按“对象身份、相对方/人员、金额/日期、状态、操作”组织;右侧固定操作列默认 `查看详情` / `处理` / `继续编辑`。
- 空状态:统一使用短标题 + 一句当前筛选结果 + 一个真实下一步;不要写流程说明。
- 更多菜单:所有 `更多` 都右对齐、浮层不被遮挡、只放低频动作;危险动作必须二次确认。
- 详情页:PC 统一为“顶部摘要 + 主事实/证据 + 右侧状态/操作区 + 历史”;列表页不默认在下方展开详情。
- 编辑页/面板:创建和编辑统一使用同一编辑模板;主按钮固定为 `保存` / `提交`,取消/返回在左或次级位置。
- 权限显隐:无权限动作直接隐藏;不要显示不可用按钮再解释一堆原因,除非是权限申请或配置缺失边界。
- 移动端对应:移动端不照搬 PC 右上角;统一为顶部标题 + 卡片列表/详情 + 底部主操作,次级动作进入 `更多`。
## Proposed Page-Level Changes
| 页面 | 标题右上角目标 | 迁移动作 |
| --- | --- | --- |
| `报销` | `新建报销` | 保持;检查导出/统计是否仍在列表工具栏。 |
| `采购` | `新建采购` | `筛选` 移到列表工具栏。 |
| `合同` | `新增合同` + `更多`(如需要) | `筛选` 移到筛选区;`导出` 移到列表工具栏/更多;`到期列表` 改为列表状态/筛选入口。 |
| `项目` | `新建项目` | `合同` 移为列表/详情关联入口;`显示测试数据` 移到筛选/排障入口。 |
| `资产` | `新增资产` + `更多` | `移动扫码查找`、`资产分类` 放入 `更多` 或管理面板,不与新增并列。 |
| `付款` | `新建批次` | `刷新` 移到列表工具栏。 |
| `应付账款` | 无主按钮,必要时 `更多` | `刷新`、`导出` 移到列表工具栏/更多。 |
| `票据` | `上传票据` + `更多` | `绑定`、`导出` 放入 `更多` 或列表工具栏。 |
## Out of Scope
- 不改变业务权限、审批规则、数据结构或接口。
- 不新增真实业务写入。
- 不重新设计移动端底部主操作;本任务先收敛 PC 页面标题右上角。
- 不部署公网;实现完成后先本地验证,部署另行获得批准。
## Acceptance Criteria
- [ ] 业务列表页标题右上角符合“一主操作 + 最多一个更多”的规则。
- [ ] `筛选`、`导出`、`刷新`、`显示测试数据` 不再作为标题右上角常驻按钮。
- [ ] 采购、合同、项目、资产、付款、应付账款、票据至少完成首轮统一。
- [ ] 规则写入前端设计规范,后续页面可复用。
- [ ] 标题、筛选、列表列、空状态、详情/编辑操作区至少完成一致性审计,形成后续改造清单。
- [ ] 前端 typecheck/build 通过。
- [ ] 聚焦 Playwright/视觉复检覆盖主要业务页。
## Test Plan
```bash
cd client && npm run typecheck
cd client && npm run build
E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/r103-cross-page-ui-consistency.spec.ts --workers=1 --reporter=list
git diff --check
python3 ./.trellis/scripts/task.py validate 05-31-unified-page-header-actions
node scripts/qa/check-trellis-governance.mjs
```
## Technical Notes
- 设计裁决使用 `frontend-design` skill,并按成熟企业后台/Ant Design Pro 的页面区分:标题区负责页面级主操作,表格工具栏负责列表级工具。
- 相关规范:`.trellis/spec/hyb-oa/frontend-react-vite-design.md`
- 相关组件:`client/src/components/patterns/ListWorkspace.tsx`、`client/src/components/common/PageHeader.tsx`、`client/src/components/patterns/BusinessListTemplate.tsx`
@@ -0,0 +1,26 @@
{
"id": "unified-page-header-actions",
"name": "unified-page-header-actions",
"title": "统一页面右上角操作区规划",
"description": "统一 PC 页面标题右上角操作区规划,收敛主操作、更多、筛选、导出、刷新等按钮位置。",
"status": "in_progress",
"dev_type": null,
"scope": "frontend",
"package": null,
"priority": "P2",
"creator": "shunli",
"assignee": "shunli",
"createdAt": "2026-05-31",
"completedAt": null,
"branch": null,
"base_branch": "main",
"worktree_path": null,
"commit": null,
"pr_url": null,
"subtasks": [],
"children": [],
"parent": null,
"relatedFiles": [],
"notes": "",
"meta": {}
}
@@ -33,6 +33,10 @@ export function WorkspaceHeader({ eyebrow, title, subtitle, meta, actions, class
); );
} }
export function HeaderActionGroup({ className, children }: WorkspaceChildrenProps) {
return <div className={cn("flex flex-wrap items-center justify-end gap-2", className)}>{children}</div>;
}
export function FilterBar({ className, children }: WorkspaceChildrenProps) { export function FilterBar({ className, children }: WorkspaceChildrenProps) {
return <section className={cn("card-std p-3 md:p-3.5", className)}>{children}</section>; return <section className={cn("card-std p-3 md:p-3.5", className)}>{children}</section>;
} }
+43 -43
查看文件
@@ -593,41 +593,7 @@ export function ApOperationsPage() {
return ( return (
<ListWorkspace> <ListWorkspace>
<WorkspaceHeader <WorkspaceHeader title="应付账款" />
title="应付账款"
actions={
<>
<Button
type="button"
variant="secondary"
onClick={() => refreshWorkbench()}
disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching || financeLedgerQuery.isFetching}
>
刷新
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="ap-export"
type="button"
variant="secondary"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="ap-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="ap-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
/>
{summaryQuery.isError ? ( {summaryQuery.isError ? (
<SectionError message={resolveErrorMessage(summaryQuery.error, "应付摘要加载失败")} /> <SectionError message={resolveErrorMessage(summaryQuery.error, "应付摘要加载失败")} />
@@ -642,15 +608,49 @@ export function ApOperationsPage() {
<SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} /> <SectionError message={resolveErrorMessage(financeLedgerQuery.error, "财务台账加载失败")} />
) : null} ) : null}
<div className="finance-command-panel"> <div className="finance-command-panel space-y-3">
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}> <Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as OperationsTab)}>
<TabsList className="ops-command-bar__tabs !w-full !justify-start"> <div className="ops-command-bar__row">
<TabsTrigger value="pending">待付款 {pendingBatches.length}</TabsTrigger> <TabsList className="ops-command-bar__tabs !justify-start">
<TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger> <TabsTrigger value="pending">待付款 {pendingBatches.length}</TabsTrigger>
<TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger> <TabsTrigger value="failed">异常付款 {failedItems.length}</TabsTrigger>
<TabsTrigger value="reconciliation">对账异常 {partialMatchItems.length}</TabsTrigger> <TabsTrigger value="risk">风险标记 {filteredRiskFlags.length}</TabsTrigger>
<TabsTrigger value="ledger">财务台账 {financeLedgerQuery.data?.total ?? 0}</TabsTrigger> <TabsTrigger value="reconciliation">对账异常 {partialMatchItems.length}</TabsTrigger>
</TabsList> <TabsTrigger value="ledger">财务台账 {financeLedgerQuery.data?.total ?? 0}</TabsTrigger>
</TabsList>
<div className="ops-command-bar__actions">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => refreshWorkbench()}
disabled={summaryQuery.isFetching || paymentBatchesQuery.isFetching || riskFlagsQuery.isFetching || financeLedgerQuery.isFetching}
>
刷新
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="ap-export"
type="button"
size="sm"
variant="secondary"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="ap-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="ap-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
<TabsContent value="ledger" className="mt-6 space-y-4"> <TabsContent value="ledger" className="mt-6 space-y-4">
<div className="finance-command-panel space-y-3 !p-3"> <div className="finance-command-panel space-y-3 !p-3">
+21 -15
查看文件
@@ -37,13 +37,14 @@ import {
BusinessDetailMain, BusinessDetailMain,
BusinessDetailWorkspace, BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate"; } from "../components/patterns/BusinessDetailTemplate";
import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { HeaderActionGroup, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { import {
BusinessListToolbar, BusinessListToolbar,
BusinessMobileList, BusinessMobileList,
BusinessTable, BusinessTable,
} from "../components/patterns/BusinessListTemplate"; } from "../components/patterns/BusinessListTemplate";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat"; import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
@@ -727,7 +728,7 @@ export function AssetRegistryPage() {
<WorkspaceHeader <WorkspaceHeader
title="资产" title="资产"
actions={ actions={
<div className="flex flex-wrap gap-2"> <HeaderActionGroup>
{isAssetDetailMode ? ( {isAssetDetailMode ? (
<Button type="button" variant="secondary" onClick={returnToAssetList}> <Button type="button" variant="secondary" onClick={returnToAssetList}>
返回列表 返回列表
@@ -739,21 +740,26 @@ export function AssetRegistryPage() {
新增资产 新增资产
</Button> </Button>
) : null} ) : null}
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }> <DropdownMenu>
移动扫码查找 <DropdownMenuTrigger asChild>
</Button> <Button type="button" variant={showCategoryManager ? "default" : "secondary"}>
{canManage ? ( 更多
<Button </Button>
type="button" </DropdownMenuTrigger>
variant={showCategoryManager ? "default" : "secondary"} <DropdownMenuContent align="end">
onClick={() => setShowCategoryManager((value) => !value)} <DropdownMenuItem onSelect={() => navigate("/mobile/assets/scan")}>
> 移动扫码查找
资产分类 </DropdownMenuItem>
</Button> {canManage ? (
) : null} <DropdownMenuItem onSelect={() => setShowCategoryManager((value) => !value)}>
{showCategoryManager ? "收起资产分类" : "资产分类"}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
</> </>
)} )}
</div> </HeaderActionGroup>
} }
/> />
+61 -54
查看文件
@@ -25,7 +25,7 @@ import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { PageHeader } from "../components/common/PageHeader"; import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput"; import { DateTextInput } from "../components/common/DateTextInput";
import { WorkspaceNotice } from "../components/patterns/ListWorkspace"; import { HeaderActionGroup, WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { import {
BusinessListToolbar, BusinessListToolbar,
BusinessMobileList, BusinessMobileList,
@@ -334,12 +334,7 @@ export function ContractsRegistryPage() {
<PageHeader <PageHeader
title="合同" title="合同"
actions={ actions={
<div className="flex flex-wrap gap-2"> <HeaderActionGroup>
{initialProjectId ? (
<Button asChild variant="secondary" type="button">
<Link to="/projects">返回项目</Link>
</Button>
) : null}
<Button <Button
type="button" type="button"
onClick={() => { onClick={() => {
@@ -349,37 +344,7 @@ export function ContractsRegistryPage() {
> >
新增合同 新增合同
</Button> </Button>
<Button </HeaderActionGroup>
type="button"
variant={showExpiring ? "default" : "secondary"}
disabled={expiringQuery.isLoading}
onClick={() => setShowExpiring((value) => !value)}
>
到期列表 {expiringContractCount}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="secondary" disabled={exportMutation.isPending}>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => setConfirmExportFormat("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setConfirmExportFormat("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant={showAdvancedFilters || filters.expiryDateFrom || filters.expiryDateTo || showTrialData ? "default" : "secondary"}
onClick={() => setShowAdvancedFilters((value) => !value)}
>
筛选
</Button>
</div>
} }
/> />
@@ -672,27 +637,48 @@ export function ContractsRegistryPage() {
<> <>
<Button <Button
type="button" type="button"
variant="secondary" size="sm"
onClick={() => { variant={showAdvancedFilters || filters.expiryDateFrom || filters.expiryDateTo || showTrialData ? "default" : "secondary"}
setFilters({ onClick={() => setShowAdvancedFilters((value) => !value)}
projectId: initialProjectId,
status: "",
contractType: "",
counterpartyName: "",
expiryDateFrom: "",
expiryDateTo: "",
});
setShowEditor(false);
setEditorId(null);
setEditor(buildEmptyEditor(initialProjectId));
}}
> >
重置筛选 筛选
</Button> </Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" size="sm" variant={showExpiring ? "default" : "secondary"}>
更多
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{initialProjectId ? (
<DropdownMenuItem asChild>
<Link to="/projects">返回项目</Link>
</DropdownMenuItem>
) : null}
<DropdownMenuItem
disabled={expiringQuery.isLoading}
onSelect={() => setShowExpiring((value) => !value)}
>
{showExpiring ? "退出到期列表" : `到期列表 ${expiringContractCount}`}
</DropdownMenuItem>
<DropdownMenuItem
disabled={exportMutation.isPending}
onSelect={() => setConfirmExportFormat("csv")}
>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem
disabled={exportMutation.isPending}
onSelect={() => setConfirmExportFormat("xlsx")}
>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</> </>
} }
advanced={showAdvancedFilters || filters.expiryDateFrom || filters.expiryDateTo || showTrialData ? ( advanced={showAdvancedFilters || filters.expiryDateFrom || filters.expiryDateTo || showTrialData ? (
<div className="grid gap-3 md:grid-cols-[160px_160px_auto] xl:grid-cols-[180px_180px_auto]" data-testid="contracts-advanced-filters"> <div className="grid gap-3 md:grid-cols-[160px_160px_auto_auto] xl:grid-cols-[180px_180px_auto_auto]" data-testid="contracts-advanced-filters">
<div className="space-y-2"> <div className="space-y-2">
<Label>到期日开始</Label> <Label>到期日开始</Label>
<DateTextInput <DateTextInput
@@ -714,6 +700,27 @@ export function ContractsRegistryPage() {
{showTrialData ? "隐藏测试数据" : "显示测试数据"} {showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button> </Button>
</div> </div>
<div className="flex items-end">
<Button
type="button"
variant="secondary"
onClick={() => {
setFilters({
projectId: initialProjectId,
status: "",
contractType: "",
counterpartyName: "",
expiryDateFrom: "",
expiryDateTo: "",
});
setShowEditor(false);
setEditorId(null);
setEditor(buildEmptyEditor(initialProjectId));
}}
>
重置筛选
</Button>
</div>
</div> </div>
) : null} ) : null}
> >
+37 -32
查看文件
@@ -29,7 +29,7 @@ import { hasPermission } from "../auth/permissions";
import { toast } from "sonner"; import { toast } from "sonner";
import { downloadBlob } from "../lib/download"; import { downloadBlob } from "../lib/download";
import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr"; import { formatInvoiceOcrErrorMessage } from "../lib/invoiceOcr";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard"; import { InvoiceUploadCard } from "./invoice-pool/InvoiceUploadCard";
@@ -475,7 +475,7 @@ export function InvoicePoolPage() {
<WorkspaceHeader <WorkspaceHeader
title="票据" title="票据"
actions={ actions={
<div className="flex flex-wrap gap-2"> <HeaderActionGroup>
{isInvoiceDetailMode ? ( {isInvoiceDetailMode ? (
<Button type="button" variant="secondary" onClick={returnToInvoiceList}> <Button type="button" variant="secondary" onClick={returnToInvoiceList}>
返回列表 返回列表
@@ -484,41 +484,13 @@ export function InvoicePoolPage() {
<> <>
<Button <Button
type="button" type="button"
variant={invoiceActionPanel === "upload" ? "default" : "secondary"} onClick={() => setInvoiceActionPanel("upload")}
onClick={() => setInvoiceActionPanel((current) => (current === "upload" ? null : "upload"))}
> >
上传票据 上传票据
</Button> </Button>
<Button
type="button"
variant={invoiceActionPanel === "bind" ? "default" : "secondary"}
onClick={() => setInvoiceActionPanel((current) => (current === "bind" ? null : "bind"))}
>
绑定
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="invoice-export"
type="button"
variant="secondary"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</> </>
)} )}
</div> </HeaderActionGroup>
} }
/> />
@@ -573,6 +545,39 @@ export function InvoicePoolPage() {
{!isInvoiceDetailMode ? ( {!isInvoiceDetailMode ? (
<InvoiceListCard <InvoiceListCard
listQuery={listQuery} listQuery={listQuery}
actions={
<>
<Button
type="button"
size="sm"
variant={invoiceActionPanel === "bind" ? "default" : "secondary"}
onClick={() => setInvoiceActionPanel((current) => (current === "bind" ? null : "bind"))}
>
绑定
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="invoice-export"
type="button"
size="sm"
variant="secondary"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem data-testid="invoice-export-csv" onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem data-testid="invoice-export-xlsx" onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
}
status={status} status={status}
setStatus={setStatus} setStatus={setStatus}
ownerScope={ownerScope} ownerScope={ownerScope}
+16 -7
查看文件
@@ -21,8 +21,9 @@ import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext"; import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions"; import { hasPermission } from "../auth/permissions";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { import {
BusinessListToolbar,
BusinessMobileList, BusinessMobileList,
BusinessTable, BusinessTable,
} from "../components/patterns/BusinessListTemplate"; } from "../components/patterns/BusinessListTemplate";
@@ -346,21 +347,18 @@ export function PaymentBatchesPage() {
<WorkspaceHeader <WorkspaceHeader
title="付款" title="付款"
actions={ actions={
<div className="flex flex-wrap gap-2"> <HeaderActionGroup>
{selectedBatchId ? ( {selectedBatchId ? (
<Button type="button" variant="secondary" onClick={returnToList}> <Button type="button" variant="secondary" onClick={returnToList}>
返回列表 返回列表
</Button> </Button>
) : null} ) : null}
<Button type="button" variant="secondary" onClick={() => void refresh()}>
刷新
</Button>
{!selectedBatchId ? ( {!selectedBatchId ? (
<Button type="button" variant={showCreatePanel ? "default" : "secondary"} onClick={() => setShowCreatePanel((current) => !current)}> <Button type="button" onClick={() => setShowCreatePanel(true)}>
新建批次 新建批次
</Button> </Button>
) : null} ) : null}
</div> </HeaderActionGroup>
} }
/> />
@@ -663,6 +661,17 @@ export function PaymentBatchesPage() {
) )
) : ( ) : (
<> <>
<BusinessListToolbar
actions={
<Button type="button" size="sm" variant="secondary" onClick={() => void refresh()}>
刷新
</Button>
}
>
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
全部批次 {batches.length}
</span>
</BusinessListToolbar>
<BusinessMobileList testId="payment-batches-mobile-list"> <BusinessMobileList testId="payment-batches-mobile-list">
{batches.map((item) => ( {batches.map((item) => (
<button <button
+30 -13
查看文件
@@ -14,7 +14,7 @@ import { useAuth } from "../auth/AuthContext";
import { hasAnyPermission } from "../auth/permissions"; import { hasAnyPermission } from "../auth/permissions";
import { DateTextInput } from "../components/common/DateTextInput"; import { DateTextInput } from "../components/common/DateTextInput";
import { StatusBadge } from "../components/common/StatusBadge"; import { StatusBadge } from "../components/common/StatusBadge";
import { ListWorkspace, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace"; import { HeaderActionGroup, ListWorkspace, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace";
import { import {
BusinessListToolbar, BusinessListToolbar,
BusinessMobileList, BusinessMobileList,
@@ -22,6 +22,12 @@ import {
} from "../components/patterns/BusinessListTemplate"; } from "../components/patterns/BusinessListTemplate";
import { SectionPanel } from "../components/patterns/SectionPanel"; import { SectionPanel } from "../components/patterns/SectionPanel";
import { Button } from "../components/ui/button"; import { Button } from "../components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label"; import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea"; import { Textarea } from "../components/ui/textarea";
@@ -179,19 +185,13 @@ export function ProjectsPage() {
<WorkspaceHeader <WorkspaceHeader
title="项目" title="项目"
actions={ actions={
<div className="flex gap-2"> <HeaderActionGroup>
<Button asChild variant="secondary" type="button">
<Link to="/contracts-registry">合同</Link>
</Button>
<Button type="button" variant={showTrialData ? "default" : "secondary"} onClick={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
</Button>
{canManageProjects ? ( {canManageProjects ? (
<Button type="button" variant={showCreatePanel ? "default" : "secondary"} onClick={() => setShowCreatePanel((value) => !value)}> <Button type="button" onClick={() => setShowCreatePanel(true)}>
新建项目 新建项目
</Button> </Button>
) : null} ) : null}
</div> </HeaderActionGroup>
} }
/> />
@@ -260,9 +260,26 @@ export function ProjectsPage() {
<div className="space-y-4"> <div className="space-y-4">
<BusinessListToolbar <BusinessListToolbar
actions={ actions={
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}> <>
{mine ? "我相关" : "全范围"} <Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}>
</Button> {mine ? "我相关" : "全范围"}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" size="sm" variant={showTrialData ? "default" : "secondary"}>
更多
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to="/contracts-registry">查看合同</Link>
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => setShowTrialData((value) => !value)}>
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
} }
> >
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px]"> <div className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px]">
+19 -16
查看文件
@@ -44,7 +44,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input"; import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Textarea } from "../components/ui/textarea"; import { Textarea } from "../components/ui/textarea";
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import { import {
BusinessListToolbar, BusinessListToolbar,
BusinessMobileList, BusinessMobileList,
@@ -804,7 +804,7 @@ export function PurchaseRequestsPage() {
<WorkspaceHeader <WorkspaceHeader
title="采购" title="采购"
actions={ actions={
<div className="flex flex-wrap gap-2"> <HeaderActionGroup>
{isPurchaseDetailMode ? ( {isPurchaseDetailMode ? (
<Button type="button" variant="secondary" onClick={returnToPurchaseList}> <Button type="button" variant="secondary" onClick={returnToPurchaseList}>
返回列表 返回列表
@@ -821,19 +821,9 @@ export function PurchaseRequestsPage() {
{createMutation.isPending ? "创建中..." : "新建采购"} {createMutation.isPending ? "创建中..." : "新建采购"}
</Button> </Button>
) : null} ) : null}
{canShowAdvancedFilters ? (
<Button
data-testid="purchase-advanced-filters"
type="button"
variant={showAdvancedFilterPanel ? "default" : "secondary"}
onClick={() => setShowAdvancedFilters((current) => !current)}
>
筛选
</Button>
) : null}
</> </>
)} )}
</div> </HeaderActionGroup>
} }
/> />
@@ -847,9 +837,22 @@ export function PurchaseRequestsPage() {
<> <>
<BusinessListToolbar <BusinessListToolbar
actions={ actions={
<span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600"> <>
{mine ? "我相关" : "全范围"} <span className="rounded-full bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-600">
</span> {mine ? "我相关" : "全范围"}
</span>
{canShowAdvancedFilters ? (
<Button
data-testid="purchase-advanced-filters"
type="button"
size="sm"
variant={showAdvancedFilterPanel ? "default" : "secondary"}
onClick={() => setShowAdvancedFilters((current) => !current)}
>
筛选
</Button>
) : null}
</>
} }
advanced={showAdvancedFilterPanel ? ( advanced={showAdvancedFilterPanel ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4" data-testid="purchase-advanced-filters-panel"> <div className="grid gap-3 md:grid-cols-3 xl:grid-cols-4" data-testid="purchase-advanced-filters-panel">
@@ -1,3 +1,4 @@
import type { ReactNode } from "react";
import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query"; import type { UseMutationResult, UseQueryResult } from "@tanstack/react-query";
import type { InvoicePoolListResponse, InvoicePoolStatus } from "../../api/invoicePool"; import type { InvoicePoolListResponse, InvoicePoolStatus } from "../../api/invoicePool";
import { StatusBadge } from "../../components/common/StatusBadge"; import { StatusBadge } from "../../components/common/StatusBadge";
@@ -21,6 +22,7 @@ import { toast } from "sonner";
export type InvoiceListCardProps = { export type InvoiceListCardProps = {
listQuery: UseQueryResult<InvoicePoolListResponse, Error>; listQuery: UseQueryResult<InvoicePoolListResponse, Error>;
actions?: ReactNode;
status: InvoicePoolStatus | ""; status: InvoicePoolStatus | "";
setStatus: (status: InvoicePoolStatus | "") => void; setStatus: (status: InvoicePoolStatus | "") => void;
ownerScope: "own" | "all"; ownerScope: "own" | "all";
@@ -84,6 +86,7 @@ function CompactInvoiceNumber({ value }: { value: string }) {
export function InvoiceListCard({ export function InvoiceListCard({
listQuery, listQuery,
actions,
status, status,
setStatus, setStatus,
ownerScope, ownerScope,
@@ -96,7 +99,7 @@ export function InvoiceListCard({
}: InvoiceListCardProps) { }: InvoiceListCardProps) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<BusinessListToolbar> <BusinessListToolbar actions={actions}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-center"> <div className="flex flex-col gap-2 sm:flex-row sm:items-center">
<select <select
className="select-sm" className="select-sm"
@@ -1100,8 +1100,10 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
await page.goto("/contracts-registry?projectId=project-wave3"); await page.goto("/contracts-registry?projectId=project-wave3");
await expect(page.getByRole("heading", { name: "合同", exact: true })).toBeVisible(); await expect(page.getByRole("heading", { name: "合同", exact: true })).toBeVisible();
await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible(); await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible();
await expect(page.getByRole("button", { name: "导出" })).toBeVisible(); await page.locator(".ops-command-bar__actions").getByRole("button", { name: "更多" }).click();
await page.getByRole("button", { name: "筛选" }).click(); await expect(page.getByRole("menuitem", { name: "导出 CSV" })).toBeVisible();
await page.keyboard.press("Escape");
await page.getByRole("button", { name: "筛选", exact: true }).click();
await expect(page.getByTestId("contracts-advanced-filters")).toBeVisible(); await expect(page.getByTestId("contracts-advanced-filters")).toBeVisible();
await page.getByRole("button", { name: "新增合同" }).click(); await page.getByRole("button", { name: "新增合同" }).click();
await expect(page.getByTestId("contracts-editor-panel")).toBeVisible(); await expect(page.getByTestId("contracts-editor-panel")).toBeVisible();
@@ -348,6 +348,8 @@ test.describe("R103 cross page UI consistency", () => {
await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购")).toBeVisible(); await expect(page.locator("header").filter({ hasText: "业务" }).getByText("采购")).toBeVisible();
await expect(page.getByRole("heading", { name: "采购" })).toBeVisible(); await expect(page.getByRole("heading", { name: "采购" })).toBeVisible();
const purchaseHeader = page.locator(".page-header-shell").filter({ hasText: "采购" }).first();
await expect(purchaseHeader.getByRole("button", { name: "筛选" })).toHaveCount(0);
await expect(page.getByTestId("purchase-requests-table")).toBeVisible(); await expect(page.getByTestId("purchase-requests-table")).toBeVisible();
await expect(page.getByTestId("purchase-advanced-filters")).toBeVisible(); await expect(page.getByTestId("purchase-advanced-filters")).toBeVisible();
await expect(page.getByTestId("purchase-attachment-upload")).toHaveCount(0); await expect(page.getByTestId("purchase-attachment-upload")).toHaveCount(0);
@@ -375,6 +377,9 @@ test.describe("R103 cross page UI consistency", () => {
await page.goto("/invoice-pool"); await page.goto("/invoice-pool");
await expect(page.getByRole("heading", { name: "票据" })).toBeVisible(); await expect(page.getByRole("heading", { name: "票据" })).toBeVisible();
const invoiceHeader = page.locator(".page-header-shell").filter({ hasText: "票据" }).first();
await expect(invoiceHeader.getByRole("button", { name: "绑定" })).toHaveCount(0);
await expect(invoiceHeader.getByTestId("invoice-export")).toHaveCount(0);
await expect(page.getByTestId("invoice-detail-panel")).toHaveCount(0); await expect(page.getByTestId("invoice-detail-panel")).toHaveCount(0);
await page.getByRole("button", { name: "绑定" }).click(); await page.getByRole("button", { name: "绑定" }).click();
await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06"); await expect(page.getByTestId("invoice-bind-reimbursement")).toContainText("草稿单 · 2026-05-06");