index.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527
  1. <template>
  2. <div class="chart">
  3. <div class="chart-block">
  4. <div class="chart-ref" ref="ref_chart"></div>
  5. </div>
  6. <div class="tools">
  7. <div class="mini-map" ref="ref_miniMap"/>
  8. <div class="operations">
  9. <div class="zoom">
  10. <el-tooltip content="缩小" effect="light" placement="top" :show-arrow="false">
  11. <div class="__hover-bg" :class="{__disabled: state.zoom <= 0.25}" @click="graphZoom(state.zoom - 0.1)">
  12. <SvgIcon name="zoom-"/>
  13. </div>
  14. </el-tooltip>
  15. <el-dropdown :teleported="false" placement="top" :popper-options="{
  16. modifiers: [
  17. {
  18. name: 'offset',
  19. options: {
  20. offset: [0, 20],
  21. },
  22. },
  23. {
  24. name: 'arrow',
  25. },
  26. ],
  27. }">
  28. <div class="__hover-bg px-0.5">{{(state.zoom * 100).toFixed(0)}}%</div>
  29. <template #dropdown>
  30. <el-dropdown-menu>
  31. <el-dropdown-item @click="graphZoom(2)">200%</el-dropdown-item>
  32. <el-dropdown-item @click="graphZoom(1)">100%</el-dropdown-item>
  33. <el-dropdown-item @click="graphZoom(0.75)">75%</el-dropdown-item>
  34. <el-dropdown-item @click="graphZoom(0.5)">50%</el-dropdown-item>
  35. <el-dropdown-item divided @click="graphZoom(0)">自适应视图</el-dropdown-item>
  36. </el-dropdown-menu>
  37. </template>
  38. </el-dropdown>
  39. <el-tooltip content="放大" effect="light" placement="top" :show-arrow="false">
  40. <div class="__hover-bg" :class="{__disabled: state.zoom >= 2}" @click="graphZoom(state.zoom + 0.1)">
  41. <SvgIcon name="zoom+"/>
  42. </div>
  43. </el-tooltip>
  44. </div>
  45. <div>
  46. <el-tooltip content="撤销" effect="light" placement="top" :show-arrow="false">
  47. <div class="__hover-bg" :class="{__disabled: !state.history.canUndo}" @click="onHistoryDo(-1)">
  48. <SvgIcon name="back"/>
  49. </div>
  50. </el-tooltip>
  51. <el-tooltip content="重做" effect="light" placement="top" :show-arrow="false">
  52. <div class="__hover-bg" :class="{__disabled: !state.history.canRedo}" @click="onHistoryDo(1)">
  53. <SvgIcon name="back" rotate="180"/>
  54. </div>
  55. </el-tooltip>
  56. <el-popover
  57. :show-arrow="false"
  58. :width="240"
  59. :popper-style="{
  60. padding: 0,
  61. }">
  62. <template #reference>
  63. <div class="__hover-bg">
  64. <SvgIcon name="history"/>
  65. </div>
  66. </template>
  67. <div class="">
  68. <div class="text-[18px] p-3 pb-1">变更历史</div>
  69. <div class="px-1">
  70. <template v-if="state.history.steps.length < 2">
  71. <div class="mt-2 h-[120px] flex flex-col justify-center items-center">
  72. <SvgIcon name="history" size="30" class="mb-3"/>
  73. 尚未更改任何内容
  74. </div>
  75. </template>
  76. <template v-else>
  77. <div class="mt-2 max-h-[300px] overflow-y-auto flex flex-col gap-1">
  78. <template v-for="(item, index) in state.history.steps">
  79. <div :class="`__hover-bg px-2 py-1.5 rounded-md text-[13px] ${state.history.current === index && 'bg-[#c8ceda33]'}`" @click="onHistoryDo(state.history.current - index)">
  80. {{MapGraphHistoryStep.get(item)}}
  81. <template v-if="state.history.current < index">{{index - state.history.current}} 步后退</template>
  82. <template v-else-if="state.history.current > index">{{state.history.current - index}} 步前进</template>
  83. <template v-else>当前状态</template>
  84. )
  85. </div>
  86. </template>
  87. </div>
  88. <div class="bg-[#eaecf0] w-full h-[1px] my-2"/>
  89. <div class="__hover-bg px-2 py-1.5 rounded-md mb-2 text-[13px]" @click="onHistoryClear(state.history.steps[0])">清空历史记录</div>
  90. </template>
  91. </div>
  92. <div class="text-[12px] text-[#676f83] p-3">
  93. 提示<br/>
  94. 您的编辑操作将被跟踪并存储在您的设备上,直到您离开编辑器。此历史记录将在您离开编辑器时被清除。
  95. </div>
  96. </div>
  97. </el-popover>
  98. </div>
  99. </div>
  100. </div>
  101. </div>
  102. </template>
  103. <script setup lang="ts">
  104. import {createVNode, getCurrentInstance, nextTick, onMounted, reactive, ref, render, watch} from "vue";
  105. import {Graph, Path, Shape} from '@antv/x6'
  106. import {getNodeDefault, lineActiveStyle, lineStyle} from "@/views/workflow/config";
  107. import nodeAdd from './node-add.vue'
  108. import {register} from "@antv/x6-vue-shape";
  109. import WorkflowNode from "./node-index.vue";
  110. import {handleEdge, handleNode} from "@/views/workflow/handle";
  111. import {Snapline} from '@antv/x6-plugin-snapline'
  112. import {ContextMenuTool} from "./context-menu-tool";
  113. import {useWorkflowStore} from "@/stores/modules/workflow";
  114. import {GraphHistoryStep, MapGraphHistoryStep, NodeType} from "@/views/workflow/types";
  115. import { MiniMap } from '@antv/x6-plugin-minimap'
  116. import { History } from '@antv/x6-plugin-history'
  117. import {v4} from "uuid";
  118. register({
  119. shape: 'workflow-node',
  120. component: WorkflowNode,
  121. })
  122. let edgeTimer: any = null
  123. Graph.registerPortLayout('start', (portsPositionArgs, elemBBox) => {
  124. return portsPositionArgs.map((_, index) => {
  125. return {
  126. position: {
  127. x: 3,
  128. y: 25 + 7,
  129. },
  130. }
  131. })
  132. })
  133. Graph.registerPortLayout('end', (portsPositionArgs, elemBBox) => {
  134. if (edgeTimer) {
  135. clearTimeout(edgeTimer)
  136. }
  137. edgeTimer = setTimeout(() => {
  138. initEdges()
  139. }, 100)
  140. return portsPositionArgs.map((_, index) => {
  141. return {
  142. position: {
  143. x: elemBBox.width + 11,
  144. y: 25 + 7,
  145. },
  146. }
  147. })
  148. })
  149. Graph.registerPortLayout('more', (portsPositionArgs, elemBBox) => {
  150. return portsPositionArgs.map((_, index) => {
  151. nextTick(() => {
  152. WorkflowStore.layoutPort(_.nodeId, _.portId)
  153. if (edgeTimer) {
  154. clearTimeout(edgeTimer)
  155. }
  156. edgeTimer = setTimeout(() => {
  157. initEdges()
  158. }, 100)
  159. })
  160. return {
  161. position: {
  162. x: elemBBox.width + 11,
  163. y: _.dy,
  164. },
  165. }
  166. })
  167. })
  168. Graph.registerConnector(
  169. 'algo-connector',
  170. (s, e) => {
  171. const offset = 6
  172. const deltaX = Math.abs(e.x - s.x)
  173. const control = Math.floor((deltaX / 3) * 2)
  174. const v1 = { x: s.x + offset + control, y: s.y }
  175. const v2 = { x: e.x - offset - control, y: e.y }
  176. return Path.normalize(
  177. `M ${s.x} ${s.y}
  178. L ${s.x} ${s.y}
  179. C ${v1.x} ${v1.y} ${v2.x} ${v2.y} ${e.x} ${e.y}
  180. L ${e.x} ${e.y}
  181. `,
  182. )
  183. },
  184. true,
  185. )
  186. Graph.registerNodeTool('contextmenu', ContextMenuTool, true)
  187. Graph.registerEdgeTool('contextmenu', ContextMenuTool, true)
  188. const WorkflowStore = useWorkflowStore()
  189. const emits = defineEmits([])
  190. const props = defineProps({
  191. data: <any>{}
  192. })
  193. const {proxy}: any = getCurrentInstance()
  194. const state: any = reactive({
  195. graph: null,
  196. isPort: false,
  197. isInitEdges: false,
  198. zoom: 1,
  199. history: {
  200. canUndo: false,
  201. canRedo: false,
  202. steps: [],
  203. current: 0
  204. }
  205. })
  206. const ref_chart = ref()
  207. const ref_miniMap = ref()
  208. const initChart = () => {
  209. state.graph = new Graph({
  210. container: ref_chart.value,
  211. autoResize: true,
  212. panning: true,
  213. mousewheel: {
  214. enabled: true,
  215. factor: 1.05
  216. },
  217. scaling: {
  218. min: 0.25,
  219. max: 2
  220. },
  221. grid: {
  222. visible: true,
  223. type: 'doubleMesh',
  224. args: [
  225. {
  226. color: 'rgba(238,238,238,0.3)', // 主网格线颜色
  227. thickness: 1, // 主网格线宽度
  228. },
  229. {
  230. color: 'rgba(221,221,221,0.3)', // 次网格线颜色
  231. thickness: 1, // 次网格线宽度
  232. factor: 2, // 主次网格线间隔
  233. },
  234. ],
  235. },
  236. highlighting: {
  237. // 连接桩可以被连接时在连接桩外围围渲染一个包围框
  238. magnetAvailable: {
  239. name: 'stroke',
  240. args: {
  241. rx: 100,
  242. ry: 100,
  243. attrs: {
  244. fill: '#fff',
  245. stroke: 'rgba(var(--czr-main-color-rgb), 0.5)',
  246. 'stroke-width': 3,
  247. },
  248. },
  249. },
  250. // 连接桩吸附连线时在连接桩外围围渲染一个包围框
  251. magnetAdsorbed: {
  252. name: 'stroke',
  253. args: {
  254. rx: 100,
  255. ry: 100,
  256. attrs: {
  257. fill: '#fff',
  258. stroke: 'rgba(var(--czr-main-color-rgb), 1)',
  259. 'stroke-width': 3,
  260. },
  261. },
  262. },
  263. },
  264. connecting: {
  265. snap: { // 连线过程中自动吸附
  266. radius: 20,
  267. },
  268. allowBlank: false, // 是否允许连接到画布空白位置的点
  269. allowLoop: false, // 是否允许创建循环连线,即边的起始节点和终止节点为同一节点
  270. allowNode: false, // 是否允许边连接到节点
  271. allowEdge: false, // 是否允许边链接到另一个边
  272. allowPort: ({sourcePort, targetPort}: any) => { // 是否允许边链接到连接桩
  273. return !sourcePort?.includes('start') && targetPort?.includes('start')
  274. },
  275. allowMulti: 'withPort', // 当设置为 'withPort' 时,在起始和终止节点的相同连接桩之间只允许创建一条边(即,起始和终止节点之间可以创建多条边,但必须要要链接在不同的连接桩上)
  276. highlight: true,
  277. // router: {
  278. // name: 'manhattan',
  279. // args: {
  280. // startDirections: ['right'],
  281. // endDirections: ['left'],
  282. // },
  283. // },
  284. connector: 'algo-connector',
  285. connectionPoint: 'anchor',
  286. anchor: 'center',
  287. // connector: {
  288. // name: 'rounded',
  289. // args: {
  290. // radius: 20,
  291. // },
  292. // },
  293. createEdge: (args) => {
  294. const id = v4()
  295. return new Shape.Edge({
  296. id,
  297. attrs: lineStyle,
  298. zIndex: -1,
  299. tools: [
  300. {
  301. name: 'contextmenu',
  302. args: {
  303. delFlag: GraphHistoryStep.EdgeDel,
  304. data: {id},
  305. }
  306. }
  307. ]
  308. })
  309. }
  310. },
  311. onPortRendered: (args: any) => {
  312. const selectors = args.contentSelectors
  313. const container = selectors && selectors.foContent
  314. if (container) {
  315. render(createVNode(nodeAdd, {
  316. port: args.port,
  317. node: args.node,
  318. graph: state.graph,
  319. }), container)
  320. }
  321. }
  322. })
  323. WorkflowStore.init(state.graph)
  324. initPlug()
  325. initWatch()
  326. initNodes()
  327. graphZoom(0)
  328. }
  329. const initNodes = () => {
  330. props.data.nodes.forEach(v => {
  331. state.graph.addNode(handleNode(v))
  332. })
  333. if (props.data.nodes.length === 0) {
  334. const node = getNodeDefault(NodeType.Root)
  335. state.graph.addNode(handleNode(node))
  336. }
  337. }
  338. const initEdges = () => {
  339. if (!state.isInitEdges) {
  340. props.data.edges.forEach(v => {
  341. const targetNode = state.graph.getCellById(v.target)
  342. targetNode.setData({
  343. edgeSource: v.port || v.source
  344. }, {deep: false})
  345. state.graph.addEdge(handleEdge(v))
  346. })
  347. state.isInitEdges = true
  348. onHistoryClear()
  349. }
  350. }
  351. const initWatch = () => {
  352. state.graph.on('node:click', ({ e, x, y, node, view, port }) => {
  353. setTimeout(() => {
  354. if (!state.isPort) {
  355. WorkflowStore.nodePanel(node)
  356. }
  357. }, 50)
  358. })
  359. state.graph.on('node:port:click', ({ e, x, y, node, view, port }) => {
  360. state.isPort = true;
  361. setTimeout(() => {
  362. state.isPort = false
  363. }, 100)
  364. })
  365. state.graph.on('node:mouseenter', ({ e, node, view }) => {
  366. const connectEdges = state.graph.getConnectedEdges(node.id)
  367. connectEdges.forEach(edge => {
  368. edge.attr(lineActiveStyle)
  369. })
  370. })
  371. state.graph.on('node:mouseleave', ({ e, node, view }) => {
  372. const connectEdges = state.graph.getConnectedEdges(node.id)
  373. connectEdges.forEach(edge => {
  374. edge.attr(lineStyle)
  375. })
  376. })
  377. state.graph.on('edge:mouseenter', ({ e, edge, view }) => {
  378. edge.attr(lineActiveStyle)
  379. const connectNodes = state.graph.getNeighbors(edge)
  380. connectNodes.forEach(node => {
  381. node.attr('active', true)
  382. })
  383. })
  384. state.graph.on('edge:mouseleave', ({ e, edge, view }) => {
  385. edge.attr(lineStyle)
  386. const connectNodes = state.graph.getNeighbors(edge)
  387. connectNodes.forEach(node => {
  388. node.attr('active', false)
  389. })
  390. })
  391. state.graph.on('scale', ({ sx, sy, ox, oy }) => {
  392. state.zoom = sx
  393. })
  394. state.graph.on('history:change', ({cmds, options}) => {
  395. if (state.isInitEdges) {
  396. state.history.canRedo = state.graph.canRedo()
  397. state.history.canUndo = state.graph.canUndo()
  398. if (options.name) {
  399. state.history.steps.unshift(options.name)
  400. }
  401. }
  402. })
  403. }
  404. const initPlug = () => {
  405. state.graph.use(
  406. new Snapline({
  407. enabled: true,
  408. clean: false,
  409. }),
  410. )
  411. state.graph.use(
  412. new MiniMap({
  413. container: ref_miniMap.value,
  414. width: 100,
  415. height: 70,
  416. padding: 6
  417. }),
  418. )
  419. state.graph.use(
  420. new History({
  421. enabled: true,
  422. beforeAddCommand: (event, args: any) => {
  423. if (
  424. args.key === 'ports' && args.options.propertyPath.includes('ports/items') ||
  425. args.key === 'attrs'
  426. ) {
  427. return false
  428. }
  429. }
  430. }),
  431. )
  432. }
  433. const graphZoom = (z) => {
  434. if (z) {
  435. state.graph.zoomTo(z)
  436. } else {
  437. state.graph.zoomToFit({ maxScale: 1 })
  438. }
  439. }
  440. const onHistoryDo = (step) => {
  441. if (step > 0) {
  442. for (let i = 1; i <= step; i++) {
  443. if (state.history.canRedo) {
  444. state.graph.redo()
  445. state.history.current -= 1
  446. }
  447. }
  448. } else {
  449. for (let i = -1; i >= step; i--) {
  450. if (state.history.canUndo) {
  451. state.graph.undo()
  452. state.history.current += 1
  453. }
  454. }
  455. }
  456. }
  457. const onHistoryClear = (first = '') => {
  458. state.graph.cleanHistory() // 初始化完成后清空历史队列
  459. state.history.steps = [first || GraphHistoryStep.Root]
  460. state.history.current = 0
  461. }
  462. watch(() => props.data, (n) => {
  463. if (n) {
  464. initChart()
  465. }
  466. }, {immediate: true})
  467. onMounted(() => {
  468. })
  469. defineExpose({
  470. toJSON: () => state.graph.toJSON()
  471. })
  472. </script>
  473. <style lang="scss" scoped>
  474. .chart {
  475. width: 100%;
  476. height: 100%;
  477. background-color: #f2f4f7;
  478. position: relative;
  479. .chart-block {
  480. width: 100%;
  481. height: 100%;
  482. .chart-ref {
  483. width: 100%;
  484. height: 100%;
  485. }
  486. }
  487. .tools {
  488. position: absolute;
  489. left: 20px;
  490. bottom: 20px;
  491. z-index: 2;
  492. .mini-map {
  493. position: absolute;
  494. bottom: calc(100% + 10px);
  495. }
  496. :deep(.operations) {
  497. display: flex;
  498. align-items: center;
  499. font-size: 14px;
  500. color: #676f83;
  501. gap: 10px;
  502. .el-popper__arrow {
  503. display: none;
  504. }
  505. >div {
  506. display: flex;
  507. align-items: center;
  508. background-color: rgba(255, 255, 255, 0.95);
  509. box-shadow: 0 0px 8px rgba(0, 0, 0, 0.1);
  510. border-radius: 6px;
  511. padding: 4px;
  512. >div {
  513. min-width: 32px;
  514. height: 32px;
  515. display: flex;
  516. align-items: center;
  517. justify-content: center;
  518. }
  519. }
  520. }
  521. }
  522. }
  523. </style>