diff --git a/.claude/settings.local.json b/.claude/settings.local.json index c727a65..0396799 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,8 @@ "Bash(sed -i 's/models\\\\./model\\\\./g' internal/handler/*.go)", "Bash(sed -i 's/models\\\\./model\\\\./g' cmd/server/main.go)", "Bash(go test:*)", - "Bash(swag init:*)" + "Bash(swag init:*)", + "Bash(npm run *)" ] } } diff --git a/frontend/src/components/ContextMenu/index.tsx b/frontend/src/components/ContextMenu/index.tsx new file mode 100644 index 0000000..5bf4187 --- /dev/null +++ b/frontend/src/components/ContextMenu/index.tsx @@ -0,0 +1,155 @@ +import { useEffect } from 'react'; +import { useGraphStore } from '../../store/graphStore'; + +export function ContextMenu() { + const { + contextMenuOpen, + contextMenuType, + contextMenuPosition, + contextMenuTarget, + closeContextMenu, + startCreatingNode, + startEditingNode, + startCreatingEdge, + deleteNode, + deleteEdge, + setSelectedNode, + refresh, + addToast, + } = useGraphStore(); + + useEffect(() => { + const handleClickOutside = () => { + if (contextMenuOpen) { + closeContextMenu(); + } + }; + + if (contextMenuOpen) { + document.addEventListener('click', handleClickOutside); + return () => { + document.removeEventListener('click', handleClickOutside); + }; + } + }, [contextMenuOpen, closeContextMenu]); + + if (!contextMenuOpen) return null; + + const handleEditNode = () => { + if (contextMenuTarget?.node) { + setSelectedNode(contextMenuTarget.node); + startEditingNode(contextMenuTarget.node); + } + closeContextMenu(); + }; + + const handleDeleteNode = async () => { + if (contextMenuTarget?.node) { + try { + await deleteNode(contextMenuTarget.node.id); + } catch { + // Error handled in store + } + } + closeContextMenu(); + }; + + const handleCreateEdgeFromNode = () => { + if (contextMenuTarget?.node) { + setSelectedNode(contextMenuTarget.node); + startCreatingEdge(contextMenuTarget.node); + } + closeContextMenu(); + }; + + const handleCopyNodeId = () => { + if (contextMenuTarget?.node) { + navigator.clipboard.writeText(contextMenuTarget.node.id); + addToast({ type: 'info', message: `节点 ID 已复制: ${contextMenuTarget.node.id}` }); + } + closeContextMenu(); + }; + + const handleDeleteEdge = async () => { + if (contextMenuTarget?.edge) { + try { + await deleteEdge(contextMenuTarget.edge.id); + } catch { + // Error handled in store + } + } + closeContextMenu(); + }; + + const handleRefresh = async () => { + await refresh(); + closeContextMenu(); + }; + + const getNodeMenuItems = () => { + if (!contextMenuTarget?.node) return []; + return [ + { label: '✏️ 编辑', onClick: handleEditNode }, + { label: '🔗 创建关联边', onClick: handleCreateEdgeFromNode }, + { label: '📋 复制 ID', onClick: handleCopyNodeId }, + { divider: true }, + { label: '🗑️ 删除', onClick: handleDeleteNode, danger: true }, + ]; + }; + + const getEdgeMenuItems = () => { + if (!contextMenuTarget?.edge) return []; + return [ + { label: '🗑️ 删除', onClick: handleDeleteEdge, danger: true }, + ]; + }; + + const getCanvasMenuItems = () => { + return [ + { label: '➕ 添加节点', onClick: () => { startCreatingNode(); closeContextMenu(); } }, + { divider: true }, + { label: '🔄 刷新数据', onClick: handleRefresh }, + ]; + }; + + const menuItems = + contextMenuType === 'node' + ? getNodeMenuItems() + : contextMenuType === 'edge' + ? getEdgeMenuItems() + : contextMenuType === 'canvas' + ? getCanvasMenuItems() + : []; + + if (menuItems.length === 0) return null; + + return ( +
+ {menuItems.map((item, index) => { + if ('divider' in item) { + return
; + } + return ( + + ); + })} +
+ ); +} diff --git a/frontend/src/components/Dialogs/ConfirmDialog.tsx b/frontend/src/components/Dialogs/ConfirmDialog.tsx new file mode 100644 index 0000000..654e43d --- /dev/null +++ b/frontend/src/components/Dialogs/ConfirmDialog.tsx @@ -0,0 +1,62 @@ +import { BaseDialog } from './index'; + +interface ConfirmDialogProps { + open: boolean; + title?: string; + message: string; + confirmText?: string; + cancelText?: string; + type?: 'danger' | 'warning' | 'info'; + onConfirm: () => void; + onCancel: () => void; +} + +export function ConfirmDialog({ + open, + title = '确认操作', + message, + confirmText = '确认', + cancelText = '取消', + type = 'warning', + onConfirm, + onCancel, +}: ConfirmDialogProps) { + if (!open) return null; + + const getConfirmClass = () => { + switch (type) { + case 'danger': + return 'bg-red-500 hover:bg-red-600'; + case 'warning': + return 'bg-orange-500 hover:bg-orange-600'; + case 'info': + return 'bg-blue-500 hover:bg-blue-600'; + default: + return 'bg-blue-500 hover:bg-blue-600'; + } + }; + + return ( + +
+

{message}

+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Dialogs/EdgeCreateDialog.tsx b/frontend/src/components/Dialogs/EdgeCreateDialog.tsx new file mode 100644 index 0000000..8d09443 --- /dev/null +++ b/frontend/src/components/Dialogs/EdgeCreateDialog.tsx @@ -0,0 +1,140 @@ +import { useState } from 'react'; +import { useGraphStore } from '../../store/graphStore'; +import type { CreateEdgeRequest } from '../../types/graph'; +import { BaseDialog } from './index'; + +const EDGE_TYPES = ['CONTAINS', 'RELATED_TO', 'DEPENDS_ON', 'PART_OF', 'HAS']; + +export function EdgeCreateDialog() { + const { + isCreatingEdge, + edgeSourceNode, + stopCreatingEdge, + createEdge, + graphData, + addToast, + } = useGraphStore(); + const [formData, setFormData] = useState({ + id: '', + label: '', + source: edgeSourceNode?.id || '', + target: '', + type: 'RELATED_TO', + }); + + if (!isCreatingEdge || !edgeSourceNode) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.target || !formData.label) { + addToast({ type: 'error', message: '请填写所有必填字段' }); + return; + } + + try { + await createEdge({ + ...formData, + id: formData.id || `edge_${Date.now()}`, + source: edgeSourceNode.id, + }); + setFormData({ + id: '', + label: '', + source: edgeSourceNode.id, + target: '', + type: 'RELATED_TO', + }); + stopCreatingEdge(); + } catch { + // Error already handled by store + } + }; + + const availableNodes = graphData.nodes.filter( + (n) => n.id !== edgeSourceNode.id + ); + + return ( + +
+
+ +
+
+ + +
+
+ + setFormData({ ...formData, label: e.target.value })} + className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="包含" + /> +
+
+ + +
+
+ + setFormData({ ...formData, id: e.target.value })} + className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="edge_123 (留空自动生成)" + /> +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Dialogs/ExportDialog.tsx b/frontend/src/components/Dialogs/ExportDialog.tsx new file mode 100644 index 0000000..86239dd --- /dev/null +++ b/frontend/src/components/Dialogs/ExportDialog.tsx @@ -0,0 +1,114 @@ +import { useState } from 'react'; +import { useGraphStore } from '../../store/graphStore'; +import { BaseDialog } from './index'; + +interface ExportDialogProps { + open: boolean; + onClose: () => void; +} + +export function ExportDialog({ open, onClose }: ExportDialogProps) { + const { graphData, addToast } = useGraphStore(); + const [exportType, setExportType] = useState<'full' | 'simple'>('full'); + + if (!open) return null; + + const handleExport = () => { + let dataToExport: string; + + if (exportType === 'full') { + dataToExport = JSON.stringify(graphData, null, 2); + } else { + const simpleData = { + nodes: graphData.nodes.map((n) => ({ + id: n.id, + label: n.label, + type: n.type || 'Unknown', + })), + edges: graphData.edges.map((e) => ({ + source: e.source, + target: e.target, + label: e.label || '', + type: e.type || 'Unknown', + })), + }; + dataToExport = JSON.stringify(simpleData, null, 2); + } + + const blob = new Blob([dataToExport], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `graph-data-${exportType}-${Date.now()}.json`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + addToast({ type: 'success', message: '数据导出成功' }); + onClose(); + }; + + return ( + +
+
+

选择导出格式

+
+ + +
+
+
+
当前图谱包含:
+
• {graphData.nodes.length} 个节点
+
• {graphData.edges.length} 条边
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Dialogs/NeighborsDialog.tsx b/frontend/src/components/Dialogs/NeighborsDialog.tsx new file mode 100644 index 0000000..fa92b9f --- /dev/null +++ b/frontend/src/components/Dialogs/NeighborsDialog.tsx @@ -0,0 +1,99 @@ +import { useState, useEffect } from 'react'; +import { useGraphStore } from '../../store/graphStore'; +import type { NeighborResponse } from '../../types/graph'; +import { BaseDialog } from './index'; + +interface NeighborsDialogProps { + open: boolean; + onClose: () => void; + nodeId?: string; +} + +export function NeighborsDialog({ open, onClose, nodeId }: NeighborsDialogProps) { + const { selectedNode, setSelectedNode, getNodeNeighbors, graphData } = + useGraphStore(); + const [neighborsData, setNeighborsData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const activeNode = nodeId ? graphData.nodes.find((n) => n.id === nodeId) : selectedNode; + + useEffect(() => { + if (open && activeNode) { + setIsLoading(true); + getNodeNeighbors(activeNode.id) + .then((data) => setNeighborsData(data)) + .finally(() => setIsLoading(false)); + } + }, [open, activeNode]); + + if (!open || !activeNode) return null; + + return ( + +
+ {isLoading ? ( +

加载中...

+ ) : neighborsData ? ( + <> + {neighborsData.nodes.length > 0 ? ( +
+

+ 邻居节点 ({neighborsData.nodes.length}) +

+
+ {neighborsData.nodes.map((node) => ( +
{ + setSelectedNode(node); + onClose(); + }} + > +
+
{node.label}
+
ID: {node.id}
+
+ + {node.type || '无类型'} + +
+ ))} +
+
+ ) : ( +

没有找到邻居节点

+ )} + {neighborsData.edges.length > 0 && ( +
+

+ 关联边 ({neighborsData.edges.length}) +

+
+ {neighborsData.edges.map((edge) => ( +
+
{edge.label}
+
+ {edge.source} → {edge.target} +
+
+ ))} +
+
+ )} + + ) : ( +

加载失败

+ )} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/Dialogs/NodeCreateDialog.tsx b/frontend/src/components/Dialogs/NodeCreateDialog.tsx new file mode 100644 index 0000000..552c786 --- /dev/null +++ b/frontend/src/components/Dialogs/NodeCreateDialog.tsx @@ -0,0 +1,102 @@ +import { useState } from 'react'; +import { useGraphStore } from '../../store/graphStore'; +import type { CreateNodeRequest } from '../../types/graph'; +import { BaseDialog } from './index'; + +const NODE_TYPES = ['概念', '实体', '属性', '关系', '工具', '方法']; + +export function NodeCreateDialog() { + const { isCreatingNode, stopCreatingNode, createNode, addToast } = useGraphStore(); + const [formData, setFormData] = useState({ + id: '', + label: '', + type: '概念', + x: 0, + y: 0, + }); + + if (!isCreatingNode) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!formData.id || !formData.label) { + addToast({ type: 'error', message: '请填写所有必填字段' }); + return; + } + + try { + await createNode({ + ...formData, + id: formData.id || `node_${Date.now()}`, + x: Math.floor(Math.random() * 800) + 100, + y: Math.floor(Math.random() * 600) + 100, + }); + setFormData({ id: '', label: '', type: '概念', x: 0, y: 0 }); + stopCreatingNode(); + } catch { + // Error already handled by store + } + }; + + return ( + +
+
+ + setFormData({ ...formData, id: e.target.value })} + className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="node_123" + /> +
+
+ + setFormData({ ...formData, label: e.target.value })} + className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + placeholder="人工智能" + /> +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Dialogs/NodeEditDialog.tsx b/frontend/src/components/Dialogs/NodeEditDialog.tsx new file mode 100644 index 0000000..4cee3f4 --- /dev/null +++ b/frontend/src/components/Dialogs/NodeEditDialog.tsx @@ -0,0 +1,113 @@ +import { useState, useEffect } from 'react'; +import { useGraphStore } from '../../store/graphStore'; +import type { UpdateNodeRequest } from '../../types/graph'; +import { BaseDialog } from './index'; + +const NODE_TYPES = ['概念', '实体', '属性', '关系', '工具', '方法']; + +export function NodeEditDialog() { + const { editingNode, stopEditingNode, updateNode } = useGraphStore(); + const [formData, setFormData] = useState({}); + + useEffect(() => { + if (editingNode) { + setFormData({ + label: editingNode.label, + type: editingNode.type, + x: editingNode.x, + y: editingNode.y, + }); + } + }, [editingNode]); + + if (!editingNode) return null; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + try { + await updateNode(editingNode.id, formData); + stopEditingNode(); + } catch { + // Error already handled by store + } + }; + + return ( + +
+
+ +
+
+ + setFormData({ ...formData, label: e.target.value })} + className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + +
+
+
+ + setFormData({ ...formData, x: Number(e.target.value) })} + className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+ + setFormData({ ...formData, y: Number(e.target.value) })} + className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500" + /> +
+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Dialogs/StatsDialog.tsx b/frontend/src/components/Dialogs/StatsDialog.tsx new file mode 100644 index 0000000..a7aa034 --- /dev/null +++ b/frontend/src/components/Dialogs/StatsDialog.tsx @@ -0,0 +1,80 @@ +import { useGraphStore } from '../../store/graphStore'; +import { BaseDialog } from './index'; + +interface StatsDialogProps { + open: boolean; + onClose: () => void; +} + +export function StatsDialog({ open, onClose }: StatsDialogProps) { + const { graphStats, loadGraphStats } = useGraphStore(); + + if (!open) return null; + + return ( + +
+ {graphStats ? ( + <> +
+
+
节点数
+
{graphStats.nodeCount}
+
+
+
边数
+
{graphStats.edgeCount}
+
+
+ {Object.keys(graphStats.nodeTypes).length > 0 && ( +
+

节点类型分布

+
+ {Object.entries(graphStats.nodeTypes).map(([type, count]) => ( +
+ {type} + {count} +
+ ))} +
+
+ )} + {Object.keys(graphStats.edgeTypes).length > 0 && ( +
+

边类型分布

+
+ {Object.entries(graphStats.edgeTypes).map(([type, count]) => ( +
+ {type} + {count} +
+ ))} +
+
+ )} + + ) : ( + <> +

统计数据未加载

+ + + )} +
+ +
+
+
+ ); +} diff --git a/frontend/src/components/Dialogs/index.tsx b/frontend/src/components/Dialogs/index.tsx new file mode 100644 index 0000000..525520e --- /dev/null +++ b/frontend/src/components/Dialogs/index.tsx @@ -0,0 +1,34 @@ +import type { ReactNode } from 'react'; + +interface BaseDialogProps { + open: boolean; + title: string; + onClose: () => void; + children: ReactNode; +} + +export function BaseDialog({ open, title, onClose, children }: BaseDialogProps) { + if (!open) return null; + + return ( +
+
+
+
+

{title}

+ +
+
{children}
+
+
+ ); +} diff --git a/frontend/src/components/GraphView/index.tsx b/frontend/src/components/GraphView/index.tsx index 0ab5453..69c66ea 100644 --- a/frontend/src/components/GraphView/index.tsx +++ b/frontend/src/components/GraphView/index.tsx @@ -185,6 +185,17 @@ export const GraphView = ({ } }); + const openContextMenu = ( + type: 'node' | 'edge' | 'canvas', + position: { x: number; y: number }, + target: { node?: Node; edge?: Edge; x?: number; y?: number } + ) => { + const event = new CustomEvent('open-context-menu', { + detail: { type, position, target } + }); + window.dispatchEvent(event); + }; + graph.on(NodeEvent.CLICK, (event: any) => { console.log("[GraphView] Node click event", event); @@ -250,6 +261,35 @@ export const GraphView = ({ } }); + // 禁用默认右键菜单 + graph.on('node:contextmenu', (event: any) => { + event.preventDefault(); + if (event.target && event.target.id) { + const node = data.nodes.find((n: Node) => n.id === event.target.id); + if (node) { + const { x, y } = event.canvas; + openContextMenu('node', { x, y }, { node }); + } + } + }); + + graph.on('edge:contextmenu', (event: any) => { + event.preventDefault(); + if (event.target && event.target.id) { + const edge = data.edges.find((e: Edge) => e.id === event.target.id); + if (edge) { + const { x, y } = event.canvas; + openContextMenu('edge', { x, y }, { edge }); + } + } + }); + + graph.on('canvas:contextmenu', (event: any) => { + event.preventDefault(); + const { x, y } = event.canvas; + openContextMenu('canvas', { x, y }, { x, y }); + }); + graph.on(CanvasEvent.CLICK, (event: any) => { if (event.targetType !== "canvas") { console.log( @@ -292,6 +332,9 @@ export const GraphView = ({ if (graphRef.current) { graphRef.current.off(NodeEvent.CLICK); + graphRef.current.off('node:contextmenu'); + graphRef.current.off('edge:contextmenu'); + graphRef.current.off('canvas:contextmenu'); graphRef.current.off(CanvasEvent.CLICK); graphRef.current.destroy(); graphRef.current = null; diff --git a/frontend/src/components/NodePanel/index.tsx b/frontend/src/components/NodePanel/index.tsx index 6d528ed..32d5207 100644 --- a/frontend/src/components/NodePanel/index.tsx +++ b/frontend/src/components/NodePanel/index.tsx @@ -1,19 +1,59 @@ +import { useState } from 'react'; import { useGraphStore } from '../../store'; /** * 节点详情面板组件 - * 展示选中节点的详细信息,包括属性和样式 + * 展示选中节点的详细信息,包括属性和样式,并提供操作按钮 */ export const NodePanel = () => { - const { selectedNode, setSelectedNode } = useGraphStore(); + const { selectedNode, setSelectedNode, startEditingNode, startCreatingEdge, deleteNode, addToast, nodePanelStatsOpen, setNodePanelStatsOpen, getNodeNeighbors } = useGraphStore(); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const [neighborsData, setNeighborsData] = useState(null); if (!selectedNode) return null; const node = selectedNode; + const handleEdit = () => { + startEditingNode(node); + }; + + const handleDelete = async () => { + try { + await deleteNode(node.id); + } catch { + // 错误已在 store 中处理 + } + setShowDeleteConfirm(false); + }; + + const handleCreateEdge = () => { + startCreatingEdge(node); + }; + + const handleCopyId = () => { + navigator.clipboard.writeText(node.id); + addToast({ type: 'info', message: `节点 ID 已复制: ${node.id}` }); + }; + + const handleViewNeighbors = async () => { + try { + const data = await getNodeNeighbors(node.id); + setNeighborsData(data); + setNodePanelStatsOpen(true); + } catch (error) { + addToast({ type: 'error', message: '获取邻居节点失败' }); + } + }; + // 判断属性是否可展示 const hasProperties = node.properties && Object.keys(node.properties).length > 0; + // 获取关联边 + const connectedEdges = useGraphStore.getState().graphData.edges.filter( + (e) => e.source === node.id || e.target === node.id + ); + return (
@@ -29,6 +69,45 @@ export const NodePanel = () => {
+ {/* 操作按钮区 */} +
+ + + + + +
+
{/* 基本信息 */}
@@ -52,6 +131,35 @@ export const NodePanel = () => {
)} + {/* 坐标信息 */} + {(node.x !== undefined || node.y !== undefined) && ( +
+
坐标
+
+ X: {node.x ?? 0}, Y: {node.y ?? 0} +
+
+ )} + + {/* 关联边 */} + {connectedEdges.length > 0 && ( +
+
+ 关联边 ({connectedEdges.length}) +
+
+ {connectedEdges.map((edge) => ( +
+
{edge.label || '未命名'}
+
+ {edge.source === node.id ? `→ ${edge.target}` : `${edge.source} ←`} +
+
+ ))} +
+
+ )} + {/* 属性部分:统一处理 undefined、null 和空对象 */}
@@ -69,9 +177,7 @@ export const NodePanel = () => { ))}
) : ( -
- 暂无属性数据 -
+
暂无属性数据
)}
@@ -99,8 +205,82 @@ export const NodePanel = () => {
)}
+ + {/* 删除确认对话框 */} + {showDeleteConfirm && ( +
+
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.label}
+
{n.id}
+
+ + {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 = () => {
) : ( <> -
-
-

知识图谱

- - {layout.type} 布局 - -
- -
- -
+
+
@@ -75,6 +84,42 @@ export const KnowledgeGraph = () => { )} )} + + {/* 右键菜单 */} + + + {/* Toast 通知 */} + + + {/* 创建节点对话框 */} + + + {/* 编辑节点对话框 */} + + + {/* 创建边对话框 */} + + + {/* 统计信息对话框 */} + setStatsOpen(false)} + /> + + {/* 邻居节点对话框 */} + {neighborNodeId && ( + + )} + + {/* 数据导出对话框 */} + setExportOpen(false)} + />
); }; diff --git a/frontend/src/services/graphApi.ts b/frontend/src/services/graphApi.ts index 5a40847..eb5c25e 100644 --- a/frontend/src/services/graphApi.ts +++ b/frontend/src/services/graphApi.ts @@ -1,5 +1,17 @@ import axios from 'axios'; -import type { GraphData, Node } from '../types/graph'; +import type { + GraphData, + Node, + CreateNodeRequest, + UpdateNodeRequest, + CreateEdgeRequest, + DeleteResponse, + EdgeResponse, + NeighborResponse, + SearchResponse, + SimpleGraphData, + GraphStats, +} from '../types/graph'; import { API_BASE_URL, API_TIMEOUT } from '../config/constants'; /** @@ -47,6 +59,58 @@ export const graphApi = { return null; } }, + + async createNode(request: CreateNodeRequest): Promise { + const response = await apiClient.post('/nodes', request); + return response.data; + }, + + async updateNode(id: string, request: UpdateNodeRequest): Promise { + const response = await apiClient.put(`/nodes/${id}`, request); + return response.data; + }, + + async deleteNode(id: string): Promise { + const response = await apiClient.delete(`/nodes/${id}`); + return response.data; + }, + + async getNodeNeighbors(id: string): Promise { + const response = await apiClient.get(`/nodes/${id}/neighbors`); + return response.data; + }, + + async createEdge(request: CreateEdgeRequest): Promise { + const response = await apiClient.post('/edges', request); + return response.data; + }, + + async deleteEdge(id: string): Promise { + const response = await apiClient.delete(`/edges/${id}`); + return response.data; + }, + + async getGraphStats(): Promise { + const response = await apiClient.get('/graph/stats'); + return response.data; + }, + + async getSimpleGraphData(): Promise { + const response = await apiClient.get('/graph/simpleJson'); + return response.data; + }, + + async searchNodes(query: string): Promise { + const response = await apiClient.get('/search', { + params: { q: query }, + }); + return response.data; + }, + + async healthCheck(): Promise<{ status: string }> { + const response = await apiClient.get<{ status: string }>('/health'); + return response.data; + }, }; export default apiClient; \ No newline at end of file diff --git a/frontend/src/store/graphStore.ts b/frontend/src/store/graphStore.ts index 58ffdba..ca325f0 100644 --- a/frontend/src/store/graphStore.ts +++ b/frontend/src/store/graphStore.ts @@ -1,5 +1,19 @@ import { create } from 'zustand'; -import type { GraphData, Node, SearchNode } from '../types/graph'; +import type { + GraphData, + Node, + Edge, + SearchNode, + CreateNodeRequest, + UpdateNodeRequest, + CreateEdgeRequest, + NeighborResponse, + GraphStats, + SimpleGraphData, + ToastItem, + ContextMenuType, + LayoutType, +} from '../types/graph'; import { graphApi } from '../services/graphApi'; import { SAMPLE_GRAPH_DATA } from '../config/sampleData'; import { resetColorAssignment } from '../config/colors'; @@ -7,21 +21,83 @@ import { resetColorAssignment } from '../config/colors'; interface GraphState { graphData: GraphData; selectedNode: Node | null; + selectedEdge: Edge | null; searchResults: SearchNode[]; loading: boolean; + editingNode: Node | null; + isCreatingNode: boolean; + isCreatingEdge: boolean; + edgeSourceNode: Node | null; + graphStats: GraphStats | null; + toasts: ToastItem[]; + contextMenuOpen: boolean; + contextMenuType: ContextMenuType; + contextMenuPosition: { x: number; y: number }; + contextMenuTarget: { node?: Node; edge?: Edge; x?: number; y?: number } | null; + layoutType: LayoutType; + nodePanelStatsOpen: boolean; + addGraphData: (data: GraphData) => void; setSelectedNode: (node: Node | null) => void; + setSelectedEdge: (edge: Edge | null) => void; + deselectAll: () => void; loadGraphData: () => Promise; handleSearch: (query: string) => void; + handleSearchAsync: (query: string) => Promise; handleNodeSelect: (node: Node | null) => void; handleNodeSelectById: (nodeId: string) => void; + + createNode: (request: CreateNodeRequest) => Promise; + updateNode: (id: string, request: UpdateNodeRequest) => Promise; + deleteNode: (id: string) => Promise; + getNodeNeighbors: (id: string) => Promise; + createEdge: (request: CreateEdgeRequest) => Promise; + deleteEdge: (id: string) => Promise; + loadGraphStats: () => Promise; + exportSimpleGraphData: () => Promise; + + startEditingNode: (node: Node) => void; + stopEditingNode: () => void; + startCreatingNode: () => void; + stopCreatingNode: () => void; + startCreatingEdge: (node: Node) => void; + stopCreatingEdge: () => void; + + addToast: (toast: Omit) => void; + removeToast: (id: string) => void; + clearToasts: () => void; + + openContextMenu: ( + type: ContextMenuType, + position: { x: number; y: number }, + target: { node?: Node; edge?: Edge; x?: number; y?: number } + ) => void; + closeContextMenu: () => void; + + setLayoutType: (type: LayoutType) => void; + setNodePanelStatsOpen: (open: boolean) => void; + + refresh: () => Promise; } export const useGraphStore = create((set, get) => ({ graphData: { nodes: [], edges: [] }, selectedNode: null, + selectedEdge: null, searchResults: [], loading: true, + editingNode: null, + isCreatingNode: false, + isCreatingEdge: false, + edgeSourceNode: null, + graphStats: null, + toasts: [], + contextMenuOpen: false, + contextMenuType: 'none', + contextMenuPosition: { x: 0, y: 0 }, + contextMenuTarget: null, + layoutType: 'force', + nodePanelStatsOpen: false, addGraphData: (data: GraphData) => { set({ graphData: data }); @@ -29,16 +105,23 @@ export const useGraphStore = create((set, get) => ({ setSelectedNode: (node: Node | null) => { console.log('[graphStore] setSelectedNode called with:', node); - set({ selectedNode: node }); + set({ selectedNode: node, selectedEdge: null }); + }, + + setSelectedEdge: (edge: Edge | null) => { + set({ selectedEdge: edge, selectedNode: null }); + }, + + deselectAll: () => { + set({ selectedNode: null, selectedEdge: null }); }, 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 { @@ -53,8 +136,7 @@ export const useGraphStore = create((set, get) => ({ } }, - handleSearch: (query: string) => { - const { graphData } = get(); + handleSearch: (query: string) => { const { graphData } = get(); const results = graphData.nodes .filter((node: Node) => node.label.toLowerCase().includes(query.toLowerCase()) || @@ -69,6 +151,34 @@ export const useGraphStore = create((set, get) => ({ set({ searchResults: results }); }, + handleSearchAsync: async (query: string) => { + try { + const response = await graphApi.searchNodes(query); + const results = response.nodes.map((node) => ({ + id: node.id, + label: node.label, + type: node.type, + matched: true, + })); + set({ searchResults: results }); + } catch (error) { + console.error('Failed to search nodes:', error); + 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(); @@ -88,4 +198,189 @@ export const useGraphStore = create((set, get) => ({ set({ selectedNode: node }); console.log('[graphStore] selectedNode set'); }, + + createNode: async (request: CreateNodeRequest) => { + try { + const newNode = await graphApi.createNode(request); + const { graphData } = get(); + set({ + graphData: { + ...graphData, + nodes: [...graphData.nodes, newNode], + }, + }); + get().addToast({ type: 'success', message: `节点 "${request.label}" 创建成功` }); + } catch (error) { + console.error('Failed to create node:', error); + get().addToast({ type: 'error', message: `节点创建失败: ${(error as Error).message}` }); + throw error; + } + }, + + updateNode: async (id: string, request: UpdateNodeRequest) => { + try { + const updatedNode = await graphApi.updateNode(id, request); + const { graphData, selectedNode } = get(); + set({ + graphData: { + ...graphData, + nodes: graphData.nodes.map((n) => (n.id === id ? updatedNode : n)), + }, + selectedNode: selectedNode?.id === id ? updatedNode : selectedNode, + }); + get().addToast({ type: 'success', message: `节点 "${request.label || id}" 更新成功` }); + } catch (error) { + console.error('Failed to update node:', error); + get().addToast({ type: 'error', message: `节点更新失败: ${(error as Error).message}` }); + throw error; + } + }, + + deleteNode: async (id: string) => { + try { + await graphApi.deleteNode(id); + const { graphData } = get(); + const updatedNodes = graphData.nodes.filter((n) => n.id !== id); + const updatedEdges = graphData.edges.filter((e) => e.source !== id && e.target !== id); + set({ + graphData: { + ...graphData, + nodes: updatedNodes, + edges: updatedEdges, + }, + selectedNode: null, + }); + get().addToast({ type: 'success', message: `节点 "${id}" 删除成功` }); + } catch (error) { + console.error('Failed to delete node:', error); + get().addToast({ type: 'error', message: `节点删除失败: ${(error as Error).message}` }); + throw error; + } + }, + + getNodeNeighbors: async (id: string) => { + try { + return await graphApi.getNodeNeighbors(id); + } catch (error) { + console.error('Failed to get node neighbors:', error); + get().addToast({ type: 'error', message: `获取邻居节点失败: ${(error as Error).message}` }); + throw error; + } + }, + + createEdge: async (request: CreateEdgeRequest) => { + try { + const newEdge = await graphApi.createEdge(request); + const { graphData } = get(); + set({ + graphData: { + ...graphData, + edges: [...graphData.edges, newEdge], + }, + }); + get().addToast({ type: 'success', message: `边 "${request.label}" 创建成功` }); + } catch (error) { + console.error('Failed to create edge:', error); + get().addToast({ type: 'error', message: `边创建失败: ${(error as Error).message}` }); + throw error; + } + }, + + deleteEdge: async (id: string) => { + try { + await graphApi.deleteEdge(id); + const { graphData } = get(); + set({ + graphData: { + ...graphData, + edges: graphData.edges.filter((e) => e.id !== id), + }, + selectedEdge: null, + }); + get().addToast({ type: 'success', message: `边 "${id}" 删除成功` }); + } catch (error) { + console.error('Failed to delete edge:', error); + get().addToast({ type: 'error', message: `边删除失败: ${(error as Error).message}` }); + throw error; + } + }, + + loadGraphStats: async () => { + try { + const stats = await graphApi.getGraphStats(); + set({ graphStats: stats }); + } catch (error) { + console.error('Failed to load graph stats:', error); + get().addToast({ type: 'error', message: `获取统计信息失败: ${(error as Error).message}` }); + throw error; + } + }, + + exportSimpleGraphData: async () => { + try { + return await graphApi.getSimpleGraphData(); + } catch (error) { + console.error('Failed to export simple graph data:', error); + get().addToast({ type: 'error', message: `导出简化数据失败: ${(error as Error).message}` }); + throw error; + } + }, + + startEditingNode: (node: Node) => set({ editingNode: node }), + stopEditingNode: () => set({ editingNode: null }), + + startCreatingNode: () => set({ isCreatingNode: true }), + stopCreatingNode: () => set({ isCreatingNode: false }), + + startCreatingEdge: (node: Node) => set({ isCreatingEdge: true, edgeSourceNode: node }), + stopCreatingEdge: () => set({ isCreatingEdge: false, edgeSourceNode: null }), + + addToast: (toast: Omit) => { + const id = `toast-${Date.now()}-${Math.random()}`; + const newToast: ToastItem = { ...toast, id }; + set({ toasts: [...get().toasts, newToast] }); + if (toast.duration !== 0) { + setTimeout(() => { + get().removeToast(id); + }, toast.duration || 3000); + } + }, + + removeToast: (id: string) => { + set({ toasts: get().toasts.filter((t) => t.id !== id) }); + }, + + clearToasts: () => { + set({ toasts: [] }); + }, + + openContextMenu: ( + type: ContextMenuType, + position: { x: number; y: number }, + target: { node?: Node; edge?: Edge; x?: number; y?: number } + ) => { + set({ + contextMenuOpen: true, + contextMenuType: type, + contextMenuPosition: position, + contextMenuTarget: target, + }); + }, + + closeContextMenu: () => { + set({ contextMenuOpen: false }); + }, + + setLayoutType: (type: LayoutType) => { + set({ layoutType: type }); + }, + + setNodePanelStatsOpen: (open: boolean) => { + set({ nodePanelStatsOpen: open }); + }, + + refresh: async () => { + await get().loadGraphData(); + get().addToast({ type: 'success', message: '数据已刷新' }); + }, })); diff --git a/frontend/src/types/graph.ts b/frontend/src/types/graph.ts index fd68b21..dfe84d1 100644 --- a/frontend/src/types/graph.ts +++ b/frontend/src/types/graph.ts @@ -67,4 +67,175 @@ export interface SearchNode { label: string; type?: string; matched?: boolean; +} + +// ==================== API 请求类型 ==================== + +/** + * 创建节点请求 + */ +export interface CreateNodeRequest { + id: string; + label: string; + type: string; + x?: number; + y?: number; + properties?: Record; +} + +/** + * 更新节点请求 + */ +export interface UpdateNodeRequest { + label?: string; + type?: string; + x?: number; + y?: number; + properties?: Record; +} + +/** + * 创建边请求 + */ +export interface CreateEdgeRequest { + id: string; + label: string; + source: string; + target: string; + type?: string; + properties?: Record; +} + +// ==================== API 响应类型 ==================== + +/** + * 删除响应 + */ +export interface DeleteResponse { + success: boolean; + message: string; + id: string; +} + +/** + * 节点响应(带邻居信息) + */ +export interface NodeResponse extends Node { + neighbors?: Node[]; +} + +/** + * 边响应 + */ +export interface EdgeResponse { + id: string; + label: string; + source: string; + target: string; + type?: string; + properties?: Record; +} + +/** + * 邻居节点响应 + */ +export interface NeighborResponse { + nodes: Node[]; + edges: Edge[]; +} + +/** + * 搜索响应 + */ +export interface SearchResponse { + query: string; + count: number; + nodes: Node[]; +} + +// ==================== 简化数据类型 ==================== + +/** + * 简化节点(仅核心信息) + */ +export interface SimpleNode { + id: string; + label: string; + type: string; +} + +/** + * 简化边(仅核心信息) + */ +export interface SimpleEdge { + source: string; + target: string; + label: string; + type?: string; +} + +/** + * 简化图谱数据 + */ +export interface SimpleGraphData { + nodes: SimpleNode[]; + edges: SimpleEdge[]; +} + +// ==================== 统计信息 ==================== + +/** + * 图谱统计信息 + */ +export interface GraphStats { + nodeCount: number; + edgeCount: number; + nodeTypes: Record; + edgeTypes: Record; +} + +// ==================== UI 状态类型 ==================== + +/** + * 右键菜单项 + */ +export interface ContextMenuItem { + key: string; + label: string; + icon?: string; + onClick: () => void; + divider?: boolean; +} + +/** + * 右键菜单类型 + */ +export type ContextMenuType = 'node' | 'edge' | 'canvas' | 'none'; + +/** + * Toast 通知类型 + */ +export type ToastType = 'success' | 'error' | 'warning' | 'info'; + +/** + * Toast 通知项 + */ +export interface ToastItem { + id: string; + type: ToastType; + message: string; + duration?: number; +} + +/** + * 布局类型 + */ +export type LayoutType = 'force' | 'circular' | 'grid'; + +/** + * 选中状态 + */ +export interface SelectionState { + nodes: Node[]; + edges: Edge[]; } \ No newline at end of file