+
setShowDeleteConfirm(false)} />
+
+
确认删除节点
+
+ 确定要删除节点 "{node.label}" 吗?此操作将同时删除该节点相关的所有边,且不可恢复。
+
+
+
+
+
+
+
+ )}
+
+ {/* 邻居节点对话框 */}
+ {nodePanelStatsOpen && neighborsData && (
+
+
setNodePanelStatsOpen(false)} />
+
+
+
邻居节点
+
+
+ {neighborsData.nodes.length > 0 ? (
+
+ {neighborsData.nodes.map((n: any) => (
+
{
+ const foundNode = useGraphStore.getState().graphData.nodes.find((item) => item.id === n.id);
+ if (foundNode) {
+ setSelectedNode(foundNode);
+ }
+ setNodePanelStatsOpen(false);
+ }}
+ >
+
+
+ {n.type || '无类型'}
+
+
+ ))}
+
+ ) : (
+
没有邻居节点
+ )}
+
+
+ )}
);
};
-export default NodePanel;
\ No newline at end of file
+export default NodePanel;
diff --git a/frontend/src/components/Toast/index.tsx b/frontend/src/components/Toast/index.tsx
new file mode 100644
index 0000000..469833c
--- /dev/null
+++ b/frontend/src/components/Toast/index.tsx
@@ -0,0 +1,45 @@
+import { useGraphStore } from '../../store/graphStore';
+import type { ToastType } from '../../types/graph';
+
+function getTypeStyles(type: ToastType): string {
+ const base = 'px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 min-w-[300px] animate-slide-in';
+ const styles = {
+ success: 'bg-green-500 text-white',
+ error: 'bg-red-500 text-white',
+ warning: 'bg-yellow-500 text-black',
+ info: 'bg-blue-500 text-white',
+ };
+ return `${base} ${styles[type]}`;
+}
+
+function getTypeIcon(type: ToastType): string {
+ const icons = {
+ success: '✓',
+ error: '✕',
+ warning: '⚠',
+ info: 'ℹ',
+ };
+ return icons[type];
+}
+
+export function Toast() {
+ const { toasts, removeToast } = useGraphStore();
+
+ return (
+
+ {toasts.map((toast) => (
+
+ {getTypeIcon(toast.type)}
+ {toast.message}
+
+
+ ))}
+
+ );
+}
diff --git a/frontend/src/components/Toolbar/index.tsx b/frontend/src/components/Toolbar/index.tsx
new file mode 100644
index 0000000..fc69eac
--- /dev/null
+++ b/frontend/src/components/Toolbar/index.tsx
@@ -0,0 +1,69 @@
+import { useGraphStore } from '../../store/graphStore';
+
+export function Toolbar() {
+ const {
+ startCreatingNode,
+ loading,
+ refresh,
+ setLayoutType,
+ layoutType,
+ } = useGraphStore();
+
+ const layouts = [
+ { type: 'force' as const, label: '力导向', icon: '🕸️' },
+ { type: 'circular' as const, label: '圆形', icon: '⭕' },
+ { type: 'grid' as const, label: '网格', icon: '▦' },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+ {layouts.map((layout) => (
+
+ ))}
+
+
+
+
+
+ Ctrl+N 创建节点
+ Ctrl+E 编辑节点
+ Delete 删除
+
+
+ );
+}
diff --git a/frontend/src/config/hotkeys.ts b/frontend/src/config/hotkeys.ts
new file mode 100644
index 0000000..764b922
--- /dev/null
+++ b/frontend/src/config/hotkeys.ts
@@ -0,0 +1,94 @@
+/**
+ * 快捷键配置
+ */
+
+export interface Hotkey {
+ key: string;
+ ctrl?: boolean;
+ shift?: boolean;
+ alt?: boolean;
+ description: string;
+ action: () => void;
+}
+
+export const HOTKEY_CONFIG: {
+ [key: string]: {
+ keys: string[];
+ description: string;
+ defaultAction?: () => void;
+ };
+} = {
+ CREATE_NODE: {
+ keys: ['Ctrl+N', 'Cmd+N'],
+ description: '创建节点',
+ },
+ EDIT_NODE: {
+ keys: ['Ctrl+E', 'Cmd+E'],
+ description: '编辑节点',
+ },
+ DELETE: {
+ keys: ['Delete', 'Backspace'],
+ description: '删除选中项',
+ },
+ DESELECT: {
+ keys: ['Ctrl+D', 'Cmd+D'],
+ description: '取消选择',
+ },
+ SEARCH: {
+ keys: ['Ctrl+F', 'Cmd+F'],
+ description: '聚焦搜索框',
+ },
+ REFRESH: {
+ keys: ['Ctrl+R', 'Cmd+R'],
+ description: '刷新数据',
+ },
+ EXPORT: {
+ keys: ['Ctrl+S', 'Cmd+S'],
+ description: '导出数据',
+ },
+ STATS: {
+ keys: ['Ctrl+I', 'Cmd+I'],
+ description: '显示统计信息',
+ },
+ ZOOM_IN: {
+ keys: ['Ctrl++', 'Cmd+='],
+ description: '放大',
+ },
+ ZOOM_OUT: {
+ keys: ['Ctrl+-', 'Cmd+-'],
+ description: '缩小',
+ },
+ ZOOM_RESET: {
+ keys: ['Ctrl+0', 'Cmd+0'],
+ description: '重置缩放',
+ },
+};
+
+export function parseHotkey(event: KeyboardEvent): string {
+ const parts: string[] = [];
+ if (event.ctrlKey || event.metaKey) {
+ parts.push('Ctrl');
+ } else if (event.ctrlKey) {
+ parts.push('Ctrl');
+ } else if (event.metaKey) {
+ parts.push('Cmd');
+ }
+
+ if (event.altKey) {
+ parts.push('Alt');
+ }
+
+ if (event.shiftKey) {
+ parts.push('Shift');
+ }
+
+ parts.push(event.key);
+
+ return parts.length > 1 ? parts.join('+') : event.key;
+}
+
+export function matchesHotkey(event: KeyboardEvent, pattern: string): boolean {
+ const actual = parseHotkey(event);
+ const patterns = pattern.includes('|') ? pattern.split('|') : [pattern];
+ return patterns.some((p) => actual === p);
+}
diff --git a/frontend/src/hooks/useHotkeys.ts b/frontend/src/hooks/useHotkeys.ts
new file mode 100644
index 0000000..47d3114
--- /dev/null
+++ b/frontend/src/hooks/useHotkeys.ts
@@ -0,0 +1,89 @@
+import { useEffect } from 'react';
+import { useGraphStore } from '../store/graphStore';
+import { matchesHotkey } from '../config/hotkeys';
+
+export function useHotkeys() {
+ const {
+ selectedNode,
+ selectedEdge,
+ startCreatingNode,
+ startEditingNode,
+ deleteNode,
+ deleteEdge,
+ setSelectedNode,
+ setSelectedEdge,
+ refresh,
+ } = useGraphStore();
+
+ useEffect(() => {
+ const handleKeyDown = async (event: KeyboardEvent) => {
+ const target = event.target as HTMLElement;
+
+ if (
+ target.tagName === 'INPUT' ||
+ target.tagName === 'TEXTAREA' ||
+ target.isContentEditable
+ ) {
+ return;
+ }
+
+ if (matchesHotkey(event, 'Ctrl+N|Cmd+N')) {
+ event.preventDefault();
+ startCreatingNode();
+ }
+
+ if (matchesHotkey(event, 'Ctrl+E|Cmd+E')) {
+ event.preventDefault();
+ if (selectedNode) {
+ startEditingNode(selectedNode);
+ }
+ }
+
+ if (matchesHotkey(event, 'Delete|Backspace')) {
+ event.preventDefault();
+ if (selectedNode) {
+ await deleteNode(selectedNode.id);
+ } else if (selectedEdge) {
+ await deleteEdge(selectedEdge.id);
+ }
+ }
+
+ if (matchesHotkey(event, 'Ctrl+D|Cmd+D')) {
+ event.preventDefault();
+ setSelectedNode(null);
+ setSelectedEdge(null);
+ }
+
+ if (matchesHotkey(event, 'Ctrl+F|Cmd+F')) {
+ event.preventDefault();
+ const searchInput = document.querySelector(
+ 'input[placeholder*="搜索"]'
+ ) as HTMLInputElement;
+ if (searchInput) {
+ searchInput.focus();
+ }
+ }
+
+ if (matchesHotkey(event, 'Ctrl+R|Cmd+R')) {
+ event.preventDefault();
+ await refresh();
+ }
+ };
+
+ window.addEventListener('keydown', handleKeyDown);
+
+ return () => {
+ window.removeEventListener('keydown', handleKeyDown);
+ };
+ }, [
+ selectedNode,
+ selectedEdge,
+ startCreatingNode,
+ startEditingNode,
+ deleteNode,
+ deleteEdge,
+ setSelectedNode,
+ setSelectedEdge,
+ refresh,
+ ]);
+}
diff --git a/frontend/src/pages/KnowledgeGraph.tsx b/frontend/src/pages/KnowledgeGraph.tsx
index 20a68b4..7e75258 100644
--- a/frontend/src/pages/KnowledgeGraph.tsx
+++ b/frontend/src/pages/KnowledgeGraph.tsx
@@ -1,11 +1,20 @@
-import { useMemo, useEffect } from 'react';
+import { useMemo, useEffect, useState } 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 { Toolbar } from '../components/Toolbar';
+import { ContextMenu } from '../components/ContextMenu';
+import { Toast } from '../components/Toast';
+import { NodeCreateDialog } from '../components/Dialogs/NodeCreateDialog';
+import { NodeEditDialog } from '../components/Dialogs/NodeEditDialog';
+import { EdgeCreateDialog } from '../components/Dialogs/EdgeCreateDialog';
+import { StatsDialog } from '../components/Dialogs/StatsDialog';
+import { NeighborsDialog } from '../components/Dialogs/NeighborsDialog';
+import { ExportDialog } from '../components/Dialogs/ExportDialog';
import { useGraphStore } from '../store';
import { useLayoutStore } from '../store';
import { generateLegendData } from '../config/colors';
+import { useHotkeys } from '../hooks/useHotkeys';
/**
* 知识图谱主页面组件
@@ -21,10 +30,19 @@ export const KnowledgeGraph = () => {
loadGraphData,
handleNodeSelect,
loading,
+ closeContextMenu,
} = useGraphStore();
const { layout } = useLayoutStore();
+ const [statsOpen, setStatsOpen] = useState(false);
+ const [exportOpen, setExportOpen] = useState(false);
+ const neighborNodeId = useGraphStore(state =>
+ state.contextMenuTarget?.node?.id || null
+ );
+
+ useHotkeys();
+
useEffect(() => {
loadGraphData();
}, [loadGraphData]);
@@ -44,17 +62,8 @@ export const KnowledgeGraph = () => {
) : (
<>
-