feat: 前端对接后端增删查改接口
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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: '数据已刷新' });
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
Reference in New Issue
Block a user