import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { ReactFlow, ReactFlowProvider, Node, Edge, Background, Controls, Handle, Position, NodeProps, EdgeProps, useNodesState, useEdgesState, useReactFlow, OnConnect, addEdge, XYPosition, MarkerType, NodeResizer, BaseEdge, EdgeLabelRenderer, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import type { Project } from "../../lib/commands"; import { getCanvasState, saveCanvasState } from "../../lib/commands"; import { openUrl } from "@tauri-apps/plugin-opener"; // ── 类型 ───────────────────────────────────────────────────────── interface GroupMember { project: Project; role: string | null | undefined; } interface ProjectNodeData { project: Project; role?: string | null; [key: string]: unknown; } interface GitNodeData { label: string; url: string; projectName: string; [key: string]: unknown; } interface EnvNodeData { variant: "prod" | "staging"; url: string; projectName: string; [key: string]: unknown; } interface ServerNodeData { note: string; projectName: string; [key: string]: unknown; } interface FocusNodeData { text: string; projectName: string; [key: string]: unknown; } interface CustomNodeData { name: string; color: string; customItemId?: string; [key: string]: unknown; } interface ContainerChildNodeData { name: string; color: string; [key: string]: unknown; } const DEFAULT_CUSTOM_COLOR = "#8b5cf6"; const PRESET_COLORS = [ "#6b7280","#ef4444","#f97316","#eab308", "#22c55e","#06b6d4","#3b82f6","#8b5cf6","#ec4899", ]; interface SidebarItem { id: string; nodeType: string; label: string; sublabel?: string; data: Record; colorClass: string; } interface CustomItem { id: string; name: string; color: string; variant?: "node" | "container"; // 默认 "node" } // ── 节点删除按钮 ───────────────────────────────────────────────── function DeleteBtn({ nodeId }: { nodeId: string }) { const { deleteElements } = useReactFlow(); return ( ); } // ── 节点复制按钮(简单节点通用)────────────────────────────────── function CopyBtn({ nodeId }: { nodeId: string }) { const { getNode, addNodes } = useReactFlow(); const copy = (e: React.MouseEvent) => { e.stopPropagation(); const node = getNode(nodeId); if (!node) return; addNodes([{ ...node, id: `${node.type ?? "node"}-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`, position: { x: node.position.x + 20, y: node.position.y + 20 }, selected: false, }]); }; return ( ); } // ── 数据节点 ───────────────────────────────────────────────────── function ProjectNode({ id, data }: NodeProps) { const { project: p, role } = data as ProjectNodeData; return (

{p.name}

{p.platform === "wsl" ? "WSL" : "Windows"} {role && {role}} {p.status && {p.status}}
{p.tech_stack &&

{p.tech_stack}

}
); } function GitNode({ id, data }: NodeProps) { const { label, url, projectName } = data as GitNodeData; return (
openUrl(url).catch(() => {})}>

🔀 {label}

{projectName}

{url.replace(/^https?:\/\/(www\.)?/, "").replace(/\.git$/, "")}

); } function EnvNode({ id, data }: NodeProps) { const { variant, url, projectName } = data as EnvNodeData; const isProd = variant === "prod"; return (
openUrl(url).catch(() => {})}>

{isProd ? "🌐 生产" : "🧪 测试"}

{projectName}

{url.replace(/^https?:\/\//, "")}

); } function ServerNode({ id, data }: NodeProps) { const { note, projectName } = data as ServerNodeData; return (

🖥 Server

{projectName}

{note}

); } function FocusNode({ id, data }: NodeProps) { const { text, projectName } = data as FocusNodeData; return (

📌 当前焦点

{projectName}

{text}

); } // ── 自定义节点(画布上)────────────────────────────────────────── function CustomNode({ id, data }: NodeProps) { const { name, color } = data as CustomNodeData; const { updateNodeData, addNodes, addEdges, getNode, deleteElements } = useReactFlow(); const [editing, setEditing] = useState(false); const [editName, setEditName] = useState(name); const [editColor, setEditColor] = useState(color || DEFAULT_CUSTOM_COLOR); const c = color || DEFAULT_CUSTOM_COLOR; const startEdit = () => { setEditName(name); setEditColor(color || DEFAULT_CUSTOM_COLOR); setEditing(true); }; const commit = () => { const n = editName.trim(); if (!n) return; updateNodeData(id, { name: n, color: editColor }); setEditing(false); }; const cancel = () => setEditing(false); // 添加子节点:在当前节点正下方创建一个新自定义节点并连线 const addChildNode = (e: React.MouseEvent) => { e.stopPropagation(); const parent = getNode(id); if (!parent) return; const childId = `custom-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`; addNodes([{ id: childId, type: "custom", position: { x: parent.position.x, y: parent.position.y + 130 }, data: { name: "子节点", color: c }, }]); addEdges([{ id: `e-${id}-${childId}`, source: id, target: childId, sourceHandle: "bottom", targetHandle: "top", type: "labeled", data: { label: "", _smooth: true }, style: { stroke: c, strokeWidth: 1.5 }, markerEnd: { type: MarkerType.ArrowClosed, color: c, width: 12, height: 12 }, }]); }; const handleStyle = { width: 8, height: 8, background: c, border: "2px solid white", boxShadow: "0 0 0 1px " + c, }; if (editing) { return (
{/* 编辑态保留隐形 handle 以免断线 */}
setEditName(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") commit(); if (e.key === "Escape") cancel(); }} onPointerDown={(e) => e.stopPropagation()} className="text-[11px] border border-gray-200 rounded px-1.5 py-0.5 outline-none focus:ring-1 bg-white w-full" />
{PRESET_COLORS.map((pc) => (
); } return (
{/* 顶部:接受父节点连线(target) */} {/* 底部:发起子节点连线(source) */} {/* 左右保留隐形 handle 供通用连线使用 */} {/* hover 时顶部工具栏:编辑 / 复制 / 删除 */}

{name}

{/* 底部:添加子节点快捷按钮 */}
); } // ── 容器节点(大图,子节点在内部)──────────────────────────────── function ContainerNode({ id, data, selected }: NodeProps) { const { name, color } = data as CustomNodeData; const { updateNodeData, addNodes, getNodes, deleteElements } = useReactFlow(); const copyContainer = (e: React.MouseEvent) => { e.stopPropagation(); const allNodes = getNodes(); const self = allNodes.find((n) => n.id === id); if (!self) return; const newContainerId = `container-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`; const children = allNodes.filter((n) => n.parentId === id); addNodes([ { ...self, id: newContainerId, position: { x: self.position.x + 20, y: self.position.y + 20 }, selected: false, }, ...children.map((child, i) => ({ ...child, id: `cchild-${Date.now()}-${i}-${Math.random().toString(36).slice(2, 4)}`, parentId: newContainerId, })), ]); }; const [editing, setEditing] = useState(false); const [editName, setEditName] = useState(name); const c = color || DEFAULT_CUSTOM_COLOR; const commitEdit = () => { const n = editName.trim(); if (!n) { setEditing(false); return; } updateNodeData(id, { name: n }); setEditing(false); }; const addChild = (e: React.MouseEvent) => { e.stopPropagation(); const siblings = getNodes().filter((n) => n.parentId === id); const col = Math.floor(siblings.length / 3); const row = siblings.length % 3; addNodes([{ id: `cchild-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`, type: "containerChild", position: { x: 16 + col * 152, y: 52 + row * 52 }, parentId: id, extent: "parent" as const, data: { name: "子节点", color: c }, }]); }; return (
{/* 标题栏 */}
{editing ? ( setEditName(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") commitEdit(); if (e.key === "Escape") setEditing(false); }} onBlur={commitEdit} onPointerDown={(e) => e.stopPropagation()} className="flex-1 min-w-0 text-[12px] font-bold bg-transparent border-b outline-none" style={{ color: c, borderColor: c }} /> ) : ( { setEditName(name); setEditing(true); }} > {name} )} {/* 颜色色板(绝对定位,不占 flex 空间,hover 显示) */} {!editing && (
{PRESET_COLORS.map((pc) => (
)} {/* 操作按钮 */}
); } // ── 容器子节点(在容器内部)────────────────────────────────────── function ContainerChildNode({ id, data }: NodeProps) { const { name, color } = data as ContainerChildNodeData; const { updateNodeData, deleteElements, getNode, addNodes } = useReactFlow(); const copyChild = (e: React.MouseEvent) => { e.stopPropagation(); const node = getNode(id); if (!node) return; addNodes([{ ...node, id: `cchild-${Date.now()}-${Math.random().toString(36).slice(2, 4)}`, position: { x: node.position.x + 16, y: node.position.y + 16 }, selected: false, }]); }; const [editing, setEditing] = useState(false); const [editName, setEditName] = useState(name); const c = color || DEFAULT_CUSTOM_COLOR; const commit = () => { const n = editName.trim(); if (n) updateNodeData(id, { name: n }); setEditing(false); }; return (
{editing ? ( setEditName(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter" || e.key === "Escape") commit(); }} onBlur={commit} onPointerDown={(e) => e.stopPropagation()} className="flex-1 min-w-0 text-[11px] font-medium bg-transparent border-b outline-none" style={{ color: c, borderColor: c }} /> ) : ( { setEditName(name); setEditing(true); }} title={name} > {name} )}
); } // 必须定义在模块顶层 const nodeTypes = { project: ProjectNode, git: GitNode, env: EnvNode, server: ServerNode, focus: FocusNode, custom: CustomNode, container: ContainerNode, containerChild: ContainerChildNode, }; // ── 可编辑标签边(支持拖动路径 + 文本标签)───────────────────── function LabeledEdge({ id, sourceX, sourceY, targetX, targetY, style, markerEnd, data, }: EdgeProps) { const { setEdges, screenToFlowPosition } = useReactFlow(); const [editing, setEditing] = useState(false); const [draft, setDraft] = useState((data?.label as string) ?? ""); const label = (data?.label as string) ?? ""; const cpOffset = (data?.cpOffset as { x: number; y: number }) ?? { x: 0, y: 0 }; const strokeColor = (style?.stroke as string) ?? "#9ca3af"; // 控制点 = 中点 + 用户拖动偏移(flow 坐标) const cpX = (sourceX + targetX) / 2 + cpOffset.x; const cpY = (sourceY + targetY) / 2 + cpOffset.y; // 二次贝塞尔路径 const edgePath = `M ${sourceX} ${sourceY} Q ${cpX} ${cpY} ${targetX} ${targetY}`; // t=0.5 时的曲线坐标(标签位置) const labelX = (sourceX + 2 * cpX + targetX) / 4; const labelY = (sourceY + 2 * cpY + targetY) / 4; // 拖动状态(ref 避免重渲染) const dragStartFlow = useRef<{ x: number; y: number } | null>(null); const dragStartCp = useRef<{ x: number; y: number }>({ x: 0, y: 0 }); const onHandlePointerDown = (e: React.PointerEvent) => { e.stopPropagation(); dragStartFlow.current = screenToFlowPosition({ x: e.clientX, y: e.clientY }); dragStartCp.current = { ...cpOffset }; (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId); }; const onHandlePointerMove = (e: React.PointerEvent) => { if (!dragStartFlow.current) return; const cur = screenToFlowPosition({ x: e.clientX, y: e.clientY }); const dx = cur.x - dragStartFlow.current.x; const dy = cur.y - dragStartFlow.current.y; setEdges((eds) => eds.map((edge) => edge.id === id ? { ...edge, data: { ...edge.data, cpOffset: { x: dragStartCp.current.x + dx, y: dragStartCp.current.y + dy } } } : edge, ), ); }; const onHandlePointerUp = (e: React.PointerEvent) => { dragStartFlow.current = null; (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); }; const commitLabel = (value: string) => { setEdges((eds) => eds.map((e) => e.id === id ? { ...e, data: { ...e.data, label: value.trim() } } : e), ); setEditing(false); }; return ( <> {/* 路径拖动把手(悬停显示,双击恢复直线) */}
{ e.stopPropagation(); setEdges((eds) => eds.map((edge) => edge.id === id ? { ...edge, data: { ...edge.data, cpOffset: { x: 0, y: 0 } } } : edge, ), ); }} title="拖动调整路径 · 双击恢复直线" /> {/* 文本标签 */}
{editing ? ( setDraft(e.target.value)} onBlur={() => commitLabel(draft)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") commitLabel(draft); if (e.key === "Escape") { setDraft(label); setEditing(false); } }} onPointerDown={(e) => e.stopPropagation()} className="text-[10px] px-1.5 py-0.5 rounded border border-gray-300 bg-white outline-none focus:ring-1 focus:ring-blue-400 shadow-sm min-w-[60px] max-w-[160px]" style={{ color: strokeColor }} /> ) : label ? ( { setDraft(label); setEditing(true); }} className="text-[10px] px-1.5 py-0.5 rounded bg-white border border-gray-200 shadow-sm cursor-text select-none whitespace-nowrap" style={{ color: strokeColor }} title="双击编辑" > {label} ) : ( { setDraft(""); setEditing(true); }} className="text-[10px] px-1 py-0.5 rounded text-gray-300 hover:text-gray-400 hover:bg-white hover:border hover:border-gray-200 cursor-text select-none transition-colors" title="双击添加标签" > ✎ )}
); } const edgeTypes = { labeled: LabeledEdge, }; // ── 持久化类型 ──────────────────────────────────────────────────── interface PersistedCanvas { nodes: { id: string; type?: string; position: XYPosition; data: Record; style?: React.CSSProperties; parentId?: string; extent?: "parent"; }[]; edges: { id: string; source: string; target: string; style?: React.CSSProperties; type?: string; data?: Record; markerEnd?: unknown }[]; customItems: CustomItem[]; } // ── 生成资料列表 ────────────────────────────────────────────────── function buildSidebarItems(members: GroupMember[]): Record { const projects: SidebarItem[] = []; const gitItems: SidebarItem[] = []; const envItems: SidebarItem[] = []; const serverItems: SidebarItem[] = []; const focusItems: SidebarItem[] = []; members.forEach(({ project: p, role }) => { projects.push({ id: `s-proj-${p.id}`, nodeType: "project", label: p.name, sublabel: role ?? p.type ?? undefined, data: { project: p, role }, colorClass: "border-blue-200" }); if (p.repo_url) gitItems.push({ id: `s-git-${p.id}-o`, nodeType: "git", label: "origin", sublabel: p.name, data: { label: "origin", url: p.repo_url, projectName: p.name }, colorClass: "border-slate-200" }); if (p.upstream_url) gitItems.push({ id: `s-git-${p.id}-u`, nodeType: "git", label: "upstream", sublabel: p.name, data: { label: "upstream", url: p.upstream_url, projectName: p.name }, colorClass: "border-slate-200" }); if (p.prod_url) envItems.push({ id: `s-env-${p.id}-p`, nodeType: "env", label: "生产", sublabel: p.name, data: { variant: "prod", url: p.prod_url, projectName: p.name }, colorClass: "border-emerald-200" }); if (p.staging_url) envItems.push({ id: `s-env-${p.id}-s`, nodeType: "env", label: "测试", sublabel: p.name, data: { variant: "staging", url: p.staging_url, projectName: p.name }, colorClass: "border-amber-200" }); if (p.server_note) serverItems.push({ id: `s-srv-${p.id}`, nodeType: "server", label: "Server", sublabel: p.name, data: { note: p.server_note, projectName: p.name }, colorClass: "border-slate-200" }); if (p.recent_focus) focusItems.push({ id: `s-foc-${p.id}`, nodeType: "focus", label: "当前焦点", sublabel: p.name, data: { text: p.recent_focus, projectName: p.name }, colorClass: "border-indigo-200" }); }); const result: Record = {}; if (projects.length) result["🗂 项目"] = projects; if (gitItems.length) result["🔀 Git 仓库"] = gitItems; if (envItems.length) result["🌐 环境"] = envItems; if (serverItems.length) result["🖥 服务器"] = serverItems; if (focusItems.length) result["📌 焦点"] = focusItems; return result; } // ── 拖拽核心逻辑(pointer events,供两种卡片复用)──────────────── interface DragHandlers { onPointerDown: (e: React.PointerEvent) => void; onPointerMove: (e: React.PointerEvent) => void; onPointerUp: (e: React.PointerEvent) => void; onPointerCancel: (e: React.PointerEvent) => void; } function useDragToCanvas( ghostLabel: string, canvasRef: React.RefObject, screenToFlowPosition: (p: XYPosition) => XYPosition, onDrop: (nodeType: string, data: Record, position: XYPosition) => void, nodeType: string, data: Record, ): DragHandlers { const ghostRef = useRef(null); const activeRef = useRef(false); const cleanup = (el: HTMLDivElement | null) => { if (el && document.body.contains(el)) document.body.removeChild(el); }; const onPointerDown = (e: React.PointerEvent) => { e.currentTarget.setPointerCapture(e.pointerId); activeRef.current = true; const ghost = document.createElement("div"); ghost.style.cssText = [ "position:fixed","pointer-events:none","z-index:9999", "background:white","border:1.5px solid #a78bfa","border-radius:8px", "padding:4px 10px","font-size:11px","color:#1f2937", "box-shadow:0 4px 16px rgba(0,0,0,0.15)","white-space:nowrap", `left:${e.clientX + 12}px`,`top:${e.clientY + 12}px`, ].join(";"); ghost.textContent = ghostLabel; document.body.appendChild(ghost); ghostRef.current = ghost; }; const onPointerMove = (e: React.PointerEvent) => { if (!activeRef.current || !ghostRef.current) return; ghostRef.current.style.left = `${e.clientX + 12}px`; ghostRef.current.style.top = `${e.clientY + 12}px`; }; const onPointerUp = (e: React.PointerEvent) => { if (!activeRef.current) return; e.currentTarget.releasePointerCapture(e.pointerId); activeRef.current = false; cleanup(ghostRef.current); ghostRef.current = null; const canvas = canvasRef.current; if (!canvas) return; const rect = canvas.getBoundingClientRect(); if (e.clientX >= rect.left && e.clientX <= rect.right && e.clientY >= rect.top && e.clientY <= rect.bottom) { onDrop(nodeType, data, screenToFlowPosition({ x: e.clientX, y: e.clientY })); } }; const onPointerCancel = (e: React.PointerEvent) => { activeRef.current = false; cleanup(ghostRef.current); ghostRef.current = null; e.currentTarget.releasePointerCapture(e.pointerId); }; return { onPointerDown, onPointerMove, onPointerUp, onPointerCancel }; } // ── 资料列表卡片 ────────────────────────────────────────────────── interface DataItemCardProps { item: SidebarItem; canvasRef: React.RefObject; onDrop: (nodeType: string, data: Record, position: XYPosition) => void; screenToFlowPosition: (p: XYPosition) => XYPosition; } function DataItemCard({ item, canvasRef, onDrop, screenToFlowPosition }: DataItemCardProps) { const handlers = useDragToCanvas( item.sublabel ? `${item.label} ${item.sublabel}` : item.label, canvasRef, screenToFlowPosition, onDrop, item.nodeType, item.data, ); return (
{item.label} {item.sublabel && {item.sublabel}}
); } // ── 自定义节点列表卡片(支持编辑名称和颜色)──────────────────────── interface CustomItemCardProps { item: CustomItem; canvasRef: React.RefObject; onDrop: (nodeType: string, data: Record, position: XYPosition) => void; screenToFlowPosition: (p: XYPosition) => XYPosition; onRemove: (id: string) => void; onUpdate: (id: string, name: string, color: string) => void; } function CustomItemCard({ item, canvasRef, onDrop, screenToFlowPosition, onRemove, onUpdate }: CustomItemCardProps) { const [editing, setEditing] = useState(false); const [editName, setEditName] = useState(item.name); const [editColor, setEditColor] = useState(item.color); const c = item.color || DEFAULT_CUSTOM_COLOR; const isContainer = item.variant === "container"; const nodeType = isContainer ? "container" : "custom"; const handlers = useDragToCanvas( `${isContainer ? "▣" : "✦"} ${item.name}`, canvasRef, screenToFlowPosition, onDrop, nodeType, { name: item.name, color: item.color, customItemId: item.id }, ); const commit = () => { const name = editName.trim(); if (!name) return; onUpdate(item.id, name, editColor); setEditing(false); }; const cancel = () => { setEditName(item.name); setEditColor(item.color); setEditing(false); }; if (editing) { return (
{/* 名称输入 */} setEditName(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") cancel(); }} className="text-[11px] border border-gray-200 rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-violet-300 bg-white w-full" /> {/* 颜色色板 */}
{PRESET_COLORS.map((pc) => (
{/* 操作按钮 */}
); } return (
{/* 可拖拽主体 */}
{isContainer ? "▣" : "✦"} {item.name}
{/* 编辑按钮 */} {/* 删除按钮 */}
); } // ── Sidebar ─────────────────────────────────────────────────────── interface SidebarProps { groups: Record; customItems: CustomItem[]; canvasRef: React.RefObject; onDrop: (nodeType: string, data: Record, position: XYPosition) => void; screenToFlowPosition: (p: XYPosition) => XYPosition; onAddCustom: (name: string, variant?: "node" | "container") => void; onRemoveCustom: (id: string) => void; onUpdateCustom: (id: string, name: string, color: string) => void; } function ResourceSidebar({ groups, customItems, canvasRef, onDrop, screenToFlowPosition, onAddCustom, onRemoveCustom, onUpdateCustom }: SidebarProps) { const [input, setInput] = useState(""); const commit = (variant?: "node" | "container") => { const name = input.trim(); if (!name) return; onAddCustom(name, variant); setInput(""); }; const onKey = (e: React.KeyboardEvent) => { if (e.key === "Enter") commit("node"); }; return (
{/* ── 资料列表 ── */}

资料列表

{Object.entries(groups).map(([category, items]) => (

{category}

{items.map((item) => ( ))}
))} {Object.keys(groups).length === 0 && (

暂无项目资料

)}
{/* ── 分割线 ── */}
自定义
{/* ── 自定义节点 ── */}

自定义节点

{/* 添加输入框 */}
setInput(e.target.value)} onKeyDown={onKey} placeholder="名称…" className="flex-1 min-w-0 border border-violet-200 rounded-md px-2 py-1 text-[11px] outline-none focus:ring-1 focus:ring-violet-300 bg-white placeholder-gray-300" />
{/* 自定义节点列表 */} {customItems.map((item) => ( ))} {customItems.length === 0 && (

输入名称后回车添加

)}
); } // ── 树形布局算法(仅针对自定义节点) ───────────────────────────────── function computeTreeLayout(nodes: Node[], edges: Edge[]): Map { const customIds = new Set(nodes.filter((n) => n.type === "custom").map((n) => n.id)); if (customIds.size === 0) return new Map(); const childrenOf = new Map(); const parentsOf = new Map(); for (const e of edges) { if (customIds.has(e.source) && customIds.has(e.target)) { childrenOf.set(e.source, [...(childrenOf.get(e.source) ?? []), e.target]); parentsOf.set(e.target, [...(parentsOf.get(e.target) ?? []), e.source]); } } // 根节点 = 没有自定义父节点的自定义节点 const roots = [...customIds].filter((id) => !parentsOf.has(id)); if (roots.length === 0) return new Map(); // 存在环,跳过 // BFS 分配层级 const level = new Map(); const queue: string[] = [...roots]; roots.forEach((r) => level.set(r, 0)); while (queue.length > 0) { const cur = queue.shift()!; for (const child of childrenOf.get(cur) ?? []) { if (!level.has(child)) { level.set(child, level.get(cur)! + 1); queue.push(child); } } } // 按层分组,居中排列 const byLevel = new Map(); for (const [id, lv] of level) { byLevel.set(lv, [...(byLevel.get(lv) ?? []), id]); } const H_GAP = 200, V_GAP = 130; const positions = new Map(); for (const [lv, ids] of byLevel) { const totalW = (ids.length - 1) * H_GAP; ids.forEach((id, i) => positions.set(id, { x: i * H_GAP - totalW / 2, y: lv * V_GAP })); } return positions; } // ── CanvasInner ─────────────────────────────────────────────────── function CanvasInner({ groups, groupId }: { groups: Record; groupId: string }) { const [initialized, setInitialized] = useState(false); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); const [customItems, setCustomItems] = useState([]); const { screenToFlowPosition, getNode, deleteElements } = useReactFlow(); const canvasRef = useRef(null); const saveTimerRef = useRef | null>(null); const [edgeMenu, setEdgeMenu] = useState<{ edgeId: string; x: number; y: number } | null>(null); // 点击其他地方关闭右键菜单 useEffect(() => { const close = () => setEdgeMenu(null); window.addEventListener("click", close); return () => window.removeEventListener("click", close); }, []); // 初始加载(从数据库) useEffect(() => { getCanvasState(groupId).then((raw) => { if (raw) { try { const parsed = JSON.parse(raw) as PersistedCanvas; setNodes((parsed.nodes ?? []) as Node[]); setEdges((parsed.edges ?? []) as Edge[]); setCustomItems(parsed.customItems ?? []); } catch { /* 数据损坏时忽略 */ } } setInitialized(true); }).catch(() => setInitialized(true)); }, [groupId]); // 防抖保存(加载完成后才监听变化) useEffect(() => { if (!initialized) return; if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { const state: PersistedCanvas = { nodes: nodes.map(({ id, type, position, data, style, parentId, extent }) => ({ id, type, position, data: data as Record, ...(style ? { style } : {}), ...(parentId ? { parentId } : {}), ...(extent === "parent" ? { extent: "parent" as const } : {}), })), edges: edges.map(({ id, source, target, style, type, data, markerEnd }) => ({ id, source, target, style, type, data: data as Record | undefined, markerEnd })), customItems, }; saveCanvasState(groupId, JSON.stringify(state)).catch(() => {}); }, 800); return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; }, [nodes, edges, customItems, groupId, initialized]); // 自定义节点之间的连线用彩色箭头,其余用灰色 const onConnect: OnConnect = useCallback( (conn) => { if (conn.source === conn.target) return; // 禁止自环 const src = getNode(conn.source); const tgt = getNode(conn.target); const bothCustom = src?.type === "custom" && tgt?.type === "custom"; const edgeColor = bothCustom ? ((src?.data as CustomNodeData).color || DEFAULT_CUSTOM_COLOR) : "#d1d5db"; setEdges((eds) => addEdge({ ...conn, type: "labeled", data: { label: "", _smooth: bothCustom }, style: { stroke: edgeColor, strokeWidth: 1.5 }, markerEnd: bothCustom ? { type: MarkerType.ArrowClosed, color: edgeColor, width: 12, height: 12 } : undefined, }, eds)); }, [setEdges, getNode], ); // 一键整理:将所有自定义节点按父子层级重新排布 const applyTreeLayout = useCallback(() => { const positions = computeTreeLayout(nodes, edges); if (positions.size === 0) return; setNodes((nds) => nds.map((n) => { const pos = positions.get(n.id); return pos ? { ...n, position: pos } : n; })); }, [nodes, edges, setNodes]); const handleDrop = useCallback( (nodeType: string, data: Record, position: XYPosition) => { const isContainer = nodeType === "container"; setNodes((nds) => nds.concat({ id: `${nodeType}-${Date.now()}-${Math.random().toString(36).slice(2, 5)}`, type: nodeType, position, data, // 容器节点需要初始尺寸,子节点才能正确定位 ...(isContainer ? { style: { width: 300, height: 220 } } : {}), })); }, [setNodes], ); const addCustomItem = useCallback((name: string, variant?: "node" | "container") => { setCustomItems((prev) => [...prev, { id: `ci-${Date.now()}`, name, color: DEFAULT_CUSTOM_COLOR, variant: variant ?? "node" }]); }, []); const removeCustomItem = useCallback((id: string) => { setCustomItems((prev) => prev.filter((c) => c.id !== id)); }, []); // 更新名称/颜色,同步更新画布上已放置的同源节点 const updateCustomItem = useCallback((id: string, name: string, color: string) => { setCustomItems((prev) => prev.map((c) => c.id === id ? { ...c, name, color } : c)); setNodes((nds) => nds.map((n) => n.type === "custom" && (n.data as CustomNodeData).customItemId === id ? { ...n, data: { ...n.data, name, color } } : n )); }, [setCustomItems, setNodes]); return (
{ e.preventDefault(); setEdgeMenu({ edgeId: edge.id, x: e.clientX, y: e.clientY }); }} nodesDraggable panOnDrag zoomOnScroll minZoom={0.3} maxZoom={2} proOptions={{ hideAttribution: true }} deleteKeyCode="Delete" > {nodes.length === 0 && (

🗂

从左侧拖入资源,自由排列

)}
{/* 边右键菜单 */} {edgeMenu && (
e.stopPropagation()} >
)} {nodes.length > 0 && (
{nodes.some((n) => n.type === "custom") && ( )}
)}
); } // ── 导出 ───────────────────────────────────────────────────────── interface Props { members: GroupMember[]; groupId: string; } export function GroupResourceCanvas({ members, groupId }: Props) { const groups = useMemo(() => buildSidebarItems(members), [members]); return (
); }