From 40c23c5cf341c6fef8f7904655179742b3e7a3dd Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 31 May 2026 11:39:46 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E4=B8=9A=E5=8A=A1=E5=88=97?= =?UTF-8?q?=E8=A1=A8=20Ant=20Design=20=E9=A3=8E=E6=A0=BC=EF=BC=8805-31-uni?= =?UTF-8?q?fy-business-list-ant-design=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 | 46 +- .../check.jsonl | 2 + .../implement.jsonl | 2 + .../implement.md | 20 + .../prd.md | 92 ++++ .../ant-design-business-list-pattern.md | 20 + .../task.json | 26 ++ .../patterns/BusinessListTemplate.tsx | 108 +++++ client/src/pages/AssetRegistryPage.tsx | 46 +- client/src/pages/ContractsRegistryPage.tsx | 417 ++++++++++-------- client/src/pages/InvoicePoolPage.tsx | 28 +- client/src/pages/PaymentBatchesPage.tsx | 109 +++-- client/src/pages/ProjectsPage.tsx | 92 +++- client/src/pages/PurchaseRequestsPage.tsx | 222 +++++----- client/src/pages/ReimbursementsPage.tsx | 214 +++------ .../pages/invoice-pool/InvoiceListCard.tsx | 22 +- .../e2e/pc-detail-mobile-visual-wave3.spec.ts | 11 +- .../e2e/pc-finance-admin-visual-wave4.spec.ts | 4 +- .../e2e/r102-reimbursement-main-flow.spec.ts | 2 +- 19 files changed, 905 insertions(+), 578 deletions(-) create mode 100644 .trellis/tasks/05-31-unify-business-list-ant-design/check.jsonl create mode 100644 .trellis/tasks/05-31-unify-business-list-ant-design/implement.jsonl create mode 100644 .trellis/tasks/05-31-unify-business-list-ant-design/implement.md create mode 100644 .trellis/tasks/05-31-unify-business-list-ant-design/prd.md create mode 100644 .trellis/tasks/05-31-unify-business-list-ant-design/research/ant-design-business-list-pattern.md create mode 100644 .trellis/tasks/05-31-unify-business-list-ant-design/task.json create mode 100644 client/src/components/patterns/BusinessListTemplate.tsx diff --git a/.trellis/spec/hyb-oa/frontend-react-vite-design.md b/.trellis/spec/hyb-oa/frontend-react-vite-design.md index 94fb1d8b..376f399d 100644 --- a/.trellis/spec/hyb-oa/frontend-react-vite-design.md +++ b/.trellis/spec/hyb-oa/frontend-react-vite-design.md @@ -313,6 +313,37 @@ pages must pick one primary template before implementation: - Focused Playwright or screenshot checks for changed A-class, mobile or shared template surfaces. +## Convention: Business Template Components + +**What**: Operational pages should reuse thin template components for layout and +interaction coordinates, while keeping business fields, permission checks, state +matrices and actions explicit in each page. + +**Contracts**: + +- Use template components for repeated page structure instead of page-local + table cards, custom command bars or copied empty states. For business ledger + lists, prefer `BusinessListToolbar`, `BusinessTable`, `BusinessTableFrame`, + `BusinessMobileList` and shared empty states from + `client/src/components/patterns/BusinessListTemplate.tsx`. +- Do not create a configuration-driven page generator for reimbursement, + procurement, contracts, projects, assets, payments, AP or invoices. These + pages have different permissions, state transitions, attachment rules and + financial actions; hiding those differences in schema config increases + maintenance risk for the solo-developer workflow. +- PC ledger pages should render filters and table body as separate surfaces: + command bar first, table/list after it. Do not place the primary table inside + another card just to add a local title such as `台账列表`, `项目列表`, + `批次列表` or `票据列表`. +- Mobile may keep card rows, but the card fields and row actions should mirror + the PC table columns and operation column. +- Details, edits and approval pages should follow the same template philosophy: + shared shell and action placement, explicit business content. Implement them + incrementally through focused Trellis tasks. + +**Why**: Template components enforce mature Ant Design / Ant Design Pro style +layout consistency without turning business pages into a brittle generator. + ## Convention: Global Shell And Page Header Baseline **What**: PC page redesigns should preserve the shared Wave0-2 shell and @@ -1590,7 +1621,9 @@ from `client/src/index.css` before creating page-local card/table layouts: - `finance-status-strip`, `finance-metric-card`, `finance-metric-card__label` and `finance-metric-card__value` for compact - five-column status summaries. + admin summaries, analytics surfaces or true dashboards. Ordinary business + ledger lists such as `付款` and `票据` should prefer `BusinessListTemplate` + table surfaces instead of standalone summary chips/cards. - `finance-workspace-grid`, `finance-command-panel`, `finance-list-card` and `finance-list-card--active` for payment/AP/invoice operational workspaces. - `admin-command-panel`, `admin-tile-grid`, `admin-tile-card` and @@ -1604,17 +1637,14 @@ hierarchy without turning every page into a bespoke Tailwind card system. **Example**: ```tsx -
+
-
可处理
-
{actionableCount}
+
启用规则
+
{enabledCount}
-
-
...
-
...
-
+... ``` **Tests Required**: diff --git a/.trellis/tasks/05-31-unify-business-list-ant-design/check.jsonl b/.trellis/tasks/05-31-unify-business-list-ant-design/check.jsonl new file mode 100644 index 00000000..6183a232 --- /dev/null +++ b/.trellis/tasks/05-31-unify-business-list-ant-design/check.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "检查实现是否符合统一列表、命令栏、操作列、详情入口和测试要求。"} +{"file": ".trellis/tasks/05-31-unify-business-list-ant-design/research/ant-design-business-list-pattern.md", "reason": "检查实现是否符合本任务采用的 Ant Design / ProTable 业务列表参考模式。"} diff --git a/.trellis/tasks/05-31-unify-business-list-ant-design/implement.jsonl b/.trellis/tasks/05-31-unify-business-list-ant-design/implement.jsonl new file mode 100644 index 00000000..4581ecb0 --- /dev/null +++ b/.trellis/tasks/05-31-unify-business-list-ant-design/implement.jsonl @@ -0,0 +1,2 @@ +{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "统一业务列表页面的视觉、交互、布局和验证规则。"} +{"file": ".trellis/tasks/05-31-unify-business-list-ant-design/research/ant-design-business-list-pattern.md", "reason": "本任务采用的 Ant Design / ProTable 业务列表参考模式。"} diff --git a/.trellis/tasks/05-31-unify-business-list-ant-design/implement.md b/.trellis/tasks/05-31-unify-business-list-ant-design/implement.md new file mode 100644 index 00000000..3f1d25b2 --- /dev/null +++ b/.trellis/tasks/05-31-unify-business-list-ant-design/implement.md @@ -0,0 +1,20 @@ +# Implementation Notes + +## Plan + +1. 新增轻量 `BusinessListTemplate` 组件族,复用现有 `ListWorkspace`、`FilterBar`、`PrimaryTable` 和 `ops-*` 样式。 +2. 移除报销页顶部三个圆形任务按钮,改回标准状态 tabs 和台账工具。 +3. 将采购、资产列表拆成命令栏 + 表格,不再把主表格嵌在卡片/筛选容器里。 +4. 将合同、项目、付款 PC 默认列表从卡片流改为表格,移动端保留卡片表达。 +5. 移除票据和付款列表上的独立摘要 chips,测试锚点改为表格。 +6. 更新前端设计 spec,沉淀“模板组件 + 页面契约,不做页面生成器”的规则。 + +## Verification + +* `cd client && npm run typecheck` +* `cd client && npm run build` +* `r103-cross-page-ui-consistency + pc-finance-admin-visual-wave4 + r102-reimbursement-main-flow`:10 passed +* `pc-detail-mobile-visual-wave3 -g "Wave3 PC"`:1 passed +* `git diff --check` +* `python3 ./.trellis/scripts/task.py validate 05-31-unify-business-list-ant-design` +* `node scripts/qa/check-trellis-governance.mjs` diff --git a/.trellis/tasks/05-31-unify-business-list-ant-design/prd.md b/.trellis/tasks/05-31-unify-business-list-ant-design/prd.md new file mode 100644 index 00000000..5d4defa2 --- /dev/null +++ b/.trellis/tasks/05-31-unify-business-list-ant-design/prd.md @@ -0,0 +1,92 @@ +# 统一业务列表 Ant Design 风格 + +## Goal + +统一报销、采购、合同、项目、资产、付款、应付账款、票据等业务列表页面的 PC 列表形态,采用成熟 Ant Design / Ant Design Pro 的企业后台心智:页头、查询区、表格、右侧操作列、分页和折叠工具区。目标是减少解释性文案、装饰性入口和页面级例外,让小团队用户一眼知道在哪里查、在哪里新建、在哪里处理。 + +## What I Already Know + +* 用户明确认为报销页顶部三个圆形按钮没有必要,且所有列表页应统一样式。 +* 当前产品面向国内小于 10 人科技初创公司,OA 页面不追求创新控件,优先参考成熟系统。 +* 系统没有 ERP,以后也不会有 ERP;财务相关入口使用 `付款`、`应付账款`、`票据` 等业务名。 +* 请假和日常考勤由企微承载,OA 不重复处理普通请假/打卡流程。 +* 视觉与交互裁决使用 `frontend-design` skill;本任务在企业 OA 场景下走克制、密集、Ant Design Pro 风格。 +* 现有规范已要求:普通业务台账使用单列列表页,不使用默认左右分栏;状态摘要使用 tabs/chips,不使用大状态卡;筛选默认一行;操作列在右侧。 + +## Current Findings + +* `ReimbursementsPage.tsx` 在列表上方额外放了三个圆形任务按钮,应移除或并入状态 tabs。 +* `AssetRegistryPage.tsx` 列表嵌在 `FilterBar` 内,台账工具是大表单卡片,列表结构与报销/采购不完全一致。 +* `ContractsRegistryPage.tsx` 列表仍是卡片流,筛选与编辑面板占位较重,不像标准表格列表。 +* `ProjectsPage.tsx` 使用项目卡片流和 `SectionPanel`,缺少统一表格列和右侧操作列。 +* `PaymentBatchesPage.tsx` 列表是 2/3 列卡片,和业务台账列表不一致。 +* `InvoicePoolPage.tsx` 与 `InvoiceListCard.tsx` 有独立摘要 chips,列表容器、筛选位置和 table wrapper 与其他页不一致。 +* `ApOperationsPage.tsx` 已较接近标准表格,但局部 `finance-command-panel` 嵌套较多,仍需和统一命令栏、表格容器收敛。 + +## Scope + +本任务修改前端列表体验与相关前端测试,范围包括共享列表模板、报销、采购、资产、合同、项目、付款、票据页面,以及稳定设计规则沉淀。后端接口、数据库、权限模型、审批引擎和公网部署不在范围内。 + +## Requirements + +* 采用“模板组件 + 页面契约”的方式推进,而不是代码生成器: + * `BusinessListTemplate`:统一业务台账列表页的页头、查询区、工具区、表格、空状态、分页和移动端卡片。 + * `BusinessDetailTemplate`:统一详情页的顶部摘要、主事实区、证据/关联区、右侧状态与操作区、历史记录区。 + * `BusinessEditTemplate`:统一新建/编辑页的分组表单、底部保存/提交动作、错误提示、离开确认。 + * `ApprovalDetailTemplate`:统一待处理/审批详情页的一条操作栏、原因输入、确认弹窗和处理历史。 +* 本任务的实现重点先落在 `BusinessListTemplate` 和列表页改造;详情、编辑、审批详情模板先记录规则,后续按页面分批落地。 +* 报销页顶部三个圆形任务按钮删除,不保留为单独首屏区域;对应数量如仍有价值,放入状态 tabs 或紧凑 chips。 +* 所有业务台账列表首屏使用同一结构: + * 页面标题区:标题 + 一个主操作按钮;低频操作进入 `台账工具` 或 `更多`。 + * 查询区:搜索、状态、范围三类高频筛选默认一行;日期、供应商、法体、成本中心、测试数据、导出等进入 `筛选` / `台账工具`。 + * 内容区:PC 默认表格,移动端继续卡片列表。 + * 表格列:对象身份、对象摘要/相对方/人员、金额或日期、状态、右侧操作。 + * 操作列:默认 `查看详情`、`处理`、`继续编辑`、`查看进度`;低频和风险动作进入 `更多`。 + * 分页:表格下方右侧或现有统一 Pagination,不在卡片流里单独发明。 +* 普通列表不显示解释性语句,例如“这里展示与你相关的记录”“待处理是需要处理的工作”。 +* 合同、项目、付款等当前卡片流列表改为统一表格;卡片只保留移动端或真正需要展示图文/摘要的非台账页面。 +* 资产、合同、付款、票据的工具区只在用户点击后展开,且位置和样式统一。 + +## Acceptance Criteria + +* [x] 形成可复用的业务列表模板/组件契约,页面不再各自拼装不同列表框架。 +* [x] 报销列表不再显示三个圆形任务按钮。 +* [x] 报销、采购、资产、合同、项目、付款、应付账款、票据的 PC 列表使用一致的页头、命令栏、表格容器、状态 tag、操作列和空状态。 +* [x] 合同、项目、付款 PC 列表不再以多列卡片作为默认展示。 +* [x] 默认可见筛选不超过一行;高级筛选与导出类能力归入 `筛选` / `台账工具`。 +* [x] 列表页不出现重复解释普通流程的文案。 +* [x] 移动端列表仍可正常浏览和进入详情。 + +## Test Plan + +* `cd client && npm run typecheck` +* `cd client && npm run build` +* `cd client && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/r103-cross-page-ui-consistency.spec.ts tests/e2e/pc-finance-admin-visual-wave4.spec.ts --workers=1 --reporter=list` +* `cd client && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/r102-reimbursement-main-flow.spec.ts --workers=1 --reporter=list` +* `cd client && E2E_BASE_URL=http://127.0.0.1:5173 npx playwright test tests/e2e/pc-detail-mobile-visual-wave3.spec.ts -g "Wave3 PC" --workers=1 --reporter=list` +* `git diff --check` +* `python3 ./.trellis/scripts/task.py validate 05-31-unify-business-list-ant-design` +* `node scripts/qa/check-trellis-governance.mjs` + +## Definition Of Done + +* 前端类型检查通过:`cd client && npm run typecheck` +* 前端构建通过:`cd client && npm run build` +* 运行受影响列表页的聚焦 Playwright/视觉检查。 +* Trellis task 校验和治理检查通过。 +* 需要沉淀的稳定规则更新到 `.trellis/spec/hyb-oa/frontend-react-vite-design.md` 或现有文档,不另起散乱文档。 + +## Out Of Scope + +* 不引入 Ant Design 组件库替换当前 UI 基座。 +* 不在本任务一次性重构所有详情页、编辑页和审批详情页;只沉淀模板规则,优先交付列表统一。 +* 不做页面代码生成器;业务页面仍显式写字段、权限、状态和动作,只复用结构、样式和操作坐标。 +* 不重做后端接口、权限模型、审批状态机或真实付款能力。 +* 不处理企微普通请假/打卡流程。 +* 不做公网部署,除非后续单独获得明确批准。 + +## Technical Notes + +* 设计参考:Ant Design / Ant Design Pro 成熟企业后台列表页心智。 +* 当前共享组件:`ListWorkspace`、`WorkspaceHeader`、`FilterBar`、`PrimaryTable`、`Pagination`、`StatusBadge`。 +* 当前共享样式:`ops-command-bar`、`ops-command-bar__row`、`ops-command-bar__tabs`、`ops-command-bar__actions`、`ops-table-focus`、`table-header`、`table-th`、`table-row`、`table-td`。 diff --git a/.trellis/tasks/05-31-unify-business-list-ant-design/research/ant-design-business-list-pattern.md b/.trellis/tasks/05-31-unify-business-list-ant-design/research/ant-design-business-list-pattern.md new file mode 100644 index 00000000..8bb80c1b --- /dev/null +++ b/.trellis/tasks/05-31-unify-business-list-ant-design/research/ant-design-business-list-pattern.md @@ -0,0 +1,20 @@ +# Ant Design 业务列表模式参考 + +## Source Notes + +* Ant Design Table 文档强调表格用于展示结构化数据,并围绕行选择、排序筛选、固定列、分页和操作列组织列表能力。 +* Ant Design Pro / ProTable 的常见心智是:查询表单、工具栏、表格主体、分页、行操作。它适合中后台的业务台账页面。 +* 本项目不直接引入 Ant Design 依赖;只参考信息架构和交互习惯,继续使用现有 React/Vite/Tailwind/shadcn 风格组件。 + +## Applied Pattern For hyb-oa + +* Page header: 页面标题 + 主操作。 +* Query bar: 搜索、状态、范围等高频筛选。 +* Toolbar: `筛选` / `台账工具` / `更多` 收纳低频能力。 +* Table: 对象身份、对象摘要、关键金额/日期、状态、操作。 +* Row action: 右侧固定操作列,主动作明确,低频动作进 `更多`。 +* Pagination: 表格底部统一分页。 + +## Design Decision + +业务台账页默认不要使用圆形按钮、摘要大卡、多列卡片流、左右分栏或解释性说明。除非页面是工作台、统计分析或详情页,否则首屏应优先让用户看到可查询、可排序/筛选、可进入详情的业务列表。 diff --git a/.trellis/tasks/05-31-unify-business-list-ant-design/task.json b/.trellis/tasks/05-31-unify-business-list-ant-design/task.json new file mode 100644 index 00000000..323ffc88 --- /dev/null +++ b/.trellis/tasks/05-31-unify-business-list-ant-design/task.json @@ -0,0 +1,26 @@ +{ + "id": "unify-business-list-ant-design", + "name": "unify-business-list-ant-design", + "title": "统一业务列表 Ant Design 风格", + "description": "统一业务台账列表页为成熟 Ant Design Pro 风格,抽取轻量列表模板并改造报销、采购、资产、合同、项目、付款、应付账款和票据列表。", + "status": "in_progress", + "dev_type": null, + "scope": "client", + "package": "hyb-oa", + "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": {} +} diff --git a/client/src/components/patterns/BusinessListTemplate.tsx b/client/src/components/patterns/BusinessListTemplate.tsx new file mode 100644 index 00000000..50f9895b --- /dev/null +++ b/client/src/components/patterns/BusinessListTemplate.tsx @@ -0,0 +1,108 @@ +import type { ReactNode } from "react"; +import { cn } from "../../lib/utils"; +import { FilterBar, PrimaryTable, WorkspaceEmptyState } from "./ListWorkspace"; + +type BusinessListToolbarProps = { + children: ReactNode; + actions?: ReactNode; + advanced?: ReactNode; + className?: string; +}; + +export function BusinessListToolbar({ children, actions, advanced, className }: BusinessListToolbarProps) { + return ( + +
+
{children}
+ {actions ?
{actions}
: null} +
+ {advanced ?
{advanced}
: null} +
+ ); +} + +export function BusinessTable({ + children, + className, + testId, +}: { + children: ReactNode; + className?: string; + testId?: string; +}) { + return ( + + + {children} +
+
+ ); +} + +export function BusinessTableFrame({ + children, + className, +}: { + children: ReactNode; + className?: string; +}) { + return {children}; +} + +export function BusinessMobileList({ + children, + className, + testId, +}: { + children: ReactNode; + className?: string; + testId?: string; +}) { + return ( +
+ {children} +
+ ); +} + +export function BusinessEmptyRow({ + colSpan, + title = "暂无数据", + description, + action, +}: { + colSpan: number; + title?: ReactNode; + description?: ReactNode; + action?: ReactNode; +}) { + return ( + + +
+
{title}
+ {description ?
{description}
: null} + {action ?
{action}
: null} +
+ + + ); +} + +export function BusinessEmptyState({ + title = "暂无数据", + description, + action, +}: { + title?: ReactNode; + description?: ReactNode; + action?: ReactNode; +}) { + return ( + +
{title}
+ {description ?
{description}
: null} + {action ?
{action}
: null} +
+ ); +} diff --git a/client/src/pages/AssetRegistryPage.tsx b/client/src/pages/AssetRegistryPage.tsx index 442e4460..481d8e45 100644 --- a/client/src/pages/AssetRegistryPage.tsx +++ b/client/src/pages/AssetRegistryPage.tsx @@ -30,7 +30,12 @@ import { DateTextInput } from "../components/common/DateTextInput"; import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; import { DetailSideMeta } from "../components/patterns/DetailSideMeta"; -import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace"; +import { ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace"; +import { + BusinessListToolbar, + BusinessMobileList, + BusinessTable, +} from "../components/patterns/BusinessListTemplate"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; @@ -890,9 +895,20 @@ export function AssetRegistryPage() {
) : null} -
- -
+
+ + + + + ) : null} + > +
))} - {canViewAll ? ( -
- - -
- ) : null}
+
{assetsQuery.isLoading ? : null} {assetsQuery.isError ? ( @@ -930,7 +937,7 @@ export function AssetRegistryPage() { ) : null} {!assetsQuery.isLoading && !assetsQuery.isError ? ( <> -
+ {orderedAssetItems.length ? ( orderedAssetItems.map((item) => (
)} -
- - + + @@ -1032,11 +1038,9 @@ export function AssetRegistryPage() { )} -
资产
-
+ ) : null} -
) : ( diff --git a/client/src/pages/ContractsRegistryPage.tsx b/client/src/pages/ContractsRegistryPage.tsx index ca5b2104..8459fb1b 100644 --- a/client/src/pages/ContractsRegistryPage.tsx +++ b/client/src/pages/ContractsRegistryPage.tsx @@ -26,8 +26,19 @@ import { PageHeader } from "../components/common/PageHeader"; import { StatusBadge } from "../components/common/StatusBadge"; import { DateTextInput } from "../components/common/DateTextInput"; import { WorkspaceNotice } from "../components/patterns/ListWorkspace"; +import { + BusinessListToolbar, + BusinessMobileList, + BusinessTable, +} from "../components/patterns/BusinessListTemplate"; import { PageScaffold } from "../components/patterns/PageScaffold"; import { Button } from "../components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../components/ui/dropdown-menu"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Textarea } from "../components/ui/textarea"; @@ -206,6 +217,7 @@ export function ContractsRegistryPage() { const [showExpiring, setShowExpiring] = useState(false); const [showTrialData, setShowTrialData] = useState(false); const [showLedgerTools, setShowLedgerTools] = useState(false); + const [showEditor, setShowEditor] = useState(false); const [confirmExportFormat, setConfirmExportFormat] = useState(null); const [confirmStatusTarget, setConfirmStatusTarget] = useState(null); @@ -264,9 +276,9 @@ export function ContractsRegistryPage() { }; const openEditor = (item: ContractRegistryItem) => { - setShowLedgerTools(true); setEditorId(item.id); setEditor(loadEditorFromItem(item)); + setShowEditor(true); }; const saveMutation = useMutation({ @@ -279,6 +291,7 @@ export function ContractsRegistryPage() { }, onSuccess: async () => { await refreshAll(); + setShowEditor(false); resetEditor(); toast.success(editorId ? "合同台账已更新" : "合同台账已创建"); }, @@ -323,10 +336,19 @@ export function ContractsRegistryPage() { actions={
{initialProjectId ? ( - + ) : null} + @@ -399,21 +421,34 @@ export function ContractsRegistryPage() { ) : null}
- {showLedgerTools || editorId ? ( -
-
-
-

{editorId ? "编辑合同台账" : "新建合同台账"}

-

- 补齐合同类型、金额、签署/生效/到期日期,续约将自动创建新的草稿合同。 -

+ {showEditor ? ( +
+
+
+

{editorId ? "编辑合同台账" : "新建合同台账"}

+

+ 补齐合同类型、金额、签署/生效/到期日期,续约将自动创建新的草稿合同。 +

+
+
+ {editorId ? ( + + ) : null} + +
- {editorId ? ( - - ) : null} -
{editingItem?.status === "expired" ? (
@@ -646,103 +681,96 @@ export function ContractsRegistryPage() {
) : null} -
-
-
-

台账列表

-
{filters.projectId ? "已按项目过滤" : "显示全部可见合同"}
-
-
- {!showLedgerTools && !editorId ? ( - - ) : null} - - {showLedgerTools ? ( - - ) : null} -
-
+ {showLedgerTools ? ( + + ) : null} + + } + > +
+
+ +
-
-
- -
+
+ +
-
- -
+
+ +
-
- +
+ setFilters((current) => ({ ...current, counterpartyName: event.target.value }))} + placeholder="搜索相对方" + /> +
- -
- setFilters((current) => ({ ...current, counterpartyName: event.target.value }))} - placeholder="搜索相对方" - /> -
- -
+ {contractsQuery.isLoading ?
加载中...
: null} {contractsQuery.isError ? ( @@ -755,79 +783,114 @@ export function ContractsRegistryPage() { ) : null} -
+ {visibleContracts.map((item) => ( -
-
-
-
-
{item.contractNo}
- - - {confidentialityLabel(item.confidentialityLevel)} - -
-
{item.counterpartyName}
-
{item.summary}
-
- {contractTypeLabel(item.contractType)} - {item.amount ? ` · ${item.amount} ${item.currency}` : " · 金额未录入"} - {item.expiryDate ? ` · 到期日 ${formatDisplayDate(item.expiryDate)}` : " · 未设置到期日"} -
-
- 项目:{item.project ? `${item.project.projectNo} · ${item.project.name}` : "未关联"} · 责任人: - {item.owner?.name ?? "-"} -
-
- 签署:{formatDisplayDate(item.signedDate)} · 生效:{formatDisplayDate(item.effectiveDate)} · 归档:{item.archiveLocation} - {item.offlineArchiveNo ? ` · 离线档案号 ${item.offlineArchiveNo}` : ""} -
-
- -
- - - {item.project ? ( - - ) : null} - +
+
+
+
{item.contractNo}
+
{item.counterpartyName}
+
-
+
+ {contractTypeLabel(item.contractType)} + {item.amount ? ` · ${item.amount} ${item.currency}` : ""} + {item.expiryDate ? ` · ${formatDisplayDate(item.expiryDate)}` : ""} +
+ + ))} - {!contractsQuery.isLoading && !visibleContracts.length ? ( -
暂无符合筛选条件的合同台账。
+
暂无合同台账
) : null} -
-
+
+ + + + + 合同 + 相对方 + 项目 / 责任人 + 金额 / 日期 + 状态 + 操作 + + + + {visibleContracts.length ? ( + visibleContracts.map((item) => ( + + +
{item.contractNo}
+
{contractTypeLabel(item.contractType)}
+
{confidentialityLabel(item.confidentialityLevel)}
+ + +
{item.counterpartyName}
+
{item.summary}
+ + +
+ {item.project ? `${item.project.projectNo} · ${item.project.name}` : "-"} +
+
{item.owner?.name ?? "-"}
+ + +
{item.amount ? `${item.amount} ${item.currency}` : "-"}
+
到期 {formatDisplayDate(item.expiryDate)}
+
签署 {formatDisplayDate(item.signedDate)}
+ + + + + +
+ + + + + + + renewMutation.mutate(item)} + > + 续约 + + {item.project ? ( + + 查看项目 + + ) : null} + setConfirmStatusTarget(item)} + > + {item.persistedStatus === "archived" ? "恢复生效" : "归档"} + + + +
+ + + )) + ) : ( + + + 暂无合同台账 + + + )} + +
+
{ - const items = listQuery.data?.items ?? []; - return { - total: listQuery.data?.total ?? 0, - pending: items.filter((item) => item.status === "pending_ocr").length, - failed: items.filter((item) => item.status === "ocr_failed").length, - available: items.filter((item) => item.status === "available").length, - bound: items.filter((item) => item.status === "bound").length, - }; - }, [listQuery.data?.items, listQuery.data?.total]); const canEditDetail = Boolean( detail && (canManageInvoice || @@ -568,22 +558,6 @@ export function InvoicePoolPage() {
) : null} - {!isInvoiceDetailMode ? ( -
- {[ - ["全部", invoiceStats.total], - ["识别中", invoiceStats.pending], - ["识别失败", invoiceStats.failed], - ["可绑定", invoiceStats.available], - ["已绑定", invoiceStats.bound], - ].map(([label, value]) => ( - - {label} {value} - - ))} -
- ) : null} - {!isInvoiceDetailMode ? ( (() => { return batches.find((item) => item.id === selectedBatchId) ?? null; }, [batches, selectedBatchId]); - const batchStats = useMemo(() => { - return { - total: batches.length, - needsReview: batches.filter((item) => item.reviewRequirement.nextStep !== "ready_for_execute" && ["draft", "reviewed"].includes(item.status)).length, - readyToPay: batches.filter((item) => item.reviewRequirement.nextStep === "ready_for_execute" && ["reviewed", "failed"].includes(item.status)).length, - needsReceipt: batches.filter((item) => - ["reviewed", "failed"].includes(item.status) && - (item.receiptAttachmentCount ?? 0) === 0 && - item.items.some((entry) => - ["pending", "failed"].includes(entry.status) && - (entry.paymentChannelOverride ?? item.defaultPaymentChannel) === "bank", - ), - ).length, - exceptions: batches.filter((item) => item.highRisk || item.status === "failed").length, - }; - }, [batches]); - const receiptAttachmentsQuery = useQuery({ queryKey: ["payment-batch-receipts", selectedBatch?.id], enabled: Boolean(selectedBatch?.id), @@ -379,22 +366,6 @@ export function PaymentBatchesPage() { {error ?
{error}
: null} - {!selectedBatchId ? ( -
- {[ - ["全部", batchStats.total], - ["待复核", batchStats.needsReview], - ["待付款", batchStats.readyToPay], - ["待补回单", batchStats.needsReceipt], - ["异常", batchStats.exceptions], - ].map(([label, value]) => ( - - {label} {value} - - ))} -
- ) : null} - {!selectedBatchId && showBatchTools ? (
@@ -691,17 +662,13 @@ export function PaymentBatchesPage() {
未找到付款批次
) ) : ( -
-
-

批次列表

- 共 {batches.length} 条 -
-
+ <> + {batches.map((item) => (
-
查看详情
))} - {!listQuery.data?.items?.length ?
暂无付款批次
: null} -
-
+ {!listQuery.data?.items?.length ?
暂无付款批次
: null} + + + + + 批次 + 实体 + 金额 + 复核 + 状态 + 操作 + + + + {batches.length ? ( + batches.map((item) => ( + + +
{item.batchNo}
+
{item.name}
+ + {entityTypeLabel(item.entityType)} + {item.totalAmount} + + {item.reviewRequirement.completedReviewCount}/{item.reviewRequirement.requiredReviewCount} + + +
+ + {item.highRisk ? 高风险 : null} +
+ + + + + + )) + ) : ( + + + 暂无付款批次 + + + )} + +
+ )} ) : null} -
- +
+ updateSearch({ mine: !mine })}> + {mine ? "我相关" : "全范围"} + + } + > +
updateSearch({ q: event.target.value })} /> - - +
+
{!showTrialData && hiddenTrialProjectCount > 0 ? ( @@ -276,18 +286,16 @@ export function ProjectsPage() { ) : null} - -
{listQuery.isLoading ?
项目加载中...
: null} {listQuery.isError ?
{resolveError(listQuery.error, "加载项目失败")}
: null} -
+ {visibleProjectItems.map((item) => ( - +
-
{item.projectNo}
+
{item.projectNo}
{item.isConfidential ? 涉密 : null}
@@ -305,13 +313,61 @@ export function ProjectsPage() { ))} {!listQuery.isLoading && !visibleProjectItems.length ? ( -
- 当前没有符合筛选条件的项目。 -
+
暂无项目
) : null} -
-
- +
+ + + + + 项目 + 客户 + 负责人 + 进度 + 状态 + 操作 + + + + {visibleProjectItems.length ? ( + visibleProjectItems.map((item) => ( + + +
{item.projectNo}
+
{item.name}
+ {item.isConfidential ?
涉密
: null} + + +
{item.clientAlias || item.clientName}
+
{item.summary || "-"}
+ + +
{item.owner?.name ?? "-"}
+
项目经理 {item.manager?.name ?? "-"}
+ + +
{summaryLine(item)}
+
目标 {formatDisplayDate(item.targetEndAt)}
+ + + + + + + + + )) + ) : ( + + + 暂无项目 + + + )} + +
); diff --git a/client/src/pages/PurchaseRequestsPage.tsx b/client/src/pages/PurchaseRequestsPage.tsx index dc92482f..9547d829 100644 --- a/client/src/pages/PurchaseRequestsPage.tsx +++ b/client/src/pages/PurchaseRequestsPage.tsx @@ -45,7 +45,12 @@ import { Button } from "../components/ui/button"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; import { Textarea } from "../components/ui/textarea"; -import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace"; +import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; +import { + BusinessListToolbar, + BusinessMobileList, + BusinessTableFrame, +} from "../components/patterns/BusinessListTemplate"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs"; import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance"; @@ -842,9 +847,97 @@ export function PurchaseRequestsPage() { {!isPurchaseDetailMode ? ( <> - -
-
+ + {mine ? "我相关" : "全范围"} + + } + advanced={showAdvancedTools ? ( +
+ + + + + + + {canViewAll ? ( +
+ + +
+ ) : null} +
+ ) : null} + > -
- -
- - {mine ? "我相关" : "全范围"} - -
-
- - {showAdvancedTools ? ( -
-
- - - - - - - {canViewAll ? ( -
- - -
- ) : ( -
- 当前仅显示与你相关的采购。 -
- )} -
-
- ) : null} -
- -
-
-
-

采购单列表

- 共 {listQuery.data?.total ?? 0} 条 -
+ {listQuery.isLoading ? : null} @@ -983,7 +970,7 @@ export function PurchaseRequestsPage() { {!listQuery.isLoading && !listQuery.isError ? ( <> -
+ {purchaseItems.length ? ( purchaseItems.map((item) => { const evidenceHint = resolvePurchaseEvidenceHint(item); @@ -1033,13 +1020,13 @@ export function PurchaseRequestsPage() { ); }) ) : ( -
+
{emptyState.title}
{emptyState.description}
)} -
- +
+ @@ -1087,7 +1074,6 @@ export function PurchaseRequestsPage() { ) : ( 未关联项目 )} -
合同:详情页登记归档摘要
@@ -1122,7 +1108,7 @@ export function PurchaseRequestsPage() { )}
- +
) : null} @@ -1132,8 +1118,6 @@ export function PurchaseRequestsPage() { onPageChange={setPage} testIdPrefix="purchase-requests" /> -
-
) : (
diff --git a/client/src/pages/ReimbursementsPage.tsx b/client/src/pages/ReimbursementsPage.tsx index 0c3bc9c4..eed695d5 100644 --- a/client/src/pages/ReimbursementsPage.tsx +++ b/client/src/pages/ReimbursementsPage.tsx @@ -1,7 +1,7 @@ import { useMemo, useState } from "react"; import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react"; +import { Wrench } from "lucide-react"; import { isApiError } from "../api/types"; import { mapApiErrorMessage } from "../api/errorMessages"; import { fetchMyDelegations } from "../api/delegations"; @@ -25,7 +25,14 @@ import { Pagination } from "../components/common/Pagination"; import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog"; import { StatusBadge } from "../components/common/StatusBadge"; import { TableSkeleton } from "../components/common/TableSkeleton"; -import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace"; +import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace"; +import { + BusinessEmptyRow, + BusinessEmptyState, + BusinessListToolbar, + BusinessMobileList, + BusinessTable, +} from "../components/patterns/BusinessListTemplate"; import { Button } from "../components/ui/button"; import { DropdownMenu, @@ -49,32 +56,6 @@ const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: s { value: "cancelled", label: "已取消" }, ]; -const REIMBURSEMENT_TASK_STATUS_OPTIONS = [ - { - value: "rejected", - title: "被驳回待补正", - icon: RotateCcw, - accentClass: "border-red-200 bg-red-50 text-red-700", - }, - { - value: "draft", - title: "草稿待完善", - icon: FilePenLine, - accentClass: "border-amber-200 bg-amber-50 text-amber-700", - }, - { - value: "reviewing", - title: "审批中进度", - icon: Clock3, - accentClass: "border-sky-200 bg-sky-50 text-sky-700", - }, -] satisfies Array<{ - value: ReimbursementStatus; - title: string; - icon: typeof RotateCcw; - accentClass: string; -}>; - function formatCurrency(value: number) { return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value); } @@ -198,21 +179,6 @@ export function ReimbursementsPage() { queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }), }); - const rejectedSummaryQuery = useQuery({ - queryKey: ["reimbursements", "task-summary", "own", "rejected"], - queryFn: () => fetchReimbursements({ status: "rejected", page: 1, pageSize: 1, scope: "own" }), - }); - - const draftSummaryQuery = useQuery({ - queryKey: ["reimbursements", "task-summary", "own", "draft"], - queryFn: () => fetchReimbursements({ status: "draft", page: 1, pageSize: 1, scope: "own" }), - }); - - const reviewingSummaryQuery = useQuery({ - queryKey: ["reimbursements", "task-summary", "own", "reviewing"], - queryFn: () => fetchReimbursements({ status: "reviewing", page: 1, pageSize: 1, scope: "own" }), - }); - const proxyDelegationsQuery = useQuery({ enabled: showProxyOwnerSelector, queryKey: ["delegations", "mine", "active"], @@ -251,11 +217,6 @@ export function ReimbursementsPage() { const templateOptions = templateOptionsQuery.data?.items ?? []; const linkableTravelRequests = linkableTravelRequestsQuery.data?.items ?? []; - const taskSummaryTotalByStatus = { - rejected: rejectedSummaryQuery.data?.total, - draft: draftSummaryQuery.data?.total, - reviewing: reviewingSummaryQuery.data?.total, - } satisfies Record<(typeof REIMBURSEMENT_TASK_STATUS_OPTIONS)[number]["value"], number | undefined>; const canShowCreateOptionsMenu = canCreateExpense && shouldLoadCreateOptions && (templateOptions.length > 0 || linkableTravelRequests.length > 0); const emptyState = resolveEmptyStateCopy(status); @@ -352,73 +313,22 @@ export function ReimbursementsPage() {

) : null} -
- {REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => { - const Icon = item.icon; - const count = taskSummaryTotalByStatus[item.value]; - const active = status === item.value && scope === "own"; - const displayCount = typeof count === "number" ? count : "-"; - - return ( - - ); - })} -
- - -
-
-
报销记录
- setSearchParams({ status: value, page: "1", scope })} - > - - {REIMBURSEMENT_STATUS_OPTIONS.map((item) => ( - - {item.label} - - ))} - - -
- -
- {canShowLedgerEntry ? ( - - ) : null} -
-
- - {showAdvancedTools ? ( -
+ + 台账工具 + + ) : null + } + advanced={showAdvancedTools ? ( + <>
当前列表
@@ -563,11 +473,28 @@ export function ReimbursementsPage() { -
-
+ ) : null} - + > + setSearchParams({ status: value, page: "1", scope })} + > + + {REIMBURSEMENT_STATUS_OPTIONS.map((item) => ( + + {item.label} + + ))} + + + {listQuery.isLoading ? : null} @@ -585,7 +512,7 @@ export function ReimbursementsPage() { {!listQuery.isLoading && !listQuery.isError ? ( <> -
+ {items.length ? ( items.map((item) => { const listAction = resolveListAction(item); @@ -669,12 +596,11 @@ export function ReimbursementsPage() { ); }) ) : ( -
-
{emptyState.title}
-
{emptyState.description}
- {status === "draft" && canCreateExpense ? ( + startCreateReimbursement()} size="sm" @@ -683,12 +609,11 @@ export function ReimbursementsPage() { {createMutation.isPending ? "创建中..." : "新建报销"} ) : null} -
+ /> )} -
+ - - + @@ -783,29 +708,24 @@ export function ReimbursementsPage() { ); }) ) : ( - - - + startCreateReimbursement()} + size="sm" + type="button" + > + {createMutation.isPending ? "创建中..." : "新建报销"} + + ) : null} + /> )} -
单号
-
-
{emptyState.title}
-
{emptyState.description}
- {status === "draft" && canCreateExpense ? ( - - ) : null} -
-
-
+
-
-

票据列表

+
+
-
+ {listQuery.isLoading ? : null} {!listQuery.isLoading ? ( <> -
+ {(listQuery.data?.items ?? []).length ? ( listQuery.data?.items.map((item) => (
@@ -193,10 +197,9 @@ export function InvoiceListCard({ 暂无票据数据
)} -
+ -
- + @@ -260,8 +263,7 @@ export function InvoiceListCard({ )} -
票据名称
-
+ diff --git a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts index 280a0d00..47ea2acc 100644 --- a/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts +++ b/client/tests/e2e/pc-detail-mobile-visual-wave3.spec.ts @@ -1099,14 +1099,17 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => { await page.goto("/contracts-registry?projectId=project-wave3"); await expect(page.getByRole("heading", { name: "合同台账", exact: true })).toBeVisible(); - await expect(page.getByTestId("contracts-registry-row")).toBeVisible(); + await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible(); await expect(page.getByRole("button", { name: "导出 CSV" })).toHaveCount(0); await page.getByRole("button", { name: "台账工具" }).click(); await expect(page.getByTestId("contracts-ledger-tools")).toBeVisible(); - await expect(page.getByTestId("contracts-editor-panel")).toBeVisible(); await expect(page.getByRole("button", { name: "导出 CSV" })).toBeVisible(); - await expect(page.getByTestId("contracts-registry-row")).toBeVisible(); - await page.getByTestId("contracts-registry-row").getByRole("button", { name: "归档" }).click(); + await page.getByRole("button", { name: "新增合同" }).click(); + await expect(page.getByTestId("contracts-editor-panel")).toBeVisible(); + await page.getByRole("button", { name: "关闭" }).click(); + await expect(page.getByTestId("contracts-registry-table").getByTestId("contracts-registry-row")).toBeVisible(); + await page.getByTestId("contracts-registry-table").getByRole("button", { name: "更多" }).first().click(); + await page.getByRole("menuitem", { name: "归档" }).click(); await expect(page.getByTestId("action-confirm-dialog")).toContainText("确认归档"); await page.getByTestId("action-confirm-cancel").click(); await expect(page.getByTestId("action-confirm-dialog")).toHaveCount(0); diff --git a/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts b/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts index f93a20d7..e5507ca7 100644 --- a/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts +++ b/client/tests/e2e/pc-finance-admin-visual-wave4.spec.ts @@ -1000,9 +1000,9 @@ async function capture(page: Page, name: string) { } const pages = [ - { path: "/payment-batches", summary: "payment-batches-summary", proof: "text=批次列表", screenshot: "01-payment-batches", expectStatusStrip: false }, + { path: "/payment-batches", proof: "data-testid=payment-batches-table", screenshot: "01-payment-batches", expectStatusStrip: false }, { path: "/ap-operations", proof: "text=异常付款", screenshot: "02-ap-operations", expectStatusStrip: false }, - { path: "/invoice-pool", summary: "invoice-pool-summary", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool", expectStatusStrip: false }, + { path: "/invoice-pool", proof: "data-testid=invoice-pool-table", screenshot: "03-invoice-pool", expectStatusStrip: false }, { path: "/system/approval-rules", summary: "approval-rules-summary", proof: "data-testid=approval-rules-table", screenshot: "04-approval-rules" }, { path: "/people", summary: "people-summary", proof: "data-testid=people-table", screenshot: "05-people" }, { path: "/organization", summary: "organization-summary", proof: "text=研发部", screenshot: "06-organization" }, diff --git a/client/tests/e2e/r102-reimbursement-main-flow.spec.ts b/client/tests/e2e/r102-reimbursement-main-flow.spec.ts index 57341693..78699b21 100644 --- a/client/tests/e2e/r102-reimbursement-main-flow.spec.ts +++ b/client/tests/e2e/r102-reimbursement-main-flow.spec.ts @@ -320,7 +320,7 @@ test.describe("R102 reimbursement main flow", () => { await page.setViewportSize({ width: 1440, height: 960 }); await page.goto("/reimbursements?status=draft"); - await expect(page.getByTestId("reimbursements-task-card-draft")).toBeVisible(); + await expect(page.getByTestId("reimbursements-tab-draft")).toBeVisible(); await expect(page.getByRole("button", { name: "台账工具" })).toHaveCount(0); await expect(page.getByTestId("reimbursement-row").getByText(formNumber)).toBeVisible(); await expect(page.getByText(reimbursementId)).toHaveCount(0);