diff --git a/package.json b/package.json index d97c6b499..de9eeb3c1 100644 --- a/package.json +++ b/package.json @@ -55,6 +55,8 @@ "@pureadmin/descriptions": "^1.2.1", "@pureadmin/table": "^3.1.2", "@pureadmin/utils": "^2.4.7", + "@vue-flow/background": "^1.3.0", + "@vue-flow/core": "^1.33.4", "@vueuse/core": "^10.9.0", "@vueuse/motion": "^2.1.0", "@wangeditor/editor": "^5.1.23", @@ -114,6 +116,7 @@ "@iconify/vue": "^4.1.1", "@intlify/unplugin-vue-i18n": "^2.0.0", "@pureadmin/theme": "^3.2.0", + "@types/dagre": "^0.7.52", "@types/gradient-string": "^1.1.5", "@types/intro.js": "^5.1.5", "@types/js-cookie": "^3.0.6", @@ -130,6 +133,7 @@ "boxen": "^7.1.1", "cloc": "^2.11.0", "cssnano": "^6.1.0", + "dagre": "^0.8.5", "eslint": "^8.57.0", "eslint-config-prettier": "^9.1.0", "eslint-define-config": "^2.1.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cc7ee20d1..f94cdb8c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -26,6 +26,12 @@ dependencies: '@pureadmin/utils': specifier: ^2.4.7 version: 2.4.7(echarts@5.5.0)(vue@3.4.21) + '@vue-flow/background': + specifier: ^1.3.0 + version: 1.3.0(@vue-flow/core@1.33.4)(vue@3.4.21) + '@vue-flow/core': + specifier: ^1.33.4 + version: 1.33.4(vue@3.4.21) '@vueuse/core': specifier: ^10.9.0 version: 10.9.0(vue@3.4.21) @@ -199,6 +205,9 @@ devDependencies: '@pureadmin/theme': specifier: ^3.2.0 version: 3.2.0 + '@types/dagre': + specifier: ^0.7.52 + version: 0.7.52 '@types/gradient-string': specifier: ^1.1.5 version: 1.1.5 @@ -247,6 +256,9 @@ devDependencies: cssnano: specifier: ^6.1.0 version: 6.1.0(postcss@8.4.35) + dagre: + specifier: ^0.8.5 + version: 0.8.5 eslint: specifier: ^8.57.0 version: 8.57.0 @@ -2003,6 +2015,10 @@ packages: '@types/node': 20.11.27 dev: true + /@types/dagre@0.7.52: + resolution: {integrity: sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==} + dev: true + /@types/estree@1.0.5: resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} @@ -2352,6 +2368,30 @@ packages: path-browserify: 1.0.1 dev: true + /@vue-flow/background@1.3.0(@vue-flow/core@1.33.4)(vue@3.4.21): + resolution: {integrity: sha512-fu/8s9wzSOQIitnSTI10XT3bzTtagh4h8EF2SWwtlDklOZjAaKy75lqv4htHa3wigy/r4LGCOGwLw3Pk88/AxA==} + peerDependencies: + '@vue-flow/core': ^1.23.0 + vue: ^3.3.0 + dependencies: + '@vue-flow/core': 1.33.4(vue@3.4.21) + vue: 3.4.21(typescript@5.4.2) + dev: false + + /@vue-flow/core@1.33.4(vue@3.4.21): + resolution: {integrity: sha512-ryoamKfQ5pgtdv//Gjpyc4nsawMOwfI2jVzOPvZ92VQs78L4lidiWD7UybqeEkrGw6UPue1CGlzoy/4KlOWcSg==} + peerDependencies: + vue: ^3.3.0 + dependencies: + '@vueuse/core': 10.9.0(vue@3.4.21) + d3-drag: 3.0.0 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + vue: 3.4.21(typescript@5.4.2) + transitivePeerDependencies: + - '@vue/composition-api' + dev: false + /@vue/babel-helper-vue-transform-on@1.2.1: resolution: {integrity: sha512-jtEXim+pfyHWwvheYwUwSXm43KwQo8nhOBDyjrUITV6X2tB7lJm6n/+4sqR8137UVZZul5hBzWHdZ2uStYpyRQ==} dev: true @@ -3851,6 +3891,71 @@ packages: /csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + dev: false + + /d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + + /d3-transition@3.0.1(d3-selection@3.0.0): + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + dev: false + + /d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + dev: false + /d@1.0.2: resolution: {integrity: sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==} engines: {node: '>=0.12'} @@ -3859,6 +3964,13 @@ packages: type: 2.7.2 dev: false + /dagre@0.8.5: + resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} + dependencies: + graphlib: 2.1.8 + lodash: 4.17.21 + dev: true + /danmu.js@1.1.13: resolution: {integrity: sha512-knFd0/cB2HA4FFWiA7eB2suc5vCvoHdqio33FyyCSfP7C+1A+zQcTvnvwfxaZhrxsGj4qaQI2I8XiTqedRaVmg==} dependencies: @@ -4962,6 +5074,12 @@ packages: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} dev: true + /graphlib@2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + dependencies: + lodash: 4.17.21 + dev: true + /has-flag@3.0.0: resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} engines: {node: '>=4'} diff --git a/src/router/enums.ts b/src/router/enums.ts index 9670bffd8..712277558 100644 --- a/src/router/enums.ts +++ b/src/router/enums.ts @@ -1,29 +1,31 @@ // 完整版菜单比较多,将 rank 抽离出来,在此方便维护 const home = 0, // 平台规定只有 home 路由的 rank 才能为 0 ,所以后端在返回 rank 的时候需要从非 0 开始 - components = 1, - able = 2, - table = 3, - list = 4, - result = 5, - error = 6, - frame = 7, - nested = 8, - permission = 9, - system = 10, - monitor = 11, - tabs = 12, - about = 13, - editor = 14, - flowchart = 15, - formdesign = 16, - board = 17, - ppt = 18, - guide = 19, - menuoverflow = 20; + vueflow = 1, + components = 2, + able = 3, + table = 4, + list = 5, + result = 6, + error = 7, + frame = 8, + nested = 9, + permission = 10, + system = 11, + monitor = 12, + tabs = 13, + about = 14, + editor = 15, + flowchart = 16, + formdesign = 17, + board = 18, + ppt = 19, + guide = 20, + menuoverflow = 21; export { home, + vueflow, components, able, table, diff --git a/src/router/modules/vueflow.ts b/src/router/modules/vueflow.ts new file mode 100644 index 000000000..7f01685e5 --- /dev/null +++ b/src/router/modules/vueflow.ts @@ -0,0 +1,22 @@ +import { vueflow } from "@/router/enums"; + +export default { + path: "/vue-flow", + redirect: "/vue-flow/index", + meta: { + icon: "ep:set-up", + title: "vue-flow", + rank: vueflow + }, + children: [ + { + path: "/vue-flow/index", + name: "VueFlow", + component: () => import("@/views/vue-flow/layouting/index.vue"), + meta: { + title: "vue-flow", + extraIcon: "IF-pure-iconfont-new svg" + } + } + ] +} satisfies RouteConfigsTable; diff --git a/src/views/vue-flow/layouting/animationEdge.vue b/src/views/vue-flow/layouting/animationEdge.vue new file mode 100644 index 000000000..38c06520f --- /dev/null +++ b/src/views/vue-flow/layouting/animationEdge.vue @@ -0,0 +1,214 @@ + + + + + + + + + 📦 + 🚚 + + + + + + diff --git a/src/views/vue-flow/layouting/icon.vue b/src/views/vue-flow/layouting/icon.vue new file mode 100644 index 000000000..5e8c05953 --- /dev/null +++ b/src/views/vue-flow/layouting/icon.vue @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/views/vue-flow/layouting/index.vue b/src/views/vue-flow/layouting/index.vue new file mode 100644 index 000000000..884ef4b45 --- /dev/null +++ b/src/views/vue-flow/layouting/index.vue @@ -0,0 +1,214 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/views/vue-flow/layouting/initialElements.ts b/src/views/vue-flow/layouting/initialElements.ts new file mode 100644 index 000000000..caf927103 --- /dev/null +++ b/src/views/vue-flow/layouting/initialElements.ts @@ -0,0 +1,76 @@ +import type { Edge, Node } from "@vue-flow/core"; + +const position = { x: 0, y: 0 }; +const nodeType = "process"; +const edgeType = "animation"; + +export const initialNodes: Node[] = [ + { + id: "1", + position, + type: nodeType + }, + { + id: "2", + position, + type: nodeType + }, + { + id: "2a", + position, + type: nodeType + }, + { + id: "2b", + position, + type: nodeType + }, + { + id: "2c", + position, + type: nodeType + }, + { + id: "2d", + position, + type: nodeType + }, + { + id: "3", + position, + type: nodeType + }, + { + id: "4", + position, + type: nodeType + }, + { + id: "5", + position, + type: nodeType + }, + { + id: "6", + position, + type: nodeType + }, + { + id: "7", + position, + type: nodeType + } +]; + +export const initialEdges: Edge[] = [ + { id: "e1-2", source: "1", target: "2", type: edgeType, animated: true }, + { id: "e1-3", source: "1", target: "3", type: edgeType, animated: true }, + { id: "e2-2a", source: "2", target: "2a", type: edgeType, animated: true }, + { id: "e2-2b", source: "2", target: "2b", type: edgeType, animated: true }, + { id: "e2-2c", source: "2", target: "2c", type: edgeType, animated: true }, + { id: "e2c-2d", source: "2c", target: "2d", type: edgeType, animated: true }, + { id: "e3-7", source: "3", target: "4", type: edgeType, animated: true }, + { id: "e4-5", source: "4", target: "5", type: edgeType, animated: true }, + { id: "e5-6", source: "5", target: "6", type: edgeType, animated: true }, + { id: "e5-7", source: "5", target: "7", type: edgeType, animated: true } +]; diff --git a/src/views/vue-flow/layouting/processNode.vue b/src/views/vue-flow/layouting/processNode.vue new file mode 100644 index 000000000..425952a8e --- /dev/null +++ b/src/views/vue-flow/layouting/processNode.vue @@ -0,0 +1,145 @@ + + + + + + 📥 + + + + + + + {{ processLabel }} + + + + + diff --git a/src/views/vue-flow/layouting/useLayout.ts b/src/views/vue-flow/layouting/useLayout.ts new file mode 100644 index 000000000..93483a5a9 --- /dev/null +++ b/src/views/vue-flow/layouting/useLayout.ts @@ -0,0 +1,52 @@ +import dagre from "dagre"; +import { ref } from "vue"; +import { Position, useVueFlow } from "@vue-flow/core"; + +export function useLayout() { + const { findNode } = useVueFlow(); + + const graph = ref(new dagre.graphlib.Graph()); + + const previousDirection = ref("LR"); + + function layout(nodes, edges, direction) { + const dagreGraph = new dagre.graphlib.Graph(); + + graph.value = dagreGraph; + + dagreGraph.setDefaultEdgeLabel(() => ({})); + + const isHorizontal = direction === "LR"; + dagreGraph.setGraph({ rankdir: direction }); + + previousDirection.value = direction; + + for (const node of nodes) { + const graphNode = findNode(node.id); + + dagreGraph.setNode(node.id, { + width: graphNode.dimensions.width || 150, + height: graphNode.dimensions.height || 50 + }); + } + + for (const edge of edges) { + dagreGraph.setEdge(edge.source, edge.target); + } + + dagre.layout(dagreGraph); + + return nodes.map(node => { + const nodeWithPosition = dagreGraph.node(node.id); + + return { + ...node, + targetPosition: isHorizontal ? Position.Left : Position.Top, + sourcePosition: isHorizontal ? Position.Right : Position.Bottom, + position: { x: nodeWithPosition.x, y: nodeWithPosition.y } + }; + }); + } + + return { graph, layout, previousDirection }; +} diff --git a/src/views/vue-flow/layouting/useRunProcess.ts b/src/views/vue-flow/layouting/useRunProcess.ts new file mode 100644 index 000000000..9b074e7ca --- /dev/null +++ b/src/views/vue-flow/layouting/useRunProcess.ts @@ -0,0 +1,181 @@ +import { ref, toRef, toValue } from "vue"; +import { useVueFlow } from "@vue-flow/core"; + +export function useRunProcess({ graph: dagreGraph, cancelOnError = true }) { + const { updateNodeData, getConnectedEdges } = useVueFlow(); + + const graph = toRef(() => toValue(dagreGraph)); + + const isRunning = ref(false); + + const executedNodes = new Set(); + + const runningTasks = new Map(); + + const upcomingTasks = new Set(); + + async function runNode(node, isStart = false) { + if (executedNodes.has(node.id)) { + return; + } + + upcomingTasks.add(node.id); + + const incomers = getConnectedEdges(node.id).filter( + connection => connection.target === node.id + ); + + await Promise.all( + incomers.map(incomer => until(() => !incomer.data.isAnimating)) + ); + + upcomingTasks.clear(); + + if (!isRunning.value) { + return; + } + + executedNodes.add(node.id); + + updateNodeData(node.id, { + isRunning: true, + isFinished: false, + hasError: false, + isCancelled: false + }); + + const delay = Math.floor(Math.random() * 2000) + 1000; + + return new Promise(resolve => { + const timeout = setTimeout( + async () => { + const children = graph.value.successors(node.id); + + const willThrowError = Math.random() < 0.15; + + if (!isStart && willThrowError) { + updateNodeData(node.id, { isRunning: false, hasError: true }); + + if (toValue(cancelOnError)) { + await skipDescendants(node.id); + runningTasks.delete(node.id); + + // @ts-expect-error + resolve(); + return; + } + } + + updateNodeData(node.id, { isRunning: false, isFinished: true }); + + runningTasks.delete(node.id); + + if (children.length > 0) { + await Promise.all(children.map(id => runNode({ id }))); + } + + // @ts-expect-error + resolve(); + }, + isStart ? 0 : delay + ); + + runningTasks.set(node.id, timeout); + }); + } + + async function run(nodes) { + if (isRunning.value) { + return; + } + + reset(nodes); + + isRunning.value = true; + + const startingNodes = nodes.filter( + node => graph.value.predecessors(node.id)?.length === 0 + ); + + await Promise.all(startingNodes.map(node => runNode(node, true))); + + clear(); + } + + function reset(nodes) { + clear(); + + for (const node of nodes) { + updateNodeData(node.id, { + isRunning: false, + isFinished: false, + hasError: false, + isSkipped: false, + isCancelled: false + }); + } + } + + async function skipDescendants(nodeId) { + const children = graph.value.successors(nodeId); + + for (const child of children) { + updateNodeData(child, { isRunning: false, isSkipped: true }); + await skipDescendants(child); + } + } + + async function stop() { + isRunning.value = false; + + for (const nodeId of upcomingTasks) { + clearTimeout(runningTasks.get(nodeId)); + runningTasks.delete(nodeId); + // @ts-expect-error + updateNodeData(nodeId, { + isRunning: false, + isFinished: false, + hasError: false, + isSkipped: false, + isCancelled: true + }); + await skipDescendants(nodeId); + } + + for (const [nodeId, task] of runningTasks) { + clearTimeout(task); + runningTasks.delete(nodeId); + updateNodeData(nodeId, { + isRunning: false, + isFinished: false, + hasError: false, + isSkipped: false, + isCancelled: true + }); + await skipDescendants(nodeId); + } + + executedNodes.clear(); + upcomingTasks.clear(); + } + + function clear() { + isRunning.value = false; + executedNodes.clear(); + runningTasks.clear(); + } + + return { run, stop, reset, isRunning }; +} + +async function until(condition) { + return new Promise(resolve => { + const interval = setInterval(() => { + if (condition()) { + clearInterval(interval); + // @ts-expect-error + resolve(); + } + }, 100); + }); +} diff --git a/src/views/vue-flow/layouting/useShuffle.ts b/src/views/vue-flow/layouting/useShuffle.ts new file mode 100644 index 000000000..d2505080a --- /dev/null +++ b/src/views/vue-flow/layouting/useShuffle.ts @@ -0,0 +1,50 @@ +function shuffleArray(array) { + for (let i = array.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [array[i], array[j]] = [array[j], array[i]]; + } +} + +function generatePossibleEdges(nodes) { + const possibleEdges = []; + + for (const sourceNode of nodes) { + for (const targetNode of nodes) { + if (sourceNode.id !== targetNode.id) { + const edgeId = `e${sourceNode.id}-${targetNode.id}`; + possibleEdges.push({ + id: edgeId, + source: sourceNode.id, + target: targetNode.id, + type: "animation", + animated: true + }); + } + } + } + + return possibleEdges; +} + +export function useShuffle() { + return nodes => { + const possibleEdges = generatePossibleEdges(nodes); + shuffleArray(possibleEdges); + + const usedNodes = new Set(); + const newEdges = []; + + for (const edge of possibleEdges) { + if ( + !usedNodes.has(edge.target) && + (usedNodes.size === 0 || usedNodes.has(edge.source)) + ) { + newEdges.push(edge); + usedNodes.add(edge.source); + usedNodes.add(edge.target); + } + } + + return newEdges; + }; +}