add news page

This commit is contained in:
锦恢 2025-07-05 05:26:07 +08:00
commit 6e8b4aa23c
9 changed files with 741 additions and 73 deletions

View File

@ -4,9 +4,6 @@ on:
push: push:
branches: branches:
- main - main
- dev
- hotfix
- hotfix/*
release: release:
types: types:
- published - published

View File

@ -4,9 +4,6 @@ on:
push: push:
branches: branches:
- main - main
- dev
- hotfix
- hotfix/*
release: release:
types: types:
- published - published

View File

@ -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.

View File

@ -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;
} }

View File

@ -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

View File

@ -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; // pinhover
clearTimeout(cancelHoverHandler); if (cancelHoverHandler) {
} clearTimeout(cancelHoverHandler);
state.hoverNodeId = id; }
} state.hoverNodeId = id;
};
const clearHoverItem = () => { const clearHoverItem = () => {
cancelHoverHandler = setTimeout(() => { if (state.pinnedNodeId) return; // pinhover
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()
@ -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;
@ -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>

View File

@ -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>
&ensp; &ensp;
<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;

View 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]));
// xx
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
// offsetXnode.xnode.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>