feat: 新增对前端用Zustand进行状态管理

This commit is contained in:
hhs
2026-04-10 14:40:13 +08:00
parent 8846228399
commit 82959dad19
10 changed files with 182 additions and 177 deletions
+16 -19
View File
@@ -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">
+8 -21
View File
@@ -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('');
};
-101
View File
@@ -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,
};
};
-15
View File
@@ -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,
};
};
+13 -19
View File
@@ -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>
)}
</>
+91
View File
@@ -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');
},
}));
+2
View File
@@ -0,0 +1,2 @@
export { useGraphStore } from './graphStore';
export { useLayoutStore } from './layoutStore';
+19
View File
@@ -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 });
},
}));