Files
deerflow-code/frontend-web/src/position-roundtable/components/PositionBusinessChainFlow.tsx
T

306 lines
11 KiB
TypeScript

/**
* 岗位会商风格的只读业务链流程图。
*
* 页面无关:8BF 等独立会商页可以直接复用岗位会商的纵向流程图视觉与交互,
* 不会读取或写入岗位会商的会话、岗位节点状态。
*/
import { useRef, useState } from "react";
import { CheckCircle2, Circle, Search } from "lucide-react";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import type { BusinessChain, ChainSeat } from "@/roundtable-planning/lib/business-chain";
import { cn } from "@/lib/utils";
const NODE_COLORS = ["#4f7df3", "#a778f5", "#65b4e8", "#f0a245", "#e675a8"];
const FLOW_NODE_WIDTH = 184;
const FLOW_NODE_GAP = 12;
type FlowStage = {
goal: string | null;
seats: ChainSeat[];
};
export interface PositionBusinessChainFlowProps {
chain: BusinessChain | null;
loading?: boolean;
className?: string;
}
function getFlowStages(chain: BusinessChain): FlowStage[] {
const seatsById = new Map(chain.seats.map((seat) => [seat.agent_id, seat]));
if (chain.stages?.length) {
return chain.stages
.map((ids, index) => ({
goal: chain.stageGoals?.[index] ?? null,
seats: ids
.map((id) => seatsById.get(id))
.filter((seat): seat is ChainSeat => Boolean(seat)),
}))
.filter((stage) => stage.seats.length > 0);
}
return chain.seats.map((seat) => ({ goal: null, seats: [seat] }));
}
export function PositionBusinessChainFlow({
chain,
loading = false,
className,
}: PositionBusinessChainFlowProps) {
const [dialogOpen, setDialogOpen] = useState(false);
return (
<section
className={cn(
"position-business-chain-flow flex h-full min-h-[460px] w-full flex-col overflow-hidden rounded-2xl border border-[#ece7e4] bg-white p-3 shadow-[0_4px_20px_rgba(0,0,0,0.02)] dark:border-[#2b8ffe]/30 dark:bg-[#042b59]/55 dark:shadow-none",
className,
)}
>
<div className="mb-3 flex shrink-0 items-center justify-between gap-2 border-b border-slate-200 pb-3 dark:border-[#2b8ffe]/40">
<div className="flex min-w-0 items-center gap-2">
<span
aria-hidden="true"
className="h-6 w-1 shrink-0 rounded-l-[2px] rounded-br-[2px] bg-[linear-gradient(167deg,#f15959_0%,#cc1925_100%)] dark:bg-[linear-gradient(167deg,#a2e1f9_0%,#45c3f3_100%)]"
/>
<div className="min-w-0">
<h2 className="truncate text-base font-semibold text-slate-900 dark:text-white">业务链条</h2>
<p className="truncate text-[11px] text-slate-400 dark:text-[#8aa0c4]" aria-live="polite">
{chain?.title ?? (loading ? "正在加载管理员配置…" : "等待管理员配置")}
</p>
</div>
</div>
{chain && (
<button
type="button"
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-full border border-[#f0c8c8] bg-[#fff7f7] text-[#cc1925] transition-colors hover:border-[#cc1925] hover:bg-[#fff0f0] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#cc1925]/30 dark:border-[#2b8ffe]/45 dark:bg-[#082d5f] dark:text-[#8fc4ff]"
title="查看完整流程图"
aria-label="查看完整流程图"
onClick={() => setDialogOpen(true)}
>
<Search className="size-[18px]" strokeWidth={2.5} />
</button>
)}
</div>
{!chain ? (
<div className="flex min-h-0 flex-1 items-center justify-center rounded-lg border border-dashed border-[#e8d0cc] px-5 text-center text-xs leading-6 text-slate-400 dark:border-[#2b8ffe]/30 dark:text-[#8aa0c4]">
{loading ? "正在加载业务链条…" : "管理员尚未配置 8BF 业务链条"}
</div>
) : (
<>
<BusinessChainCanvas chain={chain} className="min-h-0 flex-1" />
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="flex h-[86vh] w-[96vw] max-w-[1180px] flex-col gap-0 overflow-hidden p-0">
<DialogHeader className="shrink-0 border-b px-5 py-4 dark:border-[#2b8ffe]/25">
<DialogTitle className="text-base font-semibold">{chain.title} · 业务链条流程图</DialogTitle>
</DialogHeader>
<div className="min-h-0 flex-1 p-4">
<BusinessChainCanvas chain={chain} expanded className="h-full" />
</div>
</DialogContent>
</Dialog>
</>
)}
</section>
);
}
function BusinessChainCanvas({
chain,
expanded = false,
className,
}: {
chain: BusinessChain;
expanded?: boolean;
className?: string;
}) {
const [view, setView] = useState({ tx: 0, ty: 0 });
const [panning, setPanning] = useState(false);
const dragRef = useRef<{ x: number; y: number; tx: number; ty: number } | null>(null);
const stages = getFlowStages(chain);
const nodeWidth = expanded ? 250 : FLOW_NODE_WIDTH;
const nodeGap = expanded ? 18 : FLOW_NODE_GAP;
let colorIndex = 0;
const endPan = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return;
dragRef.current = null;
setPanning(false);
try {
event.currentTarget.releasePointerCapture(event.pointerId);
} catch {
// The browser may have already released the pointer capture.
}
};
return (
<div
className={cn(
"relative overflow-hidden rounded-lg border border-slate-100 bg-slate-50/70 touch-none dark:border-[#2b8ffe]/25 dark:bg-[#032650]/75",
panning ? "cursor-grabbing" : "cursor-grab",
className,
)}
style={{
backgroundImage: "radial-gradient(rgba(148,163,184,.55) 1px, transparent 1px)",
backgroundSize: "16px 16px",
}}
title="按住鼠标拖动画布;双击复位"
onDoubleClick={() => setView({ tx: 0, ty: 0 })}
onPointerDown={(event) => {
if (event.button !== 0) return;
dragRef.current = { x: event.clientX, y: event.clientY, tx: view.tx, ty: view.ty };
setPanning(true);
event.currentTarget.setPointerCapture(event.pointerId);
}}
onPointerMove={(event) => {
const drag = dragRef.current;
if (!drag) return;
setView({ tx: drag.tx + event.clientX - drag.x, ty: drag.ty + event.clientY - drag.y });
}}
onPointerUp={endPan}
onPointerCancel={endPan}
>
<div
className="flex min-h-full min-w-max flex-col items-center px-5 py-6"
style={{ transform: `translate(${view.tx}px, ${view.ty}px)` }}
>
<FlowNode
title="任务理解智能体"
description="任务理解岗"
accent={NODE_COLORS[0]}
completed
width={nodeWidth}
/>
{stages.map((stage, stageIndex) => {
const stageWidth = Math.max(
nodeWidth,
stage.seats.length * (nodeWidth + nodeGap) - nodeGap,
);
return (
<div key={`${stage.goal ?? "stage"}-${stageIndex}`} className="flex flex-col items-center">
<FlowConnector
branches={stage.seats.length}
width={stageWidth}
nodeWidth={nodeWidth}
nodeGap={nodeGap}
/>
<div className="flex items-start justify-center" style={{ gap: nodeGap }}>
{stage.seats.map((seat) => {
const accent = NODE_COLORS[(colorIndex++ + 1) % NODE_COLORS.length]!;
return (
<FlowNode
key={seat.agent_id}
title={seat.name || seat.agent_id}
description={stage.goal?.trim() || seat.description?.trim() || "业务链席位"}
accent={accent}
width={nodeWidth}
/>
);
})}
</div>
</div>
);
})}
<FlowConnector branches={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
<FlowNode
title="方案总结智能体"
description="内置总结节点"
accent="#8b83f5"
width={nodeWidth}
/>
<FlowConnector branches={1} width={nodeWidth} nodeWidth={nodeWidth} nodeGap={nodeGap} />
<FlowNode
title="行动规划智能体"
description="内置规划节点"
accent="#78bce6"
width={nodeWidth}
/>
</div>
</div>
);
}
function FlowConnector({
branches,
width,
nodeWidth,
nodeGap,
}: {
branches: number;
width: number;
nodeWidth: number;
nodeGap: number;
}) {
const connectorStyle = { backgroundColor: "rgba(148,163,184,.88)" };
if (branches <= 1) {
return (
<span className="relative mb-1 h-8 w-0.5 rounded-full" style={connectorStyle}>
<span className="absolute bottom-1 left-1/2 size-2 -translate-x-1/2 rotate-45 border-b-2 border-r-2 border-slate-400" />
</span>
);
}
const nodeCenter = nodeWidth / 2;
return (
<div className="relative mb-1 h-9" style={{ width }}>
<span className="absolute left-1/2 top-0 h-3.5 w-0.5 -translate-x-1/2 rounded-full" style={connectorStyle} />
<span className="absolute top-3.5 h-0.5 rounded-full" style={{ left: nodeCenter, right: nodeCenter, ...connectorStyle }} />
{Array.from({ length: branches }).map((_, index) => (
<span
key={index}
className="absolute top-3.5 bottom-0 w-0.5 -translate-x-1/2 rounded-full"
style={{
left: `${((index * (nodeWidth + nodeGap) + nodeCenter) / width) * 100}%`,
...connectorStyle,
}}
>
<span className="absolute bottom-1 left-1/2 size-2 -translate-x-1/2 rotate-45 border-b-2 border-r-2 border-slate-400" />
</span>
))}
</div>
);
}
function FlowNode({
title,
description,
accent,
completed = false,
width,
}: {
title: string;
description: string;
accent: string;
completed?: boolean;
width: number;
}) {
const StatusIcon = completed ? CheckCircle2 : Circle;
return (
<article
className="relative overflow-hidden rounded-xl border border-slate-200 bg-white p-3 pr-10 shadow-sm dark:border-[#2b8ffe]/40 dark:bg-[#0a2d58]"
style={{ width, borderLeftWidth: 5, borderLeftColor: accent }}
>
<span
className={cn(
"absolute right-2 top-2 flex size-6 items-center justify-center rounded-full border bg-white shadow-sm dark:bg-[#0d3768]",
completed
? "border-emerald-500 text-emerald-500"
: "border-slate-300 text-slate-400 dark:border-[#8aa0c4] dark:text-[#8aa0c4]",
)}
>
<StatusIcon className="size-3.5" strokeWidth={2.5} />
</span>
<p className="truncate text-sm font-extrabold leading-5 text-slate-900 dark:text-[#e8edf5]" title={title}>
{title}
</p>
<p className="mt-0.5 truncate text-xs font-semibold leading-4" style={{ color: accent }} title={description}>
{description}
</p>
</article>
);
}