统一编辑模板并移除首批台账工具(05-31-business-edit-template-remove-ledger-tools-wave1)

这个提交包含在:
2026-05-31 14:10:10 +08:00
父节点 22527ae37b
当前提交 1428cc4ffa
共修改 13 个文件,包含 556 行新增和 205 行删除
@@ -74,11 +74,13 @@ reference, not a copy of Feishu or Ant Design branding.
- List pages use a right-side operation column or card tail. Default row actions - List pages use a right-side operation column or card tail. Default row actions
are `查看详情`, `处理`, `继续编辑` or `查看进度`; low-frequency or risky actions are `查看详情`, `处理`, `继续编辑` or `查看进度`; low-frequency or risky actions
such as export, batch, void, download, payment and sensitive attachment such as export, batch, void, download, payment and sensitive attachment
handling move to `更多`, `台账工具` or the detail page. handling move to `更多`, clearly named permissioned actions, management
entries or the detail page.
- Business ledger pages use the same shape: page title and optional creation - Business ledger pages use the same shape: page title and optional creation
action, visible common filters, table/card list and right-side operation action, visible common filters, table/card list and right-side operation
column. `台账工具` contains export, batch, statistics, templates, full-scope column. Do not use a generic `台账工具` entry; expose low-frequency abilities
views and advanced filters. through concrete names such as `导出`, `统计`, `筛选`, `导入`, `新建方式`,
`分类管理`, `批量处理` or `更多`.
- Business ledger first screens are single-column list pages. Do not use a - Business ledger first screens are single-column list pages. Do not use a
default left-list/right-detail workspace for procurement, contracts, projects, default left-list/right-detail workspace for procurement, contracts, projects,
assets, payments, AP or invoices. Open details through a detail route, drawer assets, payments, AP or invoices. Open details through a detail route, drawer
@@ -90,8 +92,8 @@ reference, not a copy of Feishu or Ant Design branding.
- Ledger filters fit the first PC row around `search`, `status` and `scope` - Ledger filters fit the first PC row around `search`, `status` and `scope`
when those capabilities exist. Supplier, legal entity, cost center, date when those capabilities exist. Supplier, legal entity, cost center, date
ranges, contract type, sensitivity, export and test-data toggles belong in ranges, contract type, sensitivity, export and test-data toggles belong in
`筛选`, `台账工具` or a tab-specific advanced area; default-visible filters `筛选`, `更多`, clearly named role-only actions or a tab-specific advanced
should not occupy multiple rows. area; default-visible filters should not occupy multiple rows.
- 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
@@ -118,7 +120,7 @@ reference, not a copy of Feishu or Ant Design branding.
| `工作台` | 工作台总览 | Compact counts, common entries, real reminders | No forced header action. Use `去处理`, `新建报销`, `查看全部` inside relevant sections; do not show helper sentences for ordinary items. | | `工作台` | 工作台总览 | Compact counts, common entries, real reminders | No forced header action. Use `去处理`, `新建报销`, `查看全部` inside relevant sections; do not show helper sentences for ordinary items. |
| `待我处理` | 任务处理列表 | `待处理` / `已处理` tabs, count, minimal filters, task list | Row action is `处理`; `已处理记录` is a tab, not a separate header button. | | `待我处理` | 任务处理列表 | `待处理` / `已处理` tabs, count, minimal filters, task list | Row action is `处理`; `已处理记录` is a tab, not a separate header button. |
| `审批详情` | 详情工作区 | Summary, facts/evidence, right summary/action panel, history | One action bar: primary `通过`, secondary `退回`/`驳回`, `转审` in `更多`. | | `审批详情` | 详情工作区 | Summary, facts/evidence, right summary/action panel, history | One action bar: primary `通过`, secondary `退回`/`驳回`, `转审` in `更多`. |
| `报销` / `采购` / `合同` / `项目` / `资产` / `付款` / `应付账款` / `票据` | 业务台账列表 | One-row common filters plus table/card list | Header creation only when the route owns creation; risky tools in `台账工具` or detail; no default left-list/right-detail split. | | `报销` / `采购` / `合同` / `项目` / `资产` / `付款` / `应付账款` / `票据` | 业务台账列表 | One-row common filters plus table/card list | Header creation only when the route owns creation; risky tools use `更多`, clearly named role-only actions or detail pages; no default left-list/right-detail split. |
| Business details | 详情工作区 | Summary, facts, evidence, relations, history | Right-side state/action panel on PC; bottom primary action on mobile. | | Business details | 详情工作区 | Summary, facts, evidence, relations, history | Right-side state/action panel on PC; bottom primary action on mobile. |
| `管理` | 管理配置页 | Grouped landing: `人员组织`, `审批流程`, `基础资料`, `系统设置`, `审计排障` | Management subpages use search/filter, table and right operation column. | | `管理` | 管理配置页 | Grouped landing: `人员组织`, `审批流程`, `基础资料`, `系统设置`, `审计排障` | Management subpages use search/filter, table and right operation column. |
| `个人设置` | 管理配置页 variant | `个人资料`, `账号安全`, `通知偏好`, `企业微信绑定` | No employee dossier, permission matrix or org maintenance in the default profile path. | | `个人设置` | 管理配置页 variant | `个人资料`, `账号安全`, `通知偏好`, `企业微信绑定` | No employee dossier, permission matrix or org maintenance in the default profile path. |
@@ -339,11 +341,23 @@ matrices and actions explicit in each page.
main facts/evidence/history on the left, status/actions/compact summary on main facts/evidence/history on the left, status/actions/compact summary on
the right. Keep the page's business fields, permission checks, state the right. Keep the page's business fields, permission checks, state
conditions and API calls explicit in the page component. conditions and API calls explicit in the page component.
- For business edit/create pages, prefer the thin components in
`client/src/components/patterns/BusinessEditTemplate.tsx`:
`BusinessEditTemplate`, `BusinessEditWorkspace`, `BusinessEditMain`,
`BusinessEditAside`, `BusinessEditSection`, `BusinessEditFormGrid`,
`BusinessEditValidationSummary` and `BusinessEditActionBar`. They standardize
title/back/status/action, sectioned form layout, validation summary and bottom
save/submit coordinates only; fields, permissions, mutations and dirty-state
decisions stay explicit in the page.
- `BusinessDetailActionPanel` is the default PC detail operation surface. - `BusinessDetailActionPanel` is the default PC detail operation surface.
Use one primary action for save/create when the detail owns editing; use a Use one primary action for save/create when the detail owns editing; use a
horizontal action bar for approval actions; move low-frequency actions into horizontal action bar for approval actions; move low-frequency actions into
`更多` or a clearly named grouped section. Do not add a second page-local `更多` or a clearly named grouped section. Do not add a second page-local
`操作` card or a separate `摘要` card under the same right column. `操作` card or a separate `摘要` card under the same right column.
- Do not introduce or preserve a generic `台账工具` button on redesigned business
pages. Move each ability to a concrete enterprise action coordinate: `新建` /
`新建方式`, `筛选`, `导出`, `统计`, `批量处理`, row `更多`, detail action panel
or an explicitly named management entry such as `资产分类`.
- Do not create a configuration-driven page generator for reimbursement, - Do not create a configuration-driven page generator for reimbursement,
procurement, contracts, projects, assets, payments, AP or invoices. These procurement, contracts, projects, assets, payments, AP or invoices. These
pages have different permissions, state transitions, attachment rules and pages have different permissions, state transitions, attachment rules and
@@ -452,7 +466,7 @@ page-local action placement.
- List filters live in the first command bar below the page header. Search, - List filters live in the first command bar below the page header. Search,
status, scope and other common filters stay on the left; ledger tools such as status, scope and other common filters stay on the left; ledger tools such as
export, batch, statistics, templates and advanced filters stay on the right or export, batch, statistics, templates and advanced filters stay on the right or
inside `台账工具` / `更多`. inside `筛选`, `更多` or concrete role-only action groups.
- Row-level actions live at the right edge of a table row or at the tail of a - Row-level actions live at the right edge of a table row or at the tail of a
card. Prefer `查看详情`, `查看处理` and `继续编辑` over broad verbs that hide the card. Prefer `查看详情`, `查看处理` and `继续编辑` over broad verbs that hide the
result. result.
@@ -567,8 +581,8 @@ of exposing every allowed tool at once.
they must not duplicate approval action buttons. they must not duplicate approval action buttons.
- Finance and cashier views may expose authorized reimbursement, payment, - Finance and cashier views may expose authorized reimbursement, payment,
invoice, AP, receipt and exception operations. Full-scope, export, batch, invoice, AP, receipt and exception operations. Full-scope, export, batch,
delegated creation and statistics tools belong in `台账工具`, `更多` or delegated creation and statistics tools belong in concrete actions such as
role-only panels. `导出`, `统计`, `批量处理`, `新建方式`, `更多` or role-only panels.
- Owner and project-lead views emphasize team/project scope, blockers, risks and - Owner and project-lead views emphasize team/project scope, blockers, risks and
approvals. They should not drift into system configuration unless the route is approvals. They should not drift into system configuration unless the route is
a management route. a management route.
@@ -666,8 +680,8 @@ screen.
to the current user and the next action. to the current user and the next action.
- Place common filters on the left side of the command bar below the page - Place common filters on the left side of the command bar below the page
header. Put export, batch, statistics, templates, full-scope, advanced header. Put export, batch, statistics, templates, full-scope, advanced
filters, delegated creation and low-frequency module links in `台账工具`, filters, delegated creation and low-frequency module links in `筛选`,
`更多` or role-only panels. concrete right-side actions, `更多` or role-only panels.
- Batch actions appear only after selection, as a contextual command bar or - Batch actions appear only after selection, as a contextual command bar or
bottom sticky bar. High-risk batch actions such as approve, pay, delete or bottom sticky bar. High-risk batch actions such as approve, pay, delete or
publish require summary confirmation and result details. publish require summary confirmation and result details.
@@ -686,13 +700,13 @@ screen.
boundary. The list is for discovery, filtering, status reading and navigation. boundary. The list is for discovery, filtering, status reading and navigation.
High-risk or sensitive actions such as contract download, archive, payment, High-risk or sensitive actions such as contract download, archive, payment,
export, batch processing, void/cancel and sensitive attachment viewing belong export, batch processing, void/cancel and sensitive attachment viewing belong
in detail workspaces, detail command panels or `台账工具`, with permission, in detail workspaces, detail command panels, concrete role-only actions or
audit and confirmation. `更多`, with permission, audit and confirmation.
- Default ledger scope follows the current task role: ordinary employees see - Default ledger scope follows the current task role: ordinary employees see
`我发起` / `我相关`, owners and project leads see `我负责` / `项目相关`, finance `我发起` / `我相关`, owners and project leads see `我负责` / `项目相关`, finance
sees `待复核` / `待付款` / `异常`, and admins may enter or switch to `全范围`. sees `待复核` / `待付款` / `异常`, and admins may enter or switch to `全范围`.
Full scope, export and batch commands stay in `台账工具` or role-only panels, Full scope, export and batch commands stay in concrete role-only actions or
not ordinary first screens. panels, not ordinary first screens.
- Contract ledgers in V1 are registration, archive, due/renewal reminder and - Contract ledgers in V1 are registration, archive, due/renewal reminder and
relation surfaces for project, procurement and payment context. Do not design relation surfaces for project, procurement and payment context. Do not design
`/contracts-registry` as e-signature, legal-review, clause-library, `/contracts-registry` as e-signature, legal-review, clause-library,
@@ -725,8 +739,8 @@ screen.
creation; `应付账款` shows pending payment, exceptions, risks and differences creation; `应付账款` shows pending payment, exceptions, risks and differences
before export; `票据` shows OCR status, failed invoices and bindable invoices before export; `票据` shows OCR status, failed invoices and bindable invoices
before broad-scope handover tools. Batch creation, export, full-scope views before broad-scope handover tools. Batch creation, export, full-scope views
and agency-accounting handover controls live in `台账工具` or selected-context and agency-accounting handover controls live in concrete role-only actions,
panels, never as the first-screen primary action. `更多` or selected-context panels, never as the first-screen primary action.
- Payment actions require the finance/payment context: amount, payee or target - Payment actions require the finance/payment context: amount, payee or target
object, source records, offline payment explanation, evidence completeness, object, source records, offline payment explanation, evidence completeness,
receipt requirement and correction boundary. Do not place `标记已付款`, receipt requirement and correction boundary. Do not place `标记已付款`,
@@ -749,7 +763,8 @@ and management workflows.
- `cd client && npm run typecheck` - `cd client && npm run typecheck`
- `cd client && npm run build` - `cd client && npm run build`
- Focused Playwright or screenshot checks when changing list filters, command - Focused Playwright or screenshot checks when changing list filters, command
bars, `台账工具`, batch selection behavior, empty states or mobile list cards. bars, advanced action placement, batch selection behavior, empty states or
mobile list cards.
## Convention: Asset Registry Pages ## Convention: Asset Registry Pages
@@ -920,8 +935,8 @@ state, facts and consequences before acting.
- List and ledger rows show only evidence completeness summaries, missing - List and ledger rows show only evidence completeness summaries, missing
evidence hints and a detail entry. Invoice preview, attachment download, evidence hints and a detail entry. Invoice preview, attachment download,
contract file viewing, receipt download, evidence deletion and bulk packaging contract file viewing, receipt download, evidence deletion and bulk packaging
belong in detail workspaces, detail command panels, `台账工具` or authorized belong in detail workspaces, detail command panels, `更多`, concrete
audit paths. role-only actions or authorized audit paths.
- Sensitive evidence such as invoices, contracts, receipts and attachments must - Sensitive evidence such as invoices, contracts, receipts and attachments must
be permissioned; download and export belong in `更多` or audit paths when be permissioned; download and export belong in `更多` or audit paths when
appropriate. appropriate.
@@ -1070,8 +1085,8 @@ summary unless a permissioned workflow requires more.
- Download, batch download, export, print, attachment packaging, contract text - Download, batch download, export, print, attachment packaging, contract text
export and finance ledger export must be controlled by server permissions and export and finance ledger export must be controlled by server permissions and
data scope. data scope.
- Export is never the primary first-screen action. Put exports in `台账工具`, - Export is never the primary first-screen action. Put exports in a concrete
`更多` or role-only finance/admin panels. `导出` action, `更多` or role-only finance/admin panels.
- Before exporting sensitive ledgers, explain scope and sensitive contents. - Before exporting sensitive ledgers, explain scope and sensitive contents.
Finance, contract, asset and invoice exports should write audit records. Finance, contract, asset and invoice exports should write audit records.
- Export file names use business numbers and dates. Do not concatenate complete - Export file names use business numbers and dates. Do not concatenate complete
@@ -0,0 +1,5 @@
{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa spec entry and task read order"}
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task, solo-developer scope, commit and validation rules"}
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "frontend template, navigation, list/detail/edit and visual design rules"}
{"file": ".trellis/spec/hyb-oa/docs-and-evidence.md", "reason": "evidence and task documentation hygiene"}
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "export and sensitive ledger action placement constraints"}
@@ -0,0 +1,4 @@
{"file": ".trellis/spec/hyb-oa/index.md", "reason": "hyb-oa spec entry and task read order"}
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task, solo-developer scope, commit and validation rules"}
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "frontend template, navigation, list/detail/edit and visual design rules"}
{"file": ".trellis/spec/hyb-oa/security-privacy.md", "reason": "export and sensitive ledger action placement constraints"}
@@ -0,0 +1,14 @@
# Implementation Notes
## Plan
1. Add thin edit-template components that standardize form page coordinates without becoming a JSON form generator.
2. Remove `台账工具` from reimbursement and asset list pages and migrate its contents into explicit actions, advanced filter areas, row actions or page header actions.
3. Update frontend design specs so future pages follow the same template and action-placement rules.
4. Run frontend typecheck/build and focused text checks for the first batch.
## Design Decisions
- The edit template owns layout only: header/action strip, section spacing, validation summary, form grid and sticky bottom action bar.
- Business pages keep their fields, permissions, mutations, state transitions and error handling explicitly in page code.
- The generic `台账工具` name is removed from first-batch user-facing pages; every action should use a concrete verb such as `导出`, `统计`, `批量处理`, `新建`, `导入` or `更多`.
@@ -0,0 +1,82 @@
# 建立业务编辑模板并移除台账工具首批
## Goal
按已确认的 frontend-design 规则建立业务编辑页模板,并先在报销、资产两个高频页面取消 `台账工具` 入口,把原有能力迁移到具体、可理解的动作位置,降低国内小团队日常使用复杂度。
## What I Already Know
- 面向对象是国内小于 10 人的科技初创公司,系统应优先低复杂度、高频动作和成熟后台惯例。
- 当前已有列表模板 `BusinessListTemplate.tsx` 和详情模板 `BusinessDetailTemplate.tsx`。
- 当前还没有统一编辑模板。
- 用户明确不想要 `台账工具` 这个泛化入口。
- 本轮采用成熟 Ant Design / Ant Design Pro 式后台结构:页头主动作、状态/范围切换、筛选、表格、选中后批量条、详情右侧操作区、编辑页分区表单。
## Scope
- 新增 `BusinessEditTemplate` 及配套编辑页布局组件。
- 首批只迁移报销列表和资产列表中的 `台账工具` 入口。
- 更新 frontend 设计规范,固化三类模板和 `台账工具` 禁用规则。
- 补充或调整必要的前端测试断言。
## Requirements
- 列表、详情、编辑模板必须只统一布局和交互槽位,不做 JSON 配置式页面生成器。
- `台账工具` 按钮名不得继续出现在报销、资产首批页面。
- 报销列表:
- `新建报销` 保持页头主按钮。
- 更多创建方式归入 `新建报销` 的下拉或明确命名入口。
- 导出使用明确的 `导出` 入口。
- 全范围/仅自己/审批中台账使用范围或筛选动作,不藏在 `台账工具`。
- 资产列表:
- 常用列表筛选和状态切换保持在列表上方。
- 导出、盘点、导入等能力使用具体动作名或详情/管理入口,不使用 `台账工具`。
- 编辑模板应支持:
- 标题、返回、状态;
- 右上角操作;
- 底部固定操作条;
- 分区表单;
- 校验摘要;
- 未保存提醒接入点。
## Acceptance Criteria
- [x] 新增业务编辑模板组件并可被后续页面复用。
- [x] 报销页不再出现 `台账工具` 文案,原功能仍可通过具体按钮/下拉访问。
- [x] 资产页不再出现 `台账工具` 文案,原功能仍可通过具体按钮/下拉访问或合理迁移。
- [x] 前端规范记录三类模板和 `台账工具` 禁用规则。
- [x] `cd client && npm run typecheck` 通过。
- [x] `cd client && npm run build` 通过。
- [x] 相关 Playwright/静态检查按需通过。
## Out Of Scope
- 不一次性迁移合同、采购、付款、应付账款、票据和项目。
- 不重做业务状态机、审批规则或后端接口。
- 不把所有业务字段改成配置生成。
- 不做公网部署,除非用户后续明确批准。
## Test Plan
- `cd client && npm run typecheck`
- `cd client && npm run build`
- `rg -n "台账工具" client/src/pages/ReimbursementsPage.tsx client/src/pages/AssetRegistryPage.tsx`
- 运行受影响页面相关的聚焦 Playwright 测试,或新增静态/组件断言覆盖入口文案。
## Technical Notes
- 设计裁决:`frontend-design` skill。
- 现有列表模板:`client/src/components/patterns/BusinessListTemplate.tsx`
- 现有详情模板:`client/src/components/patterns/BusinessDetailTemplate.tsx`
- 首批页面:`client/src/pages/ReimbursementsPage.tsx`、`client/src/pages/AssetRegistryPage.tsx`
## Validation Result
- `cd client && npm run typecheck`:通过。
- `cd client && npm run build`:通过。
- `rg -n "台账工具|showLedgerTools|asset-ledger-tools|更多创建方式" client/src/pages/ReimbursementsPage.tsx client/src/pages/AssetRegistryPage.tsx`:无结果。
- `git diff --check`:通过。
- `python3 ./.trellis/scripts/task.py validate 05-31-business-edit-template-remove-ledger-tools-wave1`:通过。
- `node scripts/qa/check-trellis-governance.mjs`:通过。
- `cd client && npx playwright test tests/e2e/system-action-confirmation.spec.ts --workers=1 --reporter=list`:2 passed。
- `cd client && npx playwright test tests/e2e/visual-shell.spec.ts --workers=1 --reporter=list`:5 passed。
@@ -0,0 +1,31 @@
{
"id": "business-edit-template-remove-ledger-tools-wave1",
"name": "business-edit-template-remove-ledger-tools-wave1",
"title": "建立业务编辑模板并移除台账工具首批",
"description": "Build the first business edit template and remove generic ledger-tools entry points from reimbursement and asset list pages.",
"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": [
"client/src/components/patterns/BusinessEditTemplate.tsx",
"client/src/pages/ReimbursementsPage.tsx",
"client/src/pages/AssetRegistryPage.tsx",
".trellis/spec/hyb-oa/frontend-react-vite-design.md"
],
"notes": "",
"meta": {}
}
@@ -0,0 +1,162 @@
import type { ReactNode } from "react";
import { Link } from "react-router-dom";
import { cn } from "../../lib/utils";
import { Button } from "../ui/button";
import { WorkspaceHeader } from "./ListWorkspace";
import { SectionPanel } from "./SectionPanel";
export function BusinessEditTemplate({
eyebrow,
title,
backTo,
backLabel = "返回",
status,
actions,
meta,
validation,
bottomActions,
children,
className,
testId,
}: {
eyebrow?: string;
title: string;
backTo?: string;
backLabel?: string;
status?: ReactNode;
actions?: ReactNode;
meta?: ReactNode;
validation?: ReactNode;
bottomActions?: ReactNode;
children: ReactNode;
className?: string;
testId?: string;
}) {
const headerActions = actions || status || backTo ? (
<div className="flex flex-wrap items-center justify-end gap-2">
{status ? <div className="shrink-0">{status}</div> : null}
{actions}
{backTo ? (
<Button asChild type="button" variant="secondary">
<Link to={backTo}>{backLabel}</Link>
</Button>
) : null}
</div>
) : null;
return (
<div className={cn("page-scaffold", bottomActions && "pb-24 md:pb-0", className)} data-testid={testId}>
<WorkspaceHeader eyebrow={eyebrow} title={title} meta={meta} actions={headerActions} />
{validation ? <BusinessEditValidationSummary>{validation}</BusinessEditValidationSummary> : null}
{children}
{bottomActions ? <BusinessEditActionBar>{bottomActions}</BusinessEditActionBar> : null}
</div>
);
}
export function BusinessEditWorkspace({
children,
className,
testId,
}: {
children: ReactNode;
className?: string;
testId?: string;
}) {
return (
<div className={cn("detail-workspace-grid", className)} data-testid={testId}>
{children}
</div>
);
}
export function BusinessEditMain({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn("space-y-5", className)}>{children}</div>;
}
export function BusinessEditAside({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={cn("space-y-5", className)}>{children}</div>;
}
export function BusinessEditSection({
title,
description,
actions,
children,
className,
}: {
title: string;
description?: string;
actions?: ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<SectionPanel title={title} description={description} actions={actions} className={className}>
{children}
</SectionPanel>
);
}
export function BusinessEditFormGrid({
children,
className,
columns = 2,
}: {
children: ReactNode;
className?: string;
columns?: 1 | 2 | 3;
}) {
const columnClass = {
1: "grid-cols-1",
2: "md:grid-cols-2",
3: "md:grid-cols-2 xl:grid-cols-3",
}[columns];
return <div className={cn("grid gap-4", columnClass, className)}>{children}</div>;
}
export function BusinessEditValidationSummary({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div className={cn("rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700", className)}>
{children}
</div>
);
}
export function BusinessEditActionBar({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<div
className={cn(
"mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur md:static md:inset-auto md:mt-5 md:rounded-lg md:border md:bg-white md:p-3 md:shadow-none",
className,
)}
>
<div className="mobile-sticky-action-grid md:flex md:justify-end md:gap-2">{children}</div>
</div>
);
}
+97 -56
查看文件
@@ -226,7 +226,8 @@ export function AssetRegistryPage() {
const [assigneeFilter, setAssigneeFilter] = useState(""); const [assigneeFilter, setAssigneeFilter] = useState("");
const [assetSearch, setAssetSearch] = useState(""); const [assetSearch, setAssetSearch] = useState("");
const [mine, setMine] = useState(!canViewAll); const [mine, setMine] = useState(!canViewAll);
const [showLedgerTools, setShowLedgerTools] = useState(false); const [showCategoryManager, setShowCategoryManager] = useState(false);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? ""); const [selectedAssetId, setSelectedAssetId] = useState(searchParams.get("id") ?? "");
const [isCreatingAsset, setIsCreatingAsset] = useState(false); const [isCreatingAsset, setIsCreatingAsset] = useState(false);
@@ -377,6 +378,14 @@ export function AssetRegistryPage() {
const currentAsset = assetDetailQuery.data?.asset ?? null; const currentAsset = assetDetailQuery.data?.asset ?? null;
const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit); const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit);
const hasAdvancedAssetFilters = Boolean(
sensitivityFilter ||
categoryFilter ||
legalEntityFilter ||
costCenterFilter ||
assigneeFilter,
);
const showAssetAdvancedFilters = showAdvancedFilters || hasAdvancedAssetFilters;
function resetCategoryEditor(category?: AssetCategoryItem) { function resetCategoryEditor(category?: AssetCategoryItem) {
if (!category) { if (!category) {
@@ -733,9 +742,15 @@ export function AssetRegistryPage() {
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }> <Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
移动扫码查找 移动扫码查找
</Button> </Button>
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}> {canManage ? (
台账工具 <Button
type="button"
variant={showCategoryManager ? "default" : "secondary"}
onClick={() => setShowCategoryManager((value) => !value)}
>
资产分类
</Button> </Button>
) : null}
</> </>
)} )}
</div> </div>
@@ -744,8 +759,8 @@ export function AssetRegistryPage() {
{!isAssetDetailMode ? ( {!isAssetDetailMode ? (
<div className="grid gap-6"> <div className="grid gap-6">
{showLedgerTools ? ( {showCategoryManager && canManage ? (
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools"> <div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-category-manager">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3> <h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
{canManage ? ( {canManage ? (
@@ -786,56 +801,6 @@ export function AssetRegistryPage() {
) : null} ) : null}
</div> </div>
<div className="space-y-3 rounded-lg border border-zinc-200 bg-white p-3">
<div>
<h4 className="text-sm font-semibold text-zinc-900">高级筛选</h4>
</div>
<div className="grid gap-3">
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
{sensitivityOptions().map((item) => (
<option key={item.value || "all"} value={item.value}>
{item.label}
</option>
))}
</select>
<select className="select-std" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
<option value="">全部分类</option>
{(categoriesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{formatTextWithCompactLongNumbers(item.name)}
</option>
))}
</select>
<select className="select-std" value={legalEntityFilter} onChange={(event) => {
setLegalEntityFilter(event.target.value);
setCostCenterFilter("");
}}>
<option value="">全部法体</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{formatTextWithCompactLongNumbers(item.name)}
</option>
))}
</select>
<select className="select-std" value={costCenterFilter} onChange={(event) => setCostCenterFilter(event.target.value)}>
<option value="">全部成本中心</option>
{filteredFilterCostCenters.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<select className="select-std" value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} disabled={!canViewAll}>
<option value="">全部归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
</div>
{categoriesQuery.isLoading ? <TableSkeleton columns={3} rows={5} /> : null} {categoriesQuery.isLoading ? <TableSkeleton columns={3} rows={5} /> : null}
{categoriesQuery.isError ? ( {categoriesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(categoriesQuery.error)}</p> <p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(categoriesQuery.error)}</p>
@@ -903,7 +868,9 @@ export function AssetRegistryPage() {
<div className="grid gap-4"> <div className="grid gap-4">
<BusinessListToolbar <BusinessListToolbar
actions={canViewAll ? ( actions={(
<>
{canViewAll ? (
<> <>
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}> <Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
我相关 我相关
@@ -913,6 +880,80 @@ export function AssetRegistryPage() {
</Button> </Button>
</> </>
) : null} ) : null}
<Button
type="button"
size="sm"
variant={showAssetAdvancedFilters ? "default" : "secondary"}
data-testid="asset-filter-toggle"
onClick={() => setShowAdvancedFilters((value) => !value)}
>
筛选
</Button>
</>
)}
advanced={showAssetAdvancedFilters ? (
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<select className="select-std" value={sensitivityFilter} onChange={(event) => setSensitivityFilter(event.target.value as AssetSensitivityLevel | "") }>
{sensitivityOptions().map((item) => (
<option key={item.value || "all"} value={item.value}>
{item.label}
</option>
))}
</select>
<select className="select-std" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
<option value="">全部分类</option>
{(categoriesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{formatTextWithCompactLongNumbers(item.name)}
</option>
))}
</select>
<select className="select-std" value={legalEntityFilter} onChange={(event) => {
setLegalEntityFilter(event.target.value);
setCostCenterFilter("");
}}>
<option value="">全部法体</option>
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{formatTextWithCompactLongNumbers(item.name)}
</option>
))}
</select>
<select className="select-std" value={costCenterFilter} onChange={(event) => setCostCenterFilter(event.target.value)}>
<option value="">全部成本中心</option>
{filteredFilterCostCenters.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<select className="select-std" value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} disabled={!canViewAll}>
<option value="">全部归属人</option>
{(assigneesQuery.data?.items ?? []).map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
<div className="flex items-center md:col-span-2 xl:col-span-5">
<Button
type="button"
size="sm"
variant="secondary"
onClick={() => {
setSensitivityFilter("");
setCategoryFilter("");
setLegalEntityFilter("");
setCostCenterFilter("");
setAssigneeFilter("");
setShowAdvancedFilters(false);
}}
>
重置筛选
</Button>
</div>
</div>
) : null}
> >
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_190px]"> <div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_180px_190px]">
<Input <Input
+86 -89
查看文件
@@ -1,7 +1,6 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom"; import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Wrench } from "lucide-react";
import { isApiError } from "../api/types"; import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages"; import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations"; import { fetchMyDelegations } from "../api/delegations";
@@ -154,7 +153,6 @@ export function ReimbursementsPage() {
const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState(""); const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null); const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null); const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft"; const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
const page = Number(searchParams.get("page") ?? "1") || 1; const page = Number(searchParams.get("page") ?? "1") || 1;
@@ -170,9 +168,9 @@ export function ReimbursementsPage() {
hasPermission(permissions, "invoice.manage"); hasPermission(permissions, "invoice.manage");
const isAdministration = (me?.roleCodes ?? []).includes("administration"); const isAdministration = (me?.roleCodes ?? []).includes("administration");
const showProxyOwnerSelector = canProxySubmit && isAdministration; const showProxyOwnerSelector = canProxySubmit && isAdministration;
const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools; const canShowListActions = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || showProxyOwnerSelector); const showAdvancedTools = scope === "all" || showProxyOwnerSelector;
const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense; const shouldLoadCreateOptions = canCreateExpense;
const listQuery = useQuery({ const listQuery = useQuery({
queryKey: ["reimbursements", "list", status, page, scope], queryKey: ["reimbursements", "list", status, page, scope],
@@ -284,97 +282,24 @@ export function ReimbursementsPage() {
title={scope === "all" ? "报销台账" : "我的报销"} title={scope === "all" ? "报销台账" : "我的报销"}
actions={ actions={
canCreateExpense ? ( canCreateExpense ? (
<div className="flex flex-wrap items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button <Button
data-testid="reimbursements-create" data-testid="reimbursements-create"
disabled={createMutation.isPending} disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
type="button" type="button"
> >
{createMutation.isPending ? "创建中..." : "新建报销"} {createMutation.isPending ? "创建中..." : "新建报销"}
</Button> </Button>
) : null
}
/>
{createMutation.error && isApiError(createMutation.error) ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{createMutation.error.code}:
{mapApiErrorMessage(
createMutation.error.code,
createMutation.error.details,
createMutation.error.message,
)}
</p>
) : null}
{exportError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}
</p>
) : null}
<BusinessListToolbar
actions={
canShowLedgerEntry ? (
<Button
size="sm"
variant={showAdvancedTools ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)}
type="button"
>
<Wrench aria-hidden className="mr-1 size-4" />
台账工具
</Button>
) : null
}
advanced={showAdvancedTools ? (
<>
<div className="grid gap-3 text-sm md:grid-cols-4">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.currentPageCount} 条 / 共 {listQuery.data?.total ?? 0} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">当前金额</div>
<div className="mt-1 font-semibold text-zinc-900">{formatCurrency(stats.currentPageAmount)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">审批中</div>
<div className={stats.reviewing > 0 ? "mt-1 font-semibold text-amber-700" : "mt-1 font-semibold text-zinc-900"}>{stats.reviewing} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">已支付</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.paid} 条</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{showProxyOwnerSelector ? (
<select
className="select-sm"
data-testid="reimbursements-owner-select"
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
value={proxyOwnerEmployeeId}
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
) : null}
{canShowCreateOptionsMenu ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="secondary" type="button">
更多创建方式
</Button>
</DropdownMenuTrigger> </DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80"> <DropdownMenuContent align="end" className="w-80">
<DropdownMenuItem onSelect={() => startCreateReimbursement()}>
空白报销
</DropdownMenuItem>
{canShowCreateOptionsMenu ? (
<>
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400"> <div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建 从模板创建
</div> </div>
@@ -407,10 +332,36 @@ export function ReimbursementsPage() {
) : ( ) : (
<DropdownMenuItem disabled>当前无可关联申请</DropdownMenuItem> <DropdownMenuItem disabled>当前无可关联申请</DropdownMenuItem>
)} )}
</>
) : null}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</div>
) : null
}
/>
{createMutation.error && isApiError(createMutation.error) ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{createMutation.error.code}:
{mapApiErrorMessage(
createMutation.error.code,
createMutation.error.details,
createMutation.error.message,
)}
</p>
) : null} ) : null}
{exportError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}
</p>
) : null}
<BusinessListToolbar
actions={
canShowListActions ? (
<>
{canViewAll ? ( {canViewAll ? (
<> <>
<Button <Button
@@ -420,7 +371,7 @@ export function ReimbursementsPage() {
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })} onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
type="button" type="button"
> >
审批中台账 审批中
</Button> </Button>
<Button <Button
data-testid="reimbursements-scope-own" data-testid="reimbursements-scope-own"
@@ -441,7 +392,7 @@ export function ReimbursementsPage() {
全范围 全范围
</Button> </Button>
<Button asChild size="sm" variant="secondary" type="button"> <Button asChild size="sm" variant="secondary" type="button">
<Link to="/reimbursements/dashboard">统计分析</Link> <Link to="/reimbursements/dashboard">统计</Link>
</Button> </Button>
</> </>
) : null} ) : null}
@@ -473,7 +424,53 @@ export function ReimbursementsPage() {
</DropdownMenuItem> </DropdownMenuItem>
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</>
) : null
}
advanced={showAdvancedTools ? (
<>
{scope === "all" ? (
<div className="grid gap-3 text-sm md:grid-cols-4">
<div>
<div className="text-xs text-zinc-500">当前列表</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.currentPageCount} 条 / 共 {listQuery.data?.total ?? 0} 条</div>
</div> </div>
<div>
<div className="text-xs text-zinc-500">当前金额</div>
<div className="mt-1 font-semibold text-zinc-900">{formatCurrency(stats.currentPageAmount)}</div>
</div>
<div>
<div className="text-xs text-zinc-500">审批中</div>
<div className={stats.reviewing > 0 ? "mt-1 font-semibold text-amber-700" : "mt-1 font-semibold text-zinc-900"}>{stats.reviewing} 条</div>
</div>
<div>
<div className="text-xs text-zinc-500">已支付</div>
<div className="mt-1 font-semibold text-zinc-900">{stats.paid} 条</div>
</div>
</div>
) : null}
{showProxyOwnerSelector ? (
<div className={scope === "all" ? "mt-3 flex flex-wrap items-center gap-2" : "flex flex-wrap items-center gap-2"}>
{showProxyOwnerSelector ? (
<select
className="select-sm"
data-testid="reimbursements-owner-select"
onChange={(event) => setProxyOwnerEmployeeId(event.target.value)}
value={proxyOwnerEmployeeId}
>
<option value="">自己</option>
{(proxyDelegationsQuery.data?.received ?? [])
.filter((item) => item.status === "active")
.map((item) => (
<option key={item.id} value={item.principalEmployeeId}>
{item.principal?.name ?? item.principalEmployeeId}
</option>
))}
</select>
) : null}
</div>
) : null}
</> </>
) : null} ) : null}
> >
+2 -2
查看文件
@@ -85,8 +85,8 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
await (await openNavLink(page, "资产")).click(); await (await openNavLink(page, "资产")).click();
await expect(page.getByRole("heading", { name: "资产台账" })).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole("heading", { name: "资产台账" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("asset-category-code")).toHaveCount(0); await expect(page.getByTestId("asset-category-code")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click(); await page.getByRole("button", { name: "资产分类" }).click();
await expect(page.getByTestId("asset-ledger-tools")).toBeVisible(); await expect(page.getByTestId("asset-category-manager")).toBeVisible();
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
await page.getByTestId("asset-category-reset").click(); await page.getByTestId("asset-category-reset").click();
@@ -162,6 +162,7 @@ test("ASSET1D 公网资产台账试录:FA/EA-SYS/EA-REPO/EA-BKP", async ({ pag
await login(page, admin); await login(page, admin);
await page.goto("/assets"); await page.goto("/assets");
await page.getByRole("button", { name: "资产分类" }).click();
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 }); await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
const category = await createTrialCategory(page, suffix); const category = await createTrialCategory(page, suffix);
@@ -290,7 +290,6 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.getByTestId("action-confirm-submit").click(); await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.deleteReimbursement).toBe(1); await expect.poll(() => calls.deleteReimbursement).toBe(1);
await page.getByRole("button", { name: "台账工具" }).click();
await page.getByTestId("reimbursements-export").click(); await page.getByTestId("reimbursements-export").click();
await page.getByTestId("reimbursements-export-csv").click(); await page.getByTestId("reimbursements-export-csv").click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息"); await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息");
+2 -2
查看文件
@@ -390,8 +390,8 @@ test.describe("visual shell", () => {
await page.goto("/reimbursements"); await page.goto("/reimbursements");
await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible(); await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible();
await expect(page.getByTestId("reimbursements-create")).toBeVisible(); await expect(page.getByTestId("reimbursements-create")).toBeVisible();
await expect(page.getByRole("button", { name: "台账工具" })).toBeVisible(); await expect(page.getByRole("button", { name: "台账工具" })).toHaveCount(0);
await expect(page.getByTestId("reimbursements-export")).not.toBeVisible(); await expect(page.getByTestId("reimbursements-export")).toBeVisible();
await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible(); await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible();
await expect(page.getByTestId("reimbursements-table")).toBeVisible(); await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await expect(page.getByRole("button", { name: "批量操作" })).toHaveCount(0); await expect(page.getByRole("button", { name: "批量操作" })).toHaveCount(0);