306 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|