dev-manager-tauri/src/components/dashboard/GroupResourceCanvas.tsx
lanrtop a2596413c5 feat: add copy functionality for nodes and containers in GroupResourceCanvas
- Implemented CopyBtn component to allow copying of nodes with a new unique ID and adjusted position.
- Added copy functionality to ProjectNode, GitNode, EnvNode, ServerNode, FocusNode, CustomNode, and ContainerNode.
- Enhanced ContainerNode to copy itself along with its children nodes.
- Introduced a new LabeledEdge component for editable edges with drag-and-drop support for path adjustment and text labels.
- Added right-click context menu for edges to delete connections.
- Updated data structures to support new properties for tasks in BlueprintTask and BlueprintStats.
2026-04-03 08:42:17 +09:00

1368 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<string, unknown>;
colorClass: string;
}
interface CustomItem {
id: string;
name: string;
color: string;
variant?: "node" | "container"; // 默认 "node"
}
// ── 节点删除按钮 ─────────────────────────────────────────────────
function DeleteBtn({ nodeId }: { nodeId: string }) {
const { deleteElements } = useReactFlow();
return (
<button
onClick={() => deleteElements({ nodes: [{ id: nodeId }] })}
className="absolute -top-1.5 -right-1.5 w-4 h-4 rounded-full bg-gray-400 hover:bg-red-500 text-white text-[9px] flex items-center justify-center shadow transition-colors z-10 opacity-0 group-hover:opacity-100"
>
×
</button>
);
}
// ── 节点复制按钮(简单节点通用)──────────────────────────────────
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 (
<button
onClick={copy}
className="absolute -top-1.5 right-4 w-4 h-4 rounded-full bg-gray-400 hover:bg-blue-500 text-white text-[9px] flex items-center justify-center shadow transition-colors z-10 opacity-0 group-hover:opacity-100"
title="复制"
>
</button>
);
}
// ── 数据节点 ─────────────────────────────────────────────────────
function ProjectNode({ id, data }: NodeProps) {
const { project: p, role } = data as ProjectNodeData;
return (
<div className="group relative bg-white border-2 border-blue-300 rounded-xl shadow-sm w-48 select-none">
<DeleteBtn nodeId={id} />
<CopyBtn nodeId={id} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<div className="px-3 pt-2.5 pb-2">
<p className="text-xs font-bold text-gray-800 truncate" title={p.name}>{p.name}</p>
<div className="flex flex-wrap items-center gap-1 mt-1.5">
<span className={`text-[10px] px-1.5 py-px rounded font-medium ${p.platform === "wsl" ? "bg-green-50 text-green-600 border border-green-200" : "bg-blue-50 text-blue-600 border border-blue-200"}`}>
{p.platform === "wsl" ? "WSL" : "Windows"}
</span>
{role && <span className="text-[10px] px-1.5 py-px rounded bg-purple-50 text-purple-600 border border-purple-100">{role}</span>}
{p.status && <span className="text-[10px] px-1.5 py-px rounded bg-gray-50 text-gray-500 border border-gray-200">{p.status}</span>}
</div>
{p.tech_stack && <p className="text-[10px] text-gray-400 mt-1.5 truncate" title={p.tech_stack}>{p.tech_stack}</p>}
</div>
</div>
);
}
function GitNode({ id, data }: NodeProps) {
const { label, url, projectName } = data as GitNodeData;
return (
<div className="group relative bg-slate-50 border border-slate-300 rounded-lg shadow-sm w-44 select-none cursor-pointer hover:shadow-md transition-shadow"
onClick={() => openUrl(url).catch(() => {})}>
<DeleteBtn nodeId={id} />
<CopyBtn nodeId={id} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<div className="px-3 py-2">
<p className="text-[10px] font-bold text-slate-400 uppercase tracking-wide mb-0.5">🔀 {label}</p>
<p className="text-[10px] text-slate-500 truncate" title={projectName}>{projectName}</p>
<p className="text-[9px] text-slate-400 truncate mt-0.5" title={url}>{url.replace(/^https?:\/\/(www\.)?/, "").replace(/\.git$/, "")}</p>
</div>
</div>
);
}
function EnvNode({ id, data }: NodeProps) {
const { variant, url, projectName } = data as EnvNodeData;
const isProd = variant === "prod";
return (
<div className={`group relative rounded-lg shadow-sm border w-44 select-none cursor-pointer hover:shadow-md transition-shadow ${isProd ? "bg-emerald-50 border-emerald-300" : "bg-amber-50 border-amber-300"}`}
onClick={() => openUrl(url).catch(() => {})}>
<DeleteBtn nodeId={id} />
<CopyBtn nodeId={id} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<div className="px-3 py-2">
<p className={`text-[10px] font-bold uppercase tracking-wide mb-0.5 ${isProd ? "text-emerald-600" : "text-amber-600"}`}>{isProd ? "🌐 生产" : "🧪 测试"}</p>
<p className="text-[10px] text-gray-600 truncate" title={projectName}>{projectName}</p>
<p className={`text-[9px] truncate mt-0.5 ${isProd ? "text-emerald-700" : "text-amber-700"}`} title={url}>{url.replace(/^https?:\/\//, "")}</p>
</div>
</div>
);
}
function ServerNode({ id, data }: NodeProps) {
const { note, projectName } = data as ServerNodeData;
return (
<div className="group relative bg-slate-50 border border-slate-300 rounded-lg shadow-sm w-44 select-none">
<DeleteBtn nodeId={id} />
<CopyBtn nodeId={id} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<div className="px-3 py-2">
<p className="text-[10px] font-bold text-slate-500 uppercase tracking-wide mb-0.5">🖥 Server</p>
<p className="text-[10px] text-slate-500 truncate" title={projectName}>{projectName}</p>
<p className="text-[10px] text-slate-600 mt-0.5 line-clamp-2" title={note}>{note}</p>
</div>
</div>
);
}
function FocusNode({ id, data }: NodeProps) {
const { text, projectName } = data as FocusNodeData;
return (
<div className="group relative bg-indigo-50 border border-indigo-200 rounded-lg shadow-sm w-48 select-none">
<DeleteBtn nodeId={id} />
<CopyBtn nodeId={id} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<div className="px-3 py-2">
<p className="text-[10px] font-bold text-indigo-500 uppercase tracking-wide mb-0.5">📌 </p>
<p className="text-[10px] text-indigo-600 truncate" title={projectName}>{projectName}</p>
<p className="text-[10px] text-indigo-700 mt-0.5 line-clamp-3" title={text}>{text}</p>
</div>
</div>
);
}
// ── 自定义节点(画布上)──────────────────────────────────────────
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 (
<div
className="relative rounded-xl shadow-md w-44 bg-white"
style={{ border: `2px dashed ${editColor}` }}
>
{/* 编辑态保留隐形 handle 以免断线 */}
<Handle id="top" type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle id="bottom" type="source" position={Position.Bottom} style={{ opacity: 0 }} />
<Handle id="left" type="target" position={Position.Left} style={{ opacity: 0 }} />
<Handle id="right" type="source" position={Position.Right} style={{ opacity: 0 }} />
<div className="px-3 py-2.5 flex flex-col gap-2">
<input
autoFocus
value={editName}
onChange={(e) => 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"
/>
<div className="flex items-center gap-1 flex-wrap">
{PRESET_COLORS.map((pc) => (
<button
key={pc}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => setEditColor(pc)}
className="w-4 h-4 rounded-full transition-all"
style={{
backgroundColor: pc,
outline: editColor === pc ? `2px solid ${pc}` : "2px solid transparent",
outlineOffset: "2px",
transform: editColor === pc ? "scale(1.2)" : "scale(1)",
}}
/>
))}
</div>
<div className="flex gap-1">
<button onPointerDown={(e) => e.stopPropagation()} onClick={commit}
className="flex-1 text-[10px] text-white rounded py-0.5" style={{ background: editColor }}>
</button>
<button onPointerDown={(e) => e.stopPropagation()} onClick={cancel}
className="flex-1 text-[10px] border border-gray-200 text-gray-500 rounded py-0.5 hover:bg-gray-50">
</button>
</div>
</div>
</div>
);
}
return (
<div
className="group relative rounded-xl shadow-sm w-40 select-none"
style={{ border: `2px dashed ${c}`, background: `${c}14` }}
>
{/* 顶部接受父节点连线target */}
<Handle id="top" type="target" position={Position.Top}
className="opacity-30 group-hover:opacity-100 transition-opacity"
style={{ ...handleStyle, top: -4 }} />
{/* 底部发起子节点连线source */}
<Handle id="bottom" type="source" position={Position.Bottom}
className="opacity-30 group-hover:opacity-100 transition-opacity"
style={{ ...handleStyle, bottom: -4 }} />
{/* 左右保留隐形 handle 供通用连线使用 */}
<Handle id="left" type="target" position={Position.Left} style={{ opacity: 0 }} />
<Handle id="right" type="source" position={Position.Right} style={{ opacity: 0 }} />
{/* hover 时顶部工具栏:编辑 / 复制 / 删除 */}
<div className="absolute -top-6 left-1/2 -translate-x-1/2 flex items-center gap-px bg-white border border-gray-200 rounded-full shadow px-1 py-0.5 opacity-0 group-hover:opacity-100 transition-opacity z-20">
<button
onClick={startEdit}
className="w-5 h-5 rounded-full hover:bg-violet-50 text-gray-400 hover:text-violet-500 text-[10px] flex items-center justify-center transition-colors"
title="编辑"
></button>
<button
onClick={(e) => {
e.stopPropagation();
const node = getNode(id);
if (!node) return;
addNodes([{ ...node, id: `custom-${Date.now()}-${Math.random().toString(36).slice(2,4)}`, position: { x: node.position.x + 20, y: node.position.y + 20 }, selected: false }]);
}}
className="w-5 h-5 rounded-full hover:bg-blue-50 text-gray-400 hover:text-blue-500 text-[10px] flex items-center justify-center transition-colors"
title="复制"
></button>
<button
onClick={(e) => { e.stopPropagation(); deleteElements({ nodes: [{ id }] }); }}
className="w-5 h-5 rounded-full hover:bg-red-50 text-gray-400 hover:text-red-400 text-[10px] flex items-center justify-center transition-colors"
title="删除"
>×</button>
</div>
<div className="px-3 py-2.5 flex items-center gap-2">
<span className="text-sm shrink-0" style={{ color: c }}></span>
<p className="text-[11px] font-semibold truncate" style={{ color: c }} title={name}>{name}</p>
</div>
{/* 底部:添加子节点快捷按钮 */}
<button
onClick={addChildNode}
className="absolute -bottom-3.5 left-1/2 -translate-x-1/2 w-5 h-5 rounded-full bg-white border-2 text-[11px] font-bold flex items-center justify-center shadow transition-all z-10 opacity-0 group-hover:opacity-100 hover:scale-110"
style={{ borderColor: c, color: c }}
title="添加子节点"
>
+
</button>
</div>
);
}
// ── 容器节点(大图,子节点在内部)────────────────────────────────
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 (
<div
className="group/container relative rounded-2xl overflow-visible"
style={{ width: "100%", height: "100%", background: `${c}0c`, border: `2px solid ${c}` }}
>
<NodeResizer
color={c}
isVisible={selected}
minWidth={220}
minHeight={160}
handleStyle={{ width: 8, height: 8 }}
/>
<Handle type="target" position={Position.Top} style={{ opacity: 0 }} />
<Handle type="source" position={Position.Bottom} style={{ opacity: 0 }} />
{/* 标题栏 */}
<div
className="flex items-center gap-2 px-3 py-2 rounded-t-2xl"
style={{ background: `${c}22`, borderBottom: `1px solid ${c}44` }}
>
<span className="text-base leading-none" style={{ color: c }}></span>
{editing ? (
<input
autoFocus
value={editName}
onChange={(e) => 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 }}
/>
) : (
<span
className="flex-1 min-w-0 text-[12px] font-bold truncate cursor-text"
style={{ color: c }}
onDoubleClick={() => { setEditName(name); setEditing(true); }}
>
{name}
</span>
)}
{/* 颜色色板(绝对定位,不占 flex 空间hover 显示) */}
{!editing && (
<div className="absolute left-8 top-full mt-1 flex items-center gap-0.5 px-1.5 py-1 rounded-lg shadow-md opacity-0 group-hover/container:opacity-100 transition-opacity pointer-events-none group-hover/container:pointer-events-auto z-20"
style={{ background: `${c}22`, border: `1px solid ${c}44` }}>
{PRESET_COLORS.map((pc) => (
<button
key={pc}
onPointerDown={(e) => e.stopPropagation()}
onClick={() => updateNodeData(id, { color: pc })}
className="w-3 h-3 rounded-full transition-transform hover:scale-125"
style={{
backgroundColor: pc,
outline: c === pc ? `2px solid ${pc}` : "none",
outlineOffset: "1px",
}}
/>
))}
</div>
)}
{/* 操作按钮 */}
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={addChild}
className="shrink-0 w-5 h-5 rounded-full text-white text-sm font-bold flex items-center justify-center transition-colors"
style={{ background: c }}
title="添加子区域"
>+</button>
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={copyContainer}
className="shrink-0 w-4 h-4 rounded-full bg-gray-200 hover:bg-blue-400 text-white text-[9px] flex items-center justify-center transition-colors"
title="复制容器"
></button>
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={() => deleteElements({ nodes: [{ id }] })}
className="shrink-0 w-4 h-4 rounded-full bg-gray-200 hover:bg-red-400 text-white text-[9px] flex items-center justify-center transition-colors"
title="删除"
>×</button>
</div>
</div>
);
}
// ── 容器子节点(在容器内部)──────────────────────────────────────
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 (
<div
className="group/child relative flex items-center gap-2 px-3 py-2 rounded-xl select-none"
style={{ border: `1.5px solid ${c}`, background: `${c}18`, minWidth: 120 }}
>
<Handle type="target" position={Position.Left} style={{ opacity: 0 }} />
<Handle type="source" position={Position.Right} style={{ opacity: 0 }} />
<span className="text-[10px] shrink-0" style={{ color: c }}></span>
{editing ? (
<input
autoFocus
value={editName}
onChange={(e) => 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 }}
/>
) : (
<span
className="flex-1 min-w-0 text-[11px] font-medium truncate cursor-text"
style={{ color: c }}
onDoubleClick={() => { setEditName(name); setEditing(true); }}
title={name}
>
{name}
</span>
)}
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={copyChild}
className="shrink-0 w-3.5 h-3.5 rounded-full bg-gray-200 hover:bg-blue-400 text-white text-[8px] flex items-center justify-center transition-colors opacity-0 group-hover/child:opacity-100"
title="复制"
></button>
<button
onPointerDown={(e) => e.stopPropagation()}
onClick={() => deleteElements({ nodes: [{ id }] })}
className="shrink-0 w-3.5 h-3.5 rounded-full bg-gray-200 hover:bg-red-400 text-white text-[8px] flex items-center justify-center transition-colors opacity-0 group-hover/child:opacity-100"
>×</button>
</div>
);
}
// 必须定义在模块顶层
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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 (
<>
<BaseEdge id={id} path={edgePath} style={style} markerEnd={markerEnd} />
<EdgeLabelRenderer>
{/* 路径拖动把手(悬停显示,双击恢复直线) */}
<div
style={{
transform: `translate(-50%,-50%) translate(${cpX}px,${cpY}px)`,
pointerEvents: "all",
borderColor: strokeColor,
}}
className="absolute nodrag nopan w-3 h-3 rounded-full bg-white border-2 cursor-grab active:cursor-grabbing opacity-20 hover:opacity-100 transition-opacity z-10"
onPointerDown={onHandlePointerDown}
onPointerMove={onHandlePointerMove}
onPointerUp={onHandlePointerUp}
onDoubleClick={(e) => {
e.stopPropagation();
setEdges((eds) =>
eds.map((edge) =>
edge.id === id ? { ...edge, data: { ...edge.data, cpOffset: { x: 0, y: 0 } } } : edge,
),
);
}}
title="拖动调整路径 · 双击恢复直线"
/>
{/* 文本标签 */}
<div
style={{
transform: `translate(-50%,-50%) translate(${labelX}px,${labelY}px)`,
pointerEvents: "all",
}}
className="absolute nodrag nopan"
>
{editing ? (
<input
autoFocus
value={draft}
onChange={(e) => 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 ? (
<span
onDoubleClick={() => { 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}
</span>
) : (
<span
onDoubleClick={() => { 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="双击添加标签"
>
</span>
)}
</div>
</EdgeLabelRenderer>
</>
);
}
const edgeTypes = {
labeled: LabeledEdge,
};
// ── 持久化类型 ────────────────────────────────────────────────────
interface PersistedCanvas {
nodes: {
id: string; type?: string; position: XYPosition; data: Record<string, unknown>;
style?: React.CSSProperties;
parentId?: string;
extent?: "parent";
}[];
edges: { id: string; source: string; target: string; style?: React.CSSProperties; type?: string; data?: Record<string, unknown>; markerEnd?: unknown }[];
customItems: CustomItem[];
}
// ── 生成资料列表 ──────────────────────────────────────────────────
function buildSidebarItems(members: GroupMember[]): Record<string, SidebarItem[]> {
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<string, SidebarItem[]> = {};
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<HTMLDivElement>) => void;
onPointerMove: (e: React.PointerEvent<HTMLDivElement>) => void;
onPointerUp: (e: React.PointerEvent<HTMLDivElement>) => void;
onPointerCancel: (e: React.PointerEvent<HTMLDivElement>) => void;
}
function useDragToCanvas(
ghostLabel: string,
canvasRef: React.RefObject<HTMLDivElement | null>,
screenToFlowPosition: (p: XYPosition) => XYPosition,
onDrop: (nodeType: string, data: Record<string, unknown>, position: XYPosition) => void,
nodeType: string,
data: Record<string, unknown>,
): DragHandlers {
const ghostRef = useRef<HTMLDivElement | null>(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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement>) => {
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<HTMLDivElement | null>;
onDrop: (nodeType: string, data: Record<string, unknown>, 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 (
<div
{...handlers}
style={{ cursor: "grab", touchAction: "none", userSelect: "none" }}
className={`flex flex-col px-2.5 py-1.5 rounded-lg border bg-white hover:shadow-sm hover:border-blue-300 transition-shadow ${item.colorClass}`}
>
<span className="text-[11px] font-semibold text-gray-700 truncate">{item.label}</span>
{item.sublabel && <span className="text-[10px] text-gray-400 truncate mt-px">{item.sublabel}</span>}
</div>
);
}
// ── 自定义节点列表卡片(支持编辑名称和颜色)────────────────────────
interface CustomItemCardProps {
item: CustomItem;
canvasRef: React.RefObject<HTMLDivElement | null>;
onDrop: (nodeType: string, data: Record<string, unknown>, 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 (
<div className="flex flex-col gap-1.5 px-2.5 py-2 rounded-lg border-2 border-dashed bg-white" style={{ borderColor: editColor }}>
{/* 名称输入 */}
<input
autoFocus
value={editName}
onChange={(e) => 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"
/>
{/* 颜色色板 */}
<div className="flex items-center gap-1 flex-wrap">
{PRESET_COLORS.map((pc) => (
<button
key={pc}
onClick={() => setEditColor(pc)}
className="w-4 h-4 rounded-full transition-all"
style={{
backgroundColor: pc,
outline: editColor === pc ? `2px solid ${pc}` : "2px solid transparent",
outlineOffset: "2px",
transform: editColor === pc ? "scale(1.2)" : "scale(1)",
}}
/>
))}
</div>
{/* 操作按钮 */}
<div className="flex gap-1">
<button
onClick={commit}
className="flex-1 text-[10px] text-white rounded py-0.5 transition-colors"
style={{ background: editColor }}
>
</button>
<button
onClick={cancel}
className="flex-1 text-[10px] border border-gray-200 text-gray-500 rounded py-0.5 hover:bg-gray-50"
>
</button>
</div>
</div>
);
}
return (
<div className="flex items-center gap-1 group/custom">
{/* 可拖拽主体 */}
<div
{...handlers}
style={{ cursor: "grab", touchAction: "none", userSelect: "none", borderColor: c }}
className="flex-1 min-w-0 flex items-center gap-1.5 px-2.5 py-1.5 rounded-lg border-2 border-dashed hover:shadow-sm transition-shadow"
>
<span className="text-[10px] shrink-0" style={{ color: c }}>{isContainer ? "▣" : "✦"}</span>
<span className="text-[11px] font-medium truncate" style={{ color: c }}>{item.name}</span>
</div>
{/* 编辑按钮 */}
<button
onClick={() => { setEditName(item.name); setEditColor(item.color); setEditing(true); }}
className="shrink-0 w-4 h-4 flex items-center justify-center text-gray-300 hover:text-violet-500 opacity-0 group-hover/custom:opacity-100 transition-opacity text-xs"
title="编辑"
>
</button>
{/* 删除按钮 */}
<button
onClick={() => onRemove(item.id)}
className="shrink-0 w-4 h-4 flex items-center justify-center text-gray-300 hover:text-red-400 opacity-0 group-hover/custom:opacity-100 transition-opacity text-xs"
title="移除"
>
×
</button>
</div>
);
}
// ── Sidebar ───────────────────────────────────────────────────────
interface SidebarProps {
groups: Record<string, SidebarItem[]>;
customItems: CustomItem[];
canvasRef: React.RefObject<HTMLDivElement | null>;
onDrop: (nodeType: string, data: Record<string, unknown>, 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<HTMLInputElement>) => {
if (e.key === "Enter") commit("node");
};
return (
<div className="w-44 shrink-0 border-r border-gray-100 overflow-y-auto flex flex-col bg-gray-50/60">
{/* ── 资料列表 ── */}
<div className="flex flex-col gap-3 p-3">
<p className="text-[10px] font-semibold text-gray-400 uppercase tracking-widest"></p>
{Object.entries(groups).map(([category, items]) => (
<div key={category} className="flex flex-col gap-1.5">
<p className="text-[10px] font-semibold text-gray-500">{category}</p>
{items.map((item) => (
<DataItemCard
key={item.id}
item={item}
canvasRef={canvasRef}
onDrop={onDrop}
screenToFlowPosition={screenToFlowPosition}
/>
))}
</div>
))}
{Object.keys(groups).length === 0 && (
<p className="text-[10px] text-gray-400 text-center mt-2"></p>
)}
</div>
{/* ── 分割线 ── */}
<div className="flex items-center gap-2 px-3 py-2">
<div className="flex-1 h-px bg-gray-200" />
<span className="text-[9px] text-gray-300 shrink-0"></span>
<div className="flex-1 h-px bg-gray-200" />
</div>
{/* ── 自定义节点 ── */}
<div className="flex flex-col gap-2 px-3 pb-4">
<p className="text-[10px] font-semibold text-violet-400 uppercase tracking-widest"></p>
{/* 添加输入框 */}
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1">
<input
value={input}
onChange={(e) => 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"
/>
<button
onClick={() => commit("node")}
disabled={!input.trim()}
title="添加普通节点"
className="shrink-0 w-6 h-6 rounded-md bg-violet-500 hover:bg-violet-600 disabled:bg-gray-200 disabled:cursor-not-allowed text-white text-sm flex items-center justify-center transition-colors"
></button>
</div>
<button
onClick={() => commit("container")}
disabled={!input.trim()}
className="flex items-center justify-center gap-1 text-[10px] border border-dashed border-violet-300 rounded-md py-1 text-violet-500 hover:bg-violet-50 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<span></span>
</button>
</div>
{/* 自定义节点列表 */}
{customItems.map((item) => (
<CustomItemCard
key={item.id}
item={item}
canvasRef={canvasRef}
onDrop={onDrop}
screenToFlowPosition={screenToFlowPosition}
onRemove={onRemoveCustom}
onUpdate={onUpdateCustom}
/>
))}
{customItems.length === 0 && (
<p className="text-[10px] text-gray-300 text-center mt-1"></p>
)}
</div>
</div>
);
}
// ── 树形布局算法(仅针对自定义节点) ─────────────────────────────────
function computeTreeLayout(nodes: Node[], edges: Edge[]): Map<string, XYPosition> {
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<string, string[]>();
const parentsOf = new Map<string, string[]>();
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<string, number>();
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<number, string[]>();
for (const [id, lv] of level) {
byLevel.set(lv, [...(byLevel.get(lv) ?? []), id]);
}
const H_GAP = 200, V_GAP = 130;
const positions = new Map<string, XYPosition>();
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<string, SidebarItem[]>; groupId: string }) {
const [initialized, setInitialized] = useState(false);
const [nodes, setNodes, onNodesChange] = useNodesState<Node>([]);
const [edges, setEdges, onEdgesChange] = useEdgesState<Edge>([]);
const [customItems, setCustomItems] = useState<CustomItem[]>([]);
const { screenToFlowPosition, getNode, deleteElements } = useReactFlow();
const canvasRef = useRef<HTMLDivElement>(null);
const saveTimerRef = useRef<ReturnType<typeof setTimeout> | 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<string, unknown>,
...(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<string, unknown> | 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<string, unknown>, 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 (
<div className="flex flex-1 min-w-0 h-full">
<ResourceSidebar
groups={groups}
customItems={customItems}
canvasRef={canvasRef}
onDrop={handleDrop}
screenToFlowPosition={screenToFlowPosition}
onAddCustom={addCustomItem}
onRemoveCustom={removeCustomItem}
onUpdateCustom={updateCustomItem}
/>
<div ref={canvasRef} className="flex-1 relative">
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
edgeTypes={edgeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
onEdgeContextMenu={(e, edge) => {
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"
>
<Background color="#e5e7eb" gap={18} size={1} />
<Controls showInteractive={false} position="bottom-right" />
{nodes.length === 0 && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
<div className="flex flex-col items-center gap-2 text-gray-300">
<p className="text-3xl">🗂</p>
<p className="text-xs"></p>
</div>
</div>
)}
</ReactFlow>
{/* 边右键菜单 */}
{edgeMenu && (
<div
style={{ position: "fixed", left: edgeMenu.x, top: edgeMenu.y, zIndex: 9999 }}
className="bg-white border border-gray-200 rounded-lg shadow-lg py-1 min-w-[110px]"
onClick={(e) => e.stopPropagation()}
>
<button
className="w-full text-left px-3 py-1.5 text-[12px] text-red-500 hover:bg-red-50 transition-colors rounded-lg"
onClick={() => {
deleteElements({ edges: [{ id: edgeMenu.edgeId }] });
setEdgeMenu(null);
}}
>
线
</button>
</div>
)}
{nodes.length > 0 && (
<div className="absolute top-3 right-3 flex gap-1.5 z-10">
{nodes.some((n) => n.type === "custom") && (
<button
onClick={applyTreeLayout}
className="text-[10px] text-violet-500 hover:text-violet-700 border border-violet-200 hover:border-violet-400 px-2 py-1 rounded-md bg-white transition-colors"
title="将自定义节点按父子层级自动排列"
>
</button>
)}
<button
onClick={() => { setNodes([]); setEdges([]); saveCanvasState(groupId, JSON.stringify({ nodes: [], edges: [], customItems })).catch(() => {}); }}
className="text-[10px] text-gray-400 hover:text-red-500 border border-gray-200 hover:border-red-300 px-2 py-1 rounded-md bg-white transition-colors"
>
</button>
</div>
)}
</div>
</div>
);
}
// ── 导出 ─────────────────────────────────────────────────────────
interface Props {
members: GroupMember[];
groupId: string;
}
export function GroupResourceCanvas({ members, groupId }: Props) {
const groups = useMemo(() => buildSidebarItems(members), [members]);
return (
<div style={{ height: 480 }} className="w-full flex">
<ReactFlowProvider>
<CanvasInner groups={groups} groupId={groupId} />
</ReactFlowProvider>
</div>
);
}