diff --git a/frontend/src/components/GraphView/index.tsx b/frontend/src/components/GraphView/index.tsx index 7db18ac..f30b3c2 100644 --- a/frontend/src/components/GraphView/index.tsx +++ b/frontend/src/components/GraphView/index.tsx @@ -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(null); const graphRef = useRef(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 = { - '概念': { 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 = ({
-
布局类型: {layout.type}
+
布局: {layout.type}
节点: {data.nodes.length} | 边: {data.edges.length}
diff --git a/frontend/src/components/NodePanel/index.tsx b/frontend/src/components/NodePanel/index.tsx index a313d4b..ddb972f 100644 --- a/frontend/src/components/NodePanel/index.tsx +++ b/frontend/src/components/NodePanel/index.tsx @@ -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 (
@@ -21,6 +23,7 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
+ {/* 基本信息 */}
节点 ID
@@ -51,32 +55,14 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
)} - {!node.properties && ( -
-
属性
-
- 暂无属性数据 -
-
- Debug: Node ID: {node.id}, Has properties?: {node.properties ? 'Yes' : 'No'} -
+ {/* 属性部分:统一处理 undefined、null 和空对象 */} +
+
+ 属性 {hasProperties && `(${Object.keys(node.properties!).length})`}
- )} - - {node.properties && Object.keys(node.properties).length === 0 && ( -
-
属性
-
- 属性对象存在但为空 -
-
- )} - - {node.properties && Object.keys(node.properties).length > 0 && ( -
-
属性 ({Object.keys(node.properties).length})
+ {hasProperties ? (
- {Object.entries(node.properties).map(([key, value]) => ( + {Object.entries(node.properties!).map(([key, value]) => (
{key}
@@ -85,9 +71,14 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
))}
-
- )} + ) : ( +
+ 暂无属性数据 +
+ )} +
+ {/* 样式信息 */} {node.style && (
样式
diff --git a/frontend/src/components/SearchBar/index.tsx b/frontend/src/components/SearchBar/index.tsx index b97020f..31325e7 100644 --- a/frontend/src/components/SearchBar/index.tsx +++ b/frontend/src/components/SearchBar/index.tsx @@ -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) => { const value = e.target.value; setQuery(value); @@ -28,6 +34,7 @@ export const SearchBar = ({ } }; + // 处理键盘事件(Enter 确认搜索) const handleKeyDown = (e: KeyboardEvent) => { 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" >
+ {/* 搜索结果下拉列表 */} {showResults && searchResults.length > 0 && ( -
+
{searchResults.map((node) => (
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} >
{node.label}
{node.type && ( diff --git a/frontend/src/config/colors.ts b/frontend/src/config/colors.ts new file mode 100644 index 0000000..99df291 --- /dev/null +++ b/frontend/src/config/colors.ts @@ -0,0 +1,18 @@ +import type { Node } from '../types/graph'; + +// 节点类型颜色配置 +// 用于统一管理知识图谱中不同类型节点的视觉样式 +export const NODE_COLOR_MAP: Record = { + '概念': { 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; +}; diff --git a/frontend/src/config/constants.ts b/frontend/src/config/constants.ts new file mode 100644 index 0000000..d100222 --- /dev/null +++ b/frontend/src/config/constants.ts @@ -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; diff --git a/frontend/src/config/layout.ts b/frontend/src/config/layout.ts new file mode 100644 index 0000000..ec003fa --- /dev/null +++ b/frontend/src/config/layout.ts @@ -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']; diff --git a/frontend/src/config/sampleData.ts b/frontend/src/config/sampleData.ts new file mode 100644 index 0000000..66b7022 --- /dev/null +++ b/frontend/src/config/sampleData.ts @@ -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: '应用' }, + ], +}; diff --git a/frontend/src/hooks/useGraphData.ts b/frontend/src/hooks/useGraphData.ts new file mode 100644 index 0000000..ff97b9d --- /dev/null +++ b/frontend/src/hooks/useGraphData.ts @@ -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({ nodes: [], edges: [] }); + const [selectedNode, setSelectedNode] = useState(null); + const [hoveredNode, setHoveredNode] = useState(null); + const [searchResults, setSearchResults] = useState([]); + 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, + }; +}; diff --git a/frontend/src/hooks/useGraphLayout.ts b/frontend/src/hooks/useGraphLayout.ts new file mode 100644 index 0000000..62bf09f --- /dev/null +++ b/frontend/src/hooks/useGraphLayout.ts @@ -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(DEFAULT_LAYOUT); + + // 切换布局类型(目前只支持 force 力导向布局) + const handleLayoutChange = useCallback((newLayoutType: GraphLayoutConfig['type']) => { + setLayout({ type: newLayoutType }); + }, []); + + return { + layout, + handleLayoutChange, + }; +}; diff --git a/frontend/src/pages/KnowledgeGraph.tsx b/frontend/src/pages/KnowledgeGraph.tsx index 8aa7ffe..a23f343 100644 --- a/frontend/src/pages/KnowledgeGraph.tsx +++ b/frontend/src/pages/KnowledgeGraph.tsx @@ -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({ nodes: [], edges: [] }); - const [selectedNode, setSelectedNode] = useState(null); - const [hoveredNode, setHoveredNode] = useState(null); - const [searchResults, setSearchResults] = useState([]); - const [layout, setLayout] = useState({ 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 (
@@ -103,27 +37,15 @@ export const KnowledgeGraph = () => {

知识图谱

-
- {(['force', 'circular', 'grid', 'radial'] as const).map((layoutType) => ( - - ))} -
+ + {layout.type} 布局 +
@@ -134,7 +56,7 @@ export const KnowledgeGraph = () => { diff --git a/frontend/src/services/graphApi.ts b/frontend/src/services/graphApi.ts index c5f11b0..5a1d407 100644 --- a/frontend/src/services/graphApi.ts +++ b/frontend/src/services/graphApi.ts @@ -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 { try { const response = await apiClient.get('/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 { try { const response = await apiClient.get(`/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 { try { const response = await apiClient.get(`/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: [] }; } }, diff --git a/frontend/src/types/graph.ts b/frontend/src/types/graph.ts index b6a2560..c315355 100644 --- a/frontend/src/types/graph.ts +++ b/frontend/src/types/graph.ts @@ -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; diff --git a/frontend/src/utils/helpers.ts b/frontend/src/utils/helpers.ts index 0bf68d8..23e2971 100644 --- a/frontend/src/utils/helpers.ts +++ b/frontend/src/utils/helpers.ts @@ -1,3 +1,15 @@ +/** + * 通用工具函数 + * 虽然当前未被使用,但保留以备未来需求 + */ + +/** + * 防抖函数 + * 延迟执行函数,如果在延迟时间内再次调用则重置计时器 + * @param func 要执行的函数 + * @param wait 延迟时间(毫秒) + * @returns 防抖后的函数 + */ export const debounce = unknown>( func: T, wait: number @@ -12,6 +24,13 @@ export const debounce = unknown>( }; }; +/** + * 节流函数 + * 限制函数执行频率,在指定时间内只执行一次 + * @param func 要执行的函数 + * @param limit 时间间隔(毫秒) + * @returns 节流后的函数 + */ export const throttle = unknown>( func: T, limit: number @@ -27,6 +46,10 @@ export const throttle = 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;