123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846 |
- <template>
- <div class="chart">
- <div class="chart-block">
- <div class="chart-ref" ref="ref_chart"></div>
- </div>
- <div class="tools">
- <div class="mini-map" ref="ref_miniMap" />
- <div class="operations">
- <div class="zoom">
- <el-tooltip
- content="缩小"
- effect="light"
- placement="top"
- :show-arrow="false"
- >
- <div
- class="__hover-bg"
- :class="{ __disabled: state.zoom <= 0.25 }"
- @click="graphZoom(state.zoom - 0.1)"
- >
- <SvgIcon name="zoom-" />
- </div>
- </el-tooltip>
- <el-dropdown
- :teleported="false"
- placement="top"
- :popper-options="{
- modifiers: [
- {
- name: 'offset',
- options: {
- offset: [0, 20],
- },
- },
- {
- name: 'arrow',
- },
- ],
- }"
- >
- <div class="__hover-bg px-0.5">
- {{ (state.zoom * 100).toFixed(0) }}%
- </div>
- <template #dropdown>
- <el-dropdown-menu>
- <el-dropdown-item @click="graphZoom(2)">200%</el-dropdown-item>
- <el-dropdown-item @click="graphZoom(1)">100%</el-dropdown-item>
- <el-dropdown-item @click="graphZoom(0.75)"
- >75%</el-dropdown-item
- >
- <el-dropdown-item @click="graphZoom(0.5)">50%</el-dropdown-item>
- <el-dropdown-item divided @click="graphZoom(0)"
- >自适应视图</el-dropdown-item
- >
- </el-dropdown-menu>
- </template>
- </el-dropdown>
- <el-tooltip
- content="放大"
- effect="light"
- placement="top"
- :show-arrow="false"
- >
- <div
- class="__hover-bg"
- :class="{ __disabled: state.zoom >= 2 }"
- @click="graphZoom(state.zoom + 0.1)"
- >
- <SvgIcon name="zoom+" />
- </div>
- </el-tooltip>
- </div>
- <div>
- <el-tooltip
- content="撤销"
- effect="light"
- placement="top"
- :show-arrow="false"
- >
- <div
- class="__hover-bg"
- :class="{ __disabled: !state.history.canUndo }"
- @click="onHistoryDo(-1)"
- >
- <SvgIcon name="back" />
- </div>
- </el-tooltip>
- <el-tooltip
- content="重做"
- effect="light"
- placement="top"
- :show-arrow="false"
- >
- <div
- class="__hover-bg"
- :class="{ __disabled: !state.history.canRedo }"
- @click="onHistoryDo(1)"
- >
- <SvgIcon name="back" rotate="180" />
- </div>
- </el-tooltip>
- <el-popover
- :show-arrow="false"
- :width="240"
- :popper-style="{
- padding: 0,
- }"
- >
- <template #reference>
- <div class="__hover-bg">
- <SvgIcon name="history" />
- </div>
- </template>
- <div class="">
- <div class="p-3 pb-1 text-[18px]">变更历史</div>
- <div class="px-1">
- <template v-if="state.history.steps.length < 2">
- <div
- class="mt-2 flex h-[120px] flex-col items-center justify-center"
- >
- <SvgIcon name="history" size="30" class="mb-3" />
- 尚未更改任何内容
- </div>
- </template>
- <template v-else>
- <div
- class="mt-2 flex max-h-[300px] flex-col gap-1 overflow-y-auto"
- >
- <template v-for="(item, index) in state.history.steps">
- <div
- :class="`__hover-bg rounded-md px-2 py-1.5 text-[13px] ${state.history.current === index && 'bg-[#c8ceda33]'}`"
- @click="onHistoryDo(state.history.current - index)"
- >
- {{ MapGraphHistoryStep.get(item) }}
- (
- <template v-if="state.history.current < index"
- >{{ index - state.history.current }} 步后退</template
- >
- <template v-else-if="state.history.current > index"
- >{{ state.history.current - index }} 步前进</template
- >
- <template v-else>当前状态</template>
- )
- </div>
- </template>
- </div>
- <div class="my-2 h-[1px] w-full bg-[#eaecf0]" />
- <div
- class="__hover-bg mb-2 rounded-md px-2 py-1.5 text-[13px]"
- @click="onHistoryClear(state.history.steps[0])"
- >
- 清空历史记录
- </div>
- </template>
- </div>
- <div class="p-3 text-[12px] text-[#676f83]">
- 提示<br />
- 您的编辑操作将被跟踪并存储在您的设备上,直到您离开编辑器。此历史记录将在您离开编辑器时被清除。
- </div>
- </div>
- </el-popover>
- </div>
- <div>
- <el-popover
- :show-arrow="false"
- :width="100"
- :popper-style="{
- padding: 0,
- }"
- >
- <template #reference>
- <div class="__hover-bg">
- <SvgIcon name="add" />
- </div>
- </template>
- <div class="">
- <div class="p-3 pb-1 text-[18px]">新增节点</div>
- <div class="px-1">
- <div class="mt-2">
- <nodeAdd @onAddNode="onAddNode" ref="ref_dndContainer" />
- </div>
- <div class="my-2 h-[1px] w-full bg-[#eaecf0]" />
- </div>
- <div class="p-3 text-[12px] text-[#676f83]">
- 提示<br />
- 点击后拖拽至指定位置后,松开鼠标即可放置到画布上。
- </div>
- </div>
- </el-popover>
- <el-tooltip
- content="自动布局"
- effect="light"
- placement="top"
- :show-arrow="false"
- >
- <div class="__hover-bg" @click="onAutoFix">
- <SvgIcon name="mind-map" />
- </div>
- </el-tooltip>
- </div>
- </div>
- </div>
- </div>
- </template>
- <script setup lang="ts">
- import {
- createVNode,
- getCurrentInstance,
- nextTick,
- onMounted,
- reactive,
- ref,
- render,
- watch,
- } from 'vue'
- import { Graph, Path, Shape } from '@antv/x6'
- import {
- getNodeDefault,
- lineActiveStyle,
- lineStyle,
- } from '@/views/workflow/config'
- import nodePort from './node-port.vue'
- import { register } from '@antv/x6-vue-shape'
- import WorkflowNode from './node-index.vue'
- import { handleEdge, handleNode } from '@/views/workflow/handle'
- import { Snapline } from '@antv/x6-plugin-snapline'
- import { ContextMenuTool } from './context-menu-tool'
- import { useWorkflowStore } from '@/stores/modules/workflow'
- import {
- GraphHistoryStep,
- MapGraphHistoryStep,
- NodeType,
- } from '@/views/workflow/types'
- import { MiniMap } from '@antv/x6-plugin-minimap'
- import { History } from '@antv/x6-plugin-history'
- import { v4 } from 'uuid'
- import { Dnd } from '@antv/x6-plugin-dnd'
- import nodeAdd from './node-add.vue'
- import dagre from 'dagre'
- register({
- shape: 'workflow-node',
- component: WorkflowNode,
- })
- let edgeTimer: any = null
- Graph.registerPortLayout('start', (portsPositionArgs, elemBBox) => {
- return portsPositionArgs.map((_, index) => {
- return {
- position: {
- x: 0,
- y: 25,
- },
- }
- })
- })
- Graph.registerPortLayout('end', (portsPositionArgs, elemBBox) => {
- if (edgeTimer) {
- clearTimeout(edgeTimer)
- }
- edgeTimer = setTimeout(() => {
- initEdges()
- }, 100)
- return portsPositionArgs.map((_, index) => {
- return {
- position: {
- x: elemBBox.width,
- y: 25,
- },
- }
- })
- })
- Graph.registerPortLayout('more', (portsPositionArgs, elemBBox) => {
- return portsPositionArgs.map((_, index) => {
- nextTick(() => {
- WorkflowStore.layoutPort(_.nodeId, _.portId)
- if (edgeTimer) {
- clearTimeout(edgeTimer)
- }
- edgeTimer = setTimeout(() => {
- initEdges()
- }, 100)
- })
- return {
- position: {
- x: elemBBox.width,
- y: _.dy,
- },
- }
- })
- })
- Graph.registerConnector(
- 'algo-connector',
- (s, e) => {
- const offset = 6
- const deltaX = Math.abs(e.x - s.x)
- const control = Math.floor((deltaX / 3) * 2)
- const v1 = { x: s.x + offset + control, y: s.y }
- const v2 = { x: e.x - offset - control, y: e.y }
- return Path.normalize(
- `M ${s.x} ${s.y}
- L ${s.x} ${s.y}
- C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${e.x} ${e.y}
- L ${e.x} ${e.y}
- `,
- )
- },
- true,
- )
- Graph.registerNodeTool('contextmenu', ContextMenuTool, true)
- Graph.registerEdgeTool('contextmenu', ContextMenuTool, true)
- Graph.registerHighlighter(
- 'port-highlight',
- {
- highlight(cellView, magnet) {
- const dom = magnet.getElementsByClassName('node-port')[0]
- dom?.classList.add('highlight')
- },
- unhighlight(cellView, magnet) {
- const dom = magnet.getElementsByClassName('node-port')[0]
- dom?.classList.remove('highlight')
- },
- },
- true,
- )
- Graph.registerHighlighter(
- 'port-select',
- {
- highlight(cellView, magnet) {
- const dom = magnet.getElementsByClassName('node-port')[0]
- dom?.classList.add('select')
- },
- unhighlight(cellView, magnet) {
- const dom = magnet.getElementsByClassName('node-port')[0]
- dom?.classList.remove('select')
- },
- },
- true,
- )
- const WorkflowStore = useWorkflowStore()
- const emit = defineEmits(['save'])
- const props = defineProps({
- data: <any>{},
- ID: <any>{},
- })
- const { proxy }: any = getCurrentInstance()
- const state: any = reactive({
- graph: null,
- isPort: false,
- isInitEdges: false,
- zoom: 1,
- history: {
- canUndo: false,
- canRedo: false,
- steps: [],
- current: 0,
- autoSaveInit: false,
- },
- dnd: null,
- dndNode: null,
- })
- const ref_chart = ref()
- const ref_miniMap = ref()
- const ref_dndContainer = ref()
- const initChart = async () => {
- state.graph = new Graph({
- container: ref_chart.value,
- autoResize: true,
- panning: true,
- mousewheel: {
- enabled: true,
- factor: 1.05,
- },
- scaling: {
- min: 0.25,
- max: 2,
- },
- grid: {
- visible: true,
- type: 'doubleMesh',
- args: [
- {
- color: 'rgba(238,238,238,0.3)', // 主网格线颜色
- thickness: 1, // 主网格线宽度
- },
- {
- color: 'rgba(221,221,221,0.3)', // 次网格线颜色
- thickness: 1, // 次网格线宽度
- factor: 2, // 主次网格线间隔
- },
- ],
- },
- highlighting: {
- // 连接桩可以被连接时在连接桩外围围渲染一个包围框
- magnetAvailable: {
- name: 'port-highlight',
- },
- // 连接桩吸附连线时在连接桩外围围渲染一个包围框
- magnetAdsorbed: {
- name: 'port-select',
- },
- },
- connecting: {
- snap: {
- // 连线过程中自动吸附
- radius: 20,
- },
- allowBlank: false, // 是否允许连接到画布空白位置的点
- allowLoop: false, // 是否允许创建循环连线,即边的起始节点和终止节点为同一节点
- allowNode: false, // 是否允许边连接到节点
- allowEdge: false, // 是否允许边链接到另一个边
- allowPort: (args: any) => {
- // 是否允许边链接到连接桩
- // console.log(args)
- if (args.sourcePort?.includes('start')) {
- return false
- }
- if (!args.targetPort?.includes('start')) {
- return false
- }
- // if (state.graph.isSuccessor(args.sourceCell, args.targetCell)) {
- // return false
- // }
- // if (state.graph.getPredecessors(args.sourceCell)[0] && state.graph.getPredecessors(args.targetCell)[0] && state.graph.getPredecessors(args.sourceCell)[0] === state.graph.getPredecessors(args.targetCell)[0]) {
- // return false
- // }
- return true
- },
- allowMulti: 'withPort', // 当设置为 'withPort' 时,在起始和终止节点的相同连接桩之间只允许创建一条边(即,起始和终止节点之间可以创建多条边,但必须要要链接在不同的连接桩上)
- highlight: true,
- // router: {
- // name: 'manhattan',
- // args: {
- // startDirections: ['right'],
- // endDirections: ['left'],
- // },
- // },
- connector: 'algo-connector',
- connectionPoint: 'anchor',
- anchor: 'center',
- // connector: {
- // name: 'rounded',
- // args: {
- // radius: 20,
- // },
- // },
- createEdge: (args) => {
- const id = v4()
- return new Shape.Edge({
- id,
- attrs: {
- select: false,
- hover: false,
- line: lineStyle,
- },
- zIndex: -1,
- tools: [
- {
- name: 'contextmenu',
- args: {
- delFlag: GraphHistoryStep.EdgeDel,
- data: { id },
- },
- },
- ],
- })
- },
- },
- onPortRendered: (args: any) => {
- const selectors = args.contentSelectors
- const container = selectors && selectors.foContent
- if (container) {
- render(
- createVNode(nodePort, {
- port: args.port,
- node: args.node,
- graph: state.graph,
- }),
- container,
- )
- }
- },
- })
- await WorkflowStore.init(state.graph)
- initPlug()
- initWatch()
- initNodes()
- if (props.data.viewport?.zoom) {
- graphZoom(props.data.viewport.zoom)
- state.graph.translate(props.data.viewport.x, props.data.viewport.y)
- } else {
- graphZoom(0)
- emit('save')
- }
- }
- const initNodes = () => {
- props.data.nodes.forEach((v) => {
- state.graph.addNode(handleNode(v))
- })
- if (props.data.nodes.length === 0) {
- const node = getNodeDefault(NodeType.Root)
- state.graph.addNode(handleNode(node))
- }
- }
- const initEdges = () => {
- if (!state.isInitEdges) {
- props.data.edges.forEach((v) => {
- const targetNode = state.graph.getCellById(v.target)
- targetNode.setData(
- {
- edgeSource: v.port || v.source,
- },
- { deep: false },
- )
- state.graph.addEdge(handleEdge(v))
- })
- state.isInitEdges = true
- onHistoryClear()
- }
- }
- const initWatch = () => {
- state.graph.on('blank:click', () => {
- state.graph.getCells().forEach((v) => {
- v.attr('select', false)
- if (v.shape === 'edge') {
- setEdgeStyle(v)
- }
- })
- })
- state.graph.on('cell:click', () => {
- state.graph.getCells().forEach((v) => {
- v.attr('select', false)
- if (v.shape === 'edge') {
- setEdgeStyle(v)
- }
- })
- })
- state.graph.on('node:click', ({ node }) => {
- setTimeout(() => {
- if (!state.isPort) {
- WorkflowStore.nodePanelShow(node)
- // state.graph.getCells().forEach(v => v.attr('select', false))
- node.attr('select', true)
- const connectEdges = state.graph.getConnectedEdges(node.id)
- connectEdges.forEach((edge) => {
- edge.attr('select', true)
- setEdgeStyle(edge)
- })
- }
- }, 50)
- })
- state.graph.on('edge:click', ({ edge }) => {
- edge.attr('select', true)
- const connectNodes = state.graph.getNeighbors(edge)
- connectNodes.forEach((node) => {
- node.attr('select', true)
- })
- })
- state.graph.on('node:port:click', () => {
- state.isPort = true
- setTimeout(() => {
- state.isPort = false
- }, 100)
- })
- state.graph.on('node:mouseenter', ({ node }) => {
- node.attr('hover', true)
- const connectEdges = state.graph.getConnectedEdges(node.id)
- connectEdges.forEach((edge) => {
- edge.attr('hover', true)
- setEdgeStyle(edge)
- })
- })
- state.graph.on('node:mouseleave', ({ node }) => {
- node.attr('hover', false)
- const connectEdges = state.graph.getConnectedEdges(node.id)
- connectEdges.forEach((edge) => {
- edge.attr('hover', false)
- setEdgeStyle(edge)
- })
- })
- state.graph.on('edge:mouseenter', ({ edge }) => {
- edge.attr('hover', true)
- setEdgeStyle(edge)
- const connectNodes = state.graph.getNeighbors(edge)
- connectNodes.forEach((node) => {
- node.attr('hover', true)
- })
- })
- state.graph.on('edge:mouseleave', ({ edge }) => {
- edge.attr('hover', false)
- setEdgeStyle(edge)
- const connectNodes = state.graph.getNeighbors(edge)
- connectNodes.forEach((node) => {
- node.attr('hover', false)
- })
- })
- state.graph.on('scale', ({ sx, sy, ox, oy }) => {
- state.zoom = sx
- })
- state.graph.on('translate', ({ sx, sy, ox, oy }) => {
- if (state.autoSaveInit) {
- WorkflowStore.autoSave()
- }
- })
- state.graph.on('history:change', ({ cmds, options }) => {
- if (state.isInitEdges) {
- state.history.canRedo = state.graph.canRedo()
- state.history.canUndo = state.graph.canUndo()
- if (options.name) {
- const arr = [options.name]
- state.history.steps.forEach((v, i) => {
- if (i >= state.history.current) {
- arr.push(v)
- }
- })
- state.history.steps = arr
- state.history.current = 0
- }
- if (options.name === GraphHistoryStep.Dnd) {
- const nodeId = cmds[0].data.id
- state.graph
- .getCellById(nodeId)
- .data.workflowData.ports?.forEach((p) => {
- WorkflowStore.layoutPort(nodeId, p.id)
- })
- }
- if (state.autoSaveInit) {
- WorkflowStore.autoSave()
- } else {
- state.autoSaveInit = true
- }
- }
- })
- }
- const initPlug = () => {
- state.graph.use(
- new Snapline({
- enabled: true,
- clean: false,
- }),
- )
- state.graph.use(
- new MiniMap({
- container: ref_miniMap.value,
- width: 100,
- height: 70,
- padding: 6,
- }),
- )
- state.graph.use(
- new History({
- enabled: true,
- beforeAddCommand: (event, args: any) => {
- if (
- (args.key === 'ports' &&
- args.options.propertyPath.includes('ports/items')) ||
- args.key === 'attrs'
- ) {
- return false
- }
- },
- }),
- )
- state.dnd = new Dnd({
- target: state.graph,
- getDragNode: (node: any) => {
- node.size(WorkflowStore.nodeSize[node.data.workflowData.type])
- return node
- },
- getDropNode: (node) => node.clone({ keepId: true }),
- })
- }
- const setEdgeStyle = (edge) =>
- edge.attr(
- 'line',
- edge.getAttrByPath('hover') || edge.getAttrByPath('select')
- ? lineActiveStyle
- : lineStyle,
- )
- const graphZoom = (z) => {
- if (z) {
- state.graph.zoomTo(z)
- } else {
- state.graph.zoomToFit({ maxScale: 1 })
- }
- }
- const onHistoryDo = (step) => {
- if (step > 0) {
- for (let i = 1; i <= step; i++) {
- if (state.history.canRedo) {
- state.graph.redo()
- state.history.current -= 1
- }
- }
- } else {
- for (let i = -1; i >= step; i--) {
- if (state.history.canUndo) {
- state.graph.undo()
- state.history.current += 1
- }
- }
- }
- }
- const onHistoryClear = (first = '') => {
- state.graph.cleanHistory() // 初始化完成后清空历史队列
- state.history.steps = [first || GraphHistoryStep.Root]
- state.history.current = 0
- }
- const onAddNode = ({ type, e }) => {
- const node = state.graph.createNode(handleNode(getNodeDefault(type)))
- state.dnd.start(node, e)
- }
- const onAutoFix = () => {
- state.graph.startBatch(GraphHistoryStep.AutoFix)
- const xLevelsMap = new Map()
- const nodeSep = 20
- // 布局方向
- const dir = 'LR' // LR RL TB BT
- const nodes = state.graph.getNodes()
- const edges = state.graph.getEdges()
- const g = new dagre.graphlib.Graph()
- g.setGraph({
- rankdir: dir,
- nodesep: nodeSep,
- ranksep: 100,
- })
- g.setDefaultEdgeLabel(() => ({}))
- nodes.forEach((node) => {
- g.setNode(node.id, node.size())
- })
- edges.forEach((edge) => {
- const source = edge.getSource()
- const target = edge.getTarget()
- g.setEdge(source.cell, target.cell)
- })
- dagre.layout(g)
- g.nodes().forEach((id) => {
- const node = state.graph.getCellById(id) as Node
- if (node) {
- const pos = g.node(id)
- node.position(pos.x, pos.y)
- // 自动布局后针对某些节点高度过大,导致挡住其他节点的二次手动排序
- if (xLevelsMap.has(pos.x)) {
- xLevelsMap.set(pos.x, [...xLevelsMap.get(pos.x), { ...pos, id }])
- } else {
- xLevelsMap.set(pos.x, [{ ...pos, id }])
- }
- }
- })
- xLevelsMap.forEach((value, key, map) => {
- const arr = value.sort((a, b) => a.y - b.y)
- arr.forEach((n, i) => {
- if (i > 0) {
- const last = arr[i - 1]
- if (n.y < last.y + last.height) {
- n.y = nodeSep + last.y + last.height
- const node = state.graph.getCellById(n.id) as Node
- node.position(n.x, n.y)
- }
- }
- })
- })
- setTimeout(() => {
- state.graph.stopBatch(GraphHistoryStep.AutoFix)
- }, 100)
- graphZoom(0)
- }
- watch(
- () => props.data,
- (n) => {
- if (n) {
- initChart()
- }
- },
- { immediate: true },
- )
- onMounted(() => {})
- defineExpose({
- toJSON: () => state.graph.toJSON(),
- })
- </script>
- <style lang="scss" scoped>
- .chart {
- width: 100%;
- height: 100%;
- background-color: #f2f4f7;
- position: relative;
- .chart-block {
- width: 100%;
- height: 100%;
- .chart-ref {
- width: 100%;
- height: 100%;
- }
- }
- .tools {
- position: absolute;
- left: 20px;
- bottom: 20px;
- z-index: 2;
- .mini-map {
- position: absolute;
- bottom: calc(100% + 10px);
- }
- :deep(.operations) {
- display: flex;
- align-items: center;
- font-size: 14px;
- color: #676f83;
- gap: 10px;
- .el-popper__arrow {
- display: none;
- }
- > div {
- display: flex;
- align-items: center;
- background-color: rgba(255, 255, 255, 0.95);
- box-shadow: 0 0px 8px rgba(0, 0, 0, 0.1);
- border-radius: 6px;
- padding: 4px;
- > div {
- min-width: 32px;
- height: 32px;
- display: flex;
- align-items: center;
- justify-content: center;
- }
- }
- }
- }
- }
- :deep(.port-start-high) {
- background-color: red;
- width: 10px;
- height: 10px;
- .port-start {
- background-color: red !important;
- }
- }
- </style>
|