refactor: 重构前端代码并增加注释

This commit is contained in:
2026-04-06 14:24:45 +08:00
parent 882e14a47b
commit 120edca338
13 changed files with 463 additions and 312 deletions
+122 -166
View File
@@ -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>
+21 -30
View File
@@ -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>
+13 -1
View File
@@ -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 && (
+18
View File
@@ -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;
};
+26
View File
@@ -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;
+18
View File
@@ -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'];
+27
View File
@@ -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: '应用' },
],
};
+85
View File
@@ -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,
};
};
+21
View File
@@ -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,
};
};
+26 -104
View File
@@ -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}
/>
+33 -10
View File
@@ -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: [] };
}
},
+25 -1
View File
@@ -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;
+28
View File
@@ -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;