feat: 前端对接后端增删查改接口
This commit is contained in:
@@ -15,7 +15,8 @@
|
|||||||
"Bash(sed -i 's/models\\\\./model\\\\./g' internal/handler/*.go)",
|
"Bash(sed -i 's/models\\\\./model\\\\./g' internal/handler/*.go)",
|
||||||
"Bash(sed -i 's/models\\\\./model\\\\./g' cmd/server/main.go)",
|
"Bash(sed -i 's/models\\\\./model\\\\./g' cmd/server/main.go)",
|
||||||
"Bash(go test:*)",
|
"Bash(go test:*)",
|
||||||
"Bash(swag init:*)"
|
"Bash(swag init:*)",
|
||||||
|
"Bash(npm run *)"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) => {
|
graph.on(NodeEvent.CLICK, (event: any) => {
|
||||||
console.log("[GraphView] Node click event", event);
|
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) => {
|
graph.on(CanvasEvent.CLICK, (event: any) => {
|
||||||
if (event.targetType !== "canvas") {
|
if (event.targetType !== "canvas") {
|
||||||
console.log(
|
console.log(
|
||||||
@@ -292,6 +332,9 @@ export const GraphView = ({
|
|||||||
|
|
||||||
if (graphRef.current) {
|
if (graphRef.current) {
|
||||||
graphRef.current.off(NodeEvent.CLICK);
|
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.off(CanvasEvent.CLICK);
|
||||||
graphRef.current.destroy();
|
graphRef.current.destroy();
|
||||||
graphRef.current = null;
|
graphRef.current = null;
|
||||||
|
|||||||
@@ -1,19 +1,59 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useGraphStore } from '../../store';
|
import { useGraphStore } from '../../store';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 节点详情面板组件
|
* 节点详情面板组件
|
||||||
* 展示选中节点的详细信息,包括属性和样式
|
* 展示选中节点的详细信息,包括属性和样式,并提供操作按钮
|
||||||
*/
|
*/
|
||||||
export const NodePanel = () => {
|
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;
|
if (!selectedNode) return null;
|
||||||
|
|
||||||
const node = selectedNode;
|
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 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 (
|
return (
|
||||||
<div className="w-full overflow-y-auto">
|
<div className="w-full overflow-y-auto">
|
||||||
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
<div className="p-4 border-b border-gray-200 flex justify-between items-center">
|
||||||
@@ -29,6 +69,45 @@ export const NodePanel = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 className="p-4 space-y-4">
|
||||||
{/* 基本信息 */}
|
{/* 基本信息 */}
|
||||||
<div>
|
<div>
|
||||||
@@ -52,6 +131,35 @@ export const NodePanel = () => {
|
|||||||
</div>
|
</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 和空对象 */}
|
{/* 属性部分:统一处理 undefined、null 和空对象 */}
|
||||||
<div>
|
<div>
|
||||||
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
|
<div className="text-xs text-gray-500 uppercase tracking-wide mb-2">
|
||||||
@@ -69,9 +177,7 @@ export const NodePanel = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-gray-400 italic">
|
<div className="text-sm text-gray-400 italic">暂无属性数据</div>
|
||||||
暂无属性数据
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -99,6 +205,80 @@ export const NodePanel = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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 GraphView from '../components/GraphView/index';
|
||||||
import SearchBar from '../components/SearchBar/index';
|
|
||||||
import NodePanel from '../components/NodePanel/index';
|
import NodePanel from '../components/NodePanel/index';
|
||||||
import Legend from '../components/Legend/index';
|
import Legend from '../components/Legend/index';
|
||||||
|
import { 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 { useGraphStore } from '../store';
|
||||||
import { useLayoutStore } from '../store';
|
import { useLayoutStore } from '../store';
|
||||||
import { generateLegendData } from '../config/colors';
|
import { generateLegendData } from '../config/colors';
|
||||||
|
import { useHotkeys } from '../hooks/useHotkeys';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 知识图谱主页面组件
|
* 知识图谱主页面组件
|
||||||
@@ -21,10 +30,19 @@ export const KnowledgeGraph = () => {
|
|||||||
loadGraphData,
|
loadGraphData,
|
||||||
handleNodeSelect,
|
handleNodeSelect,
|
||||||
loading,
|
loading,
|
||||||
|
closeContextMenu,
|
||||||
} = useGraphStore();
|
} = useGraphStore();
|
||||||
|
|
||||||
const { layout } = useLayoutStore();
|
const { layout } = useLayoutStore();
|
||||||
|
|
||||||
|
const [statsOpen, setStatsOpen] = useState(false);
|
||||||
|
const [exportOpen, setExportOpen] = useState(false);
|
||||||
|
const neighborNodeId = useGraphStore(state =>
|
||||||
|
state.contextMenuTarget?.node?.id || null
|
||||||
|
);
|
||||||
|
|
||||||
|
useHotkeys();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadGraphData();
|
loadGraphData();
|
||||||
}, [loadGraphData]);
|
}, [loadGraphData]);
|
||||||
@@ -44,17 +62,8 @@ export const KnowledgeGraph = () => {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<header className="bg-white border-b border-gray-200 px-6 py-4 flex items-center justify-between">
|
<header className="bg-white border-b border-gray-200 px-4 py-2">
|
||||||
<div className="flex items-center space-x-4">
|
<Toolbar />
|
||||||
<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>
|
</header>
|
||||||
|
|
||||||
<div className="flex-1 flex overflow-hidden">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,17 @@
|
|||||||
import axios from 'axios';
|
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';
|
import { API_BASE_URL, API_TIMEOUT } from '../config/constants';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -47,6 +59,58 @@ export const graphApi = {
|
|||||||
return null;
|
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;
|
export default apiClient;
|
||||||
@@ -1,5 +1,19 @@
|
|||||||
import { create } from 'zustand';
|
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 { graphApi } from '../services/graphApi';
|
||||||
import { SAMPLE_GRAPH_DATA } from '../config/sampleData';
|
import { SAMPLE_GRAPH_DATA } from '../config/sampleData';
|
||||||
import { resetColorAssignment } from '../config/colors';
|
import { resetColorAssignment } from '../config/colors';
|
||||||
@@ -7,21 +21,83 @@ import { resetColorAssignment } from '../config/colors';
|
|||||||
interface GraphState {
|
interface GraphState {
|
||||||
graphData: GraphData;
|
graphData: GraphData;
|
||||||
selectedNode: Node | null;
|
selectedNode: Node | null;
|
||||||
|
selectedEdge: Edge | null;
|
||||||
searchResults: SearchNode[];
|
searchResults: SearchNode[];
|
||||||
loading: boolean;
|
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;
|
addGraphData: (data: GraphData) => void;
|
||||||
setSelectedNode: (node: Node | null) => void;
|
setSelectedNode: (node: Node | null) => void;
|
||||||
|
setSelectedEdge: (edge: Edge | null) => void;
|
||||||
|
deselectAll: () => void;
|
||||||
loadGraphData: () => Promise<void>;
|
loadGraphData: () => Promise<void>;
|
||||||
handleSearch: (query: string) => void;
|
handleSearch: (query: string) => void;
|
||||||
|
handleSearchAsync: (query: string) => Promise<void>;
|
||||||
handleNodeSelect: (node: Node | null) => void;
|
handleNodeSelect: (node: Node | null) => void;
|
||||||
handleNodeSelectById: (nodeId: string) => 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) => ({
|
export const useGraphStore = create<GraphState>((set, get) => ({
|
||||||
graphData: { nodes: [], edges: [] },
|
graphData: { nodes: [], edges: [] },
|
||||||
selectedNode: null,
|
selectedNode: null,
|
||||||
|
selectedEdge: null,
|
||||||
searchResults: [],
|
searchResults: [],
|
||||||
loading: true,
|
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) => {
|
addGraphData: (data: GraphData) => {
|
||||||
set({ graphData: data });
|
set({ graphData: data });
|
||||||
@@ -29,14 +105,21 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
|||||||
|
|
||||||
setSelectedNode: (node: Node | null) => {
|
setSelectedNode: (node: Node | null) => {
|
||||||
console.log('[graphStore] setSelectedNode called with:', node);
|
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 () => {
|
loadGraphData: async () => {
|
||||||
try {
|
try {
|
||||||
set({ loading: true });
|
set({ loading: true });
|
||||||
const data = await graphApi.getData();
|
const data = await graphApi.getData();
|
||||||
|
|
||||||
resetColorAssignment();
|
resetColorAssignment();
|
||||||
|
|
||||||
if (data.nodes.length === 0) {
|
if (data.nodes.length === 0) {
|
||||||
@@ -53,7 +136,33 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleSearch: (query: string) => {
|
handleSearch: (query: string) => { const { graphData } = get();
|
||||||
|
const results = graphData.nodes
|
||||||
|
.filter((node: Node) =>
|
||||||
|
node.label.toLowerCase().includes(query.toLowerCase()) ||
|
||||||
|
node.id.toLowerCase().includes(query.toLowerCase())
|
||||||
|
)
|
||||||
|
.map((node: Node) => ({
|
||||||
|
id: node.id,
|
||||||
|
label: node.label,
|
||||||
|
type: node.type,
|
||||||
|
matched: true,
|
||||||
|
}));
|
||||||
|
set({ searchResults: results });
|
||||||
|
},
|
||||||
|
|
||||||
|
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 { graphData } = get();
|
||||||
const results = graphData.nodes
|
const results = graphData.nodes
|
||||||
.filter((node: Node) =>
|
.filter((node: Node) =>
|
||||||
@@ -67,6 +176,7 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
|||||||
matched: true,
|
matched: true,
|
||||||
}));
|
}));
|
||||||
set({ searchResults: results });
|
set({ searchResults: results });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
handleNodeSelectById: (nodeId: string) => {
|
handleNodeSelectById: (nodeId: string) => {
|
||||||
@@ -88,4 +198,189 @@ export const useGraphStore = create<GraphState>((set, get) => ({
|
|||||||
set({ selectedNode: node });
|
set({ selectedNode: node });
|
||||||
console.log('[graphStore] selectedNode set');
|
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: '数据已刷新' });
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -68,3 +68,174 @@ export interface SearchNode {
|
|||||||
type?: string;
|
type?: string;
|
||||||
matched?: boolean;
|
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