fix: 解决拖拽时节点详情窗口异常问题
This commit is contained in:
@@ -8,9 +8,8 @@ import { GRAPH_DEFAULT_HEIGHT } from '../../config/constants';
|
||||
interface GraphViewProps {
|
||||
data: GraphData;
|
||||
layout?: GraphLayoutConfig;
|
||||
onNodeClick?: (node: Node) => void;
|
||||
onNodeClick?: (node: Node | null) => void;
|
||||
onEdgeClick?: (edge: Edge) => void;
|
||||
onNodeHover?: (node: Node | null) => void;
|
||||
height?: number;
|
||||
}
|
||||
|
||||
@@ -23,14 +22,25 @@ export const GraphView = ({
|
||||
layout = FORCE_LAYOUT_CONFIG,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onNodeHover,
|
||||
height = GRAPH_DEFAULT_HEIGHT,
|
||||
}: GraphViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(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(() => {
|
||||
if (!containerRef.current) return;
|
||||
if (!data.id || data.nodes.length === 0) return;
|
||||
|
||||
// 清理现有图谱实例
|
||||
if (graphRef.current) {
|
||||
@@ -98,6 +108,15 @@ export const GraphView = ({
|
||||
size: 50,
|
||||
lineWidth: 2,
|
||||
},
|
||||
state: {
|
||||
selected: {
|
||||
stroke: '#1890FF',
|
||||
lineWidth: 4,
|
||||
halo: true,
|
||||
haloColor: 'rgba(24, 144, 255, 0.3)',
|
||||
haloLineWidth: 12,
|
||||
},
|
||||
},
|
||||
},
|
||||
edge: {
|
||||
type: 'line',
|
||||
@@ -105,65 +124,102 @@ export const GraphView = ({
|
||||
lineWidth: 1,
|
||||
endArrow: true,
|
||||
},
|
||||
state: {
|
||||
selected: {
|
||||
stroke: '#1890FF',
|
||||
lineWidth: 3,
|
||||
},
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
...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);
|
||||
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);
|
||||
});
|
||||
|
||||
// 节点点击事件
|
||||
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 () => {
|
||||
isCancelledRef.current = true;
|
||||
|
||||
if (renderPromiseRef.current) {
|
||||
renderPromiseRef.current.catch(() => {});
|
||||
renderPromiseRef.current = null;
|
||||
}
|
||||
|
||||
if (graphRef.current) {
|
||||
graphRef.current.off('canvas:click');
|
||||
graphRef.current.destroy();
|
||||
graphRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [height, layout.type, onNodeClick, onEdgeClick, onNodeHover, data]);
|
||||
}, [height, layout.type, data.id, onNodeClickRef, onEdgeClickRef]);
|
||||
|
||||
// 窗口大小调整响应
|
||||
useEffect(() => {
|
||||
|
||||
@@ -16,7 +16,7 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
const hasProperties = node.properties && Object.keys(node.properties).length > 0;
|
||||
|
||||
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">
|
||||
<h3 className="text-lg font-semibold text-gray-800">节点详情</h3>
|
||||
{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) {
|
||||
setGraphData(SAMPLE_GRAPH_DATA);
|
||||
setGraphData({ ...SAMPLE_GRAPH_DATA, id: `sample-${Date.now()}` });
|
||||
} else {
|
||||
setGraphData(data);
|
||||
setGraphData({ ...data, id: `data-${Date.now()}` });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load graph data:', error);
|
||||
// 重置颜色分配缓存
|
||||
resetColorAssignment();
|
||||
// API 失败时使用示例数据作为降级方案
|
||||
setGraphData(SAMPLE_GRAPH_DATA);
|
||||
setGraphData({ ...SAMPLE_GRAPH_DATA, id: `fallback-${Date.now()}` });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -59,17 +59,36 @@ export const useGraphData = () => {
|
||||
|
||||
// 通过 ID 选择节点(用于搜索结果点击)
|
||||
const handleNodeSelectById = useCallback((nodeId: string) => {
|
||||
console.log('[useGraphData] handleNodeSelectById called with id:', nodeId);
|
||||
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
|
||||
console.log('[useGraphData] Found node:', node);
|
||||
if (node) {
|
||||
console.log('[useGraphData] Setting selectedNode:', node);
|
||||
setSelectedNode(node);
|
||||
} else {
|
||||
console.log('[useGraphData] Node not found with id:', nodeId);
|
||||
}
|
||||
}, [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);
|
||||
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(() => {
|
||||
loadGraphData();
|
||||
@@ -81,11 +100,10 @@ export const useGraphData = () => {
|
||||
hoveredNode,
|
||||
searchResults,
|
||||
loading,
|
||||
setHoveredNode,
|
||||
setSelectedNode,
|
||||
setSearchResults,
|
||||
handleSearch,
|
||||
handleNodeSelectById,
|
||||
handleNodeSelect,
|
||||
handleNodeHover,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import GraphView from '../components/GraphView/index';
|
||||
import SearchBar from '../components/SearchBar/index';
|
||||
import NodePanel from '../components/NodePanel/index';
|
||||
@@ -18,17 +18,15 @@ export const KnowledgeGraph = () => {
|
||||
const {
|
||||
graphData,
|
||||
selectedNode,
|
||||
hoveredNode,
|
||||
searchResults,
|
||||
loading,
|
||||
setHoveredNode,
|
||||
setSelectedNode,
|
||||
handleSearch,
|
||||
handleNodeSelectById,
|
||||
handleNodeSelect,
|
||||
} = useGraphData();
|
||||
|
||||
const { layout } = useGraphLayout();
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const legendData = useMemo(() => {
|
||||
if (!graphData || graphData.nodes.length === 0) {
|
||||
@@ -37,11 +35,6 @@ export const KnowledgeGraph = () => {
|
||||
return generateLegendData(graphData);
|
||||
}, [graphData]);
|
||||
|
||||
const propertiesList = useMemo(() => {
|
||||
if (!hoveredNode?.properties) return [];
|
||||
return Object.entries(hoveredNode.properties);
|
||||
}, [hoveredNode]);
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen flex flex-col bg-gray-50">
|
||||
{loading ? (
|
||||
@@ -73,94 +66,19 @@ export const KnowledgeGraph = () => {
|
||||
<GraphView
|
||||
data={graphData}
|
||||
layout={layout}
|
||||
onNodeClick={setSelectedNode}
|
||||
onNodeHover={setHoveredNode}
|
||||
onNodeClick={handleNodeSelect}
|
||||
/>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{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
|
||||
node={selectedNode}
|
||||
onClose={() => setSelectedNode(null)}
|
||||
/>
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -46,6 +46,7 @@ export interface Edge {
|
||||
export interface GraphData {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
id?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user