/** * 岗位会商风格的只读业务链流程图。 * * 页面无关: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 (
{chain && ( )}
{!chain ? (
{loading ? "正在加载业务链条…" : "管理员尚未配置 8BF 业务链条"}
) : ( <> {chain.title} · 业务链条流程图
)}
); } 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) => { 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 (
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} >
{stages.map((stage, stageIndex) => { const stageWidth = Math.max( nodeWidth, stage.seats.length * (nodeWidth + nodeGap) - nodeGap, ); return (
{stage.seats.map((seat) => { const accent = NODE_COLORS[(colorIndex++ + 1) % NODE_COLORS.length]!; return ( ); })}
); })}
); } 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 ( ); } const nodeCenter = nodeWidth / 2; return (
{Array.from({ length: branches }).map((_, index) => ( ))}
); } function FlowNode({ title, description, accent, completed = false, width, }: { title: string; description: string; accent: string; completed?: boolean; width: number; }) { const StatusIcon = completed ? CheckCircle2 : Circle; return (

{title}

{description}

); }