finish auto detection
This commit is contained in:
parent
18c934b983
commit
690dde1cd0
@ -1,8 +1,8 @@
|
|||||||
@font-face {
|
@font-face {
|
||||||
font-family: "iconfont"; /* Project id 4870215 */
|
font-family: "iconfont"; /* Project id 4870215 */
|
||||||
src: url('iconfont.woff2?t=1750532923458') format('woff2'),
|
src: url('iconfont.woff2?t=1751568095152') format('woff2'),
|
||||||
url('iconfont.woff?t=1750532923458') format('woff'),
|
url('iconfont.woff?t=1751568095152') format('woff'),
|
||||||
url('iconfont.ttf?t=1750532923458') format('truetype');
|
url('iconfont.ttf?t=1751568095152') format('truetype');
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconfont {
|
.iconfont {
|
||||||
@ -13,6 +13,14 @@
|
|||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.icon-pin:before {
|
||||||
|
content: "\e863";
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-serial:before {
|
||||||
|
content: "\e78f";
|
||||||
|
}
|
||||||
|
|
||||||
.icon-deploy:before {
|
.icon-deploy:before {
|
||||||
content: "\e614";
|
content: "\e614";
|
||||||
}
|
}
|
||||||
|
Binary file not shown.
@ -1,21 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="diagram-item-record" v-if="props.dataView && props.dataView.tool">
|
<div class="diagram-item-record" v-if="props.dataView && props.dataView.tool">
|
||||||
|
<div class="item-status" :class="props.dataView.status">{{ props.dataView.status }}</div>
|
||||||
|
|
||||||
<div class="item-header">
|
<div class="item-header">
|
||||||
<span class="item-title">{{ props.dataView.tool.name }}</span>
|
<span class="item-title">{{ props.dataView.tool.name }}</span>
|
||||||
<span class="item-status" :class="props.dataView.status">{{ props.dataView.status }}</span>
|
</div>
|
||||||
</div>
|
|
||||||
<div class="item-desc">{{ props.dataView.tool.description }}</div>
|
<div class="item-desc">{{ props.dataView.tool.description }}</div>
|
||||||
|
|
||||||
<div v-if="props.dataView.function !== undefined" class="item-result">
|
<br>
|
||||||
<div class="item-label">Function</div>
|
|
||||||
<div class="item-json">{{ props.dataView.function.name }}</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-if="props.dataView.function !== undefined" class="item-result">
|
<div v-if="props.dataView.function !== undefined" class="item-result">
|
||||||
<span class="item-label">Arguments</span>
|
<span class="item-label">Arguments</span>
|
||||||
<json-render :json="props.dataView.function.arguments" />
|
<div class="code-container">
|
||||||
|
<json-render :json="props.dataView.function.arguments" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
<div v-if="props.dataView.result !== undefined" class="item-result">
|
<div v-if="props.dataView.result !== undefined" class="item-result">
|
||||||
<span class="item-label">Result</span>
|
<span class="item-label">Result</span>
|
||||||
<template v-if="Array.isArray(props.dataView.result)">
|
<template v-if="Array.isArray(props.dataView.result)">
|
||||||
@ -85,10 +88,9 @@ function formatJson(obj: any) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.item-status {
|
.item-status {
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
padding: 2px 10px;
|
padding: 5px 0;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-left: 8px;
|
|
||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,7 +129,7 @@ function formatJson(obj: any) {
|
|||||||
.item-json {
|
.item-json {
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
font-size: 13px;
|
font-size: 15px;
|
||||||
font-family: var(--code-font-family, monospace);
|
font-family: var(--code-font-family, monospace);
|
||||||
margin: 2px 0 8px 0;
|
margin: 2px 0 8px 0;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
@ -137,6 +139,13 @@ function formatJson(obj: any) {
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.code-container {
|
||||||
|
margin-top: 10px;
|
||||||
|
border-radius: .3em;
|
||||||
|
padding: 0 10px;
|
||||||
|
background-color: var(--sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
.item-result {
|
.item-result {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
@ -45,7 +45,7 @@ export interface NodeDataView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DiagramContext {
|
export interface DiagramContext {
|
||||||
reset: () => void,
|
preset: (type: string) => void,
|
||||||
render: () => void,
|
render: () => void,
|
||||||
state?: DiagramState,
|
state?: DiagramState,
|
||||||
setCaption: (value: string) => void
|
setCaption: (value: string) => void
|
||||||
|
@ -1,27 +1,38 @@
|
|||||||
<template>
|
<template>
|
||||||
<div style="display: flex; align-items: center; gap: 16px;">
|
<div style="display: flex; align-items: flex-start; gap: 32px;">
|
||||||
<div ref="svgContainer" class="diagram-container"></div>
|
<div ref="svgContainer" class="diagram-container"></div>
|
||||||
|
<div class="diagram-info-panel">
|
||||||
<template v-for="(node, index) in state.nodes" :key="node.id + '-popup'">
|
<div style="display: flex; justify-content: flex-end; align-items: center;">
|
||||||
<transition name="collapse-from-top" mode="out-in">
|
<el-button
|
||||||
<div
|
circle
|
||||||
v-show="state.hoverNodeId === node.id && state.dataView.get(node.id)?.status !== 'waiting'"
|
size="small"
|
||||||
@mouseenter="setHoverItem(node.id)"
|
:type="state.pinnedNodeId ? 'primary' : 'default'"
|
||||||
@mouseleave="clearHoverItem()"
|
@click="togglePin"
|
||||||
:style="getNodePopupStyle(node)"
|
style="margin-bottom: 4px;"
|
||||||
class="node-popup"
|
:disabled="!infoNodeId"
|
||||||
>
|
>
|
||||||
<el-scrollbar height="100%" width="100%">
|
<span class="iconfont icon-pin"></span>
|
||||||
<DiagramItemRecord :data-view="state.dataView.get(node.id)"/>
|
</el-button>
|
||||||
</el-scrollbar>
|
</div>
|
||||||
</div>
|
<template v-if="infoNodeId && state.dataView.get(infoNodeId)">
|
||||||
</transition>
|
<el-scrollbar height="500px" width="300px">
|
||||||
</template>
|
<DiagramItemRecord :data-view="state.dataView.get(infoNodeId)" />
|
||||||
|
</el-scrollbar>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<div class="diagram-info-empty">
|
||||||
|
<el-icon style="font-size: 32px; color: #bbb; margin-bottom: 8px;">
|
||||||
|
<i-ep-InfoFilled />
|
||||||
|
</el-icon>
|
||||||
|
<div style="color: #bbb; font-size: 15px;">暂无节点信息</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, nextTick, reactive, inject } from 'vue';
|
import { ref, onMounted, nextTick, reactive, inject, computed } from 'vue';
|
||||||
import * as d3 from 'd3';
|
import * as d3 from 'd3';
|
||||||
import ELK from 'elkjs/lib/elk.bundled.js';
|
import ELK from 'elkjs/lib/elk.bundled.js';
|
||||||
import { mcpClientAdapter } from '@/views/connect/core';
|
import { mcpClientAdapter } from '@/views/connect/core';
|
||||||
@ -53,6 +64,7 @@ const state = reactive({
|
|||||||
selectedNodeId: null as string | null,
|
selectedNodeId: null as string | null,
|
||||||
draggingNodeId: null as string | null,
|
draggingNodeId: null as string | null,
|
||||||
hoverNodeId: null as string | null,
|
hoverNodeId: null as string | null,
|
||||||
|
pinnedNodeId: null as string | null, // 新增
|
||||||
offset: { x: 0, y: 0 },
|
offset: { x: 0, y: 0 },
|
||||||
dataView: new Map<string, NodeDataView>
|
dataView: new Map<string, NodeDataView>
|
||||||
});
|
});
|
||||||
@ -66,6 +78,7 @@ if (autoDetectDiagram) {
|
|||||||
autoDetectDiagram.views?.forEach(item => {
|
autoDetectDiagram.views?.forEach(item => {
|
||||||
state.dataView.set(item.tool.name, {
|
state.dataView.set(item.tool.name, {
|
||||||
tool: item.tool,
|
tool: item.tool,
|
||||||
|
function: item.function,
|
||||||
status: item.status || 'waiting',
|
status: item.status || 'waiting',
|
||||||
result: item.result || null
|
result: item.result || null
|
||||||
});
|
});
|
||||||
@ -84,23 +97,38 @@ console.log(state.dataView);
|
|||||||
let cancelHoverHandler: NodeJS.Timeout | undefined = undefined;
|
let cancelHoverHandler: NodeJS.Timeout | undefined = undefined;
|
||||||
|
|
||||||
const setHoverItem = (id: string) => {
|
const setHoverItem = (id: string) => {
|
||||||
if (cancelHoverHandler) {
|
if (state.pinnedNodeId) return; // 如果已pin,不响应hover
|
||||||
clearTimeout(cancelHoverHandler);
|
if (cancelHoverHandler) {
|
||||||
}
|
clearTimeout(cancelHoverHandler);
|
||||||
state.hoverNodeId = id;
|
}
|
||||||
}
|
state.hoverNodeId = id;
|
||||||
|
};
|
||||||
|
|
||||||
const clearHoverItem = () => {
|
const clearHoverItem = () => {
|
||||||
cancelHoverHandler = setTimeout(() => {
|
if (state.pinnedNodeId) return; // 如果已pin,不响应hover
|
||||||
if (cancelHoverHandler) {
|
cancelHoverHandler = setTimeout(() => {
|
||||||
clearTimeout(cancelHoverHandler);
|
if (cancelHoverHandler) {
|
||||||
}
|
clearTimeout(cancelHoverHandler);
|
||||||
if (state.hoverNodeId) {
|
}
|
||||||
state.hoverNodeId = null;
|
if (state.hoverNodeId) {
|
||||||
}
|
state.hoverNodeId = null;
|
||||||
}, 300);
|
}
|
||||||
|
}, 300);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// pin 按钮点击事件
|
||||||
|
function togglePin() {
|
||||||
|
if (state.pinnedNodeId) {
|
||||||
|
state.pinnedNodeId = null;
|
||||||
|
} else if (state.hoverNodeId) {
|
||||||
|
state.pinnedNodeId = state.hoverNodeId;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 让面板内容始终显示 pinnedNodeId 优先
|
||||||
|
const infoNodeId = computed(() => state.pinnedNodeId || state.hoverNodeId);
|
||||||
|
|
||||||
|
|
||||||
const getAllTools = async () => {
|
const getAllTools = async () => {
|
||||||
const items = [];
|
const items = [];
|
||||||
for (const client of mcpClientAdapter.clients) {
|
for (const client of mcpClientAdapter.clients) {
|
||||||
@ -154,7 +182,7 @@ const drawDiagram = async () => {
|
|||||||
|
|
||||||
// 如果保存了 edges 信息,则需要进行同步
|
// 如果保存了 edges 信息,则需要进行同步
|
||||||
const reservedEdges = autoDetectDiagram?.edges;
|
const reservedEdges = autoDetectDiagram?.edges;
|
||||||
if (reservedEdges) {
|
if (reservedEdges && reservedEdges.length > 0) {
|
||||||
for (const edge of reservedEdges) {
|
for (const edge of reservedEdges) {
|
||||||
if (edge.sources && edge.targets && edge.sources.length > 0 && edge.targets.length > 0) {
|
if (edge.sources && edge.targets && edge.sources.length > 0 && edge.targets.length > 0) {
|
||||||
edges.push({
|
edges.push({
|
||||||
@ -379,7 +407,7 @@ function renderSvg() {
|
|||||||
state.draggingNodeId = null;
|
state.draggingNodeId = null;
|
||||||
})
|
})
|
||||||
.on('mouseover', function (event, d) {
|
.on('mouseover', function (event, d) {
|
||||||
setHoverItem(d.id);
|
setHoverItem(d.id);
|
||||||
d3.select(this).select('rect')
|
d3.select(this).select('rect')
|
||||||
.transition()
|
.transition()
|
||||||
.duration(200)
|
.duration(200)
|
||||||
@ -387,7 +415,7 @@ function renderSvg() {
|
|||||||
.attr('stroke-width', 2);
|
.attr('stroke-width', 2);
|
||||||
})
|
})
|
||||||
.on('mouseout', function (event, d) {
|
.on('mouseout', function (event, d) {
|
||||||
clearHoverItem();
|
// clearHoverItem();
|
||||||
if (state.selectedNodeId === d.id) return;
|
if (state.selectedNodeId === d.id) return;
|
||||||
d3.select(this).select('rect')
|
d3.select(this).select('rect')
|
||||||
.transition()
|
.transition()
|
||||||
@ -413,7 +441,7 @@ function renderSvg() {
|
|||||||
.attr('fill', 'var(--main-color)')
|
.attr('fill', 'var(--main-color)')
|
||||||
.attr('font-weight', 600)
|
.attr('font-weight', 600)
|
||||||
.text(d => d.labels?.[0]?.text || 'Tool');
|
.text(d => d.labels?.[0]?.text || 'Tool');
|
||||||
|
|
||||||
nodeGroupEnter.append('g').attr('class', 'node-status');
|
nodeGroupEnter.append('g').attr('class', 'node-status');
|
||||||
|
|
||||||
// 合并 enter+update
|
// 合并 enter+update
|
||||||
@ -557,7 +585,7 @@ function renderSvg() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 重置连接为链表结构
|
// 重置连接为链表结构
|
||||||
function resetConnections() {
|
function serialConnection() {
|
||||||
if (!state.nodes.length) return;
|
if (!state.nodes.length) return;
|
||||||
const edges = [];
|
const edges = [];
|
||||||
for (let i = 0; i < state.nodes.length - 1; ++i) {
|
for (let i = 0; i < state.nodes.length - 1; ++i) {
|
||||||
@ -573,8 +601,21 @@ function resetConnections() {
|
|||||||
recomputeLayout().then(renderSvg);
|
recomputeLayout().then(renderSvg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parallelConnection() {
|
||||||
|
if (!state.nodes.length) return;
|
||||||
|
const edges = [] as Edge[];
|
||||||
|
state.edges = edges;
|
||||||
|
recomputeLayout().then(renderSvg);
|
||||||
|
}
|
||||||
|
|
||||||
const context = inject('context') as any;
|
const context = inject('context') as any;
|
||||||
context.reset = resetConnections;
|
context.preset = (type: string) => {
|
||||||
|
if (type === 'serial') {
|
||||||
|
serialConnection();
|
||||||
|
} else if (type === 'parallel') {
|
||||||
|
parallelConnection();
|
||||||
|
}
|
||||||
|
};
|
||||||
context.state = state;
|
context.state = state;
|
||||||
context.render = renderSvg;
|
context.render = renderSvg;
|
||||||
|
|
||||||
@ -590,7 +631,7 @@ function getNodePopupStyle(node: any): any {
|
|||||||
const marginY = 80;
|
const marginY = 80;
|
||||||
const popupWidth = 300;
|
const popupWidth = 300;
|
||||||
const popupHeight = 500;
|
const popupHeight = 500;
|
||||||
|
|
||||||
let left = (node.x || 0) + (node.width || 160) + 100;
|
let left = (node.x || 0) + (node.width || 160) + 100;
|
||||||
let top = (node.y || 0) + 30;
|
let top = (node.y || 0) + 30;
|
||||||
|
|
||||||
@ -619,13 +660,12 @@ function getNodePopupStyle(node: any): any {
|
|||||||
|
|
||||||
<style>
|
<style>
|
||||||
.diagram-container {
|
.diagram-container {
|
||||||
width: 100%;
|
width: 600px;
|
||||||
min-height: 200px;
|
min-height: 200px;
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: flex-start;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 24px 0;
|
padding: 24px 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -651,4 +691,41 @@ function getNodePopupStyle(node: any): any {
|
|||||||
transform: rotate(360deg);
|
transform: rotate(360deg);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.diagram-container {
|
||||||
|
width: 600px;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-info-panel {
|
||||||
|
position: absolute;
|
||||||
|
right: 30px;
|
||||||
|
top: 10px;
|
||||||
|
width: 300px;
|
||||||
|
min-height: 180px;
|
||||||
|
border: 1px solid var(--main-color);
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 12px 0 rgba(0,0,0,0.06);
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
margin-top: 8px;
|
||||||
|
transition: box-shadow 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.diagram-info-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 120px;
|
||||||
|
opacity: 0.85;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
@ -4,15 +4,30 @@
|
|||||||
<div style="display: flex; align-items: center;">
|
<div style="display: flex; align-items: center;">
|
||||||
<span>Tool Diagram</span>
|
<span>Tool Diagram</span>
|
||||||
 
|
 
|
||||||
<el-button size="small" type="primary" @click="() => context.reset()">{{ t("reset") }}</el-button>
|
<!-- 重置按钮弹出下拉列表 -->
|
||||||
<!-- 自检程序弹出表单 -->
|
<el-popover placement="bottom" width="180" trigger="click" v-model:visible="resetPopoverVisible">
|
||||||
|
<template #reference>
|
||||||
|
<el-button size="small" type="primary">
|
||||||
|
{{ t("preset") }}
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
<div style="display: flex; gap: 8px;">
|
||||||
|
<el-button size="small" @click="tomoPreset('serial')">
|
||||||
|
<span class="iconfont icon-serial"></span>
|
||||||
|
</el-button>
|
||||||
|
<el-button size="small" @click="tomoPreset('parallel')">
|
||||||
|
<span class="iconfont icon-parallel"></span>
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</el-popover>
|
||||||
|
<!-- 原有自检程序弹出表单 -->
|
||||||
<el-popover placement="top" width="350" trigger="click" v-model:visible="testFormVisible">
|
<el-popover placement="top" width="350" trigger="click" v-model:visible="testFormVisible">
|
||||||
<template #reference>
|
<template #reference>
|
||||||
<el-button size="small" type="primary">
|
<el-button size="small" type="primary">
|
||||||
{{ t('start-auto-detect') }}
|
{{ t('start-auto-detect') }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
</template>
|
||||||
|
<!-- ...原有自检表单内容... -->
|
||||||
<el-input type="textarea" v-model="testPrompt" :rows="2" style="margin-bottom: 8px;"
|
<el-input type="textarea" v-model="testPrompt" :rows="2" style="margin-bottom: 8px;"
|
||||||
placeholder="请输入 prompt" />
|
placeholder="请输入 prompt" />
|
||||||
<div style="display: flex; align-items: center; margin-bottom: 8px;">
|
<div style="display: flex; align-items: center; margin-bottom: 8px;">
|
||||||
@ -34,11 +49,21 @@
|
|||||||
<el-scrollbar height="80vh">
|
<el-scrollbar height="80vh">
|
||||||
<Diagram :tab-id="props.tabId" />
|
<Diagram :tab-id="props.tabId" />
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
<transition name="main-fade" mode="out-in">
|
|
||||||
<div class="caption" v-show="showCaption">
|
<div class="caption" v-if="showCaption">
|
||||||
{{ caption }}
|
{{ caption }}
|
||||||
</div>
|
</div>
|
||||||
</transition>
|
<div v-else>
|
||||||
|
<span class="caption">
|
||||||
|
<el-tooltip
|
||||||
|
placement="top"
|
||||||
|
effect="light"
|
||||||
|
content="点击连接线取消连接,点击节点以创建连接"
|
||||||
|
>
|
||||||
|
<span class="iconfont icon-about"></span>
|
||||||
|
</el-tooltip>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@ -88,7 +113,7 @@ function setCaption(text: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const context: DiagramContext = {
|
const context: DiagramContext = {
|
||||||
reset: () => { },
|
preset: () => { },
|
||||||
render: () => { },
|
render: () => { },
|
||||||
state: undefined,
|
state: undefined,
|
||||||
setCaption
|
setCaption
|
||||||
@ -144,16 +169,24 @@ async function onTestConfirm() {
|
|||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resetPopoverVisible = ref(false);
|
||||||
|
|
||||||
|
function tomoPreset(type: string) {
|
||||||
|
resetPopoverVisible.value = false;
|
||||||
|
context.preset?.(type);
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.no-padding-dialog {
|
.no-padding-dialog {
|
||||||
margin-top: 30px !important;
|
margin-top: 30px !important;
|
||||||
|
width: 90vw !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-padding-dialog .caption {
|
.no-padding-dialog .caption {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 20px;
|
right: 30px;
|
||||||
bottom: 10px;
|
bottom: 10px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
547
renderer/src/components/main-panel/tool/diagram.vue
Normal file
547
renderer/src/components/main-panel/tool/diagram.vue
Normal file
@ -0,0 +1,547 @@
|
|||||||
|
<template>
|
||||||
|
<div style="display: flex; align-items: center; gap: 16px;">
|
||||||
|
<div ref="svgContainer" class="diagram-container"></div>
|
||||||
|
|
||||||
|
<!-- <template v-for="(node, index) in state.nodes" :key="node.id + '-popup'">
|
||||||
|
<div
|
||||||
|
v-if="state.hoverNodeId === node.id"
|
||||||
|
:style="getNodePopupStyle(node)"
|
||||||
|
class="node-popup"
|
||||||
|
>
|
||||||
|
<div>节点:{{ node.labels?.[0]?.text || node.id }}</div>
|
||||||
|
<div>宽: {{ node.width }}, 高: {{ node.height }}</div>
|
||||||
|
</div>
|
||||||
|
</template> -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, nextTick, reactive, inject } from 'vue';
|
||||||
|
import * as d3 from 'd3';
|
||||||
|
import ELK from 'elkjs/lib/elk.bundled.js';
|
||||||
|
import { mcpClientAdapter } from '@/views/connect/core';
|
||||||
|
import { invalidConnectionDetector, type Edge, type Node, type NodeDataView } from './diagram';
|
||||||
|
import { ElMessage } from 'element-plus';
|
||||||
|
|
||||||
|
|
||||||
|
const svgContainer = ref<HTMLDivElement | null>(null);
|
||||||
|
let prevNodes: any[] = [];
|
||||||
|
let prevEdges: any[] = [];
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
nodes: [] as any[],
|
||||||
|
edges: [] as any[],
|
||||||
|
selectedNodeId: null as string | null,
|
||||||
|
draggingNodeId: null as string | null,
|
||||||
|
hoverNodeId: null as string | null,
|
||||||
|
offset: { x: 0, y: 0 },
|
||||||
|
dataView: new Map<string, NodeDataView>
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
|
||||||
|
// 默认按照链表进行串联
|
||||||
|
const nodes = [] as Node[];
|
||||||
|
const edges = [] as Edge[];
|
||||||
|
|
||||||
|
for (let i = 0; i < tools.length - 1; ++i) {
|
||||||
|
const prev = tools[i];
|
||||||
|
const next = tools[i + 1];
|
||||||
|
edges.push({
|
||||||
|
id: prev.name + '-' + next.name,
|
||||||
|
sources: [prev.name],
|
||||||
|
targets: [next.name]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const tool of tools) {
|
||||||
|
nodes.push({
|
||||||
|
id: tool.name,
|
||||||
|
width: 200,
|
||||||
|
height: 64, // 增加高度
|
||||||
|
labels: [{ text: tool.name || 'Tool' }]
|
||||||
|
});
|
||||||
|
|
||||||
|
state.dataView.set(tool.name, {
|
||||||
|
tool,
|
||||||
|
status: 'waiting',
|
||||||
|
result: null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
state.edges = edges;
|
||||||
|
state.nodes = nodes;
|
||||||
|
|
||||||
|
// 重新计算布局
|
||||||
|
await recomputeLayout();
|
||||||
|
|
||||||
|
// 绘制 svg
|
||||||
|
renderSvg();
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderSvg() {
|
||||||
|
const prevNodeMap = new Map(prevNodes.map(n => [n.id, n]));
|
||||||
|
const prevEdgeMap = new Map(prevEdges.map(e => [e.id, e]));
|
||||||
|
|
||||||
|
// 计算所有节点的最小x和最大x
|
||||||
|
const xs = state.nodes.map(n => (n.x || 0));
|
||||||
|
const minX = Math.min(...xs);
|
||||||
|
const maxX = Math.max(...xs.map((x, i) => x + (state.nodes[i].width || 160)));
|
||||||
|
const contentWidth = maxX - minX;
|
||||||
|
const svgWidth = Math.max(contentWidth + 120, 400); // 120为两侧留白
|
||||||
|
const offsetX = (svgWidth - contentWidth) / 2 - minX;
|
||||||
|
|
||||||
|
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', svgWidth)
|
||||||
|
.attr('height', height)
|
||||||
|
.style('user-select', 'none') as any;
|
||||||
|
} else {
|
||||||
|
svg.attr('width', svgWidth).attr('height', height);
|
||||||
|
svg.selectAll('defs').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Arrow marker
|
||||||
|
svg
|
||||||
|
.append('defs')
|
||||||
|
.append('marker')
|
||||||
|
.attr('id', 'arrow')
|
||||||
|
.attr('viewBox', '0 0 8 8')
|
||||||
|
.attr('refX', 6)
|
||||||
|
.attr('refY', 4)
|
||||||
|
.attr('markerWidth', 5)
|
||||||
|
.attr('markerHeight', 5)
|
||||||
|
.attr('orient', 'auto')
|
||||||
|
.append('path')
|
||||||
|
.attr('d', 'M 0 0 L 8 4 L 0 8 z')
|
||||||
|
.attr('fill', 'var(--main-color)');
|
||||||
|
|
||||||
|
// 1. 创建/获取 main group
|
||||||
|
let mainGroup = svg.select('g.main-group');
|
||||||
|
if (mainGroup.empty()) {
|
||||||
|
mainGroup = svg.append('g').attr('class', 'main-group') as any;
|
||||||
|
}
|
||||||
|
mainGroup
|
||||||
|
.transition()
|
||||||
|
.duration(600)
|
||||||
|
.attr('transform', `translate(${offsetX}, 0)`);
|
||||||
|
|
||||||
|
// Draw edges with enter animation
|
||||||
|
const allSections: { id: string, section: any }[] = [];
|
||||||
|
(state.edges || []).forEach(edge => {
|
||||||
|
const sections = edge.sections || [];
|
||||||
|
sections.forEach((section: any, idx: number) => {
|
||||||
|
allSections.push({
|
||||||
|
id: (edge.id || '') + '-' + (section.id || idx),
|
||||||
|
section
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const edgeSelection = mainGroup.selectAll<SVGLineElement, any>('.edge')
|
||||||
|
.data(allSections, d => d.id);
|
||||||
|
|
||||||
|
edgeSelection.exit().remove();
|
||||||
|
|
||||||
|
const edgeEnter = edgeSelection.enter()
|
||||||
|
.append('line')
|
||||||
|
.attr('class', 'edge')
|
||||||
|
.attr('x1', d => {
|
||||||
|
const prev = prevEdgeMap.get(d.id);
|
||||||
|
return prev && prev.sections && prev.sections[0]
|
||||||
|
? prev.sections[0].startPoint.x + 30
|
||||||
|
: d.section.startPoint.x + 30;
|
||||||
|
})
|
||||||
|
.attr('y1', d => {
|
||||||
|
const prev = prevEdgeMap.get(d.id);
|
||||||
|
return prev && prev.sections && prev.sections[0]
|
||||||
|
? prev.sections[0].startPoint.y + 30
|
||||||
|
: d.section.startPoint.y + 30;
|
||||||
|
})
|
||||||
|
.attr('x2', d => {
|
||||||
|
const prev = prevEdgeMap.get(d.id);
|
||||||
|
return prev && prev.sections && prev.sections[0]
|
||||||
|
? prev.sections[0].endPoint.x + 30
|
||||||
|
: d.section.endPoint.x + 30;
|
||||||
|
})
|
||||||
|
.attr('y2', d => {
|
||||||
|
const prev = prevEdgeMap.get(d.id);
|
||||||
|
return prev && prev.sections && prev.sections[0]
|
||||||
|
? prev.sections[0].endPoint.y + 30
|
||||||
|
: 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)
|
||||||
|
.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);
|
||||||
|
|
||||||
|
// update + 动画(注意这里不再 transition opacity)
|
||||||
|
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)
|
||||||
|
.attr('opacity', 1);
|
||||||
|
|
||||||
|
// --- 节点动画部分 ---
|
||||||
|
const nodeGroup = mainGroup.selectAll<SVGGElement, any>('.node')
|
||||||
|
.data(state.nodes, d => d.id);
|
||||||
|
|
||||||
|
nodeGroup.exit().remove();
|
||||||
|
|
||||||
|
// 节点 enter
|
||||||
|
const nodeGroupEnter = nodeGroup.enter()
|
||||||
|
.append('g')
|
||||||
|
.attr('class', 'node')
|
||||||
|
.attr('transform', d => {
|
||||||
|
const prev = prevNodeMap.get(d.id);
|
||||||
|
if (prev) {
|
||||||
|
return `translate(${(prev.x || 0) + 30}, ${(prev.y || 0) + 30})`;
|
||||||
|
}
|
||||||
|
return `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) {
|
||||||
|
|
||||||
|
const { canConnect, reason } = invalidConnectionDetector(state, d);
|
||||||
|
|
||||||
|
console.log(reason);
|
||||||
|
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
ElMessage.warning(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (canConnect) {
|
||||||
|
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 = null;
|
||||||
|
renderSvg();
|
||||||
|
}
|
||||||
|
context.setCaption('');
|
||||||
|
|
||||||
|
} else {
|
||||||
|
state.selectedNodeId = d.id;
|
||||||
|
renderSvg();
|
||||||
|
context.setCaption('选择另一个节点以定义测试拓扑');
|
||||||
|
}
|
||||||
|
state.draggingNodeId = null;
|
||||||
|
})
|
||||||
|
.on('mouseover', function (event, d) {
|
||||||
|
state.hoverNodeId = d.id;
|
||||||
|
d3.select(this).select('rect')
|
||||||
|
.transition()
|
||||||
|
.duration(200)
|
||||||
|
.attr('stroke', 'var(--main-color)')
|
||||||
|
.attr('stroke-width', 2);
|
||||||
|
})
|
||||||
|
.on('mouseout', function (event, d) {
|
||||||
|
state.hoverNodeId = null;
|
||||||
|
if (state.selectedNodeId === d.id) return;
|
||||||
|
d3.select(this).select('rect')
|
||||||
|
.transition()
|
||||||
|
.duration(200)
|
||||||
|
.attr('stroke', 'var(--main-light-color-10)')
|
||||||
|
.attr('stroke-width', 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
nodeGroupEnter.append('rect')
|
||||||
|
.attr('width', d => d.width)
|
||||||
|
.attr('height', d => d.height)
|
||||||
|
.attr('rx', 16)
|
||||||
|
.attr('fill', 'var(--main-light-color-20)')
|
||||||
|
.attr('stroke', d => state.selectedNodeId === d.id ? 'var(--main-color)' : 'var(--main-light-color-10)')
|
||||||
|
.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');
|
||||||
|
|
||||||
|
// 状态条
|
||||||
|
nodeGroupEnter.append('g')
|
||||||
|
.attr('class', 'node-status')
|
||||||
|
.each(function (d) {
|
||||||
|
const status = state.dataView.get(d.id)?.status || 'waiting';
|
||||||
|
const g = d3.select(this);
|
||||||
|
if (status === 'running') {
|
||||||
|
// 动画圆环+文字
|
||||||
|
g.append('circle')
|
||||||
|
.attr('cx', d.width / 2 - 32)
|
||||||
|
.attr('cy', d.height - 16)
|
||||||
|
.attr('r', 6) // 半径更小
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', 'var(--main-color)') // 使用主题色
|
||||||
|
.attr('stroke-width', 3)
|
||||||
|
.attr('stroke-dasharray', 20)
|
||||||
|
.attr('stroke-dashoffset', 0)
|
||||||
|
.append('animateTransform')
|
||||||
|
.attr('attributeName', 'transform')
|
||||||
|
.attr('attributeType', 'XML')
|
||||||
|
.attr('type', 'rotate')
|
||||||
|
.attr('from', `0 ${(d.width / 2 - 32)} ${(d.height - 16)}`)
|
||||||
|
.attr('to', `360 ${(d.width / 2 - 32)} ${(d.height - 16)}`)
|
||||||
|
.attr('dur', '1s')
|
||||||
|
.attr('repeatCount', 'indefinite');
|
||||||
|
g.append('text')
|
||||||
|
.attr('x', d.width / 2 - 16)
|
||||||
|
.attr('y', d.height - 12)
|
||||||
|
.attr('font-size', 13)
|
||||||
|
.attr('fill', 'var(--main-color)')
|
||||||
|
.text('running');
|
||||||
|
} else if (status === 'waiting') {
|
||||||
|
g.append('circle')
|
||||||
|
.attr('cx', d.width / 2 - 32)
|
||||||
|
.attr('cy', d.height - 16)
|
||||||
|
.attr('r', 6)
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', '#bdbdbd')
|
||||||
|
.attr('stroke-width', 3);
|
||||||
|
g.append('text')
|
||||||
|
.attr('x', d.width / 2 - 16)
|
||||||
|
.attr('y', d.height - 12)
|
||||||
|
.attr('font-size', 13)
|
||||||
|
.attr('fill', '#bdbdbd')
|
||||||
|
.text('waiting');
|
||||||
|
} else if (status === 'success') {
|
||||||
|
g.append('circle')
|
||||||
|
.attr('cx', d.width / 2 - 32)
|
||||||
|
.attr('cy', d.height - 16)
|
||||||
|
.attr('r', 6) // 保持和 waiting 一致
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', '#4caf50')
|
||||||
|
.attr('stroke-width', 3);
|
||||||
|
g.append('text')
|
||||||
|
.attr('x', d.width / 2 - 16)
|
||||||
|
.attr('y', d.height - 12)
|
||||||
|
.attr('font-size', 13)
|
||||||
|
.attr('fill', '#4caf50')
|
||||||
|
.text('success');
|
||||||
|
} else if (status === 'error') {
|
||||||
|
g.append('circle')
|
||||||
|
.attr('cx', d.width / 2 - 32)
|
||||||
|
.attr('cy', d.height - 16)
|
||||||
|
.attr('r', 6) // 保持和 waiting 一致
|
||||||
|
.attr('fill', 'none')
|
||||||
|
.attr('stroke', '#f44336')
|
||||||
|
.attr('stroke-width', 3);
|
||||||
|
g.append('text')
|
||||||
|
.attr('x', d.width / 2 - 16)
|
||||||
|
.attr('y', d.height - 12)
|
||||||
|
.attr('font-size', 13)
|
||||||
|
.attr('fill', '#f44336')
|
||||||
|
.text('error');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 节点 enter 动画
|
||||||
|
nodeGroupEnter
|
||||||
|
.transition()
|
||||||
|
.duration(600)
|
||||||
|
.attr('opacity', 1)
|
||||||
|
.attr('transform', d => `translate(${(d.x || 0) + 30}, ${(d.y || 0) + 30})`);
|
||||||
|
|
||||||
|
// 节点 update 动画
|
||||||
|
nodeGroup
|
||||||
|
.transition()
|
||||||
|
.duration(600)
|
||||||
|
.ease(d3.easeCubicInOut)
|
||||||
|
.attr('transform', d => `translate(${(d.x || 0) + 30}, ${(d.y || 0) + 30})`);
|
||||||
|
|
||||||
|
// 高亮选中节点动画
|
||||||
|
nodeGroup.select('rect')
|
||||||
|
.transition()
|
||||||
|
.duration(400)
|
||||||
|
.attr('stroke-width', d => state.selectedNodeId === d.id ? 2 : 1)
|
||||||
|
.attr('stroke', d => state.selectedNodeId === d.id ? 'var(--main-color)' : 'var(--main-light-color-10)');
|
||||||
|
|
||||||
|
// 边高亮
|
||||||
|
svg.selectAll<SVGLineElement, any>('.edge')
|
||||||
|
.on('mouseover', function () {
|
||||||
|
d3.select(this)
|
||||||
|
.transition()
|
||||||
|
.duration(200)
|
||||||
|
.attr('stroke', 'var(--main-color)')
|
||||||
|
.attr('stroke-width', 4.5);
|
||||||
|
|
||||||
|
context.setCaption('点击边以删除');
|
||||||
|
|
||||||
|
})
|
||||||
|
.on('mouseout', function () {
|
||||||
|
d3.select(this)
|
||||||
|
.transition()
|
||||||
|
.duration(200)
|
||||||
|
.attr('stroke', 'var(--main-color)')
|
||||||
|
.attr('stroke-width', 2.5);
|
||||||
|
|
||||||
|
context.setCaption('');
|
||||||
|
})
|
||||||
|
.on('click', function (event, d) {
|
||||||
|
// 只删除当前 edge
|
||||||
|
state.edges = state.edges.filter(e => {
|
||||||
|
// 多段 edge 情况
|
||||||
|
if (e.sections) {
|
||||||
|
// 只保留不是当前 section 的
|
||||||
|
return !e.sections.some((section: any, idx: number) =>
|
||||||
|
((e.id || '') + '-' + (section.id || idx)) === d.id
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 单段 edge 情况
|
||||||
|
return e.id !== d.id && e.id !== d.section?.id;
|
||||||
|
});
|
||||||
|
recomputeLayout().then(renderSvg);
|
||||||
|
event.stopPropagation();
|
||||||
|
});
|
||||||
|
|
||||||
|
// 渲染结束后保存当前快照
|
||||||
|
prevNodes = state.nodes.map(n => ({ ...n }));
|
||||||
|
prevEdges = (state.edges || []).map(e => ({ ...e, sections: e.sections ? e.sections.map((s: any) => ({ ...s })) : [] }));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置连接为链表结构
|
||||||
|
function resetConnections() {
|
||||||
|
if (!state.nodes.length) return;
|
||||||
|
const edges = [];
|
||||||
|
for (let i = 0; i < state.nodes.length - 1; ++i) {
|
||||||
|
const prev = state.nodes[i];
|
||||||
|
const next = state.nodes[i + 1];
|
||||||
|
edges.push({
|
||||||
|
id: prev.id + '-' + next.id,
|
||||||
|
sources: [prev.id],
|
||||||
|
targets: [next.id]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.edges = edges;
|
||||||
|
recomputeLayout().then(renderSvg);
|
||||||
|
}
|
||||||
|
|
||||||
|
const context = inject('context') as any;
|
||||||
|
context.reset = resetConnections;
|
||||||
|
context.state = state;
|
||||||
|
context.render = renderSvg;
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
nextTick(drawDiagram);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 计算窗口位置
|
||||||
|
function getNodePopupStyle(node: any): any {
|
||||||
|
// 节点的 svg 坐标转为容器内绝对定位
|
||||||
|
// 注意:这里假设 offsetX、node.x、node.y 已经是最新的
|
||||||
|
console.log(node);
|
||||||
|
|
||||||
|
const left = (node.x || 0) + (node.width || 160) - 120; // 节点右侧
|
||||||
|
const top = (node.y || 0) + 30; // 节点顶部对齐
|
||||||
|
return {
|
||||||
|
position: 'absolute',
|
||||||
|
left: `${left}px`,
|
||||||
|
top: `${top}px`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.diagram-container {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 200px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 24px 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-popup {
|
||||||
|
position: absolute;
|
||||||
|
background: var(--background);
|
||||||
|
border: 1px solid var(--main-color);
|
||||||
|
width: 240px;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
|
||||||
|
white-space: nowrap;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 旋转动画 */
|
||||||
|
.status-running-circle {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
transform-origin: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
Loading…
x
Reference in New Issue
Block a user