- 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.
1368 lines
56 KiB
TypeScript
1368 lines
56 KiB
TypeScript
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>
|
||
);
|
||
}
|