feat: 前端对接后端增删查改接口

This commit is contained in:
2026-04-17 14:41:21 +08:00
parent 5d4bf761b8
commit 61e9b87e98
20 changed files with 2022 additions and 27 deletions
@@ -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 (
<div
className="fixed z-[10002] bg-white dark:bg-gray-800 rounded-lg shadow-xl py-1 min-w-[180px] border border-gray-200 dark:border-gray-700"
style={{
left: contextMenuPosition.x,
top: contextMenuPosition.y,
}}
>
{menuItems.map((item, index) => {
if ('divider' in item) {
return <div key={`divider-${index}`} className="h-px bg-gray-200 dark:bg-gray-700 my-1" />;
}
return (
<button
key={index}
type="button"
onClick={item.onClick}
className={`w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${
'danger' in item && item.danger
? 'text-red-600 dark:text-red-400'
: 'text-gray-700 dark:text-gray-300'
}`}
>
{item.label}
</button>
);
})}
</div>
);
}
@@ -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 (
<BaseDialog open title={title} onClose={onCancel}>
<div className="space-y-4">
<p className="text-gray-700 dark:text-gray-300">{message}</p>
<div className="flex gap-3 justify-end pt-4">
<button
type="button"
onClick={onCancel}
className="px-4 py-2 border rounded hover:bg-gray-100"
>
{cancelText}
</button>
<button
type="button"
onClick={onConfirm}
className={`px-4 py-2 text-white rounded ${getConfirmClass()}`}
>
{confirmText}
</button>
</div>
</div>
</BaseDialog>
);
}
@@ -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<CreateEdgeRequest>({
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 (
<BaseDialog open title="创建边" onClose={stopCreatingEdge}>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-500">
源节点: {edgeSourceNode.label} ({edgeSourceNode.id})
</label>
</div>
<div>
<label className="block text-sm font-medium mb-1">
目标节点 <span className="text-red-500">*</span>
</label>
<select
value={formData.target}
onChange={(e) => setFormData({ ...formData, target: e.target.value })}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">请选择目标节点</option>
{availableNodes.map((node) => (
<option key={node.id} value={node.id}>
{node.label} ({node.id})
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
边标签 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.label}
onChange={(e) => 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="包含"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
类型
</label>
<select
value={formData.type || ''}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{EDGE_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium mb-1">
边 ID
</label>
<input
type="text"
value={formData.id}
onChange={(e) => 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 (留空自动生成)"
/>
</div>
<div className="flex gap-3 justify-end pt-4">
<button
type="button"
onClick={stopCreatingEdge}
className="px-4 py-2 border rounded hover:bg-gray-100"
>
取消
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
创建
</button>
</div>
</form>
</BaseDialog>
);
}
@@ -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 (
<BaseDialog open title="导出图谱数据" onClose={onClose}>
<div className="space-y-4">
<div>
<h3 className="font-medium mb-2">选择导出格式</h3>
<div className="space-y-2">
<label className="flex items-center gap-2 p-3 border rounded cursor-pointer hover:bg-gray-50">
<input
type="radio"
value="full"
checked={exportType === 'full'}
onChange={(e) => setExportType(e.target.value as 'full' | 'simple')}
className="w-4 h-4"
/>
<div>
<div className="font-medium">完整数据</div>
<div className="text-sm text-gray-500">
包含所有节点和边的完整信息(坐标、样式、属性等)
</div>
</div>
</label>
<label className="flex items-center gap-2 p-3 border rounded cursor-pointer hover:bg-gray-50">
<input
type="radio"
value="simple"
checked={exportType === 'simple'}
onChange={(e) => setExportType(e.target.value as 'full' | 'simple')}
className="w-4 h-4"
/>
<div>
<div className="font-medium">简化数据</div>
<div className="text-sm text-gray-500">
仅保留核心信息,适合 LLM 处理
</div>
</div>
</label>
</div>
</div>
<div className="p-3 bg-gray-50 rounded text-sm">
<div>当前图谱包含:</div>
<div>• {graphData.nodes.length} 个节点</div>
<div>• {graphData.edges.length} 条边</div>
</div>
<div className="flex gap-3 justify-end pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border rounded hover:bg-gray-100"
>
取消
</button>
<button
type="button"
onClick={handleExport}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
导出 JSON
</button>
</div>
</div>
</BaseDialog>
);
}
@@ -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<NeighborResponse | null>(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 (
<BaseDialog open title={`邻居节点: ${activeNode.label}`} onClose={onClose}>
<div className="space-y-4">
{isLoading ? (
<p className="text-gray-500">加载中...</p>
) : neighborsData ? (
<>
{neighborsData.nodes.length > 0 ? (
<div>
<h3 className="font-medium mb-2">
邻居节点 ({neighborsData.nodes.length})
</h3>
<div className="space-y-2 max-h-[300px] overflow-y-auto">
{neighborsData.nodes.map((node) => (
<div
key={node.id}
className="flex items-center justify-between p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer"
onClick={() => {
setSelectedNode(node);
onClose();
}}
>
<div>
<div className="font-medium">{node.label}</div>
<div className="text-sm text-gray-500">ID: {node.id}</div>
</div>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
{node.type || '无类型'}
</span>
</div>
))}
</div>
</div>
) : (
<p className="text-gray-500">没有找到邻居节点</p>
)}
{neighborsData.edges.length > 0 && (
<div>
<h3 className="font-medium mb-2">
关联边 ({neighborsData.edges.length})
</h3>
<div className="space-y-2 max-h-[200px] overflow-y-auto">
{neighborsData.edges.map((edge) => (
<div key={edge.id} className="p-2 bg-gray-50 rounded">
<div className="font-medium">{edge.label}</div>
<div className="text-sm text-gray-500">
{edge.source} → {edge.target}
</div>
</div>
))}
</div>
</div>
)}
</>
) : (
<p className="text-gray-500">加载失败</p>
)}
<div className="flex gap-3 justify-end pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border rounded hover:bg-gray-100"
>
关闭
</button>
</div>
</div>
</BaseDialog>
);
}
@@ -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<CreateNodeRequest>({
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 (
<BaseDialog open title="创建节点" onClose={stopCreatingNode}>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1">
ID <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.id}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
标签 <span className="text-red-500">*</span>
</label>
<input
type="text"
value={formData.label}
onChange={(e) => 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="人工智能"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
类型 <span className="text-red-500">*</span>
</label>
<select
value={formData.type}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{NODE_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
<div className="flex gap-3 justify-end pt-4">
<button
type="button"
onClick={stopCreatingNode}
className="px-4 py-2 border rounded hover:bg-gray-100"
>
取消
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
创建
</button>
</div>
</form>
</BaseDialog>
);
}
@@ -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<UpdateNodeRequest>({});
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 (
<BaseDialog open title="编辑节点" onClose={stopEditingNode}>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2 text-gray-500">
ID: {editingNode.id}
</label>
</div>
<div>
<label className="block text-sm font-medium mb-1">
标签
</label>
<input
type="text"
value={formData.label || ''}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
类型
</label>
<select
value={formData.type || ''}
onChange={(e) => setFormData({ ...formData, type: e.target.value })}
className="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">无</option>
{NODE_TYPES.map((type) => (
<option key={type} value={type}>
{type}
</option>
))}
</select>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1">
X 坐标
</label>
<input
type="number"
value={formData.x || 0}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1">
Y 坐标
</label>
<input
type="number"
value={formData.y || 0}
onChange={(e) => 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"
/>
</div>
</div>
<div className="flex gap-3 justify-end pt-4">
<button
type="button"
onClick={stopEditingNode}
className="px-4 py-2 border rounded hover:bg-gray-100"
>
取消
</button>
<button
type="submit"
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
保存
</button>
</div>
</form>
</BaseDialog>
);
}
@@ -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 (
<BaseDialog open title="图谱统计信息" onClose={onClose}>
<div className="space-y-4">
{graphStats ? (
<>
<div className="grid grid-cols-2 gap-4">
<div className="bg-blue-50 p-4 rounded">
<div className="text-sm text-gray-600">节点数</div>
<div className="text-2xl font-bold text-blue-600">{graphStats.nodeCount}</div>
</div>
<div className="bg-green-50 p-4 rounded">
<div className="text-sm text-gray-600">边数</div>
<div className="text-2xl font-bold text-green-600">{graphStats.edgeCount}</div>
</div>
</div>
{Object.keys(graphStats.nodeTypes).length > 0 && (
<div>
<h3 className="font-medium mb-2">节点类型分布</h3>
<div className="space-y-1">
{Object.entries(graphStats.nodeTypes).map(([type, count]) => (
<div key={type} className="flex justify-between text-sm">
<span>{type}</span>
<span className="font-medium">{count}</span>
</div>
))}
</div>
</div>
)}
{Object.keys(graphStats.edgeTypes).length > 0 && (
<div>
<h3 className="font-medium mb-2">边类型分布</h3>
<div className="space-y-1">
{Object.entries(graphStats.edgeTypes).map(([type, count]) => (
<div key={type} className="flex justify-between text-sm">
<span>{type}</span>
<span className="font-medium">{count}</span>
</div>
))}
</div>
</div>
)}
</>
) : (
<>
<p className="text-gray-500 mb-4">统计数据未加载</p>
<button
type="button"
onClick={loadGraphStats}
className="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
>
加载统计
</button>
</>
)}
<div className="flex gap-3 justify-end pt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 border rounded hover:bg-gray-100"
>
关闭
</button>
</div>
</div>
</BaseDialog>
);
}
+34
View File
@@ -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 (
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative z-[10001] bg-white dark:bg-gray-800 rounded-lg shadow-xl min-w-[400px] max-w-[600px] max-h-[80vh] overflow-hidden">
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold">{title}</h2>
<button
type="button"
onClick={onClose}
className="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
✕
</button>
</div>
<div className="p-6 overflow-y-auto max-h-[calc(80vh-140px)]">{children}</div>
</div>
</div>
);
}
@@ -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;
+186 -6
View File
@@ -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<any>(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 (
<div className="w-full overflow-y-auto">
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
@@ -29,6 +69,45 @@ export const NodePanel = () => {
</button>
</div>
{/* 操作按钮区 */}
<div className="px-4 py-3 border-b border-gray-200 flex flex-wrap gap-2">
<button
type="button"
onClick={handleEdit}
className="px-3 py-1.5 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 flex items-center gap-1"
>
✏️ 编辑
</button>
<button
type="button"
onClick={handleCreateEdge}
className="px-3 py-1.5 text-sm bg-purple-500 text-white rounded hover:bg-purple-600 flex items-center gap-1"
>
🔗 创建边
</button>
<button
type="button"
onClick={handleViewNeighbors}
className="px-3 py-1.5 text-sm bg-teal-500 text-white rounded hover:bg-teal-600 flex items-center gap-1"
>
👥 邻居
</button>
<button
type="button"
onClick={handleCopyId}
className="px-3 py-1.5 text-sm bg-gray-500 text-white rounded hover:bg-gray-600 flex items-center gap-1"
>
📋 复制ID
</button>
<button
type="button"
onClick={() => setShowDeleteConfirm(true)}
className="px-3 py-1.5 text-sm bg-red-500 text-white rounded hover:bg-red-600 flex items-center gap-1"
>
🗑️ 删除
</button>
</div>
<div className="p-4 space-y-4">
{/* 基本信息 */}
<div>
@@ -52,6 +131,35 @@ export const NodePanel = () => {
</div>
)}
{/* 坐标信息 */}
{(node.x !== undefined || node.y !== undefined) && (
<div>
<div className="text-xs text-gray-500 uppercase tracking-wide mb-1">坐标</div>
<div className="text-sm text-gray-700">
X: {node.x ?? 0}, Y: {node.y ?? 0}
</div>
</div>
)}
{/* 关联边 */}
{connectedEdges.length > 0 && (
<div>
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
关联边 ({connectedEdges.length})
</div>
<div className="space-y-2 max-h-[150px] overflow-y-auto">
{connectedEdges.map((edge) => (
<div key={edge.id} className="p-2 bg-gray-50 rounded text-sm">
<div className="font-medium">{edge.label || '未命名'}</div>
<div className="text-xs text-gray-500">
{edge.source === node.id ? `→ ${edge.target}` : `${edge.source} ←`}
</div>
</div>
))}
</div>
</div>
)}
{/* 属性部分:统一处理 undefined、null 和空对象 */}
<div>
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
@@ -69,9 +177,7 @@ export const NodePanel = () => {
))}
</div>
) : (
<div className="text-sm text-gray-400 italic">
暂无属性数据
</div>
<div className="text-sm text-gray-400 italic">暂无属性数据</div>
)}
</div>
@@ -99,8 +205,82 @@ export const NodePanel = () => {
</div>
)}
</div>
{/* 删除确认对话框 */}
{showDeleteConfirm && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => setShowDeleteConfirm(false)} />
<div className="relative bg-white rounded-lg p-6 max-w-md">
<h3 className="text-lg font-semibold mb-2">确认删除节点</h3>
<p className="text-gray-600 mb-4">
确定要删除节点 "{node.label}" 吗?此操作将同时删除该节点相关的所有边,且不可恢复。
</p>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={() => setShowDeleteConfirm(false)}
className="px-4 py-2 border rounded hover:bg-gray-100"
>
取消
</button>
<button
type="button"
onClick={handleDelete}
className="px-4 py-2 bg-red-500 text-white rounded hover:bg-red-600"
>
确认删除
</button>
</div>
</div>
</div>
)}
{/* 邻居节点对话框 */}
{nodePanelStatsOpen && neighborsData && (
<div className="fixed inset-0 z-[10000] flex items-center justify-center">
<div className="absolute inset-0 bg-black/50" onClick={() => setNodePanelStatsOpen(false)} />
<div className="relative bg-white rounded-lg p-6 max-w-2xl max-h-[80vh] overflow-y-auto">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-semibold">邻居节点</h3>
<button
onClick={() => setNodePanelStatsOpen(false)}
className="text-gray-400 hover:text-gray-600"
>
✕
</button>
</div>
{neighborsData.nodes.length > 0 ? (
<div className="space-y-2">
{neighborsData.nodes.map((n: any) => (
<div
key={n.id}
className="p-2 bg-gray-50 rounded hover:bg-gray-100 cursor-pointer flex justify-between items-center"
onClick={() => {
const foundNode = useGraphStore.getState().graphData.nodes.find((item) => item.id === n.id);
if (foundNode) {
setSelectedNode(foundNode);
}
setNodePanelStatsOpen(false);
}}
>
<div>
<div className="font-medium">{n.label}</div>
<div className="text-sm text-gray-500">{n.id}</div>
</div>
<span className="text-xs bg-blue-100 text-blue-800 px-2 py-1 rounded">
{n.type || '无类型'}
</span>
</div>
))}
</div>
) : (
<p className="text-gray-500">没有邻居节点</p>
)}
</div>
</div>
)}
</div>
);
};
export default NodePanel;
export default NodePanel;
+45
View File
@@ -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 (
<div className="fixed top-4 right-4 z-[9999] flex flex-col gap-2">
{toasts.map((toast) => (
<div key={toast.id} className={getTypeStyles(toast.type)}>
<span className="text-lg font-bold">{getTypeIcon(toast.type)}</span>
<span className="flex-1">{toast.message}</span>
<button
type="button"
onClick={() => removeToast(toast.id)}
className="opacity-70 hover:opacity-100 text-xl font-bold leading-none"
>
×
</button>
</div>
))}
</div>
);
}
+69
View File
@@ -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 (
<div className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-2 flex items-center gap-2">
<button
type="button"
onClick={startCreatingNode}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 disabled:opacity-50"
disabled={loading}
>
<span>➕</span>
<span>添加节点</span>
</button>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600" />
<button
type="button"
onClick={refresh}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600 disabled:opacity-50"
disabled={loading}
>
<span>🔄</span>
<span>刷新</span>
</button>
<div className="ml-2 flex items-center gap-1">
{layouts.map((layout) => (
<button
key={layout.type}
type="button"
onClick={() => setLayoutType(layout.type)}
className={`flex items-center gap-1.5 px-3 py-1.5 text-sm rounded transition-colors ${
layoutType === layout.type
? 'bg-blue-500 text-white'
: 'bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
<span>{layout.icon}</span>
<span>{layout.label}</span>
</button>
))}
</div>
<div className="w-px h-6 bg-gray-300 dark:bg-gray-600 ml-2" />
<div className="text-xs text-gray-500 dark:text-gray-400 flex gap-3">
<span>Ctrl+N 创建节点</span>
<span>Ctrl+E 编辑节点</span>
<span>Delete 删除</span>
</div>
</div>
);
}
+94
View File
@@ -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);
}
+89
View File
@@ -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,
]);
}
+58 -13
View File
@@ -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 = () => {
</div>
) : (
<>
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
<div className="flex items-center space-x-4">
<h1 className="text-xl font-semibold text-gray-800">知识图谱</h1>
<span className="text-sm text-gray-500 bg-gray-100 px-2 py-1 rounded">
{layout.type} 布局
</span>
</div>
<div className="flex items-center space-x-4">
<SearchBar />
</div>
<header className="bg-white border-b border-gray-200 px-4 py-2">
<Toolbar />
</header>
<div className="flex-1 flex overflow-hidden">
@@ -75,6 +84,42 @@ export const KnowledgeGraph = () => {
)}
</>
)}
{/* 右键菜单 */}
<ContextMenu />
{/* Toast 通知 */}
<Toast />
{/* 创建节点对话框 */}
<NodeCreateDialog />
{/* 编辑节点对话框 */}
<NodeEditDialog />
{/* 创建边对话框 */}
<EdgeCreateDialog />
{/* 统计信息对话框 */}
<StatsDialog
open={statsOpen}
onClose={() => setStatsOpen(false)}
/>
{/* 邻居节点对话框 */}
{neighborNodeId && (
<NeighborsDialog
open={true}
nodeId={neighborNodeId}
onClose={closeContextMenu}
/>
)}
{/* 数据导出对话框 */}
<ExportDialog
open={exportOpen}
onClose={() => setExportOpen(false)}
/>
</div>
);
};
+65 -1
View File
@@ -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<Node> {
const response = await apiClient.post<Node>('/nodes', request);
return response.data;
},
async updateNode(id: string, request: UpdateNodeRequest): Promise<Node> {
const response = await apiClient.put<Node>(`/nodes/${id}`, request);
return response.data;
},
async deleteNode(id: string): Promise<DeleteResponse> {
const response = await apiClient.delete<DeleteResponse>(`/nodes/${id}`);
return response.data;
},
async getNodeNeighbors(id: string): Promise<NeighborResponse> {
const response = await apiClient.get<NeighborResponse>(`/nodes/${id}/neighbors`);
return response.data;
},
async createEdge(request: CreateEdgeRequest): Promise<EdgeResponse> {
const response = await apiClient.post<EdgeResponse>('/edges', request);
return response.data;
},
async deleteEdge(id: string): Promise<DeleteResponse> {
const response = await apiClient.delete<DeleteResponse>(`/edges/${id}`);
return response.data;
},
async getGraphStats(): Promise<GraphStats> {
const response = await apiClient.get<GraphStats>('/graph/stats');
return response.data;
},
async getSimpleGraphData(): Promise<SimpleGraphData> {
const response = await apiClient.get<SimpleGraphData>('/graph/simpleJson');
return response.data;
},
async searchNodes(query: string): Promise<SearchResponse> {
const response = await apiClient.get<SearchResponse>('/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;
+301 -6
View File
@@ -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<void>;
handleSearch: (query: string) => void;
handleSearchAsync: (query: string) => Promise<void>;
handleNodeSelect: (node: Node | null) => void;
handleNodeSelectById: (nodeId: string) => void;
createNode: (request: CreateNodeRequest) => Promise<void>;
updateNode: (id: string, request: UpdateNodeRequest) => Promise<void>;
deleteNode: (id: string) => Promise<void>;
getNodeNeighbors: (id: string) => Promise<NeighborResponse>;
createEdge: (request: CreateEdgeRequest) => Promise<void>;
deleteEdge: (id: string) => Promise<void>;
loadGraphStats: () => Promise<void>;
exportSimpleGraphData: () => Promise<SimpleGraphData>;
startEditingNode: (node: Node) => void;
stopEditingNode: () => void;
startCreatingNode: () => void;
stopCreatingNode: () => void;
startCreatingEdge: (node: Node) => void;
stopCreatingEdge: () => void;
addToast: (toast: Omit<ToastItem, 'id'>) => 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<void>;
}
export const useGraphStore = create<GraphState>((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<GraphState>((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<GraphState>((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<GraphState>((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<GraphState>((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<ToastItem, 'id'>) => {
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: '数据已刷新' });
},
}));
+171
View File
@@ -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<string, unknown>;
}
/**
* 更新节点请求
*/
export interface UpdateNodeRequest {
label?: string;
type?: string;
x?: number;
y?: number;
properties?: Record<string, unknown>;
}
/**
* 创建边请求
*/
export interface CreateEdgeRequest {
id: string;
label: string;
source: string;
target: string;
type?: string;
properties?: Record<string, unknown>;
}
// ==================== 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<string, unknown>;
}
/**
* 邻居节点响应
*/
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<string, number>;
edgeTypes: Record<string, number>;
}
// ==================== 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[];
}