新建流程图
<template>
<div class="content-main">
<div class="tool-container">
<div @click="undo" class="command" title="后退">
<Icon icon="ant-design:undo-outlined" />
</div>
<div @click="redo" class="command" title="前进">
<Icon icon="ant-design:redo-outlined" />
</div>
<el-divider direction="vertical" />
<div @click="copy" class="command" title="复制">
<Icon icon="ant-design:copy-filled" />
</div>
<div @click="paste" class="command" title="粘贴">
<Icon icon="fa-solid:paste" />
</div>
<div @click="del" class="command" title="删除">
<Icon icon="ant-design:delete-filled" />
</div>
<el-divider direction="vertical" />
<div @click="save" class="command" title="保存">
<Icon icon="ant-design:save-filled" />
</div>
<el-divider direction="vertical" />
<div @click="exportPng" class="command" title="导出PNG">
<Icon icon="ant-design:file-image-filled" />
</div>
</div>
<div class="content-container" id="">
<div class="content">
<div class="stencil" ref="stencilContainer"></div>
<div class="graph-content" id="graphContainer" ref="graphContainer"> </div>
<div class="editor-sidebar">
<div class="edit-panel">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>{{ cellFrom.title }}</span>
</div>
</template>
<el-form :model="nodeFrom" label-width="50px" v-if="nodeFrom.show">
<el-form-item label="label">
<el-input v-model="nodeFrom.label" @blur="changeLabel" />
</el-form-item>
<el-form-item label="desc">
<el-input type="textarea" v-model="nodeFrom.desc" @blur="changeDesc" />
</el-form-item>
</el-form>
<el-form :model="cellFrom" label-width="50px" v-if="cellFrom.show">
<el-form-item label="label">
<el-input v-model="cellFrom.label" @blur="changeEdgeLabel" />
</el-form-item>
<!-- <el-form-item label="连线方式">
<el-select v-model="cellFrom.edgeType" class="m-2" placeholder="Select" @change="changeEdgeType">
<el-option
v-for="item in EDGE_TYPE_LIST"
:key="item.type"
:label="item.name"
:value="item.type"
/>
</el-select>
</el-form-item> -->
</el-form>
</el-card>
</div>
<div>
<el-card shadow="never">
<template #header>
<div class="card-header">
<span>Minimap</span>
</div>
</template>
<div class="minimap" ref="miniMapContainer"></div>
</el-card>
</div>
</div>
</div>
</div>
<div v-if="showMenu" class="node-menu" ref="nodeMenu">
<div
class="menu-item"
v-for="(item, index) in PROCESSING_TYPE_LIST"
:key="index"
@click="addNodeTool(item)"
>
<el-image :src="item.image" style="width: 16px; height: 16px" fit="fill" />
<span>{{ item.name }}</span>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Graph, Path, Edge, StringExt, Node, Cell, Model, DataUri } from '@antv/x6'
import { Transform } from '@antv/x6-plugin-transform'
import { Selection } from '@antv/x6-plugin-selection'
import { Snapline } from '@antv/x6-plugin-snapline'
import { Keyboard } from '@antv/x6-plugin-keyboard'
import { Clipboard } from '@antv/x6-plugin-clipboard'
import { History } from '@antv/x6-plugin-history'
import { MiniMap } from '@antv/x6-plugin-minimap'
import { Stencil } from '@antv/x6-plugin-stencil'
import { Export } from '@antv/x6-plugin-export'
import { ref, onMounted, reactive, toRefs, nextTick, onUnmounted } from 'vue'
import '@/styles/animation.less'
import { ElMessage, ElCard, ElForm, ElFormItem, ElInput, ElImage, ElDivider } from 'element-plus'
const stencilContainer = ref()
const graphContainer = ref()
const miniMapContainer = ref()
let graph: any = null
const state = reactive({
cellFrom: {
title: 'Canvas',
label: '',
desc: '',
show: false,
id: '',
edgeType: 'topBottom'
},
nodeFrom: {
title: 'Canvas',
label: '',
desc: '',
show: false,
id: ''
},
showMenu: false,
data: {
nodes: [
{
id: 'ac51fb2f-2753-4852-8239-53672a29bb14',
position: {
x: -340,
y: -160
},
data: {
name: '诗名',
type: 'OUTPUT',
desc: '春望'
}
},
{
id: '81004c2f-0413-4cc6-8622-127004b3befa',
position: {
x: -340,
y: -10
},
data: {
name: '第一句',
type: 'SYNC',
desc: '国破山河在'
}
},
{
id: '7505da25-1308-4d7a-98fd-e6d5c917d35d',
position: {
x: -140,
y: 180
},
data: {
name: '结束',
type: 'INPUT',
desc: '城春草木胜'
}
}
],
edges: [
{
id: '6eea5dc9-4e15-4e78-959f-ee13ec59d11c',
shape: 'processing-curve',
source: { cell: 'ac51fb2f-2753-4852-8239-53672a29bb14', port: '-out' },
target: { cell: '81004c2f-0413-4cc6-8622-127004b3befa', port: '-in' },
zIndex: -1,
data: {
source: 'ac51fb2f-2753-4852-8239-53672a29bb14',
target: '81004c2f-0413-4cc6-8622-127004b3befa'
}
},
{
id: '8cbce713-54be-4c07-8efa-59c505f74ad7',
labels: ['下半句'],
shape: 'processing-curve',
source: { cell: '81004c2f-0413-4cc6-8622-127004b3befa', port: '-out' },
target: { cell: '7505da25-1308-4d7a-98fd-e6d5c917d35d', port: '-in' },
data: {
source: '81004c2f-0413-4cc6-8622-127004b3befa',
target: '7505da25-1308-4d7a-98fd-e6d5c917d35d'
}
}
]
},
nodeStatusList: [
{
id: 'ac51fb2f-2753-4852-8239-53672a29bb14',
status: 'success'
},
{
id: '81004c2f-0413-4cc6-8622-127004b3befa',
status: 'success'
}
],
edgeStatusList: [
{
id: '6eea5dc9-4e15-4e78-959f-ee13ec59d11c',
status: 'success'
},
{
id: '8cbce713-54be-4c07-8efa-59c505f74ad7',
status: 'executing'
}
],
PROCESSING_TYPE_LIST: [
{
type: 'SYNC',
name: '数据同步',
image: new URL('@/assets/imgs/persimmon.png', import.meta.url).href
},
{
type: 'INPUT',
name: '结束',
image: new URL('@/assets/imgs/lime.png', import.meta.url).href
}
],
EDGE_TYPE_LIST: [
{
type: 'topBottom',
name: '上下'
},
{
type: 'leftRight',
name: '左右'
}
]
})
const { cellFrom, nodeFrom, showMenu, PROCESSING_TYPE_LIST } = toRefs(state)
let nodeMenu = ref()
enum NodeType {
INPUT = 'INPUT',
FILTER = 'FILTER',
JOIN = 'JOIN',
UNION = 'UNION',
AGG = 'AGG',
OUTPUT = 'OUTPUT',
SYNC = 'SYNC'
}
interface Position {
x: number
y: number
}
function init() {
graph = new Graph({
container: graphContainer.value,
grid: true,
panning: {
enabled: true,
eventTypes: ['leftMouseDown', 'mouseWheel']
},
mousewheel: {
enabled: true,
modifiers: 'ctrl',
factor: 1.1,
maxScale: 1.5,
minScale: 0.5
},
highlighting: {
magnetAdsorbed: {
name: 'stroke',
args: {
attrs: {
fill: '#fff',
stroke: '#31d0c6',
strokeWidth: 4
}
}
}
},
connecting: {
snap: true,
allowBlank: false,
allowLoop: false,
highlight: true,
createEdge() {
return graph.createEdge({
shape: 'processing-curve',
attrs: {
line: {
strokeDasharray: '5 5'
}
},
zIndex: -1
})
},
validateConnection({ sourceMagnet, targetMagnet }) {
if (!sourceMagnet || sourceMagnet.getAttribute('port-group') === 'in') {
return false
}
if (!targetMagnet || targetMagnet.getAttribute('port-group') === 'out') {
return false
}
return true
}
}
})
graph.centerContent()
graph
.use(
new Transform({
resizing: true,
rotating: true
})
)
.use(
new Selection({
rubberband: true,
showNodeSelectionBox: true
})
)
.use(
new MiniMap({
container: miniMapContainer.value,
width: 200,
height: 260,
padding: 10
})
)
.use(new Snapline())
.use(new Keyboard())
.use(new Clipboard())
.use(new History())
.use(new Export())
const ports = {
groups: {
in: {
position: 'top',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden'
}
}
}
},
out: {
position: 'bottom',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#31d0c6',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden'
}
}
}
},
left: {
position: 'left',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden'
}
}
}
},
right: {
position: 'right',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden'
}
}
}
}
}
}
Graph.registerNode(
'custom-node',
{
inherit: 'rect',
width: 140,
height: 76,
attrs: {
body: {
strokeWidth: 1
},
image: {
width: 16,
height: 16,
x: 12,
y: 6
},
text: {
refX: 40,
refY: 15,
fontSize: 15,
'text-anchor': 'start'
},
label: {
text: 'Please nominate this node',
refX: 10,
refY: 30,
fontSize: 12,
fill: 'rgba(0,0,0,0.6)',
'text-anchor': 'start',
textWrap: {
width: -10,
height: '70%',
ellipsis: true,
breakWord: true
}
}
},
markup: [
{
tagName: 'rect',
selector: 'body'
},
{
tagName: 'image',
selector: 'image'
},
{
tagName: 'text',
selector: 'text'
},
{
tagName: 'text',
selector: 'label'
}
],
data: {},
relation: {},
ports: { ...ports }
},
true
)
const stencil = new Stencil({
title: '数据集成',
target: graph,
search: false,
collapsable: true,
stencilGraphWidth: 300,
stencilGraphHeight: 600,
groups: [
{
name: 'processLibrary',
title: 'dataSource'
}
],
layoutOptions: {
dx: 30,
dy: 20,
columns: 1,
columnWidth: 130,
rowHeight: 100
}
})
stencilContainer.value.appendChild(stencil.container)
const showPorts = (ports: NodeListOf<SVGElement>, show: boolean) => {
for (let i = 0, len = ports.length; i < len; i += 1) {
ports[i].style.visibility = show ? 'visible' : 'hidden'
}
}
graph.on('node:mouseenter', () => {
const container = graphContainer.value
const ports = container.querySelectorAll('.x6-port-body')
showPorts(ports, true)
})
graph.on('node:mouseleave', () => {
const container = graphContainer.value
const ports = container.querySelectorAll(
'.x6-port-body'
) as NodeListOf<SVGElement>
showPorts(ports, false)
})
graph.bindKey(['meta+c', 'ctrl+c'], () => {
copy()
})
graph.bindKey(['meta+x', 'ctrl+x'], () => {
const cells = graph.getSelectedCells()
if (cells.length) {
graph.cut(cells)
}
return false
})
graph.bindKey(['meta+v', 'ctrl+v'], () => {
paste()
})
graph.bindKey(['meta+z', 'ctrl+z'], () => {
undo()
})
graph.bindKey(['meta+y', 'ctrl+y'], () => {
redo()
})
graph.bindKey(['meta+a', 'ctrl+a'], () => {
const nodes = graph.getNodes()
if (nodes) {
graph.select(nodes)
}
})
graph.bindKey('backspace', () => {
del()
})
graph.bindKey(['ctrl+1', 'meta+1'], () => {
const zoom = graph.zoom()
if (zoom < 1.5) {
graph.zoom(0.1)
}
})
graph.bindKey(['ctrl+2', 'meta+2'], () => {
const zoom = graph.zoom()
if (zoom > 0.5) {
graph.zoom(-0.1)
}
})
graph.on('node:added', ({ node }: any) => {
addNodeInfo(node)
})
graph.on('node:click', ({ node }: any) => {
addNodeInfo(node)
})
graph.on('node:selected', (args: { cell: Cell; node: Node; options: Model.SetOptions }) => {
if (NodeType.INPUT != args.node.data.type) {
args.node.removeTools()
args.node.addTools({
name: 'button',
args: {
x: 0,
y: 0,
offset: { x: 160, y: 40 },
markup: [
{
tagName: 'circle',
selector: 'button',
attrs: {
r: 8,
stroke: 'rgba(0,0,0,.25)',
strokeWidth: 1,
fill: 'rgba(255, 255, 255, 1)',
cursor: 'pointer'
}
},
{
tagName: 'text',
textContent: '+',
selector: 'icon',
attrs: {
fill: 'rgba(0,0,0,.25)',
fontSize: 15,
textAnchor: 'middle',
pointerEvents: 'none',
y: '0.3em',
stroke: 'rgba(0,0,0,.25)'
}
}
],
onClick({ e, view }: any) {
showNodeTool(e, view)
}
}
})
}
})
graph.on('node:unselected', (args: { cell: Cell; node: Node; options: Model.SetOptions }) => {
args.node.removeTools()
})
graph.on('edge:added', ({ edge }: any) => {
addEdgeInfo(edge)
edge.data = {
source: edge.source.cell,
target: edge.target.cell
}
})
graph.on('edge:click', ({ edge }: any) => {
addEdgeInfo(edge)
})
graph.on('edge:selected', (args: { cell: Cell; edge: Edge; options: Model.SetOptions }) => {
args.edge.attr('line/strokeWidth', 3)
})
graph.on('edge:unselected', (args: { cell: Cell; edge: Edge; options: Model.SetOptions }) => {
args.edge.attr('line/strokeWidth', 1)
})
const nodeShapes = [
{
label: '开始',
nodeType: 'OUTPUT' as NodeType
},
{
label: '数据同步',
nodeType: 'SYNC' as NodeType
},
{
label: '结束',
nodeType: 'INPUT' as NodeType
}
]
const nodes = nodeShapes.map((item) => {
const id = StringExt.uuid()
const node = {
id: id,
shape: 'custom-node',
ports: getPortsByType(item.nodeType, id),
data: {
name: `${item.label}`,
type: item.nodeType
},
attrs: getNodeAttrs(item.nodeType)
}
const newNode = graph.addNode(node)
return newNode
})
stencil.load(nodes, 'processLibrary')
}
const getPortsByType = (type: NodeType, nodeId: string) => {
let ports = [] as any
switch (type) {
case NodeType.INPUT:
ports = [
{
id: `${nodeId}-in`,
group: 'in'
},
{
id: `${nodeId}-left`,
group: 'left'
},
{
id: `${nodeId}-right`,
group: 'right'
}
]
break
case NodeType.OUTPUT:
ports = [
{
id: `${nodeId}-out`,
group: 'out'
},
{
id: `${nodeId}-left`,
group: 'left'
},
{
id: `${nodeId}-right`,
group: 'right'
}
]
break
default:
ports = [
{
id: `${nodeId}-in`,
group: 'in'
},
{
id: `${nodeId}-out`,
group: 'out'
},
{
id: `${nodeId}-left`,
group: 'left'
},
{
id: `${nodeId}-right`,
group: 'right'
}
]
break
}
return ports
}
Graph.registerConnector(
'curveConnectorTB',
(s, e) => {
const offset = 4
const deltaY = Math.abs(e.y - s.y)
const control = Math.floor((deltaY / 3) * 2)
const v1 = { x: s.x, y: s.y + offset + control }
const v2 = { x: e.x, y: e.y - offset - control }
return Path.normalize(
`M ${s.x} ${s.y}
L ${s.x} ${s.y + offset}
C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${e.x} ${e.y - offset}
L ${e.x} ${e.y}
`
)
},
true
)
Graph.registerConnector(
'curveConnectorLR',
(sourcePoint, targetPoint) => {
const hgap = Math.abs(targetPoint.x - sourcePoint.x)
const path = new Path()
path.appendSegment(Path.createSegment('M', sourcePoint.x - 4, sourcePoint.y))
path.appendSegment(Path.createSegment('L', sourcePoint.x + 12, sourcePoint.y))
path.appendSegment(
Path.createSegment(
'C',
sourcePoint.x < targetPoint.x ? sourcePoint.x + hgap / 2 : sourcePoint.x - hgap / 2,
sourcePoint.y,
sourcePoint.x < targetPoint.x ? targetPoint.x - hgap / 2 : targetPoint.x + hgap / 2,
targetPoint.y,
targetPoint.x - 6,
targetPoint.y
)
)
path.appendSegment(Path.createSegment('L', targetPoint.x + 2, targetPoint.y))
return path.serialize()
},
true
)
Graph.registerEdge(
'processing-curve',
{
inherit: 'edge',
markup: [
{
tagName: 'path',
selector: 'wrap',
attrs: {
fill: 'none',
cursor: 'pointer',
stroke: 'transparent',
strokeLinecap: 'round'
}
},
{
tagName: 'path',
selector: 'line',
attrs: {
fill: 'none',
pointerEvents: 'none'
}
}
],
connector: { name: 'smooth' },
attrs: {
wrap: {
connection: true,
strokeWidth: 10,
strokeLinejoin: 'round'
},
line: {
connection: true,
stroke: '#A2B1C3',
strokeWidth: 1,
targetMarker: {
name: 'classic',
size: 6
}
}
}
},
true
)
function save() {
console.log('save')
const graphData = graph.toJSON()
console.log(graphData)
}
function undo() {
if (graph.canUndo()) {
graph.undo()
}
return false
}
function redo() {
if (graph.canRedo()) {
graph.redo()
}
return false
}
function copy() {
const cells = graph.getSelectedCells()
if (cells.length) {
graph.copy(cells)
}
return false
}
function paste() {
if (!graph.isClipboardEmpty()) {
const cells = graph.paste({ offset: 32 })
graph.cleanSelection()
graph.select(cells)
}
return false
}
function del() {
const cells = graph.getSelectedCells()
if (cells.length) {
graph.removeCells(cells)
}
}
function exportPng() {
graph.toPNG(
(dataUri: string) => {
DataUri.downloadDataUri(dataUri, 'chart.png')
},
{
padding: {
top: 20,
right: 20,
bottom: 20,
left: 20
}
}
)
}
function addNodeInfo(node: any) {
state.nodeFrom.title = 'Node'
state.nodeFrom.label = node.label
state.nodeFrom.desc = node.attrs.label.text
state.nodeFrom.show = true
state.nodeFrom.id = node.id
state.cellFrom.show = false
}
function addEdgeInfo(edge: any) {
state.nodeFrom.show = false
state.cellFrom.title = 'Edge'
if (edge.labels[0]) {
state.cellFrom.label = edge.labels[0].attrs.label.text
} else {
state.cellFrom.label = ''
}
state.cellFrom.edgeType = edge.data ? edge.data.edgeType : ''
state.cellFrom.show = true
state.cellFrom.id = edge.id
}
function changeLabel() {
const nodes = graph.getNodes()
nodes.forEach((node: any) => {
if (state.nodeFrom.id == node.id) {
node.label = state.nodeFrom.label
}
})
}
function changeDesc() {
const nodes = graph.getNodes()
nodes.forEach((node: any) => {
if (state.nodeFrom.id == node.id) {
node.attr('label/text', state.nodeFrom.desc)
}
})
}
function changeEdgeLabel() {
const edges = graph.getEdges()
edges.forEach((edge: any) => {
if (state.cellFrom.id == edge.id) {
edge.setLabels(state.cellFrom.label)
console.log(edge)
}
})
}
const getNodeAttrs = (nodeType: string) => {
let attr = {} as any
switch (nodeType) {
case NodeType.INPUT:
attr = {
image: {
'xlink:href': new URL('@/assets/imgs/lime.png', import.meta.url).href
},
body: {
fill: '#b9dec9',
stroke: '#229453'
},
text: {
text: '结束',
fill: '#229453'
}
}
break
case NodeType.SYNC:
attr = {
image: {
'xlink:href': new URL('@/assets/imgs/persimmon.png', import.meta.url).href
},
body: {
fill: '#edc3ae',
stroke: '#f9723d'
},
text: {
text: '数据同步',
fill: '#f9723d'
}
}
break
case NodeType.OUTPUT:
attr = {
image: {
'xlink:href': new URL('@/assets/imgs/rice.png', import.meta.url).href
},
body: {
fill: '#EFF4FF',
stroke: '#5F95FF'
},
text: {
text: '开始',
fill: '#5F95FF'
}
}
break
}
return attr
}
function getData() {
let cells = [] as any
const location = state.data
location.nodes.map((node) => {
let attr = getNodeAttrs(node.data.type)
if (node.data.desc) {
attr.label = { text: node.data.desc }
}
if (node.data.name) {
let temp = attr.text
if (temp) {
temp.text = node.data.name
}
}
cells.push(
graph.addNode({
id: node.id,
x: node.position.x,
y: node.position.y,
shape: 'custom-node',
attrs: attr,
ports: getPortsByType(node.data.type as NodeType, node.id),
data: node.data
})
)
})
location.edges.map((edge) => {
cells.push(
graph.addEdge({
id: edge.id,
source: edge.source,
target: edge.target,
zIndex: edge.zIndex,
shape: 'processing-curve',
labels: edge.labels,
attrs: { line: { strokeDasharray: '5 5' } },
data: edge.data
})
)
})
graph.resetCells(cells)
}
const excuteAnimate = (edge: any) => {
edge.attr({
line: {
stroke: '#3471F9'
}
})
edge.attr('line/strokeDasharray', 5)
edge.attr('line/style/animation', 'running-line 30s infinite linear')
}
const showEdgeStatus = () => {
state.edgeStatusList.forEach((item) => {
const edge = graph.getCellById(item.id)
if (item.status == 'success') {
edge.attr('line/strokeDasharray', 0)
edge.attr('line/stroke', '#52c41a')
} else if ('error' == item.status) {
edge.attr('line/stroke', '#ff4d4f')
} else if ('executing' == item.status) {
excuteAnimate(edge)
}
})
}
function showNodeTool(e: any, _view: any) {
state.showMenu = true
nextTick(() => {
nodeMenu.value.style.top = e.offsetY + 60 + 'px'
nodeMenu.value.style.left = e.offsetX + 210 + 'px'
})
}
function addNodeTool(item: any) {
createDownstream(item.type)
state.showMenu = false
}
const getDownstreamNodePosition = (node: Node, graph: Graph, dx = 250, dy = 100) => {
const downstreamNodeIdList: string[] = []
graph.getEdges().forEach((edge) => {
const originEdge = edge.toJSON()?.data
console.log(node)
if (originEdge.source === node.id) {
downstreamNodeIdList.push(originEdge.target)
}
})
const position = node.getPosition()
let minX = Infinity
let maxY = -Infinity
graph.getNodes().forEach((graphNode) => {
if (downstreamNodeIdList.indexOf(graphNode.id) > -1) {
const nodePosition = graphNode.getPosition()
if (nodePosition.x < minX) {
minX = nodePosition.x
}
if (nodePosition.y > maxY) {
maxY = nodePosition.y
}
}
})
return {
x: minX !== Infinity ? minX : position.x + dx,
y: maxY !== -Infinity ? maxY + dy : position.y
}
}
const createDownstream = (type: NodeType) => {
const cells = graph.getSelectedCells()
if (cells.length == 1) {
const node = cells[0]
if (graph) {
const position = getDownstreamNodePosition(node, graph)
const newNode = createNode(type, graph, position)
const source = node.id
const target = newNode.id
createEdge(source, target, graph)
}
} else {
ElMessage({
message: '请选择一个节点',
type: 'warning'
})
}
}
const createNode = (type: NodeType, graph: Graph, position?: Position): Node => {
let newNode = {} as Node
const typeName = state.PROCESSING_TYPE_LIST?.find((item) => item.type === type)?.name
const id = StringExt.uuid()
const node = {
id,
shape: 'custom-node',
x: position?.x,
y: position?.y,
ports: getPortsByType(type, id),
data: {
name: `${typeName}`,
type
},
attrs: getNodeAttrs(type)
}
newNode = graph.addNode(node)
return newNode
}
const createEdge = (source: string, target: string, graph: Graph) => {
const edge = {
id: StringExt.uuid(),
shape: 'processing-curve',
source: {
cell: source
},
target: {
cell: target
},
zIndex: -1,
data: {
source,
target
},
attrs: { line: { strokeDasharray: '5 5' } }
}
if (graph) {
graph.addEdge(edge)
}
}
onMounted(() => {
init()
getData()
showEdgeStatus()
})
onUnmounted(() => {
graph.dispose()
})
</script>
<style lang="less" scoped>
.content-main {
display: flex;
width: 100%;
flex-direction: column;
height: calc(100vh - 85px - 40px);
background-color: #ffffff;
position: relative;
.tool-container {
padding: 8px;
display: flex;
align-items: center;
color: rgba(0, 0, 0, 0.45);
.command {
display: inline-block;
width: 27px;
height: 27px;
margin: 0 6px;
padding-top: 6px;
text-align: center;
cursor: pointer;
}
}
}
.content-container {
position: relative;
width: 100%;
height: 100%;
.content {
width: 100%;
height: 100%;
position: relative;
min-width: 400px;
min-height: 600px;
display: flex;
border: 1px solid #dfe3e8;
flex-direction: row;
flex: 1 1;
.stencil {
width: 250px;
height: 100%;
border-right: 1px solid #dfe3e8;
position: relative;
:deep(.x6-widget-stencil) {
background-color: #fff;
}
:deep(.x6-widget-stencil-title) {
background-color: #fff;
}
:deep(.x6-widget-stencil-group-title) {
background-color: #fff !important;
}
}
.graph-content {
width: calc(100% - 180px);
height: 100%;
}
.editor-sidebar {
display: flex;
flex-direction: column;
border-left: 1px solid #e6f7ff;
background: #fafafa;
z-index: 9;
.el-card {
border: none;
}
.edit-panel {
flex: 1 1;
background-color: #fff;
}
:deep(.x6-widget-minimap-viewport) {
border: 1px solid #8f8f8f;
}
:deep(.x6-widget-minimap-viewport-zoom) {
border: 1px solid #8f8f8f;
}
}
}
}
:deep(.x6-widget-transform) {
margin: -1px 0 0 -1px;
padding: 0px;
border: 1px solid #239edd;
}
:deep(.x6-widget-transform > div) {
border: 1px solid #239edd;
}
:deep(.x6-widget-transform > div:hover) {
background-color: #3dafe4;
}
:deep(.x6-widget-transform-active-handle) {
background-color: #3dafe4;
}
:deep(.x6-widget-transform-resize) {
border-radius: 0;
}
:deep(.x6-widget-selection-inner) {
border: 1px solid #239edd;
}
:deep(.x6-widget-selection-box) {
opacity: 0;
}
.topic-image {
visibility: hidden;
cursor: pointer;
}
.x6-node:hover .topic-image {
visibility: visible;
}
.x6-node-selected rect {
stroke-width: 2px;
}
.node-menu {
position: absolute;
box-shadow: var(--el-box-shadow-light);
background: var(--el-bg-color-overlay);
border: 1px solid var(--el-border-color-light);
padding: 5px 0px;
.menu-item {
display: flex;
align-items: center;
white-space: nowrap;
list-style: none;
line-height: 22px;
padding: 5px 16px;
margin: 0;
font-size: var(--el-font-size-base);
color: var(--el-text-color-regular);
cursor: pointer;
outline: none;
box-sizing: border-box;
}
.menu-item .el-image {
margin-right: 5px;
}
.menu-item:hover {
background-color: var(--el-color-primary-light-9);
color: var(--el-color-primary);
}
}
</style>
显示流程图
<template>
<div class="content-main">
<div class="content-container" id="">
<div class="content">
<div class="graph-content" id="graphContainer" ref="graphContainer"></div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { Graph, Path, Edge } from '@antv/x6'
import { ref, onMounted, reactive } from 'vue'
import '@/styles/animation.less'
const graphContainer = ref()
let graph: any = null
const state = reactive({
data: {
nodes: [
{
id: 'ac51fb2f-2753-4852-8239-53672a29bb14',
x: -340,
y: -160,
ports: [
{
id: 'ac51fb2f-2753-4852-8239-53672a29bb14_out',
group: 'out'
}
],
data: {
name: '数据输入_1',
type: 'OUTPUT',
checkStatus: 'sucess'
},
attrs: {
body: {
fill: '#EFF4FF',
stroke: '#5F95FF'
},
image: {
'xlink:href': 'http://localhost:20002/src/assets/imgs/rice.png'
},
label: {
text: '春望'
},
text: {
fill: '#5F95FF',
text: '开始'
}
}
},
{
id: '81004c2f-0413-4cc6-8622-127004b3befa',
x: -340,
y: -10,
ports: [
{
id: '81004c2f-0413-4cc6-8622-127004b3befa_in',
group: 'in'
},
{
id: '81004c2f-0413-4cc6-8622-127004b3befa_out',
group: 'out'
}
],
data: {
name: '数据输入_1',
type: 'SYAN',
checkStatus: 'sucess'
},
attrs: {
body: {
fill: '#edc3ae',
stroke: '#f9723d'
},
image: {
'xlink:href': 'http://localhost:20002/src/assets/imgs/persimmon.png'
},
label: {
text: '国破山河在'
},
text: {
fill: '#f9723d',
text: '数据同步'
}
}
},
{
id: '7505da25-1308-4d7a-98fd-e6d5c917d35d',
x: -140,
y: 180,
ports: [
{
id: '7505da25-1308-4d7a-98fd-e6d5c917d35d_in',
group: 'in'
}
],
data: {
name: '数据输入_1',
type: 'INPUT',
checkStatus: 'sucess'
},
attrs: {
body: {
fill: '#b9dec9',
stroke: '#229453'
},
image: {
'xlink:href': 'http://localhost:20002/src/assets/imgs/lime.png'
},
label: {
text: '城春草木胜'
},
text: {
fill: '#229453',
text: '结束'
}
}
}
],
edges: [
{
attrs: { line: { strokeDasharray: '5 5' } },
connector: { name: 'curveConnector' },
id: '6eea5dc9-4e15-4e78-959f-ee13ec59d11c',
shape: 'data-processing-curve',
source: { cell: 'ac51fb2f-2753-4852-8239-53672a29bb14', port: '_out' },
target: { cell: '81004c2f-0413-4cc6-8622-127004b3befa', port: '_in' },
zIndex: -1
},
{
attrs: { line: { strokeDasharray: '5 5' } },
connector: { name: 'curveConnector' },
id: '8cbce713-54be-4c07-8efa-59c505f74ad7',
labels: ['下半句'],
shape: 'data-processing-curve',
source: { cell: '81004c2f-0413-4cc6-8622-127004b3befa', port: '_out' },
target: { cell: '7505da25-1308-4d7a-98fd-e6d5c917d35d', port: '_in' }
}
]
},
nodeStatusList: [
{
id: 'ac51fb2f-2753-4852-8239-53672a29bb14',
status: 'success'
},
{
id: '81004c2f-0413-4cc6-8622-127004b3befa',
status: 'success'
}
],
edgeStatusList: [
{
id: '6eea5dc9-4e15-4e78-959f-ee13ec59d11c',
status: 'success'
},
{
id: '8cbce713-54be-4c07-8efa-59c505f74ad7',
status: 'executing'
}
]
})
function init() {
graph = new Graph({
container: graphContainer.value,
interacting: function () {
return { nodeMovable: false }
},
grid: true,
panning: {
enabled: false,
eventTypes: ['leftMouseDown', 'mouseWheel']
},
mousewheel: {
enabled: true,
modifiers: 'ctrl',
factor: 1.1,
maxScale: 1.5,
minScale: 0.5
},
highlighting: {
magnetAdsorbed: {
name: 'stroke',
args: {
attrs: {
fill: '#fff',
stroke: '#31d0c6',
strokeWidth: 4
}
}
}
},
connecting: {
snap: true,
allowBlank: false,
allowLoop: false,
highlight: true,
sourceAnchor: {
name: 'bottom',
args: {
dx: 0
}
},
targetAnchor: {
name: 'top',
args: {
dx: 0
}
},
createEdge() {
return graph.createEdge({
shape: 'data-processing-curve',
attrs: {
line: {
strokeDasharray: '5 5'
}
},
zIndex: -1
})
},
validateConnection({ sourceMagnet, targetMagnet }) {
if (!sourceMagnet || sourceMagnet.getAttribute('port-group') === 'in') {
return false
}
if (!targetMagnet || targetMagnet.getAttribute('port-group') === 'out') {
return false
}
return true
}
}
})
graph.centerContent()
const ports = {
groups: {
in: {
position: 'top',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden'
}
}
}
},
out: {
position: 'bottom',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#31d0c6',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden'
}
}
}
},
left: {
position: 'left',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden'
}
}
}
},
right: {
position: 'right',
attrs: {
circle: {
r: 4,
magnet: true,
stroke: '#5F95FF',
strokeWidth: 1,
fill: '#fff',
style: {
visibility: 'hidden'
}
}
}
}
}
}
Graph.registerNode(
'custom-node',
{
inherit: 'rect',
width: 140,
height: 76,
attrs: {
body: {
strokeWidth: 1
},
image: {
width: 16,
height: 16,
x: 12,
y: 6
},
text: {
refX: 40,
refY: 15,
fontSize: 15,
'text-anchor': 'start'
},
label: {
text: 'Please nominate this node',
refX: 10,
refY: 30,
fontSize: 12,
fill: 'rgba(0,0,0,0.6)',
'text-anchor': 'start',
textWrap: {
width: -10,
height: '70%',
ellipsis: true,
breakWord: true
}
}
},
markup: [
{
tagName: 'rect',
selector: 'body'
},
{
tagName: 'image',
selector: 'image'
},
{
tagName: 'text',
selector: 'text'
},
{
tagName: 'text',
selector: 'label'
}
],
data: {},
relation: {},
ports: { ...ports }
},
true
)
Graph.registerConnector(
'curveConnector',
(s, e) => {
const offset = 4
const deltaY = Math.abs(e.y - s.y)
const control = Math.floor((deltaY / 3) * 2)
const v1 = { x: s.x, y: s.y + offset + control }
const v2 = { x: e.x, y: e.y - offset - control }
return Path.normalize(
`M ${s.x} ${s.y}
L ${s.x} ${s.y + offset}
C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${e.x} ${e.y - offset}
L ${e.x} ${e.y}
`
)
},
true
)
}
Edge.config({
markup: [
{
tagName: 'path',
selector: 'wrap',
attrs: {
fill: 'none',
cursor: 'pointer',
stroke: 'transparent',
strokeLinecap: 'round'
}
},
{
tagName: 'path',
selector: 'line',
attrs: {
fill: 'none',
pointerEvents: 'none'
}
}
],
connector: { name: 'curveConnector' },
attrs: {
wrap: {
connection: true,
strokeWidth: 10,
strokeLinejoin: 'round'
},
line: {
connection: true,
stroke: '#A2B1C3',
strokeWidth: 1,
targetMarker: {
name: 'classic',
size: 6
}
}
}
})
Graph.registerEdge('data-processing-curve', Edge, true)
function getData() {
let cells = [] as any
const location = state.data
location.nodes.map((node) => {
cells.push(
graph.addNode({
id: node.id,
x: node.x,
y: node.y,
shape: 'custom-node',
attrs: node.attrs,
ports: node.ports,
data: node.data
})
)
})
location.edges.map((edge) => {
cells.push(
graph.addEdge({
id: edge.id,
source: edge.source,
target: edge.target,
zIndex: edge.zIndex,
shape: 'data-processing-curve',
connector: { name: 'curveConnector' },
labels: edge.labels,
attrs: edge.attrs
})
)
})
graph.resetCells(cells)
}
const excuteAnimate = (edge: any) => {
edge.attr({
line: {
stroke: '#3471F9'
}
})
edge.attr('line/strokeDasharray', 5)
edge.attr('line/style/animation', 'running-line 30s infinite linear')
}
const showEdgeStatus = () => {
state.edgeStatusList.forEach((item) => {
const edge = graph.getCellById(item.id)
if (item.status == 'success') {
edge.attr('line/strokeDasharray', 0)
edge.attr('line/stroke', '#52c41a')
} else if ('error' == item.status) {
edge.attr('line/stroke', '#ff4d4f')
} else if ('executing' == item.status) {
excuteAnimate(edge)
}
})
}
onMounted(() => {
init()
getData()
showEdgeStatus()
})
</script>
<style lang="less" scoped>
.content-main {
display: flex;
width: 100%;
flex-direction: column;
height: calc(100vh - 85px - 40px);
background-color: #ffffff;
position: relative;
}
.content-container {
position: relative;
width: 100%;
height: 100%;
.content {
width: 100%;
height: 100%;
position: relative;
min-width: 400px;
min-height: 600px;
display: flex;
border: 1px solid #dfe3e8;
flex-direction: row;
flex: 1 1;
.graph-content {
width: calc(100%);
height: 100%;
}
}
}
:deep(.x6-widget-transform) {
margin: -1px 0 0 -1px;
padding: 0px;
border: 1px solid #239edd;
}
:deep(.x6-widget-transform > div) {
border: 1px solid #239edd;
}
:deep(.x6-widget-transform > div:hover) {
background-color: #3dafe4;
}
:deep(.x6-widget-transform-active-handle) {
background-color: #3dafe4;
}
:deep(.x6-widget-transform-resize) {
border-radius: 0;
}
:deep(.x6-widget-selection-inner) {
border: 1px solid #239edd;
}
:deep(.x6-widget-selection-box) {
opacity: 0;
}
.topic-image {
visibility: hidden;
cursor: pointer;
}
.x6-node:hover .topic-image {
visibility: visible;
}
.x6-node-selected rect {
stroke-width: 2px;
}
</style>