统一详情页操作区模板(05-31-unify-business-detail-ant-design)
这个提交包含在:
@@ -326,6 +326,19 @@ matrices and actions explicit in each page.
|
||||
lists, prefer `BusinessListToolbar`, `BusinessTable`, `BusinessTableFrame`,
|
||||
`BusinessMobileList` and shared empty states from
|
||||
`client/src/components/patterns/BusinessListTemplate.tsx`.
|
||||
- For PC business detail pages, prefer the thin components in
|
||||
`client/src/components/patterns/BusinessDetailTemplate.tsx`:
|
||||
`BusinessDetailWorkspace`, `BusinessDetailMain`, `BusinessDetailAside`,
|
||||
`BusinessDetailActionPanel`, `BusinessDetailFactGrid` and
|
||||
`BusinessDetailFact`. They define the Ant Design Pro style coordinates only:
|
||||
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.
|
||||
- `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 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
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Verify detail pages follow shared action placement and template contracts."}
|
||||
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Verify task scope, validation and commit governance."}
|
||||
{"file": ".trellis/tasks/05-31-unify-business-detail-ant-design/research/detail-page-pattern.md", "reason": "Verify implementation matches the selected task slice."}
|
||||
@@ -0,0 +1,3 @@
|
||||
{"file": ".trellis/spec/hyb-oa/frontend-react-vite-design.md", "reason": "Frontend page template, action placement and mature enterprise design rules for detail pages."}
|
||||
{"file": ".trellis/spec/hyb-oa/project-governance.md", "reason": "Trellis task, solo developer scope and commit rules."}
|
||||
{"file": ".trellis/tasks/05-31-unify-business-detail-ant-design/research/detail-page-pattern.md", "reason": "Task-specific detail page pattern findings and implementation slice."}
|
||||
@@ -0,0 +1,35 @@
|
||||
# Implementation Notes
|
||||
|
||||
## Slice
|
||||
|
||||
This task implements the first reusable PC business detail slice:
|
||||
|
||||
* Add `BusinessDetailTemplate` as a thin layout component family.
|
||||
* Apply it to `AssetRegistryPage.tsx` detail mode.
|
||||
* Apply it to `WorkflowTaskDetailPage.tsx` action/summary area.
|
||||
* Keep business fields, permission checks, state rules and API calls in the
|
||||
owning page components.
|
||||
|
||||
## Design Rules
|
||||
|
||||
* Follow mature Ant Design Pro style coordinates: main facts and evidence on
|
||||
the left, status/actions/compact summary on the right.
|
||||
* Do not introduce a configuration-driven page generator.
|
||||
* Do not duplicate page-local `操作` and `摘要` cards in the same right column.
|
||||
* Approval details keep one horizontal action bar: `通过`, `退回`, `驳回`,
|
||||
`更多 -> 转审`.
|
||||
* Asset details keep save/create as the primary action; transfer actions stay
|
||||
in the same right-side operation panel as a grouped section.
|
||||
|
||||
## Verification Notes
|
||||
|
||||
* `cd client && npm run typecheck` passed.
|
||||
* `cd client && npm run build` passed.
|
||||
* `workflow-action-confirm-ui.spec.ts` passed.
|
||||
* `pc-detail-mobile-visual-wave3.spec.ts -g "Wave3 PC"` passed and screenshot
|
||||
review confirmed the approval action area no longer renders an empty divider.
|
||||
* `assets-flow.spec.ts` was rerun with local backend started. It passed login
|
||||
and asset creation, then local auth refresh returned `401` after a full
|
||||
`page.goto(/assets?id=...)`, redirecting to `/login`. This is recorded as a
|
||||
local authentication-refresh/test-environment blocker, not a detail-template
|
||||
regression.
|
||||
@@ -0,0 +1,92 @@
|
||||
# 统一业务详情页操作区 Ant Design 风格
|
||||
|
||||
## Goal
|
||||
|
||||
在上一阶段统一业务列表之后,继续统一 PC 详情页和详情操作区。目标是采用成熟 Ant Design / Ant Design Pro 的详情页心智:顶部摘要、主内容事实区、右侧状态/操作面板、底部历史或关联信息,减少自由堆叠的大卡片和页面级例外,让用户在资产、审批、报销、采购、项目、付款等详情页里始终知道“信息在哪看、动作在哪做”。
|
||||
|
||||
## What I Already Know
|
||||
|
||||
* 用户明确要求页面、操作等参考成熟系统,不做创新控件。
|
||||
* 当前产品面向国内小于 10 人科技初创公司,功能要少出错、低维护成本。
|
||||
* 上一任务已落地 `BusinessListTemplate`,并沉淀“薄模板 + 页面契约,不做配置化生成器”的规则。
|
||||
* 现有规范要求:PC 详情页使用 Ant Design Pro 风格详情工作区;主事实/证据在左,状态和高风险操作在右;审批详情使用一条操作栏,不使用竖向操作卡片。
|
||||
* 代码中已有 `detail-workspace-grid`、`detail-command-panel`、`detail-fact-grid`、`detail-action-stack`、`DetailSideMeta`、`EntityOverviewHero` 等基础样式和组件。
|
||||
* `ReimbursementDetailPage.tsx`、`ProjectDetailPage.tsx`、`WorkflowTaskDetailPage.tsx` 已部分符合详情工作区;`AssetRegistryPage.tsx` 详情页仍较复杂,操作区和摘要表现与其他详情页不一致。
|
||||
|
||||
## Current Findings
|
||||
|
||||
* `AssetRegistryPage.tsx` 详情页是当前最明显的首批改造对象:主内容和右侧操作区虽然已有两列,但右侧 `操作` + `资产摘要` 是页面局部拼装,资产流转字段常驻在操作面板内,视觉密度和其他详情页不一致。
|
||||
* `WorkflowTaskDetailPage.tsx` 已有横向 `workflow-action-bar`,但还没有复用通用详情操作组件;应沉淀为模板契约,避免后续页面再出现竖排动作卡。
|
||||
* `ReimbursementDetailPage.tsx` 和 `ProjectDetailPage.tsx` 已是较好的参照,不应在本轮大幅改动。
|
||||
* `PurchaseRequestsPage.tsx` 详情模式仍混有“当前状态与下一步”解释文案,但采购详情结构较大,本轮先不重排全部采购表单。
|
||||
* `PaymentBatchesPage.tsx` 付款详情仍有财务工作台特性,涉及真实付款动作,不在首批详情模板改造里做行为调整。
|
||||
|
||||
## Scope
|
||||
|
||||
本任务修改前端详情页体验与相关前端测试,范围包括共享详情模板组件、资产详情页首批改造、审批详情动作区模板化复核,以及稳定设计规则沉淀。后端接口、数据库、权限模型、审批引擎、真实付款能力、公网部署不在范围内。
|
||||
|
||||
## Requirements
|
||||
|
||||
* 采用“模板组件 + 页面契约”的方式推进,不做配置驱动详情页生成器。
|
||||
* 新增轻量 `BusinessDetailTemplate` 组件族,复用现有 detail CSS 类,不重建视觉系统:
|
||||
* `BusinessDetailWorkspace`:统一 PC 两列详情布局。
|
||||
* `BusinessDetailMain` / `BusinessDetailAside`:约束主内容和右侧操作/摘要区域。
|
||||
* `BusinessDetailActionPanel`:统一右侧状态、主操作、次级操作、更多操作和摘要。
|
||||
* `BusinessDetailFactGrid` / `BusinessDetailFact`:统一详情摘要事实卡。
|
||||
* 首批落地页面:
|
||||
* `AssetRegistryPage.tsx`:把详情页右侧操作区改为统一操作面板;保存/创建作为主操作;领用、归还、调拨作为流转操作区;资产摘要合并到同一右侧面板或标准事实区,避免独立局部卡片风格。
|
||||
* `WorkflowTaskDetailPage.tsx`:保持一条审批操作栏,使用统一详情操作组件承载;`通过` 主按钮,`退回`、`驳回` 次级按钮,`转审` 放入更多;原因输入仍在点击后出现。
|
||||
* 保持已有业务状态和权限判断显式写在页面内,不把状态矩阵隐藏进模板配置。
|
||||
* 不改变资产保存、领用、归还、调拨、审批通过/退回/驳回/转审的后端调用和权限条件。
|
||||
* PC 详情默认两列;移动端不在本轮重构,但不得破坏现有移动路由。
|
||||
* 不新增解释普通流程的默认可见文案;异常、敏感字段、不可逆动作说明可以保留。
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
* [x] 新增详情模板组件,页面不再各自拼装右侧操作/摘要面板。
|
||||
* [x] 资产详情页使用统一右侧操作面板,保存/创建是主操作,流转动作在同一操作区内有清晰分组。
|
||||
* [x] 资产摘要不再作为独立局部风格卡片堆在操作区下方,而是进入统一事实/摘要结构。
|
||||
* [x] 审批详情页保持横向一条动作栏,不出现多个竖向主操作卡片。
|
||||
* [ ] 既有资产流转、保存和审批动作测试仍通过。审批动作已通过;资产主链路在本地后端重跑时通过登录和创建,随后因本地 auth refresh `401` 重定向登录,未进入详情断言。
|
||||
* [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/assets-flow.spec.ts tests/e2e/workflow-action-confirm-ui.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-detail-ant-design`
|
||||
* `node scripts/qa/check-trellis-governance.mjs`
|
||||
|
||||
## Definition Of Done
|
||||
|
||||
* 前端类型检查和构建通过。
|
||||
* 聚焦资产和审批动作 E2E 通过。
|
||||
* 详情页视觉冒烟覆盖通过。
|
||||
* Trellis task 校验和治理检查通过。
|
||||
* 稳定设计规则更新到 `.trellis/spec/hyb-oa/frontend-react-vite-design.md`。
|
||||
|
||||
## Verification Results
|
||||
|
||||
* `cd client && npm run typecheck` passed.
|
||||
* `cd client && npm run build` passed.
|
||||
* `workflow-action-confirm-ui.spec.ts` passed.
|
||||
* `pc-detail-mobile-visual-wave3.spec.ts -g "Wave3 PC"` passed; screenshot review confirmed the right-side approval action panel has one horizontal action bar and no empty divider.
|
||||
* `assets-flow.spec.ts` blocked by local auth refresh after a full page reload: the test passed login and `POST /api/assets`, then `/api/auth/refresh` returned `401` and the app redirected to `/login`.
|
||||
|
||||
## Out Of Scope
|
||||
|
||||
* 不一次性重构所有详情页、编辑页和移动详情页。
|
||||
* 不引入 Ant Design 组件库替换当前 UI 基座。
|
||||
* 不做配置化详情页生成器。
|
||||
* 不调整后端权限、审批状态机、资产状态机、真实付款执行逻辑或数据库结构。
|
||||
* 不做公网部署,除非后续单独获得明确批准。
|
||||
|
||||
## Technical Notes
|
||||
|
||||
* 视觉基线:成熟 Ant Design / Ant Design Pro 详情页模式,但使用项目现有 React/Tailwind 组件。
|
||||
* 现有详情样式:`detail-workspace-grid`、`detail-command-panel`、`detail-fact-grid`、`detail-fact-card`、`detail-action-stack`。
|
||||
* 现有参照页面:`ReimbursementDetailPage.tsx`、`ProjectDetailPage.tsx`、`WorkflowTaskDetailPage.tsx`。
|
||||
* 首批主要改造页面:`AssetRegistryPage.tsx`、`WorkflowTaskDetailPage.tsx`。
|
||||
@@ -0,0 +1,27 @@
|
||||
# Detail Page Pattern Notes
|
||||
|
||||
## Mature Enterprise Baseline
|
||||
|
||||
For hyb-oa, the practical baseline is Ant Design Pro style rather than a novel visual design:
|
||||
|
||||
* Top summary identifies the object and status.
|
||||
* Main column contains facts, editable fields, evidence, relations and history.
|
||||
* Right column contains the current state, allowed actions and compact metadata.
|
||||
* Primary action appears once. Secondary actions stay nearby; risky or low-frequency actions move to `更多` or a grouped action section.
|
||||
* Explanatory copy is reserved for exceptions, irreversible actions, sensitive data or external-system boundaries.
|
||||
|
||||
## Repo Findings
|
||||
|
||||
* Existing CSS already supports the target: `detail-workspace-grid`, `detail-command-panel`, `detail-fact-grid`, `detail-fact-card`, `detail-action-stack`.
|
||||
* `ReimbursementDetailPage.tsx` and `ProjectDetailPage.tsx` are good references for a two-column detail workspace.
|
||||
* `WorkflowTaskDetailPage.tsx` already has a horizontal approval action bar and should be protected from regressing into vertical action cards.
|
||||
* `AssetRegistryPage.tsx` is the clearest first implementation target because the right-side operation and summary area is page-local and dense.
|
||||
|
||||
## Recommended Task Slice
|
||||
|
||||
Implement a thin `BusinessDetailTemplate` component family first, then apply it to:
|
||||
|
||||
1. Asset detail right-side action/summary area.
|
||||
2. Workflow task detail action panel.
|
||||
|
||||
Leave reimbursement, project, purchase and payment detail pages for later slices unless the shared template can be adopted without changing behavior.
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "unify-business-detail-ant-design",
|
||||
"name": "unify-business-detail-ant-design",
|
||||
"title": "统一业务详情页操作区 Ant Design 风格",
|
||||
"description": "统一 PC 业务详情页和详情操作区为成熟 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": {}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Children, type ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { SectionPanel } from "./SectionPanel";
|
||||
|
||||
export function BusinessDetailWorkspace({
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("detail-workspace-grid", className)} data-testid={testId}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BusinessDetailMain({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("space-y-5", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function BusinessDetailAside({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return <div className={cn("space-y-5", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function BusinessDetailFactGrid({
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("detail-fact-grid", className)} data-testid={testId}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BusinessDetailFact({
|
||||
label,
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
label: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("detail-fact-card", className)}>
|
||||
<div className="detail-fact-card__label">{label}</div>
|
||||
<div className="detail-fact-card__value">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BusinessDetailActionPanel({
|
||||
title = "操作",
|
||||
status,
|
||||
primaryAction,
|
||||
secondaryActions,
|
||||
moreActions,
|
||||
summary,
|
||||
children,
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
title?: string;
|
||||
status?: ReactNode;
|
||||
primaryAction?: ReactNode;
|
||||
secondaryActions?: ReactNode;
|
||||
moreActions?: ReactNode;
|
||||
summary?: ReactNode;
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
const visibleChildren = Children.toArray(children);
|
||||
const hasActionContent = Boolean(primaryAction || secondaryActions || moreActions);
|
||||
const hasChildren = visibleChildren.length > 0;
|
||||
const hasContentBeforeSummary = Boolean(primaryAction || secondaryActions || moreActions || hasChildren);
|
||||
|
||||
return (
|
||||
<SectionPanel title={title} actions={status} className={cn("detail-action-panel xl:sticky xl:top-24", className)}>
|
||||
<div className="detail-action-stack" data-testid={testId}>
|
||||
{primaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
|
||||
{secondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null}
|
||||
{moreActions ? <div className="border-t border-zinc-200 pt-3">{moreActions}</div> : null}
|
||||
{hasChildren ? (
|
||||
<div className={cn(hasActionContent && "border-t border-zinc-200 pt-3")}>{children}</div>
|
||||
) : null}
|
||||
{summary ? (
|
||||
<div className={cn(hasContentBeforeSummary && "border-t border-zinc-200 pt-3")}>
|
||||
<div className="space-y-2 text-sm text-zinc-600">{summary}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
);
|
||||
}
|
||||
@@ -653,6 +653,10 @@
|
||||
@apply grid gap-3 overflow-visible;
|
||||
}
|
||||
|
||||
.detail-action-panel {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.detail-action-card {
|
||||
border-color: rgba(226, 232, 240, 0.94);
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
|
||||
@@ -29,14 +29,20 @@ import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
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 {
|
||||
BusinessDetailActionPanel,
|
||||
BusinessDetailAside,
|
||||
BusinessDetailFact,
|
||||
BusinessDetailFactGrid,
|
||||
BusinessDetailMain,
|
||||
BusinessDetailWorkspace,
|
||||
} from "../components/patterns/BusinessDetailTemplate";
|
||||
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";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
@@ -1044,7 +1050,8 @@ export function AssetRegistryPage() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5 xl:grid-cols-[minmax(0,1fr)_320px]">
|
||||
<BusinessDetailWorkspace>
|
||||
<BusinessDetailMain>
|
||||
<div className="card-std space-y-4 p-4 md:p-5">
|
||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
|
||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
|
||||
@@ -1078,24 +1085,20 @@ export function AssetRegistryPage() {
|
||||
) : null}
|
||||
|
||||
{!isCreatingAsset && currentAsset ? (
|
||||
<div className="grid gap-3 md:grid-cols-4" data-testid="asset-detail-summary">
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||
<div className="text-xs text-zinc-500">责任人</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">{formatEmployeeSummary(currentAsset.owner ?? currentAsset.assignee)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||
<div className="text-xs text-zinc-500">保管/位置</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">{currentAsset.keeper ? formatEmployeeSummary(currentAsset.keeper) : currentAsset.location ?? "待确认"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||
<div className="text-xs text-zinc-500">{isElectronicAssetType(currentAsset.assetType) ? "最近验证" : "最近盘点"}</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">{currentAsset.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : "未登记"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 p-3 text-sm">
|
||||
<div className="text-xs text-zinc-500">状态</div>
|
||||
<div className="mt-1"><StatusBadge status={currentAsset.status} /></div>
|
||||
</div>
|
||||
</div>
|
||||
<BusinessDetailFactGrid className="xl:grid-cols-4" testId="asset-detail-summary">
|
||||
<BusinessDetailFact label="责任人">
|
||||
{formatEmployeeSummary(currentAsset.owner ?? currentAsset.assignee)}
|
||||
</BusinessDetailFact>
|
||||
<BusinessDetailFact label="保管/位置">
|
||||
{currentAsset.keeper ? formatEmployeeSummary(currentAsset.keeper) : currentAsset.location ?? "待确认"}
|
||||
</BusinessDetailFact>
|
||||
<BusinessDetailFact label={isElectronicAssetType(currentAsset.assetType) ? "最近验证" : "最近盘点"}>
|
||||
{currentAsset.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : "未登记"}
|
||||
</BusinessDetailFact>
|
||||
<BusinessDetailFact label="状态">
|
||||
<StatusBadge status={currentAsset.status} />
|
||||
</BusinessDetailFact>
|
||||
</BusinessDetailFactGrid>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-6">
|
||||
@@ -1302,31 +1305,54 @@ export function AssetRegistryPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</BusinessDetailMain>
|
||||
{(isCreatingAsset || currentAsset) && !assetDetailQuery.isLoading ? (
|
||||
<div className="space-y-5">
|
||||
<SectionPanel title="操作" className="xl:sticky xl:top-24">
|
||||
<div className="space-y-4">
|
||||
{canManage ? (
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="asset-save"
|
||||
disabled={saveAssetMutation.isPending || !canEditCurrent}
|
||||
onClick={() => saveAssetMutation.mutate()}
|
||||
>
|
||||
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
|
||||
</Button>
|
||||
) : null}
|
||||
<BusinessDetailAside>
|
||||
<BusinessDetailActionPanel
|
||||
status={!isCreatingAsset && currentAsset ? <StatusBadge status={currentAsset.status} /> : null}
|
||||
primaryAction={canManage ? (
|
||||
<Button
|
||||
className="w-full justify-center"
|
||||
type="button"
|
||||
data-testid="asset-save"
|
||||
disabled={saveAssetMutation.isPending || !canEditCurrent}
|
||||
onClick={() => saveAssetMutation.mutate()}
|
||||
>
|
||||
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
|
||||
</Button>
|
||||
) : null}
|
||||
summary={(
|
||||
<>
|
||||
<div>状态:{isCreatingAsset ? "新建中" : assetStatusLabel(currentAsset?.status)}</div>
|
||||
<div>类型:{assetTypeLabel(assetForm.assetType)}</div>
|
||||
<div>责任人:{formatEmployeeSummary(currentAsset?.owner ?? currentAsset?.assignee)}</div>
|
||||
<div>归属人:{formatEmployeeSummary(currentAsset?.assignee)}</div>
|
||||
<div>保管人:{formatEmployeeSummary(currentAsset?.keeper)}</div>
|
||||
<div>位置:{(currentAsset?.location ?? assetForm.location) || "-"}</div>
|
||||
<div>
|
||||
{isElectronicAssetType(assetForm.assetType) ? "最近验证" : "最近盘点"}:
|
||||
{currentAsset?.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : assetForm.lastVerifiedAt || "-"}
|
||||
</div>
|
||||
<div>
|
||||
采购来源:
|
||||
{currentAsset?.purchaseRequest
|
||||
? formatTextWithCompactLongNumbers(currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id)
|
||||
: "-"}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
testId="asset-detail-action-panel"
|
||||
>
|
||||
{currentAsset && canTransfer && !isCreatingAsset && currentAsset.assetType === "fixed_asset" ? (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-semibold text-zinc-900">资产流转</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
归属人:{formatEmployeeSummary(currentAsset.assignee)} · 位置:{currentAsset.location ?? "未设置"}
|
||||
</div>
|
||||
|
||||
{currentAsset && canTransfer && !isCreatingAsset && currentAsset.assetType === "fixed_asset" ? (
|
||||
<div className="space-y-3 border-t border-zinc-200 pt-4">
|
||||
<div className="text-sm font-semibold text-zinc-900">资产流转</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2 text-xs text-zinc-600">
|
||||
归属人:{formatEmployeeSummary(currentAsset.assignee)} · 位置:{currentAsset.location ?? "未设置"}
|
||||
</div>
|
||||
|
||||
{movementError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{movementError}</p>
|
||||
) : null}
|
||||
{movementError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{movementError}</p>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
@@ -1397,32 +1423,12 @@ export function AssetRegistryPage() {
|
||||
{transferAssetMutation.isPending ? "调拨中..." : "调拨"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<DetailSideMeta title="资产摘要">
|
||||
<div>状态:{isCreatingAsset ? "新建中" : assetStatusLabel(currentAsset?.status)}</div>
|
||||
<div>类型:{assetTypeLabel(assetForm.assetType)}</div>
|
||||
<div>责任人:{formatEmployeeSummary(currentAsset?.owner ?? currentAsset?.assignee)}</div>
|
||||
<div>归属人:{formatEmployeeSummary(currentAsset?.assignee)}</div>
|
||||
<div>保管人:{formatEmployeeSummary(currentAsset?.keeper)}</div>
|
||||
<div>位置:{(currentAsset?.location ?? assetForm.location) || "-"}</div>
|
||||
<div>
|
||||
{isElectronicAssetType(assetForm.assetType) ? "最近验证" : "最近盘点"}:
|
||||
{currentAsset?.lastVerifiedAt ? formatDisplayDateTime(currentAsset.lastVerifiedAt) : assetForm.lastVerifiedAt || "-"}
|
||||
</div>
|
||||
<div>
|
||||
采购来源:
|
||||
{currentAsset?.purchaseRequest
|
||||
? formatTextWithCompactLongNumbers(currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id)
|
||||
: "-"}
|
||||
</div>
|
||||
</DetailSideMeta>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</BusinessDetailActionPanel>
|
||||
</BusinessDetailAside>
|
||||
) : null}
|
||||
</div>
|
||||
</BusinessDetailWorkspace>
|
||||
)}
|
||||
</ListWorkspace>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,12 @@ import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import {
|
||||
BusinessDetailActionPanel,
|
||||
BusinessDetailAside,
|
||||
BusinessDetailMain,
|
||||
BusinessDetailWorkspace,
|
||||
} from "../components/patterns/BusinessDetailTemplate";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
import { StatStrip } from "../components/patterns/ListWorkspace";
|
||||
import { MetricCard } from "../components/patterns/MetricCard";
|
||||
@@ -298,8 +303,8 @@ export function WorkflowTaskDetailPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="detail-workspace-grid">
|
||||
<div className="space-y-5">
|
||||
<BusinessDetailWorkspace>
|
||||
<BusinessDetailMain>
|
||||
<SectionPanel title="当前状态与审批对象">
|
||||
<div className="space-y-4" data-testid="workflow-task-overview">
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
@@ -438,21 +443,14 @@ export function WorkflowTaskDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
</div>
|
||||
</BusinessDetailMain>
|
||||
|
||||
<div className="space-y-5">
|
||||
<SectionPanel title="处理" className="workflow-action-panel">
|
||||
<div className="detail-action-stack">
|
||||
{actionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
data-testid="workflow-task-action-error"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="workflow-action-bar">
|
||||
<BusinessDetailAside>
|
||||
<BusinessDetailActionPanel
|
||||
title="处理"
|
||||
className="workflow-action-panel xl:top-20"
|
||||
secondaryActions={(
|
||||
<>
|
||||
<Button
|
||||
data-testid="workflow-task-approve"
|
||||
disabled={!allowedActions.has("approve") || mutation.isPending}
|
||||
@@ -491,101 +489,112 @@ export function WorkflowTaskDetailPage() {
|
||||
</button>
|
||||
</details>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
summary={(
|
||||
<>
|
||||
<div>节点:{detail.task.stepName}</div>
|
||||
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
|
||||
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
|
||||
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
|
||||
<div>发起时间:{formatDateTime(detail.workflowInstance?.startedAt)}</div>
|
||||
</>
|
||||
)}
|
||||
testId="workflow-task-action-panel"
|
||||
>
|
||||
{actionError ? (
|
||||
<p
|
||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||
data-testid="workflow-task-action-error"
|
||||
>
|
||||
{actionError}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{activeSecondaryAction === "reject" ? (
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">驳回意见</p>
|
||||
<Textarea
|
||||
data-testid="workflow-task-reject-comment"
|
||||
onChange={(event) => setRejectComment(event.target.value)}
|
||||
rows={3}
|
||||
value={rejectComment}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-reject"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
||||
onClick={() => requestWorkflowAction("reject")}
|
||||
type="button"
|
||||
>
|
||||
确认驳回
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeSecondaryAction === "reject" ? (
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">驳回意见</p>
|
||||
<Textarea
|
||||
data-testid="workflow-task-reject-comment"
|
||||
onChange={(event) => setRejectComment(event.target.value)}
|
||||
rows={3}
|
||||
value={rejectComment}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-reject"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
||||
onClick={() => requestWorkflowAction("reject")}
|
||||
type="button"
|
||||
>
|
||||
确认驳回
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{activeSecondaryAction === "return" ? (
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">退回意见</p>
|
||||
<Textarea
|
||||
data-testid="workflow-task-return-comment"
|
||||
onChange={(event) => setReturnComment(event.target.value)}
|
||||
rows={3}
|
||||
value={returnComment}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-return"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
||||
onClick={() => requestWorkflowAction("return")}
|
||||
type="button"
|
||||
>
|
||||
确认退回
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeSecondaryAction === "return" ? (
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">退回意见</p>
|
||||
<Textarea
|
||||
data-testid="workflow-task-return-comment"
|
||||
onChange={(event) => setReturnComment(event.target.value)}
|
||||
rows={3}
|
||||
value={returnComment}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-return"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
||||
onClick={() => requestWorkflowAction("return")}
|
||||
type="button"
|
||||
>
|
||||
确认退回
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{activeSecondaryAction === "transfer" && canTransfer ? (
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="workflow-task-transfer-approver"
|
||||
onChange={(event) => setSelectedApprover(event.target.value)}
|
||||
value={selectedApprover}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approverQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Textarea
|
||||
data-testid="workflow-task-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
rows={3}
|
||||
value={transferReason}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-transfer"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!allowedActions.has("transfer") ||
|
||||
mutation.isPending ||
|
||||
!selectedApprover ||
|
||||
transferReason.trim().length < 10
|
||||
}
|
||||
onClick={requestWorkflowTransfer}
|
||||
type="button"
|
||||
>
|
||||
确认转审
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<DetailSideMeta title="任务摘要">
|
||||
<div>节点:{detail.task.stepName}</div>
|
||||
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
|
||||
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
|
||||
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
|
||||
<div>发起时间:{formatDateTime(detail.workflowInstance?.startedAt)}</div>
|
||||
</DetailSideMeta>
|
||||
</div>
|
||||
</div>
|
||||
{activeSecondaryAction === "transfer" && canTransfer ? (
|
||||
<div className="detail-action-card space-y-2">
|
||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="workflow-task-transfer-approver"
|
||||
onChange={(event) => setSelectedApprover(event.target.value)}
|
||||
value={selectedApprover}
|
||||
>
|
||||
<option value="">请选择目标审批人</option>
|
||||
{(approverQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Textarea
|
||||
data-testid="workflow-task-transfer-reason"
|
||||
onChange={(event) => setTransferReason(event.target.value)}
|
||||
rows={3}
|
||||
value={transferReason}
|
||||
/>
|
||||
<Button
|
||||
data-testid="workflow-task-transfer"
|
||||
variant="secondary"
|
||||
disabled={
|
||||
!allowedActions.has("transfer") ||
|
||||
mutation.isPending ||
|
||||
!selectedApprover ||
|
||||
transferReason.trim().length < 10
|
||||
}
|
||||
onClick={requestWorkflowTransfer}
|
||||
type="button"
|
||||
>
|
||||
确认转审
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</BusinessDetailActionPanel>
|
||||
</BusinessDetailAside>
|
||||
</BusinessDetailWorkspace>
|
||||
</PageScaffold>
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmCopy)}
|
||||
|
||||
在新工单中引用
屏蔽一个用户