feat: 新增对前端用Zustand进行状态管理
This commit is contained in:
@@ -1,16 +1,15 @@
|
||||
import type { Node } from '../../types/graph';
|
||||
|
||||
interface NodePanelProps {
|
||||
node: Node | null;
|
||||
onClose?: () => void;
|
||||
}
|
||||
import { useGraphStore } from '../../store';
|
||||
|
||||
/**
|
||||
* 节点详情面板组件
|
||||
* 展示选中节点的详细信息,包括属性和样式
|
||||
*/
|
||||
export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
if (!node) return null;
|
||||
export const NodePanel = () => {
|
||||
const { selectedNode, setSelectedNode } = useGraphStore();
|
||||
|
||||
if (!selectedNode) return null;
|
||||
|
||||
const node = selectedNode;
|
||||
|
||||
// 判断属性是否可展示
|
||||
const hasProperties = node.properties && Object.keys(node.properties).length > 0;
|
||||
@@ -19,17 +18,15 @@ export const NodePanel = ({ node, onClose }: NodePanelProps) => {
|
||||
<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 && (
|
||||
<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" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setSelectedNode(null)}
|
||||
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" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-4 space-y-4">
|
||||
|
||||
@@ -1,24 +1,13 @@
|
||||
import { useState, type ChangeEvent, type KeyboardEvent } from 'react';
|
||||
import type { SearchNode } from '../../types/graph';
|
||||
import { useGraphStore } from '../../store';
|
||||
import { SEARCH_RESULT_MAX_HEIGHT } from '../../config/constants';
|
||||
|
||||
interface SearchBarProps {
|
||||
onSearch?: (query: string) => void;
|
||||
onNodeSelect?: (nodeId: string) => void;
|
||||
searchResults?: SearchNode[];
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 搜索栏组件
|
||||
* 支持实时搜索和结果展示
|
||||
*/
|
||||
export const SearchBar = ({
|
||||
onSearch,
|
||||
onNodeSelect,
|
||||
searchResults = [],
|
||||
placeholder = '搜索节点...',
|
||||
}: SearchBarProps) => {
|
||||
export const SearchBar = ({ placeholder = '搜索节点...' }: { placeholder?: string }) => {
|
||||
const { handleSearch, handleNodeSelectById, searchResults } = useGraphStore();
|
||||
const [query, setQuery] = useState('');
|
||||
const [showResults, setShowResults] = useState(false);
|
||||
|
||||
@@ -26,8 +15,8 @@ export const SearchBar = ({
|
||||
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
setQuery(value);
|
||||
if (onSearch && value.trim()) {
|
||||
onSearch(value.trim());
|
||||
if (value.trim()) {
|
||||
handleSearch(value.trim());
|
||||
setShowResults(true);
|
||||
} else {
|
||||
setShowResults(false);
|
||||
@@ -37,8 +26,8 @@ export const SearchBar = ({
|
||||
// 处理键盘事件(Enter 确认搜索)
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
if (onSearch && query.trim()) {
|
||||
onSearch(query.trim());
|
||||
if (query.trim()) {
|
||||
handleSearch(query.trim());
|
||||
}
|
||||
setShowResults(false);
|
||||
}
|
||||
@@ -46,9 +35,7 @@ export const SearchBar = ({
|
||||
|
||||
// 选择搜索结果中的节点
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
if (onNodeSelect) {
|
||||
onNodeSelect(nodeId);
|
||||
}
|
||||
handleNodeSelectById(nodeId);
|
||||
setShowResults(false);
|
||||
setQuery('');
|
||||
};
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
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';
|
||||
import { resetColorAssignment } from '../config/colors';
|
||||
|
||||
/**
|
||||
* 知识图谱数据管理 Hook
|
||||
* 负责图谱数据的加载、搜索和节点选择
|
||||
*/
|
||||
export const useGraphData = () => {
|
||||
const [graphData, setGraphData] = useState<GraphData>({ nodes: [], edges: [] });
|
||||
const [selectedNode, setSelectedNode] = 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();
|
||||
|
||||
// 重置颜色分配缓存,确保每次加载数据时颜色分配一致
|
||||
resetColorAssignment();
|
||||
|
||||
// 如果后端返回空数据,使用示例数据
|
||||
if (data.nodes.length === 0) {
|
||||
setGraphData({ ...SAMPLE_GRAPH_DATA, id: `sample-${Date.now()}` });
|
||||
} else {
|
||||
setGraphData({ ...data, id: `data-${Date.now()}` });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load graph data:', error);
|
||||
// 重置颜色分配缓存
|
||||
resetColorAssignment();
|
||||
// API 失败时使用示例数据作为降级方案
|
||||
setGraphData({ ...SAMPLE_GRAPH_DATA, id: `fallback-${Date.now()}` });
|
||||
} 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) => {
|
||||
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 | null) => {
|
||||
console.log('[useGraphData] handleNodeSelect called with node:', node);
|
||||
console.log('[useGraphData] About to call setSelectedNode');
|
||||
setSelectedNode(node);
|
||||
console.log('[useGraphData] setSelectedNode called');
|
||||
}, []);
|
||||
|
||||
// 监听 selectedNode 变化,用于调试
|
||||
useEffect(() => {
|
||||
console.log('[useGraphData] selectedNode state changed:', selectedNode);
|
||||
console.log('[useGraphData] selectedNode truthiness:', !!selectedNode);
|
||||
}, [selectedNode]);
|
||||
|
||||
// 组件挂载时加载数据
|
||||
useEffect(() => {
|
||||
loadGraphData();
|
||||
}, [loadGraphData]);
|
||||
|
||||
return {
|
||||
graphData,
|
||||
selectedNode,
|
||||
searchResults,
|
||||
loading,
|
||||
setSelectedNode,
|
||||
handleSearch,
|
||||
handleNodeSelectById,
|
||||
handleNodeSelect,
|
||||
};
|
||||
};
|
||||
@@ -1,15 +0,0 @@
|
||||
import type { GraphLayoutConfig } from '../types/graph';
|
||||
|
||||
/**
|
||||
* 知识图谱布局管理 Hook
|
||||
* 返回固定的默认布局配置
|
||||
*/
|
||||
export const useGraphLayout = () => {
|
||||
return {
|
||||
layout: {
|
||||
type: 'd3-force' as const,
|
||||
collide: { radius: 60 } as const,
|
||||
link: { distance: 150 } as const,
|
||||
} as GraphLayoutConfig,
|
||||
};
|
||||
};
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import GraphView from '../components/GraphView/index';
|
||||
import SearchBar from '../components/SearchBar/index';
|
||||
import NodePanel from '../components/NodePanel/index';
|
||||
import Legend from '../components/Legend/index';
|
||||
import { useGraphData } from '../hooks/useGraphData';
|
||||
import { useGraphLayout } from '../hooks/useGraphLayout';
|
||||
import { useGraphStore } from '../store';
|
||||
import { useLayoutStore } from '../store';
|
||||
import { generateLegendData } from '../config/colors';
|
||||
|
||||
/**
|
||||
@@ -18,15 +18,16 @@ export const KnowledgeGraph = () => {
|
||||
const {
|
||||
graphData,
|
||||
selectedNode,
|
||||
searchResults,
|
||||
loading,
|
||||
setSelectedNode,
|
||||
handleSearch,
|
||||
handleNodeSelectById,
|
||||
loadGraphData,
|
||||
handleNodeSelect,
|
||||
} = useGraphData();
|
||||
loading,
|
||||
} = useGraphStore();
|
||||
|
||||
const { layout } = useGraphLayout();
|
||||
const { layout } = useLayoutStore();
|
||||
|
||||
useEffect(() => {
|
||||
loadGraphData();
|
||||
}, [loadGraphData]);
|
||||
|
||||
const legendData = useMemo(() => {
|
||||
if (!graphData || graphData.nodes.length === 0) {
|
||||
@@ -52,11 +53,7 @@ export const KnowledgeGraph = () => {
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<SearchBar
|
||||
onSearch={handleSearch}
|
||||
onNodeSelect={handleNodeSelectById}
|
||||
searchResults={searchResults}
|
||||
/>
|
||||
<SearchBar />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -73,10 +70,7 @@ export const KnowledgeGraph = () => {
|
||||
|
||||
{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)}
|
||||
/>
|
||||
<NodePanel />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { create } from 'zustand';
|
||||
import type { GraphData, Node, SearchNode } from '../types/graph';
|
||||
import { graphApi } from '../services/graphApi';
|
||||
import { SAMPLE_GRAPH_DATA } from '../config/sampleData';
|
||||
import { resetColorAssignment } from '../config/colors';
|
||||
|
||||
interface GraphState {
|
||||
graphData: GraphData;
|
||||
selectedNode: Node | null;
|
||||
searchResults: SearchNode[];
|
||||
loading: boolean;
|
||||
addGraphData: (data: GraphData) => void;
|
||||
setSelectedNode: (node: Node | null) => void;
|
||||
loadGraphData: () => Promise<void>;
|
||||
handleSearch: (query: string) => void;
|
||||
handleNodeSelect: (node: Node | null) => void;
|
||||
handleNodeSelectById: (nodeId: string) => void;
|
||||
}
|
||||
|
||||
export const useGraphStore = create<GraphState>((set, get) => ({
|
||||
graphData: { nodes: [], edges: [] },
|
||||
selectedNode: null,
|
||||
searchResults: [],
|
||||
loading: true,
|
||||
|
||||
addGraphData: (data: GraphData) => {
|
||||
set({ graphData: data });
|
||||
},
|
||||
|
||||
setSelectedNode: (node: Node | null) => {
|
||||
console.log('[graphStore] setSelectedNode called with:', node);
|
||||
set({ selectedNode: node });
|
||||
},
|
||||
|
||||
loadGraphData: async () => {
|
||||
try {
|
||||
set({ loading: true });
|
||||
const data = await graphApi.getData();
|
||||
|
||||
resetColorAssignment();
|
||||
|
||||
if (data.nodes.length === 0) {
|
||||
set({ graphData: { ...SAMPLE_GRAPH_DATA, id: `sample-${Date.now()}` } });
|
||||
} else {
|
||||
set({ graphData: { ...data, id: `data-${Date.now()}` } });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load graph data:', error);
|
||||
resetColorAssignment();
|
||||
set({ graphData: { ...SAMPLE_GRAPH_DATA, id: `fallback-${Date.now()}` } });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
handleSearch: (query: string) => {
|
||||
const { graphData } = get();
|
||||
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,
|
||||
}));
|
||||
set({ searchResults: results });
|
||||
},
|
||||
|
||||
handleNodeSelectById: (nodeId: string) => {
|
||||
console.log('[graphStore] handleNodeSelectById called with id:', nodeId);
|
||||
const { graphData } = get();
|
||||
const node = graphData.nodes.find((n: Node) => n.id === nodeId);
|
||||
console.log('[graphStore] Found node:', node);
|
||||
if (node) {
|
||||
console.log('[graphStore] Setting selectedNode:', node);
|
||||
set({ selectedNode: node });
|
||||
} else {
|
||||
console.log('[graphStore] Node not found with id:', nodeId);
|
||||
}
|
||||
},
|
||||
|
||||
handleNodeSelect: (node: Node | null) => {
|
||||
console.log('[graphStore] handleNodeSelect called with node:', node);
|
||||
console.log('[graphStore] About to set selectedNode');
|
||||
set({ selectedNode: node });
|
||||
console.log('[graphStore] selectedNode set');
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,2 @@
|
||||
export { useGraphStore } from './graphStore';
|
||||
export { useLayoutStore } from './layoutStore';
|
||||
@@ -0,0 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import type { GraphLayoutConfig } from '../types/graph';
|
||||
|
||||
interface LayoutState {
|
||||
layout: GraphLayoutConfig;
|
||||
setLayout: (layout: GraphLayoutConfig) => void;
|
||||
}
|
||||
|
||||
export const useLayoutStore = create<LayoutState>((set) => ({
|
||||
layout: {
|
||||
type: 'd3-force' as const,
|
||||
collide: { radius: 60 } as const,
|
||||
link: { distance: 150 } as const,
|
||||
} as GraphLayoutConfig,
|
||||
|
||||
setLayout: (layout: GraphLayoutConfig) => {
|
||||
set({ layout });
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user