统一编辑模板并移除首批台账工具(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
are `查看详情`, `处理`, `继续编辑` or `查看进度`; low-frequency or risky actions
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
action, visible common filters, table/card list and right-side operation
column. `台账工具` contains export, batch, statistics, templates, full-scope
views and advanced filters.
column. Do not use a generic `台账工具` entry; expose low-frequency abilities
through concrete names such as `导出`, `统计`, `筛选`, `导入`, `新建方式`,
`分类管理`, `批量处理` or `更多`.
- Business ledger first screens are single-column list pages. Do not use a
default left-list/right-detail workspace for procurement, contracts, projects,
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`
when those capabilities exist. Supplier, legal entity, cost center, date
ranges, contract type, sensitivity, export and test-data toggles belong in
`筛选`, `台账工具` or a tab-specific advanced area; default-visible filters
should not occupy multiple rows.
`筛选`, `更多`, clearly named role-only actions or a tab-specific advanced
area; default-visible filters should not occupy multiple rows.
- Business list columns show object identity, counterpart/person, amount/date,
state and operation. `下一步` is not a default list column or right-side help
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. |
| `待我处理` | 任务处理列表 | `待处理` / `已处理` 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 `更多`. |
| `报销` / `采购` / `合同` / `项目` / `资产` / `付款` / `应付账款` / `票据` | 业务台账列表 | 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. |
| `管理` | 管理配置页 | 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. |
@@ -339,11 +341,23 @@ matrices and actions explicit in each page.
main facts/evidence/history on the left, status/actions/compact summary on
the right. Keep the page's business fields, permission checks, state
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.
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
`更多` or a clearly named grouped section. Do not add a second page-local
`操作` 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,
procurement, contracts, projects, assets, payments, AP or invoices. These
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,
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
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
card. Prefer `查看详情`, `查看处理` and `继续编辑` over broad verbs that hide the
result.
@@ -567,8 +581,8 @@ of exposing every allowed tool at once.
they must not duplicate approval action buttons.
- Finance and cashier views may expose authorized reimbursement, payment,
invoice, AP, receipt and exception operations. Full-scope, export, batch,
delegated creation and statistics tools belong in `台账工具`, `更多` or
role-only panels.
delegated creation and statistics tools belong in concrete actions such as
`导出`, `统计`, `批量处理`, `新建方式`, `更多` or role-only panels.
- Owner and project-lead views emphasize team/project scope, blockers, risks and
approvals. They should not drift into system configuration unless the route is
a management route.
@@ -666,8 +680,8 @@ screen.
to the current user and the next action.
- Place common filters on the left side of the command bar below the page
header. Put export, batch, statistics, templates, full-scope, advanced
filters, delegated creation and low-frequency module links in `台账工具`,
`更多` or role-only panels.
filters, delegated creation and low-frequency module links in `筛选`,
concrete right-side actions, `更多` or role-only panels.
- 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
publish require summary confirmation and result details.
@@ -686,13 +700,13 @@ screen.
boundary. The list is for discovery, filtering, status reading and navigation.
High-risk or sensitive actions such as contract download, archive, payment,
export, batch processing, void/cancel and sensitive attachment viewing belong
in detail workspaces, detail command panels or `台账工具`, with permission,
audit and confirmation.
in detail workspaces, detail command panels, concrete role-only actions or
`更多`, with permission, audit and confirmation.
- Default ledger scope follows the current task role: ordinary employees see
`我发起` / `我相关`, owners and project leads see `我负责` / `项目相关`, finance
sees `待复核` / `待付款` / `异常`, and admins may enter or switch to `全范围`.
Full scope, export and batch commands stay in `台账工具` or role-only panels,
not ordinary first screens.
Full scope, export and batch commands stay in concrete role-only actions or
panels, not ordinary first screens.
- Contract ledgers in V1 are registration, archive, due/renewal reminder and
relation surfaces for project, procurement and payment context. Do not design
`/contracts-registry` as e-signature, legal-review, clause-library,
@@ -725,8 +739,8 @@ screen.
creation; `应付账款` shows pending payment, exceptions, risks and differences
before export; `票据` shows OCR status, failed invoices and bindable invoices
before broad-scope handover tools. Batch creation, export, full-scope views
and agency-accounting handover controls live in `台账工具` or selected-context
panels, never as the first-screen primary action.
and agency-accounting handover controls live in concrete role-only actions,
`更多` or selected-context panels, never as the first-screen primary action.
- Payment actions require the finance/payment context: amount, payee or target
object, source records, offline payment explanation, evidence completeness,
receipt requirement and correction boundary. Do not place `标记已付款`,
@@ -749,7 +763,8 @@ and management workflows.
- `cd client && npm run typecheck`
- `cd client && npm run build`
- 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
@@ -920,8 +935,8 @@ state, facts and consequences before acting.
- List and ledger rows show only evidence completeness summaries, missing
evidence hints and a detail entry. Invoice preview, attachment download,
contract file viewing, receipt download, evidence deletion and bulk packaging
belong in detail workspaces, detail command panels, `台账工具` or authorized
audit paths.
belong in detail workspaces, detail command panels, `更多`, concrete
role-only actions or authorized audit paths.
- Sensitive evidence such as invoices, contracts, receipts and attachments must
be permissioned; download and export belong in `更多` or audit paths when
appropriate.
@@ -1070,8 +1085,8 @@ summary unless a permissioned workflow requires more.
- Download, batch download, export, print, attachment packaging, contract text
export and finance ledger export must be controlled by server permissions and
data scope.
- Export is never the primary first-screen action. Put exports in `台账工具`,
`更多` or role-only finance/admin panels.
- Export is never the primary first-screen action. Put exports in a concrete
`导出` action, `更多` or role-only finance/admin panels.
- Before exporting sensitive ledgers, explain scope and sensitive contents.
Finance, contract, asset and invoice exports should write audit records.
- 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>
);
}
+103 -62
查看文件
@@ -226,7 +226,8 @@ export function AssetRegistryPage() {
const [assigneeFilter, setAssigneeFilter] = useState("");
const [assetSearch, setAssetSearch] = useState("");
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 [isCreatingAsset, setIsCreatingAsset] = useState(false);
@@ -377,6 +378,14 @@ export function AssetRegistryPage() {
const currentAsset = assetDetailQuery.data?.asset ?? null;
const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit);
const hasAdvancedAssetFilters = Boolean(
sensitivityFilter ||
categoryFilter ||
legalEntityFilter ||
costCenterFilter ||
assigneeFilter,
);
const showAssetAdvancedFilters = showAdvancedFilters || hasAdvancedAssetFilters;
function resetCategoryEditor(category?: AssetCategoryItem) {
if (!category) {
@@ -733,9 +742,15 @@ export function AssetRegistryPage() {
<Button type="button" variant="secondary" onClick={() => navigate("/mobile/assets/scan") }>
移动扫码查找
</Button>
<Button type="button" variant={showLedgerTools ? "default" : "secondary"} onClick={() => setShowLedgerTools((value) => !value)}>
台账工具
</Button>
{canManage ? (
<Button
type="button"
variant={showCategoryManager ? "default" : "secondary"}
onClick={() => setShowCategoryManager((value) => !value)}
>
资产分类
</Button>
) : null}
</>
)}
</div>
@@ -744,8 +759,8 @@ export function AssetRegistryPage() {
{!isAssetDetailMode ? (
<div className="grid gap-6">
{showLedgerTools ? (
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-ledger-tools">
{showCategoryManager && canManage ? (
<div className="card-std space-y-4 p-4 md:p-5" data-testid="asset-category-manager">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
{canManage ? (
@@ -786,56 +801,6 @@ export function AssetRegistryPage() {
) : null}
</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.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>
@@ -903,15 +868,91 @@ export function AssetRegistryPage() {
<div className="grid gap-4">
<BusinessListToolbar
actions={canViewAll ? (
actions={(
<>
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
我相关
</Button>
<Button type="button" size="sm" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
全范围
{canViewAll ? (
<>
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
我相关
</Button>
<Button type="button" size="sm" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
全范围
</Button>
</>
) : 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]">
+113 -116
查看文件
@@ -1,7 +1,6 @@
import { useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Wrench } from "lucide-react";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
@@ -154,7 +153,6 @@ export function ReimbursementsPage() {
const [proxyOwnerEmployeeId, setProxyOwnerEmployeeId] = useState("");
const [deleteTarget, setDeleteTarget] = useState<ReimbursementListItem | null>(null);
const [exportConfirmFormat, setExportConfirmFormat] = useState<ExportFormat | null>(null);
const [showLedgerTools, setShowLedgerTools] = useState(false);
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
const page = Number(searchParams.get("page") ?? "1") || 1;
@@ -170,9 +168,9 @@ export function ReimbursementsPage() {
hasPermission(permissions, "invoice.manage");
const isAdministration = (me?.roleCodes ?? []).includes("administration");
const showProxyOwnerSelector = canProxySubmit && isAdministration;
const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || showProxyOwnerSelector);
const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense;
const canShowListActions = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
const showAdvancedTools = scope === "all" || showProxyOwnerSelector;
const shouldLoadCreateOptions = canCreateExpense;
const listQuery = useQuery({
queryKey: ["reimbursements", "list", status, page, scope],
@@ -284,100 +282,27 @@ export function ReimbursementsPage() {
title={scope === "all" ? "报销台账" : "我的报销"}
actions={
canCreateExpense ? (
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</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>
<DropdownMenuContent align="end" className="w-80">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建
</div>
<div className="flex flex-wrap items-center justify-end gap-2">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
</DropdownMenuTrigger>
<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>
{templateOptions.length ? (
templateOptions.map((template) => (
<DropdownMenuItem
@@ -404,13 +329,39 @@ export function ReimbursementsPage() {
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>当前无可关联申请</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
) : (
<DropdownMenuItem disabled>当前无可关联申请</DropdownMenuItem>
)}
</>
) : null}
</DropdownMenuContent>
</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}
{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 ? (
<>
<Button
@@ -420,7 +371,7 @@ export function ReimbursementsPage() {
onClick={() => setSearchParams({ status: "reviewing", page: "1", scope: "all" })}
type="button"
>
审批中台账
审批中
</Button>
<Button
data-testid="reimbursements-scope-own"
@@ -441,22 +392,22 @@ export function ReimbursementsPage() {
全范围
</Button>
<Button asChild size="sm" variant="secondary" type="button">
<Link to="/reimbursements/dashboard">统计分析</Link>
<Link to="/reimbursements/dashboard">统计</Link>
</Button>
</>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-export"
size="sm"
variant="secondary"
type="button"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
<Button
data-testid="reimbursements-export"
size="sm"
variant="secondary"
type="button"
disabled={exportMutation.isPending}
>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
@@ -473,7 +424,53 @@ export function ReimbursementsPage() {
</DropdownMenuItem>
</DropdownMenuContent>
</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 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}
>
+2 -2
查看文件
@@ -85,8 +85,8 @@ test("E2E 资产流转主链路:建卡→领用归还→调拨→盘点", asyn
await (await openNavLink(page, "资产")).click();
await expect(page.getByRole("heading", { name: "资产台账" })).toBeVisible({ timeout: 30_000 });
await expect(page.getByTestId("asset-category-code")).toHaveCount(0);
await page.getByRole("button", { name: "台账工具" }).click();
await expect(page.getByTestId("asset-ledger-tools")).toBeVisible();
await page.getByRole("button", { name: "资产分类" }).click();
await expect(page.getByTestId("asset-category-manager")).toBeVisible();
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
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 page.goto("/assets");
await page.getByRole("button", { name: "资产分类" }).click();
await expect(page.getByTestId("asset-category-code")).toBeVisible({ timeout: 30_000 });
const category = await createTrialCategory(page, suffix);
@@ -290,7 +290,6 @@ test.describe("R7E 全站高风险操作二次确认", () => {
await page.getByTestId("action-confirm-submit").click();
await expect.poll(() => calls.deleteReimbursement).toBe(1);
await page.getByRole("button", { name: "台账工具" }).click();
await page.getByTestId("reimbursements-export").click();
await page.getByTestId("reimbursements-export-csv").click();
await expect(page.getByTestId("action-confirm-dialog")).toContainText("导出文件可能包含业务或个人信息");
+2 -2
查看文件
@@ -390,8 +390,8 @@ test.describe("visual shell", () => {
await page.goto("/reimbursements");
await expect(page.getByRole("heading", { name: "我的报销" })).toBeVisible();
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
await expect(page.getByRole("button", { name: "台账工具" })).toBeVisible();
await expect(page.getByTestId("reimbursements-export")).not.toBeVisible();
await expect(page.getByRole("button", { name: "台账工具" })).toHaveCount(0);
await expect(page.getByTestId("reimbursements-export")).toBeVisible();
await expect(page.getByTestId("reimbursements-status-tabs")).toBeVisible();
await expect(page.getByTestId("reimbursements-table")).toBeVisible();
await expect(page.getByRole("button", { name: "批量操作" })).toHaveCount(0);