2025-07-01 00:17:14 +08:00

235 lines
6.7 KiB
Vue

<template>
<div ref="svgContainer" class="diagram-container"></div>
</template>
<script setup lang="ts">
import { ref, onMounted, nextTick, reactive } from 'vue';
import * as d3 from 'd3';
import ELK, { type ElkNode } from 'elkjs/lib/elk.bundled.js';
import { mcpClientAdapter } from '@/views/connect/core';
const svgContainer = ref<HTMLDivElement | null>(null);
type Node = ElkNode & {
[key: string]: any;
};
const state = reactive({
nodes: [] as any[],
edges: [] as any[],
selectedNodeId: null as string | null,
draggingNodeId: null as string | null,
offset: { x: 0, y: 0 }
});
const getAllTools = async () => {
const items = [];
for (const client of mcpClientAdapter.clients) {
const clientTools = await client.getTools();
items.push(...clientTools.values());
}
return items;
};
const recomputeLayout = async () => {
const elk = new ELK();
const elkGraph = {
id: 'root',
layoutOptions: {
'elk.direction': 'DOWN',
'elk.spacing.nodeNode': '40',
'elk.layered.spacing.nodeNodeBetweenLayers': '40'
},
children: state.nodes,
edges: state.edges
};
const layout = await elk.layout(elkGraph) as Node;
state.nodes.forEach((n, i) => {
const ln = layout.children?.find(c => c.id === n.id);
if (ln) {
n.x = ln.x;
n.y = ln.y;
n.width = ln.width;
n.height = ln.height;
}
});
state.edges = layout.edges || [];
return layout;
};
const drawDiagram = async () => {
const tools = await getAllTools();
state.nodes = tools.map((tool, i) => ({
id: String(i),
width: 160,
height: 48,
labels: [{ text: tool.name || 'Tool' }]
}));
// 默认按照链表进行串联
state.edges = tools.slice(1).map((_, i) => ({
id: `e${i}`,
sources: [String(i)],
targets: [String(i + 1)]
}));
await recomputeLayout();
renderSvg();
};
function renderSvg() {
const width = Math.max(...state.nodes.map(n => (n.x || 0) + (n.width || 160)), 400) + 60;
const height = Math.max(...state.nodes.map(n => (n.y || 0) + (n.height || 48)), 300) + 60;
// 不再全量清空,只清空 svg 元素
let svg = d3.select(svgContainer.value).select('svg');
if (svg.empty()) {
svg = d3
.select(svgContainer.value)
.append('svg')
.attr('width', width)
.attr('height', height)
.style('user-select', 'none');
} else {
svg.attr('width', width).attr('height', height);
svg.selectAll('defs').remove();
}
// Arrow marker
svg
.append('defs')
.append('marker')
.attr('id', 'arrow')
.attr('viewBox', '0 0 10 10')
.attr('refX', 8)
.attr('refY', 5)
.attr('markerWidth', 6)
.attr('markerHeight', 6)
.attr('orient', 'auto')
.append('path')
.attr('d', 'M 0 0 L 10 5 L 0 10 z')
.attr('fill', 'var(--main-color)');
// Draw edges with enter animation
const allSections: { id: string, section: any }[] = [];
(state.edges || []).forEach(edge => {
const sections = edge.sections || [];
sections.forEach((section, idx) => {
allSections.push({
id: (edge.id || '') + '-' + (section.id || idx),
section
});
});
});
const edgeSelection = svg.selectAll<SVGLineElement, any>('.edge')
.data(allSections, d => d.id);
edgeSelection.exit().remove();
const edgeEnter = edgeSelection.enter()
.append('line')
.attr('class', 'edge')
.attr('x1', d => d.section.startPoint.x + 30)
.attr('y1', d => d.section.startPoint.y + 30)
.attr('x2', d => d.section.endPoint.x + 30)
.attr('y2', d => d.section.endPoint.y + 30)
.attr('stroke', 'var(--main-color)')
.attr('stroke-width', 2.5)
.attr('marker-end', 'url(#arrow)')
.attr('opacity', 0);
edgeEnter
.transition()
.duration(600)
.attr('opacity', 1);
edgeSelection.merge(edgeEnter)
.transition()
.duration(600)
.ease(d3.easeCubicInOut)
.attr('x1', d => d.section.startPoint.x + 30)
.attr('y1', d => d.section.startPoint.y + 30)
.attr('x2', d => d.section.endPoint.x + 30)
.attr('y2', d => d.section.endPoint.y + 30);
// --- 节点动画部分 ---
const nodeGroup = svg.selectAll<SVGGElement, any>('.node')
.data(state.nodes, d => d.id);
nodeGroup.exit().remove();
const nodeGroupEnter = nodeGroup.enter()
.append('g')
.attr('class', 'node')
.attr('transform', d => `translate(${(d.x || 0) + 30}, ${(d.y || 0) + 30})`)
.style('cursor', 'pointer')
.attr('opacity', 0)
.on('mousedown', null)
.on('mouseup', function (event, d) {
event.stopPropagation();
if (state.selectedNodeId && state.selectedNodeId !== d.id) {
state.edges.push({
id: `e${state.selectedNodeId}_${d.id}_${Date.now()}`,
sources: [state.selectedNodeId],
targets: [d.id]
});
state.selectedNodeId = null;
recomputeLayout().then(renderSvg);
} else {
state.selectedNodeId = d.id;
renderSvg();
}
state.draggingNodeId = null;
});
nodeGroupEnter.append('rect')
.attr('width', d => d.width)
.attr('height', d => d.height)
.attr('rx', 16)
.attr('fill', d => state.selectedNodeId === d.id ? 'var(--main-color)' : 'var(--main-color)')
.attr('opacity', d => state.selectedNodeId === d.id ? 0.25 : 0.12)
.attr('stroke', 'var(--main-color)')
.attr('stroke-width', 2);
nodeGroupEnter.append('text')
.attr('x', d => d.width / 2)
.attr('y', d => d.height / 2 + 6)
.attr('text-anchor', 'middle')
.attr('font-size', 16)
.attr('fill', 'var(--main-color)')
.attr('font-weight', 600)
.text(d => d.labels?.[0]?.text || 'Tool');
// 节点 enter 动画
nodeGroupEnter
.transition()
.duration(600)
.attr('opacity', 1);
// 节点 update 动画
nodeGroup
.transition()
.duration(600)
.ease(d3.easeCubicInOut)
.attr('transform', d => `translate(${(d.x || 0) + 30}, ${(d.y || 0) + 30})`);
}
onMounted(() => {
nextTick(drawDiagram);
});
</script>
<style>
.diagram-container {
width: 100%;
min-height: 300px;
display: flex;
justify-content: center;
align-items: flex-start;
border-radius: 8px;
padding: 24px 0;
overflow-x: auto;
}
</style>