fix(pc-detail-responsive-approval-polish): 协调详情断点并收敛审批展示
这个提交包含在:
@@ -3,7 +3,7 @@ import { NavLinks } from "./NavLinks";
|
||||
|
||||
export function Sidebar() {
|
||||
return (
|
||||
<aside className="app-shell-sidebar fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 lg:flex lg:flex-col">
|
||||
<aside className="app-shell-sidebar fixed inset-y-0 z-20 hidden w-72 overflow-hidden border-r border-slate-200 xl:flex xl:flex-col">
|
||||
<div className="border-b border-slate-200 px-6 py-5">
|
||||
<AppBrand />
|
||||
</div>
|
||||
|
||||
@@ -143,7 +143,7 @@ export function Topbar() {
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<Dialog open={mobileNavOpen} onOpenChange={setMobileNavOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="lg:hidden" size="icon" variant="secondary" aria-label="打开菜单" type="button">
|
||||
<Button className="xl:hidden" size="icon" variant="secondary" aria-label="打开菜单" type="button">
|
||||
<MenuIcon />
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Children, type ReactNode } from "react";
|
||||
import { Children, Fragment, isValidElement, type ReactNode } from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { SectionPanel } from "./SectionPanel";
|
||||
|
||||
@@ -71,6 +71,15 @@ export function BusinessDetailFact({
|
||||
);
|
||||
}
|
||||
|
||||
function hasVisibleNode(node: ReactNode): boolean {
|
||||
return Children.toArray(node).some((child) => {
|
||||
if (isValidElement(child) && child.type === Fragment) {
|
||||
return hasVisibleNode(child.props.children);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function BusinessDetailActionPanel({
|
||||
title = "操作",
|
||||
status,
|
||||
@@ -92,21 +101,29 @@ export function BusinessDetailActionPanel({
|
||||
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);
|
||||
const hasStatus = hasVisibleNode(status);
|
||||
const hasPrimaryAction = hasVisibleNode(primaryAction);
|
||||
const hasSecondaryActions = hasVisibleNode(secondaryActions);
|
||||
const hasMoreActions = hasVisibleNode(moreActions);
|
||||
const hasSummary = hasVisibleNode(summary);
|
||||
const hasChildren = hasVisibleNode(children);
|
||||
const hasActionContent = hasPrimaryAction || hasSecondaryActions || hasMoreActions;
|
||||
const hasContentBeforeSummary = hasActionContent || hasChildren;
|
||||
|
||||
if (!hasStatus && !hasActionContent && !hasSummary && !hasChildren) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SectionPanel title={title} actions={status} className={cn("detail-action-panel lg:sticky lg: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}
|
||||
{hasPrimaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
|
||||
{hasSecondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null}
|
||||
{hasMoreActions ? <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 ? (
|
||||
{hasSummary ? (
|
||||
<div className={cn(hasContentBeforeSummary && "border-t border-zinc-200 pt-3")}>
|
||||
<div className="space-y-2 text-sm text-zinc-600">{summary}</div>
|
||||
</div>
|
||||
|
||||
@@ -66,7 +66,7 @@ export function AppShellLayout() {
|
||||
{isMobileRoute ? null : <Sidebar />}
|
||||
|
||||
<main
|
||||
className={`app-shell-main flex min-w-0 flex-1 flex-col ${isMobileRoute ? "hyb-mobile-shell overflow-hidden" : "lg:ml-72"}`}
|
||||
className={`app-shell-main flex min-w-0 flex-1 flex-col ${isMobileRoute ? "hyb-mobile-shell overflow-hidden" : "xl:ml-72"}`}
|
||||
style={isMobileRoute ? { height: "var(--mobile-viewport-height, 100vh)", maxHeight: "var(--mobile-viewport-height, 100vh)", minHeight: "var(--mobile-viewport-height, 100vh)" } : undefined}
|
||||
>
|
||||
{isMobileRoute ? null : <Topbar />}
|
||||
|
||||
@@ -52,6 +52,22 @@ export function workflowActionLabel(action: string | null | undefined) {
|
||||
return action ? labels[action] ?? action : "-";
|
||||
}
|
||||
|
||||
export function approvalRoleLabel(role: string | null | undefined) {
|
||||
if (!role?.trim()) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
manager: "主管",
|
||||
ceo: "负责人",
|
||||
finance: "财务",
|
||||
finance_mgr: "财务经理",
|
||||
hr: "人事",
|
||||
};
|
||||
|
||||
return labels[role.trim().toLowerCase()] ?? "审批人";
|
||||
}
|
||||
|
||||
export function formatApprovalHistoryTime(value: string | null | undefined) {
|
||||
return value ? value.slice(0, 19).replace("T", " ") : "-";
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
|
||||
import { approvalRoleLabel, resolvePurchaseDisplayCode, workflowActionLabel } from "../lib/approvalDisplay";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import {
|
||||
buildPcDetailSource,
|
||||
@@ -254,13 +254,16 @@ export function WorkflowTaskDetailPage() {
|
||||
onClick={() => navigate(backSource, { replace: true })}
|
||||
type="button"
|
||||
>
|
||||
{backSource.startsWith("/reimbursements") ? "返回报销列表" : "返回待办列表"}
|
||||
返回审批列表
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const detail = detailQuery.data;
|
||||
const canApprove = allowedActions.has("approve");
|
||||
const canReturn = allowedActions.has("return");
|
||||
const canReject = allowedActions.has("reject");
|
||||
const canTransfer = allowedActions.has("transfer");
|
||||
const allowedActionLabels = detail.allowedActions.map(workflowActionLabel).join(" / ");
|
||||
const businessStatusLabel = workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus);
|
||||
@@ -281,7 +284,7 @@ export function WorkflowTaskDetailPage() {
|
||||
actions={(
|
||||
<Button asChild variant="secondary" data-testid="workflow-task-back">
|
||||
<Link replace to={backSource}>
|
||||
{backSource.startsWith("/reimbursements") ? "返回报销列表" : "返回待办列表"}
|
||||
返回审批列表
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
@@ -294,7 +297,9 @@ export function WorkflowTaskDetailPage() {
|
||||
<StatStrip columns={4} className="ops-status-strip">
|
||||
<MetricCard title="当前节点" value={<span className="text-base">{detail.task.stepName}</span>} />
|
||||
<MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
|
||||
<MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels || "-"}</span>} />
|
||||
{allowedActionLabels ? (
|
||||
<MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels}</span>} />
|
||||
) : null}
|
||||
<MetricCard title="创建时间" value={<span className="text-base">{formatDisplayDateTime(detail.task.createdAt)}</span>} />
|
||||
</StatStrip>
|
||||
|
||||
@@ -431,7 +436,7 @@ export function WorkflowTaskDetailPage() {
|
||||
{workflowActionLabel(item.action)} · {item.actorName || item.actorId}
|
||||
</div>
|
||||
<div className="text-zinc-600">{item.comment || "-"}</div>
|
||||
<div className="text-xs text-zinc-500">{item.createdAt || "-"}</div>
|
||||
<div className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
@@ -445,53 +450,59 @@ export function WorkflowTaskDetailPage() {
|
||||
<BusinessDetailActionPanel
|
||||
title="处理"
|
||||
className="workflow-action-panel"
|
||||
secondaryActions={(
|
||||
primaryAction={canApprove ? (
|
||||
<Button
|
||||
data-testid="workflow-task-approve"
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => requestWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
) : null}
|
||||
secondaryActions={canReturn || canReject ? (
|
||||
<>
|
||||
<Button
|
||||
data-testid="workflow-task-approve"
|
||||
disabled={!allowedActions.has("approve") || mutation.isPending}
|
||||
onClick={() => requestWorkflowAction("approve")}
|
||||
type="button"
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="workflow-task-return-toggle"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("return") || mutation.isPending}
|
||||
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")}
|
||||
type="button"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
<Button
|
||||
data-testid="workflow-task-reject-toggle"
|
||||
variant="secondary"
|
||||
disabled={!allowedActions.has("reject") || mutation.isPending}
|
||||
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")}
|
||||
type="button"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
{canTransfer ? (
|
||||
<details className="workflow-action-more">
|
||||
<summary>更多</summary>
|
||||
<button
|
||||
data-testid="workflow-task-transfer-menu-item"
|
||||
type="button"
|
||||
onClick={() => setActiveSecondaryAction("transfer")}
|
||||
>
|
||||
转审
|
||||
</button>
|
||||
</details>
|
||||
{canReturn ? (
|
||||
<Button
|
||||
data-testid="workflow-task-return-toggle"
|
||||
variant="secondary"
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "return" ? null : "return")}
|
||||
type="button"
|
||||
>
|
||||
退回
|
||||
</Button>
|
||||
) : null}
|
||||
{canReject ? (
|
||||
<Button
|
||||
data-testid="workflow-task-reject-toggle"
|
||||
variant="secondary"
|
||||
disabled={mutation.isPending}
|
||||
onClick={() => setActiveSecondaryAction(activeSecondaryAction === "reject" ? null : "reject")}
|
||||
type="button"
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
) : null}
|
||||
moreActions={canTransfer ? (
|
||||
<details className="workflow-action-more">
|
||||
<summary>更多</summary>
|
||||
<button
|
||||
data-testid="workflow-task-transfer-menu-item"
|
||||
type="button"
|
||||
onClick={() => setActiveSecondaryAction("transfer")}
|
||||
>
|
||||
转审
|
||||
</button>
|
||||
</details>
|
||||
) : null}
|
||||
summary={(
|
||||
<>
|
||||
<div>节点:{detail.task.stepName}</div>
|
||||
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
|
||||
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
|
||||
<div>当前审批角色:{approvalRoleLabel(detail.task.assigneeRole)}</div>
|
||||
<div>创建时间:{formatDisplayDateTime(detail.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDisplayDateTime(detail.task.completedAt)}</div>
|
||||
<div>发起时间:{formatDisplayDateTime(detail.workflowInstance?.startedAt)}</div>
|
||||
|
||||
在新工单中引用
屏蔽一个用户