Sfoglia il codice sorgente

Merge branch 'feature/task-188' into develop

cc12458 6 mesi fa
parent
commit
62ae5d2829

+ 7 - 0
@types/typed-router.d.ts

@@ -39,6 +39,13 @@ declare module 'vue-router/auto-routes' {
     '//tcmRecuperation/institution': RouteRecordInfo<'//tcmRecuperation/institution', '/tcmRecuperation/institution', Record<never, never>, Record<never, never>>,
     '//tcmRecuperation/preserve': RouteRecordInfo<'//tcmRecuperation/preserve', '/tcmRecuperation/preserve', Record<never, never>, Record<never, never>>,
     '//tcmRecuperation/system': RouteRecordInfo<'//tcmRecuperation/system', '/tcmRecuperation/system', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/': RouteRecordInfo<'/aio/flow-config/', '/aio/flow-config', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/nodes/FlowNode': RouteRecordInfo<'/aio/flow-config/nodes/FlowNode', '/aio/flow-config/nodes/FlowNode', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/nodes/FlowNodeInlay': RouteRecordInfo<'/aio/flow-config/nodes/FlowNodeInlay', '/aio/flow-config/nodes/FlowNodeInlay', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/panel/RegisterPanel': RouteRecordInfo<'/aio/flow-config/panel/RegisterPanel', '/aio/flow-config/panel/RegisterPanel', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/panel/ReportPanel': RouteRecordInfo<'/aio/flow-config/panel/ReportPanel', '/aio/flow-config/panel/ReportPanel', Record<never, never>, Record<never, never>>,
+    '/aio/flow-config/panel/StartPanel': RouteRecordInfo<'/aio/flow-config/panel/StartPanel', '/aio/flow-config/panel/StartPanel', Record<never, never>, Record<never, never>>,
+    '/aio/FlowConfigDemo': RouteRecordInfo<'/aio/FlowConfigDemo', '/aio/FlowConfigDemo', Record<never, never>, Record<never, never>>,
     '/login': RouteRecordInfo<'/login', '/login', Record<never, never>, Record<never, never>>,
   }
 }

+ 6 - 0
package.json

@@ -14,6 +14,10 @@
   },
   "dependencies": {
     "@ant-design/icons-vue": "^7.0.1",
+    "@logicflow/core": "^2.1.2",
+    "@logicflow/extension": "^2.1.4",
+    "@logicflow/layout": "^2.0.2",
+    "@logicflow/vue-node-registry": "^1.1.3",
     "@unocss/reset": "^0.61.0",
     "@vueuse/components": "^10.11.0",
     "@vueuse/core": "^10.11.0",
@@ -28,12 +32,14 @@
     "pinia-plugin-persistedstate": "^3.2.1",
     "swiper": "^8.4.7",
     "v-selectpage": "^3.0.1",
+    "vite-svg-loader": "^5.1.0",
     "vue": "^3.4.29",
     "vue-echarts": "^7.0.2",
     "vue-range-component": "^1.0.3",
     "vue-router": "^4.3.3",
     "vue-virtual-scroller": "2.0.0-beta.8",
     "vue3-slider": "^1.10.1",
+    "vuedraggable": "^4.1.0",
     "vxe-pc-ui": "^4.6.42",
     "vxe-table": "^4.7.62"
   },

+ 292 - 0
pnpm-lock.yaml

@@ -11,6 +11,18 @@ importers:
       '@ant-design/icons-vue':
         specifier: ^7.0.1
         version: 7.0.1(vue@3.5.13(typescript@5.4.5))
+      '@logicflow/core':
+        specifier: ^2.1.2
+        version: 2.1.2
+      '@logicflow/extension':
+        specifier: ^2.1.4
+        version: 2.1.4(@logicflow/core@2.1.2)(@logicflow/vue-node-registry@1.1.3(@logicflow/core@2.1.2)(vue@3.5.13(typescript@5.4.5)))
+      '@logicflow/layout':
+        specifier: ^2.0.2
+        version: 2.0.2
+      '@logicflow/vue-node-registry':
+        specifier: ^1.1.3
+        version: 1.1.3(@logicflow/core@2.1.2)(vue@3.5.13(typescript@5.4.5))
       '@unocss/reset':
         specifier: ^0.61.0
         version: 0.61.9
@@ -53,6 +65,9 @@ importers:
       v-selectpage:
         specifier: ^3.0.1
         version: 3.0.1(vue@3.5.13(typescript@5.4.5))
+      vite-svg-loader:
+        specifier: ^5.1.0
+        version: 5.1.0(vue@3.5.13(typescript@5.4.5))
       vue:
         specifier: ^3.4.29
         version: 3.5.13(typescript@5.4.5)
@@ -71,6 +86,9 @@ importers:
       vue3-slider:
         specifier: ^1.10.1
         version: 1.10.1(vue@3.5.13(typescript@5.4.5))
+      vuedraggable:
+        specifier: ^4.1.0
+        version: 4.1.0(vue@3.5.13(typescript@5.4.5))
       vxe-pc-ui:
         specifier: ^4.6.42
         version: 4.6.42(vue@3.5.13(typescript@5.4.5))
@@ -197,6 +215,9 @@ packages:
   '@antfu/utils@8.1.1':
     resolution: {integrity: sha512-Mex9nXf9vR6AhcXmMrlz/HVgYYZpVGJ6YlPgwl7UnaFpnshXs6EK/oa5Gpf3CzENMjkvEx2tQtntGnb7UtSTOQ==}
 
+  '@antv/hierarchy@0.6.14':
+    resolution: {integrity: sha512-V3uknf7bhynOqQDw2sg+9r9DwZ9pc6k/EcqyTFdfXB1+ydr7urisP0MipIuimucvQKN+Qkd+d6w601r1UIroqQ==}
+
   '@babel/code-frame@7.26.2':
     resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==}
     engines: {node: '>=6.9.0'}
@@ -863,6 +884,28 @@ packages:
   '@jridgewell/trace-mapping@0.3.25':
     resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
 
+  '@logicflow/core@2.1.2':
+    resolution: {integrity: sha512-2LW4ZMt0krcEgybkbnn1L0SzSXv02eEMAbTMKwQzBd4Bi2Si56j/OxMMLmx+dhfLNzw2g8JW/98Aj0tP8gDxcw==}
+
+  '@logicflow/extension@2.1.4':
+    resolution: {integrity: sha512-vMYwWYaNQNYruqdKnm7vcP4PiP+sIadulZfycmC+Acdn/3JYCEtAZzJOGt5gorG8F1H5vKXIXOFzwVLuuetoLQ==}
+    peerDependencies:
+      '@logicflow/core': 2.1.2
+      '@logicflow/vue-node-registry': 1.1.3
+
+  '@logicflow/layout@2.0.2':
+    resolution: {integrity: sha512-Ux47QLaxQ0nT91oA+XZ7mDZs8tbCbJuFihoT/zQQv6XsqSy6x9cPP+OxwswxqQb5HGrOIGXLM0MrEEPKQxf2VQ==}
+
+  '@logicflow/vue-node-registry@1.1.3':
+    resolution: {integrity: sha512-8xgAd8MQ1PD2ZjM8TCzHtE+nS2uRm+nkRV74wxKPk7ZL3TRj3dJFH0VdLctMxAN+ouq1QPXMsjhWBmHWYSKZKQ==}
+    peerDependencies:
+      '@logicflow/core': 2.1.2
+      '@vue/composition-api': ^1.0.0-rc.10
+      vue: ^2.0.0 || >=3.0.0
+    peerDependenciesMeta:
+      '@vue/composition-api':
+        optional: true
+
   '@nodelib/fs.scandir@2.1.5':
     resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
     engines: {node: '>= 8'}
@@ -1103,9 +1146,16 @@ packages:
     resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==}
     engines: {node: '>=18'}
 
+  '@sphinxxxx/color-conversion@2.2.2':
+    resolution: {integrity: sha512-XExJS3cLqgrmNBIP3bBw6+1oQ1ksGjFh0+oClDKFYpCCqx/hlqwWO5KO/S63fzUo67SxI9dMrF0y5T/Ey7h8Zw==}
+
   '@sxzz/popperjs-es@2.11.7':
     resolution: {integrity: sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==, tarball: https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz}
 
+  '@trysound/sax@0.2.0':
+    resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==}
+    engines: {node: '>=10.13.0'}
+
   '@tsconfig/node20@20.1.5':
     resolution: {integrity: sha512-Vm8e3WxDTqMGPU4GATF9keQAIy1Drd7bPwlgzKJnZtoOsTm1tduUTbDjg0W5qERvGuxPI2h9RbMufH0YdfBylA==}
 
@@ -1557,6 +1607,9 @@ packages:
     resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==}
     engines: {node: '>= 14.16.0'}
 
+  classnames@2.5.1:
+    resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==}
+
   color-convert@2.0.1:
     resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
     engines: {node: '>=7.0.0'}
@@ -1571,6 +1624,10 @@ packages:
     resolution: {integrity: sha512-/rFeCpNJQbhSZjGVwO9RFV3xPqbnERS8MmIQzCtD/zl6gpJuV/bMLuN92oG3F7d8oDEHHRrujSXNUr8fpjntKw==}
     engines: {node: '>=18'}
 
+  commander@7.2.0:
+    resolution: {integrity: sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw==}
+    engines: {node: '>= 10'}
+
   compute-scroll-into-view@1.0.20:
     resolution: {integrity: sha512-UCB0ioiyj8CRjtrvaceBLqqhZCVP+1B8+NWQhmdsm0VXOJtobBCf1dBQmebCCo34qZmUwZfIH2MZLqNHazrfjg==}
 
@@ -1601,18 +1658,36 @@ packages:
     resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
     engines: {node: '>= 8'}
 
+  css-select@5.2.2:
+    resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}
+
+  css-tree@2.2.1:
+    resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==}
+    engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
+
   css-tree@2.3.1:
     resolution: {integrity: sha512-6Fv1DV/TYw//QF5IzQdqsNDjx/wc8TrMBZsqjL9eW01tWb7R7k/mq+/VXfJCl7SoD5emsJop9cOByJZfs8hYIw==}
     engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
 
+  css-what@6.2.2:
+    resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==}
+    engines: {node: '>= 6'}
+
   cssesc@3.0.0:
     resolution: {integrity: sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==}
     engines: {node: '>=4'}
     hasBin: true
 
+  csso@5.0.5:
+    resolution: {integrity: sha512-0LrrStPOdJj+SPCCrGhzryycLjwcgUSHBtxNA8aIDxf0GLsRh1cKYhB00Gd1lDOS4yGH69+SNn13+TWbVHETFQ==}
+    engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'}
+
   csstype@3.1.3:
     resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
 
+  dagre@0.8.5:
+    resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==}
+
   dayjs@1.11.13:
     resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
 
@@ -1668,12 +1743,25 @@ packages:
   dom-scroll-into-view@2.0.1:
     resolution: {integrity: sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==}
 
+  dom-serializer@2.0.0:
+    resolution: {integrity: sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==}
+
   dom-zindex@1.0.6:
     resolution: {integrity: sha512-FKWIhiU96bi3xpP9ewRMgANsoVmMUBnMnmpCT6dPMZOunVYJQmJhSRruoI0XSPoHeIif3kyEuiHbFrOJwEJaEA==}
 
   dom7@4.0.6:
     resolution: {integrity: sha512-emjdpPLhpNubapLFdjNL9tP06Sr+GZkrIHEXLWvOGsytACUrkbeIdjO5g77m00BrHTznnlcNqgmn7pCN192TBA==}
 
+  domelementtype@2.3.0:
+    resolution: {integrity: sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==}
+
+  domhandler@5.0.3:
+    resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
+    engines: {node: '>= 4'}
+
+  domutils@3.2.2:
+    resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
+
   duplexer@0.1.2:
     resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==}
 
@@ -1900,6 +1988,9 @@ packages:
   graphemer@1.4.0:
     resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==}
 
+  graphlib@2.1.8:
+    resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==}
+
   gzip-size@6.0.0:
     resolution: {integrity: sha512-ax7ZYomf6jqPTQ4+XCpUGyXKHk5WweS+e05MBO4/y3WJ5RkmPXNKvX+bx1behVILVwr6JSQvZAku021CHPXG3Q==}
     engines: {node: '>=10'}
@@ -1912,6 +2003,9 @@ packages:
     resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==}
     hasBin: true
 
+  hoist-non-react-statics@2.5.5:
+    resolution: {integrity: sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==}
+
   hookable@5.5.3:
     resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
 
@@ -2101,9 +2195,15 @@ packages:
   magic-string@0.30.17:
     resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
 
+  mdn-data@2.0.28:
+    resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==}
+
   mdn-data@2.0.30:
     resolution: {integrity: sha512-GaqWWShW4kv/G9IEucWScBx9G1/vsFZZJUO+tD26M8J8z3Kw5RDQjaoZe03YAClgeS/SWPOcb4nkFBTEi5DUEA==}
 
+  medium-editor@5.23.3:
+    resolution: {integrity: sha512-he9/TdjX8f8MGdXGfCs8AllrYnqXJJvjNkDKmPg3aPW/uoIrlRqtkFthrwvmd+u4QyzEiadhCCM0EwTiRdUCJw==}
+
   memoize-one@6.0.0:
     resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
 
@@ -2135,10 +2235,27 @@ packages:
   mlly@1.7.4:
     resolution: {integrity: sha512-qmdSIPC4bDJXgZTCR7XosJiNKySV7O215tsPtDN9iEO/7q/76b/ijtgRu/+epFXSJhijtTCCGp3DWS549P3xKw==}
 
+  mobx-preact@3.0.0:
+    resolution: {integrity: sha512-ijan/cBs3WmRye87E5+3JmoFBB00KDAwNA3pm7bMwYLPHBAXlN86aC3gdrXw8aKzM5RI8V3a993PphzPv6P4FA==}
+    peerDependencies:
+      mobx: 5.x
+      preact: '>=8'
+
+  mobx-utils@5.6.2:
+    resolution: {integrity: sha512-a/WlXyGkp6F12b01sTarENpxbmlRgPHFyR1Xv2bsSjQBm5dcOtd16ONb40/vOqck8L99NHpI+C9MXQ+SZ8f+yw==}
+    peerDependencies:
+      mobx: ^4.13.1 || ^5.13.1
+
+  mobx@5.15.7:
+    resolution: {integrity: sha512-wyM3FghTkhmC+hQjyPGGFdpehrcX1KOXsDuERhfK2YbJemkUhEB+6wzEN639T21onxlfYBmriA1PFnvxTUhcKw==}
+
   mockjs@1.1.0:
     resolution: {integrity: sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ==}
     hasBin: true
 
+  mousetrap@1.6.5:
+    resolution: {integrity: sha512-QNo4kEepaIBwiT8CDhP98umTetp+JNfQYBWvC1pc6/OAibuXtRcxZ58Qz8skvEHYvURne/7R8T5VoOI7rDsEUA==}
+
   mrmime@2.0.1:
     resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==}
     engines: {node: '>=10'}
@@ -2306,6 +2423,9 @@ packages:
     resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==}
     engines: {node: ^10 || ^12 || >=14}
 
+  preact@10.27.2:
+    resolution: {integrity: sha512-5SYSgFKSyhCbk6SrXyMpqjb5+MQBgfvEKE/OC+PujcY34sOpqtr+0AZQtPYx5IA6VxynQ7rUPCtKzyovpj9Bpg==}
+
   prelude-ls@1.2.1:
     resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
     engines: {node: '>= 0.8.0'}
@@ -2333,6 +2453,9 @@ packages:
   queue-microtask@1.2.3:
     resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
 
+  rangy@1.3.2:
+    resolution: {integrity: sha512-fS1C4MOyk8T+ZJZdLcgrukPWxkyDXa+Hd2Kj+Zg4wIK71yrWgmjzHubzPMY1G+WD9EgGxMp3fIL0zQ1ickmSWA==}
+
   rate-limiter-flexible@5.0.5:
     resolution: {integrity: sha512-+/dSQfo+3FYwYygUs/V2BBdwGa9nFtakDwKt4l0bnvNB53TNT++QSFewwHX9qXrZJuMe9j+TUaU21lm5ARgqdQ==}
 
@@ -2436,6 +2559,9 @@ packages:
     resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==}
     engines: {node: '>=8'}
 
+  sortablejs@1.14.0:
+    resolution: {integrity: sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==}
+
   source-map-js@1.2.1:
     resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
     engines: {node: '>=0.10.0'}
@@ -2473,6 +2599,11 @@ packages:
     resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
     engines: {node: '>=8'}
 
+  svgo@3.3.2:
+    resolution: {integrity: sha512-OoohrmuUlBs8B8o6MB2Aevn+pRIH9zDALSR+6hhqVfa6fRwG/Qw9VUMSMW9VNg2CFc/MTIfabtdOVl9ODIJjpw==}
+    engines: {node: '>=14.0.0'}
+    hasBin: true
+
   swiper@8.4.7:
     resolution: {integrity: sha512-VwO/KU3i9IV2Sf+W2NqyzwWob4yX9Qdedq6vBtS0rFqJ6Fa5iLUJwxQkuD4I38w0WDJwmFl8ojkdcRFPHWD+2g==}
     engines: {node: '>= 4.7.0'}
@@ -2614,6 +2745,10 @@ packages:
   util-deprecate@1.0.2:
     resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==}
 
+  uuid@9.0.1:
+    resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
+    hasBin: true
+
   v-dropdown@3.0.0:
     resolution: {integrity: sha512-sDBiHhTjm0/eLmC83vUbOIHXNTlQD30w+usrh779OG+aNc/5vqyFQbSBPGekvD7pQPbWSPc5esVT8jcqfLRnrg==}
     peerDependencies:
@@ -2624,6 +2759,9 @@ packages:
     peerDependencies:
       vue: ^3.2.0
 
+  vanilla-picker@2.12.3:
+    resolution: {integrity: sha512-qVkT1E7yMbUsB2mmJNFmaXMWE2hF8ffqzMMwe9zdAikd8u2VfnsVY2HQcOUi2F38bgbxzlJBEdS1UUhOXdF9GQ==}
+
   vite-hot-client@0.2.4:
     resolution: {integrity: sha512-a1nzURqO7DDmnXqabFOliz908FRmIppkBKsJthS8rbe8hBEXwEwe4C3Pp33Z1JoFCYfVL4kTOMLKk0ZZxREIeA==}
     peerDependencies:
@@ -2653,6 +2791,11 @@ packages:
     peerDependencies:
       vite: ^3.0.0-0 || ^4.0.0-0 || ^5.0.0-0 || ^6.0.0-0
 
+  vite-svg-loader@5.1.0:
+    resolution: {integrity: sha512-M/wqwtOEjgb956/+m5ZrYT/Iq6Hax0OakWbokj8+9PXOnB7b/4AxESHieEtnNEy7ZpjsjYW1/5nK8fATQMmRxw==}
+    peerDependencies:
+      vue: '>=3.2.13'
+
   vite@5.4.16:
     resolution: {integrity: sha512-Y5gnfp4NemVfgOTDQAunSD4346fal44L9mszGGY/e+qxsRT5y1sMlS/8tiQ8AFAp+MFgYNSINdfEchJiPm41vQ==}
     engines: {node: ^18.0.0 || >=20.0.0}
@@ -2774,6 +2917,11 @@ packages:
       typescript:
         optional: true
 
+  vuedraggable@4.1.0:
+    resolution: {integrity: sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==}
+    peerDependencies:
+      vue: ^3.0.1
+
   vxe-pc-ui@4.6.42:
     resolution: {integrity: sha512-grBaVbagoc5rbTq2jj1P/cWYP+sBo+VSXFRpNGYOe9Ka4EG9JP+LIa7h0lKfojDE5fGlPUYTkkYNe0fsQVDQ0g==}
 
@@ -2867,6 +3015,8 @@ snapshots:
 
   '@antfu/utils@8.1.1': {}
 
+  '@antv/hierarchy@0.6.14': {}
+
   '@babel/code-frame@7.26.2':
     dependencies:
       '@babel/helper-validator-identifier': 7.25.9
@@ -3388,6 +3538,42 @@ snapshots:
       '@jridgewell/resolve-uri': 3.1.2
       '@jridgewell/sourcemap-codec': 1.5.0
 
+  '@logicflow/core@2.1.2':
+    dependencies:
+      classnames: 2.5.1
+      lodash-es: 4.17.21
+      mobx: 5.15.7
+      mobx-preact: 3.0.0(mobx@5.15.7)(preact@10.27.2)
+      mobx-utils: 5.6.2(mobx@5.15.7)
+      mousetrap: 1.6.5
+      preact: 10.27.2
+      uuid: 9.0.1
+
+  '@logicflow/extension@2.1.4(@logicflow/core@2.1.2)(@logicflow/vue-node-registry@1.1.3(@logicflow/core@2.1.2)(vue@3.5.13(typescript@5.4.5)))':
+    dependencies:
+      '@antv/hierarchy': 0.6.14
+      '@logicflow/core': 2.1.2
+      '@logicflow/vue-node-registry': 1.1.3(@logicflow/core@2.1.2)(vue@3.5.13(typescript@5.4.5))
+      classnames: 2.5.1
+      lodash-es: 4.17.21
+      medium-editor: 5.23.3
+      mobx: 5.15.7
+      preact: 10.27.2
+      rangy: 1.3.2
+      vanilla-picker: 2.12.3
+
+  '@logicflow/layout@2.0.2':
+    dependencies:
+      '@logicflow/core': 2.1.2
+      dagre: 0.8.5
+
+  '@logicflow/vue-node-registry@1.1.3(@logicflow/core@2.1.2)(vue@3.5.13(typescript@5.4.5))':
+    dependencies:
+      '@logicflow/core': 2.1.2
+      lodash-es: 4.17.21
+      vue: 3.5.13(typescript@5.4.5)
+      vue-demi: 0.14.10(vue@3.5.13(typescript@5.4.5))
+
   '@nodelib/fs.scandir@2.1.5':
     dependencies:
       '@nodelib/fs.stat': 2.0.5
@@ -3544,8 +3730,12 @@ snapshots:
 
   '@sindresorhus/merge-streams@4.0.0': {}
 
+  '@sphinxxxx/color-conversion@2.2.2': {}
+
   '@sxzz/popperjs-es@2.11.7': {}
 
+  '@trysound/sax@0.2.0': {}
+
   '@tsconfig/node20@20.1.5': {}
 
   '@types/estree@1.0.7': {}
@@ -4204,6 +4394,8 @@ snapshots:
     dependencies:
       readdirp: 4.1.2
 
+  classnames@2.5.1: {}
+
   color-convert@2.0.1:
     dependencies:
       color-name: 1.1.4
@@ -4214,6 +4406,8 @@ snapshots:
 
   commander@13.1.0: {}
 
+  commander@7.2.0: {}
+
   compute-scroll-into-view@1.0.20: {}
 
   concat-map@0.0.1: {}
@@ -4238,15 +4432,39 @@ snapshots:
       shebang-command: 2.0.0
       which: 2.0.2
 
+  css-select@5.2.2:
+    dependencies:
+      boolbase: 1.0.0
+      css-what: 6.2.2
+      domhandler: 5.0.3
+      domutils: 3.2.2
+      nth-check: 2.1.1
+
+  css-tree@2.2.1:
+    dependencies:
+      mdn-data: 2.0.28
+      source-map-js: 1.2.1
+
   css-tree@2.3.1:
     dependencies:
       mdn-data: 2.0.30
       source-map-js: 1.2.1
 
+  css-what@6.2.2: {}
+
   cssesc@3.0.0: {}
 
+  csso@5.0.5:
+    dependencies:
+      css-tree: 2.2.1
+
   csstype@3.1.3: {}
 
+  dagre@0.8.5:
+    dependencies:
+      graphlib: 2.1.8
+      lodash: 4.17.21
+
   dayjs@1.11.13: {}
 
   de-indent@1.0.2: {}
@@ -4285,12 +4503,30 @@ snapshots:
 
   dom-scroll-into-view@2.0.1: {}
 
+  dom-serializer@2.0.0:
+    dependencies:
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+      entities: 4.5.0
+
   dom-zindex@1.0.6: {}
 
   dom7@4.0.6:
     dependencies:
       ssr-window: 4.0.2
 
+  domelementtype@2.3.0: {}
+
+  domhandler@5.0.3:
+    dependencies:
+      domelementtype: 2.3.0
+
+  domutils@3.2.2:
+    dependencies:
+      dom-serializer: 2.0.0
+      domelementtype: 2.3.0
+      domhandler: 5.0.3
+
   duplexer@0.1.2: {}
 
   echarts@5.6.0:
@@ -4639,6 +4875,10 @@ snapshots:
 
   graphemer@1.4.0: {}
 
+  graphlib@2.1.8:
+    dependencies:
+      lodash: 4.17.21
+
   gzip-size@6.0.0:
     dependencies:
       duplexer: 0.1.2
@@ -4647,6 +4887,8 @@ snapshots:
 
   he@1.2.0: {}
 
+  hoist-non-react-statics@2.5.5: {}
+
   hookable@5.5.3: {}
 
   human-signals@8.0.1: {}
@@ -4803,8 +5045,12 @@ snapshots:
     dependencies:
       '@jridgewell/sourcemap-codec': 1.5.0
 
+  mdn-data@2.0.28: {}
+
   mdn-data@2.0.30: {}
 
+  medium-editor@5.23.3: {}
+
   memoize-one@6.0.0: {}
 
   memorystream@0.3.1: {}
@@ -4835,10 +5081,24 @@ snapshots:
       pkg-types: 1.3.1
       ufo: 1.5.4
 
+  mobx-preact@3.0.0(mobx@5.15.7)(preact@10.27.2):
+    dependencies:
+      hoist-non-react-statics: 2.5.5
+      mobx: 5.15.7
+      preact: 10.27.2
+
+  mobx-utils@5.6.2(mobx@5.15.7):
+    dependencies:
+      mobx: 5.15.7
+
+  mobx@5.15.7: {}
+
   mockjs@1.1.0:
     dependencies:
       commander: 13.1.0
 
+  mousetrap@1.6.5: {}
+
   mrmime@2.0.1: {}
 
   ms@2.1.3: {}
@@ -4993,6 +5253,8 @@ snapshots:
       picocolors: 1.1.1
       source-map-js: 1.2.1
 
+  preact@10.27.2: {}
+
   prelude-ls@1.2.1: {}
 
   prettier-linter-helpers@1.0.0:
@@ -5011,6 +5273,8 @@ snapshots:
 
   queue-microtask@1.2.3: {}
 
+  rangy@1.3.2: {}
+
   rate-limiter-flexible@5.0.5: {}
 
   read-package-json-fast@3.0.2:
@@ -5116,6 +5380,8 @@ snapshots:
 
   slash@3.0.0: {}
 
+  sortablejs@1.14.0: {}
+
   source-map-js@1.2.1: {}
 
   speakingurl@14.0.1: {}
@@ -5144,6 +5410,16 @@ snapshots:
     dependencies:
       has-flag: 4.0.0
 
+  svgo@3.3.2:
+    dependencies:
+      '@trysound/sax': 0.2.0
+      commander: 7.2.0
+      css-select: 5.2.2
+      css-tree: 2.3.1
+      css-what: 6.2.2
+      csso: 5.0.5
+      picocolors: 1.1.1
+
   swiper@8.4.7:
     dependencies:
       dom7: 4.0.6
@@ -5331,6 +5607,8 @@ snapshots:
 
   util-deprecate@1.0.2: {}
 
+  uuid@9.0.1: {}
+
   v-dropdown@3.0.0(vue@3.5.13(typescript@5.4.5)):
     dependencies:
       vue: 3.5.13(typescript@5.4.5)
@@ -5340,6 +5618,10 @@ snapshots:
       v-dropdown: 3.0.0(vue@3.5.13(typescript@5.4.5))
       vue: 3.5.13(typescript@5.4.5)
 
+  vanilla-picker@2.12.3:
+    dependencies:
+      '@sphinxxxx/color-conversion': 2.2.2
+
   vite-hot-client@0.2.4(vite@5.4.16(@types/node@20.17.29)(sass@1.86.1)):
     dependencies:
       vite: 5.4.16(@types/node@20.17.29)(sass@1.86.1)
@@ -5398,6 +5680,11 @@ snapshots:
     transitivePeerDependencies:
       - supports-color
 
+  vite-svg-loader@5.1.0(vue@3.5.13(typescript@5.4.5)):
+    dependencies:
+      svgo: 3.3.2
+      vue: 3.5.13(typescript@5.4.5)
+
   vite@5.4.16(@types/node@20.17.29)(sass@1.86.1):
     dependencies:
       esbuild: 0.21.5
@@ -5488,6 +5775,11 @@ snapshots:
     optionalDependencies:
       typescript: 5.4.5
 
+  vuedraggable@4.1.0(vue@3.5.13(typescript@5.4.5)):
+    dependencies:
+      sortablejs: 1.14.0
+      vue: 3.5.13(typescript@5.4.5)
+
   vxe-pc-ui@4.6.42(vue@3.5.13(typescript@5.4.5)):
     dependencies:
       '@vxe-ui/core': 4.1.5(vue@3.5.13(typescript@5.4.5))

+ 78 - 0
src/libs/logic-flow/VLogicFlow.vue

@@ -0,0 +1,78 @@
+<script setup lang="ts">
+import { tryOnMounted } from '@vueuse/core';
+import { LogicFlow } from '@logicflow/core';
+
+import { provideLogicFlow, tryLogicFlow } from './use';
+import { EndNode, StartNode } from './nodes';
+import { PolylineEdge } from './edges';
+
+import type { LogicFlowInstance, LogicFlowOptions } from './types';
+
+defineOptions({
+  name: 'VLogicFlow',
+});
+const props = defineProps<{ options?: LogicFlowOptions; loading?: boolean }>();
+const emits = defineEmits<{ loaded: [LogicFlowInstance] }>();
+const containerRef = useTemplateRef('container');
+tryOnMounted(() => {
+  const options = {
+    ...props.options,
+    parentTransform: tryLogicFlow()?.graphModel.transformModel,
+    container: containerRef.value!,
+  };
+  const lf = new LogicFlow(options);
+  lf.register(PolylineEdge);
+  lf.register(StartNode);
+  lf.register(EndNode);
+  lf.setDefaultEdgeType('custom-polyline-edge');
+  provideLogicFlow(lf);
+  emits('loaded', lf);
+});
+</script>
+
+<template>
+  <div class="v-logic-flow-wrapper">
+    <a-spin :spinning="props.loading" :delay="200">
+      <div class="v-logic-flow" v-bind="$attrs">
+        <main ref="container"></main>
+        <slot name="top"></slot>
+        <slot name="panel" v-if="!props.loading"></slot>
+        <slot name="float" v-if="!props.loading"></slot>
+      </div>
+    </a-spin>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.v-logic-flow-wrapper {
+  :deep(.ant-spin-nested-loading) {
+    position: relative;
+    height: 100%;
+    .ant-spin {
+      max-height: 100%;
+    }
+
+    > div {
+      position: absolute;
+      top: 0;
+      right: 0;
+      bottom: 0;
+      left: 0;
+    }
+  }
+}
+.v-logic-flow {
+  width: 100%;
+  height: 100%;
+  position: relative;
+  > main {
+    width: 100%;
+    height: 100%;
+  }
+}
+.panel-wrapper {
+  position: absolute;
+  top: 0;
+  left: 0;
+}
+</style>

+ 2 - 0
src/libs/logic-flow/constant.ts

@@ -0,0 +1,2 @@
+export const START_ID = `start`;
+export const END_ID = `end`;

+ 5 - 0
src/libs/logic-flow/edges/index.ts

@@ -0,0 +1,5 @@
+import __polyline from './polyline';
+
+export {
+  __polyline as PolylineEdge,
+}

+ 22 - 0
src/libs/logic-flow/edges/polyline.ts

@@ -0,0 +1,22 @@
+import { PolylineEdge as __PolylineEdge, PolylineEdgeModel as __PolylineEdgeModel } from '@logicflow/core';
+
+class PolylineEdgeModel extends __PolylineEdgeModel {
+  getEdgeAnimationStyle() {
+    const style = super.getEdgeAnimationStyle();
+    style.strokeDasharray = '5 5';
+    style.strokeDashoffset = '100%';
+    style.animationDuration = '10s';
+    return style;
+  }
+  setProperties(properties: Record<string, any>) {
+    super.setProperties(properties);
+    if ('id' in properties) this.id = properties.id;
+    if ('isAnimation' in properties) this.isAnimation = properties.isAnimation;
+  }
+}
+
+export default {
+  type: 'custom-polyline-edge',
+  model: PolylineEdgeModel,
+  view: __PolylineEdge,
+};

+ 64 - 0
src/libs/logic-flow/index.ts

@@ -0,0 +1,64 @@
+import { type CallbackArgs, type EventArgs, type EventCallback, LogicFlow } from '@logicflow/core';
+import { register as __register } from '@logicflow/vue-node-registry';
+import type { Dagre } from '@logicflow/layout';
+
+import '@logicflow/core/lib/style/index.css';
+import '@logicflow/extension/es/index.css';
+import '@logicflow/extension/lib/style/index.css';
+
+import VLogicFlow from './VLogicFlow.vue';
+import { useEventListener } from './use';
+import type { LogicFlowInstance } from './types';
+
+export type * from './types';
+export { VLogicFlow };
+
+export type GraphData = LogicFlow.GraphConfigData;
+
+export default function init(
+  lf: LogicFlowInstance,
+  config?: {
+    register: { category: 'node' | 'edge'; type: string; view: any; model: any }[];
+    graph?: GraphData;
+  }
+) {
+  const register = (type: string, component: Component, model?: any) => __register({ type, component, model }, lf);
+  const listener = <T extends keyof EventArgs>(event: T, callback: EventCallback<T>, predicate?: (event: CallbackArgs<T>) => boolean, once?: boolean) =>
+    useEventListener(lf.graphModel.eventCenter, event, callback, predicate, once);
+  const getDagre = () => lf.extension.dagre as Dagre;
+
+  for (const { category, type, view, model } of config?.register ?? []) {
+    if (category === 'node') register(type, view, model);
+  }
+  lf.renderRawData(config?.graph ?? {});
+
+  let delayClick: ReturnType<typeof setTimeout>;
+  listener('edge:dbclick', (event) => {
+    lf.deleteEdge(event.data.id);
+  });
+  listener('node:dbclick', (event) => {
+    clearTimeout(delayClick);
+    lf.deleteNode(event.data.id);
+  });
+  listener('node:click', (event) => {
+    delayClick = setTimeout(() => {
+      lf.graphModel.eventCenter.emit('node:click:300', event);
+    }, 300);
+  });
+
+  /**
+   * 增强功能
+   * @description 渐进连线成功触发 [edge:proximity-connect] 事件
+   */
+  let dropAddEdgeTime = 0;
+  listener('node:drop', () => {
+    if (Date.now() - dropAddEdgeTime < 100) lf.graphModel.eventCenter.emit('edge:proximity-connect', {});
+  });
+  listener('edge:add', () => {
+    dropAddEdgeTime = Date.now();
+  });
+
+  return { lf, register, getDagre, listener };
+}
+
+export type VLogicFlowInstance = ReturnType<typeof init>;

+ 48 - 0
src/libs/logic-flow/nodes/end.ts

@@ -0,0 +1,48 @@
+import { RectNode, RectNodeModel } from '@logicflow/core';
+import { START_ID } from '../constant';
+
+class EndNodeModel extends RectNodeModel {
+  getDefaultAnchor() {
+    return super.getDefaultAnchor().map((anchor) => Object.assign(anchor, { type: 'incoming' }));
+  }
+
+  getConnectedSourceRules() {
+    const rules = super.getConnectedSourceRules();
+    rules.push({
+      message: '结束节点不允许输出',
+      validate: () => false,
+    });
+    return rules;
+  }
+
+  getConnectedTargetRules() {
+    const rules = super.getConnectedTargetRules();
+    rules.push({
+      message: `当前节点不是输出锚点`,
+      validate: (source, target, sourceAnchor, targetAnchor) => {
+        return !sourceAnchor?.type || sourceAnchor?.type === 'outgoing';
+      }
+    })
+
+    rules.push({
+      message: '禁止直接连接开始节点',
+      validate: (source, target, sourceAnchor, targetAnchor) => {
+        return !source || source.id !== START_ID;
+      },
+    });
+
+    rules.push({
+      message: `当前节点和目标节点已存在连接`,
+      validate: (source, target) => {
+        return !target?.graphModel.getNodeIncomingNode(target?.id).find(node => node.id === source?.id)
+      }
+    })
+    return rules;
+  }
+}
+
+export default {
+  type: 'EndNode',
+  model: EndNodeModel,
+  view: RectNode,
+};

+ 8 - 0
src/libs/logic-flow/nodes/index.ts

@@ -0,0 +1,8 @@
+import __start from './start';
+import __end from './end';
+
+
+export {
+  __start as StartNode,
+  __end as EndNode,
+}

+ 34 - 0
src/libs/logic-flow/nodes/start.ts

@@ -0,0 +1,34 @@
+import { RectNode, RectNodeModel } from '@logicflow/core';
+
+class StartNodeModel extends RectNodeModel {
+  getDefaultAnchor() {
+    return super.getDefaultAnchor().map((anchor) => Object.assign(anchor, { type: 'outgoing' }));
+  }
+
+  getConnectedSourceRules() {
+    const rules = super.getConnectedSourceRules();
+    rules.push({
+      message: '开始节点仅允许1个输出',
+      validate: (source) => {
+        const edge = this.graphModel.getNodeOutgoingEdge(source!.id);
+        return !edge.length;
+      },
+    });
+    return rules;
+  }
+
+  getConnectedTargetRules() {
+    const rules = super.getConnectedTargetRules();
+    rules.push({
+      message: '开始节点不允许输入',
+      validate: () => false,
+    });
+    return rules;
+  }
+}
+
+export default {
+  type: 'StartNode',
+  model: StartNodeModel,
+  view: RectNode,
+};

+ 9 - 0
src/libs/logic-flow/types.ts

@@ -0,0 +1,9 @@
+import type { LogicFlow, Options } from '@logicflow/core';
+import { LogicFlow as LogicFlowClass } from '@logicflow/core';
+
+export type LogicFlowInstance = InstanceType<typeof LogicFlowClass>;
+export type LogicFlowOptions = Partial<Options.Common>;
+
+export type LogicFlowNodeProperties = LogicFlow.PropertiesType;
+export type LogicFlowNode<P extends LogicFlowNodeProperties = LogicFlowNodeProperties> = LogicFlow.NodeConfig<P>;
+export type LogicFlowGraphData = LogicFlow.GraphConfigData;

+ 45 - 0
src/libs/logic-flow/use.ts

@@ -0,0 +1,45 @@
+import type { LogicFlowInstance } from './types';
+import type { CallbackArgs, EventArgs, EventCallback, EventEmitter } from '@logicflow/core';
+import { tryOnScopeDispose } from '@vueuse/core';
+
+const symbol = Symbol('logicFlow') as InjectionKey<LogicFlowInstance>;
+
+export function provideLogicFlow(lf: LogicFlowInstance) {
+  provide(symbol, lf);
+}
+
+export function useLogicFlow(): LogicFlowInstance {
+  const lf = inject(symbol, null);
+  if (!lf) throw new Error('LogicFlow instance is not provided. Please ensure you have called provideLogicFlow before using useLogicFlow.');
+  return lf;
+}
+
+export function tryLogicFlow(): LogicFlowInstance | null {
+  try {
+    return useLogicFlow();
+  } catch (error) {
+    return null;
+  }
+}
+
+export function useEventListener<T extends keyof EventArgs>(
+  emitter: EventEmitter,
+  event: T,
+  callback: EventCallback<T>,
+  predicate?: (event: CallbackArgs<T>) => boolean,
+  once?: boolean
+): () => void {
+  const cleanups: Function[] = [];
+  const cleanup = () => {
+    cleanups.forEach((fn) => fn());
+    cleanups.length = 0;
+  };
+
+  const cb: EventCallback<T> = typeof predicate === 'function' ? (event) => (predicate(event) ?? true) && callback(event) : callback;
+
+  emitter.on(event, cb, once);
+  cleanups.push(() => emitter.off(event, cb));
+
+  tryOnScopeDispose(cleanup);
+  return () => cleanup();
+}

File diff suppressed because it is too large
+ 0 - 0
src/pages/aio/flow-config/assets/config.svg


File diff suppressed because it is too large
+ 0 - 0
src/pages/aio/flow-config/assets/pulse.svg


+ 1 - 0
src/pages/aio/flow-config/assets/questionnaire.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1760325595137" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="13876" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M895.946667 734.048l1.066666 1.013333a29.824 29.824 0 0 1 0 43.413334l-162.261333 152.96a31.925333 31.925333 0 0 1-22.762667 8.704 31.925333 31.925333 0 0 1-22.773333-8.704l-93.184-87.84a29.824 29.824 0 0 1 0-43.413334l1.077333-1.013333a32 32 0 0 1 43.904 0l70.976 66.901333 140.053334-132.021333a32 32 0 0 1 43.904 0zM768 85.333333c64.8 0 117.333333 52.533333 117.333333 117.333334v394.666666a32 32 0 0 1-64 0V202.666667a53.333333 53.333333 0 0 0-53.333333-53.333334H256a53.333333 53.333333 0 0 0-53.333333 53.333334v618.666666a53.333333 53.333333 0 0 0 53.333333 53.333334h234.666667a32 32 0 0 1 0 64H256c-64.8 0-117.333333-52.533333-117.333333-117.333334V202.666667c0-64.8 52.533333-117.333333 117.333333-117.333334zM554.666667 544a32 32 0 0 1 0 64H341.333333a32 32 0 0 1 0-64z m128-170.666667a32 32 0 0 1 0 64H341.333333a32 32 0 0 1 0-64z" p-id="13877"></path></svg>

+ 1 - 0
src/pages/aio/flow-config/assets/report.svg

@@ -0,0 +1 @@
+<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1760324635575" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="9612" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M351.3 669.7h62.4v163.8h-62.4z m-87.6 53.5h62.4v110.3h-62.4z m175-24.6h62.4v134.9h-62.4z m87.4-67.5h62.4v202.4h-62.4z m87.3 38.6h62.4v163.8h-62.4z m87.4-96.4h62.4v260.2h-62.4zM360.8 649.5L335 621.1l85.5-79.1 53.9 39.2 88.6-79.5 71.1 40.5 118-102.2 24.9 29.1-138.4 120-70.6-40.4-91 81.8-53.6-38.9z" p-id="9613"></path><path d="M744.4 1024H92V0h707.6C872.6 0 932 57.1 932 127.2v650.5h-38.2V127.2c0-48.9-42.3-88.7-94.2-88.7H130.2v947h614.2v38.5z" p-id="9614"></path><path d="M197.6 141.6h50V191h-50z m0 115.3h50v49.4h-50z m100-98.8h483.3V191H297.6z m0 115.3h383.3v32.9H297.6z m-100 98.8h50v49.4h-50z m100 16.5h299.9v32.9H297.6z" p-id="9615"></path></svg>

File diff suppressed because it is too large
+ 0 - 0
src/pages/aio/flow-config/assets/start.svg


File diff suppressed because it is too large
+ 0 - 0
src/pages/aio/flow-config/assets/tongue.svg


+ 5 - 0
src/pages/aio/flow-config/index.ts

@@ -0,0 +1,5 @@
+import __index_vue from './index.vue';
+
+export { __index_vue as AioFlowConfig };
+export { analysisRequestData } from './tool';
+export type { FlowRequestData } from './tool';

+ 394 - 0
src/pages/aio/flow-config/index.vue

@@ -0,0 +1,394 @@
+<script setup lang="ts">
+import { h } from 'vue';
+
+import { tryOnUnmounted, useParentElement } from '@vueuse/core';
+import { VxeUI } from 'vxe-pc-ui';
+
+import { notification } from 'ant-design-vue';
+import { ArrowDownOutlined, ArrowRightOutlined, CloseOutlined, FullscreenExitOutlined, MenuOutlined, SearchOutlined, ToolOutlined } from '@ant-design/icons-vue';
+
+import type { CallbackArgs } from '@logicflow/core';
+import { ProximityConnect } from '@logicflow/extension';
+import { Dagre } from '@logicflow/layout';
+import VLogicFlowInit, { type LogicFlowInstance, type LogicFlowOptions, VLogicFlow, type VLogicFlowInstance } from '@/libs/logic-flow';
+
+import { FlowNodeComponent, type FlowNodeProps, FlowNodeView, FlowNodeViewModel, Node } from './nodes';
+import { type FlowRequestData, fromFlowRequestData, type Gather, toFlowRequestData } from './tool';
+import RegisterPanel from './panel/RegisterPanel.vue';
+import ReportPanel from './panel/ReportPanel.vue';
+
+import { withResolvers } from '@/tools/promise';
+import StartPanel from '@/pages/aio/flow-config/panel/StartPanel.vue';
+
+defineOptions({
+  name: 'FlowConfig',
+});
+
+const requestData = defineModel<FlowRequestData>('requestData', {
+  default: () => ({}),
+});
+
+const options = reactive<LogicFlowOptions>({
+  grid: { visible: true, size: 20, type: 'mesh', config: { color: '#ababab', thickness: 1 } },
+  snapToGrid: true,
+  snapline: false,
+  textEdit: false,
+  plugins: [Dagre, ProximityConnect],
+  pluginsOptions: {
+    proximityConnect: {
+      enable: true, // 插件是否启用
+      distance: 100, // 渐进连线阈值
+      reverseDirection: true, // 连线方向
+    },
+  },
+});
+const nodes = ref<string[]>([]);
+const nodeGroup = shallowRef<FlowNodeProps[][]>([]);
+
+const parentRef = useParentElement();
+const el = computed(() => parentRef.value?.querySelector(`.v-logic-flow`));
+
+const scope = effectScope();
+let instance!: VLogicFlowInstance;
+const init = (lf: LogicFlowInstance): void => {
+  instance = VLogicFlowInit(lf, {
+    register: [{ category: 'node', type: 'FlowNode', view: FlowNodeView, model: FlowNodeViewModel }],
+  });
+
+  scope.run(() => {
+    watchPostEffect(() => {
+      const value = requestData.value;
+      console.log('[AioFlowConfig] 接收 request-data 数据', value);
+      update(value);
+    });
+  });
+
+  instance.listener('connection:not-allowed', (event) => {
+    notification.warning({
+      key: 'connection:not-allowed',
+      message: '无法连接节点',
+      description: event.msg,
+      top: '12px',
+      getContainer: () => (el.value as HTMLElement) ?? document.body,
+    });
+  });
+  // @ts-ignore
+  instance.listener('node:click:300', (event) => openNodeConfig(event.data));
+  instance.listener('node:properties-change', (event) => {
+    if (event.keys.includes('requestData')) {
+      // 更新面板属性
+      for (const group of nodeGroup.value) {
+        const node = group.find((node) => node.id === event.id);
+        if (node) {
+          Object.assign(node.properties!, event.properties);
+          break;
+        }
+      }
+    }
+  });
+  // @ts-ignore
+  instance.listener('node:add,node:dnd-add', (event: CallbackArgs<'data'>) => {
+    dragPanelNodeId.value = '';
+    // 判断是否存在节点
+    const length = instance.lf.getGraphRawData().nodes.filter((node) => Node.formatText(node) === Node.formatText(event.data)).length;
+    if (length > 1) {
+      notification.warning({
+        key: 'node:add',
+        message: '无法添加节点',
+        description: `已存在 [${Node.formatText(event.data)}] 节点`,
+        top: '12px',
+        getContainer: () => (el.value as HTMLElement) ?? document.body,
+      });
+      instance.lf.deleteNode(event.data.id);
+      return;
+    }
+    if (!nodes.value.includes(event.data.id)) nodes.value.push(event.data.id);
+  });
+  instance.listener('node:delete', (event) => {
+    if ([Node.ID_Start, Node.ID_End].includes(event.data.id)) return instance.lf.addNode(event.data);
+
+    const index = nodes.value.indexOf(event.data.id);
+    if (index > -1) nodes.value.splice(index, 1);
+  });
+  // @ts-ignore
+  instance.listener('edge:proximity-connect', () => {
+    updateLayout();
+  });
+};
+
+const updateLayout = (dir?: 'LR' | 'TB' | 'center') => {
+  if (dir === 'center') {
+    instance.lf.fitView(100, 0);
+    instance.lf.translateCenter();
+  } else {
+    const dagre = instance?.getDagre();
+    dagre?.layout({
+      // @ts-ignore
+      rankdir: dir ?? dagre?.option?.['rankdir'],
+      align: 'UL',
+      ranker: 'longest-path',
+      nodesep: 60,
+      ranksep: 80,
+      acyclicer: 'greedy',
+      isDefaultAnchor: true,
+    });
+
+    if (instance.lf.getGraphRawData().nodes.length > 8) instance.lf.fitView(24, 24);
+    else instance.lf.translateCenter();
+  }
+};
+
+const openPanel = ref(true);
+const dragPanelNodeId = ref('');
+const startDragPanelNode = (node: any, event: MouseEvent) => {
+  let el = event.target as HTMLElement;
+  do {
+    if (el.id === node.id) break;
+    el = el.parentElement as HTMLElement;
+  } while (el);
+
+  if (el.classList.contains('disabled')) {
+    /*notification.warning({
+      key: 'node:add',
+      message: '无法添加节点',
+      description: `已存在 [${node.text}] 节点`,
+      top: '12px',
+      getContainer: () => (el.value as HTMLElement) ?? document.body,
+    });*/
+    return;
+  }
+
+  dragPanelNodeId.value = node.id;
+  instance.lf.dnd.startDrag(node);
+  window.addEventListener(
+    'click',
+    () => {
+      dragPanelNodeId.value = '';
+    },
+    { once: true }
+  );
+};
+const getPanelNodeDisabled = (node: FlowNodeProps) => nodes.value.includes(node.id!);
+
+const openNodeConfig = (node: FlowNodeProps) => {
+  let panel;
+  switch (node.id) {
+    case Node.ID_Start:
+      panel = StartPanel;
+      break;
+    case Node.ID_Register:
+      panel = RegisterPanel;
+      break;
+    case Node.ID_Report_TongueAndFace:
+    case Node.ID_Report_Health:
+    case Node.ID_Scheme_Health:
+      panel = ReportPanel;
+      break;
+  }
+  if (panel) {
+    VxeUI.modal.open({
+      id: `node:config`,
+      title: `${Node.formatText(node)} 节点`,
+      escClosable: true,
+      zIndex: 9999,
+      slots: {
+        default: () =>
+          h(panel, {
+            id: node.id,
+            requestData: (node.properties?.requestData as string[]) ?? [],
+            'onUpdate:requestData'(value: any) {
+              instance.lf.getNodeModelById(node.id!)?.setProperties({ requestData: value });
+              VxeUI.modal.close(`node:config`);
+            },
+          }),
+      },
+    });
+  }
+};
+
+const update = (data?: FlowRequestData) => {
+  const { graph, group } = fromFlowRequestData(data ?? requestData.value);
+  nodes.value = graph.nodes?.map((node) => node.id!) ?? [];
+  nodeGroup.value = group;
+
+  instance.lf.renderRawData(graph);
+  updateLayout('TB');
+  if (graph.nodes && graph.nodes.length > 2) updateLayout('center');
+};
+const validate = (tips = true) => {
+  const preNodeRules = {
+    [Node.ID_Report_Pulse]: Node.ID_Analysis_Pulse,
+    [Node.ID_Report_TongueAndFace]: Node.ID_Analysis_TongueAndFace,
+    [Node.ID_Analysis_Health]: Node.ID_Analysis_TongueAndFace,
+    [Node.ID_Report_Alcohol]: Node.ID_Analysis_Health,
+    [Node.ID_Report_Health]: Node.ID_Analysis_Health,
+    [Node.ID_Scheme_Health]: Node.ID_Analysis_Health,
+  };
+  const map = (node: any, level = 0, gather: Gather = []) => {
+    const sourceNodeId = node.id;
+    const directNodes = instance.lf.getNodeOutgoingNode(sourceNodeId);
+    const edges = instance.lf.getNodeOutgoingEdge(sourceNodeId);
+    for (const directNode of directNodes) {
+      const targetNodeId = directNode.id;
+
+      const rule = preNodeRules[targetNodeId as keyof typeof preNodeRules];
+      (typeof rule === 'function'
+        ? rule
+        : (node: any, gather: Gather) => {
+            if (rule && !gather.find((item) => item.targetNodeId === rule)) {
+              throw { gather, node, message: `目标 [${Node.formatText(node)}] 节点上流路径:必须存在 [${Node.formatText(rule)}] 节点` };
+            }
+            return;
+          })(directNode, gather);
+
+      // @ts-ignore
+      gather.push({ level, sourceNodeId, targetNodeId, edgeId: edges.find((edge) => edge.targetNodeId === directNode.id)?.id });
+      if (node.id !== Node.ID_End) map(directNode, level + 1, gather);
+    }
+    if (gather.length) return gather;
+    else throw { gather, message: `请连接 [${Node.formatText(node)}] 节点` };
+  };
+
+  const start = instance.lf.getNodeModelById(Node.ID_Start);
+
+  let gather;
+  const { promise, resolve, reject } = withResolvers<{ gather: Gather; data?: FlowRequestData; message?: string }>();
+  try {
+    gather = map(start);
+
+    const data: Record<string, any> = {
+      [Node.ID_Start]: instance.lf.getNodeModelById(Node.ID_Start)?.getProperties().requestData,
+    };
+    for (const group of nodeGroup.value) for (const node of group) if (node.id) data[node.id] = node.properties?.requestData;
+    requestData.value = toFlowRequestData(gather, data);
+    resolve({ gather, data: requestData.value });
+    console.log('[AioFlowConfig] 更新 request-data 数据: ', requestData.value);
+  } catch (error: any) {
+    if (tips) {
+      notification.error({
+        key: 'graph:validate',
+        message: '检测连接错误',
+        description: error.message,
+        top: '12px',
+        getContainer: () => (el.value as HTMLElement) ?? document.body,
+      });
+    }
+    gather = error.gather;
+    reject(error);
+  }
+
+  if (Array.isArray(gather) && gather.length) {
+    for (const { edgeId } of gather) {
+      instance.lf.setProperties(edgeId, { isAnimation: true });
+      instance.lf.openEdgeAnimation(edgeId);
+    }
+  } else {
+    for (const edge of instance.lf.getGraphRawData().edges) {
+      const edgeId = edge.id;
+      instance.lf.closeEdgeAnimation(edgeId);
+    }
+  }
+
+  return promise;
+};
+
+tryOnUnmounted(scope.stop);
+
+defineExpose({
+  validate,
+  update,
+});
+</script>
+
+<template>
+  <v-logic-flow :class="{ 'flow-notification-container': el }" @loaded="init($event)" :options="options">
+    <template #top>
+      <div class="top-wrapper">{{ dragPanelNodeId ? `拖拽 [节点] 到画布进行配置` : `单击 [节点] 进行编辑, 双击 [节点][边] 进行删除;` }}</div>
+    </template>
+    <template #panel>
+      <a-dropdown class="panel-wrapper" :trigger="['click']" :open="openPanel">
+        <a-button type="primary" size="large" shape="circle" @click.prevent="openPanel = !openPanel">
+          <template #icon>
+            <CloseOutlined v-if="openPanel"></CloseOutlined>
+            <MenuOutlined v-else />
+          </template>
+        </a-button>
+        <template #overlay>
+          <a-card size="small" style="width: 370px">
+            <div class="flex justify-between m-y-2" v-for="(group, g) in nodeGroup" :key="g">
+              <FlowNodeComponent
+                :class="{ selected: dragPanelNodeId === node.id, disabled: getPanelNodeDisabled(node) }"
+                v-for="node in group"
+                :key="node.id"
+                :id="node.id"
+                :text="node.text"
+                v-bind="node.properties"
+                @mousedown="startDragPanelNode(node, $event)"
+              />
+            </div>
+          </a-card>
+        </template>
+      </a-dropdown>
+    </template>
+    <template #float>
+      <a-float-button-group trigger="hover" type="primary" :style="{ right: '24px' }">
+        <template #icon>
+          <ToolOutlined />
+        </template>
+        <a-float-button tooltip="检测连接" @click="validate().catch()">
+          <template #icon><SearchOutlined /></template>
+        </a-float-button>
+        <a-float-button tooltip="居中显示" @click="updateLayout('center')">
+          <template #icon><FullscreenExitOutlined /></template>
+        </a-float-button>
+        <a-float-button tooltip="布局从左到右" @click="updateLayout('LR')">
+          <template #icon><ArrowRightOutlined /></template>
+        </a-float-button>
+        <a-float-button tooltip="布局从上到下" @click="updateLayout('TB')">
+          <template #icon><ArrowDownOutlined /></template>
+        </a-float-button>
+      </a-float-button-group>
+    </template>
+  </v-logic-flow>
+</template>
+
+<style scoped lang="scss">
+.top-wrapper {
+  position: absolute;
+  top: 12px;
+  left: max(10%, 300px);
+  right: max(10%, 300px);
+  text-align: center;
+  color: #bbbbbb;
+}
+.panel-wrapper {
+  position: absolute;
+  top: 24px;
+  left: 24px;
+  z-index: 1;
+}
+.ant-float-btn-group {
+  position: absolute !important;
+  bottom: 24px;
+}
+.flow-notification-container {
+  :deep(.ant-notification) {
+    position: absolute !important;
+  }
+}
+:deep(.node-container) {
+  --width: 160px;
+
+  &.selected {
+    .text-container {
+      border-style: dashed;
+      border-color: #1a82ca;
+    }
+  }
+
+  &.disabled {
+    opacity: 0.5;
+    cursor: no-drop;
+  }
+}
+</style>

+ 111 - 0
src/pages/aio/flow-config/nodes/FlowNode.model.ts

@@ -0,0 +1,111 @@
+import { RectNodeModel } from '@logicflow/core';
+import { END_ID } from '@/libs/logic-flow/constant';
+import type { FlowNodeAnchor, FlowNodeConnectRuleResult, FlowNodeProperties } from './index';
+
+export default class FlowNodeModel extends RectNodeModel<FlowNodeProperties> {
+  override getTextStyle() {
+    const style = super.getTextStyle();
+    style.color = 'transparent';
+    return style;
+  }
+
+  private isAllowConnected(source: FlowNodeModel, target: FlowNodeModel, sourceAnchor: FlowNodeAnchor, targetAnchor: FlowNodeAnchor, edgeID?: string): FlowNodeConnectRuleResult {
+    // 自身节点禁止连接
+    if (source === target) return { isAllPass: false, msg: `节点不能连接自身` };
+
+    // 获取当前节点禁止直接连接
+    const forbidDirectTarget = source.getProperties().forbidDirectTarget ?? [];
+    if (forbidDirectTarget.includes(target.id))
+      return {
+        isAllPass: false,
+        msg: `当前 [${source.text.value}] 节点不能直接连接 [${forbidDirectTarget.map((id) => this.graphModel.getNodeModelById(id)?.text.value).join(' | ')}] 节点`,
+      };
+
+    // 获取目标节点 forbidSource
+    let forbidSource = target.getProperties().forbidSource ?? [];
+    if (forbidSource.includes(source.id)) return { isAllPass: false, msg: `目标 [${target.text.value}] 节点不能在当前 [${source.text.value}] 节点之后` };
+
+    // 获取当前节点的上游节点
+    const visited = new Set([source.id, target.id]);
+    const prev = [source.id];
+    do {
+      const sourceId = prev.shift()!;
+      visited.add(sourceId);
+      const directNodes = this.graphModel.getNodeIncomingNode(sourceId);
+      for (const directNode of directNodes) {
+        if (forbidSource.includes(directNode.id))
+          return {
+            isAllPass: false,
+            msg: `当前 [${source.text.value}] 节点上流路径存在:[${directNode.text.value}] 节点不能在目标 [${target.text.value}] 节点之前`,
+          };
+        if (!visited.has(directNode.id)) prev.push(directNode.id);
+      }
+    } while (prev.length);
+
+    // 获取目标节点的下游节点
+    const next = [target.id];
+    do {
+      const targetId = next.shift()!;
+      visited.add(targetId);
+      const directNodes = target.graphModel.getNodeOutgoingNode(targetId);
+      for (const directNode of directNodes) {
+        forbidSource = (directNode.getProperties().forbidSource as string[]) ?? [];
+        if (forbidSource.includes(source.id))
+          return {
+            isAllPass: false,
+            msg: `目标 [${target.text.value}] 节点下流路径存在:[${directNode.text.value}] 节点不能在当前 [${source.text.value}] 节点之后`,
+          };
+        if (!visited.has(directNode.id)) next.push(directNode.id);
+      }
+    } while (next.length);
+
+    return { isAllPass: true };
+  }
+
+  override isAllowConnectedAsTarget(...args: any[]): FlowNodeConnectRuleResult {
+    // 获取目标节点连接到的所有起始节点
+    const directNodes = this.graphModel.getNodeIncomingNode(this.id);
+    if (directNodes.length >= 1)
+      return {
+        isAllPass: false,
+        msg: `目标 [${this.text.value}] 节点已存在 ${directNodes.length}个输入 (${directNodes.map((node) => node.text.value)})`,
+      };
+
+    // 获取目标节点 onlySource
+    const onlySource = (this.getProperties().onlySource as string[]) ?? [];
+    if (onlySource.length && !onlySource.includes(args[0].id))
+      return {
+        isAllPass: false,
+        msg: `目标 [${this.text.value}] 节点仅允许 [${onlySource.map((id) => this.graphModel.getNodeModelById(id)?.text.value).join(' | ')}] 节点作为输入`,
+      };
+
+    // 当前节点是否开始
+    if (this.getProperties().start)
+      return {
+        isAllPass: false,
+        msg: `当前 [${this.text.value}] 为开始节点不允许输入`,
+      };
+
+
+    return this.isAllowConnected(args[0], this, args[1], args[2], args[3]);
+  }
+
+  override isAllowConnectedAsSource(...args: any[]): FlowNodeConnectRuleResult {
+    // 获取当前节点所有的下一级节点
+    const directNodes = this.graphModel.getNodeOutgoingNode(this.id).filter((node) => node.id !== END_ID);
+    if (directNodes.length >= 1 && args[0].id !== END_ID)
+      return {
+        isAllPass: false,
+        msg: `当前 [${this.text.value}] 节点已存在 ${directNodes.length}个输出 (${directNodes.map((node) => node.text.value)})`,
+      };
+
+    // 当前节点是否结束
+    if (this.getProperties().finish && args[0].id !== END_ID)
+      return {
+        isAllPass: false,
+        msg: `当前 [${this.text.value}] 为结束节点不允许输出`,
+      };
+
+    return this.isAllowConnected(this, args[0], args[1], args[2], args[3]);
+  }
+}

+ 65 - 0
src/pages/aio/flow-config/nodes/FlowNode.vue

@@ -0,0 +1,65 @@
+<script setup lang="ts">
+import { tryOnMounted } from '@vueuse/core';
+import { GraphModel } from '@logicflow/core';
+import { VueNodeModel } from '@logicflow/vue-node-registry';
+
+import { useEventListener } from '@/libs/logic-flow/use';
+
+import type { FlowNodeProperties } from './index';
+import FlowNodeInlay from './FlowNodeInlay.vue';
+
+type NodeModelInstance = InstanceType<typeof VueNodeModel>;
+type GraphModelInstance = InstanceType<typeof GraphModel>;
+
+defineOptions({
+  name: 'LogicFlowNode',
+});
+
+const getNode = inject<() => NodeModelInstance>('getNode');
+const getGraph = inject<() => GraphModelInstance>('getGraph');
+
+const text = ref('');
+const id = ref('');
+
+const properties = ref<FlowNodeProperties>({
+  width: 0,
+  height: 0,
+  radius: 0,
+  iconBackground: 'transparent',
+  iconColor: '#000',
+});
+
+tryOnMounted(() => {
+  const model = getNode?.()!;
+  const graph = getGraph?.()!;
+
+  id.value = model.id;
+  text.value = model.text.value;
+
+  updateProperties(model.getProperties());
+  useEventListener(
+    graph.eventCenter,
+    'node:properties-change',
+    (event) => updateProperties(event.properties),
+    (event) => event.id === id.value
+  );
+});
+
+function updateProperties(props: FlowNodeProperties) {
+  properties.value = Object.assign(properties.value, props);
+}
+</script>
+
+<template>
+  <FlowNodeInlay class="node" :id :text v-bind="properties"></FlowNodeInlay>
+</template>
+
+<style scoped lang="scss">
+.node {
+  --width: calc(v-bind(properties.width) * 1px);
+  --height: calc(v-bind(properties.height) * 1px);
+  --radius: calc(v-bind(properties.radius) * 1px);
+  --icon-background: v-bind(properties.iconBackground);
+  --icon-color: v-bind(properties.iconColor);
+}
+</style>

+ 113 - 0
src/pages/aio/flow-config/nodes/FlowNodeInlay.vue

@@ -0,0 +1,113 @@
+<script setup lang="ts">
+import { h } from 'vue';
+import type { FlowNodeProperties } from './index';
+
+import IconStart from '@/pages/aio/flow-config/assets/start.svg';
+import IconConfig from '@/pages/aio/flow-config/assets/config.svg';
+import IconReport from '@/pages/aio/flow-config/assets/report.svg';
+import IconPulse from '@/pages/aio/flow-config/assets/pulse.svg';
+import IconTongue from '@/pages/aio/flow-config/assets/tongue.svg';
+import IconQuestionnaire from '@/pages/aio/flow-config/assets/questionnaire.svg';
+
+interface Props extends FlowNodeProperties {
+  id: string;
+  text?: string;
+}
+
+defineOptions({
+  name: 'FlowNodeInlay',
+});
+
+const Icon = {
+  start: IconStart,
+  config: IconConfig,
+  report: IconReport,
+  pulse: IconPulse,
+  tongue: IconTongue,
+  questionnaire: IconQuestionnaire,
+};
+
+const { id, icon, text, configurable } = defineProps<Props>();
+
+const iconComponent = computed(() => (icon ? Icon[icon] : void 0));
+</script>
+
+<template>
+  <div :id="id" class="node-container" :class="{ configurable }">
+    <div v-if="iconComponent" class="icon-container flex flex-center">
+      <component :is="h(iconComponent)" class="icon" />
+    </div>
+    <div v-if="text" class="text-container flex flex-center">
+      <div class="text">{{ text }}</div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+$height: 40px;
+$radius: 8px;
+$icon: 22px;
+
+.flex {
+  display: flex;
+}
+.flex-center {
+  justify-content: center;
+  align-items: center;
+}
+
+.node-container {
+  position: relative;
+  width: var(--width, 120px);
+  height: var(--height, $height);
+  user-select: none;
+
+  &.configurable {
+    cursor: pointer;
+
+    .text-container::after {
+      content: '';
+      position: absolute;
+      top: 6px;
+      right: 6px;
+      width: 5px;
+      height: 5px;
+      border-radius: 50%;
+      background-color: #ff4d4f;
+    }
+  }
+}
+
+.icon-container {
+  position: absolute;
+  top: 0;
+  left: 0;
+  width: var(--height, $height);
+  height: 100%;
+  border-top-left-radius: var(--radius, $radius);
+  border-bottom-left-radius: var(--radius, $radius);
+  background-color: var(--icon-background, v-bind(iconBackground));
+
+  & + .text-container {
+    padding-left: var(--height, $height);
+  }
+
+  svg {
+    height: $icon;
+    fill: var(--icon-color, v-bind(iconColor));
+  }
+}
+
+.text-container {
+  line-height: $icon;
+  height: 100%;
+  border-radius: var(--radius, $radius);
+  background-color: #fff;
+  border: 2px solid #111;
+
+  .text {
+    height: $icon;
+    font-size: 14px;
+  }
+}
+</style>

+ 187 - 0
src/pages/aio/flow-config/nodes/config.ts

@@ -0,0 +1,187 @@
+import { END_ID, START_ID } from '@/libs/logic-flow/constant';
+import type { FlowNodeProperties } from './index';
+
+interface CreateNodeOptions extends FlowNodeProperties {
+  point?: Partial<{
+    x: number;
+    y: number;
+  }>;
+  rect?: Partial<{
+    radius: number;
+    width: number;
+    height: number;
+  }>;
+}
+
+export const DEFAULT_NODE_WIDTH = 160;
+export const DEFAULT_NODE_HEIGHT = 40;
+
+export const ID_Start = START_ID;
+export const ID_End = END_ID;
+export const ID_Register = 'register';
+export const ID_Analysis_Pulse = 'pulseAnalysis';
+export const ID_Analysis_TongueAndFace = 'tongueAndFaceAnalysis';
+export const ID_Analysis_Health = 'healthAnalysis';
+
+export const ID_Report_Pulse = 'pulseAnalysisReport';
+export const ID_Report_TongueAndFace = 'tongueAndFaceAnalysisReport';
+export const ID_Report_Alcohol = 'alcoholAnalysisReport';
+export const ID_Report_Health = 'healthAnalysisReport';
+export const ID_Scheme_Health = 'healthAnalysisScheme';
+
+const factory = (type: 'StartNode' | 'EndNode' | 'FlowNode', id: string, text: string, options?: CreateNodeOptions) => {
+  const { point, rect, ...properties } = options ?? {};
+  return {
+    type,
+    id,
+    text,
+    x: point?.x ?? 100,
+    y: point?.y ?? 100,
+    properties: {
+      radius: 8,
+      width: rect?.width ?? DEFAULT_NODE_WIDTH,
+      height: rect?.height ?? DEFAULT_NODE_HEIGHT,
+      ...properties,
+    },
+  };
+};
+
+const textRef = {
+  [ID_Start]: '开始检测',
+  [ID_End]: '返回首页',
+  [ID_Register]: '建档',
+  [ID_Analysis_Pulse]: '脉象分析',
+  [ID_Analysis_TongueAndFace]: '舌面象分析',
+  [ID_Analysis_Health]: '问诊',
+  [ID_Report_Pulse]: '脉象分析报告',
+  [ID_Report_TongueAndFace]: '舌面象分析报告',
+  [ID_Report_Health]: '健康分析报告',
+  [ID_Scheme_Health]: '调理方案',
+  [ID_Report_Alcohol]: '黄酒建议',
+};
+
+export function formatText(value: string | { text?: string | { value?: string } }) {
+  let text = '未命名节点';
+  if (typeof value === 'string') text = textRef[<keyof typeof textRef>value] ?? text;
+  else if (typeof value.text === 'string') text = value.text ?? text;
+  else if (typeof value.text === 'object') text = value.text?.value ?? text;
+  return text;
+}
+
+export function start(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Start, textRef[ID_Start], {
+    icon: 'start',
+    iconBackground: '#cf1322',
+    iconColor: '#fff',
+    start: true,
+    configurable: true,
+    ...options,
+  });
+}
+
+export function end(options?: CreateNodeOptions) {
+  return factory('EndNode', ID_End, textRef[ID_End], {
+    radius: 20,
+    ...options,
+  });
+}
+
+export function register(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Register, textRef[ID_Register], {
+    icon: 'config',
+    iconBackground: '#c41d7f',
+    iconColor: '#fff',
+    onlySource: [START_ID],
+    configurable: true,
+    ...options,
+  });
+}
+
+export function pulseAnalysis(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Analysis_Pulse, textRef[ID_Analysis_Pulse], {
+    icon: 'pulse',
+    iconBackground: '#d46b08',
+    iconColor: '#fff',
+    forbidSource: [ID_Report_Pulse],
+    ...options,
+  });
+}
+export function tongueAndFaceAnalysis(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Analysis_TongueAndFace, textRef[ID_Analysis_TongueAndFace], {
+    icon: 'tongue',
+    iconBackground: '#d46b08',
+    iconColor: '#fff',
+    forbidSource: [ID_Report_TongueAndFace, ID_Analysis_Health, ID_Report_Alcohol, ID_Report_Health, ID_Scheme_Health],
+    ...options,
+  });
+}
+
+export function healthAnalysis(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Analysis_Health, textRef[ID_Analysis_Health], {
+    icon: 'questionnaire',
+    iconBackground: '#d46b08',
+    iconColor: '#fff',
+    forbidSource: [ID_Report_Alcohol, ID_Report_Health, ID_Scheme_Health],
+    ...options,
+  });
+}
+
+export function pulseAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_Pulse, textRef[ID_Report_Pulse], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    forbidDirectTarget: [ID_End],
+    ...options,
+  });
+}
+
+export function tongueAndFaceAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_TongueAndFace, textRef[ID_Report_TongueAndFace], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    forbidDirectTarget: [ID_End],
+    ...options,
+  });
+}
+
+export function healthAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_Health, textRef[ID_Report_Health], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    finish: true,
+    configurable: true,
+    ...options,
+  });
+}
+
+export function healthAnalysisScheme(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Scheme_Health, textRef[ID_Scheme_Health], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    finish: true,
+    configurable: true,
+    ...options,
+  });
+}
+
+export function alcoholAnalysisReport(options?: CreateNodeOptions) {
+  return factory('FlowNode', ID_Report_Alcohol, textRef[ID_Report_Alcohol], {
+    icon: 'report',
+    iconBackground: '#08979c',
+    iconColor: '#fff',
+    ...options,
+  });
+}
+
+export function edge(sourceNodeId: string, targetNodeId: string, properties?: Record<string, any>) {
+  return {
+    type: 'custom-polyline-edge',
+    sourceNodeId,
+    targetNodeId,
+    properties: properties ?? {},
+  };
+}

+ 34 - 0
src/pages/aio/flow-config/nodes/index.ts

@@ -0,0 +1,34 @@
+import type { IRectNodeProperties, Model, RectNodeModel } from '@logicflow/core';
+import type { LogicFlowNode } from '@/libs/logic-flow';
+
+import __FlowNode_component from './FlowNode.vue';
+import __FlowNodeInlay_component from './FlowNodeInlay.vue';
+import __FlowNode_model from './FlowNode.model';
+
+export {
+  __FlowNode_component as FlowNodeView,
+  __FlowNode_model as FlowNodeViewModel,
+  __FlowNodeInlay_component as FlowNodeComponent,
+};
+
+export * as Node from './config';
+
+export interface FlowNodeProperties extends IRectNodeProperties {
+  icon?: 'start' | 'config' | 'report' | 'pulse' | 'tongue' | 'questionnaire';
+  iconBackground?: string;
+  iconColor?: string;
+
+  forbidDirectTarget?: string[];
+  forbidSource?: string[];
+  onlySource?: string[];
+
+  start?: boolean;
+  finish?: boolean;
+
+  configurable?: boolean;
+  requestData?: Record<string, any>;
+}
+export type FlowNodeProps = LogicFlowNode<FlowNodeProperties>;
+export type FlowNodeModel = RectNodeModel<FlowNodeProperties>;
+export type FlowNodeAnchor = Model.AnchorConfig & { type?: 'outgoing' | 'incoming'};
+export type FlowNodeConnectRuleResult  = Model.ConnectRuleResult;

+ 179 - 0
src/pages/aio/flow-config/panel/RegisterPanel.vue

@@ -0,0 +1,179 @@
+<script setup lang="ts">
+import draggable from 'vuedraggable';
+import { CloseOutlined, HolderOutlined } from '@ant-design/icons-vue';
+import { analysisRegisterFields, Field_Card, Field_Phone } from '../tool';
+
+interface Option {
+  id: string;
+  name: string;
+  required?: boolean;
+}
+const props = defineProps<{ id: string }>();
+const requestData = defineModel<string[]>('requestData', { default: [] });
+
+let getConfigItemLabel = (id: string) => id;
+
+const options = ref<Option[]>([]);
+const selected = ref<Option[]>([]);
+
+watchEffect(() => reset());
+
+const append = (option: Option, index: number) => {
+  options.value.splice(index, 1);
+  if (option.required) selected.value.unshift(option);
+  else selected.value.push(option);
+};
+
+const remove = (option: Option, index: number) => {
+  selected.value.splice(index, 1);
+  if (option.required) options.value.unshift(option);
+  else options.value.push(option);
+};
+
+const error = ref('');
+watch(
+  [selected, options],
+  () => {
+    error.value = '';
+  },
+  { deep: true }
+);
+function save() {
+  error.value = '';
+  const fieldCard = selected.value.find((option) => option.id === Field_Card);
+  const fieldPhone = selected.value.find((option) => option.id === Field_Phone);
+
+  if (fieldCard && !fieldCard.required && fieldPhone && !fieldPhone.required) {
+    error.value = `字段 [${getConfigItemLabel(Field_Card)}] [${getConfigItemLabel(Field_Phone)}] 至少需要一个是必填的`;
+  } else if (fieldCard && !fieldCard.required && !fieldPhone) {
+    fieldCard.required = true;
+    nextTick(() => {
+      error.value = `字段 [${getConfigItemLabel(Field_Card)}] 需要是必填的 (请重试)`;
+    });
+  } else if (fieldPhone && !fieldPhone.required && !fieldCard) {
+    fieldPhone.required = true;
+    nextTick(() => {
+      error.value = `字段 [${getConfigItemLabel(Field_Phone)}] 需要是必填的 (请重试)`;
+    });
+  } else if (!fieldCard && !fieldPhone) {
+    error.value = `至少需要 [${getConfigItemLabel(Field_Card)}] 或 [${getConfigItemLabel(Field_Card)}] 字段,且有一个需要是必填的`;
+  } else {
+    requestData.value = selected.value.map((option) => [option.id, option.required ? 'required' : ''].filter(Boolean).join(':'));
+  }
+}
+
+function reset() {
+  const values = analysisRegisterFields(requestData.value);
+  selected.value = values.selected;
+  options.value = values.options;
+  getConfigItemLabel = (id: string) => values.config.find((item) => item[0] === id)?.[1] ?? id;
+}
+</script>
+
+<template>
+  <div>
+    <draggable class="draggable-list selected" :class="{ empty: selected.length === 0 }" :list="selected" group="fields" item-key="id">
+      <template #item="{ element, index }">
+        <div class="draggable-list-item has-icon">
+          <div>
+            <HolderOutlined class="icon left" />
+            <span>{{ element.name }}</span>
+          </div>
+          <div>
+            <a-switch v-model:checked="element.required" checked-children="必填" un-checked-children="可选" />
+            <CloseOutlined class="icon right" style="cursor: pointer" @click.prevent="remove(element, index)" />
+          </div>
+        </div>
+      </template>
+      <template #header>
+        <div class="draggable-list-header">配置字段</div>
+      </template>
+    </draggable>
+    <draggable class="draggable-list options" :class="{ empty: options.length === 0 }" :list="options" group="fields" item-key="id">
+      <template #header>
+        <div class="draggable-list-header">备选字段</div>
+      </template>
+      <template #item="{ element, index }">
+        <a-tag :color="element.required ? '#f50' : '#108ee9'" class="draggable-list-item" @click="append(element, index)">
+          {{ element.name }}
+        </a-tag>
+      </template>
+    </draggable>
+    <div class="flex items-center" style="margin-top: 10px">
+      <div class="flex-auto error">{{ error }}</div>
+      <div class="flex-none">
+        <vxe-button type="reset" content="重置" @click="reset()"></vxe-button>
+        <vxe-button type="submit" status="primary" content="确定" @click="save()"></vxe-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.draggable-list-header {
+  padding: 8px 0;
+  font-size: 16px;
+}
+.draggable-list.empty {
+  border-radius: 8px;
+  border: 1px #111 dashed;
+  height: 100px;
+  > .draggable-list-header {
+    display: none;
+  }
+
+  &::after {
+    content: var(--empty);
+    display: flex;
+    justify-content: center;
+    align-items: center;
+    height: 100%;
+    color: rgba(0, 0, 0, 0.45);
+  }
+}
+
+.draggable-list.selected {
+  --empty: '点击或拖拽下方标签至此区域以配置';
+
+  .draggable-list-item {
+    display: flex;
+    justify-content: space-between;
+    align-items: center;
+    padding: 8px 16px;
+    &.has-icon {
+      padding-left: 8px;
+      padding-right: 8px;
+    }
+    cursor: move;
+    border-collapse: collapse;
+    border: 1px solid rgba(0, 0, 0, 0.5);
+    .icon {
+      &.left {
+        padding-right: 8px;
+      }
+      &.right {
+        padding-left: 8px;
+      }
+    }
+  }
+}
+
+.draggable-list.options {
+  --empty: '拖拽至此区域以取消配置';
+  .draggable-list-header {
+    padding-bottom: 0;
+  }
+  &.empty {
+    margin-top: 12px;
+  }
+  .draggable-list-item {
+    margin-top: 8px;
+    cursor: pointer;
+  }
+}
+
+.error {
+  font-size: 12px;
+  color: #ff4d4f;
+}
+</style>

+ 101 - 0
src/pages/aio/flow-config/panel/ReportPanel.vue

@@ -0,0 +1,101 @@
+<script setup lang="ts">
+import { ID_Report_Health } from '../nodes/config';
+import { Key_jumpable, Key_miniProgramCode, Key_payLock, Key_printable } from '../tool';
+
+interface Model {
+  payLock: boolean;
+  miniProgramCode: boolean;
+
+  jumpable?: boolean;
+  printable?: boolean;
+}
+
+const props = defineProps<{ id: string }>();
+const requestData = defineModel<{ key: string; elements: string[] }>('requestData', { default: { elements: [] } });
+
+const isHealthAnalysisReport = computed(() => props.id === ID_Report_Health);
+
+const model = ref<Model>({
+  payLock: true,
+  miniProgramCode: false,
+});
+
+watchEffect(() => reset());
+
+const updatePayLockAndMiniProgramCode = (key: 'payLock' | 'miniProgramCode', value: boolean) => {
+  if (key === 'payLock' && value) model.value.miniProgramCode = true;
+  else if (key === 'miniProgramCode' && !value) model.value.payLock = false;
+};
+
+const error = ref('');
+watch(
+  model,
+  () => {
+    error.value = '';
+  },
+  { deep: true }
+);
+function save() {
+  error.value = '';
+
+  if (model.value.payLock && !model.value.miniProgramCode) {
+    model.value.miniProgramCode = true;
+    nextTick(() => {
+      error.value = `[扫码查看] 需要配置 [小程序码] (请重试)`;
+    });
+  } else {
+    const key = requestData.value.key;
+    const elements: string[] = [];
+    if (model.value.miniProgramCode) elements.push(`${key}_${Key_miniProgramCode}`);
+    if (model.value.payLock) elements.push(`${key}_${Key_payLock}`);
+    if (!model.value.jumpable && isHealthAnalysisReport.value) elements.push(`${key}_${Key_jumpable}`);
+    if (!model.value.printable && isHealthAnalysisReport.value) elements.push(`${key}_${Key_printable}`);
+    requestData.value = { key, elements };
+  }
+}
+function reset() {
+  Object.assign(model.value, { payLock: false, miniProgramCode: false, jumpable: true, printable: true });
+  const { key, elements } = requestData.value;
+  for (const element of elements) {
+    if (!element.startsWith(key)) continue;
+    if (element.endsWith(Key_miniProgramCode)) model.value.miniProgramCode = true;
+    if (element.endsWith(Key_payLock)) model.value.payLock = true;
+    if (element.endsWith(Key_jumpable)) model.value.jumpable = false;
+    if (element.endsWith(Key_printable)) model.value.printable = false;
+  }
+}
+</script>
+
+<template>
+  <div>
+    <a-form-item label="查看报告">
+      <a-switch v-model:checked="model.payLock" checked-children="扫码查看" un-checked-children="完整展示" @change="updatePayLockAndMiniProgramCode('payLock', model.payLock)" />
+    </a-form-item>
+    <a-form-item label="按钮配置">
+      <a-checkbox v-model:checked="model.miniProgramCode" @change="updatePayLockAndMiniProgramCode('miniProgramCode', model.miniProgramCode)">小程序码</a-checkbox>
+      <template v-if="isHealthAnalysisReport">
+        <a-space :size="12">
+          <a-checkbox v-model:checked="model.printable">打印分析报告</a-checkbox>
+          <a-checkbox v-model:checked="model.jumpable">加载调理方案</a-checkbox>
+        </a-space>
+      </template>
+    </a-form-item>
+    <div class="flex items-center" style="margin-top: 10px">
+      <div class="flex-auto error">{{ error }}</div>
+      <div class="flex-none">
+        <vxe-button type="reset" content="重置" @click="reset()"></vxe-button>
+        <vxe-button type="submit" status="primary" content="确定" @click="save()"></vxe-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.ant-form-item {
+  margin-bottom: 12px;
+}
+.error {
+  font-size: 12px;
+  color: #ff4d4f;
+}
+</style>

+ 154 - 0
src/pages/aio/flow-config/panel/StartPanel.vue

@@ -0,0 +1,154 @@
+<script setup lang="ts">
+interface Model {
+  copyright?: string;
+
+  elements?: string[];
+
+  homeType?: number;
+  homeValue?: string;
+}
+
+const Preset_Image_1 = `preset:1;el:scan|btn;`;
+const Preset_Image_2 = `preset:2;el:scan;`;
+
+const props = defineProps<{ id: string }>();
+const requestData = defineModel<{ partner?: string; technicalSupporter?: string; tabletDrainageImage?: string }>('requestData', { default: {} });
+
+const model = ref<Model>({});
+
+watchEffect(() => reset());
+
+const error = ref('');
+watch(
+  [model],
+  () => {
+    error.value = '';
+  },
+  { deep: true }
+);
+function save() {
+  error.value = '';
+  const values = model.value;
+  if (values.homeType === 99 && !values.homeValue?.trim()) {
+    error.value = `[自定义] 起始页需要配置值`;
+  } else {
+    let image = values.homeValue;
+    if (values.homeType === 1) image = Preset_Image_1.split(';')[0];
+    else if (values.homeType === 2) image = Preset_Image_2.split(';')[0];
+    else if (values.homeType === 99 && !image?.includes('preset:')) image = `preset:99;${image}`;
+
+    const snippets = image?.replace(/\s/g, ';')?.split(';').filter(Boolean) ?? [];
+    const elIndex = snippets.findIndex((s) => s.startsWith('el'));
+
+    if (elIndex > -1) {
+      const els = snippets[elIndex].split('|').filter((k) => k != 'btn' && k != 'scan');
+      els.push(...values.elements!);
+      snippets[elIndex] = els.join('|');
+    } else {
+      snippets.push(`el:${values.elements?.join('|')}`);
+    }
+
+    const [partner, technicalSupporter] = values.copyright?.trim()?.split(/\s/) ?? [];
+    requestData.value = { partner, technicalSupporter, tabletDrainageImage: snippets.join(';') };
+  }
+}
+function reset() {
+  Object.assign(model.value, { copyright: '', homeType: 1, homeValue: '', elements: [] });
+  const { partner, technicalSupporter, tabletDrainageImage } = requestData.value;
+  let image = tabletDrainageImage;
+  if (!image || /^preset:1;?$/.test(image)) image = Preset_Image_1;
+  else if (/^preset:2;?$/.test(image)) image = Preset_Image_2;
+
+  model.value.copyright = [partner, technicalSupporter].filter(Boolean).join('\n');
+  const elements = [];
+  const snippets = [];
+  for (const snippet of image.split(';')) {
+    const [key, config] = snippet.split(':');
+    if (key === 'preset') model.value.homeType = +config || 1;
+    else if (key === 'el') {
+      const els = config.split('|');
+      if (els.includes('btn')) elements.push('btn');
+      if (els.includes('scan')) elements.push('scan');
+
+      const rest = els.filter((k) => k != 'btn' && k != 'scan').join('|');
+      if (rest) snippets.push(`${key}:${rest}`);
+    } else snippets.push(snippet);
+  }
+  model.value.elements = [...elements];
+  model.value.homeValue = snippets.join(';');
+}
+
+const updateHomeType = () => {
+  let image = '';
+  if (model.value.homeType === 1) image = Preset_Image_1;
+  else if (model.value.homeType === 2) image = Preset_Image_2;
+  if (image) {
+    const [_, el] = image.match(/el:([^;]*)/) ?? [];
+    model.value.elements = el?.split('|') ?? [];
+  }
+};
+</script>
+
+<template>
+  <div>
+    <a-form-item label="起始页">
+      <a-radio-group v-model:value="model.homeType" @change="updateHomeType()">
+        <a-radio :value="1">预设:默认</a-radio>
+        <a-radio :value="2">预设:萧山</a-radio>
+        <a-radio :value="99">
+          <template v-if="model.homeType === 99">
+            <div class="flex-none">自定义:</div>
+            <a-textarea class="small" v-model:value="model.homeValue" :auto-size="{ minRows: 1, maxRows: 10 }" placeholder="page:~;com:~;img:~;"></a-textarea>
+          </template>
+          <template v-else>自定义</template>
+        </a-radio>
+      </a-radio-group>
+      <div class="mt-2 pt-2" style="border-top: 1px #d9d9d9 dashed">
+        <a-checkbox-group
+          v-model:value="model.elements"
+          :options="[
+            { label: '显示检测按钮', value: 'btn' },
+            { label: '支持扫码开始', value: 'scan' },
+          ]"
+        />
+      </div>
+    </a-form-item>
+    <a-form-item label="版权信息">
+      <a-textarea v-model:value="model.copyright" placeholder="最多显示两行文本" :auto-size="{ minRows: 2, maxRows: 2 }" />
+    </a-form-item>
+    <div class="flex items-center" style="margin-top: 10px">
+      <div class="flex-auto error">{{ error }}</div>
+      <div class="flex-none">
+        <vxe-button type="reset" content="重置" @click="reset()"></vxe-button>
+        <vxe-button type="submit" status="primary" content="确定" @click="save()"></vxe-button>
+      </div>
+    </div>
+  </div>
+</template>
+
+<style scoped lang="scss">
+.ant-form-item {
+  margin-bottom: 12px;
+  ::placeholder {
+    color: #d4d5d7;
+    opacity: 1;
+  }
+}
+.ant-radio-group {
+  width: 100%;
+}
+.ant-radio-wrapper {
+  display: flex;
+  width: 100%;
+  line-height: 30px;
+  :deep(span:not(.ant-radio)) {
+    flex: auto;
+    display: flex;
+    align-items: center;
+  }
+}
+.error {
+  font-size: 12px;
+  color: #ff4d4f;
+}
+</style>

+ 226 - 0
src/pages/aio/flow-config/tool.ts

@@ -0,0 +1,226 @@
+import {
+  alcoholAnalysisReport,
+  edge,
+  end,
+  healthAnalysis,
+  healthAnalysisReport,
+  healthAnalysisScheme,
+  ID_Analysis_Health,
+  ID_Analysis_Pulse,
+  ID_Analysis_TongueAndFace,
+  ID_End,
+  ID_Register,
+  ID_Report_Alcohol,
+  ID_Report_Health,
+  ID_Report_Pulse,
+  ID_Report_TongueAndFace,
+  ID_Scheme_Health,
+  ID_Start,
+  pulseAnalysis,
+  pulseAnalysisReport,
+  register,
+  start,
+  tongueAndFaceAnalysis,
+  tongueAndFaceAnalysisReport,
+} from './nodes/config';
+import type { LogicFlowGraphData } from '@/libs/logic-flow';
+
+const nodeRef = {
+  [ID_Start]: 'screen',
+  [ID_End]: 'screen',
+  [ID_Register]: /* 建档页 */ 'patient_file',
+  [ID_Analysis_Pulse]: /* 脉诊页 */ 'pulse_upload',
+  [ID_Analysis_TongueAndFace]: /*拍照页*/ 'tongueface_upload',
+  [ID_Analysis_Health]: /* 问卷页 */ 'tongueface_analysis',
+  [ID_Report_Pulse]: /* 脉诊结果页 */ 'pulse_upload_result',
+  [ID_Report_TongueAndFace]: 'tongueface_analysis_result',
+  [ID_Report_Health]: /* 健康报告页 */ 'health_analysis',
+  [ID_Scheme_Health]: /* 调理方案页 */ 'health_analysis_scheme',
+  [ID_Report_Alcohol]: /* 酒精结果页 */ 'alcohol_upload_result',
+} as const;
+
+export type NodeId = keyof typeof nodeRef;
+export type Gather = { level: number; sourceNodeId: NodeId; targetNodeId: NodeId; edgeId?: string }[];
+
+const refNode = ((ref) => Object.keys(ref).reduce((record, key) => ((record[ref[<NodeId>key]] = <NodeId>key), record), {} as Record<string, NodeId>))(nodeRef);
+
+export function toFlowRequestData(gather: Gather, data: Record<string, any>) {
+  const value: string[] = [];
+  const _gather = [...gather].sort((g1, g2) => g1.level - g2.level || +(g1.targetNodeId === ID_End) - +(g2.targetNodeId === ID_End));
+  for (const { targetNodeId } of _gather) {
+    if (targetNodeId === ID_End) {
+      value.push(value.pop()!.replace(/\?*$/, '?'));
+    } else value.push(nodeRef[targetNodeId]);
+  }
+  const getReportRequestData = (key: string) => data[key]?.elements ?? [];
+  return {
+    tabletProcessModules: value,
+    tabletFileFields: data[ID_Register] ?? [],
+    tabletRequiredPageOperationElements: [
+      ...getReportRequestData(ID_Report_TongueAndFace),
+      ...getReportRequestData(ID_Report_Health),
+      ...getReportRequestData(ID_Scheme_Health),
+    ] as string[],
+    ...(data[ID_Start] as {
+      partner: string;
+      technicalSupporter: string;
+      tabletDrainageImage: string;
+    }),
+  } as const;
+}
+
+export type FlowRequestData = Partial<ReturnType<typeof toFlowRequestData>>;
+
+type ReportPrefixKey = 'tongueface_upload_report_page' | 'health_analysis_report_page' | 'health_analysis_scheme_page';
+
+export function fromFlowRequestData(data?: FlowRequestData) {
+  const getReportRequestData = (key: ReportPrefixKey) => ({
+    key,
+    elements: Array.isArray(data?.tabletRequiredPageOperationElements) ? data.tabletRequiredPageOperationElements.filter((item) => item.startsWith(key)) : [],
+  });
+
+  const group = [
+    [register({ requestData: Array.isArray(data?.tabletFileFields) ? data.tabletFileFields : [] })],
+    [pulseAnalysis(), pulseAnalysisReport()],
+    [tongueAndFaceAnalysis(), tongueAndFaceAnalysisReport({ requestData: getReportRequestData('tongueface_upload_report_page') })],
+    [healthAnalysis()],
+    [alcoholAnalysisReport()],
+    [
+      healthAnalysisReport({ requestData: getReportRequestData('health_analysis_report_page') }),
+      healthAnalysisScheme({ requestData: getReportRequestData('health_analysis_scheme_page') }),
+    ],
+  ];
+
+  const nodes = new Set<string>([ID_Start, ID_End]);
+  const edges: LogicFlowGraphData['edges'] = [];
+  const flow = Array.isArray(data?.tabletProcessModules) ? [...data.tabletProcessModules] : [];
+  if (flow.length && flow[0] === nodeRef[ID_Start]) flow.shift();
+  if (flow.length && flow[flow.length - 1] === nodeRef[ID_End]) flow.pop();
+  flow.unshift(ID_Start);
+  for (let i = 1; i < flow.length; i++) {
+    const [source] = flow[i - 1].split(/[?:]/).filter(Boolean);
+    const [target, title, countDown] = flow[i].split(/[?:]/).filter(Boolean);
+    const optional = flow[i].includes('?');
+
+    const sourceNodeId = refNode[source] ?? source;
+    const targetNodeId = refNode[target] ?? target;
+
+    nodes.add(sourceNodeId).add(targetNodeId);
+
+    edges.push(edge(sourceNodeId, targetNodeId));
+    if (optional) edges.push(edge(sourceNodeId, ID_End, { title, countDown }));
+  }
+
+  return {
+    graph: {
+      nodes: Array.from(nodes, (id) => {
+        if (id === ID_Start)
+          return start({
+            requestData: {
+              partner: data?.partner,
+              technicalSupporter: data?.technicalSupporter,
+              tabletDrainageImage: data?.tabletDrainageImage,
+            },
+          });
+        if (id === ID_End) return end();
+        for (const items of group) for (const node of items) if (node.id == id) return node;
+        return void 0;
+      }).filter(Boolean),
+      edges,
+    } as LogicFlowGraphData,
+    group,
+  };
+}
+
+export const Field_Card = 'cardno';
+export const Field_Phone = 'phone';
+export function analysisRegisterFields(fields?: string[]) {
+  const config = [
+    [Field_Card, '身份证号', true],
+    [Field_Phone, '手机号码', true],
+    ['name', '姓名'],
+    ['age', '年龄'],
+    ['sex', '性别'],
+    ['height', '身高'],
+    ['weight', '体重'],
+    ['womenSpecialPeriod', '女性特殊期'],
+    ['isEasyAllergy', '容易过敏'],
+    ['foodAllergy', '食物过敏'],
+    ['hobbyFlavor', '喜好口味'],
+    ['drinkState', '饮酒情况'],
+    ['smokeState', '吸烟情况'],
+    ['address', '现住址'],
+    ['detailAddress', '详细地址'],
+    ['job', '职业'],
+  ] as const;
+
+  if (!Array.isArray(fields)) fields = [];
+  type Option = { id: string; name: string; required?: boolean };
+  const options: Option[] = config.map(([id, name, required = false]) => ({ id, name, required }));
+  const selected: Option[] = [];
+  for (const item of fields) {
+    const [id, required] = item.split(':');
+    if (id === 'code') continue;
+    const index = options.findIndex((item) => item.id === id);
+    if (index > -1) selected.push({ ...options.splice(index, 1)[0], required: required === 'required' || required === 'true' });
+  }
+
+  return { config, options, selected };
+}
+
+export const Key_miniProgramCode = 'appletbutton';
+export const Key_payLock = 'appletscan';
+export const Key_jumpable = 'notjump';
+export const Key_printable = 'notprint';
+
+export function analysisRequestData(data?: FlowRequestData): Record<
+  NodeId,
+  {
+    has: boolean;
+    optional: boolean;
+    format: string;
+    fields?: { id: string; name: string; required?: boolean }[];
+    payLock?: boolean;
+    miniProgramCode?: boolean;
+    jumpable?: boolean;
+    printable?: boolean;
+  }
+> {
+  const get = (id: NodeId) => {
+    const key = data?.tabletProcessModules?.find((key) => key.startsWith(nodeRef[id])) ?? '';
+    const has = !!key;
+    const optional = key.includes('?');
+    return { has, optional, format: has ? `有${optional ? '(可选)' : ''}` : '无' };
+  };
+
+  const report = (id: NodeId, prefix: ReportPrefixKey) => {
+    const values = get(id);
+    if (values.has) {
+      const payLock = data?.tabletRequiredPageOperationElements?.includes(`${prefix}_${Key_payLock}`);
+      const mini = data?.tabletRequiredPageOperationElements?.includes(`${prefix}_${Key_miniProgramCode}`);
+      values.format += `(${mini && payLock ? '扫码查看' : '完整展示'})`;
+      Object.assign(values, { payLock: mini && payLock, miniProgramCode: mini });
+      if (id === ID_Report_Health) {
+        const jumpable = !data?.tabletRequiredPageOperationElements?.includes(`${prefix}_${Key_jumpable}`);
+        const printable = !data?.tabletRequiredPageOperationElements?.includes(`${prefix}_${Key_printable}`);
+        Object.assign(values, { jumpable, printable });
+      }
+    }
+    return values;
+  };
+
+  return {
+    [ID_Register]: {
+      ...get(ID_Register),
+      fields: analysisRegisterFields(data?.tabletFileFields).selected,
+    },
+    [ID_Analysis_Pulse]: get(ID_Analysis_Pulse),
+    [ID_Analysis_TongueAndFace]: get(ID_Analysis_TongueAndFace),
+    [ID_Analysis_Health]: get(ID_Analysis_Health),
+    [ID_Report_Pulse]: get(ID_Report_Pulse),
+    [ID_Report_TongueAndFace]: report(ID_Report_TongueAndFace, 'tongueface_upload_report_page'),
+    [ID_Report_Alcohol]: get(ID_Report_Alcohol),
+    [ID_Report_Health]: report(ID_Report_Health, 'health_analysis_report_page'),
+    [ID_Scheme_Health]: report(ID_Scheme_Health, 'health_analysis_scheme_page'),
+  } as any;
+}

+ 2 - 0
vite.config.ts

@@ -4,6 +4,7 @@ import vueJsx from '@vitejs/plugin-vue-jsx';
 import { fileURLToPath, URL } from 'node:url';
 
 import UnoCSS from 'unocss/vite';
+import SVGLoader from 'vite-svg-loader';
 
 import AutoImport               from 'unplugin-auto-import/vite';
 import { AntDesignVueResolver } from 'unplugin-vue-components/resolvers';
@@ -41,6 +42,7 @@ export default defineConfig((configEnv) => {
       vueJsx(),
       vueDevTools(),
       UnoCSS(),
+      SVGLoader(),
       AutoImport({
         imports: [
           'vue',

Some files were not shown because too many files changed in this diff