fix: 解决拖拽时节点详情窗口异常问题
This commit is contained in:
@@ -8,9 +8,8 @@ import { GRAPH_DEFAULT_HEIGHT } from '../../config/constants';
|
|||||||
interface GraphViewProps {
|
interface GraphViewProps {
|
||||||
data: GraphData;
|
data: GraphData;
|
||||||
layout?: GraphLayoutConfig;
|
layout?: GraphLayoutConfig;
|
||||||
onNodeClick?: (node: Node) => void;
|
onNodeClick?: (node: Node | null) => void;
|
||||||
onEdgeClick?: (edge: Edge) => void;
|
onEdgeClick?: (edge: Edge) => void;
|
||||||
onNodeHover?: (node: Node | null) => void;
|
|
||||||
height?: number;
|
height?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -23,14 +22,25 @@ export const GraphView = ({
|
|||||||
layout = FORCE_LAYOUT_CONFIG,
|
layout = FORCE_LAYOUT_CONFIG,
|
||||||
onNodeClick,
|
onNodeClick,
|
||||||
onEdgeClick,
|
onEdgeClick,
|
||||||
onNodeHover,
|
|
||||||
height = GRAPH_DEFAULT_HEIGHT,
|
height = GRAPH_DEFAULT_HEIGHT,
|
||||||
}: GraphViewProps) => {
|
}: GraphViewProps) => {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const graphRef = useRef<Graph | null>(null);
|
const graphRef = useRef<Graph | null>(null);
|
||||||
|
const cleanupRef = useRef<(() => void) | null>(null);
|
||||||
|
const isCancelledRef = useRef(false);
|
||||||
|
const renderPromiseRef = useRef<Promise<void> | null>(null);
|
||||||
|
|
||||||
|
const onNodeClickRef = useRef(onNodeClick);
|
||||||
|
const onEdgeClickRef = useRef(onEdgeClick);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onNodeClickRef.current = onNodeClick;
|
||||||
|
onEdgeClickRef.current = onEdgeClick;
|
||||||
|
}, [onNodeClick, onEdgeClick]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!containerRef.current) return;
|
if (!containerRef.current) return;
|
||||||
|
if (!data.id || data.nodes.length === 0) return;
|
||||||
|
|
||||||
// 清理现有图谱实例
|
// 清理现有图谱实例
|
||||||
if (graphRef.current) {
|
if (graphRef.current) {
|
||||||
@@ -98,6 +108,15 @@ export const GraphView = ({
|
|||||||
size: 50,
|
size: 50,
|
||||||
lineWidth: 2,
|
lineWidth: 2,
|
||||||
},
|
},
|
||||||
|
state: {
|
||||||
|
selected: {
|
||||||
|
stroke: '#1890FF',
|
||||||
|
lineWidth: 4,
|
||||||
|
halo: true,
|
||||||
|
haloColor: 'rgba(24, 144, 255, 0.3)',
|
||||||
|
haloLineWidth: 12,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
edge: {
|
edge: {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
@@ -105,65 +124,102 @@ export const GraphView = ({
|
|||||||
lineWidth: 1,
|
lineWidth: 1,
|
||||||
endArrow: true,
|
endArrow: true,
|
||||||
},
|
},
|
||||||
|
state: {
|
||||||
|
selected: {
|
||||||
|
stroke: '#1890FF',
|
||||||
|
lineWidth: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
layout: {
|
layout: {
|
||||||
...FORCE_LAYOUT_CONFIG,
|
...FORCE_LAYOUT_CONFIG,
|
||||||
},
|
},
|
||||||
behaviors: GRAPH_BEHAVIORS,
|
behaviors: [
|
||||||
|
...GRAPH_BEHAVIORS,
|
||||||
|
{
|
||||||
|
type: 'click-select',
|
||||||
|
key: 'click-select',
|
||||||
|
degree: 0,
|
||||||
|
state: 'selected',
|
||||||
|
multiple: false,
|
||||||
|
animation: true,
|
||||||
|
onClick: (event: any) => {
|
||||||
|
console.log('[GraphView] Node click event triggered', event);
|
||||||
|
console.log('[GraphView] event.target:', event.target);
|
||||||
|
console.log('[GraphView] onNodeClickRef.current:', onNodeClickRef.current);
|
||||||
|
|
||||||
|
if (event.target && event.target.id) {
|
||||||
|
const nodeId = event.target.id;
|
||||||
|
console.log('[GraphView] Looking for node with id:', nodeId);
|
||||||
|
console.log('[GraphView] Available nodes:', data.nodes.map((n: Node) => n.id));
|
||||||
|
|
||||||
|
const node = data.nodes.find((n: Node) => n.id === nodeId);
|
||||||
|
console.log('[GraphView] Found node:', nodeId, node);
|
||||||
|
|
||||||
|
if (node && onNodeClickRef.current) {
|
||||||
|
console.log('[GraphView] Calling onNodeClick callback with node:', node);
|
||||||
|
onNodeClickRef.current(node);
|
||||||
|
} else {
|
||||||
|
console.log('[GraphView] Failed: node not found or callback not available');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'drag-element-force',
|
||||||
|
key: 'drag-element-force',
|
||||||
|
state: 'selected',
|
||||||
|
fixed: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
const graph = new Graph(graphConfig);
|
const graph = new Graph(graphConfig);
|
||||||
graphRef.current = graph;
|
graphRef.current = graph;
|
||||||
|
|
||||||
graph.render().catch((renderError) => {
|
isCancelledRef.current = false;
|
||||||
|
cleanupRef.current = () => {
|
||||||
|
isCancelledRef.current = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
renderPromiseRef.current = graph.render().catch((renderError) => {
|
||||||
|
if (!isCancelledRef.current) {
|
||||||
console.error('Graph render error:', renderError);
|
console.error('Graph render error:', renderError);
|
||||||
});
|
|
||||||
|
|
||||||
// 节点点击事件
|
|
||||||
graph.on('node:click', (evt: any) => {
|
|
||||||
const nodeId = evt.target.id;
|
|
||||||
if (nodeId && onNodeClick) {
|
|
||||||
const nodeData = data.nodes.find((n: Node) => n.id === nodeId);
|
|
||||||
if (nodeData) {
|
|
||||||
onNodeClick(nodeData);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 边点击事件
|
|
||||||
graph.on('edge:click', (evt: any) => {
|
|
||||||
if (evt.target.id && onEdgeClick) {
|
|
||||||
const edgeData = data.edges.find((e: Edge) => e.id === evt.target.id);
|
|
||||||
if (edgeData) {
|
// 画布点击事件(取消选择)
|
||||||
onEdgeClick(edgeData);
|
graph.on('canvas:click', () => {
|
||||||
}
|
console.log('[GraphView] Canvas click - deselecting node');
|
||||||
|
console.log('[GraphView] onNodeClickRef.current:', onNodeClickRef.current);
|
||||||
|
if (onNodeClickRef.current) {
|
||||||
|
console.log('[GraphView] Calling onNodeClick with null');
|
||||||
|
onNodeClickRef.current(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 节点悬停事件
|
|
||||||
graph.on('node:mouseenter', (evt: any) => {
|
|
||||||
if (evt.target.id && onNodeHover) {
|
|
||||||
const nodeData = data.nodes.find((n: Node) => n.id === evt.target.id);
|
|
||||||
if (nodeData) {
|
|
||||||
onNodeHover(nodeData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
graph.on('node:mouseleave', () => {
|
|
||||||
if (onNodeHover) {
|
|
||||||
onNodeHover(null);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 清理函数
|
// 清理函数
|
||||||
return () => {
|
return () => {
|
||||||
|
isCancelledRef.current = true;
|
||||||
|
|
||||||
|
if (renderPromiseRef.current) {
|
||||||
|
renderPromiseRef.current.catch(() => {});
|
||||||
|
renderPromiseRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
if (graphRef.current) {
|
if (graphRef.current) {
|
||||||
|
graphRef.current.off('canvas:click');
|
||||||
graphRef.current.destroy();
|
graphRef.current.destroy();
|
||||||
graphRef.current = null;
|
graphRef.current = null;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}, [height, layout.type, onNodeClick, onEdgeClick, onNodeHover, data]);
|
}, [height, layout.type, data.id, onNodeClickRef, onEdgeClickRef]);
|
||||||
|
|
||||||
// 窗口大小调整响应
|
// 窗口大小调整响应
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
|||||||
const hasProperties = node.properties && Object.keys(node.properties).length > 0;
|
const hasProperties = node.properties && Object.keys(node.properties).length > 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-80 bg-white border-l border-gray-200 shadow-lg overflow-y-auto">
|
<div className="w-full overflow-y-auto">
|
||||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
<h3 className="text-lg font-semibold text-gray-800">节点详情</h3>
|
<h3 className="text-lg font-semibold text-gray-800">节点详情</h3>
|
||||||
{onClose && (
|
{onClose && (
|
||||||
|
|||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import type { Node } from '../../types/graph';
|
||||||
|
|
||||||
|
interface NodeTooltipProps {
|
||||||
|
node: Node | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NodeTooltip = ({ node }: NodeTooltipProps) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setIsExpanded(false);
|
||||||
|
}, [node?.id]);
|
||||||
|
|
||||||
|
const propertiesList = useMemo(() => {
|
||||||
|
if (!node?.properties) return [];
|
||||||
|
return Object.entries(node.properties);
|
||||||
|
}, [node?.properties]);
|
||||||
|
|
||||||
|
const hasProperties = propertiesList.length > 0;
|
||||||
|
|
||||||
|
if (!node) return null;
|
||||||
|
|
||||||
|
const handleExpand = () => setIsExpanded(true);
|
||||||
|
const handleCollapse = () => setIsExpanded(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="absolute bottom-4 right-1/2 translate-x-1/2 bg-white/95 backdrop-blur-sm rounded-lg shadow-lg p-4 max-w-md w-80 z-20">
|
||||||
|
{!isExpanded ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="font-semibold text-base text-gray-800">{node.label}</div>
|
||||||
|
</div>
|
||||||
|
{node.type && (
|
||||||
|
<div className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded inline-block">
|
||||||
|
{node.type}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasProperties && (
|
||||||
|
<div className="space-y-1 text-xs">
|
||||||
|
{propertiesList.slice(0, 2).map(([key, value]) => (
|
||||||
|
<div key={key} className="flex justify-between gap-2">
|
||||||
|
<span className="text-gray-500 shrink-0">{key}:</span>
|
||||||
|
<span className="text-gray-800 text-right truncate max-w-[70%]">
|
||||||
|
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{propertiesList.length > 2 && (
|
||||||
|
<div className="text-gray-400 text-center pt-1">还有 {propertiesList.length - 2} 项属性...</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasProperties && (
|
||||||
|
<button
|
||||||
|
onClick={handleExpand}
|
||||||
|
className="w-full mt-2 py-1.5 text-xs font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded transition-colors"
|
||||||
|
>
|
||||||
|
展开查看全部 ▼
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="font-semibold text-base text-gray-800">{node.label}</div>
|
||||||
|
<button
|
||||||
|
onClick={handleCollapse}
|
||||||
|
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-0.5 hover:bg-gray-100 rounded transition-colors"
|
||||||
|
>
|
||||||
|
收起 ▲
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{node.type && (
|
||||||
|
<div className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded inline-block">
|
||||||
|
{node.type}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasProperties && (
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
|
||||||
|
属性 ({propertiesList.length})
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 text-xs max-h-40 overflow-y-auto">
|
||||||
|
{propertiesList.map(([key, value]) => (
|
||||||
|
<div key={key} className="flex justify-between gap-2 py-0.5">
|
||||||
|
<span className="text-gray-600 shrink-0">{key}:</span>
|
||||||
|
<span className="text-gray-800 text-right break-all max-w-[65%]">
|
||||||
|
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NodeTooltip;
|
||||||
@@ -26,16 +26,16 @@ export const useGraphData = () => {
|
|||||||
|
|
||||||
// 如果后端返回空数据,使用示例数据
|
// 如果后端返回空数据,使用示例数据
|
||||||
if (data.nodes.length === 0) {
|
if (data.nodes.length === 0) {
|
||||||
setGraphData(SAMPLE_GRAPH_DATA);
|
setGraphData({ ...SAMPLE_GRAPH_DATA, id: `sample-${Date.now()}` });
|
||||||
} else {
|
} else {
|
||||||
setGraphData(data);
|
setGraphData({ ...data, id: `data-${Date.now()}` });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load graph data:', error);
|
console.error('Failed to load graph data:', error);
|
||||||
// 重置颜色分配缓存
|
// 重置颜色分配缓存
|
||||||
resetColorAssignment();
|
resetColorAssignment();
|
||||||
// API 失败时使用示例数据作为降级方案
|
// API 失败时使用示例数据作为降级方案
|
||||||
setGraphData(SAMPLE_GRAPH_DATA);
|
setGraphData({ ...SAMPLE_GRAPH_DATA, id: `fallback-${Date.now()}` });
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -59,17 +59,36 @@ export const useGraphData = () => {
|
|||||||
|
|
||||||
// 通过 ID 选择节点(用于搜索结果点击)
|
// 通过 ID 选择节点(用于搜索结果点击)
|
||||||
const handleNodeSelectById = useCallback((nodeId: string) => {
|
const handleNodeSelectById = useCallback((nodeId: string) => {
|
||||||
|
console.log('[useGraphData] handleNodeSelectById called with id:', nodeId);
|
||||||
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
|
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
|
||||||
|
console.log('[useGraphData] Found node:', node);
|
||||||
if (node) {
|
if (node) {
|
||||||
|
console.log('[useGraphData] Setting selectedNode:', node);
|
||||||
setSelectedNode(node);
|
setSelectedNode(node);
|
||||||
|
} else {
|
||||||
|
console.log('[useGraphData] Node not found with id:', nodeId);
|
||||||
}
|
}
|
||||||
}, [graphData.nodes]);
|
}, [graphData.nodes]);
|
||||||
|
|
||||||
// 选择节点(用于图谱点击)
|
// 选择节点(用于图谱点击)
|
||||||
const handleNodeSelect = useCallback((node: Node) => {
|
const handleNodeSelect = useCallback((node: Node | null) => {
|
||||||
|
console.log('[useGraphData] handleNodeSelect called with node:', node);
|
||||||
|
console.log('[useGraphData] About to call setSelectedNode');
|
||||||
setSelectedNode(node);
|
setSelectedNode(node);
|
||||||
|
console.log('[useGraphData] setSelectedNode called');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 悬停节点(用于图谱悬停)
|
||||||
|
const handleNodeHover = useCallback((node: Node | null) => {
|
||||||
|
setHoveredNode(node);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 监听 selectedNode 变化,用于调试
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('[useGraphData] selectedNode state changed:', selectedNode);
|
||||||
|
console.log('[useGraphData] selectedNode truthiness:', !!selectedNode);
|
||||||
|
}, [selectedNode]);
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
// 组件挂载时加载数据
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGraphData();
|
loadGraphData();
|
||||||
@@ -81,11 +100,10 @@ export const useGraphData = () => {
|
|||||||
hoveredNode,
|
hoveredNode,
|
||||||
searchResults,
|
searchResults,
|
||||||
loading,
|
loading,
|
||||||
setHoveredNode,
|
|
||||||
setSelectedNode,
|
setSelectedNode,
|
||||||
setSearchResults,
|
|
||||||
handleSearch,
|
handleSearch,
|
||||||
handleNodeSelectById,
|
handleNodeSelectById,
|
||||||
handleNodeSelect,
|
handleNodeSelect,
|
||||||
|
handleNodeHover,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import GraphView from '../components/GraphView/index';
|
import GraphView from '../components/GraphView/index';
|
||||||
import SearchBar from '../components/SearchBar/index';
|
import SearchBar from '../components/SearchBar/index';
|
||||||
import NodePanel from '../components/NodePanel/index';
|
import NodePanel from '../components/NodePanel/index';
|
||||||
@@ -18,17 +18,15 @@ export const KnowledgeGraph = () => {
|
|||||||
const {
|
const {
|
||||||
graphData,
|
graphData,
|
||||||
selectedNode,
|
selectedNode,
|
||||||
hoveredNode,
|
|
||||||
searchResults,
|
searchResults,
|
||||||
loading,
|
loading,
|
||||||
setHoveredNode,
|
|
||||||
setSelectedNode,
|
setSelectedNode,
|
||||||
handleSearch,
|
handleSearch,
|
||||||
handleNodeSelectById,
|
handleNodeSelectById,
|
||||||
|
handleNodeSelect,
|
||||||
} = useGraphData();
|
} = useGraphData();
|
||||||
|
|
||||||
const { layout } = useGraphLayout();
|
const { layout } = useGraphLayout();
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
|
||||||
|
|
||||||
const legendData = useMemo(() => {
|
const legendData = useMemo(() => {
|
||||||
if (!graphData || graphData.nodes.length === 0) {
|
if (!graphData || graphData.nodes.length === 0) {
|
||||||
@@ -37,11 +35,6 @@ export const KnowledgeGraph = () => {
|
|||||||
return generateLegendData(graphData);
|
return generateLegendData(graphData);
|
||||||
}, [graphData]);
|
}, [graphData]);
|
||||||
|
|
||||||
const propertiesList = useMemo(() => {
|
|
||||||
if (!hoveredNode?.properties) return [];
|
|
||||||
return Object.entries(hoveredNode.properties);
|
|
||||||
}, [hoveredNode]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-screen flex flex-col bg-gray-50">
|
<div className="w-full h-screen flex flex-col bg-gray-50">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
@@ -73,94 +66,19 @@ export const KnowledgeGraph = () => {
|
|||||||
<GraphView
|
<GraphView
|
||||||
data={graphData}
|
data={graphData}
|
||||||
layout={layout}
|
layout={layout}
|
||||||
onNodeClick={setSelectedNode}
|
onNodeClick={handleNodeSelect}
|
||||||
onNodeHover={setHoveredNode}
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{hoveredNode && (
|
|
||||||
<div className="absolute bottom-4 right-1/2 translate-x-1/2 bg-white/95 backdrop-blur-sm rounded-lg shadow-lg p-4 max-w-md w-80 z-20">
|
|
||||||
{!isExpanded ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="font-semibold text-base text-gray-800">{hoveredNode.label}</div>
|
|
||||||
</div>
|
|
||||||
{hoveredNode.type && (
|
|
||||||
<div className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded inline-block">
|
|
||||||
{hoveredNode.type}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{propertiesList.length > 0 && (
|
|
||||||
<div className="space-y-1 text-xs">
|
|
||||||
{propertiesList.slice(0, 2).map(([key, value]) => (
|
|
||||||
<div key={key} className="flex justify-between gap-2">
|
|
||||||
<span className="text-gray-500 shrink-0">{key}:</span>
|
|
||||||
<span className="text-gray-800 text-right truncate max-w-[70%]">
|
|
||||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{propertiesList.length > 2 && (
|
|
||||||
<div className="text-gray-400 text-center pt-1">还有 {propertiesList.length - 2} 项属性...</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{propertiesList.length > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsExpanded(true)}
|
|
||||||
className="w-full mt-2 py-1.5 text-xs font-medium text-blue-600 hover:text-blue-700 hover:bg-blue-50 rounded transition-colors"
|
|
||||||
>
|
|
||||||
展开查看全部 ▼
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<div className="flex justify-between items-start">
|
|
||||||
<div className="font-semibold text-base text-gray-800">{hoveredNode.label}</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setIsExpanded(false)}
|
|
||||||
className="text-xs text-gray-500 hover:text-gray-700 px-2 py-0.5 hover:bg-gray-100 rounded transition-colors"
|
|
||||||
>
|
|
||||||
收起 ▲
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{hoveredNode.type && (
|
|
||||||
<div className="text-xs text-gray-500 bg-gray-100 px-2 py-0.5 rounded inline-block">
|
|
||||||
{hoveredNode.type}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{propertiesList.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
|
|
||||||
属性 ({propertiesList.length})
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1.5 text-xs max-h-40 overflow-y-auto">
|
|
||||||
{propertiesList.map(([key, value]) => (
|
|
||||||
<div key={key} className="flex justify-between gap-2 py-0.5">
|
|
||||||
<span className="text-gray-600 shrink-0">{key}:</span>
|
|
||||||
<span className="text-gray-800 text-right break-all max-w-[65%]">
|
|
||||||
{typeof value === 'object' ? JSON.stringify(value) : String(value)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
{selectedNode && (
|
{selectedNode && (
|
||||||
<aside className="w-80 bg-white border-l border-gray-200">
|
<div className="fixed right-6 top-24 w-96 bg-white rounded-lg shadow-2xl border border-gray-200 z-50">
|
||||||
<NodePanel
|
<NodePanel
|
||||||
node={selectedNode}
|
node={selectedNode}
|
||||||
onClose={() => setSelectedNode(null)}
|
onClose={() => setSelectedNode(null)}
|
||||||
/>
|
/>
|
||||||
</aside>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ export interface Edge {
|
|||||||
export interface GraphData {
|
export interface GraphData {
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user