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
+31 -1
View File
@@ -18,7 +18,8 @@
"postcss": "^8.5.8", "postcss": "^8.5.8",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwindcss": "^4.2.2" "tailwindcss": "^4.2.2",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
@@ -6816,6 +6817,35 @@
"peerDependencies": { "peerDependencies": {
"zod": "^3.25.0 || ^4.0.0" "zod": "^3.25.0 || ^4.0.0"
} }
},
"node_modules/zustand": {
"version": "5.0.12",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.12.tgz",
"integrity": "sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
} }
} }
+2 -1
View File
@@ -20,7 +20,8 @@
"postcss": "^8.5.8", "postcss": "^8.5.8",
"react": "^19.2.4", "react": "^19.2.4",
"react-dom": "^19.2.4", "react-dom": "^19.2.4",
"tailwindcss": "^4.2.2" "tailwindcss": "^4.2.2",
"zustand": "^5.0.12"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
+16 -19
View File
@@ -1,16 +1,15 @@
import type { Node } from '../../types/graph'; import { useGraphStore } from '../../store';
interface NodePanelProps {
node: Node | null;
onClose?: () => void;
}
/** /**
* 节点详情面板组件 * 节点详情面板组件
* 展示选中节点的详细信息,包括属性和样式 * 展示选中节点的详细信息,包括属性和样式
*/ */
export const NodePanel = ({ node, onClose }: NodePanelProps) => { export const NodePanel = () => {
if (!node) return null; const { selectedNode, setSelectedNode } = useGraphStore();
if (!selectedNode) return null;
const node = selectedNode;
// 判断属性是否可展示 // 判断属性是否可展示
const hasProperties = node.properties && Object.keys(node.properties).length > 0; 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="w-full overflow-y-auto">
<div className="p-4 border-b border-gray-200 flex justify-between items-center"> <div className="p-4 border-b border-gray-200 flex justify-between items-center">
<h3 className="text-lg font-semibold text-gray-800">节点详情</h3> <h3 className="text-lg font-semibold text-gray-800">节点详情</h3>
{onClose && ( <button
<button onClick={() => setSelectedNode(null)}
onClick={onClose} className="text-gray-400 hover:text-gray-600 transition-colors"
className="text-gray-400 hover:text-gray-600 transition-colors" aria-label="关闭面板"
aria-label="关闭面板" >
> <svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<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" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" /> </svg>
</svg> </button>
</button>
)}
</div> </div>
<div className="p-4 space-y-4"> <div className="p-4 space-y-4">
+8 -21
View File
@@ -1,24 +1,13 @@
import { useState, type ChangeEvent, type KeyboardEvent } from 'react'; 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'; 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 = ({ export const SearchBar = ({ placeholder = '搜索节点...' }: { placeholder?: string }) => {
onSearch, const { handleSearch, handleNodeSelectById, searchResults } = useGraphStore();
onNodeSelect,
searchResults = [],
placeholder = '搜索节点...',
}: SearchBarProps) => {
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const [showResults, setShowResults] = useState(false); const [showResults, setShowResults] = useState(false);
@@ -26,8 +15,8 @@ export const SearchBar = ({
const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => { const handleInputChange = (e: ChangeEvent<HTMLInputElement>) => {
const value = e.target.value; const value = e.target.value;
setQuery(value); setQuery(value);
if (onSearch && value.trim()) { if (value.trim()) {
onSearch(value.trim()); handleSearch(value.trim());
setShowResults(true); setShowResults(true);
} else { } else {
setShowResults(false); setShowResults(false);
@@ -37,8 +26,8 @@ export const SearchBar = ({
// 处理键盘事件(Enter 确认搜索) // 处理键盘事件(Enter 确认搜索)
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => { const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
if (onSearch && query.trim()) { if (query.trim()) {
onSearch(query.trim()); handleSearch(query.trim());
} }
setShowResults(false); setShowResults(false);
} }
@@ -46,9 +35,7 @@ export const SearchBar = ({
// 选择搜索结果中的节点 // 选择搜索结果中的节点
const handleNodeClick = (nodeId: string) => { const handleNodeClick = (nodeId: string) => {
if (onNodeSelect) { handleNodeSelectById(nodeId);
onNodeSelect(nodeId);
}
setShowResults(false); setShowResults(false);
setQuery(''); 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 GraphView from '../components/GraphView/index';
import SearchBar from '../components/SearchBar/index'; import SearchBar from '../components/SearchBar/index';
import NodePanel from '../components/NodePanel/index'; import NodePanel from '../components/NodePanel/index';
import Legend from '../components/Legend/index'; import Legend from '../components/Legend/index';
import { useGraphData } from '../hooks/useGraphData'; import { useGraphStore } from '../store';
import { useGraphLayout } from '../hooks/useGraphLayout'; import { useLayoutStore } from '../store';
import { generateLegendData } from '../config/colors'; import { generateLegendData } from '../config/colors';
/** /**
@@ -18,15 +18,16 @@ export const KnowledgeGraph = () => {
const { const {
graphData, graphData,
selectedNode, selectedNode,
searchResults, loadGraphData,
loading,
setSelectedNode,
handleSearch,
handleNodeSelectById,
handleNodeSelect, handleNodeSelect,
} = useGraphData(); loading,
} = useGraphStore();
const { layout } = useGraphLayout(); const { layout } = useLayoutStore();
useEffect(() => {
loadGraphData();
}, [loadGraphData]);
const legendData = useMemo(() => { const legendData = useMemo(() => {
if (!graphData || graphData.nodes.length === 0) { if (!graphData || graphData.nodes.length === 0) {
@@ -52,11 +53,7 @@ export const KnowledgeGraph = () => {
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<SearchBar <SearchBar />
onSearch={handleSearch}
onNodeSelect={handleNodeSelectById}
searchResults={searchResults}
/>
</div> </div>
</header> </header>
@@ -73,10 +70,7 @@ export const KnowledgeGraph = () => {
{selectedNode && ( {selectedNode && (
<div className="fixed right-6 top-24 w-96 bg-white rounded-lg shadow-2xl border border-gray-200 z-50"> <div className="fixed right-6 top-24 w-96 bg-white rounded-lg shadow-2xl border border-gray-200 z-50">
<NodePanel <NodePanel />
node={selectedNode}
onClose={() => setSelectedNode(null)}
/>
</div> </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 });
},
}));