统一详情页操作区模板(05-31-unify-business-detail-ant-design)

这个提交包含在:
2026-05-31 12:21:42 +08:00
父节点 7b5476a70e
当前提交 2bcd9012f7
共修改 11 个文件,包含 513 行新增和 178 行删除
@@ -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>
);
}
+4
查看文件
@@ -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);
+75 -69
查看文件
@@ -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>
);
+118 -109
查看文件
@@ -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)}