add news page
This commit is contained in:
commit
6e8b4aa23c
3
.github/workflows/build.yaml
vendored
3
.github/workflows/build.yaml
vendored
@ -4,9 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- hotfix
|
||||
- hotfix/*
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
3
.github/workflows/test.yaml
vendored
3
.github/workflows/test.yaml
vendored
@ -4,9 +4,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- hotfix
|
||||
- hotfix/*
|
||||
release:
|
||||
types:
|
||||
- published
|
||||
|
@ -1,8 +1,8 @@
|
||||
@font-face {
|
||||
font-family: "iconfont"; /* Project id 4870215 */
|
||||
src: url('iconfont.woff2?t=1750532923458') format('woff2'),
|
||||
url('iconfont.woff?t=1750532923458') format('woff'),
|
||||
url('iconfont.ttf?t=1750532923458') format('truetype');
|
||||
src: url('iconfont.woff2?t=1751568095152') format('woff2'),
|
||||
url('iconfont.woff?t=1751568095152') format('woff'),
|
||||
url('iconfont.ttf?t=1751568095152') format('truetype');
|
||||
}
|
||||
|
||||
.iconfont {
|
||||
@ -13,6 +13,14 @@
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.icon-pin:before {
|
||||
content: "\e863";
|
||||
}
|
||||
|
||||
.icon-serial:before {
|
||||
content: "\e78f";
|
||||
}
|
||||
|
||||
.icon-deploy:before {
|
||||
content: "\e614";
|
||||
}
|
||||
|
Binary file not shown.
@ -1,21 +1,24 @@
|
||||
<template>
|
||||
<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">
|
||||
<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 v-if="props.dataView.function !== undefined" class="item-result">
|
||||
<div class="item-label">Function</div>
|
||||
<div class="item-json">{{ props.dataView.function.name }}</div>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
<div v-if="props.dataView.function !== undefined" class="item-result">
|
||||
<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>
|
||||
|
||||
<br>
|
||||
|
||||
<div v-if="props.dataView.result !== undefined" class="item-result">
|
||||
<span class="item-label">Result</span>
|
||||
<template v-if="Array.isArray(props.dataView.result)">
|
||||
@ -85,10 +88,9 @@ function formatJson(obj: any) {
|
||||
}
|
||||
|
||||
.item-status {
|
||||
font-size: 13px;
|
||||
padding: 2px 10px;
|
||||
font-size: 15px;
|
||||
padding: 5px 0;
|
||||
border-radius: 12px;
|
||||
margin-left: 8px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
@ -127,7 +129,7 @@ function formatJson(obj: any) {
|
||||
.item-json {
|
||||
border-radius: 4px;
|
||||
padding: 6px 10px;
|
||||
font-size: 13px;
|
||||
font-size: 15px;
|
||||
font-family: var(--code-font-family, monospace);
|
||||
margin: 2px 0 8px 0;
|
||||
white-space: pre-wrap;
|
||||
@ -137,6 +139,13 @@ function formatJson(obj: any) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.code-container {
|
||||
margin-top: 10px;
|
||||
border-radius: .3em;
|
||||
padding: 0 10px;
|
||||
background-color: var(--sidebar);
|
||||
}
|
||||
|
||||
.item-result {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
@ -45,7 +45,7 @@ export interface NodeDataView {
|
||||
}
|
||||
|
||||
export interface DiagramContext {
|
||||
reset: () => void,
|
||||
preset: (type: string) => void,
|
||||
render: () => void,
|
||||
state?: DiagramState,
|
||||
setCaption: (value: string) => void
|
||||
|
@ -1,27 +1,38 @@
|
||||
<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>
|
||||
|
||||
<template v-for="(node, index) in state.nodes" :key="node.id + '-popup'">
|
||||
<transition name="collapse-from-top" mode="out-in">
|
||||
<div
|
||||
v-show="state.hoverNodeId === node.id && state.dataView.get(node.id)?.status !== 'waiting'"
|
||||
@mouseenter="setHoverItem(node.id)"
|
||||
@mouseleave="clearHoverItem()"
|
||||
:style="getNodePopupStyle(node)"
|
||||
class="node-popup"
|
||||
>
|
||||
<el-scrollbar height="100%" width="100%">
|
||||
<DiagramItemRecord :data-view="state.dataView.get(node.id)"/>
|
||||
</el-scrollbar>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
<div class="diagram-info-panel">
|
||||
<div style="display: flex; justify-content: flex-end; align-items: center;">
|
||||
<el-button
|
||||
circle
|
||||
size="small"
|
||||
:type="state.pinnedNodeId ? 'primary' : 'default'"
|
||||
@click="togglePin"
|
||||
style="margin-bottom: 4px;"
|
||||
:disabled="!infoNodeId"
|
||||
>
|
||||
<span class="iconfont icon-pin"></span>
|
||||
</el-button>
|
||||
</div>
|
||||
<template v-if="infoNodeId && state.dataView.get(infoNodeId)">
|
||||
<el-scrollbar height="500px" width="300px">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<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 ELK from 'elkjs/lib/elk.bundled.js';
|
||||
import { mcpClientAdapter } from '@/views/connect/core';
|
||||
@ -53,6 +64,7 @@ const state = reactive({
|
||||
selectedNodeId: null as string | null,
|
||||
draggingNodeId: null as string | null,
|
||||
hoverNodeId: null as string | null,
|
||||
pinnedNodeId: null as string | null, // 新增
|
||||
offset: { x: 0, y: 0 },
|
||||
dataView: new Map<string, NodeDataView>
|
||||
});
|
||||
@ -66,6 +78,7 @@ if (autoDetectDiagram) {
|
||||
autoDetectDiagram.views?.forEach(item => {
|
||||
state.dataView.set(item.tool.name, {
|
||||
tool: item.tool,
|
||||
function: item.function,
|
||||
status: item.status || 'waiting',
|
||||
result: item.result || null
|
||||
});
|
||||
@ -84,23 +97,38 @@ console.log(state.dataView);
|
||||
let cancelHoverHandler: NodeJS.Timeout | undefined = undefined;
|
||||
|
||||
const setHoverItem = (id: string) => {
|
||||
if (cancelHoverHandler) {
|
||||
clearTimeout(cancelHoverHandler);
|
||||
}
|
||||
state.hoverNodeId = id;
|
||||
}
|
||||
if (state.pinnedNodeId) return; // 如果已pin,不响应hover
|
||||
if (cancelHoverHandler) {
|
||||
clearTimeout(cancelHoverHandler);
|
||||
}
|
||||
state.hoverNodeId = id;
|
||||
};
|
||||
|
||||
const clearHoverItem = () => {
|
||||
cancelHoverHandler = setTimeout(() => {
|
||||
if (cancelHoverHandler) {
|
||||
clearTimeout(cancelHoverHandler);
|
||||
}
|
||||
if (state.hoverNodeId) {
|
||||
state.hoverNodeId = null;
|
||||
}
|
||||
}, 300);
|
||||
if (state.pinnedNodeId) return; // 如果已pin,不响应hover
|
||||
cancelHoverHandler = setTimeout(() => {
|
||||
if (cancelHoverHandler) {
|
||||
clearTimeout(cancelHoverHandler);
|
||||
}
|
||||
if (state.hoverNodeId) {
|
||||
state.hoverNodeId = null;
|
||||
}
|
||||
}, 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 items = [];
|
||||
for (const client of mcpClientAdapter.clients) {
|
||||
@ -154,7 +182,7 @@ const drawDiagram = async () => {
|
||||
|
||||
// 如果保存了 edges 信息,则需要进行同步
|
||||
const reservedEdges = autoDetectDiagram?.edges;
|
||||
if (reservedEdges) {
|
||||
if (reservedEdges && reservedEdges.length > 0) {
|
||||
for (const edge of reservedEdges) {
|
||||
if (edge.sources && edge.targets && edge.sources.length > 0 && edge.targets.length > 0) {
|
||||
edges.push({
|
||||
@ -379,7 +407,7 @@ function renderSvg() {
|
||||
state.draggingNodeId = null;
|
||||
})
|
||||
.on('mouseover', function (event, d) {
|
||||
setHoverItem(d.id);
|
||||
setHoverItem(d.id);
|
||||
d3.select(this).select('rect')
|
||||
.transition()
|
||||
.duration(200)
|
||||
@ -387,7 +415,7 @@ function renderSvg() {
|
||||
.attr('stroke-width', 2);
|
||||
})
|
||||
.on('mouseout', function (event, d) {
|
||||
clearHoverItem();
|
||||
// clearHoverItem();
|
||||
if (state.selectedNodeId === d.id) return;
|
||||
d3.select(this).select('rect')
|
||||
.transition()
|
||||
@ -413,7 +441,7 @@ function renderSvg() {
|
||||
.attr('fill', 'var(--main-color)')
|
||||
.attr('font-weight', 600)
|
||||
.text(d => d.labels?.[0]?.text || 'Tool');
|
||||
|
||||
|
||||
nodeGroupEnter.append('g').attr('class', 'node-status');
|
||||
|
||||
// 合并 enter+update
|
||||
@ -557,7 +585,7 @@ function renderSvg() {
|
||||
}
|
||||
|
||||
// 重置连接为链表结构
|
||||
function resetConnections() {
|
||||
function serialConnection() {
|
||||
if (!state.nodes.length) return;
|
||||
const edges = [];
|
||||
for (let i = 0; i < state.nodes.length - 1; ++i) {
|
||||
@ -573,8 +601,21 @@ function resetConnections() {
|
||||
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;
|
||||
context.reset = resetConnections;
|
||||
context.preset = (type: string) => {
|
||||
if (type === 'serial') {
|
||||
serialConnection();
|
||||
} else if (type === 'parallel') {
|
||||
parallelConnection();
|
||||
}
|
||||
};
|
||||
context.state = state;
|
||||
context.render = renderSvg;
|
||||
|
||||
@ -590,7 +631,7 @@ function getNodePopupStyle(node: any): any {
|
||||
const marginY = 80;
|
||||
const popupWidth = 300;
|
||||
const popupHeight = 500;
|
||||
|
||||
|
||||
let left = (node.x || 0) + (node.width || 160) + 100;
|
||||
let top = (node.y || 0) + 30;
|
||||
|
||||
@ -619,13 +660,12 @@ function getNodePopupStyle(node: any): any {
|
||||
|
||||
<style>
|
||||
.diagram-container {
|
||||
width: 100%;
|
||||
width: 600px;
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
border-radius: 8px;
|
||||
padding: 24px 0;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
@ -651,4 +691,41 @@ function getNodePopupStyle(node: any): any {
|
||||
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>
|
@ -4,15 +4,30 @@
|
||||
<div style="display: flex; align-items: center;">
|
||||
<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">
|
||||
<template #reference>
|
||||
<el-button size="small" type="primary">
|
||||
{{ t('start-auto-detect') }}
|
||||
</el-button>
|
||||
</template>
|
||||
|
||||
<!-- ...原有自检表单内容... -->
|
||||
<el-input type="textarea" v-model="testPrompt" :rows="2" style="margin-bottom: 8px;"
|
||||
placeholder="请输入 prompt" />
|
||||
<div style="display: flex; align-items: center; margin-bottom: 8px;">
|
||||
@ -34,11 +49,21 @@
|
||||
<el-scrollbar height="80vh">
|
||||
<Diagram :tab-id="props.tabId" />
|
||||
</el-scrollbar>
|
||||
<transition name="main-fade" mode="out-in">
|
||||
<div class="caption" v-show="showCaption">
|
||||
{{ caption }}
|
||||
</div>
|
||||
</transition>
|
||||
|
||||
<div class="caption" v-if="showCaption">
|
||||
{{ caption }}
|
||||
</div>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
@ -88,7 +113,7 @@ function setCaption(text: string) {
|
||||
}
|
||||
|
||||
const context: DiagramContext = {
|
||||
reset: () => { },
|
||||
preset: () => { },
|
||||
render: () => { },
|
||||
state: undefined,
|
||||
setCaption
|
||||
@ -144,16 +169,24 @@ async function onTestConfirm() {
|
||||
|
||||
|
||||
}
|
||||
|
||||
const resetPopoverVisible = ref(false);
|
||||
|
||||
function tomoPreset(type: string) {
|
||||
resetPopoverVisible.value = false;
|
||||
context.preset?.(type);
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.no-padding-dialog {
|
||||
margin-top: 30px !important;
|
||||
width: 90vw !important;
|
||||
}
|
||||
|
||||
.no-padding-dialog .caption {
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
right: 30px;
|
||||
bottom: 10px;
|
||||
margin: 0 auto;
|
||||
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