refactor: 重构前端代码并增加注释
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { Graph } from '@antv/g6';
|
||||
import type { GraphData, Node, Edge, GraphLayoutConfig } from '../../types/graph';
|
||||
import { getNodeColors } from '../../config/colors';
|
||||
import { FORCE_LAYOUT_CONFIG, GRAPH_BEHAVIORS } from '../../config/layout';
|
||||
import { GRAPH_DEFAULT_HEIGHT } from '../../config/constants';
|
||||
|
||||
interface GraphViewProps {
|
||||
data: GraphData;
|
||||
@@ -11,13 +14,17 @@ interface GraphViewProps {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 知识图谱可视化组件
|
||||
* 使用 AntV G6 渲染交互式图谱,支持节点点击、悬停等交互
|
||||
*/
|
||||
export const GraphView = ({
|
||||
data,
|
||||
layout = { type: 'force' },
|
||||
layout = FORCE_LAYOUT_CONFIG,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onNodeHover,
|
||||
height = 600,
|
||||
height = GRAPH_DEFAULT_HEIGHT,
|
||||
}: GraphViewProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const graphRef = useRef<Graph | null>(null);
|
||||
@@ -25,195 +32,144 @@ export const GraphView = ({
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
|
||||
try {
|
||||
console.log('🎨 GraphView - Initializing graph with data:', data);
|
||||
console.log('🎨 GraphView - Container dimensions:', {
|
||||
width: containerRef.current.clientWidth,
|
||||
height: height
|
||||
});
|
||||
// 清理现有图谱实例
|
||||
if (graphRef.current) {
|
||||
graphRef.current.destroy();
|
||||
graphRef.current = null;
|
||||
}
|
||||
|
||||
if (graphRef.current) {
|
||||
try {
|
||||
graphRef.current.destroy();
|
||||
} catch (e) {
|
||||
console.log('🎨 GraphView - Destroy error:', e);
|
||||
}
|
||||
graphRef.current = null;
|
||||
}
|
||||
|
||||
const processedGraphData = {
|
||||
nodes: data.nodes.map((node: Node) => {
|
||||
const typeColors: Record<string, { fill: string; stroke: string }> = {
|
||||
'概念': { fill: '#4ECDC4', stroke: '#45B7B0' },
|
||||
'工具': { fill: '#FFA07A', stroke: '#FF7F50' },
|
||||
'应用': { fill: '#2ECC71', stroke: '#27AE60' },
|
||||
};
|
||||
|
||||
const colors = typeColors[node.type] || { fill: '#C6E5FF', stroke: '#5B8FF9' };
|
||||
|
||||
return {
|
||||
// 转换数据格式以适配 G6
|
||||
const processedGraphData = {
|
||||
nodes: data.nodes.map((node: Node) => {
|
||||
const colors = getNodeColors(node);
|
||||
return {
|
||||
id: node.id,
|
||||
data: {
|
||||
id: node.id,
|
||||
data: {
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
properties: node.properties,
|
||||
style: node.style,
|
||||
},
|
||||
style: {
|
||||
fill: colors.fill,
|
||||
stroke: colors.stroke,
|
||||
lineWidth: 2,
|
||||
labelText: node.label,
|
||||
labelFill: '#2C3E50',
|
||||
labelFontSize: 14,
|
||||
labelFontWeight: 'bold',
|
||||
labelPlacement: 'bottom',
|
||||
},
|
||||
};
|
||||
}),
|
||||
edges: data.edges.map((edge: Edge) => ({
|
||||
id: edge.id,
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
properties: node.properties,
|
||||
style: node.style,
|
||||
},
|
||||
style: {
|
||||
fill: colors.fill,
|
||||
stroke: colors.stroke,
|
||||
lineWidth: 2,
|
||||
labelText: node.label,
|
||||
labelFill: '#2C3E50',
|
||||
labelFontSize: 14,
|
||||
labelFontWeight: 'bold' as const,
|
||||
labelPlacement: 'bottom' as const,
|
||||
},
|
||||
};
|
||||
}),
|
||||
edges: data.edges.map((edge: Edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edge.label,
|
||||
data: {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edge.label,
|
||||
data: {
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
label: edge.label,
|
||||
type: edge.type,
|
||||
properties: edge.properties,
|
||||
},
|
||||
style: {
|
||||
stroke: '#95A5A6',
|
||||
lineWidth: 2,
|
||||
labelText: edge.label,
|
||||
labelFill: '#666',
|
||||
labelFontSize: 10,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
console.log('🎨 GraphView - Processed graph data nodes:', processedGraphData.nodes.length);
|
||||
console.log('🎨 GraphView - Processed graph data edges:', processedGraphData.edges.length);
|
||||
|
||||
const graphConfig: any = {
|
||||
container: containerRef.current,
|
||||
width: containerRef.current.clientWidth,
|
||||
height: height,
|
||||
data: processedGraphData,
|
||||
autoFit: 'view',
|
||||
node: {
|
||||
type: 'circle',
|
||||
style: {
|
||||
size: 50,
|
||||
lineWidth: 2,
|
||||
},
|
||||
type: edge.type,
|
||||
properties: edge.properties,
|
||||
},
|
||||
edge: {
|
||||
type: 'line',
|
||||
style: {
|
||||
lineWidth: 1,
|
||||
endArrow: true,
|
||||
},
|
||||
style: {
|
||||
stroke: '#95A5A6',
|
||||
lineWidth: 2,
|
||||
labelText: edge.label,
|
||||
labelFill: '#666',
|
||||
labelFontSize: 10,
|
||||
},
|
||||
layout: {
|
||||
type: layout.type,
|
||||
nodeSize: 60,
|
||||
preventOverlap: true,
|
||||
linkDistance: 150,
|
||||
})),
|
||||
};
|
||||
|
||||
// 创建图谱配置
|
||||
const graphConfig = {
|
||||
container: containerRef.current,
|
||||
width: containerRef.current.clientWidth,
|
||||
height: height,
|
||||
data: processedGraphData,
|
||||
autoFit: 'view' as const,
|
||||
node: {
|
||||
type: 'circle',
|
||||
style: {
|
||||
size: 50,
|
||||
lineWidth: 2,
|
||||
},
|
||||
behaviors: ['drag-canvas', 'zoom-canvas', 'drag-element'],
|
||||
};
|
||||
},
|
||||
edge: {
|
||||
type: 'line',
|
||||
style: {
|
||||
lineWidth: 1,
|
||||
endArrow: true,
|
||||
},
|
||||
},
|
||||
layout: {
|
||||
...FORCE_LAYOUT_CONFIG,
|
||||
},
|
||||
behaviors: GRAPH_BEHAVIORS,
|
||||
};
|
||||
|
||||
console.log('🎨 GraphView - Creating graph with config:', graphConfig);
|
||||
|
||||
const graph = new Graph(graphConfig);
|
||||
const graph = new Graph(graphConfig);
|
||||
graphRef.current = graph;
|
||||
|
||||
graph.render().catch((renderError) => {
|
||||
console.error('Graph render error:', renderError);
|
||||
});
|
||||
|
||||
graphRef.current = graph;
|
||||
|
||||
console.log('🎨 GraphView - Starting render');
|
||||
graph.render()
|
||||
.then(() => {
|
||||
console.log('🎨 GraphView - Graph rendered successfully');
|
||||
})
|
||||
.catch((renderError) => {
|
||||
console.error('🎨 GraphView - Render error:', renderError);
|
||||
});
|
||||
|
||||
graph.on('node:click', (evt) => {
|
||||
console.log('🔗 G6 - Node clicked, evt:', evt);
|
||||
const nodeId = evt.itemId;
|
||||
console.log('🔗 G6 - Node ID:', nodeId);
|
||||
|
||||
if (nodeId && onNodeClick) {
|
||||
const nodeData = data.nodes.find((n: Node) => n.id === nodeId);
|
||||
console.log('🔗 G6 - Found node data:', nodeData);
|
||||
if (nodeData) {
|
||||
console.log('🔗 G6 - Node properties:', nodeData.properties);
|
||||
onNodeClick(nodeData);
|
||||
} else {
|
||||
console.log('🔗 G6 - Node data not found for ID:', nodeId);
|
||||
}
|
||||
// 节点点击事件
|
||||
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) => {
|
||||
if (evt.itemId && onEdgeClick) {
|
||||
const edgeData = data.edges.find((e: Edge) => e.id === evt.itemId);
|
||||
if (edgeData) {
|
||||
onEdgeClick(edgeData);
|
||||
}
|
||||
// 边点击事件
|
||||
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('node:mouseenter', (evt) => {
|
||||
if (evt.itemId && onNodeHover) {
|
||||
const nodeData = data.nodes.find((n: Node) => n.id === evt.itemId);
|
||||
if (nodeData) {
|
||||
onNodeHover(nodeData);
|
||||
}
|
||||
// 节点悬停事件
|
||||
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);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error initializing graph:', error);
|
||||
}
|
||||
graph.on('node:mouseleave', () => {
|
||||
if (onNodeHover) {
|
||||
onNodeHover(null);
|
||||
}
|
||||
});
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (graphRef.current) {
|
||||
try {
|
||||
graphRef.current.destroy();
|
||||
} catch (error) {
|
||||
console.error('Error destroying graph:', error);
|
||||
}
|
||||
graphRef.current.destroy();
|
||||
graphRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [
|
||||
height,
|
||||
layout.type,
|
||||
layout.nodeSize,
|
||||
layout.linkDistance,
|
||||
onNodeClick,
|
||||
onEdgeClick,
|
||||
onNodeHover,
|
||||
data,
|
||||
]);
|
||||
}, [height, layout.type, onNodeClick, onEdgeClick, onNodeHover, data]);
|
||||
|
||||
// 窗口大小调整响应
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
if (graphRef.current && containerRef.current) {
|
||||
try {
|
||||
graphRef.current.resize(containerRef.current.clientWidth, height);
|
||||
} catch (error) {
|
||||
console.error('Error resizing graph:', error);
|
||||
}
|
||||
graphRef.current.resize(containerRef.current.clientWidth, height);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -225,7 +181,7 @@ export const GraphView = ({
|
||||
<div className="relative w-full h-full">
|
||||
<div ref={containerRef} className="w-full h-full" style={{ height: `${height}px` }} />
|
||||
<div className="absolute bottom-4 right-4 bg-white/90 backdrop-blur-sm rounded-lg shadow-lg p-3">
|
||||
<div className="text-xs text-gray-600 mb-2">布局类型: {layout.type}</div>
|
||||
<div className="text-xs text-gray-600 mb-2">布局: {layout.type}</div>
|
||||
<div className="text-xs text-gray-600">节点: {data.nodes.length} | 边: {data.edges.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5,13 +5,15 @@ interface NodePanelProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点详情面板组件
|
||||
* 展示选中节点的详细信息,包括属性和样式
|
||||
*/
|
||||
export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
if (!node) return null;
|
||||
|
||||
console.log('📋 NodePanel - Rendering node:', node);
|
||||
console.log('📋 NodePanel - Node properties:', node.properties);
|
||||
console.log('📋 NodePanel - Has properties:', !!node.properties);
|
||||
console.log('📋 NodePanel - Properties keys:', node.properties ? Object.keys(node.properties) : []);
|
||||
// 判断属性是否可展示
|
||||
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">
|
||||
@@ -21,6 +23,7 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors"
|
||||
aria-label="关闭面板"
|
||||
>
|
||||
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
||||
@@ -30,6 +33,7 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
{/* 基本信息 */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">节点 ID</div>
|
||||
<div className="font-mono text-sm bg-gray-50 p-2 rounded overflow-x-auto">
|
||||
@@ -51,32 +55,14 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!node.properties && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">属性</div>
|
||||
<div className="text-sm text-gray-400 italic">
|
||||
暂无属性数据
|
||||
</div>
|
||||
<div className="text-xs text-gray-300 italic mt-1">
|
||||
Debug: Node ID: {node.id}, Has properties?: {node.properties ? 'Yes' : 'No'}
|
||||
</div>
|
||||
{/* 属性部分:统一处理 undefined、null 和空对象 */}
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
|
||||
属性 {hasProperties && `(${Object.keys(node.properties!).length})`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.properties && Object.keys(node.properties).length === 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">属性</div>
|
||||
<div className="text-sm text-gray-400 italic">
|
||||
属性对象存在但为空
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{node.properties && Object.keys(node.properties).length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">属性 ({Object.keys(node.properties).length})</div>
|
||||
{hasProperties ? (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(node.properties).map(([key, value]) => (
|
||||
{Object.entries(node.properties!).map(([key, value]) => (
|
||||
<div key={key} className="flex justify-between items-start">
|
||||
<div className="text-sm text-gray-600">{key}</div>
|
||||
<div className="text-sm font-medium text-gray-800 text-right max-w-[50%] break-all">
|
||||
@@ -85,9 +71,14 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
) : (
|
||||
<div className="text-sm text-gray-400 italic">
|
||||
暂无属性数据
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 样式信息 */}
|
||||
{node.style && (
|
||||
<div>
|
||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">样式</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import type { SearchNode } from '../../types/graph';
|
||||
import { SEARCH_RESULT_MAX_HEIGHT } from '../../config/constants';
|
||||
|
||||
interface SearchBarProps {
|
||||
onSearch?: (query: string) => void;
|
||||
@@ -8,6 +9,10 @@ interface SearchBarProps {
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索栏组件
|
||||
* 支持实时搜索和结果展示
|
||||
*/
|
||||
export const SearchBar = ({
|
||||
onSearch,
|
||||
onNodeSelect,
|
||||
@@ -17,6 +22,7 @@ export const SearchBar = ({
|
||||
const [query, setQuery] = useState('');
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
// 处理输入变化
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setQuery(value);
|
||||
@@ -28,6 +34,7 @@ export const SearchBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 处理键盘事件(Enter 确认搜索)
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (onSearch && query.trim()) {
|
||||
@@ -37,6 +44,7 @@ export const SearchBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 选择搜索结果中的节点
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
if (onNodeSelect) {
|
||||
onNodeSelect(nodeId);
|
||||
@@ -63,6 +71,7 @@ export const SearchBar = ({
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
@@ -73,13 +82,16 @@ export const SearchBar = ({
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果下拉列表 */}
|
||||
{showResults && searchResults.length > 0 && (
|
||||
<div className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg max-h-64 overflow-y-auto">
|
||||
<div className="absolute z-50 w-full mt-2 bg-white border border-gray-200 rounded-lg shadow-lg overflow-y-auto" style={{ maxHeight: `${SEARCH_RESULT_MAX_HEIGHT}px` }}>
|
||||
{searchResults.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
onClick={() => handleNodeClick(node.id)}
|
||||
className="px-4 py-2 hover:bg-purple-50 cursor-pointer border-b border-gray-100 last:border-b-0 transition-colors"
|
||||
role="option"
|
||||
tabIndex={0}
|
||||
>
|
||||
<div className="font-medium text-gray-800">{node.label}</div>
|
||||
{node.type && (
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Node } from '../types/graph';
|
||||
|
||||
// 节点类型颜色配置
|
||||
// 用于统一管理知识图谱中不同类型节点的视觉样式
|
||||
export const NODE_COLOR_MAP: Record<string, { fill: string; stroke: string }> = {
|
||||
'概念': { fill: '#4ECDC4', stroke: '#45B7B0' },
|
||||
'工具': { fill: '#FFA07A', stroke: '#FF7F50' },
|
||||
'应用': { fill: '#2ECC71', stroke: '#27AE60' },
|
||||
};
|
||||
|
||||
// 默认节点颜色(当类型未匹配时)
|
||||
export const DEFAULT_NODE_COLORS = { fill: '#C6E5FF', stroke: '#5B8FF9' };
|
||||
|
||||
// 获取节点颜色配置
|
||||
export const getNodeColors = (node: Node): { fill: string; stroke: string } => {
|
||||
if (!node.type) return DEFAULT_NODE_COLORS;
|
||||
return NODE_COLOR_MAP[node.type] || DEFAULT_NODE_COLORS;
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
// API 相关常量
|
||||
export const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || 'http://localhost:3001/api';
|
||||
export const API_TIMEOUT = 10000;
|
||||
|
||||
// 图谱渲染配置
|
||||
export const GRAPH_CONTAINER_MIN_WIDTH = 600;
|
||||
export const GRAPH_DEFAULT_HEIGHT = 600;
|
||||
|
||||
// 搜索相关常量
|
||||
export const SEARCH_DEBOUNCE_MS = 300;
|
||||
export const SEARCH_RESULT_MAX_HEIGHT = 256; // 16rem
|
||||
|
||||
// UI 相关常量
|
||||
export const NODE_PANEL_WIDTH = 320; // 20rem
|
||||
export const HOVER_PANEL_MAX_WIDTH = 320; // 20rem
|
||||
|
||||
// 性能优化常量
|
||||
export const RESIZE_DEBOUNCE_MS = 200;
|
||||
export const MAX_NODES_FOR_ANIMATION = 500;
|
||||
|
||||
// 错误消息
|
||||
export const ERROR_MESSAGES = {
|
||||
LOAD_GRAPH_FAILED: '加载图谱数据失败',
|
||||
SEARCH_FAILED: '搜索节点失败',
|
||||
NETWORK_ERROR: '网络连接错误',
|
||||
} as const;
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { GraphLayoutConfig } from '../types/graph';
|
||||
|
||||
// 支持的布局类型(仅保留 force 力导向布局)
|
||||
export const SUPPORTED_LAYOUTS = ['force'] as const;
|
||||
|
||||
// 力导向布局默认配置
|
||||
export const FORCE_LAYOUT_CONFIG = {
|
||||
type: 'force' as const,
|
||||
nodeSize: 60,
|
||||
preventOverlap: true,
|
||||
linkDistance: 150,
|
||||
};
|
||||
|
||||
// 默认布局配置
|
||||
export const DEFAULT_LAYOUT: GraphLayoutConfig = FORCE_LAYOUT_CONFIG;
|
||||
|
||||
// 图谱交互行为配置
|
||||
export const GRAPH_BEHAVIORS = ['drag-canvas', 'zoom-canvas', 'drag-element'];
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { GraphData } from '../types/graph';
|
||||
|
||||
/**
|
||||
* 示例知识图谱数据
|
||||
* 用于开发、演示或后端返回空数据时的展示
|
||||
*/
|
||||
export const SAMPLE_GRAPH_DATA: GraphData = {
|
||||
nodes: [
|
||||
{ id: '1', label: '人工智能', type: '概念', properties: { importance: 1 } },
|
||||
{ id: '2', label: '机器学习', type: '概念', properties: { importance: 0.8 } },
|
||||
{ id: '3', label: '深度学习', type: '概念', properties: { importance: 0.7 } },
|
||||
{ id: '4', label: '神经网络', type: '概念', properties: { importance: 0.6 } },
|
||||
{ id: '5', label: 'TensorFlow', type: '工具', properties: { importance: 0.5 } },
|
||||
{ id: '6', label: 'PyTorch', type: '工具', properties: { importance: 0.5 } },
|
||||
{ id: '7', label: '自然语言处理', type: '应用', properties: { importance: 0.6 } },
|
||||
{ id: '8', label: '计算机视觉', type: '应用', properties: { importance: 0.6 } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', source: '1', target: '2', label: '包含' },
|
||||
{ id: 'e2', source: '2', target: '3', label: '子领域' },
|
||||
{ id: 'e3', source: '3', target: '4', label: '基于' },
|
||||
{ id: 'e4', source: '3', target: '5', label: '框架' },
|
||||
{ id: 'e5', source: '3', target: '6', label: '框架' },
|
||||
{ id: 'e6', source: '1', target: '7', label: '应用' },
|
||||
{ id: 'e7', source: '1', target: '8', label: '应用' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import type { GraphData, Node, SearchNode } from '../types/graph';
|
||||
import { graphApi } from '../services/graphApi';
|
||||
import { SAMPLE_GRAPH_DATA } from '../config/sampleData';
|
||||
|
||||
/**
|
||||
* 知识图谱数据管理 Hook
|
||||
* 负责图谱数据的加载、搜索和节点选择
|
||||
*/
|
||||
export const useGraphData = () => {
|
||||
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] });
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [hoveredNode, setHoveredNode] = useState<Node | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<SearchNode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 从后端加载图谱数据
|
||||
const loadGraphData = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await graphApi.getData();
|
||||
|
||||
// 如果后端返回空数据,使用示例数据
|
||||
if (data.nodes.length === 0) {
|
||||
setGraphData(SAMPLE_GRAPH_DATA);
|
||||
} else {
|
||||
setGraphData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load graph data:', error);
|
||||
// API 失败时使用示例数据作为降级方案
|
||||
setGraphData(SAMPLE_GRAPH_DATA);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 搜索节点(前端过滤,可根据需要切换到后端搜索)
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
const results = graphData.nodes
|
||||
.filter((node: Node) =>
|
||||
node.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
node.id.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.map((node: Node) => ({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
matched: true,
|
||||
}));
|
||||
setSearchResults(results);
|
||||
}, [graphData]);
|
||||
|
||||
// 通过 ID 选择节点(用于搜索结果点击)
|
||||
const handleNodeSelectById = useCallback((nodeId: string) => {
|
||||
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
}
|
||||
}, [graphData.nodes]);
|
||||
|
||||
// 选择节点(用于图谱点击)
|
||||
const handleNodeSelect = useCallback((node: Node) => {
|
||||
setSelectedNode(node);
|
||||
}, []);
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
loadGraphData();
|
||||
}, [loadGraphData]);
|
||||
|
||||
return {
|
||||
graphData,
|
||||
selectedNode,
|
||||
hoveredNode,
|
||||
searchResults,
|
||||
loading,
|
||||
setHoveredNode,
|
||||
setSelectedNode,
|
||||
setSearchResults,
|
||||
handleSearch,
|
||||
handleNodeSelectById,
|
||||
handleNodeSelect,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,21 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import type { GraphLayoutConfig } from '../types/graph';
|
||||
import { DEFAULT_LAYOUT } from '../config/layout';
|
||||
|
||||
/**
|
||||
* 知识图谱布局管理 Hook
|
||||
* 负责布局状态的管理和切换
|
||||
*/
|
||||
export const useGraphLayout = () => {
|
||||
const [layout, setLayout] = useState<GraphLayoutConfig>(DEFAULT_LAYOUT);
|
||||
|
||||
// 切换布局类型(目前只支持 force 力导向布局)
|
||||
const handleLayoutChange = useCallback((newLayoutType: GraphLayoutConfig['type']) => {
|
||||
setLayout({ type: newLayoutType });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
layout,
|
||||
handleLayoutChange,
|
||||
};
|
||||
};
|
||||
@@ -1,96 +1,30 @@
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import GraphView from '../components/GraphView/index';
|
||||
import SearchBar from '../components/SearchBar/index';
|
||||
import NodePanel from '../components/NodePanel/index';
|
||||
import type { GraphData, Node, SearchNode, GraphLayoutConfig } from '../types/graph';
|
||||
import { graphApi } from '../services/graphApi';
|
||||
import { useGraphData } from '../hooks/useGraphData';
|
||||
import { useGraphLayout } from '../hooks/useGraphLayout';
|
||||
|
||||
/**
|
||||
* 知识图谱主页面组件
|
||||
* 功能:
|
||||
* - 展示交互式知识图谱
|
||||
* - 支持节点搜索和选择
|
||||
* - 支持查看节点详情
|
||||
*/
|
||||
export const KnowledgeGraph = () => {
|
||||
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] });
|
||||
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
||||
const [hoveredNode, setHoveredNode] = useState<Node | null>(null);
|
||||
const [searchResults, setSearchResults] = useState<SearchNode[]>([]);
|
||||
const [layout, setLayout] = useState<GraphLayoutConfig>({ type: 'force' });
|
||||
const [loading, setLoading] = useState(true);
|
||||
const {
|
||||
graphData,
|
||||
selectedNode,
|
||||
hoveredNode,
|
||||
searchResults,
|
||||
loading,
|
||||
setHoveredNode,
|
||||
setSelectedNode,
|
||||
handleSearch,
|
||||
handleNodeSelectById,
|
||||
} = useGraphData();
|
||||
|
||||
const handleSearch = useCallback((query: string) => {
|
||||
const results = graphData.nodes
|
||||
.filter((node: Node) =>
|
||||
node.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||
node.id.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
.map((node: Node) => ({
|
||||
id: node.id,
|
||||
label: node.label,
|
||||
type: node.type,
|
||||
matched: true,
|
||||
}));
|
||||
setSearchResults(results);
|
||||
}, [graphData]);
|
||||
|
||||
const handleNodeSelect = useCallback((nodeId: string) => {
|
||||
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
|
||||
if (node) {
|
||||
setSelectedNode(node);
|
||||
}
|
||||
}, [graphData.nodes]);
|
||||
|
||||
const handleNodeClick = useCallback((node: Node) => {
|
||||
setSelectedNode(node);
|
||||
}, []);
|
||||
|
||||
const handleLayoutChange = useCallback((newLayout: GraphLayoutConfig['type']) => {
|
||||
setLayout({ type: newLayout });
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const loadGraphData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await graphApi.getData();
|
||||
|
||||
console.log('🎯 KnowledgeGraph - Received data:', data);
|
||||
console.log('🎯 KnowledgeGraph - Node count:', data.nodes.length);
|
||||
console.log('🎯 KnowledgeGraph - First node properties:', data.nodes[0]?.properties);
|
||||
|
||||
// 添加一些示例数据,如果 API 返回空数据
|
||||
if (data.nodes.length === 0) {
|
||||
console.log('⚠️ API returned empty data, using sample data');
|
||||
const sampleData: GraphData = {
|
||||
nodes: [
|
||||
{ id: '1', label: '人工智能', type: '概念', properties: { importance: 1 } },
|
||||
{ id: '2', label: '机器学习', type: '概念', properties: { importance: 0.8 } },
|
||||
{ id: '3', label: '深度学习', type: '概念', properties: { importance: 0.7 } },
|
||||
{ id: '4', label: '神经网络', type: '概念', properties: { importance: 0.6 } },
|
||||
{ id: '5', label: 'TensorFlow', type: '工具', properties: { importance: 0.5 } },
|
||||
{ id: '6', label: 'PyTorch', type: '工具', properties: { importance: 0.5 } },
|
||||
{ id: '7', label: '自然语言处理', type: '应用', properties: { importance: 0.6 } },
|
||||
{ id: '8', label: '计算机视觉', type: '应用', properties: { importance: 0.6 } },
|
||||
],
|
||||
edges: [
|
||||
{ id: 'e1', source: '1', target: '2', label: '包含' },
|
||||
{ id: 'e2', source: '2', target: '3', label: '子领域' },
|
||||
{ id: 'e3', source: '3', target: '4', label: '基于' },
|
||||
{ id: 'e4', source: '3', target: '5', label: '框架' },
|
||||
{ id: 'e5', source: '3', target: '6', label: '框架' },
|
||||
{ id: 'e6', source: '1', target: '7', label: '应用' },
|
||||
{ id: 'e7', source: '1', target: '8', label: '应用' },
|
||||
],
|
||||
};
|
||||
setGraphData(sampleData);
|
||||
} else {
|
||||
console.log('✅ Setting graph data from API');
|
||||
setGraphData(data);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load graph data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadGraphData();
|
||||
}, []);
|
||||
const { layout } = useGraphLayout();
|
||||
|
||||
return (
|
||||
<div className="w-full h-screen flex flex-col bg-gray-50">
|
||||
@@ -103,27 +37,15 @@ export const KnowledgeGraph = () => {
|
||||
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<h1 className="text-xl font-semibold text-gray-800">知识图谱</h1>
|
||||
<div className="flex space-x-2">
|
||||
{(['force', 'circular', 'grid', 'radial'] as const).map((layoutType) => (
|
||||
<button
|
||||
key={layoutType}
|
||||
onClick={() => handleLayoutChange(layoutType)}
|
||||
className={`px-3 py-1 rounded text-sm transition-colors ${
|
||||
layout.type === layoutType
|
||||
? 'bg-purple-600 text-white'
|
||||
: 'bg-gray-100 text-gray-700 hover:bg-gray-200'
|
||||
}`}
|
||||
>
|
||||
{layoutType}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 bg-gray-100 px-2 py-1 rounded">
|
||||
{layout.type} 布局
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<SearchBar
|
||||
onSearch={handleSearch}
|
||||
onNodeSelect={handleNodeSelect}
|
||||
onNodeSelect={handleNodeSelectById}
|
||||
searchResults={searchResults}
|
||||
/>
|
||||
</div>
|
||||
@@ -134,7 +56,7 @@ export const KnowledgeGraph = () => {
|
||||
<GraphView
|
||||
data={graphData}
|
||||
layout={layout}
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeClick={setSelectedNode}
|
||||
onNodeHover={setHoveredNode}
|
||||
/>
|
||||
|
||||
|
||||
@@ -1,56 +1,79 @@
|
||||
import axios from 'axios';
|
||||
import type { GraphData, Node, Edge } from '../types/graph';
|
||||
import { API_BASE_URL, API_TIMEOUT } from '../config/constants';
|
||||
|
||||
const API_BASE_URL = 'http://localhost:3001/api';
|
||||
|
||||
/**
|
||||
* API 客户端实例
|
||||
* 统一配置了 base URL、超时时间和默认请求头
|
||||
*/
|
||||
const apiClient = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
timeout: 10000,
|
||||
timeout: API_TIMEOUT,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* 知识图谱 API 服务
|
||||
* 提供与后端交互的所有图谱相关接口
|
||||
*/
|
||||
export const graphApi = {
|
||||
/**
|
||||
* 获取完整的知识图谱数据
|
||||
* @returns 节点和边的集合
|
||||
*/
|
||||
async getData(): Promise<GraphData> {
|
||||
try {
|
||||
const response = await apiClient.get<GraphData>('/graph');
|
||||
console.log('🔍 API Response - Graph Data:', JSON.stringify(response.data, null, 2));
|
||||
console.log('🔍 API Response - First Node:', response.data.nodes[0]);
|
||||
console.log('🔍 API Response - First Node Properties:', response.data.nodes[0]?.properties);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching graph data:', error);
|
||||
console.error('Failed to fetch graph data:', error);
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 搜索节点(后端搜索)
|
||||
* @param query 搜索关键词
|
||||
* @returns 匹配的节点列表
|
||||
*/
|
||||
async searchNodes(query: string): Promise<Node[]> {
|
||||
try {
|
||||
const response = await apiClient.get<Node[]>(`/search?q=${encodeURIComponent(query)}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error searching nodes:', error);
|
||||
console.error('Failed to search nodes:', error);
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 根据 ID 获取节点详情
|
||||
* @param id 节点 ID
|
||||
* @returns 节点对象或 null
|
||||
*/
|
||||
async getNodeById(id: string): Promise<Node | null> {
|
||||
try {
|
||||
const response = await apiClient.get<Node>(`/nodes/${id}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching node:', error);
|
||||
console.error('Failed to fetch node:', error);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* 获取节点的邻居节点和连接边
|
||||
* @param nodeId 节点 ID
|
||||
* @returns 包含邻居节点和边的对象
|
||||
*/
|
||||
async getNeighbors(nodeId: string): Promise<{ nodes: Node[]; edges: Edge[] }> {
|
||||
try {
|
||||
const response = await apiClient.get(`/nodes/${nodeId}/neighbors`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('Error fetching neighbors:', error);
|
||||
console.error('Failed to fetch neighbors:', error);
|
||||
return { nodes: [], edges: [] };
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
/**
|
||||
* 知识图谱数据类型定义
|
||||
*/
|
||||
|
||||
/**
|
||||
* 图谱节点
|
||||
* 表示知识图谱中的一个实体或概念
|
||||
*/
|
||||
export interface Node {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -13,6 +21,10 @@ export interface Node {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 图谱边
|
||||
* 表示节点之间的关系
|
||||
*/
|
||||
export interface Edge {
|
||||
id: string;
|
||||
source: string;
|
||||
@@ -27,18 +39,30 @@ export interface Edge {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 图谱数据
|
||||
* 包含所有节点和边的集合
|
||||
*/
|
||||
export interface GraphData {
|
||||
nodes: Node[];
|
||||
edges: Edge[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 图谱布局配置
|
||||
* 目前仅支持 force 力导向布局
|
||||
*/
|
||||
export interface GraphLayoutConfig {
|
||||
type: 'force' | 'circular' | 'grid' | 'radial';
|
||||
type: 'force';
|
||||
nodeSize?: number;
|
||||
linkDistance?: number;
|
||||
center?: [number, number];
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索结果节点
|
||||
* 用于搜索功能展示的简化节点信息
|
||||
*/
|
||||
export interface SearchNode {
|
||||
id: string;
|
||||
label: string;
|
||||
|
||||
@@ -1,3 +1,15 @@
|
||||
/**
|
||||
* 通用工具函数
|
||||
* 虽然当前未被使用,但保留以备未来需求
|
||||
*/
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* 延迟执行函数,如果在延迟时间内再次调用则重置计时器
|
||||
* @param func 要执行的函数
|
||||
* @param wait 延迟时间(毫秒)
|
||||
* @returns 防抖后的函数
|
||||
*/
|
||||
export const debounce = <T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
wait: number
|
||||
@@ -12,6 +24,13 @@ export const debounce = <T extends (...args: unknown[]) => unknown>(
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* 限制函数执行频率,在指定时间内只执行一次
|
||||
* @param func 要执行的函数
|
||||
* @param limit 时间间隔(毫秒)
|
||||
* @returns 节流后的函数
|
||||
*/
|
||||
export const throttle = <T extends (...args: unknown[]) => unknown>(
|
||||
func: T,
|
||||
limit: number
|
||||
@@ -27,6 +46,10 @@ export const throttle = <T extends (...args: unknown[]) => unknown>(
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 生成 UUID
|
||||
* @returns UUID 字符串
|
||||
*/
|
||||
export const generateUUID = (): string => {
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
@@ -35,6 +58,11 @@ export const generateUUID = (): string => {
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据重要性计算节点大小
|
||||
* @param importance 重要性系数(默认为 1)
|
||||
* @returns 节点大小
|
||||
*/
|
||||
export const calculateNodeSize = (importance: number = 1): number => {
|
||||
const baseSize = 40;
|
||||
const multiplier = importance * 20;
|
||||
|
||||
Reference in New Issue
Block a user