fix: 解决拖拽时节点详情窗口异常问题

This commit is contained in:
2026-04-06 15:30:02 +08:00
parent 57eaaaffee
commit cb8f7f6cbe
6 changed files with 234 additions and 139 deletions
+94 -38
View File
@@ -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) => {
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);
}
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('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(() => {
+1 -1
View File
@@ -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;
+24 -6
View File
@@ -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,
};
};
+12 -94
View File
@@ -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>
{selectedNode && (
<aside className="w-80 bg-white border-l border-gray-200">
<NodePanel
node={selectedNode}
onClose={() => setSelectedNode(null)}
/>
</aside>
)}
</div>
{selectedNode && (
<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)}
/>
</div>
)}
</>
)}
</div>
+1
View File
@@ -46,6 +46,7 @@ export interface Edge {
export interface GraphData {
nodes: Node[];
edges: Edge[];
id?: string;
}
/**